(function () { "use strict"; const root = document.getElementById("options-root"); if (!root) return; if (root.getAttribute("data-options-booted") === "1") return; root.setAttribute("data-options-booted", "1"); const panelCache = (window.__optionsPanelCache = window.__optionsPanelCache || {}); const state = { underlying: root.dataset.defaultUnderly || "ETH", optType: "C", moneyFilter: "all", chainView: "list", strikeExpandAll: false, chain: panelCache.chain || null, selectedInst: null, orderQuote: null, expandedPosInst: null, posTab: "live", /** 未点设定前的目标输入草稿,避免持仓轮询重绘清空 */ targetDraftByInst: {}, }; let lastGoodPositions = null; let lastGoodPositionsAt = 0; let positionsRefreshSeq = 0; let chainLoadSeq = 0; let selectSeq = 0; let refreshAllTimer = null; let pendingRefreshTimer = null; let pendingTtlSeconds = 600; const POSITIONS_STALE_MS = 45000; const PENDING_POLL_MS = 8000; const orderPanelHome = (function () { const host = document.getElementById("opt-order-panel-host"); return host ? host.parentElement : null; })(); function fmt(v, d) { if (v === null || v === undefined || Number.isNaN(Number(v))) return "—"; return Number(v).toFixed(d == null ? 2 : d); } function fmtDisplay(v, fallback) { if (v !== null && v !== undefined && String(v).trim() !== "") return String(v); if (fallback !== undefined) return fmtDisplay(fallback); return "—"; } function fmtOptionPx(v, tickSz) { if (v === null || v === undefined || Number.isNaN(Number(v))) return "—"; const n = Number(v); const tick = Number(tickSz); if (!tickSz || Number.isNaN(tick) || tick <= 0) { // 无 tick 时裁掉浮点毛刺,勿 482.4881990066513 let s = n.toFixed(4).replace(/\.?0+$/, ""); return s || "0"; } let decimals = 0; if (tick < 1) decimals = Math.max(0, -Math.round(Math.log10(tick))); else if (String(tick).indexOf(".") >= 0) decimals = String(tick).split(".")[1].length; let s = n.toFixed(decimals); // 仅裁小数尾零;整数 tick(BTC=5)时绝不能把 1370 裁成 137 if (decimals > 0) s = s.replace(/\.?0+$/, ""); return s || "0"; } async function apiJson(url, opts) { const r = await fetch(url, Object.assign({ credentials: "same-origin" }, opts || {})); return r.json(); } function orderPanel() { return document.getElementById("opt-order-panel"); } function orderPanelHost() { return document.getElementById("opt-order-panel-host"); } function syncPickButtons(instId) { document.querySelectorAll(".opt-pick-btn").forEach(function (btn) { const on = !!instId && btn.getAttribute("data-inst") === instId; btn.classList.toggle("active", on); btn.disabled = false; if (!btn.dataset.origText) btn.dataset.origText = "选择"; btn.textContent = on ? "已选" : btn.dataset.origText; }); } function parkOrderPanel() { stopPendingOrdersPoll(); const panel = orderPanel(); const host = orderPanelHost(); // 把整块 host(含面板)移回原位,再删行内 tr,避免 tbody 重绘销毁下单 DOM if (host && orderPanelHome && host.parentElement !== orderPanelHome) { orderPanelHome.appendChild(host); } else if (panel && host && panel.parentElement !== host) { host.appendChild(panel); } if (host) host.hidden = true; if (panel) panel.style.display = "none"; const inline = document.querySelector(".opt-order-inline-row"); if (inline) inline.remove(); document.querySelectorAll(".opt-strike-row").forEach(function (r) { r.classList.remove("opt-row-selected"); }); syncPickButtons(null); } function placeOrderPanelAfter(instId) { const panel = orderPanel(); const host = orderPanelHost(); if (!panel || !host || !instId) { syncPickButtons(instId || null); return false; } const row = document.querySelector('#opt-strike-tbody tr.opt-strike-row[data-inst="' + CSS.escape(instId) + '"]') || document.querySelector('#opt-strike-tbody tr.opt-strike-row[data-call-inst="' + CSS.escape(instId) + '"]') || document.querySelector('#opt-strike-tbody tr.opt-strike-row[data-put-inst="' + CSS.escape(instId) + '"]'); if (!row) { syncPickButtons(null); return false; } document.querySelectorAll(".opt-strike-row").forEach(function (r) { r.classList.toggle("opt-row-selected", r === row); }); syncPickButtons(instId); const oldInline = document.querySelector(".opt-order-inline-row"); if (oldInline) oldInline.remove(); if (panel.parentElement !== host) host.appendChild(panel); const tr = document.createElement("tr"); tr.className = "opt-order-inline-row"; const td = document.createElement("td"); td.colSpan = strikeTableColspan(); td.appendChild(host); tr.appendChild(td); row.after(tr); host.hidden = false; panel.style.display = ""; tr.scrollIntoView({ behavior: "smooth", block: "nearest" }); refreshPendingOrders(); startPendingOrdersPoll(); return true; } function fmtPendingAge(sec) { if (sec == null || Number.isNaN(Number(sec))) return "—"; let s = Math.max(0, Math.round(Number(sec))); if (s < 60) return s + "秒"; const m = Math.floor(s / 60); const rs = s % 60; if (m < 60) return rs ? m + "分" + rs + "秒" : m + "分"; const h = Math.floor(m / 60); const rm = m % 60; return rm ? h + "时" + rm + "分" : h + "时"; } function paintPendingOrders(orders, ttlSec) { const host = document.getElementById("opt-pending-list"); const hint = document.getElementById("opt-pending-ttl-hint"); if (ttlSec != null && !Number.isNaN(Number(ttlSec))) { pendingTtlSeconds = Number(ttlSec); } if (hint) { const ttl = pendingTtlSeconds; hint.textContent = ttl > 0 ? ("平仓限价超 " + fmtPendingAge(ttl) + " 未成交将自动撤销") : "平仓超时自动撤单已关闭"; } if (!host) return; const rows = Array.isArray(orders) ? orders : []; if (!rows.length) { host.innerHTML = '
暂无未成交委托
'; return; } host.innerHTML = rows.map(function (o) { const side = String(o.side || "").toLowerCase(); const sideCls = side === "buy" ? "is-buy" : side === "sell" ? "is-sell" : ""; const remain = (o.sz != null && o.fill_sz != null) ? Math.max(0, Number(o.sz) - Number(o.fill_sz)) : o.sz; const pxTxt = o.px != null ? fmtOptionPx(o.px, null) : "—"; const kind = o.is_close_order ? "平仓" : "开仓"; let ttlTxt = ""; if (o.auto_cancel_enabled) { if (o.stale) ttlTxt = " · 超时待撤"; else if (o.expire_in_sec != null) ttlTxt = " · 剩 " + fmtPendingAge(o.expire_in_sec) + " 自动撤"; } const ageTxt = o.age_sec != null ? ("已挂 " + fmtPendingAge(o.age_sec)) : ""; return ( '
' + '
' + '' + kind + " · " + (o.side_label || side || "—") + "" + '' + "
" + '
' + (o.inst_id || "—") + "
" + '
价 ' + pxTxt + " · 张数 " + (o.sz != null ? o.sz : "—") + (o.fill_sz != null && Number(o.fill_sz) > 0 ? " · 已成 " + o.fill_sz : "") + (remain != null && o.fill_sz != null && Number(o.fill_sz) > 0 ? " · 剩余 " + remain : "") + (ageTxt ? " · " + ageTxt : "") + ttlTxt + "
" ); }).join(""); host.querySelectorAll(".opt-pending-cancel").forEach(function (btn) { btn.addEventListener("click", function () { cancelPendingOrder(btn.getAttribute("data-inst"), btn.getAttribute("data-ord"), btn); }); }); } async function refreshPendingOrders() { const host = document.getElementById("opt-pending-list"); if (!host) return; try { const d = await apiJson("/api/options/orders/pending"); if (!d.ok) { host.innerHTML = '
' + (d.msg || "获取委托失败") + "
"; return; } paintPendingOrders(d.orders || [], d.pending_ttl_seconds); } catch (e) { host.innerHTML = '
获取委托失败
'; } } function startPendingOrdersPoll() { stopPendingOrdersPoll(); pendingRefreshTimer = setInterval(function () { if (!document.getElementById("options-root")) { stopPendingOrdersPoll(); return; } refreshPendingOrders(); }, PENDING_POLL_MS); } function stopPendingOrdersPoll() { if (pendingRefreshTimer) { clearInterval(pendingRefreshTimer); pendingRefreshTimer = null; } } async function cancelPendingOrder(inst, ordId, btn) { if (!inst || !ordId) return; if (!confirm("撤销该委托?\n合约: " + inst + "\n订单: " + ordId)) return; if (btn) btn.disabled = true; try { const d = await apiJson("/api/options/orders/cancel", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ inst_id: inst, ord_id: ordId }), }); if (!d.ok) { alert(d.msg || "撤销失败"); return; } await refreshPendingOrders(); refreshAllPositions(); if (typeof refreshAccountSnapshot === "function") refreshAccountSnapshot(); } finally { if (btn) btn.disabled = false; } } 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 strikeTableColspan() { return state.chainView === "t" ? 9 : 8; } function syncChainViewUI() { const isT = state.chainView === "t"; document.querySelectorAll(".opt-view-btn").forEach(function (b) { b.classList.toggle("active", (b.getAttribute("data-view") || "") === state.chainView); }); const typeGroup = document.getElementById("opt-type-btn-group"); if (typeGroup) typeGroup.hidden = isT; const expandWrap = document.getElementById("opt-strike-expand-wrap"); if (expandWrap) expandWrap.hidden = !isT; const headList = document.getElementById("opt-strike-head-list"); const headT = document.getElementById("opt-strike-head-t"); const headTCols = document.getElementById("opt-strike-head-t-cols"); if (headList) { headList.classList.toggle("hidden", isT); headList.hidden = isT; } if (headT) { headT.classList.toggle("hidden", !isT); headT.hidden = !isT; } if (headTCols) { headTCols.classList.toggle("hidden", !isT); headTCols.hidden = !isT; } const wrap = document.getElementById("opt-strike-table-wrap"); if (wrap) wrap.classList.toggle("options-strike-table-wrap--t", isT); const table = document.getElementById("opt-strike-table"); if (table) table.classList.toggle("options-strike-table--t", isT); } function matchesMoneyFilter(moneyness) { const m = (moneyness || "").toLowerCase(); if (state.moneyFilter === "all") return true; if (state.moneyFilter === "otm") return m === "otm"; return m === "itm" || m === "atm"; } function moneyFilterLabel() { if (state.moneyFilter === "otm") return "虚值"; if (state.moneyFilter === "itm") return "实值"; return ""; } function countContractsForType(contracts) { if (state.chainView === "t") { return countStraddleStrikes(contracts); } return (contracts || []).filter(function (c) { return c.opt_type === state.optType; }).length; } function countStraddleStrikes(contracts) { const strikes = new Set(); (contracts || []).forEach(function (c) { if (c.strike != null) strikes.add(String(c.strike)); }); return strikes.size; } function buildStraddleRows(contracts) { const map = {}; (contracts || []).forEach(function (c) { const key = String(c.strike); if (!map[key]) map[key] = { strike: c.strike, call: null, put: null }; const o = (c.opt_type || "").toUpperCase(); if (o === "C") map[key].call = c; else if (o === "P") map[key].put = c; }); return Object.keys(map) .map(function (k) { return map[k]; }) .sort(function (a, b) { return Number(a.strike) - Number(b.strike); }); } function findAtmStrike(rows, indexPx) { if (!rows.length || indexPx == null || Number.isNaN(Number(indexPx))) return null; let best = rows[0].strike; let bestDist = Math.abs(Number(rows[0].strike) - Number(indexPx)); rows.forEach(function (row) { const d = Math.abs(Number(row.strike) - Number(indexPx)); if (d < bestDist || (d === bestDist && Number(row.strike) < Number(best))) { bestDist = d; best = row.strike; } }); return best; } function matchesStrikeRowFilter(strike, indexPx, atmStrike) { if (state.moneyFilter === "all") return true; if (atmStrike != null && Number(strike) === Number(atmStrike)) return true; if (indexPx == null || Number.isNaN(Number(indexPx))) return true; if (state.moneyFilter === "itm") return Number(strike) <= Number(indexPx); if (state.moneyFilter === "otm") return Number(strike) >= Number(indexPx); return true; } function filterStraddleRows(rows, indexPx) { const atmStrike = findAtmStrike(rows, indexPx); return rows.filter(function (row) { return matchesStrikeRowFilter(row.strike, indexPx, atmStrike); }); } function sliceAtmWindow(rows, indexPx) { if (state.strikeExpandAll || !rows.length) return rows; const atmStrike = findAtmStrike(rows, indexPx); const idx = rows.findIndex(function (r) { return Number(r.strike) === Number(atmStrike); }); if (idx < 0) return rows.slice(0, Math.min(rows.length, 11)); const start = Math.max(0, idx - 5); const end = Math.min(rows.length, idx + 6); return rows.slice(start, end); } function straddleAskPerUnit(callAsk, putAsk) { const c = Number(callAsk); const p = Number(putAsk); if (!Number.isFinite(c) || !Number.isFinite(p) || c <= 0 || p <= 0) return null; return Math.round((c + p) * 10000) / 10000; } function formatStraddleBand(strike, combinedAsk) { const per = combinedAsk; if (strike == null || per == null) return "—"; const k = Number(strike); const d = Number(per); if (!Number.isFinite(k) || !Number.isFinite(d)) return "—"; const lo = Math.round((k - d) * 10) / 10; const hi = Math.round((k + d) * 10) / 10; return lo.toFixed(0) + " ~ " + hi.toFixed(0); } function formatStraddlePremiumCell(callAsk, putAsk) { const per = straddleAskPerUnit(callAsk, putAsk); if (per == null) return '不可双买'; return fmtUsdc(per) + " USDC"; } function pickBtnHtml(instId) { if (!instId) return "—"; return ''; } function syncMoneyFilterButtons() { document.querySelectorAll(".opt-money-btn").forEach(function (b) { b.classList.toggle("active", (b.getAttribute("data-money") || "") === state.moneyFilter); }); } function resetMoneyFilterToAll() { state.moneyFilter = "all"; syncMoneyFilterButtons(); } function updateUnderlyingLabel() { const el = document.getElementById("opt-order-eth-label"); if (el) el.textContent = state.underlying + " 数量"; } function filterChainContracts(contracts) { return (contracts || []).filter(function (c) { return c.opt_type === state.optType && matchesMoneyFilter(c.moneyness); }); } function moneynessBadge(c) { const m = (c && c.moneyness) || ""; const label = (c && c.moneyness_label) || "—"; return '' + label + ""; } function optTypeLabel(t) { return (t || "").toUpperCase() === "P" ? "看跌 Put" : "看涨 Call"; } function expLabel(ms) { try { 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); } } function renderIndexLine() { 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)); } const line = document.getElementById("opt-index-line"); if (line) { line.textContent = "指数 " + state.underlying + " ≈ " + fmt(idx, 2) + " · 默认显示全部 · 实值含平值 · 虚值=价外"; } } function renderExpiryOptions(preserveSelection) { const sel = document.getElementById("opt-exp-select"); if (!sel) return; const prev = preserveSelection !== false ? sel.value : ""; const exps = (state.chain && state.chain.expiries) || []; sel.innerHTML = ''; exps.forEach(function (e) { const o = document.createElement("option"); o.value = String(e.exp_time); o.textContent = expLabel(e.exp_time) + " (" + countContractsForType(e.contracts) + ")"; sel.appendChild(o); }); if (prev && exps.some(function (e) { return String(e.exp_time) === String(prev); })) { sel.value = prev; } } function setExpirySelectStatus(text) { const sel = document.getElementById("opt-exp-select"); if (!sel) return; sel.innerHTML = ""; const o = document.createElement("option"); o.value = ""; o.textContent = text || "选择到期日"; sel.appendChild(o); } function chainHasExpiries(chain) { return !!(chain && Array.isArray(chain.expiries) && chain.expiries.length > 0); } function renderExpiries() { renderExpiryOptions(true); renderIndexLine(); } function fmtPxSz(px, sz, estimated) { if (px === null || px === undefined || Number.isNaN(Number(px))) return "—"; let price = Number(px).toFixed(4).replace(/\.?0+$/, ""); if (estimated) price += "~"; if (sz === null || sz === undefined || sz === "" || Number.isNaN(Number(sz))) return price; const s = Number(sz); const size = Math.abs(s - Math.round(s)) < 1e-9 ? String(Math.round(s)) : String(s); return price + "/" + size; } /** 买盘深度:价格/流动性;仅展示平仓所需档位(买一不够才出买二…). */ function fmtCloseLevels(preview, tickSz) { if (preview && preview.bid_invalid) { return "暂无有效买盘"; } const levels = ((preview && preview.levels) || []).slice(0, 5); if (!levels.length) return "—"; return levels.map(function (x, idx) { const levelNo = x.level != null ? x.level : idx + 1; const liq = x.available_sheets != null ? x.available_sheets : x.sz; const pxTxt = fmtOptionPx(x.px, tickSz); if (liq === null || liq === undefined || liq === "" || Number.isNaN(Number(liq))) { return "买" + levelNo + " " + pxTxt; } const s = Number(liq); const size = Math.abs(s - Math.round(s)) < 1e-9 ? String(Math.round(s)) : String(s); return "买" + levelNo + " " + pxTxt + "/" + size; }).join(" · "); } function closeGateHint(preview) { if (!preview) return ""; if (preview.bid_invalid || preview.manual_close_blocked) { return preview.bid_invalid_reason || "当前买一无效,禁止买一平仓"; } const gate = preview.close_gate || {}; // 2× 只是目标平仓门控,本身不会自动平;手动买一平不拦截 if (preview.close_gate_blocked || (gate.ready === false && !gate.passed)) { return "目标门控: " + (preview.close_gate_msg || gate.msg || "可回收需≥2×权利金并持续2分钟"); } return ""; } function netPnlFromPos(p) { const preview = (p && p.close_preview) || {}; if (preview.estimated_pnl != null && !Number.isNaN(Number(preview.estimated_pnl))) { return Number(preview.estimated_pnl); } const recv = Number(preview.total_received); const prem = Number(p && p.premium_paid); if (preview.total_received != null && !Number.isNaN(recv) && !Number.isNaN(prem)) { return recv - prem; } return null; } function netRoiFromPos(p, net) { const preview = (p && p.close_preview) || {}; if (preview.estimated_pnl_ratio_pct != null && !Number.isNaN(Number(preview.estimated_pnl_ratio_pct))) { return Number(preview.estimated_pnl_ratio_pct); } const prem = Number(p && p.premium_paid); if (net == null || Number.isNaN(prem) || prem <= 0) return null; return (net / prem) * 100; } function fmtUsdc(v) { if (v === null || v === undefined || Number.isNaN(Number(v))) return "—"; return Number(v).toFixed(2); } function fmtClosePreview(preview, premiumPaid) { if (!preview || preview.total_received == null) return "—"; const recvTxt = fmtUsdc(preview.total_received); let cls = ""; const prem = Number(premiumPaid); const recv = Number(preview.total_received); if (!Number.isNaN(prem) && !Number.isNaN(recv)) { if (recv > prem) cls = " pos-pnl-profit"; else if (recv < prem) cls = " pos-pnl-loss"; } return '' + recvTxt + " USDC"; } function fmtClosePreviewText(preview) { if (!preview || preview.total_received == null) return "—"; let text = fmt(preview.total_received, 4) + " USDC"; if (preview.covered_sheets != null) { text += " · 覆盖 " + preview.covered_sheets + "张"; } if (preview.uncovered_sheets > 0) { text += " · 缺 " + preview.uncovered_sheets + "张"; } return text; } function fmtPreviewLevels(preview) { const levels = (preview && preview.levels) || []; if (!levels.length) return "暂无可用买盘深度"; return levels.map(function (x) { return "买" + x.level + " " + fmt(x.px, 4) + " × " + x.sheets + "张 ≈ " + fmt(x.received, 4) + " USDC"; }).join("\n"); } function pnlCls(v) { if (v === null || v === undefined || Number.isNaN(Number(v))) return ""; const n = Number(v); if (n > 0) return "pos-pnl-profit"; if (n < 0) return "pos-pnl-loss"; return ""; } function expiryIntrinsicPerUnit(optType, strike, targetIdx) { const tgt = Number(targetIdx); const k = Number(strike); if (!Number.isFinite(tgt) || !Number.isFinite(k)) return null; const o = (optType || "").toUpperCase(); if (o === "C") return Math.max(0, tgt - k); if (o === "P") return Math.max(0, k - tgt); return null; } function estimateExpiryValue(optType, strike, targetIdx, ethAmount) { const amt = Number(ethAmount); const intrinsic = expiryIntrinsicPerUnit(optType, strike, targetIdx); if (intrinsic == null || !Number.isFinite(amt) || amt <= 0) return null; return Math.round(intrinsic * amt * 100) / 100; } function estimateExpiryProfit(optType, strike, targetIdx, ethAmount, totalPremium) { const value = estimateExpiryValue(optType, strike, targetIdx, ethAmount); const prem = Number(totalPremium); if (value == null || !Number.isFinite(prem)) return null; return Math.round((value - prem) * 100) / 100; } function calcContractLeverage(indexPx, ethAmount, totalPremium) { if (indexPx == null || ethAmount == null || totalPremium == null) return null; const idx = Number(indexPx); const amt = Number(ethAmount); const prem = Number(totalPremium); if (!Number.isFinite(idx) || !Number.isFinite(amt) || !Number.isFinite(prem) || amt <= 0 || prem <= 0) { return null; } return Math.round((idx * amt) / prem * 10) / 10; } function fmtLeverage(v) { if (v === null || v === undefined || Number.isNaN(Number(v))) return "—"; return "约 " + Number(v).toFixed(1) + "×"; } function fmtUsdcSigned(v) { if (v === null || v === undefined || Number.isNaN(Number(v))) return "—"; const n = Number(v); const sign = n > 0 ? "+" : ""; return sign + fmtUsdc(n) + " USDC"; } function updateOrderEstimates() { const levEl = document.getElementById("opt-order-leverage"); const valueEl = document.getElementById("opt-est-value"); const profitEl = document.getElementById("opt-est-profit"); const targetLevEl = document.getElementById("opt-est-leverage"); const targetEl = document.getElementById("opt-target-idx"); const q = state.orderQuote; if (!q || !q.ok || !q.can_open) { if (levEl) levEl.textContent = "—"; if (valueEl) valueEl.textContent = "—"; if (profitEl) { profitEl.textContent = "—"; profitEl.className = "v"; } if (targetLevEl) targetLevEl.textContent = "—"; return; } const sz = q.sizing || {}; const ethAmount = sz.eth_amount; const premium = sz.total_premium; const lev = calcContractLeverage(q.index_px, ethAmount, premium); if (levEl) levEl.textContent = fmtLeverage(lev); if (valueEl && profitEl && targetEl) { const targetRaw = targetEl.value; if (targetRaw === "" || targetRaw == null) { valueEl.textContent = "—"; profitEl.textContent = "—"; profitEl.className = "v"; if (targetLevEl) targetLevEl.textContent = "—"; } else { const value = estimateExpiryValue(q.opt_type, q.strike, Number(targetRaw), ethAmount); const profit = estimateExpiryProfit(q.opt_type, q.strike, Number(targetRaw), ethAmount, premium); if (value == null || Number.isNaN(value)) { valueEl.textContent = "—"; } else { valueEl.textContent = fmtUsdc(value) + " USDC"; } if (profit == null || Number.isNaN(profit)) { profitEl.textContent = "—"; profitEl.className = "v"; } else { profitEl.textContent = fmtUsdcSigned(profit); profitEl.className = "v " + pnlCls(profit); } const targetLev = calcContractLeverage(Number(targetRaw), ethAmount, premium); if (targetLevEl) targetLevEl.textContent = fmtLeverage(targetLev); } } } function updateEstimatedProfit() { updateOrderEstimates(); } function fmtDist(v) { if (v === null || v === undefined || Number.isNaN(Number(v))) return "—"; const n = Number(v); const sign = n > 0 ? "+" : ""; return sign + n.toFixed(1); } function distBeClass(v) { if (v === null || v === undefined || Number.isNaN(Number(v))) return ""; const n = Number(v); if (n > 0) return "opt-be-dist-up"; if (n < 0) return "opt-be-dist-down"; return ""; } function bindStrikePickButtons(tbody) { tbody.querySelectorAll(".opt-pick-btn").forEach(function (btn) { btn.addEventListener("click", function () { selectContract(btn.getAttribute("data-inst"), btn); }); }); } function finishStrikeRender(tbody, prevSelected, matchedSelected) { bindStrikePickButtons(tbody); if (matchedSelected && prevSelected) { selectContract(prevSelected, null, true); } else if (!matchedSelected) { state.selectedInst = null; } } function renderStrikes() { syncChainViewUI(); if (state.chainView === "t") renderStrikesT(); else renderStrikesList(); } function renderStrikesList() { const tbody = document.getElementById("opt-strike-tbody"); const expMs = document.getElementById("opt-exp-select").value; const prevSelected = state.selectedInst; const cols = strikeTableColspan(); parkOrderPanel(); tbody.innerHTML = ""; if (!expMs || !state.chain) { tbody.innerHTML = '请选择到期日'; state.selectedInst = null; return; } const exp = (state.chain.expiries || []).find(function (e) { return String(e.exp_time) === String(expMs); }); if (!exp) { state.selectedInst = null; return; } const list = filterChainContracts(exp.contracts); if (!list.length) { const label = moneyFilterLabel(); const suffix = label ? label : optTypeLabel(state.optType); tbody.innerHTML = '该到期日暂无' + suffix + "合约"; state.selectedInst = null; return; } let matchedSelected = false; list.forEach(function (c) { const tr = document.createElement("tr"); tr.className = "opt-strike-row"; tr.setAttribute("data-inst", c.inst_id); if (c.moneyness) tr.classList.add("opt-row-" + c.moneyness); tr.innerHTML = "" + c.strike + "" + "" + moneynessBadge(c) + "" + "" + c.inst_id + "" + "" + fmtPxSz(c.ask, c.ask_sz, c.ask_estimated) + "" + "" + fmtPxSz(c.bid, c.bid_sz) + "" + "" + (c.expiry_be_px != null ? fmt(c.expiry_be_px, 0) : "—") + "" + '' + fmtDist(c.dist_expiry_be) + "" + '' + pickBtnHtml(c.inst_id) + ""; tbody.appendChild(tr); if (c.inst_id === prevSelected) matchedSelected = true; }); finishStrikeRender(tbody, prevSelected, matchedSelected); } function renderStrikesT() { const tbody = document.getElementById("opt-strike-tbody"); const expMs = document.getElementById("opt-exp-select").value; const prevSelected = state.selectedInst; const cols = strikeTableColspan(); const indexPx = state.chain && state.chain.index_px; parkOrderPanel(); tbody.innerHTML = ""; if (!expMs || !state.chain) { tbody.innerHTML = '请选择到期日'; state.selectedInst = null; return; } const exp = (state.chain.expiries || []).find(function (e) { return String(e.exp_time) === String(expMs); }); if (!exp) { state.selectedInst = null; return; } let rows = filterStraddleRows(buildStraddleRows(exp.contracts), indexPx); rows = sliceAtmWindow(rows, indexPx); if (!rows.length) { const label = moneyFilterLabel(); const suffix = label ? label + "区" : "匹配"; tbody.innerHTML = '该到期日暂无' + suffix + "行权价"; state.selectedInst = null; return; } const atmStrike = findAtmStrike(rows, indexPx); let matchedSelected = false; rows.forEach(function (row) { const call = row.call; const put = row.put; const combined = straddleAskPerUnit(call && call.ask, put && put.ask); const tr = document.createElement("tr"); tr.className = "opt-strike-row opt-strike-row-t"; tr.setAttribute("data-strike", String(row.strike)); if (Number(row.strike) === Number(atmStrike)) tr.classList.add("opt-strike-row-atm"); if (call && call.inst_id) tr.setAttribute("data-call-inst", call.inst_id); if (put && put.inst_id) tr.setAttribute("data-put-inst", put.inst_id); tr.innerHTML = '' + (call ? fmtPxSz(call.ask, call.ask_sz, call.ask_estimated) : "—") + "" + '' + (call ? moneynessBadge(call) : "—") + "" + '' + pickBtnHtml(call && call.inst_id) + "" + '' + row.strike + "" + '' + formatStraddlePremiumCell(call && call.ask, put && put.ask) + "" + '' + formatStraddleBand(row.strike, combined) + "" + '' + (put ? moneynessBadge(put) : "—") + "" + '' + (put ? fmtPxSz(put.ask, put.ask_sz, put.ask_estimated) : "—") + "" + '' + pickBtnHtml(put && put.inst_id) + ""; tbody.appendChild(tr); if (prevSelected && ((call && call.inst_id === prevSelected) || (put && put.inst_id === prevSelected))) { matchedSelected = true; } }); if (!state.strikeExpandAll && rows.length >= 1) { const hint = document.createElement("tr"); hint.className = "opt-strike-hint-row"; hint.innerHTML = '默认显示 ATM ±5 档 · 勾选「展开全部」查看该到期全部行权价'; tbody.appendChild(hint); } finishStrikeRender(tbody, prevSelected, matchedSelected); } function fillOrderPanel(d) { state.orderQuote = d && d.ok ? d : null; const sz = d.sizing || {}; const canOpen = !!(d && d.ok && d.can_open); document.getElementById("opt-order-inst").textContent = d.inst_id || state.selectedInst || ""; const askEl = document.getElementById("opt-order-ask"); if (askEl) { askEl.textContent = canOpen ? fmtPxSz(d.ask, d.ask_sz) : "—"; } const bidEl = document.getElementById("opt-order-bid"); if (bidEl) bidEl.textContent = fmtPxSz(d.bid, d.bid_sz); const refEl = document.getElementById("opt-order-ref-ask"); if (refEl) { if (canOpen) { refEl.textContent = "—"; } else if (d.ref_ask != null && !Number.isNaN(Number(d.ref_ask))) { refEl.textContent = fmtPxSz(d.ref_ask, null, true) + " (不可开仓)"; } else if (d.mark != null && !Number.isNaN(Number(d.mark))) { refEl.textContent = fmtPxSz(d.mark, null, true) + " (不可开仓)"; } else { refEl.textContent = "—"; } } document.getElementById("opt-order-sheets").textContent = canOpen && sz.sheets != null ? sz.sheets : "—"; document.getElementById("opt-order-eth").textContent = canOpen && sz.eth_amount != null ? sz.eth_amount : "—"; updateUnderlyingLabel(); document.getElementById("opt-order-premium").textContent = canOpen && sz.total_premium != null ? fmtUsdc(sz.total_premium) + " USDC" : "—"; const beEl = document.getElementById("opt-order-expiry-be"); const distEl = document.getElementById("opt-order-dist-be"); if (beEl) { beEl.textContent = d.expiry_be_px != null ? fmt(d.expiry_be_px, 0) : "—"; } if (distEl) { distEl.textContent = fmtDist(d.dist_expiry_be); distEl.className = "v " + distBeClass(d.dist_expiry_be); } const openBtn = document.getElementById("opt-open-btn"); if (openBtn) { openBtn.disabled = !canOpen || sz.ok === false; openBtn.textContent = canOpen ? "限价买入 @ 卖一" : "暂无卖一深度,无法开仓"; } const msgEl = document.getElementById("opt-order-msg"); if (!d.ok) { msgEl.textContent = d.msg || "报价失败"; msgEl.classList.add("opt-error"); } else if (!canOpen) { const ref = d.ref_ask != null ? d.ref_ask : d.mark; let tip = d.msg || d.open_block_msg || "当前无卖一深度,无法按卖一限价买入"; if (ref != null && !Number.isNaN(Number(ref))) { tip += "。参考标记价 ~" + Number(ref).toFixed(4).replace(/\.?0+$/, "") + "(仅供参考,不可用于开仓)"; } else { tip += "。无可用参考标记价"; } msgEl.textContent = tip; msgEl.classList.add("opt-error"); } else if (sz.ok === false) { msgEl.textContent = sz.msg || ""; msgEl.classList.add("opt-error"); } else if (sz.ask_depth_capped) { msgEl.textContent = sz.msg || "已按卖一深度限制张数"; msgEl.classList.remove("opt-error"); } else { msgEl.textContent = ""; msgEl.classList.remove("opt-error"); } updateEstimatedProfit(); } async function selectContract(instId, pickBtn, silent) { const seq = ++selectSeq; state.selectedInst = instId; placeOrderPanelAfter(instId); if (pickBtn) { pickBtn.disabled = true; if (!pickBtn.dataset.origText) pickBtn.dataset.origText = "选择"; pickBtn.textContent = "加载…"; } try { const d = await apiJson(quoteUrl(instId)); if (seq !== selectSeq) return d; fillOrderPanel(d); return d; } finally { if (seq === selectSeq) { syncPickButtons(instId); if (!silent) placeOrderPanelAfter(instId); } } } async function loadChain(opts) { const soft = !!(opts && opts.soft); const uly = state.underlying; const seq = ++chainLoadSeq; const btn = document.getElementById("opt-load-chain"); if (btn && !soft) btn.disabled = true; if (!soft) { setExpirySelectStatus("加载到期日中…"); const tbody = document.getElementById("opt-strike-tbody"); if (tbody) { tbody.innerHTML = '加载期权链…'; } } try { let d = null; let lastMsg = ""; for (let attempt = 0; attempt < 2; attempt++) { if (seq !== chainLoadSeq) return; d = await apiJson("/api/options/chain?underlying=" + encodeURIComponent(uly)); if (seq !== chainLoadSeq) return; if (d && d.ok && chainHasExpiries(d)) break; lastMsg = (d && (d.msg || d.chain_error)) || "暂无到期日"; d = null; if (attempt === 0) { if (!soft) setExpirySelectStatus("重试加载到期日…"); await new Promise(function (resolve) { setTimeout(resolve, 400); }); } } if (seq !== chainLoadSeq) return; if (!d || !d.ok || !chainHasExpiries(d)) { if (chainHasExpiries(state.chain) && state.chain.underlying === uly) { if (!soft) { renderExpiries(); renderStrikes(); } return; } if (soft) return; setExpirySelectStatus("选择到期日"); const tbody = document.getElementById("opt-strike-tbody"); if (tbody) { tbody.innerHTML = '' + (lastMsg || "暂无到期日,请点「刷新链」") + ""; } alert(lastMsg || "加载到期日失败,请点「刷新链」重试"); return; } const keepExp = soft ? (document.getElementById("opt-exp-select") || {}).value : ""; state.chain = d; panelCache.chain = d; panelCache.underlying = uly; panelCache.optType = state.optType; if (!soft) { state.selectedInst = null; resetMoneyFilterToAll(); state.strikeExpandAll = false; const expandCb = document.getElementById("opt-strike-expand-all"); if (expandCb) expandCb.checked = false; parkOrderPanel(); } updateUnderlyingLabel(); renderExpiries(); if (soft && keepExp) { const sel = document.getElementById("opt-exp-select"); if (sel && Array.from(sel.options).some(function (o) { return o.value === keepExp; })) { sel.value = keepExp; } } // soft 时保留 selectedInst;renderStrikes 会先 park 再按 prevSelected 静默重挂下单面板 renderStrikes(); } catch (e) { if (seq !== chainLoadSeq || soft) return; setExpirySelectStatus("选择到期日"); const tbody = document.getElementById("opt-strike-tbody"); if (tbody) { tbody.innerHTML = '加载失败: ' + String((e && e.message) || e) + ""; } } finally { if (seq === chainLoadSeq && btn) btn.disabled = false; } } async function openPosition() { if (!state.selectedInst) { alert("请先选择合约"); return; } const q = state.orderQuote; if (!q || !q.ok || !q.can_open) { alert((q && (q.msg || q.open_block_msg)) || "暂无卖一深度,无法按卖一开仓"); return; } if (q.sizing && q.sizing.ok === false) { alert(q.sizing.msg || "张数无效"); return; } const btn = document.getElementById("opt-open-btn"); btn.disabled = true; try { const mode = currentSizeMode(); 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); } else if (mode === "sheets") { body.sheets = parseInt(document.getElementById("opt-sheets-amount").value, 10); } const tgtRaw = (document.getElementById("opt-target-idx").value || "").trim(); if (tgtRaw !== "") { const tgt = parseFloat(tgtRaw); if (!Number.isFinite(tgt) || tgt <= 0) { alert("目标位无效"); return; } body.target_index = tgt; } 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 ? "下单已提交,右侧可查看/撤销未成交委托" : (d.msg || "失败"); msgEl.classList.toggle("opt-error", !d.ok); if (d.ok) { refreshPendingOrders(); startPendingOrdersPoll(); refreshAllPositions(); if (typeof refreshAccountSnapshot === "function") refreshAccountSnapshot(); } else { alert(d.msg || "下单失败"); } } finally { const latest = state.orderQuote; btn.disabled = !(latest && latest.ok && latest.can_open && !(latest.sizing && latest.sizing.ok === false)); btn.textContent = (latest && latest.can_open) ? "限价买入 @ 卖一" : "暂无卖一深度,无法开仓"; } } function renderPositionCardInner(p) { const net = netPnlFromPos(p); const roi = netRoiFromPos(p, net); const uplCls = pnlCls(net); const sideCls = (p.opt_type || "").toUpperCase() === "P" ? "pos-side-short" : "pos-side-long"; const expMs = p.exp_time_ms != null ? p.exp_time_ms : p.exp_time; const expAttr = expMs != null && expMs !== "" ? String(expMs) : ""; const closePreview = p.close_preview || {}; const closeSheets = p.avail_pos != null && Number(p.avail_pos) > 0 ? p.avail_pos : p.pos; const tickSz = p.tick_sz; const premTxt = fmtDisplay(p.premium_paid_fmt, p.premium_paid != null ? fmtUsdc(p.premium_paid) : null); // 优先用数值+tick 现算,避免接口侧 mark_px_fmt 带着浮点毛刺直出 const avgTxt = p.avg_px != null ? fmtOptionPx(p.avg_px, tickSz) : fmtDisplay(p.avg_px_fmt); const markTxt = p.mark_px != null ? fmtOptionPx(p.mark_px, tickSz) : fmtDisplay(p.mark_px_fmt); return ( '
' + '
' + (p.inst_id || "") + '' + '' + optTypeLabel(p.opt_type) + "
" + '
' + '' + "
" + '
' + '行权价: ' + fmt(p.strike, 0) + "" + '张数: ' + fmt(p.pos, 0) + " · 币量 " + fmt(p.eth_amount, 4) + "" + (expAttr ? '到期倒计时: ' : "") + "
" + '
' + '
权利金' + premTxt + " USDC
" + '
开仓均价' + avgTxt + "
" + '
标记价' + markTxt + "
" + '
指数价' + fmt(p.idx_px, 0) + "
" + '
到期平衡' + fmt(p.expiry_be_px, 0) + "
" + '
平掉回本' + fmt(p.close_be_px, 0) + "
" + '
净盈亏' + (closePreview.bid_invalid || net == null ? "—" : fmt(net, 2)) + "
" + '
收益率' + (closePreview.bid_invalid || roi == null ? "—" : fmt(roi, 2) + "%") + "
" + '
买盘深度' + fmtCloseLevels(closePreview, tickSz) + "
" + '
按买盘回收' + (closePreview.bid_invalid ? '暂无有效买盘' : fmtClosePreview(closePreview, p.premium_paid)) + "
" + "
" + (function () { const hint = closeGateHint(closePreview); return hint ? '
' + hint + "
" : ""; })() + renderTargetDelegateRow(p) ); } function posEthAmount(p) { if (p.eth_amount != null && Number(p.eth_amount) > 0) return Number(p.eth_amount); const sheets = Number(p.avail_pos != null ? p.avail_pos : p.pos); const ct = Number(p.ct_mult != null ? p.ct_mult : 0.01); if (Number.isFinite(sheets) && sheets > 0 && Number.isFinite(ct) && ct > 0) return sheets * ct; return null; } function formatTargetEstimateHtml(optType, strike, targetIdx, ethAmount, premiumPaid) { const value = estimateExpiryValue(optType, strike, targetIdx, ethAmount); const profit = estimateExpiryProfit(optType, strike, targetIdx, ethAmount, premiumPaid); if (value == null && profit == null) return ""; let html = ''; html += '价值' + (value == null ? "—" : fmtUsdc(value) + " USDC") + ""; html += '预估盈利' + (profit == null ? "—" : fmtUsdcSigned(profit)) + ""; html += ""; return html; } function renderTargetDelegateRow(p) { const inst = p.inst_id || ""; const hedgeTarget = p.hedge_plan_target || null; if (hedgeTarget && Number(hedgeTarget.target_index) > 0) { const side = (p.opt_type || hedgeTarget.opt_type || "").toUpperCase() === "P" ? "Put ≤" : "Call ≥"; return ( '
' + '对冲计划' + '计划 #' + hedgeTarget.plan_id + " · " + side + " " + fmt(hedgeTarget.target_index, 1) + "" + '进行中 · 由对冲计划监控,到位后仅平盈利腿' + "
" ); } const tgt = p.target_index != null && p.target_index !== "" ? Number(p.target_index) : null; const armed = tgt != null && Number.isFinite(tgt) && tgt > 0; const ethAmt = posEthAmount(p); const prem = p.premium_paid; const estHtml = armed ? formatTargetEstimateHtml(p.opt_type, p.strike, tgt, ethAmt, prem) : ''; return ( '
' + '委托' + '' + '' + '" + (armed ? '目标 ' + fmt(tgt, 1) + "" : "") + estHtml + '' + (armed ? "监控中 · 到位按买一限价平" : "输入后设定 · 到位按买一限价平 · 到期即止损") + "" + "
" ); } function updatePosTargetEstimate(row) { if (!row) return; const est = row.querySelector(".opt-target-est"); if (!est) return; const inp = row.querySelector(".opt-pos-target-input"); const typed = inp ? String(inp.value || "").trim() : ""; const armed = row.getAttribute("data-armed-target") || ""; const targetRaw = typed !== "" ? typed : armed; if (targetRaw === "") { est.className = "opt-target-est opt-target-est--idle"; est.innerHTML = ""; return; } const html = formatTargetEstimateHtml( row.getAttribute("data-opt-type"), row.getAttribute("data-strike"), targetRaw, row.getAttribute("data-eth"), row.getAttribute("data-prem") ); if (!html) { est.className = "opt-target-est opt-target-est--idle"; est.innerHTML = ""; return; } const tmp = document.createElement("div"); tmp.innerHTML = html; const node = tmp.firstChild; est.className = "opt-target-est"; est.innerHTML = node ? node.innerHTML : ""; } function renderPositionCard(p) { return ( '
' + renderPositionCardInner(p) + "
" ); } function renderPositionAccordionItem(p, expanded) { const net = netPnlFromPos(p); const roi = netRoiFromPos(p, net); const uplCls = pnlCls(net); const sideCls = (p.opt_type || "").toUpperCase() === "P" ? "pos-side-short" : "pos-side-long"; const expMs = p.exp_time_ms != null ? p.exp_time_ms : p.exp_time; const expAttr = expMs != null && expMs !== "" ? String(expMs) : ""; const inst = p.inst_id || ""; return ( '
' + '" + '
' + '
' + renderPositionCardInner(p) + "
" ); } function applyAccordionState() { const wrap = document.getElementById("opt-pos-cards"); if (!wrap) return; wrap.querySelectorAll(".opt-pos-accordion-item").forEach(function (el) { const open = el.getAttribute("data-inst") === state.expandedPosInst; el.classList.toggle("is-expanded", open); const btn = el.querySelector(".opt-pos-bar"); const body = el.querySelector(".opt-pos-accordion-body"); if (btn) btn.setAttribute("aria-expanded", open ? "true" : "false"); if (body) body.hidden = !open; }); } function bindPositionActions(container) { if (!container) return; container.querySelectorAll(".opt-close-btn").forEach(function (btn) { btn.addEventListener("click", function (e) { e.stopPropagation(); closePosition(btn.getAttribute("data-inst"), btn); }); }); container.querySelectorAll(".opt-target-set-btn").forEach(function (btn) { btn.addEventListener("click", function (e) { e.stopPropagation(); setPositionTarget(btn.getAttribute("data-inst"), btn); }); }); container.querySelectorAll(".opt-target-cancel-btn").forEach(function (btn) { btn.addEventListener("click", function (e) { e.stopPropagation(); cancelPositionTarget(btn.getAttribute("data-inst"), btn); }); }); container.querySelectorAll(".opt-pos-target-input").forEach(function (inp) { inp.addEventListener("click", function (e) { e.stopPropagation(); }); inp.addEventListener("input", function () { const instId = inp.getAttribute("data-inst") || ""; const draft = String(inp.value || ""); if (instId) { if (draft.trim() === "") delete state.targetDraftByInst[instId]; else state.targetDraftByInst[instId] = draft; } updatePosTargetEstimate(inp.closest(".opt-target-row")); }); inp.addEventListener("keydown", function (e) { if (e.key === "Enter") { e.preventDefault(); e.stopPropagation(); setPositionTarget(inp.getAttribute("data-inst"), null); } }); // 重绘后恢复预估展示(草稿或已设定目标) updatePosTargetEstimate(inp.closest(".opt-target-row")); }); container.querySelectorAll(".opt-pos-bar").forEach(function (bar) { bar.addEventListener("click", function () { const item = bar.closest(".opt-pos-accordion-item"); if (!item) return; const inst = item.getAttribute("data-inst"); state.expandedPosInst = state.expandedPosInst === inst ? null : inst; applyAccordionState(); if (window.OptionsExpiryCountdown && OptionsExpiryCountdown.ensureTimer) { OptionsExpiryCountdown.ensureTimer(); } }); }); } async function setPositionTarget(inst, btn) { if (!inst) return; const card = document.querySelector('.opt-pos-card[data-inst="' + inst + '"]') || document.querySelector('.opt-pos-accordion-item[data-inst="' + inst + '"]'); const row = card ? card.querySelector(".opt-target-row") : null; const inp = card ? card.querySelector(".opt-pos-target-input") : null; const raw = inp ? String(inp.value || "").trim() : ""; const tgt = parseFloat(raw); if (!Number.isFinite(tgt) || tgt <= 0) { alert("请输入有效目标指数价"); return; } if (btn) btn.disabled = true; try { const d = await apiJson("/api/options/target", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ inst_id: inst, target_index: tgt }), }); if (!d.ok) { alert(d.msg || "设定失败"); return; } delete state.targetDraftByInst[inst]; if (inp) inp.value = ""; if (row) { row.setAttribute("data-armed-target", String(tgt)); updatePosTargetEstimate(row); } await refreshAllPositions(); } finally { if (btn) btn.disabled = false; } } async function cancelPositionTarget(inst, btn) { if (!inst) return; if (btn) btn.disabled = true; try { const d = await apiJson("/api/options/target/cancel", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ inst_id: inst }), }); if (!d.ok) { alert(d.msg || "取消失败"); return; } delete state.targetDraftByInst[inst]; await refreshAllPositions(); } finally { if (btn) btn.disabled = false; } } function paintTargetMonitors(list) { const box = document.getElementById("opt-target-monitors"); const host = document.getElementById("opt-target-monitors-list"); if (!box || !host) return; const rows = Array.isArray(list) ? list.filter(function (t) { return t && t.inst_id; }) : []; if (!rows.length) { box.hidden = true; host.innerHTML = ""; return; } box.hidden = false; host.innerHTML = rows.map(function (t) { const side = (t.opt_type || "").toUpperCase() === "P" ? "Put ≤" : "Call ≥"; const managed = t.managed_by === "hedge_plan"; return ( '
' + '' + (t.inst_id || "") + "" + '' + side + " " + fmt(t.target_index, 1) + "" + (managed ? '对冲计划 #' + (t.plan_id || "") + " · 进行中" : '') + "
" ); }).join(""); host.querySelectorAll(".opt-target-mon-cancel").forEach(function (btn) { btn.addEventListener("click", function () { cancelPositionTarget(btn.getAttribute("data-inst"), btn); }); }); } async function closePosition(inst, btn) { const sheets = btn && btn.getAttribute("data-sheets") ? parseInt(btn.getAttribute("data-sheets"), 10) : null; let url = "/api/options/quote?inst_id=" + encodeURIComponent(inst) + "&mode=close_preview"; if (sheets && sheets > 0) url += "&sheets=" + encodeURIComponent(sheets); const q = await apiJson(url); if (!q.ok) { alert(q.msg || "获取买一价失败"); return; } const preview = q.close_preview || {}; if (preview.bid_invalid || preview.manual_close_blocked) { alert(preview.bid_invalid_reason || "当前买一为无效残档,禁止买一平仓。"); return; } if (!preview.covered_sheets || preview.covered_sheets <= 0) { alert("暂无有效买一深度,请稍后重试或到 OKX App 挂限价"); return; } const lv = (preview.levels && preview.levels[0]) || {}; const msg = [ "按买一限价卖出本轮可平张数?", "合约: " + inst, "锁定买一: " + (lv.px != null ? lv.px : "—") + " × " + (lv.sheets != null ? lv.sheets : preview.covered_sheets) + " 张", "预计收回: " + fmtClosePreviewText(preview), preview.estimated_pnl != null ? "预估盈亏: " + fmt(preview.estimated_pnl, 4) + " USDC" : "", preview.uncovered_sheets > 0 ? "\n注意: 买一深度不足,预计仍剩 " + preview.uncovered_sheets + " 张,需下次再平。" : "" ].filter(function (x) { return x !== ""; }).join("\n"); if (!confirm(msg)) 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, mode: "bid1", sheets: sheets }), }); if (r.ok) { let okMsg = "买一平仓已提交 " + (r.submitted_sheets || 0) + " 张"; if (r.locked_bid_px != null) okMsg += "\n锁定买一: " + r.locked_bid_px; if (r.premium_received != null) okMsg += "\n预估收回: " + fmt(r.premium_received, 4) + " USDC"; if (r.remaining_sheets > 0) okMsg += "\n剩余: " + r.remaining_sheets + " 张(下次再平)"; if (r.stopped_reason) okMsg += "\n状态: " + r.stopped_reason; alert(okMsg); } else { alert(r.msg || "平仓失败"); } refreshAllPositions(); if (typeof refreshAccountSnapshot === "function") refreshAccountSnapshot(); } finally { if (btn) btn.disabled = false; } } function setOptionsPosTab(tabId) { const tab = tabId || "live"; state.posTab = tab; document.querySelectorAll(".opt-pos-tab").forEach(function (btn) { const on = btn.getAttribute("data-opt-pos-tab") === tab; btn.classList.toggle("active", on); btn.setAttribute("aria-selected", on ? "true" : "false"); }); document.querySelectorAll("[data-opt-pos-pane]").forEach(function (pane) { const on = pane.getAttribute("data-opt-pos-pane") === tab; pane.classList.toggle("is-active", on); pane.hidden = !on; }); if (tab === "live" && window.OptionsExpiryCountdown && OptionsExpiryCountdown.ensureTimer) { OptionsExpiryCountdown.ensureTimer(); } } function bindOptionsPosTabs() { document.querySelectorAll(".opt-pos-tab").forEach(function (btn) { btn.addEventListener("click", function () { setOptionsPosTab(btn.getAttribute("data-opt-pos-tab")); }); }); setOptionsPosTab(state.posTab); } function resolvePositionsList(d) { const now = Date.now(); const list = (d && d.ok && d.positions) ? d.positions : []; if (d && d.ok) { if (list.length) { lastGoodPositions = list; lastGoodPositionsAt = now; return list; } lastGoodPositions = null; lastGoodPositionsAt = 0; return list; } if (lastGoodPositions && lastGoodPositions.length && now - lastGoodPositionsAt < POSITIONS_STALE_MS) { return lastGoodPositions; } return []; } function paintPositions(list) { const wrap = document.getElementById("opt-pos-cards"); const empty = document.getElementById("opt-pos-empty"); const livePane = document.getElementById("opt-pos-live"); if (!wrap) return; const active = document.activeElement; // 正在输入目标指数:先落到草稿,本轮不重绘整卡,避免数字往回退 if (active && active.classList && active.classList.contains("opt-pos-target-input")) { const focusInst = active.getAttribute("data-inst") || ""; if (focusInst) { state.targetDraftByInst[focusInst] = String(active.value || ""); } return; } // 未聚焦时也同步可见输入,防止漏掉 input 事件 wrap.querySelectorAll(".opt-pos-target-input").forEach(function (inp) { const id = inp.getAttribute("data-inst") || ""; if (!id) return; const v = String(inp.value || ""); if (v.trim() === "") delete state.targetDraftByInst[id]; else state.targetDraftByInst[id] = v; }); wrap.innerHTML = ""; if (!list.length) { if (empty) empty.style.display = ""; state.expandedPosInst = null; if (livePane) livePane.classList.remove("options-pos-live-pane--accordion"); return; } if (empty) empty.style.display = "none"; const multi = list.length >= 2; wrap.classList.toggle("opt-pos-cards--accordion", multi); if (livePane) livePane.classList.toggle("options-pos-live-pane--accordion", multi); if (multi) { const ids = list.map(function (p) { return p.inst_id; }); if (state.expandedPosInst && ids.indexOf(state.expandedPosInst) < 0) { state.expandedPosInst = null; } list.forEach(function (p) { const div = document.createElement("div"); div.innerHTML = renderPositionAccordionItem(p, p.inst_id === state.expandedPosInst); wrap.appendChild(div.firstChild); }); } else { state.expandedPosInst = null; list.forEach(function (p) { const div = document.createElement("div"); div.innerHTML = renderPositionCard(p); wrap.appendChild(div.firstChild); }); } bindPositionActions(wrap); if (window.OptionsExpiryCountdown && OptionsExpiryCountdown.ensureTimer) { OptionsExpiryCountdown.ensureTimer(); } } async function refreshPositions() { const seq = ++positionsRefreshSeq; const d = await apiJson("/api/options/positions"); if (seq !== positionsRefreshSeq) return; const list = resolvePositionsList(d); paintPositions(list); const fromPos = list.reduce(function (targets, p) { if (!p) return targets; if (p.target_index != null) { targets.push({ id: p.target_monitor_id, inst_id: p.inst_id, opt_type: p.opt_type, target_index: p.target_index, }); } const hedgeTarget = p.hedge_plan_target; if (hedgeTarget && hedgeTarget.target_index != null) { targets.push({ inst_id: p.inst_id, opt_type: p.opt_type || hedgeTarget.opt_type, target_index: hedgeTarget.target_index, plan_id: hedgeTarget.plan_id, managed_by: hedgeTarget.managed_by, }); } return targets; }, []); if (fromPos.length) { paintTargetMonitors(fromPos); } else { const t = await apiJson("/api/options/targets"); if (seq !== positionsRefreshSeq) return; paintTargetMonitors((t && t.ok && t.targets) ? t.targets : []); } } function paintPnlStat(el, value) { if (!el) return; if (value == null || value === "" || Number.isNaN(Number(value))) { el.textContent = "—"; el.classList.remove("pos-pnl-profit", "pos-pnl-loss"); return; } const n = Number(value); el.textContent = (n > 0 ? "+" : "") + fmt(n, 2) + " USDC"; el.classList.toggle("pos-pnl-profit", n > 0); el.classList.toggle("pos-pnl-loss", n < 0); } async function refreshStats() { const d = await apiJson("/api/options/stats"); const winEl = document.getElementById("opt-stats-winrate"); const plrEl = document.getElementById("opt-stats-plr"); const closedEl = document.getElementById("opt-stats-closed"); const profitEl = document.getElementById("opt-stats-profit"); const lossEl = document.getElementById("opt-stats-loss"); const avgHoldEl = document.getElementById("opt-stats-avg-hold"); const winHoldEl = document.getElementById("opt-stats-win-hold"); const lossHoldEl = document.getElementById("opt-stats-loss-hold"); const openHoldEl = document.getElementById("opt-stats-open-hold"); const totalPnlEl = document.getElementById("opt-stats-total-pnl"); const netRealizedEl = document.getElementById("opt-stats-net-realized"); const openFloatEl = document.getElementById("opt-stats-open-float"); const statEls = [winEl, plrEl, closedEl, profitEl, lossEl, avgHoldEl, winHoldEl, lossHoldEl, openHoldEl]; if (!d.ok) { statEls.forEach(function (el) { if (el) el.textContent = "—"; }); paintPnlStat(totalPnlEl, null); paintPnlStat(netRealizedEl, null); paintPnlStat(openFloatEl, null); paintStatsCharts(null); return; } paintPnlStat(totalPnlEl, d.total_pnl); paintPnlStat(netRealizedEl, d.net_realized_pnl); paintPnlStat(openFloatEl, d.open_float_pnl); if (winEl) winEl.textContent = d.total_closed ? d.win_rate + "%" : "0%"; if (plrEl) { plrEl.textContent = d.profit_loss_ratio != null ? String(d.profit_loss_ratio) : "—"; } if (closedEl) closedEl.textContent = String(d.total_closed || 0); if (profitEl) { profitEl.textContent = d.avg_win != null && d.avg_win > 0 ? fmt(d.avg_win, 2) + " USDC" : (d.win_count ? "0 USDC" : "—"); } if (lossEl) { lossEl.textContent = d.avg_loss != null && d.avg_loss > 0 ? fmt(d.avg_loss, 2) + " USDC" : (d.loss_count ? "0 USDC" : "—"); } if (avgHoldEl) avgHoldEl.textContent = fmtDuration(d.avg_hold_sec); if (winHoldEl) winHoldEl.textContent = fmtDuration(d.avg_win_hold_sec); if (lossHoldEl) lossHoldEl.textContent = fmtDuration(d.avg_loss_hold_sec); if (openHoldEl) { const cnt = Number(d.open_count) || 0; if (!cnt) { openHoldEl.textContent = "0 笔"; } else { openHoldEl.textContent = cnt + " 笔 · " + fmtDuration(d.avg_open_hold_sec); } } paintStatsCharts(d); } function fmtDuration(sec) { if (sec == null || sec === "" || Number.isNaN(Number(sec))) return "—"; let s = Math.max(0, Math.round(Number(sec))); if (s < 60) return s + "秒"; const m = Math.floor(s / 60); if (m < 60) { const rs = s % 60; return rs ? m + "分" + rs + "秒" : m + "分"; } const h = Math.floor(m / 60); const rm = m % 60; if (h < 24) return rm ? h + "时" + rm + "分" : h + "时"; const d = Math.floor(h / 24); const rh = h % 24; return rh ? d + "天" + rh + "时" : d + "天"; } function setBarFill(el, pct) { if (!el) return; const n = Math.max(0, Math.min(100, Number(pct) || 0)); el.style.width = n + "%"; } function paintStatsCharts(d) { const ring = document.getElementById("opt-stats-ring"); const ringLabel = document.getElementById("opt-stats-ring-label"); const profitBar = document.getElementById("opt-stats-bar-profit"); const lossBar = document.getElementById("opt-stats-bar-loss"); const profitBarLabel = document.getElementById("opt-stats-bar-profit-label"); const lossBarLabel = document.getElementById("opt-stats-bar-loss-label"); const winHoldBar = document.getElementById("opt-stats-bar-win-hold"); const lossHoldBar = document.getElementById("opt-stats-bar-loss-hold"); const winHoldBarLabel = document.getElementById("opt-stats-win-hold-label"); const lossHoldBarLabel = document.getElementById("opt-stats-loss-hold-label"); if (!d || !d.ok) { if (ring) ring.style.setProperty("--win-pct", "0"); if (ringLabel) ringLabel.textContent = "—"; [profitBar, lossBar, winHoldBar, lossHoldBar].forEach(function (el) { setBarFill(el, 0); }); [profitBarLabel, lossBarLabel, winHoldBarLabel, lossHoldBarLabel].forEach(function (el) { if (el) el.textContent = "—"; }); return; } const winRate = d.total_closed ? Number(d.win_rate) || 0 : 0; if (ring) ring.style.setProperty("--win-pct", String(winRate)); if (ringLabel) ringLabel.textContent = d.total_closed ? winRate.toFixed(0) + "%" : "0%"; const profit = Math.max(0, Number(d.avg_win) || 0); const loss = Math.max(0, Number(d.avg_loss) || 0); const pnlTotal = profit + loss; if (pnlTotal > 0) { setBarFill(profitBar, (profit / pnlTotal) * 100); setBarFill(lossBar, (loss / pnlTotal) * 100); if (profitBarLabel) profitBarLabel.textContent = fmt(profit, 2) + " USDC"; if (lossBarLabel) lossBarLabel.textContent = fmt(loss, 2) + " USDC"; } else { setBarFill(profitBar, 0); setBarFill(lossBar, 0); if (profitBarLabel) profitBarLabel.textContent = d.win_count ? "0 USDC" : "—"; if (lossBarLabel) lossBarLabel.textContent = d.loss_count ? "0 USDC" : "—"; } const winHold = Number(d.avg_win_hold_sec) || 0; const lossHold = Number(d.avg_loss_hold_sec) || 0; const holdMax = Math.max(winHold, lossHold); if (holdMax > 0) { setBarFill(winHoldBar, (winHold / holdMax) * 100); setBarFill(lossHoldBar, (lossHold / holdMax) * 100); if (winHoldBarLabel) winHoldBarLabel.textContent = fmtDuration(d.avg_win_hold_sec); if (lossHoldBarLabel) lossHoldBarLabel.textContent = fmtDuration(d.avg_loss_hold_sec); } else { setBarFill(winHoldBar, 0); setBarFill(lossHoldBar, 0); if (winHoldBarLabel) winHoldBarLabel.textContent = "—"; if (lossHoldBarLabel) lossHoldBarLabel.textContent = "—"; } } async function deleteHistoryRow(key, status) { const warn = status === "open" ? "该记录仍为持仓中,仅从列表隐藏,不影响交易所持仓.确认删除?" : "确认从列表隐藏该条历史记录?"; if (!confirm(warn)) return; const r = await apiJson("/api/options/history/" + encodeURIComponent(key), { method: "DELETE" }); if (!r.ok) { alert(r.msg || "删除失败"); return; } refreshAllPositions(); } function optHistoryStatus(h) { if (h.status_label) return h.status_label; if (h.status === "open") return "持仓中"; if (h.status !== "closed") return "持仓中"; return "已平"; } function optHistoryStatusHtml(h) { const s = optHistoryStatus(h); let cls = "opt-hist-status"; if (s === "已平") cls += " opt-hist-status--closed"; else if (s === "到期" || s === "强平") cls += " opt-hist-status--expired"; else cls += " opt-hist-status--open"; return '' + s + ""; } 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 premTxt = fmtDisplay(h.premium_paid_fmt, h.premium_paid != null ? fmtUsdc(h.premium_paid) : null); const isOpen = h.status === "open"; const pnl = isOpen ? null : h.realized_pnl; const pnlTxt = pnl != null ? fmt(pnl, 2) : "—"; const pnlCls = pnl > 0 ? "pos-pnl-profit" : pnl < 0 ? "pos-pnl-loss" : ""; const timeTxt = (h.closed_at || h.created_at || "—").replace("T", " ").slice(0, 19); const histKey = h.history_key || ""; tr.innerHTML = '' + (h.inst_id || "") + "" + "" + fmt(h.sheets, 0) + "" + "" + premTxt + "" + "" + optHistoryStatusHtml(h) + "" + '' + pnlTxt + "" + '' + timeTxt + "" + ''; tbody.appendChild(tr); }); tbody.querySelectorAll(".opt-history-del").forEach(function (btn) { btn.addEventListener("click", function () { deleteHistoryRow(btn.getAttribute("data-key"), btn.getAttribute("data-status")); }); }); } function refreshAllPositions() { if (refreshAllTimer) clearTimeout(refreshAllTimer); refreshAllTimer = setTimeout(function () { refreshAllTimer = null; refreshPositions(); refreshStats(); refreshHistory(); }, 120); } function onExpiryChange() { resetMoneyFilterToAll(); state.strikeExpandAll = false; const expandCb = document.getElementById("opt-strike-expand-all"); if (expandCb) expandCb.checked = false; renderStrikes(); } function bootOptionsPanel() { updateSizeInputs(); syncMoneyFilterButtons(); syncChainViewUI(); updateUnderlyingLabel(); refreshPendingOrders(); startPendingOrdersPoll(); const hasCache = chainHasExpiries(panelCache.chain) && panelCache.underlying === state.underlying && panelCache.optType === state.optType; if (hasCache) { state.chain = panelCache.chain; renderExpiries(); renderStrikes(); refreshAllPositions(); // 后台静默刷新,避免缓存过期后到期日变空 loadChain({ soft: true }); return; } requestAnimationFrame(function () { loadChain(); refreshAllPositions(); }); } document.querySelectorAll(".opt-uly-btn").forEach(function (btn) { btn.addEventListener("click", function () { document.querySelectorAll(".opt-uly-btn").forEach(function (b) { b.classList.remove("active"); }); btn.classList.add("active"); state.underlying = btn.getAttribute("data-uly"); loadChain(); }); }); document.querySelectorAll(".opt-view-btn").forEach(function (btn) { btn.addEventListener("click", function () { const view = btn.getAttribute("data-view") || "list"; if (view === state.chainView) return; state.chainView = view; if (view === "t") { state.strikeExpandAll = false; const expandCb = document.getElementById("opt-strike-expand-all"); if (expandCb) expandCb.checked = false; } syncChainViewUI(); renderStrikes(); }); }); const expandAllCb = document.getElementById("opt-strike-expand-all"); if (expandAllCb) { expandAllCb.addEventListener("change", function () { state.strikeExpandAll = !!expandAllCb.checked; renderStrikes(); }); } document.querySelectorAll(".opt-type-btn").forEach(function (btn) { btn.addEventListener("click", function () { document.querySelectorAll(".opt-type-btn").forEach(function (b) { b.classList.remove("active"); }); btn.classList.add("active"); state.optType = btn.getAttribute("data-type"); resetMoneyFilterToAll(); renderExpiryOptions(true); renderStrikes(); }); }); document.querySelectorAll(".opt-money-btn").forEach(function (btn) { btn.addEventListener("click", function () { state.moneyFilter = btn.getAttribute("data-money") || "all"; syncMoneyFilterButtons(); renderStrikes(); }); }); document.getElementById("opt-exp-select").addEventListener("change", onExpiryChange); document.getElementById("opt-load-chain").addEventListener("click", loadChain); document.getElementById("opt-refresh-positions").addEventListener("click", refreshAllPositions); document.getElementById("opt-open-btn").addEventListener("click", openPosition); const pendingRefreshBtn = document.getElementById("opt-pending-refresh"); if (pendingRefreshBtn) { pendingRefreshBtn.addEventListener("click", function () { refreshPendingOrders(); }); } bindOptionsPosTabs(); document.querySelectorAll('input[name="opt-size-mode"]').forEach(function (r) { r.addEventListener("change", function () { updateSizeInputs(); if (state.selectedInst) selectContract(state.selectedInst, null, true); }); }); ["opt-sheets-amount", "opt-eth-amount", "opt-target-idx"].forEach(function (id) { const el = document.getElementById(id); if (!el) return; el.addEventListener("change", function () { if (id === "opt-target-idx") { updateEstimatedProfit(); return; } if (state.selectedInst) selectContract(state.selectedInst, null, true); }); if (id === "opt-target-idx") { el.addEventListener("input", updateEstimatedProfit); } }); bootOptionsPanel(); window.OptionsPanelLive = { refreshSoft: function () { refreshAllPositions(); }, refreshChain: loadChain, }; })();