Files
crypto_monitor/lib/common/static/hedge_plan.js
T
2026-07-14 12:33:30 +08:00

738 lines
24 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* OKX 对冲计划 P0:行情 + 永期列表 / 期期 T + 情景测算 + 门禁.
*/
(function () {
const root = document.getElementById("hedge-plan-root");
if (!root) return;
const state = {
tab: "perp_options",
mode: "perp_options",
underlying: root.getAttribute("data-default-underly") || "ETH",
moneyFilter: "all",
chain: null,
selected: null,
legA: null,
legB: null,
market: null,
};
function $(id) {
return document.getElementById(id);
}
async function apiJson(url, opts) {
const res = await fetch(url, Object.assign({ credentials: "same-origin" }, opts || {}));
const data = await res.json().catch(function () {
return {};
});
if (!res.ok) throw new Error(data.msg || res.statusText || "请求失败");
return data;
}
function fmt(v, d) {
if (v === null || v === undefined || Number.isNaN(Number(v))) return "—";
return Number(v).toFixed(d == null ? 2 : d);
}
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) {
return String(n).replace(/(\.\d*?[1-9])0+$/, "$1").replace(/\.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).replace(/\.?0+$/, "");
return s || "0";
}
/** 价格/流动性(张),价格按 tick_sz 对齐交易所精度. */
function fmtPxSz(px, sz, estimated, tickSz) {
if (px === null || px === undefined || Number.isNaN(Number(px))) return "—";
let price = fmtOptionPx(px, tickSz);
if (price === "—") return "—";
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 moneynessBadge(c) {
const m = (c && c.moneyness) || "";
const label = (c && c.moneyness_label) || "—";
return '<span class="opt-moneyness opt-moneyness-' + m + '">' + label + "</span>";
}
function matchesMoneyFilter(c) {
const f = state.moneyFilter || "all";
if (f === "all") return true;
const m = (c.moneyness || "").toLowerCase();
if (f === "itm") return m === "itm" || m === "atm";
if (f === "otm") return m === "otm";
return true;
}
function optTypeForDirection(dir) {
return dir === "short" ? "C" : "P";
}
function syncUnderlyingUI() {
const uly = state.underlying || "ETH";
document.querySelectorAll(".hp-uly-btn, .hp-uly-btn-oo").forEach(function (b) {
const on = b.getAttribute("data-uly") === uly;
b.classList.toggle("active", on);
b.setAttribute("aria-pressed", on ? "true" : "false");
});
const lab = $("hp-perp-uly-label");
if (lab) lab.textContent = uly;
const ooLab = $("hp-oo-uly-label");
if (ooLab) ooLab.textContent = uly;
}
function syncMoneyUI() {
document.querySelectorAll(".hp-money-btn").forEach(function (b) {
const on = b.getAttribute("data-money") === state.moneyFilter;
b.classList.toggle("active", on);
});
}
function syncTabUI() {
const tab = state.tab || "perp_options";
document.querySelectorAll(".hp-tab").forEach(function (b) {
const on = b.getAttribute("data-tab") === tab;
b.classList.toggle("active", on);
b.setAttribute("aria-selected", on ? "true" : "false");
});
["perp_options", "options_options", "history", "stats"].forEach(function (id) {
const panel = $("hp-tab-" + id);
if (!panel) return;
const on = id === tab;
panel.classList.toggle("hidden", !on);
if (on) panel.removeAttribute("hidden");
else panel.setAttribute("hidden", "");
});
if (tab === "perp_options" || tab === "options_options") {
state.mode = tab;
}
}
function setGateLine(gates) {
const el = $("hp-gate-line");
if (!el) return;
if (!gates) {
el.textContent = "";
return;
}
const parts = [
"计仓:" + (gates.is_full_margin ? "全仓" : "非全仓"),
"测算:" + (gates.can_preview ? "可" : "否"),
"开仓:" + (gates.can_start ? "可" : "否"),
];
if (gates.reasons && gates.reasons.length) parts.push(gates.reasons.join("; "));
el.textContent = parts.join(" · ");
const start = $("hp-start-btn");
if (start) start.disabled = !gates.can_start;
}
function setOptionsBalance(chain) {
const acct = (chain && chain.options_account) || {};
const label = (chain && chain.account_label) || acct.label || "期权账户";
const tag = $("hp-opt-acct-tag");
if (tag) tag.textContent = label;
const line =
label +
" · 交易 USDC " +
fmt(acct.trading_usdc, 2) +
" · 资金 USDC " +
fmt(acct.funding_usdc, 2);
const el = $("hp-opt-bal-line");
if (el) el.textContent = line;
const oo = $("hp-oo-bal-line");
if (oo) oo.textContent = line;
}
async function loadGates() {
try {
const d = await apiJson("/api/hedge-plan/gates?plan_type=" + encodeURIComponent(state.mode));
setGateLine(d);
} catch (e) {
setGateLine({ can_preview: false, can_start: false, reasons: [e.message], is_full_margin: false });
}
}
async function loadMarket() {
const dir = ($("hp-direction") && $("hp-direction").value) || "long";
const d = await apiJson(
"/api/hedge-plan/market?base=" +
encodeURIComponent(state.underlying) +
"&direction=" +
encodeURIComponent(dir)
);
state.market = d;
setGateLine(d.gates);
const acctLabel = d.account_label || "合约账户";
const tag = $("hp-perp-acct-tag");
if (tag) tag.textContent = acctLabel;
const q = $("hp-perp-quote");
if (q) {
q.innerHTML =
"<strong>" +
(d.base || state.underlying) +
"</strong> · " +
acctLabel +
"可用 <strong>" +
fmt(d.available_usdt, 2) +
"</strong> U<br/>标记 <strong>" +
fmt(d.mark, 2) +
"</strong> · 最新 " +
fmt(d.last, 2) +
" · 卖一 " +
fmt(d.ask, 2) +
" · 买一 " +
fmt(d.bid, 2) +
" · 面值 " +
fmt(d.contract_size, 4);
}
const sz = $("hp-sizing-line");
if (sz) {
if (d.full_margin_sizing) {
const s = d.full_margin_sizing;
sz.textContent =
"全仓建议(" +
acctLabel +
"):保证金 " +
fmt(s.margin_capital, 2) +
"U × " +
s.leverage +
"x → 名义 " +
fmt(s.notional_value, 2) +
"U · 建议约 " +
fmt(d.suggest_contracts, 4) +
" 张";
} else {
sz.textContent = "非全仓或不具备保证金数据时仅手动填张数;永期开仓需全仓.";
}
}
const entry = $("hp-entry");
if (entry && d.entry_ref && !entry.value) entry.value = d.entry_ref;
const contracts = $("hp-contracts");
if (contracts && d.suggest_contracts != null && !contracts.value) {
contracts.value = d.suggest_contracts;
}
const label = $("hp-opt-type-label");
if (label) label.textContent = d.suggested_opt_type === "C" ? "Call" : "Put";
}
function fillExpSelect(sel, chain) {
if (!sel) return;
const prev = sel.value;
sel.innerHTML = '<option value="">选择到期日</option>';
(chain.expiries || []).forEach(function (e) {
const opt = document.createElement("option");
opt.value = String(e.exp_time);
const dt = new Date(Number(e.exp_time));
opt.textContent = dt.toLocaleString();
sel.appendChild(opt);
});
if (prev) sel.value = prev;
if (!sel.value && chain.expiries && chain.expiries[0]) {
sel.value = String(chain.expiries[0].exp_time);
}
}
async function loadChain() {
const d = await apiJson(
"/api/hedge-plan/options-chain?underlying=" + encodeURIComponent(state.underlying)
);
state.chain = d;
const uly = d.underlying || state.underlying;
const idx = $("hp-index-line");
if (idx) {
idx.textContent =
"指数 " + uly + " " + fmt(d.index_px, 2) + " · " + (d.inst_family || "") + " · 实值含平值";
}
const ooIdx = $("hp-oo-index");
if (ooIdx) ooIdx.textContent = "指数 " + uly + " " + fmt(d.index_px, 2);
setOptionsBalance(d);
fillExpSelect($("hp-exp-select"), d);
fillExpSelect($("hp-oo-exp-select"), d);
renderListStrikes();
renderTStrikes();
if (d.index_px && $("hp-target") && !$("hp-target").value) {
$("hp-target").value = d.index_px;
}
}
function currentExp(selectId) {
const sel = $(selectId);
const expMs = sel && sel.value;
if (!expMs || !state.chain) return null;
return (state.chain.expiries || []).find(function (e) {
return String(e.exp_time) === String(expMs);
});
}
function pickContract(c, list) {
if (!c) return;
state.selected = c;
const el = $("hp-sel-inst");
if (el) el.textContent = c.inst_id;
const sel = $("hp-strike-select");
if (sel) sel.value = c.inst_id;
const tbody = $("hp-strike-tbody");
if (tbody) {
tbody.querySelectorAll(".opt-strike-row").forEach(function (r) {
r.classList.toggle("opt-row-selected", r.getAttribute("data-inst") === c.inst_id);
});
tbody.querySelectorAll(".hp-pick").forEach(function (b) {
b.classList.toggle("active", b.getAttribute("data-inst") === c.inst_id);
});
}
updatePremiumLine();
}
function strikeOptionLabel(c) {
return (
"K" +
c.strike +
" " +
(c.moneyness_label || c.opt_type || "") +
" " +
fmtPxSz(c.ask, c.ask_sz, c.ask_estimated, c.tick_sz)
);
}
function fillStrikeSelect(list, prevInst) {
const sel = $("hp-strike-select");
if (!sel) return;
const rest = (list || []).slice(5);
sel.innerHTML = '<option value="">更多行权价(' + rest.length + ")…</option>";
rest.forEach(function (c) {
const opt = document.createElement("option");
opt.value = c.inst_id;
opt.textContent = strikeOptionLabel(c);
sel.appendChild(opt);
});
if (prevInst && rest.some(function (c) { return c.inst_id === prevInst; })) {
sel.value = prevInst;
}
}
function renderListStrikes() {
const tbody = $("hp-strike-tbody");
if (!tbody) return;
const dir = ($("hp-direction") && $("hp-direction").value) || "long";
const want = optTypeForDirection(dir);
const exp = currentExp("hp-exp-select");
const prevInst = state.selected && state.selected.inst_id;
tbody.innerHTML = "";
if (!exp) {
tbody.innerHTML = '<tr><td colspan="5" class="muted">请选择到期日</td></tr>';
fillStrikeSelect([], null);
return;
}
const list = (exp.contracts || []).filter(function (c) {
return String(c.opt_type || "").toUpperCase() === want && matchesMoneyFilter(c);
});
if (!list.length) {
tbody.innerHTML = '<tr><td colspan="5" class="muted">无匹配合约</td></tr>';
fillStrikeSelect([], null);
return;
}
const visible = list.slice(0, 5);
fillStrikeSelect(list, prevInst);
visible.forEach(function (c) {
const tr = document.createElement("tr");
tr.className = "opt-strike-row" + (c.moneyness ? " opt-row-" + c.moneyness : "");
if (prevInst && c.inst_id === prevInst) tr.classList.add("opt-row-selected");
tr.setAttribute("data-inst", c.inst_id);
tr.innerHTML =
"<td>" +
c.strike +
"</td><td>" +
moneynessBadge(c) +
'</td><td class="opt-px-sz">' +
fmtPxSz(c.ask, c.ask_sz, c.ask_estimated, c.tick_sz) +
'</td><td class="opt-px-sz">' +
fmtPxSz(c.bid, c.bid_sz, false, c.tick_sz) +
'</td><td><button type="button" class="btn-secondary hp-pick' +
(prevInst && c.inst_id === prevInst ? " active" : "") +
'" data-inst="' +
c.inst_id +
'">选用</button></td>';
tbody.appendChild(tr);
});
if (list.length > 5) {
const tip = document.createElement("tr");
tip.innerHTML =
'<td colspan="5" class="muted">仅显示前 5 行,其余 ' +
(list.length - 5) +
" 条请用上方下拉框选用</td>";
tbody.appendChild(tip);
}
tbody.querySelectorAll(".hp-pick").forEach(function (btn) {
btn.addEventListener("click", function () {
const inst = btn.getAttribute("data-inst");
const c = list.find(function (x) {
return x.inst_id === inst;
});
pickContract(c, list);
});
});
}
function updatePremiumLine() {
const line = $("hp-premium-line");
if (!line || !state.selected) {
if (line) line.textContent = "";
return;
}
const sheets = Number(($("hp-sheets") && $("hp-sheets").value) || 1);
const ct = Number(state.selected.ct_mult || 0.01);
const ask = Number(state.selected.ask || 0);
const prem = ask * sheets * ct;
line.textContent = "预估权利金 ≈ " + fmt(prem, 4) + " USDC(期权账户)";
}
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 renderTStrikes() {
const tbody = $("hp-oo-tbody");
if (!tbody) return;
const exp = currentExp("hp-oo-exp-select");
tbody.innerHTML = "";
if (!exp) {
tbody.innerHTML = '<tr><td colspan="7" class="muted">请选择到期日</td></tr>';
return;
}
const rows = buildStraddleRows(exp.contracts);
rows.forEach(function (row) {
const tr = document.createElement("tr");
const call = row.call;
const put = row.put;
const callAsk = call ? fmtPxSz(call.ask, call.ask_sz, call.ask_estimated, call.tick_sz) : "—";
const putAsk = put ? fmtPxSz(put.ask, put.ask_sz, put.ask_estimated, put.tick_sz) : "—";
tr.innerHTML =
'<td class="opt-t-call opt-px-sz">' +
callAsk +
'</td><td class="opt-t-call">' +
(call ? moneynessBadge(call) : "—") +
"</td><td>" +
(call
? '<button type="button" class="btn-secondary hp-oo-pick" data-side="C" data-inst="' +
call.inst_id +
'">Call</button>'
: "—") +
'</td><td class="opt-t-strike"><strong>' +
row.strike +
'</strong></td><td class="opt-t-put">' +
(put ? moneynessBadge(put) : "—") +
'</td><td class="opt-t-put opt-px-sz">' +
putAsk +
"</td><td>" +
(put
? '<button type="button" class="btn-secondary hp-oo-pick" data-side="P" data-inst="' +
put.inst_id +
'">Put</button>'
: "—") +
"</td>";
tbody.appendChild(tr);
});
tbody.querySelectorAll(".hp-oo-pick").forEach(function (btn) {
btn.addEventListener("click", function () {
const inst = btn.getAttribute("data-inst");
const exp2 = currentExp("hp-oo-exp-select");
const c = (exp2.contracts || []).find(function (x) {
return x.inst_id === inst;
});
if (!c) return;
if (!state.legA) state.legA = c;
else if (!state.legB || state.legB.inst_id === state.legA.inst_id) state.legB = c;
else {
state.legA = c;
state.legB = null;
}
renderOoLegs();
});
});
}
function renderOoLegs() {
const el = $("hp-oo-legs");
if (!el) return;
function one(tag, c) {
if (!c) return tag + ": —";
return (
tag +
": " +
c.opt_type +
" K" +
c.strike +
" " +
(c.moneyness_label || "") +
" ask=" +
fmtPxSz(c.ask, c.ask_sz, c.ask_estimated, c.tick_sz) +
" (" +
c.inst_id +
")"
);
}
el.innerHTML = one("腿A", state.legA) + "<br/>" + one("腿B", state.legB);
}
function legPayload(c, sheets) {
return {
opt_type: c.opt_type,
strike: c.strike,
sheets: sheets,
ct_mult: c.ct_mult || 0.01,
ask: c.ask,
inst_id: c.inst_id,
};
}
function setUnderlying(uly, forceReload) {
const next = (uly || "ETH").toUpperCase();
const changed = next !== state.underlying;
state.underlying = next;
syncUnderlyingUI();
if (!changed && !forceReload) return;
state.selected = null;
state.legA = null;
state.legB = null;
if ($("hp-entry")) $("hp-entry").value = "";
if ($("hp-contracts")) $("hp-contracts").value = "";
if ($("hp-tp")) $("hp-tp").value = "";
if ($("hp-sl")) $("hp-sl").value = "";
if ($("hp-target")) $("hp-target").value = "";
if ($("hp-sel-inst")) $("hp-sel-inst").textContent = "—";
if ($("hp-premium-line")) $("hp-premium-line").textContent = "";
if ($("hp-oo-legs")) $("hp-oo-legs").textContent = "尚未选用两腿";
void refreshAll();
}
async function runPreview() {
const isOo = state.mode === "options_options";
const tbody = $(isOo ? "hp-result-tbody-oo" : "hp-result-tbody");
const summary = $(isOo ? "hp-summary-oo" : "hp-summary");
try {
let body;
if (isOo) {
if (!state.legA || !state.legB) throw new Error("请选用两条期权腿");
const target = Number(($("hp-target") && $("hp-target").value) || 0);
if (!target) throw new Error("请填写目标价");
body = {
plan_type: "options_options",
target_price: target,
index_px: (state.chain && state.chain.index_px) || target,
leg_a: legPayload(state.legA, 1),
leg_b: legPayload(state.legB, 1),
};
} else {
if (!state.selected) throw new Error("请选用期权腿");
const entry = Number(($("hp-entry") && $("hp-entry").value) || 0);
const tp = Number(($("hp-tp") && $("hp-tp").value) || 0);
const sl = Number(($("hp-sl") && $("hp-sl").value) || 0);
const contracts = Number(($("hp-contracts") && $("hp-contracts").value) || 0);
const sheets = Number(($("hp-sheets") && $("hp-sheets").value) || 1);
if (!entry || !tp || !sl || !contracts) throw new Error("请完整填写开仓/止盈/止损/张数");
body = {
plan_type: "perp_options",
direction: ($("hp-direction") && $("hp-direction").value) || "long",
entry: entry,
tp: tp,
sl: sl,
contracts: contracts,
contract_size: (state.market && state.market.contract_size) || 0.01,
opt_type: state.selected.opt_type,
strike: state.selected.strike,
sheets: sheets,
ct_mult: state.selected.ct_mult || 0.01,
ask: state.selected.ask,
index_px: state.chain && state.chain.index_px,
};
}
const d = await apiJson("/api/hedge-plan/preview", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
setGateLine(d.gates);
const s = d.summary || {};
if (summary) {
if (d.plan_type === "perp_options") {
summary.innerHTML =
"止盈合计 <strong>" +
fmt(s.tp_total) +
"</strong> · 止损合计 <strong>" +
fmt(s.sl_total) +
"</strong> · 保费 " +
fmt(s.premium_paid) +
(s.hedge_ratio_at_sl != null ? " · 止损对冲率 " + fmt(s.hedge_ratio_at_sl) + "%" : "");
} else {
summary.innerHTML =
"目标价合计 <strong>" +
fmt(s.at_target_total) +
"</strong> · 到期现价 <strong>" +
fmt(s.expiry_flat_total) +
"</strong> · 保费 " +
fmt(s.premium_paid) +
(s.expiry_is_loss ? " · 到期无盈利(记总亏损)" : "");
}
}
if (!tbody) return;
tbody.innerHTML = "";
(d.scenarios || []).forEach(function (sc) {
const tr = document.createElement("tr");
let mid;
if (sc.perp_pnl != null) {
mid = "永续 " + fmt(sc.perp_pnl);
} else {
mid = "A " + fmt(sc.leg_a_pnl) + " / B " + fmt(sc.leg_b_pnl);
}
const optCol = sc.options_pnl != null ? fmt(sc.options_pnl) : "—";
tr.innerHTML =
"<td>" +
(sc.label || sc.id) +
"</td><td>" +
fmt(sc.spot) +
"</td><td>" +
mid +
"</td><td>" +
optCol +
"</td><td><strong>" +
fmt(sc.total) +
"</strong></td><td class=\"muted\">" +
(sc.note || "") +
"</td>";
tbody.appendChild(tr);
});
} catch (e) {
if (tbody) tbody.innerHTML = '<tr><td colspan="6" class="err">' + (e.message || e) + "</td></tr>";
if (summary) summary.textContent = "";
}
}
function bind() {
document.querySelectorAll(".hp-tab").forEach(function (b) {
b.addEventListener("click", function () {
state.tab = b.getAttribute("data-tab") || "perp_options";
syncTabUI();
if (state.tab === "perp_options" || state.tab === "options_options") {
void loadGates();
} else {
const el = $("hp-gate-line");
if (el) {
el.textContent =
state.tab === "history"
? "历史记录:独立对冲表,与普通交易记录分离"
: "统计:止盈=盈利−保费;止损=期权盈利−永续亏损";
}
}
});
});
document.querySelectorAll(".hp-uly-btn, .hp-uly-btn-oo").forEach(function (b) {
b.addEventListener("click", function () {
setUnderlying(b.getAttribute("data-uly") || "ETH", true);
});
});
document.querySelectorAll(".hp-money-btn").forEach(function (b) {
b.addEventListener("click", function () {
state.moneyFilter = b.getAttribute("data-money") || "all";
syncMoneyUI();
renderListStrikes();
});
});
const dir = $("hp-direction");
if (dir) {
dir.addEventListener("change", function () {
state.selected = null;
if ($("hp-sel-inst")) $("hp-sel-inst").textContent = "—";
void loadMarket().then(function () {
renderListStrikes();
});
});
}
if ($("hp-refresh"))
$("hp-refresh").addEventListener("click", function () {
void refreshAll();
});
if ($("hp-load-chain"))
$("hp-load-chain").addEventListener("click", function () {
void loadChain();
});
if ($("hp-oo-load-chain"))
$("hp-oo-load-chain").addEventListener("click", function () {
void loadChain();
});
if ($("hp-exp-select")) $("hp-exp-select").addEventListener("change", renderListStrikes);
if ($("hp-oo-exp-select")) $("hp-oo-exp-select").addEventListener("change", renderTStrikes);
if ($("hp-strike-select")) {
$("hp-strike-select").addEventListener("change", function () {
const inst = $("hp-strike-select").value;
if (!inst || !state.chain) return;
const exp = currentExp("hp-exp-select");
const c = ((exp && exp.contracts) || []).find(function (x) {
return x.inst_id === inst;
});
if (c) pickContract(c);
});
}
if ($("hp-sheets")) $("hp-sheets").addEventListener("input", updatePremiumLine);
if ($("hp-preview-btn"))
$("hp-preview-btn").addEventListener("click", function () {
state.mode = "perp_options";
void runPreview();
});
if ($("hp-preview-btn-oo"))
$("hp-preview-btn-oo").addEventListener("click", function () {
state.mode = "options_options";
void runPreview();
});
}
async function refreshAll() {
if (state.tab === "perp_options" || state.tab === "options_options") {
await loadGates();
}
try {
await loadMarket();
} catch (e) {
const q = $("hp-perp-quote");
if (q) q.textContent = e.message || String(e);
}
try {
await loadChain();
} catch (e) {
const tbody = $("hp-strike-tbody");
if (tbody) tbody.innerHTML = '<tr><td colspan="5" class="err">' + (e.message || e) + "</td></tr>";
}
}
syncTabUI();
syncUnderlyingUI();
syncMoneyUI();
bind();
void refreshAll();
})();