Files
crypto_monitor/lib/common/static/options_panel.js
T
2026-07-11 08:57:19 +08:00

1052 lines
41 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(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: "itm",
chain: panelCache.chain || null,
selectedInst: null,
orderQuote: null,
expandedPosInst: null,
posTab: "live",
};
let lastGoodPositions = null;
let lastGoodPositionsAt = 0;
let positionsRefreshSeq = 0;
let refreshAllTimer = null;
const POSITIONS_STALE_MS = 45000;
function fmt(v, d) {
if (v === null || v === undefined || Number.isNaN(Number(v))) return "—";
return Number(v).toFixed(d == null ? 2 : d);
}
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 parkOrderPanel() {
const panel = orderPanel();
const host = orderPanelHost();
const inline = document.querySelector(".opt-order-inline-row");
if (inline) inline.remove();
if (panel && host && panel.parentElement !== host) {
host.appendChild(panel);
host.hidden = true;
}
if (panel) panel.style.display = "none";
document.querySelectorAll(".opt-strike-row").forEach(function (r) {
r.classList.remove("opt-row-selected");
});
document.querySelectorAll(".opt-pick-btn").forEach(function (b) {
b.classList.remove("active");
b.disabled = false;
if (b.dataset.origText) b.textContent = b.dataset.origText;
});
}
function placeOrderPanelAfter(instId) {
const panel = orderPanel();
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 (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");
td.colSpan = 8;
td.appendChild(panel);
tr.appendChild(td);
row.after(tr);
panel.style.display = "";
orderPanelHost().hidden = true;
tr.scrollIntoView({ behavior: "smooth", block: "nearest" });
}
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 += "&eth_amount=" + encodeURIComponent(eth);
} else if (mode === "sheets") {
const sheets = document.getElementById("opt-sheets-amount").value;
if (sheets) url += "&sheets=" + encodeURIComponent(sheets);
}
return url;
}
function matchesMoneyFilter(moneyness) {
const m = (moneyness || "").toLowerCase();
if (state.moneyFilter === "otm") return m === "otm";
return m === "itm" || m === "atm";
}
function moneyFilterLabel() {
return state.moneyFilter === "otm" ? "虚值" : "实值";
}
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 '<span class="opt-moneyness opt-moneyness-' + m + '">' + label + "</span>";
}
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 renderExpiries() {
const sel = document.getElementById("opt-exp-select");
sel.innerHTML = '<option value="">选择到期日</option>';
const exps = (state.chain && state.chain.expiries) || [];
exps.forEach(function (e) {
const o = document.createElement("option");
o.value = String(e.exp_time);
o.textContent = expLabel(e.exp_time) + " (" + filterChainContracts(e.contracts).length + ")";
sel.appendChild(o);
});
const idx = state.chain && state.chain.index_px;
const dte = state.chain && state.chain.chain_max_dte_days;
if (dte != null) {
const el = document.getElementById("opt-chain-dte");
if (el) el.textContent = String(Math.round(dte));
}
document.getElementById("opt-index-line").textContent =
"指数 " + state.underlying + " ≈ " + fmt(idx, 2) + " · 实值含平值 · 虚值=价外";
}
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 fmtBidDepth(levels) {
const bids = (levels || []).slice(0, 5);
if (!bids.length) return "—";
return '<div class="opt-bid-depth">' + bids.map(function (x, idx) {
const levelCls = idx === 0 ? " opt-bid-level--best" : "";
return (
'<span class="opt-bid-level' + levelCls + '">' +
'<span class="opt-bid-rank">买' + (idx + 1) + "</span>" +
'<span class="opt-bid-price">' + fmt(x.px, 4) + "</span>" +
'<span class="opt-bid-size">' + fmt(x.sz, 0) + "张</span>" +
"</span>"
);
}).join("") + "</div>";
}
function fmtClosePreview(preview) {
if (!preview || preview.total_received == null) return "—";
const missing = Number(preview.uncovered_sheets || 0);
const covered = Number(preview.covered_sheets || 0);
const pnl = preview.estimated_pnl;
const pnlCls = pnlClsName(pnl);
return (
'<div class="opt-close-preview">' +
'<div class="opt-close-main">' + fmt(preview.total_received, 4) + '<span>USDC</span></div>' +
'<div class="opt-close-sub">' +
'<span class="opt-close-pill opt-close-pill--ok">覆盖 ' + covered + "张</span>" +
(missing > 0 ? '<span class="opt-close-pill opt-close-pill--warn">缺 ' + missing + "张</span>" : "") +
(pnl != null ? '<span class="opt-close-pnl ' + pnlCls + '">' + (Number(pnl) > 0 ? "+" : "") + fmt(pnl, 4) + "</span>" : "") +
"</div></div>"
);
}
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 pnlClsName(v) {
if (v === null || v === undefined || Number.isNaN(Number(v))) return "";
const n = Number(v);
if (n > 0) return "is-profit";
if (n < 0) return "is-loss";
return "";
}
function estimateExpiryProfit(optType, strike, targetIdx, entryPx, ethAmount) {
if (strike == null || targetIdx == null || entryPx == null || ethAmount == null) return null;
const amt = Number(ethAmount);
const entry = Number(entryPx);
const tgt = Number(targetIdx);
const k = Number(strike);
if (!Number.isFinite(amt) || !Number.isFinite(entry) || !Number.isFinite(tgt) || !Number.isFinite(k) || amt <= 0) {
return null;
}
const o = (optType || "").toUpperCase();
let intrinsic = 0;
if (o === "C") intrinsic = Math.max(0, tgt - k);
else if (o === "P") intrinsic = Math.max(0, k - tgt);
else return null;
return Math.round((intrinsic - entry) * amt * 10000) / 10000;
}
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 updateOrderEstimates() {
const levEl = document.getElementById("opt-order-leverage");
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) {
if (levEl) levEl.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 (profitEl && targetEl) {
const targetRaw = targetEl.value;
if (targetRaw === "" || targetRaw == null) {
profitEl.textContent = "—";
profitEl.className = "v";
if (targetLevEl) targetLevEl.textContent = "—";
} else {
const profit = estimateExpiryProfit(q.opt_type, q.strike, Number(targetRaw), q.ask, ethAmount);
if (profit == null || Number.isNaN(profit)) {
profitEl.textContent = "—";
profitEl.className = "v";
} else {
const sign = profit > 0 ? "+" : "";
profitEl.textContent = sign + profit.toFixed(4) + " USDC";
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 renderStrikes() {
const tbody = document.getElementById("opt-strike-tbody");
const expMs = document.getElementById("opt-exp-select").value;
const prevSelected = state.selectedInst;
parkOrderPanel();
tbody.innerHTML = "";
if (!expMs || !state.chain) {
tbody.innerHTML = '<tr><td colspan="8" class="muted">请选择到期日</td></tr>';
state.selectedInst = null;
return;
}
const exp = (state.chain.expiries || []).find(function (e) {
return String(e.exp_time) === String(expMs);
});
if (!exp) return;
const list = filterChainContracts(exp.contracts);
if (!list.length) {
tbody.innerHTML = '<tr><td colspan="8" class="muted">该到期日暂无' + moneyFilterLabel() + "合约</td></tr>";
state.selectedInst = null;
return;
}
let stillVisible = 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 =
"<td>" + c.strike + "</td>" +
"<td>" + moneynessBadge(c) + "</td>" +
"<td><code>" + c.inst_id + "</code></td>" +
"<td class=\"opt-px-sz\">" + fmtPxSz(c.ask, c.ask_sz, c.ask_estimated) + "</td>" +
"<td class=\"opt-px-sz\">" + fmtPxSz(c.bid, c.bid_sz) + "</td>" +
"<td>" + (c.expiry_be_px != null ? fmt(c.expiry_be_px, 0) : "—") + "</td>" +
'<td class="' + distBeClass(c.dist_expiry_be) + '">' + fmtDist(c.dist_expiry_be) + "</td>" +
'<td class="opt-row-actions">' +
'<button type="button" class="btn-secondary opt-pick-btn" data-inst="' + c.inst_id + '">选择</button>' +
"</td>";
tbody.appendChild(tr);
if (c.inst_id === prevSelected) stillVisible = true;
});
tbody.querySelectorAll(".opt-pick-btn").forEach(function (btn) {
btn.addEventListener("click", function () {
selectContract(btn.getAttribute("data-inst"), btn);
});
});
if (stillVisible && prevSelected) {
selectContract(prevSelected, null, true);
} else {
state.selectedInst = null;
}
}
function fillOrderPanel(d) {
state.orderQuote = d && d.ok ? d : null;
const sz = d.sizing || {};
document.getElementById("opt-order-inst").textContent = d.inst_id || state.selectedInst || "";
document.getElementById("opt-order-ask").textContent = fmtPxSz(d.ask, d.ask_sz);
const bidEl = document.getElementById("opt-order-bid");
if (bidEl) bidEl.textContent = fmtPxSz(d.bid, d.bid_sz);
document.getElementById("opt-order-sheets").textContent = sz.sheets != null ? sz.sheets : "—";
document.getElementById("opt-order-eth").textContent = sz.eth_amount != null ? sz.eth_amount : "—";
document.getElementById("opt-order-premium").textContent = sz.total_premium != null ? fmt(sz.total_premium, 4) + " USDC" : "—";
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 msgEl = document.getElementById("opt-order-msg");
if (!d.ok) {
msgEl.textContent = d.msg || "报价失败";
msgEl.classList.add("opt-error");
} else if (sz.ok === false) {
msgEl.textContent = sz.msg || "";
msgEl.classList.add("opt-error");
} else {
msgEl.textContent = "";
msgEl.classList.remove("opt-error");
}
updateEstimatedProfit();
}
async function selectContract(instId, pickBtn, silent) {
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));
fillOrderPanel(d);
return d;
} finally {
if (pickBtn) {
pickBtn.disabled = false;
pickBtn.textContent = "已选";
pickBtn.classList.add("active");
}
if (!silent) placeOrderPanelAfter(instId);
}
}
async function loadChain() {
const d = await apiJson("/api/options/chain?underlying=" + encodeURIComponent(state.underlying));
if (!d.ok) {
alert(d.msg || "加载失败");
return;
}
state.chain = d;
panelCache.chain = d;
panelCache.underlying = state.underlying;
panelCache.optType = state.optType;
state.selectedInst = null;
parkOrderPanel();
renderExpiries();
renderStrikes();
}
async function openPosition() {
if (!state.selectedInst) {
alert("请先选择合约");
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 d = await apiJson("/api/options/open", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const msgEl = document.getElementById("opt-order-msg");
msgEl.textContent = d.ok ? "下单已提交,可在 OKX 委托中查看" : (d.msg || "失败");
msgEl.classList.toggle("opt-error", !d.ok);
if (d.ok) {
refreshAllPositions();
if (typeof refreshAccountSnapshot === "function") refreshAccountSnapshot();
} else {
alert(d.msg || "下单失败");
}
} finally {
btn.disabled = false;
}
}
function renderPositionCardInner(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";
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 closePreviewCls = pnlCls(closePreview.estimated_pnl);
const closeSheets = p.avail_pos != null && Number(p.avail_pos) > 0 ? p.avail_pos : p.pos;
return (
'<div class="pos-card-head">' +
'<div class="pos-card-symbol"><strong>' + (p.inst_id || "") + '</strong>' +
'<span class="pos-side-badge ' + sideCls + '">' + optTypeLabel(p.opt_type) + "</span></div>" +
'<div class="pos-head-actions">' +
'<button type="button" class="btn-primary opt-close-btn" data-inst="' + p.inst_id + '" data-sheets="' + closeSheets + '">多档平仓</button>' +
"</div></div>" +
'<div class="pos-meta">' +
'<span class="pos-meta-item">行权价: ' + fmt(p.strike, 0) + "</span>" +
'<span class="pos-meta-item">张数: ' + fmt(p.pos, 0) + " · 币量 " + fmt(p.eth_amount, 4) + "</span>" +
(expAttr
? '<span class="pos-meta-item">到期倒计时: <span class="opt-expiry-cd" data-opt-exp-ms="' + expAttr + '">—</span></span>'
: "") +
"</div>" +
'<div class="pos-grid">' +
'<div class="pos-cell"><span class="pos-label">权利金</span><span class="pos-value">' + fmt(p.premium_paid, 4) + " USDC</span></div>" +
'<div class="pos-cell"><span class="pos-label">开仓均价</span><span class="pos-value">' + fmt(p.avg_px, 4) + "</span></div>" +
'<div class="pos-cell"><span class="pos-label">标记价</span><span class="pos-value">' + fmt(p.mark_px, 4) + "</span></div>" +
'<div class="pos-cell"><span class="pos-label">指数价</span><span class="pos-value">' + fmt(p.idx_px, 0) + "</span></div>" +
'<div class="pos-cell opt-pos-cell--depth"><span class="pos-label">买盘深度</span><span class="pos-value">' + fmtBidDepth(p.bid_depth) + "</span></div>" +
'<div class="pos-cell opt-pos-cell--close"><span class="pos-label">按买盘收回</span><span class="pos-value ' + closePreviewCls + '">' + fmtClosePreview(closePreview) + "</span></div>" +
'<div class="pos-cell"><span class="pos-label">到期平衡</span><span class="pos-value">' + fmt(p.expiry_be_px, 0) + "</span></div>" +
'<div class="pos-cell"><span class="pos-label">平掉回本</span><span class="pos-value">' + fmt(p.close_be_px, 0) + "</span></div>" +
'<div class="pos-cell"><span class="pos-label">浮盈亏</span><span class="pos-value ' + uplCls + '">' + fmt(p.upl, 2) + "</span></div>" +
'<div class="pos-cell"><span class="pos-label">收益率</span><span class="pos-value ' + uplCls + '">' +
(p.upl_ratio_pct != null ? fmt(p.upl_ratio_pct, 2) + "%" : "—") + "</span></div>" +
"</div>"
);
}
function renderPositionCard(p) {
return (
'<div class="pos-card opt-pos-card" data-inst="' + (p.inst_id || "") + '">' +
renderPositionCardInner(p) +
"</div>"
);
}
function renderPositionAccordionItem(p, expanded) {
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";
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 (
'<div class="opt-pos-accordion-item' + (expanded ? " is-expanded" : "") + '" data-inst="' + inst + '">' +
'<button type="button" class="opt-pos-bar" aria-expanded="' + (expanded ? "true" : "false") + '">' +
'<span class="opt-pos-bar-main">' +
'<span class="opt-pos-bar-chevron" aria-hidden="true">▶</span>' +
'<span class="opt-pos-bar-id-group">' +
'<strong class="opt-pos-bar-title" title="' + inst + '">' + inst + "</strong>" +
'<span class="pos-side-badge ' + sideCls + '">' + optTypeLabel(p.opt_type) + "</span>" +
"</span>" +
'<span class="opt-pos-bar-meta">行权 ' + fmt(p.strike, 0) + " · " + fmt(p.pos, 0) + "张</span>" +
"</span>" +
'<span class="opt-pos-bar-side">' +
(expAttr
? '<span class="opt-pos-bar-cd">到期 <span class="opt-expiry-cd" data-opt-exp-ms="' + expAttr + '">—</span></span>'
: "") +
'<span class="opt-pos-bar-pnl ' + uplCls + '">' + fmt(p.upl, 2) + " USDC</span>" +
'<span class="opt-pos-bar-roi ' + uplCls + '">' +
(p.upl_ratio_pct != null ? fmt(p.upl_ratio_pct, 2) + "%" : "—") + "</span>" +
"</span>" +
"</button>" +
'<div class="opt-pos-accordion-body"' + (expanded ? "" : ' hidden') + '>' +
'<div class="pos-card opt-pos-card opt-pos-card--inline" data-inst="' + inst + '">' +
renderPositionCardInner(p) +
"</div></div></div>"
);
}
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-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 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.covered_sheets || preview.covered_sheets <= 0) {
alert("暂无可用买盘深度,请稍后在 OKX App 平仓或等盘口恢复");
return;
}
const msg = [
"按最多5档买盘拆分限价卖出?",
"合约: " + inst,
"预计收回: " + fmtClosePreviewText(preview),
preview.estimated_pnl != null ? "预估盈亏: " + fmt(preview.estimated_pnl, 4) + " USDC" : "",
"",
fmtPreviewLevels(preview),
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: "depth_split", sheets: sheets }),
});
if (r.ok) {
let okMsg = "平仓单已提交 " + (r.submitted_sheets || 0) + " 张";
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;
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;
paintPositions(resolvePositionsList(d));
}
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 statEls = [winEl, plrEl, closedEl, profitEl, lossEl, avgHoldEl, winHoldEl, lossHoldEl, openHoldEl];
if (!d.ok) {
statEls.forEach(function (el) {
if (el) el.textContent = "—";
});
paintStatsCharts(null);
return;
}
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.total_profit != null && d.total_profit > 0
? fmt(d.total_profit, 2) + " USDC" : (d.total_closed ? "0 USDC" : "—");
}
if (lossEl) {
lossEl.textContent = d.total_loss != null && d.total_loss > 0
? fmt(d.total_loss, 2) + " USDC" : (d.total_closed ? "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.total_profit) || 0);
const loss = Math.max(0, Number(d.total_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.total_closed ? "0 USDC" : "—";
if (lossBarLabel) lossBarLabel.textContent = d.total_closed ? "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(id, status) {
const warn = status === "open"
? "该记录仍为持仓中,仅删除本地记录,不影响交易所持仓.确认删除?"
: "确认删除该条期权历史记录?";
if (!confirm(warn)) return;
const r = await apiJson("/api/options/history/" + encodeURIComponent(id), { method: "DELETE" });
if (!r.ok) {
alert(r.msg || "删除失败");
return;
}
refreshAllPositions();
}
function optHistoryStatus(h) {
if (h.status !== "closed") return "持仓中";
if ((h.signal_note || "").indexOf("到期结算") >= 0) return "到期";
if (h.premium_received === 0 && h.realized_pnl != null && h.realized_pnl < 0 && !h.close_ord_id) {
return "到期";
}
return "已平";
}
function optHistoryStatusHtml(h) {
const s = optHistoryStatus(h);
let cls = "opt-hist-status";
if (s === "已平") cls += " opt-hist-status--closed";
else if (s === "到期") cls += " opt-hist-status--expired";
else cls += " opt-hist-status--open";
return '<span class="' + cls + '">' + s + "</span>";
}
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 = '<tr><td colspan="7" class="muted">暂无历史记录</td></tr>';
return;
}
list.forEach(function (h) {
const tr = document.createElement("tr");
const premTxt = h.premium_paid != null ? fmt(h.premium_paid, 2) : "—";
const pnl = 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);
tr.innerHTML =
'<td class="opt-hist-inst"><code>' + (h.inst_id || "") + "</code></td>" +
"<td>" + fmt(h.sheets, 0) + "</td>" +
"<td>" + premTxt + "</td>" +
"<td>" + optHistoryStatusHtml(h) + "</td>" +
'<td class="' + pnlCls + '">' + pnlTxt + "</td>" +
"<td class=\"opt-hist-time\">" + timeTxt + "</td>" +
'<td><button type="button" class="btn-secondary btn-sm opt-history-del" data-id="' + h.id + '" data-status="' + (h.status || "") + '">删除</button></td>';
tbody.appendChild(tr);
});
tbody.querySelectorAll(".opt-history-del").forEach(function (btn) {
btn.addEventListener("click", function () {
deleteHistoryRow(btn.getAttribute("data-id"), btn.getAttribute("data-status"));
});
});
}
function refreshAllPositions() {
if (refreshAllTimer) clearTimeout(refreshAllTimer);
refreshAllTimer = setTimeout(function () {
refreshAllTimer = null;
refreshPositions();
refreshStats();
refreshHistory();
}, 120);
}
function bootOptionsPanel() {
updateSizeInputs();
const hasCache =
panelCache.chain &&
panelCache.underlying === state.underlying &&
panelCache.optType === state.optType;
if (hasCache) {
state.chain = panelCache.chain;
renderExpiries();
renderStrikes();
refreshAllPositions();
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-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");
renderExpiries();
renderStrikes();
});
});
document.querySelectorAll(".opt-money-btn").forEach(function (btn) {
btn.addEventListener("click", function () {
document.querySelectorAll(".opt-money-btn").forEach(function (b) { b.classList.remove("active"); });
btn.classList.add("active");
state.moneyFilter = btn.getAttribute("data-money") || "itm";
renderExpiries();
renderStrikes();
});
});
document.getElementById("opt-exp-select").addEventListener("change", renderStrikes);
document.getElementById("opt-load-chain").addEventListener("click", loadChain);
document.getElementById("opt-refresh-positions").addEventListener("click", refreshAllPositions);
document.getElementById("opt-open-btn").addEventListener("click", openPosition);
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,
};
})();