3d7d754ba3
目标位指数仅作到期实值参考,展示盈利÷本合约权利金;持仓目标行同步显示盈亏比。 Co-authored-by: Cursor <cursoragent@cursor.com>
2361 lines
91 KiB
JavaScript
2361 lines
91 KiB
JavaScript
(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,
|
||
/** 环境 OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED;链接口可热更新 */
|
||
askLiqFilter: root.dataset.askLiqFilter !== "0",
|
||
budgetBuffer: (function () {
|
||
const raw = root.dataset.budgetBuffer;
|
||
const n = raw != null && raw !== "" ? Number(raw) : NaN;
|
||
return !Number.isNaN(n) && n > 0 ? n : 0.95;
|
||
})(),
|
||
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() {
|
||
const panel = orderPanel();
|
||
const host = orderPanelHost();
|
||
// 弹窗挂到 body;关闭后收回原位,绝不插入期权链表格
|
||
if (panel && host && panel.parentElement !== host) host.appendChild(panel);
|
||
if (host) {
|
||
host.hidden = true;
|
||
host.setAttribute("aria-hidden", "true");
|
||
if (orderPanelHome && host.parentElement !== orderPanelHome) {
|
||
orderPanelHome.appendChild(host);
|
||
}
|
||
}
|
||
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) + '"]');
|
||
document.querySelectorAll(".opt-strike-row").forEach(function (r) {
|
||
r.classList.toggle("opt-row-selected", !!row && r === row);
|
||
});
|
||
syncPickButtons(instId);
|
||
const oldInline = document.querySelector(".opt-order-inline-row");
|
||
if (oldInline) oldInline.remove();
|
||
if (panel.parentElement !== host) host.appendChild(panel);
|
||
// 挂到 body,避免被卡片 overflow 裁成「行内展开」
|
||
if (host.parentElement !== document.body) document.body.appendChild(host);
|
||
host.hidden = false;
|
||
host.setAttribute("aria-hidden", "false");
|
||
panel.style.display = "";
|
||
return true;
|
||
}
|
||
|
||
function closeOrderDialog() {
|
||
state.selectedInst = null;
|
||
state.orderQuote = null;
|
||
parkOrderPanel();
|
||
}
|
||
|
||
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 = '<div class="muted opt-pending-empty">暂无未成交委托</div>';
|
||
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 (
|
||
'<div class="opt-pending-item" data-ord="' + (o.ord_id || "") + '" data-inst="' + (o.inst_id || "") + '">' +
|
||
'<div class="opt-pending-item-top">' +
|
||
'<span class="opt-pending-side ' + sideCls + '">' + kind + " · " + (o.side_label || side || "—") + "</span>" +
|
||
'<button type="button" class="btn-secondary opt-pending-cancel" data-ord="' + (o.ord_id || "") +
|
||
'" data-inst="' + (o.inst_id || "") + '">撤销</button>' +
|
||
"</div>" +
|
||
'<div class="opt-pending-inst" title="' + (o.inst_id || "") + '">' + (o.inst_id || "—") + "</div>" +
|
||
'<div class="opt-pending-meta">价 ' + 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 +
|
||
"</div></div>"
|
||
);
|
||
}).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 = '<div class="muted opt-pending-empty">' + (d.msg || "获取委托失败") + "</div>";
|
||
return;
|
||
}
|
||
paintPendingOrders(d.orders || [], d.pending_ttl_seconds);
|
||
} catch (e) {
|
||
host.innerHTML = '<div class="muted opt-pending-empty">获取委托失败</div>';
|
||
}
|
||
}
|
||
|
||
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");
|
||
const hint = document.getElementById("opt-budget-full-hint");
|
||
const capEl = document.getElementById("opt-budget-full-cap");
|
||
if (sheetsEl) sheetsEl.style.display = mode === "sheets" ? "" : "none";
|
||
if (ethEl) ethEl.style.display = mode === "eth_amount" ? "" : "none";
|
||
if (hint) hint.style.display = mode === "budget_full" ? "" : "none";
|
||
if (capEl && root && root.dataset.tradeBudget) {
|
||
const n = Number(root.dataset.tradeBudget);
|
||
if (Number.isFinite(n) && n > 0) capEl.textContent = n.toFixed(2);
|
||
}
|
||
document.querySelectorAll(".opt-size-mode-chip").forEach(function (chip) {
|
||
const radio = chip.querySelector('input[name="opt-size-mode"]');
|
||
chip.classList.toggle("is-selected", !!(radio && radio.checked));
|
||
chip.classList.toggle("active", !!(radio && radio.checked));
|
||
});
|
||
}
|
||
|
||
function hardenOrderAutofill() {
|
||
function looksLikeUsername(v) {
|
||
return /^[a-z][a-z0-9._-]{1,31}$/i.test(String(v || "").trim());
|
||
}
|
||
function harden(el) {
|
||
if (!el) return;
|
||
function wipe() {
|
||
if (looksLikeUsername(el.value)) el.value = "";
|
||
}
|
||
wipe();
|
||
el.addEventListener("focus", function () {
|
||
el.removeAttribute("readonly");
|
||
});
|
||
el.addEventListener("blur", function () {
|
||
if (!el.value) el.setAttribute("readonly", "readonly");
|
||
});
|
||
setTimeout(wipe, 200);
|
||
setTimeout(wipe, 800);
|
||
setTimeout(wipe, 2000);
|
||
}
|
||
const note = document.getElementById("opt-signal-note");
|
||
harden(note);
|
||
[
|
||
"opt-sheets-amount",
|
||
"opt-eth-amount",
|
||
"opt-target-idx",
|
||
].forEach(function (id) {
|
||
harden(document.getElementById(id));
|
||
});
|
||
}
|
||
|
||
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 9;
|
||
}
|
||
|
||
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 = false;
|
||
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 askLiqFilterOn() {
|
||
return !!state.askLiqFilter;
|
||
}
|
||
|
||
function hasAskLiquidity(c) {
|
||
if (!c) return false;
|
||
if (c.ask_estimated) return false;
|
||
const a = Number(c.ask);
|
||
const s = Number(c.ask_sz);
|
||
return Number.isFinite(a) && a > 0 && Number.isFinite(s) && s >= 1;
|
||
}
|
||
|
||
function syncAskLiqFilterFromChain(d) {
|
||
if (!d || d.ask_liq_filter_enabled == null) return;
|
||
state.askLiqFilter = !!d.ask_liq_filter_enabled;
|
||
root.dataset.askLiqFilter = state.askLiqFilter ? "1" : "0";
|
||
}
|
||
|
||
function countContractsForType(contracts) {
|
||
if (state.chainView === "t") {
|
||
return countStraddleStrikes(contracts);
|
||
}
|
||
return (contracts || []).filter(function (c) {
|
||
if (c.opt_type !== state.optType) return false;
|
||
if (askLiqFilterOn() && !hasAskLiquidity(c)) return false;
|
||
return true;
|
||
}).length;
|
||
}
|
||
|
||
function countStraddleStrikes(contracts) {
|
||
const rows = buildStraddleRows(contracts).filter(function (row) {
|
||
if (!askLiqFilterOn()) return true;
|
||
return hasAskLiquidity(row.call) || hasAskLiquidity(row.put);
|
||
});
|
||
return rows.length;
|
||
}
|
||
|
||
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) {
|
||
if (!matchesStrikeRowFilter(row.strike, indexPx, atmStrike)) return false;
|
||
if (askLiqFilterOn() && !hasAskLiquidity(row.call) && !hasAskLiquidity(row.put)) {
|
||
return false;
|
||
}
|
||
return true;
|
||
});
|
||
}
|
||
|
||
// 默认窗口:平值 + 实值 3 档 + 虚值 3 档(T 型按行权价 ATM±3)
|
||
const DEFAULT_ATM_SIDE = 3;
|
||
|
||
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); });
|
||
const side = DEFAULT_ATM_SIDE;
|
||
if (idx < 0) return rows.slice(0, Math.min(rows.length, side * 2 + 1));
|
||
const start = Math.max(0, idx - side);
|
||
const end = Math.min(rows.length, idx + side + 1);
|
||
return rows.slice(start, end);
|
||
}
|
||
|
||
function sliceListByMoneyness(list) {
|
||
if (state.strikeExpandAll || !list.length) return list;
|
||
const sorted = list.slice().sort(function (a, b) {
|
||
return Number(a.strike) - Number(b.strike);
|
||
});
|
||
const itm = [];
|
||
const atm = [];
|
||
const otm = [];
|
||
sorted.forEach(function (c) {
|
||
const m = String(c.moneyness || "").toLowerCase();
|
||
if (m === "atm") atm.push(c);
|
||
else if (m === "itm") itm.push(c);
|
||
else if (m === "otm") otm.push(c);
|
||
});
|
||
if (!atm.length && !itm.length && !otm.length) {
|
||
const indexPx = state.chain && state.chain.index_px;
|
||
return sliceAtmWindow(
|
||
sorted.map(function (c) { return { strike: c.strike, _c: c }; }),
|
||
indexPx
|
||
).map(function (r) { return r._c; });
|
||
}
|
||
const n = DEFAULT_ATM_SIDE;
|
||
const isPut = String(state.optType || "").toUpperCase() === "P";
|
||
// Call: ITM 在下方取靠近 ATM 的末 N;OTM 取前 N。Put 相反。
|
||
const pickedItm = isPut ? itm.slice(0, n) : itm.slice(-n);
|
||
const pickedOtm = isPut ? otm.slice(-n) : otm.slice(0, n);
|
||
return pickedItm.concat(atm, pickedOtm).sort(function (a, b) {
|
||
return Number(a.strike) - Number(b.strike);
|
||
});
|
||
}
|
||
|
||
function strikeWindowHintHtml(cols) {
|
||
return (
|
||
'<td colspan="' +
|
||
cols +
|
||
'" class="muted opt-strike-hint">默认显示平值 + 实值3档 + 虚值3档 · 勾选「展开全部」查看该到期全部行权价</td>'
|
||
);
|
||
}
|
||
|
||
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 '<span class="muted">不可双买</span>';
|
||
return fmtUsdc(per) + " USDC";
|
||
}
|
||
|
||
function pickBtnHtml(instId) {
|
||
if (!instId) return "—";
|
||
return '<button type="button" class="btn-secondary opt-pick-btn" data-inst="' + instId + '">选择</button>';
|
||
}
|
||
|
||
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) {
|
||
if (c.opt_type !== state.optType) return false;
|
||
if (!matchesMoneyFilter(c.moneyness)) return false;
|
||
if (askLiqFilterOn() && !hasAskLiquidity(c)) return false;
|
||
return true;
|
||
});
|
||
}
|
||
|
||
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 sourceText(p) {
|
||
const lab = (p && p.source_label) || "纯期权";
|
||
const src = (p && p.source) || "option";
|
||
let pid = p && p.source_plan_id;
|
||
if (pid == null && p && p.hedge_plan_target && p.hedge_plan_target.plan_id != null) {
|
||
pid = p.hedge_plan_target.plan_id;
|
||
}
|
||
if (src !== "option" && pid != null && pid !== "") return lab + " #" + pid;
|
||
return lab;
|
||
}
|
||
|
||
function sourceBadgeHtml(p) {
|
||
const src = (p && p.source) || "option";
|
||
const cls =
|
||
src === "options_options"
|
||
? "opt-source-badge opt-source-badge--oo"
|
||
: src === "perp_options"
|
||
? "opt-source-badge opt-source-badge--po"
|
||
: "opt-source-badge opt-source-badge--plain";
|
||
return '<span class="' + cls + '" title="持仓来源">' + sourceText(p) + "</span>";
|
||
}
|
||
|
||
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 applyBudgetBuffer(raw) {
|
||
if (raw == null || raw === "") return;
|
||
const buf = Number(raw);
|
||
if (Number.isNaN(buf) || buf <= 0) return;
|
||
state.budgetBuffer = buf;
|
||
const el = document.getElementById("opt-budget-buf");
|
||
if (el) el.textContent = fmt(buf, 2);
|
||
}
|
||
|
||
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));
|
||
}
|
||
if (state.chain && state.chain.budget_buffer != null) {
|
||
applyBudgetBuffer(state.chain.budget_buffer);
|
||
}
|
||
const line = document.getElementById("opt-index-line");
|
||
if (line) {
|
||
const liqHint = askLiqFilterOn() ? "仅显示卖一深度≥1张" : "显示全部卖一(含估算~)";
|
||
line.textContent =
|
||
"指数 " + state.underlying + " ≈ " + fmt(idx, 2) +
|
||
" · 默认最近一期 · " + liqHint + " · 实值含平值 · 虚值=价外";
|
||
}
|
||
}
|
||
|
||
function pickNearestExpiry(exps) {
|
||
if (!exps || !exps.length) return "";
|
||
const now = Date.now();
|
||
let best = null;
|
||
let bestDelta = Infinity;
|
||
exps.forEach(function (e) {
|
||
const t = Number(e.exp_time);
|
||
if (!Number.isFinite(t)) return;
|
||
const delta = t - now;
|
||
if (delta < -60000) return;
|
||
if (delta < bestDelta) {
|
||
bestDelta = delta;
|
||
best = e;
|
||
}
|
||
});
|
||
if (best) return String(best.exp_time);
|
||
return String(exps[0].exp_time);
|
||
}
|
||
|
||
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 = '<option value="">选择到期日</option>';
|
||
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;
|
||
} else if (exps.length) {
|
||
sel.value = pickNearestExpiry(exps);
|
||
}
|
||
}
|
||
|
||
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.bid_invalid) {
|
||
const upl = p && p.upl != null ? Number(p.upl) : NaN;
|
||
return Number.isFinite(upl) ? upl : null;
|
||
}
|
||
if (preview.estimated_pnl != null && !Number.isNaN(Number(preview.estimated_pnl))) {
|
||
return Number(preview.estimated_pnl);
|
||
}
|
||
const covered = Number(preview.covered_sheets);
|
||
const recv = Number(preview.total_received);
|
||
const prem = Number(p && p.premium_paid);
|
||
if (
|
||
preview.total_received != null &&
|
||
Number.isFinite(covered) &&
|
||
covered > 0 &&
|
||
!Number.isNaN(recv) &&
|
||
!Number.isNaN(prem)
|
||
) {
|
||
return recv - prem;
|
||
}
|
||
const upl = p && p.upl != null ? Number(p.upl) : NaN;
|
||
return Number.isFinite(upl) ? upl : 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 '<span class="opt-close-value' + cls + '">' + recvTxt + " USDC</span>";
|
||
}
|
||
|
||
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 estimateProfitRr(profit, totalPremium) {
|
||
const pnl = Number(profit);
|
||
const prem = Number(totalPremium);
|
||
if (!Number.isFinite(pnl) || !Number.isFinite(prem) || prem <= 0) return null;
|
||
return Math.round((pnl / prem) * 100) / 100;
|
||
}
|
||
|
||
function fmtProfitRr(v) {
|
||
if (v === null || v === undefined || Number.isNaN(Number(v))) return "—";
|
||
return Number(v).toFixed(2);
|
||
}
|
||
|
||
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) + "×";
|
||
}
|
||
|
||
/** 链上展示:指数 ÷ 卖一(每1币). */
|
||
function calcAskLeverage(indexPx, askPx) {
|
||
if (indexPx == null || askPx == null) return null;
|
||
const idx = Number(indexPx);
|
||
const ask = Number(askPx);
|
||
if (!Number.isFinite(idx) || !Number.isFinite(ask) || ask <= 0) return null;
|
||
return Math.round((idx / ask) * 10) / 10;
|
||
}
|
||
|
||
function fmtChainLeverage(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 rrEl = document.getElementById("opt-est-rr") || 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 (rrEl) {
|
||
rrEl.textContent = "—";
|
||
rrEl.className = "v";
|
||
}
|
||
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 (rrEl) {
|
||
rrEl.textContent = "—";
|
||
rrEl.className = "v";
|
||
}
|
||
} else {
|
||
const value = estimateExpiryValue(q.opt_type, q.strike, Number(targetRaw), ethAmount);
|
||
const profit = estimateExpiryProfit(q.opt_type, q.strike, Number(targetRaw), ethAmount, premium);
|
||
const rr = estimateProfitRr(profit, 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);
|
||
}
|
||
if (rrEl) {
|
||
if (rr == null || Number.isNaN(rr)) {
|
||
rrEl.textContent = "—";
|
||
rrEl.className = "v";
|
||
} else {
|
||
rrEl.textContent = fmtProfitRr(rr);
|
||
rrEl.className = "v " + pnlCls(rr);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
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 = '<tr><td colspan="' + cols + '" 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) {
|
||
state.selectedInst = null;
|
||
return;
|
||
}
|
||
let list = filterChainContracts(exp.contracts);
|
||
list = sliceListByMoneyness(list);
|
||
if (!list.length) {
|
||
const label = moneyFilterLabel();
|
||
const suffix = label ? label : optTypeLabel(state.optType);
|
||
const liqTip = askLiqFilterOn() ? "(卖一深度≥1 时才显示,可在环境配置关闭筛选)" : "";
|
||
tbody.innerHTML = '<tr><td colspan="' + cols + '" class="muted">该到期日暂无' + suffix + "合约" + liqTip + "</td></tr>";
|
||
state.selectedInst = null;
|
||
return;
|
||
}
|
||
let matchedSelected = false;
|
||
const indexPx = state.chain && state.chain.index_px;
|
||
const atmStrike = findAtmStrike(
|
||
list.map(function (c) { return { strike: c.strike }; }),
|
||
indexPx
|
||
);
|
||
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);
|
||
if (atmStrike != null && Number(c.strike) === Number(atmStrike)) {
|
||
tr.classList.add("opt-strike-row-atm");
|
||
}
|
||
const chainLev = calcAskLeverage(indexPx, c.ask);
|
||
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-chain-lev">' + fmtChainLeverage(chainLev) + "</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">' +
|
||
pickBtnHtml(c.inst_id) +
|
||
"</td>";
|
||
tbody.appendChild(tr);
|
||
if (c.inst_id === prevSelected) matchedSelected = true;
|
||
});
|
||
if (!state.strikeExpandAll && list.length >= 1) {
|
||
const hint = document.createElement("tr");
|
||
hint.className = "opt-strike-hint-row";
|
||
hint.innerHTML = strikeWindowHintHtml(cols);
|
||
tbody.appendChild(hint);
|
||
}
|
||
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 = '<tr><td colspan="' + cols + '" 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) {
|
||
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 = '<tr><td colspan="' + cols + '" class="muted">该到期日暂无' + suffix + "行权价</td></tr>";
|
||
state.selectedInst = null;
|
||
return;
|
||
}
|
||
const atmStrike = findAtmStrike(rows, indexPx);
|
||
let matchedSelected = false;
|
||
rows.forEach(function (row) {
|
||
const callRaw = row.call;
|
||
const putRaw = row.put;
|
||
const call = callRaw && (!askLiqFilterOn() || hasAskLiquidity(callRaw)) ? callRaw : null;
|
||
const put = putRaw && (!askLiqFilterOn() || hasAskLiquidity(putRaw)) ? putRaw : null;
|
||
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 =
|
||
'<td class="opt-t-call opt-px-sz">' + (call ? fmtPxSz(call.ask, call.ask_sz, call.ask_estimated) : "—") + "</td>" +
|
||
'<td class="opt-t-call">' + (call ? moneynessBadge(call) : "—") + "</td>" +
|
||
'<td class="opt-t-call opt-row-actions">' + pickBtnHtml(call && call.inst_id) + "</td>" +
|
||
'<td class="opt-t-mid opt-t-strike"><strong>' + row.strike + "</strong></td>" +
|
||
'<td class="opt-t-mid opt-t-straddle-prem">' + formatStraddlePremiumCell(call && call.ask, put && put.ask) + "</td>" +
|
||
'<td class="opt-t-mid opt-t-straddle-band">' + formatStraddleBand(row.strike, combined) + "</td>" +
|
||
'<td class="opt-t-put">' + (put ? moneynessBadge(put) : "—") + "</td>" +
|
||
'<td class="opt-t-put opt-px-sz">' + (put ? fmtPxSz(put.ask, put.ask_sz, put.ask_estimated) : "—") + "</td>" +
|
||
'<td class="opt-t-put opt-row-actions">' + pickBtnHtml(put && put.inst_id) + "</td>";
|
||
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 = strikeWindowHintHtml(cols);
|
||
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 =
|
||
'<tr><td colspan="' + strikeTableColspan() + '" class="muted">加载期权链…</td></tr>';
|
||
}
|
||
}
|
||
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 =
|
||
'<tr><td colspan="' + strikeTableColspan() + '" class="muted">' +
|
||
(lastMsg || "暂无到期日,请点「刷新链」") +
|
||
"</td></tr>";
|
||
}
|
||
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;
|
||
syncAskLiqFilterFromChain(d);
|
||
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 =
|
||
'<tr><td colspan="' + strikeTableColspan() + '" class="muted">加载失败: ' +
|
||
String((e && e.message) || e) +
|
||
"</td></tr>";
|
||
}
|
||
} finally {
|
||
if (seq === chainLoadSeq && btn) btn.disabled = false;
|
||
}
|
||
}
|
||
|
||
async function openPosition() {
|
||
if (!state.selectedInst) {
|
||
alert("请先选择合约");
|
||
return false;
|
||
}
|
||
const q = state.orderQuote;
|
||
if (!q || !q.ok || !q.can_open) {
|
||
alert((q && (q.msg || q.open_block_msg)) || "暂无卖一深度,无法按卖一开仓");
|
||
return false;
|
||
}
|
||
if (q.sizing && q.sizing.ok === false) {
|
||
alert(q.sizing.msg || "张数无效");
|
||
return false;
|
||
}
|
||
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 false;
|
||
}
|
||
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();
|
||
closeOrderDialog();
|
||
setOptionsPosTab("pending");
|
||
return true;
|
||
}
|
||
alert(d.msg || "下单失败");
|
||
return false;
|
||
} 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 (
|
||
'<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>" +
|
||
sourceBadgeHtml(p) +
|
||
"</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">持仓来源: ' + sourceText(p) + "</span>" +
|
||
'<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">' + premTxt + " USDC</span></div>" +
|
||
'<div class="pos-cell"><span class="pos-label">开仓均价</span><span class="pos-value">' + avgTxt + "</span></div>" +
|
||
'<div class="pos-cell"><span class="pos-label">标记价</span><span class="pos-value">' + markTxt + "</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"><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 + '">' +
|
||
(closePreview.bid_invalid || net == null ? "—" : fmt(net, 2)) + "</span></div>" +
|
||
'<div class="pos-cell"><span class="pos-label">收益率</span><span class="pos-value ' + uplCls + '">' +
|
||
(closePreview.bid_invalid || roi == null ? "—" : fmt(roi, 2) + "%") + "</span></div>" +
|
||
'<div class="pos-cell opt-pos-cell--depth"><span class="pos-label">买盘深度</span><span class="pos-value opt-bid-plain">' + fmtCloseLevels(closePreview, tickSz) + "</span></div>" +
|
||
'<div class="pos-cell opt-pos-cell--close"><span class="pos-label">按买盘回收</span><span class="pos-value">' +
|
||
(closePreview.bid_invalid
|
||
? '<span class="muted">暂无有效买盘</span>'
|
||
: fmtClosePreview(closePreview, p.premium_paid)) + "</span></div>" +
|
||
"</div>" +
|
||
(function () {
|
||
const hint = closeGateHint(closePreview);
|
||
return hint ? '<div class="muted opt-bid-invalid-hint">' + hint + "</div>" : "";
|
||
})() +
|
||
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);
|
||
const rr = estimateProfitRr(profit, premiumPaid);
|
||
if (value == null && profit == null && rr == null) return "";
|
||
let html = '<span class="opt-target-est">';
|
||
html += '<span class="opt-target-est-item"><span class="k">价值</span><span class="v">' +
|
||
(value == null ? "—" : fmtUsdc(value) + " USDC") + "</span></span>";
|
||
html += '<span class="opt-target-est-item"><span class="k">预估盈利</span><span class="v ' + pnlCls(profit) + '">' +
|
||
(profit == null ? "—" : fmtUsdcSigned(profit)) + "</span></span>";
|
||
html += '<span class="opt-target-est-item"><span class="k">盈亏比</span><span class="v ' + pnlCls(rr) + '">' +
|
||
(rr == null ? "—" : fmtProfitRr(rr)) + "</span></span>";
|
||
html += "</span>";
|
||
return html;
|
||
}
|
||
|
||
function renderTargetDelegateRow(p) {
|
||
const inst = p.inst_id || "";
|
||
const hedgeTarget = p.hedge_plan_target || null;
|
||
if (hedgeTarget) {
|
||
const rr = hedgeTarget.profit_rr != null ? Number(hedgeTarget.profit_rr) : null;
|
||
if (rr != null && rr > 0) {
|
||
return (
|
||
'<div class="opt-target-row opt-target-row--managed">' +
|
||
'<span class="opt-target-row-label">对冲计划</span>' +
|
||
'<span class="opt-target-armed">计划 #' +
|
||
hedgeTarget.plan_id +
|
||
" · 盈亏比 " +
|
||
fmt(rr, 2) +
|
||
"</span>" +
|
||
'<span class="muted opt-target-row-hint">进行中 · 盈利达总权利金×盈亏比仅平盈利腿;亏损腿按本合约残值平或到期平</span>' +
|
||
"</div>"
|
||
);
|
||
}
|
||
if (Number(hedgeTarget.target_index) > 0) {
|
||
const side = (p.opt_type || hedgeTarget.opt_type || "").toUpperCase() === "P" ? "Put ≤" : "Call ≥";
|
||
return (
|
||
'<div class="opt-target-row opt-target-row--managed">' +
|
||
'<span class="opt-target-row-label">对冲计划</span>' +
|
||
'<span class="opt-target-armed">计划 #' +
|
||
hedgeTarget.plan_id +
|
||
" · " +
|
||
side +
|
||
" " +
|
||
fmt(hedgeTarget.target_index, 1) +
|
||
"</span>" +
|
||
'<span class="muted opt-target-row-hint">进行中 · 由对冲计划监控,到位后仅平盈利腿</span>' +
|
||
"</div>"
|
||
);
|
||
}
|
||
}
|
||
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)
|
||
: '<span class="opt-target-est opt-target-est--idle"></span>';
|
||
return (
|
||
'<div class="opt-target-row" data-inst="' + inst + '"' +
|
||
' data-opt-type="' + (p.opt_type || "") + '"' +
|
||
' data-strike="' + (p.strike != null ? p.strike : "") + '"' +
|
||
' data-eth="' + (ethAmt != null ? ethAmt : "") + '"' +
|
||
' data-prem="' + (prem != null ? prem : "") + '"' +
|
||
' data-armed-target="' + (armed ? tgt : "") + '">' +
|
||
'<span class="opt-target-row-label">委托</span>' +
|
||
'<input type="number" class="opt-pos-target-input" data-inst="' + inst + '" step="0.1" min="0" placeholder="监控目标指数" value="' +
|
||
(state.targetDraftByInst[inst] != null ? String(state.targetDraftByInst[inst]) : "") + '">' +
|
||
'<button type="button" class="btn-secondary opt-target-set-btn" data-inst="' + inst + '">设定</button>' +
|
||
'<button type="button" class="btn-secondary opt-target-cancel-btn" data-inst="' + inst + '"' + (armed ? "" : " disabled") + ">取消</button>" +
|
||
(armed
|
||
? '<span class="opt-target-armed">目标 ' + fmt(tgt, 1) + "</span>"
|
||
: "") +
|
||
estHtml +
|
||
'<span class="muted opt-target-row-hint">' +
|
||
(armed ? "监控中 · 目标位参考 · 到位按买一限价平" : "目标位参考(到期实值估盈亏比) · 到位按买一限价平 · 到期即止损") +
|
||
"</span>" +
|
||
"</div>"
|
||
);
|
||
}
|
||
|
||
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 (
|
||
'<div class="pos-card opt-pos-card" data-inst="' + (p.inst_id || "") + '">' +
|
||
renderPositionCardInner(p) +
|
||
"</div>"
|
||
);
|
||
}
|
||
|
||
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 (
|
||
'<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>" +
|
||
sourceBadgeHtml(p) +
|
||
"</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 + '">' + (net == null ? "—" : fmt(net, 2) + " USDC") + "</span>" +
|
||
'<span class="opt-pos-bar-roi ' + uplCls + '">' +
|
||
(roi == null ? "—" : fmt(roi, 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-target-set-btn").forEach(function (btn) {
|
||
btn.addEventListener("click", function (e) {
|
||
e.stopPropagation();
|
||
setPositionTarget(btn.getAttribute("data-inst"), btn);
|
||
});
|
||
});
|
||
container.querySelectorAll(".opt-target-cancel-btn").forEach(function (btn) {
|
||
btn.addEventListener("click", function (e) {
|
||
e.stopPropagation();
|
||
cancelPositionTarget(btn.getAttribute("data-inst"), btn);
|
||
});
|
||
});
|
||
container.querySelectorAll(".opt-pos-target-input").forEach(function (inp) {
|
||
inp.addEventListener("click", function (e) { e.stopPropagation(); });
|
||
inp.addEventListener("input", function () {
|
||
const instId = inp.getAttribute("data-inst") || "";
|
||
const draft = String(inp.value || "");
|
||
if (instId) {
|
||
if (draft.trim() === "") delete state.targetDraftByInst[instId];
|
||
else state.targetDraftByInst[instId] = draft;
|
||
}
|
||
updatePosTargetEstimate(inp.closest(".opt-target-row"));
|
||
});
|
||
inp.addEventListener("keydown", function (e) {
|
||
if (e.key === "Enter") {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
setPositionTarget(inp.getAttribute("data-inst"), null);
|
||
}
|
||
});
|
||
// 重绘后恢复预估展示(草稿或已设定目标)
|
||
updatePosTargetEstimate(inp.closest(".opt-target-row"));
|
||
});
|
||
container.querySelectorAll(".opt-pos-bar").forEach(function (bar) {
|
||
bar.addEventListener("click", function () {
|
||
const item = bar.closest(".opt-pos-accordion-item");
|
||
if (!item) return;
|
||
const inst = item.getAttribute("data-inst");
|
||
state.expandedPosInst = state.expandedPosInst === inst ? null : inst;
|
||
applyAccordionState();
|
||
if (window.OptionsExpiryCountdown && OptionsExpiryCountdown.ensureTimer) {
|
||
OptionsExpiryCountdown.ensureTimer();
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
async function setPositionTarget(inst, btn) {
|
||
if (!inst) return;
|
||
const card = document.querySelector('.opt-pos-card[data-inst="' + inst + '"]') ||
|
||
document.querySelector('.opt-pos-accordion-item[data-inst="' + inst + '"]');
|
||
const row = card ? card.querySelector(".opt-target-row") : null;
|
||
const inp = card ? card.querySelector(".opt-pos-target-input") : null;
|
||
const raw = inp ? String(inp.value || "").trim() : "";
|
||
const tgt = parseFloat(raw);
|
||
if (!Number.isFinite(tgt) || tgt <= 0) {
|
||
alert("请输入有效目标指数价");
|
||
return;
|
||
}
|
||
if (btn) btn.disabled = true;
|
||
try {
|
||
const d = await apiJson("/api/options/target", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ inst_id: inst, target_index: tgt }),
|
||
});
|
||
if (!d.ok) {
|
||
alert(d.msg || "设定失败");
|
||
return;
|
||
}
|
||
delete state.targetDraftByInst[inst];
|
||
if (inp) inp.value = "";
|
||
if (row) {
|
||
row.setAttribute("data-armed-target", String(tgt));
|
||
updatePosTargetEstimate(row);
|
||
}
|
||
await refreshAllPositions();
|
||
} finally {
|
||
if (btn) btn.disabled = false;
|
||
}
|
||
}
|
||
|
||
async function cancelPositionTarget(inst, btn) {
|
||
if (!inst) return;
|
||
if (btn) btn.disabled = true;
|
||
try {
|
||
const d = await apiJson("/api/options/target/cancel", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ inst_id: inst }),
|
||
});
|
||
if (!d.ok) {
|
||
alert(d.msg || "取消失败");
|
||
return;
|
||
}
|
||
delete state.targetDraftByInst[inst];
|
||
await refreshAllPositions();
|
||
} finally {
|
||
if (btn) btn.disabled = false;
|
||
}
|
||
}
|
||
|
||
function paintTargetMonitors(list) {
|
||
const box = document.getElementById("opt-target-monitors");
|
||
const host = document.getElementById("opt-target-monitors-list");
|
||
if (!box || !host) return;
|
||
const rows = Array.isArray(list) ? list.filter(function (t) { return t && t.inst_id; }) : [];
|
||
if (!rows.length) {
|
||
box.hidden = true;
|
||
host.innerHTML = "";
|
||
return;
|
||
}
|
||
box.hidden = false;
|
||
host.innerHTML = rows.map(function (t) {
|
||
const side = (t.opt_type || "").toUpperCase() === "P" ? "Put ≤" : "Call ≥";
|
||
const managed = t.managed_by === "hedge_plan";
|
||
return (
|
||
'<div class="opt-target-mon-item' + (managed ? " opt-target-mon-item--managed" : "") + '">' +
|
||
'<code class="opt-target-mon-inst" title="' + (t.inst_id || "") + '">' + (t.inst_id || "") + "</code>" +
|
||
'<span class="opt-target-mon-rule">' + side + " " + fmt(t.target_index, 1) + "</span>" +
|
||
(managed
|
||
? '<span class="opt-target-mon-managed">对冲计划 #' + (t.plan_id || "") + " · 进行中</span>"
|
||
: '<button type="button" class="btn-secondary opt-target-mon-cancel" data-inst="' + (t.inst_id || "") + '">取消</button>') +
|
||
"</div>"
|
||
);
|
||
}).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();
|
||
}
|
||
if (tab === "pending") {
|
||
refreshPendingOrders();
|
||
startPendingOrdersPoll();
|
||
}
|
||
}
|
||
|
||
function bindOptionsPosTabs() {
|
||
document.querySelectorAll(".opt-pos-tab").forEach(function (btn) {
|
||
btn.addEventListener("click", function () {
|
||
setOptionsPosTab(btn.getAttribute("data-opt-pos-tab"));
|
||
});
|
||
});
|
||
setOptionsPosTab(state.posTab);
|
||
}
|
||
|
||
function resolvePositionsList(d) {
|
||
const now = Date.now();
|
||
const list = (d && d.ok && d.positions) ? d.positions : [];
|
||
if (d && d.ok) {
|
||
if (list.length) {
|
||
lastGoodPositions = list;
|
||
lastGoodPositionsAt = now;
|
||
return list;
|
||
}
|
||
lastGoodPositions = null;
|
||
lastGoodPositionsAt = 0;
|
||
return list;
|
||
}
|
||
if (lastGoodPositions && lastGoodPositions.length && now - lastGoodPositionsAt < POSITIONS_STALE_MS) {
|
||
return lastGoodPositions;
|
||
}
|
||
return [];
|
||
}
|
||
|
||
function paintPositions(list) {
|
||
const wrap = document.getElementById("opt-pos-cards");
|
||
const empty = document.getElementById("opt-pos-empty");
|
||
const livePane = document.getElementById("opt-pos-live");
|
||
if (!wrap) return;
|
||
const active = document.activeElement;
|
||
// 正在输入目标指数:先落到草稿,本轮不重绘整卡,避免数字往回退
|
||
if (active && active.classList && active.classList.contains("opt-pos-target-input")) {
|
||
const focusInst = active.getAttribute("data-inst") || "";
|
||
if (focusInst) {
|
||
state.targetDraftByInst[focusInst] = String(active.value || "");
|
||
}
|
||
return;
|
||
}
|
||
// 未聚焦时也同步可见输入,防止漏掉 input 事件
|
||
wrap.querySelectorAll(".opt-pos-target-input").forEach(function (inp) {
|
||
const id = inp.getAttribute("data-inst") || "";
|
||
if (!id) return;
|
||
const v = String(inp.value || "");
|
||
if (v.trim() === "") delete state.targetDraftByInst[id];
|
||
else state.targetDraftByInst[id] = v;
|
||
});
|
||
wrap.innerHTML = "";
|
||
if (!list.length) {
|
||
if (empty) empty.style.display = "";
|
||
state.expandedPosInst = null;
|
||
if (livePane) livePane.classList.remove("options-pos-live-pane--accordion");
|
||
return;
|
||
}
|
||
if (empty) empty.style.display = "none";
|
||
const multi = list.length >= 2;
|
||
wrap.classList.toggle("opt-pos-cards--accordion", multi);
|
||
if (livePane) livePane.classList.toggle("options-pos-live-pane--accordion", multi);
|
||
if (multi) {
|
||
const ids = list.map(function (p) { return p.inst_id; });
|
||
if (state.expandedPosInst && ids.indexOf(state.expandedPosInst) < 0) {
|
||
state.expandedPosInst = null;
|
||
}
|
||
list.forEach(function (p) {
|
||
const div = document.createElement("div");
|
||
div.innerHTML = renderPositionAccordionItem(p, p.inst_id === state.expandedPosInst);
|
||
wrap.appendChild(div.firstChild);
|
||
});
|
||
} else {
|
||
state.expandedPosInst = null;
|
||
list.forEach(function (p) {
|
||
const div = document.createElement("div");
|
||
div.innerHTML = renderPositionCard(p);
|
||
wrap.appendChild(div.firstChild);
|
||
});
|
||
}
|
||
bindPositionActions(wrap);
|
||
if (window.OptionsExpiryCountdown && OptionsExpiryCountdown.ensureTimer) {
|
||
OptionsExpiryCountdown.ensureTimer();
|
||
}
|
||
}
|
||
|
||
async function refreshPositions() {
|
||
const seq = ++positionsRefreshSeq;
|
||
const d = await apiJson("/api/options/positions");
|
||
if (seq !== positionsRefreshSeq) return;
|
||
const list = resolvePositionsList(d);
|
||
paintPositions(list);
|
||
const fromPos = list.reduce(function (targets, p) {
|
||
if (!p) return targets;
|
||
if (p.target_index != null) {
|
||
targets.push({
|
||
id: p.target_monitor_id,
|
||
inst_id: p.inst_id,
|
||
opt_type: p.opt_type,
|
||
target_index: p.target_index,
|
||
});
|
||
}
|
||
const hedgeTarget = p.hedge_plan_target;
|
||
if (hedgeTarget && (hedgeTarget.target_index != null || hedgeTarget.profit_rr != null)) {
|
||
targets.push({
|
||
inst_id: p.inst_id,
|
||
opt_type: p.opt_type || hedgeTarget.opt_type,
|
||
target_index: hedgeTarget.target_index,
|
||
profit_rr: hedgeTarget.profit_rr,
|
||
plan_id: hedgeTarget.plan_id,
|
||
managed_by: hedgeTarget.managed_by,
|
||
exit_mode: hedgeTarget.exit_mode,
|
||
});
|
||
}
|
||
return targets;
|
||
}, []);
|
||
if (fromPos.length) {
|
||
paintTargetMonitors(fromPos);
|
||
} else {
|
||
const t = await apiJson("/api/options/targets");
|
||
if (seq !== positionsRefreshSeq) return;
|
||
paintTargetMonitors((t && t.ok && t.targets) ? t.targets : []);
|
||
}
|
||
}
|
||
|
||
function paintPnlStat(el, value) {
|
||
if (!el) return;
|
||
if (value == null || value === "" || Number.isNaN(Number(value))) {
|
||
el.textContent = "—";
|
||
el.classList.remove("pos-pnl-profit", "pos-pnl-loss");
|
||
return;
|
||
}
|
||
const n = Number(value);
|
||
el.textContent = (n > 0 ? "+" : "") + fmt(n, 2) + " USDC";
|
||
el.classList.toggle("pos-pnl-profit", n > 0);
|
||
el.classList.toggle("pos-pnl-loss", n < 0);
|
||
}
|
||
|
||
async function refreshStats() {
|
||
const d = await apiJson("/api/options/stats");
|
||
const winEl = document.getElementById("opt-stats-winrate");
|
||
const plrEl = document.getElementById("opt-stats-plr");
|
||
const closedEl = document.getElementById("opt-stats-closed");
|
||
const profitEl = document.getElementById("opt-stats-profit");
|
||
const lossEl = document.getElementById("opt-stats-loss");
|
||
const avgHoldEl = document.getElementById("opt-stats-avg-hold");
|
||
const winHoldEl = document.getElementById("opt-stats-win-hold");
|
||
const lossHoldEl = document.getElementById("opt-stats-loss-hold");
|
||
const openHoldEl = document.getElementById("opt-stats-open-hold");
|
||
const totalPnlEl = document.getElementById("opt-stats-total-pnl");
|
||
const netRealizedEl = document.getElementById("opt-stats-net-realized");
|
||
const openFloatEl = document.getElementById("opt-stats-open-float");
|
||
const statEls = [winEl, plrEl, closedEl, profitEl, lossEl, avgHoldEl, winHoldEl, lossHoldEl, openHoldEl];
|
||
if (!d.ok) {
|
||
statEls.forEach(function (el) {
|
||
if (el) el.textContent = "—";
|
||
});
|
||
paintPnlStat(totalPnlEl, null);
|
||
paintPnlStat(netRealizedEl, null);
|
||
paintPnlStat(openFloatEl, null);
|
||
paintStatsCharts(null);
|
||
return;
|
||
}
|
||
paintPnlStat(totalPnlEl, d.total_pnl);
|
||
paintPnlStat(netRealizedEl, d.net_realized_pnl);
|
||
paintPnlStat(openFloatEl, d.open_float_pnl);
|
||
if (winEl) winEl.textContent = d.total_closed ? d.win_rate + "%" : "0%";
|
||
if (plrEl) {
|
||
plrEl.textContent = d.profit_loss_ratio != null ? String(d.profit_loss_ratio) : "—";
|
||
}
|
||
if (closedEl) closedEl.textContent = String(d.total_closed || 0);
|
||
if (profitEl) {
|
||
profitEl.textContent = d.avg_win != null && d.avg_win > 0
|
||
? fmt(d.avg_win, 2) + " USDC" : (d.win_count ? "0 USDC" : "—");
|
||
}
|
||
if (lossEl) {
|
||
lossEl.textContent = d.avg_loss != null && d.avg_loss > 0
|
||
? fmt(d.avg_loss, 2) + " USDC" : (d.loss_count ? "0 USDC" : "—");
|
||
}
|
||
if (avgHoldEl) avgHoldEl.textContent = fmtDuration(d.avg_hold_sec);
|
||
if (winHoldEl) winHoldEl.textContent = fmtDuration(d.avg_win_hold_sec);
|
||
if (lossHoldEl) lossHoldEl.textContent = fmtDuration(d.avg_loss_hold_sec);
|
||
if (openHoldEl) {
|
||
const cnt = Number(d.open_count) || 0;
|
||
if (!cnt) {
|
||
openHoldEl.textContent = "0 笔";
|
||
} else {
|
||
openHoldEl.textContent = cnt + " 笔 · " + fmtDuration(d.avg_open_hold_sec);
|
||
}
|
||
}
|
||
paintStatsCharts(d);
|
||
}
|
||
|
||
function fmtDuration(sec) {
|
||
if (sec == null || sec === "" || Number.isNaN(Number(sec))) return "—";
|
||
let s = Math.max(0, Math.round(Number(sec)));
|
||
if (s < 60) return s + "秒";
|
||
const m = Math.floor(s / 60);
|
||
if (m < 60) {
|
||
const rs = s % 60;
|
||
return rs ? m + "分" + rs + "秒" : m + "分";
|
||
}
|
||
const h = Math.floor(m / 60);
|
||
const rm = m % 60;
|
||
if (h < 24) return rm ? h + "时" + rm + "分" : h + "时";
|
||
const d = Math.floor(h / 24);
|
||
const rh = h % 24;
|
||
return rh ? d + "天" + rh + "时" : d + "天";
|
||
}
|
||
|
||
function setBarFill(el, pct) {
|
||
if (!el) return;
|
||
const n = Math.max(0, Math.min(100, Number(pct) || 0));
|
||
el.style.width = n + "%";
|
||
}
|
||
|
||
function paintStatsCharts(d) {
|
||
const ring = document.getElementById("opt-stats-ring");
|
||
const ringLabel = document.getElementById("opt-stats-ring-label");
|
||
const profitBar = document.getElementById("opt-stats-bar-profit");
|
||
const lossBar = document.getElementById("opt-stats-bar-loss");
|
||
const profitBarLabel = document.getElementById("opt-stats-bar-profit-label");
|
||
const lossBarLabel = document.getElementById("opt-stats-bar-loss-label");
|
||
const winHoldBar = document.getElementById("opt-stats-bar-win-hold");
|
||
const lossHoldBar = document.getElementById("opt-stats-bar-loss-hold");
|
||
const winHoldBarLabel = document.getElementById("opt-stats-win-hold-label");
|
||
const lossHoldBarLabel = document.getElementById("opt-stats-loss-hold-label");
|
||
if (!d || !d.ok) {
|
||
if (ring) ring.style.setProperty("--win-pct", "0");
|
||
if (ringLabel) ringLabel.textContent = "—";
|
||
[profitBar, lossBar, winHoldBar, lossHoldBar].forEach(function (el) { setBarFill(el, 0); });
|
||
[profitBarLabel, lossBarLabel, winHoldBarLabel, lossHoldBarLabel].forEach(function (el) {
|
||
if (el) el.textContent = "—";
|
||
});
|
||
return;
|
||
}
|
||
const winRate = d.total_closed ? Number(d.win_rate) || 0 : 0;
|
||
if (ring) ring.style.setProperty("--win-pct", String(winRate));
|
||
if (ringLabel) ringLabel.textContent = d.total_closed ? winRate.toFixed(0) + "%" : "0%";
|
||
|
||
const profit = Math.max(0, Number(d.avg_win) || 0);
|
||
const loss = Math.max(0, Number(d.avg_loss) || 0);
|
||
const pnlTotal = profit + loss;
|
||
if (pnlTotal > 0) {
|
||
setBarFill(profitBar, (profit / pnlTotal) * 100);
|
||
setBarFill(lossBar, (loss / pnlTotal) * 100);
|
||
if (profitBarLabel) profitBarLabel.textContent = fmt(profit, 2) + " USDC";
|
||
if (lossBarLabel) lossBarLabel.textContent = fmt(loss, 2) + " USDC";
|
||
} else {
|
||
setBarFill(profitBar, 0);
|
||
setBarFill(lossBar, 0);
|
||
if (profitBarLabel) profitBarLabel.textContent = d.win_count ? "0 USDC" : "—";
|
||
if (lossBarLabel) lossBarLabel.textContent = d.loss_count ? "0 USDC" : "—";
|
||
}
|
||
|
||
const winHold = Number(d.avg_win_hold_sec) || 0;
|
||
const lossHold = Number(d.avg_loss_hold_sec) || 0;
|
||
const holdMax = Math.max(winHold, lossHold);
|
||
if (holdMax > 0) {
|
||
setBarFill(winHoldBar, (winHold / holdMax) * 100);
|
||
setBarFill(lossHoldBar, (lossHold / holdMax) * 100);
|
||
if (winHoldBarLabel) winHoldBarLabel.textContent = fmtDuration(d.avg_win_hold_sec);
|
||
if (lossHoldBarLabel) lossHoldBarLabel.textContent = fmtDuration(d.avg_loss_hold_sec);
|
||
} else {
|
||
setBarFill(winHoldBar, 0);
|
||
setBarFill(lossHoldBar, 0);
|
||
if (winHoldBarLabel) winHoldBarLabel.textContent = "—";
|
||
if (lossHoldBarLabel) lossHoldBarLabel.textContent = "—";
|
||
}
|
||
}
|
||
|
||
async function deleteHistoryRow(key, status, instId, closedAt) {
|
||
const warn = status === "open"
|
||
? "该记录仍为持仓中,仅从列表隐藏,不影响交易所持仓.确认删除?"
|
||
: "确认从列表隐藏该条历史记录?(期权复盘页也会同步隐藏)";
|
||
if (!confirm(warn)) return;
|
||
const body = {};
|
||
if (instId) body.inst_id = instId;
|
||
if (closedAt) body.closed_at = closedAt;
|
||
const r = await apiJson("/api/options/history/" + encodeURIComponent(key), {
|
||
method: "DELETE",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(body),
|
||
});
|
||
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 '<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 = 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 =
|
||
'<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-key="' +
|
||
histKey +
|
||
'" data-status="' +
|
||
(h.status || "") +
|
||
'" data-inst="' +
|
||
(h.inst_id || "") +
|
||
'" data-closed="' +
|
||
(h.closed_at || h.created_at || "") +
|
||
'">删除</button></td>';
|
||
tbody.appendChild(tr);
|
||
});
|
||
tbody.querySelectorAll(".opt-history-del").forEach(function (btn) {
|
||
btn.addEventListener("click", function () {
|
||
deleteHistoryRow(
|
||
btn.getAttribute("data-key"),
|
||
btn.getAttribute("data-status"),
|
||
btn.getAttribute("data-inst"),
|
||
btn.getAttribute("data-closed")
|
||
);
|
||
});
|
||
});
|
||
}
|
||
|
||
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() {
|
||
applyBudgetBuffer(state.budgetBuffer);
|
||
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;
|
||
// 实值/虚值筛选下档位本来就少,展开几乎不变;勾选时切回「全部」才有意义
|
||
if (state.strikeExpandAll && state.moneyFilter !== "all") {
|
||
state.moneyFilter = "all";
|
||
syncMoneyFilterButtons();
|
||
}
|
||
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();
|
||
hardenOrderAutofill();
|
||
|
||
document.querySelectorAll('input[name="opt-size-mode"]').forEach(function (r) {
|
||
r.addEventListener("change", function () {
|
||
updateSizeInputs();
|
||
if (state.selectedInst) selectContract(state.selectedInst, null, true);
|
||
});
|
||
});
|
||
|
||
function bindOrderDialogChrome() {
|
||
const host = orderPanelHost();
|
||
const closeBtn = document.getElementById("opt-order-close-btn");
|
||
const cancelBtn = document.getElementById("opt-order-cancel-btn");
|
||
if (closeBtn) closeBtn.addEventListener("click", closeOrderDialog);
|
||
if (cancelBtn) cancelBtn.addEventListener("click", closeOrderDialog);
|
||
if (host) {
|
||
host.addEventListener("click", function (ev) {
|
||
if (ev.target === host) closeOrderDialog();
|
||
});
|
||
}
|
||
document.addEventListener("keydown", function (ev) {
|
||
if (ev.key !== "Escape") return;
|
||
const h = orderPanelHost();
|
||
if (h && !h.hidden) closeOrderDialog();
|
||
});
|
||
}
|
||
bindOrderDialogChrome();
|
||
|
||
["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,
|
||
};
|
||
})();
|