(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 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 ≥";
return (
'' +
'' + (t.inst_id || "") + "" +
'' + side + " " + fmt(t.target_index, 1) + "" +
'' +
"
"
);
}).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
.filter(function (p) { return p && p.target_index != null; })
.map(function (p) {
return {
id: p.target_monitor_id,
inst_id: p.inst_id,
opt_type: p.opt_type,
target_index: p.target_index,
};
});
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,
};
})();