Files
crypto_monitor/lib/common/static/hedge_plan.js
T
2026-07-20 09:36:46 +08:00

1781 lines
62 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;
function flagOn(attr) {
return root.getAttribute(attr) !== "0";
}
const showPerp = flagOn("data-show-perp");
const showOo = flagOn("data-show-oo");
function pickDefaultTab() {
if (showPerp) return "perp_options";
if (showOo) return "options_options";
return "active";
}
const state = {
tab: pickDefaultTab(),
mode: showPerp ? "perp_options" : showOo ? "options_options" : "perp_options",
underlying: root.getAttribute("data-default-underly") || "ETH",
moneyFilter: "all",
chain: null,
selected: null,
legA: null,
legB: null,
market: null,
ooSheetsMode: "same_sheets",
ooBiasSplitBy: "budget",
ooBiasRatio: 0.7,
ooCloseModeEnabled: root.getAttribute("data-oo-close-mode-enabled") !== "0",
ooCloseMode: "close_all",
direction: "long",
tradingUsdc: null,
fundingUsdc: null,
tradeBudgetUsdc: null,
budgetBuffer: 0.95,
previewOk: false,
canStart: false,
previewPlanType: null,
};
function getDirection() {
return (state.direction || "long").toLowerCase() === "short" ? "short" : "long";
}
function syncPoDirUI() {
const dir = getDirection();
document.querySelectorAll(".hp-po-dir").forEach(function (b) {
const on = (b.getAttribute("data-dir") || "") === dir;
b.classList.toggle("is-selected", on);
b.classList.toggle("active", on);
});
}
function setDirection(dir, forceReload) {
const next = (dir || "long").toLowerCase() === "short" ? "short" : "long";
const changed = next !== getDirection();
state.direction = next;
syncPoDirUI();
if (!changed && !forceReload) return;
state.selected = null;
if ($("hp-sel-inst")) $("hp-sel-inst").textContent = "—";
void loadMarket().then(function () {
renderListStrikes();
});
}
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);
// 仅裁小数尾零;整数 tick(BTC=5)时绝不能把 1370 裁成 137
if (decimals > 0) s = s.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 pnlClass(v) {
if (v == null || v === "") return "";
const n = Number(v);
if (Number.isNaN(n)) return "";
return n >= 0 ? "hp-pnl-pos" : "hp-pnl-neg";
}
function fmtPnlHtml(v, digits) {
if (v == null || v === "" || Number.isNaN(Number(v))) return "—";
const cls = pnlClass(v);
return '<strong class="' + cls + '">' + fmt(v, digits) + "</strong>";
}
function fmtRr(v) {
if (v == null || Number.isNaN(Number(v))) return "—";
return fmt(v, 2) + ":1";
}
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() {
let tab = state.tab || pickDefaultTab();
if (tab === "perp_options" && !showPerp) tab = pickDefaultTab();
if (tab === "options_options" && !showOo) tab = pickDefaultTab();
state.tab = tab;
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", "active", "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;
}
const hint = $("hp-acct-hint");
if (hint) {
if (tab === "options_options") {
hint.textContent = "期期双腿→期权账户";
} else if (tab === "perp_options") {
hint.textContent = "永续腿→合约账户 · 期权腿→期权账户";
} else {
hint.textContent = "进行中/历史含永期与期期;显示开关只影响新建测算 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(" · ");
state.canStart = !!gates.can_start;
syncPreviewStartBtn();
}
function syncPreviewStartBtn() {
const start = $("hp-preview-start");
if (!start) return;
start.disabled = !(state.previewOk && state.canStart);
}
function openPreviewModal() {
const modal = $("hp-preview-modal");
if (modal) modal.hidden = false;
}
function closePreviewModal() {
const modal = $("hp-preview-modal");
if (modal) modal.hidden = true;
}
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;
if (acct.trading_usdc != null && acct.trading_usdc !== "") {
state.tradingUsdc = Number(acct.trading_usdc);
}
if (acct.funding_usdc != null && acct.funding_usdc !== "") {
state.fundingUsdc = Number(acct.funding_usdc);
}
if (chain && chain.trade_budget_usdc != null && chain.trade_budget_usdc !== "") {
state.tradeBudgetUsdc = Number(chain.trade_budget_usdc);
}
if (chain && chain.budget_buffer != null && chain.budget_buffer !== "") {
const buf = Number(chain.budget_buffer);
if (!Number.isNaN(buf) && buf > 0) state.budgetBuffer = buf;
}
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 fundEl = $("hp-oo-funding-usdc");
const tradeEl = $("hp-oo-trading-usdc");
if (fundEl) fundEl.textContent = fmt(acct.funding_usdc, 2);
if (tradeEl) tradeEl.textContent = fmt(acct.trading_usdc, 2);
autoFillOoSheets();
}
function setOoXferMsg(text, kind) {
const el = $("hp-oo-xfer-msg");
if (!el) return;
el.textContent = text || "";
el.classList.toggle("is-err", kind === "err");
el.classList.toggle("is-ok", kind === "ok");
el.classList.toggle("muted", !kind);
}
function ooXferDir() {
const sel = $("hp-oo-xfer-dir");
return (sel && sel.value) || "funding_to_trading";
}
async function submitOoUsdcTransfer(amount) {
const dir = ooXferDir();
const from = dir === "trading_to_funding" ? "trading" : "funding";
const to = dir === "trading_to_funding" ? "funding" : "trading";
setOoXferMsg("划转中…", null);
try {
const d = await apiJson("/api/options/transfer", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ccy: "USDC", from: from, to: to, amount: amount }),
});
if (!d.ok) {
setOoXferMsg(d.msg || "划转失败", "err");
return;
}
setOoXferMsg("划转成功", "ok");
if ($("hp-oo-xfer-amount")) $("hp-oo-xfer-amount").value = "";
await loadChain();
} catch (e) {
setOoXferMsg(e.message || "划转失败", "err");
}
}
function resolveOoBudget() {
const buf = state.budgetBuffer > 0 ? state.budgetBuffer : 0.95;
const trading = state.tradingUsdc;
const cap = state.tradeBudgetUsdc;
let tradingCap = null;
let tradeCap = null;
if (trading != null && !Number.isNaN(Number(trading))) {
tradingCap = Math.max(0, Number(trading) * buf);
}
if (cap != null && !Number.isNaN(Number(cap))) {
tradeCap = Math.max(0, Number(cap));
}
if (tradingCap == null && tradeCap == null) {
return { ok: false, budget: 0, tradingCap: null, tradeCap: null, buf: buf, msg: "缺少交易户余额与单笔预算" };
}
let budget = 0;
if (tradingCap == null) budget = tradeCap;
else if (tradeCap == null) budget = tradingCap;
else budget = Math.min(tradingCap, tradeCap);
budget = Math.floor(budget * 1e6 + 1e-12) / 1e6;
return {
ok: budget > 0,
budget: budget,
tradingCap: tradingCap,
tradeCap: tradeCap,
buf: buf,
msg: budget > 0 ? "" : "可用预算为 0",
};
}
function unitCost(c) {
if (!c) return 0;
const ask = Number(c.ask || 0);
if (!(ask > 0)) return 0;
return ask * Number(c.ct_mult || 0.01);
}
function capByDepth(n, askSz) {
let out = Math.max(0, Math.floor(Number(n) || 0));
if (askSz == null || askSz === "") return out;
const d = Number(askSz);
if (Number.isNaN(d)) return out;
if (d <= 0) return 0;
return Math.min(out, Math.floor(d + 1e-12));
}
function normalizeOptCP(ot) {
const u = String(ot || "").toUpperCase();
if (u.indexOf("C") === 0) return "C";
if (u.indexOf("P") === 0) return "P";
return "";
}
function ooSizeModeLabel(mode) {
if (mode === "long_bias") return "做多";
if (mode === "short_bias") return "做空";
if (mode === "split_budget") return "均分";
return "同张数";
}
function suggestOoSheetsLocal(budget, mode) {
const costA = unitCost(state.legA);
const costB = unitCost(state.legB);
if (!(budget > 0)) {
return { sheetsA: 0, sheetsB: 0, premium: 0, ok: false, msg: "可用预算为 0" };
}
if (!(costA > 0) || !(costB > 0)) {
return { sheetsA: 0, sheetsB: 0, premium: 0, ok: false, msg: "缺少有效卖一价,无法建议张数" };
}
let ratio = Number(state.ooBiasRatio);
if (!(ratio > 0) || !(ratio < 1)) ratio = 0.7;
const splitBy = state.ooBiasSplitBy === "sheets" ? "sheets" : "budget";
const pair = costA + costB;
const nPair = pair > 0 ? Math.floor(budget / pair + 1e-12) : 0;
const nSame = Math.min(
capByDepth(nPair, state.legA && state.legA.ask_sz),
capByDepth(nPair, state.legB && state.legB.ask_sz)
);
let nA = 0;
let nB = 0;
if (mode === "long_bias" || mode === "short_bias") {
const aCP = normalizeOptCP(state.legA && state.legA.opt_type);
const bCP = normalizeOptCP(state.legB && state.legB.opt_type);
if (!((aCP === "C" && bCP === "P") || (aCP === "P" && bCP === "C"))) {
return { sheetsA: 0, sheetsB: 0, premium: 0, ok: false, msg: "做多/做空需一腿 Call、一腿 Put" };
}
const callIsA = aCP === "C";
const majorIsCall = mode === "long_bias";
let nCall = 0;
let nPut = 0;
if (splitBy === "sheets") {
// 总张数 = 同张数两侧合计(每腿 n → 共 2n),再按比例拆
const total = nSame * 2;
if (total < 2) {
return { sheetsA: 0, sheetsB: 0, premium: 0, ok: false, msg: "同张数总规模不足 2,无法按比例拆分" };
}
let majorN = Math.round(total * ratio);
majorN = Math.max(1, Math.min(majorN, total - 1));
const minorN = total - majorN;
nCall = majorIsCall ? majorN : minorN;
nPut = majorIsCall ? minorN : majorN;
} else {
const majBudget = budget * ratio;
const minBudget = budget * (1 - ratio);
const costCall = callIsA ? costA : costB;
const costPut = callIsA ? costB : costA;
if (majorIsCall) {
nCall = Math.floor(majBudget / costCall + 1e-12);
nPut = Math.floor(minBudget / costPut + 1e-12);
} else {
nPut = Math.floor(majBudget / costPut + 1e-12);
nCall = Math.floor(minBudget / costCall + 1e-12);
}
}
nA = callIsA ? nCall : nPut;
nB = callIsA ? nPut : nCall;
nA = capByDepth(nA, state.legA && state.legA.ask_sz);
nB = capByDepth(nB, state.legB && state.legB.ask_sz);
} else if (mode === "split_budget") {
const half = budget / 2;
nA = Math.floor(half / costA + 1e-12);
nB = Math.floor(half / costB + 1e-12);
nA = capByDepth(nA, state.legA && state.legA.ask_sz);
nB = capByDepth(nB, state.legB && state.legB.ask_sz);
} else {
nA = nSame;
nB = nSame;
}
const premium = costA * nA + costB * nB;
const ok = nA >= 1 && nB >= 1;
return {
sheetsA: nA,
sheetsB: nB,
premium: premium,
ok: ok,
msg: ok ? "" : "预算不够开 1+1(或卖一深度不足)",
};
}
function syncOoSizeModeUI() {
document.querySelectorAll(".hp-oo-size-mode").forEach(function (b) {
const on = b.getAttribute("data-oo-size") === state.ooSheetsMode;
b.classList.toggle("active", on);
b.classList.toggle("is-selected", on);
b.setAttribute("aria-pressed", on ? "true" : "false");
});
}
function syncOoCloseModeUI() {
const enabled = !!state.ooCloseModeEnabled;
const row = $("hp-oo-close-mode-row");
if (row) row.classList.toggle("hidden", !enabled);
if (!enabled) state.ooCloseMode = "hold_expiry";
document.querySelectorAll(".hp-oo-close-mode").forEach(function (b) {
const on = b.getAttribute("data-oo-close") === state.ooCloseMode;
b.classList.toggle("active", on);
b.classList.toggle("is-selected", on);
b.setAttribute("aria-pressed", on ? "true" : "false");
});
}
function updateOoBudgetLine(extra) {
const line = $("hp-oo-budget-line");
if (!line) return;
const b = resolveOoBudget();
const sizeLabel = ooSizeModeLabel(state.ooSheetsMode);
const closeLabel = !state.ooCloseModeEnabled
? ""
: state.ooCloseMode === "hold_expiry"
? "到期平"
: "全平";
const parts = ["可用 " + fmt(b.budget, 2) + "U", sizeLabel];
if (closeLabel) parts.push(closeLabel);
if (extra && extra.msg) parts.push(extra.msg);
else if (b.msg) parts.push(b.msg);
line.textContent = parts.join(" · ");
line.title =
"预算=min(交易×" +
fmt(b.buf, 2) +
", 单笔)" +
(b.tradingCap != null ? " · 交易×缓冲 " + fmt(b.tradingCap, 2) : "") +
(b.tradeCap != null ? " · 单笔 " + fmt(b.tradeCap, 2) : "");
line.classList.toggle("hp-oo-budget-warn", !!(extra && extra.msg) || !b.ok);
}
function autoFillOoSheets() {
syncOoSizeModeUI();
if (!state.legA || !state.legB) {
updateOoBudgetLine(null);
return;
}
const b = resolveOoBudget();
const sug = suggestOoSheetsLocal(b.budget, state.ooSheetsMode);
const a = $("hp-oo-sheets-a");
const bb = $("hp-oo-sheets-b");
if (a && !a.disabled) a.value = String(sug.sheetsA);
if (bb && !bb.disabled) bb.value = String(sug.sheetsB);
updateOoBudgetLine(sug.ok ? null : sug);
updateOoPremiumLine();
}
async function loadGates() {
try {
const d = await apiJson("/api/hedge-plan/gates?plan_type=" + encodeURIComponent(state.mode));
if (d.oo_close_mode_enabled != null) {
state.ooCloseModeEnabled = !!d.oo_close_mode_enabled;
}
if (!state.ooCloseModeEnabled) {
state.ooCloseMode = "hold_expiry";
} else if (d.oo_close_mode_default && !state._ooCloseModeTouched) {
state.ooCloseMode = d.oo_close_mode_default === "hold_expiry" ? "hold_expiry" : "close_all";
}
if (d.oo_bias_split_by != null) {
state.ooBiasSplitBy = d.oo_bias_split_by === "sheets" ? "sheets" : "budget";
}
if (d.oo_bias_ratio != null && Number(d.oo_bias_ratio) > 0 && Number(d.oo_bias_ratio) < 1) {
state.ooBiasRatio = Number(d.oo_bias_ratio);
}
syncOoCloseModeUI();
setGateLine(d);
if (state.mode === "options_options") autoFillOoSheets();
} catch (e) {
setGateLine({ can_preview: false, can_start: false, reasons: [e.message], is_full_margin: false });
}
}
async function loadMarket() {
const dir = getDirection();
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 amtPrec = d.amount_precision != null ? Number(d.amount_precision) : 4;
const markEl = $("hp-po-mark");
if (markEl) markEl.textContent = "标记 " + fmt(d.mark, 2);
const q = $("hp-perp-quote");
if (q) {
q.innerHTML =
"可用 <strong>" +
fmt(d.available_usdt, 2) +
"</strong> USDT · 卖一 " +
fmt(d.ask, 2) +
" · 买一 " +
fmt(d.bid, 2) +
" · 面值 " +
fmt(d.contract_size, 4) +
" · 精度 " +
amtPrec +
" 位";
}
const contractsInput = $("hp-contracts");
if (contractsInput) {
const step = amtPrec <= 0 ? "1" : String(Math.pow(10, -amtPrec));
contractsInput.step = step;
}
const sz = $("hp-sizing-line");
if (sz) {
if (d.full_margin_sizing) {
const s = d.full_margin_sizing;
sz.innerHTML =
"全仓建议 <strong>" +
fmt(d.suggest_contracts, amtPrec) +
"</strong> 张 · 保证金 " +
fmt(s.margin_capital, 2) +
" × " +
s.leverage +
"x · 名义 " +
fmt(s.notional_value, 2) +
" USDT";
} else {
sz.textContent = "非全仓时请手动填张数;永期开仓需全仓";
}
}
const entry = $("hp-entry");
if (entry && d.entry_ref && !entry.value) entry.value = d.entry_ref;
if (contractsInput && d.suggest_contracts != null && !contractsInput.value) {
contractsInput.value = fmt(d.suggest_contracts, amtPrec);
}
const label = $("hp-opt-type-label");
if (label) label.textContent = d.suggested_opt_type === "C" ? "Call" : "Put";
updatePerpPnlHint();
}
function updatePerpPnlHint() {
const el = $("hp-perp-pnl-line");
if (!el) return;
const entry = Number(($("hp-entry") && $("hp-entry").value) || NaN);
const tp = Number(($("hp-tp") && $("hp-tp").value) || NaN);
const sl = Number(($("hp-sl") && $("hp-sl").value) || NaN);
const contracts = Number(($("hp-contracts") && $("hp-contracts").value) || NaN);
const cs = Number((state.market && state.market.contract_size) || 0.01);
const dir = getDirection();
if (!(entry > 0) || !(contracts > 0) || !(cs > 0)) {
el.innerHTML = '<span class="muted">填开仓价与张数后,输入止盈/止损可看盈亏</span>';
return;
}
function pnlAt(exitPx) {
const coins = contracts * cs;
if (dir === "short") return (entry - exitPx) * coins;
return (exitPx - entry) * coins;
}
function chip(lab, px) {
if (!(px > 0)) {
return '<span class="hp-po-chip muted">' + lab + " —</span>";
}
const p = pnlAt(px);
const cls = p >= 0 ? "hp-pnl-pos" : "hp-pnl-neg";
const sign = p >= 0 ? "+" : "";
return (
'<span class="hp-po-chip">' +
lab +
' <strong class="' +
cls +
'">' +
sign +
fmt(p, 2) +
"</strong></span>"
);
}
el.innerHTML = chip("止盈", tp) + chip("止损", sl);
}
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 idx = $("hp-index-line");
if (idx) idx.textContent = "指数 " + fmt(d.index_px, 2);
const ooIdx = $("hp-oo-index");
if (ooIdx) ooIdx.textContent = "指数 " + fmt(d.index_px, 2);
setOptionsBalance(d);
fillExpSelect($("hp-exp-select"), d);
fillExpSelect($("hp-oo-exp-select"), d);
renderListStrikes();
renderTStrikes();
if (d.index_px) {
const idx = Number(d.index_px);
if ($("hp-target-up") && !$("hp-target-up").value) {
$("hp-target-up").value = String(Math.round(idx * 1.03));
}
if ($("hp-target-down") && !$("hp-target-down").value) {
$("hp-target-down").value = String(Math.round(idx * 0.97));
}
}
}
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) {
if (!c) return;
state.selected = c;
const el = $("hp-sel-inst");
if (el) el.textContent = 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 renderListStrikes() {
const tbody = $("hp-strike-tbody");
if (!tbody) return;
const want = optTypeForDirection(getDirection());
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>';
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>';
return;
}
list.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);
});
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);
});
});
}
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() {
function fill(tag, c, infoId, sheetsId) {
const info = $(infoId);
const sheets = $(sheetsId);
if (!info) return;
if (!c) {
info.textContent = tag + ": 尚未选用";
if (sheets) {
sheets.disabled = true;
sheets.value = "1";
}
return;
}
info.innerHTML =
tag +
": <strong>" +
c.opt_type +
" K" +
c.strike +
"</strong> " +
(c.moneyness_label || "") +
" · 卖一 " +
fmtPxSz(c.ask, c.ask_sz, c.ask_estimated, c.tick_sz) +
" <code>" +
c.inst_id +
"</code>";
if (sheets) sheets.disabled = false;
}
fill("腿A", state.legA, "hp-oo-leg-a-info", "hp-oo-sheets-a");
fill("腿B", state.legB, "hp-oo-leg-b-info", "hp-oo-sheets-b");
autoFillOoSheets();
}
function ooSheets(id) {
const n = Number(($(id) && $(id).value) || 1);
return n > 0 ? n : 1;
}
function updateOoPremiumLine() {
const line = $("hp-oo-prem-line");
if (!line) return;
if (!state.legA && !state.legB) {
line.textContent = "";
return;
}
function prem(c, sheets) {
if (!c) return 0;
return Number(c.ask || 0) * sheets * Number(c.ct_mult || 0.01);
}
const a = prem(state.legA, ooSheets("hp-oo-sheets-a"));
const b = prem(state.legB, ooSheets("hp-oo-sheets-b"));
line.textContent =
"预估权利金 A " +
fmt(a, 4) +
" + B " +
fmt(b, 4) +
" ≈ " +
fmt(a + b, 4) +
" USDC";
}
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-up")) $("hp-target-up").value = "";
if ($("hp-target-down")) $("hp-target-down").value = "";
if ($("hp-sel-inst")) $("hp-sel-inst").textContent = "—";
if ($("hp-premium-line")) $("hp-premium-line").textContent = "";
if ($("hp-oo-sheets-a")) {
$("hp-oo-sheets-a").value = "1";
$("hp-oo-sheets-a").disabled = true;
}
if ($("hp-oo-sheets-b")) {
$("hp-oo-sheets-b").value = "1";
$("hp-oo-sheets-b").disabled = true;
}
renderOoLegs();
void refreshAll();
}
async function runPreview() {
const isOo = state.mode === "options_options";
const tbody = $("hp-result-tbody");
const summary = $("hp-preview-summary");
const midTh = $("hp-preview-mid-th");
const title = $("hp-preview-title");
state.previewOk = false;
state.previewPlanType = isOo ? "options_options" : "perp_options";
syncPreviewStartBtn();
if (midTh) midTh.textContent = isOo ? "腿盈亏" : "永续/腿盈亏";
if (title) title.textContent = isOo ? "情景测算 · 期期" : "情景测算 · 永期";
if (summary) summary.textContent = "";
if (tbody) tbody.innerHTML = '<tr><td colspan="6" class="muted">计算中…</td></tr>';
openPreviewModal();
try {
let body;
if (isOo) {
if (!state.legA || !state.legB) throw new Error("请选用两条期权腿");
const up = Number(($("hp-target-up") && $("hp-target-up").value) || 0);
const down = Number(($("hp-target-down") && $("hp-target-down").value) || 0);
if (!up || !down) throw new Error("请填写上破与下破目标价");
if (up <= down) throw new Error("上破目标价必须大于下破目标价");
body = {
plan_type: "options_options",
target_price_up: up,
target_price_down: down,
target_price: up,
index_px: (state.chain && state.chain.index_px) || (up + down) / 2,
leg_a: legPayload(state.legA, ooSheets("hp-oo-sheets-a")),
leg_b: legPayload(state.legB, ooSheets("hp-oo-sheets-b")),
};
} 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: getDirection(),
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 =
"止盈合计 " +
fmtPnlHtml(s.tp_total) +
" · 止损合计 " +
fmtPnlHtml(s.sl_total) +
" · 保费 " +
fmt(s.premium_paid) +
(s.hedge_ratio_at_sl != null ? " · 止损对冲率 " + fmt(s.hedge_ratio_at_sl) + "%" : "");
} else {
const upTot = s.at_target_up_total != null ? s.at_target_up_total : s.at_target_total;
const dnTot = s.at_target_down_total;
let rrLine = "";
if (s.rr_at_up != null || s.rr_at_down != null) {
rrLine =
" · 盈亏比 上破 " +
fmtRr(s.rr_at_up) +
(dnTot != null ? " / 下破 " + fmtRr(s.rr_at_down) : "") +
'<span class="muted">(亏=全额保费 ' +
fmt(s.rr_risk_premium != null ? s.rr_risk_premium : s.premium_paid) +
"</span>";
}
summary.innerHTML =
"上破 " +
fmtPnlHtml(upTot) +
(dnTot != null ? " · 下破 " + fmtPnlHtml(dnTot) : "") +
" · 到期现价 " +
fmtPnlHtml(s.expiry_flat_total) +
" · 保费 " +
fmt(s.premium_paid) +
rrLine +
(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 = "永续 " + fmtPnlHtml(sc.perp_pnl);
} else {
mid = "A " + fmtPnlHtml(sc.leg_a_pnl) + " / B " + fmtPnlHtml(sc.leg_b_pnl);
}
const optCol = sc.options_pnl != null ? fmtPnlHtml(sc.options_pnl) : "—";
tr.innerHTML =
"<td>" +
(sc.label || sc.id) +
"</td><td>" +
fmt(sc.spot) +
"</td><td>" +
mid +
"</td><td>" +
optCol +
"</td><td>" +
fmtPnlHtml(sc.total) +
"</td><td class=\"muted\">" +
(sc.note || "") +
"</td>";
tbody.appendChild(tr);
});
state.previewOk = true;
syncPreviewStartBtn();
} catch (e) {
state.previewOk = false;
syncPreviewStartBtn();
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 () {
const next = b.getAttribute("data-tab") || pickDefaultTab();
if (next === "perp_options" && !showPerp) return;
if (next === "options_options" && !showOo) return;
state.tab = next;
syncTabUI();
if (state.tab === "perp_options" || state.tab === "options_options") {
void loadGates();
} else if (state.tab === "active") {
void loadActivePlans();
} else if (state.tab === "history") {
void loadHistory();
} else if (state.tab === "stats") {
void loadStats();
} else {
const el = $("hp-gate-line");
if (el) el.textContent = "";
}
});
});
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();
});
});
document.querySelectorAll(".hp-po-dir").forEach(function (b) {
b.addEventListener("click", function () {
setDirection(b.getAttribute("data-dir") || "long", true);
});
});
["hp-entry", "hp-tp", "hp-sl", "hp-contracts"].forEach(function (id) {
const el = $(id);
if (el) el.addEventListener("input", updatePerpPnlHint);
});
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-sheets")) $("hp-sheets").addEventListener("input", updatePremiumLine);
["hp-oo-sheets-a", "hp-oo-sheets-b"].forEach(function (id) {
const el = $(id);
if (el) el.addEventListener("input", updateOoPremiumLine);
});
document.querySelectorAll(".hp-oo-size-mode").forEach(function (b) {
b.addEventListener("click", function () {
state.ooSheetsMode = b.getAttribute("data-oo-size") || "same_sheets";
syncOoSizeModeUI();
autoFillOoSheets();
updateOoBudgetLine(null);
});
});
document.querySelectorAll(".hp-oo-close-mode").forEach(function (b) {
b.addEventListener("click", function () {
if (!state.ooCloseModeEnabled) return;
state.ooCloseMode = b.getAttribute("data-oo-close") || "close_all";
state._ooCloseModeTouched = true;
syncOoCloseModeUI();
updateOoBudgetLine(null);
});
});
if ($("hp-oo-xfer-btn")) {
$("hp-oo-xfer-btn").addEventListener("click", function () {
const amount = Number(($("hp-oo-xfer-amount") && $("hp-oo-xfer-amount").value) || 0);
if (!(amount > 0)) {
setOoXferMsg("请输入有效数量", "err");
return;
}
void submitOoUsdcTransfer(amount);
});
}
if ($("hp-oo-xfer-all")) {
$("hp-oo-xfer-all").addEventListener("click", function () {
const dir = ooXferDir();
const max =
dir === "trading_to_funding" ? Number(state.tradingUsdc || 0) : Number(state.fundingUsdc || 0);
if (!(max > 0)) {
setOoXferMsg("划出账户可用余额不足", "err");
return;
}
const amt = Math.floor(max * 100) / 100;
const label = dir === "trading_to_funding" ? "交易 → 资金" : "资金 → 交易";
if (!window.confirm("确认全部划转?\n方向:" + label + "\n数量:" + amt + " USDC")) return;
if ($("hp-oo-xfer-amount")) $("hp-oo-xfer-amount").value = String(amt);
void submitOoUsdcTransfer(amt);
});
}
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();
});
if ($("hp-preview-cancel")) $("hp-preview-cancel").addEventListener("click", closePreviewModal);
if ($("hp-preview-cancel-x")) $("hp-preview-cancel-x").addEventListener("click", closePreviewModal);
if ($("hp-preview-start"))
$("hp-preview-start").addEventListener("click", function () {
const planType = state.previewPlanType || state.mode;
void startPlan(planType, true);
});
const previewModal = $("hp-preview-modal");
if (previewModal) {
previewModal.addEventListener("click", function (ev) {
if (ev.target === previewModal) closePreviewModal();
});
}
if ($("hp-detail-close")) $("hp-detail-close").addEventListener("click", closeModal);
const modal = $("hp-detail-modal");
if (modal) {
modal.addEventListener("click", function (ev) {
if (ev.target === modal) closeModal();
});
}
}
async function loadHistory() {
const tbody = $("hp-history-tbody");
if (!tbody) return;
try {
const d = await apiJson("/api/hedge-plan/history");
const rows = d.plans || [];
if (!rows.length) {
tbody.innerHTML = '<tr><td colspan="10" class="muted">暂无已结束计划</td></tr>';
return;
}
tbody.innerHTML = "";
rows.forEach(function (p) {
const tr = document.createElement("tr");
const typeLabel = p.plan_type === "perp_options" ? "永期" : "期期";
const contracts = p.contracts_summary || "—";
const pnl = p.realized_pnl_total;
const pnlCls =
pnl == null || Number.isNaN(Number(pnl)) ? "" : Number(pnl) >= 0 ? "hp-pnl-pos" : "hp-pnl-neg";
tr.innerHTML =
"<td>#" +
p.id +
"</td><td>" +
typeLabel +
"</td><td>" +
(p.underlying || "") +
"</td><td class=\"hp-contracts-cell\" title=\"" +
String(contracts).replace(/"/g, "&quot;") +
"\"><code>" +
contracts +
"</code></td><td>" +
(p.status || "") +
'</td><td class="' +
pnlCls +
'">' +
fmt(pnl) +
"</td><td>" +
reasonLabel(p.close_reason) +
"</td><td>" +
(p.opened_at || "—") +
"</td><td>" +
(p.closed_at || "—") +
'</td><td class="hp-hist-actions">' +
'<button type="button" class="btn-secondary hp-btn-detail" data-id="' +
p.id +
'">成交细节</button> ' +
'<button type="button" class="btn-secondary hp-btn-del" data-id="' +
p.id +
'">删除</button>' +
"</td>";
tbody.appendChild(tr);
});
tbody.querySelectorAll(".hp-btn-detail").forEach(function (btn) {
btn.addEventListener("click", function () {
void showPlanDetail(Number(btn.getAttribute("data-id")));
});
});
tbody.querySelectorAll(".hp-btn-del").forEach(function (btn) {
btn.addEventListener("click", function () {
void deletePlan(Number(btn.getAttribute("data-id")));
});
});
} catch (e) {
tbody.innerHTML = '<tr><td colspan="10" class="err">' + (e.message || e) + "</td></tr>";
}
}
function activeTargetLabel(p) {
if (p.plan_type === "perp_options") {
return "止盈 " + fmt(p.tp) + " · 止损 " + fmt(p.sl);
}
return "上破 " + fmt(p.target_price_up || p.target_price) + " · 下破 " + fmt(p.target_price_down || p.target_price);
}
function activeStatusLabel(p) {
if ((p.status || "") === "partial") {
return '<span class="hp-plan-partial">半腿待补</span>';
}
if ((p.status || "") === "opening") {
return '<span class="hp-plan-active">开仓中</span>';
}
return '<span class="hp-plan-active">进行中</span>';
}
function completeLegButtonHtml(p) {
if ((p.status || "") !== "partial") return "";
const role = p.missing_leg || "";
let label = "";
if (role === "perp") label = "补开永续";
else if (role === "option_b") label = "补开腿B";
else if (role === "option_hedge" || role === "option_a") label = "补开期权";
else return "";
return (
'<button type="button" class="primary hp-btn-complete" data-id="' +
p.id +
'" data-role="' +
role +
'">' +
label +
"</button> "
);
}
async function completeMissingLeg(planId) {
if (!window.confirm("确认补开缺失腿并真实下单?")) return;
try {
const d = await apiJson("/api/hedge-plan/" + planId + "/complete-leg", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
alert("补开成功 #" + (d.plan_id || planId) + " · 已完全成交并进入进行中");
void loadActivePlans();
void loadGates();
} catch (e) {
alert(e.message || String(e));
}
}
async function endActivePlan(planId) {
if (
!window.confirm(
"确认结束计划 #" +
planId +
"?\n不会自动平仓;未成交/待补腿将标为未成交取消。\n已有持仓请自行平掉。"
)
) {
return;
}
try {
const d = await apiJson("/api/hedge-plan/" + planId + "/end", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
alert(d.msg || "计划已结束 #" + (d.plan_id || planId));
void loadActivePlans();
void loadHistory();
void loadGates();
} catch (e) {
alert(e.message || String(e));
}
}
function legStatusLabel(st) {
const map = {
open: "持仓中",
pending: "待补/未成交",
cancelled: "未成交取消",
canceled: "未成交取消",
closed: "已平仓",
};
return map[st] || st || "—";
}
async function loadActivePlans() {
const tbody = $("hp-active-tbody");
if (!tbody) return;
try {
const d = await apiJson("/api/hedge-plan/active");
const rows = d.plans || [];
if (!rows.length) {
tbody.innerHTML = '<tr><td colspan="8" class="muted">暂无进行中的计划</td></tr>';
return;
}
tbody.innerHTML = "";
rows.forEach(function (p) {
const tr = document.createElement("tr");
const typeLabel = p.plan_type === "perp_options" ? "永期" : "期期";
const contracts = p.contracts_summary || "—";
tr.innerHTML =
"<td>#" +
p.id +
"</td><td>" +
typeLabel +
"</td><td>" +
(p.underlying || "") +
"</td><td class=\"hp-contracts-cell\" title=\"" +
String(contracts).replace(/"/g, "&quot;") +
"\"><code>" +
contracts +
"</code></td><td>" +
activeStatusLabel(p) +
"</td><td>" +
activeTargetLabel(p) +
"</td><td>" +
(p.opened_at || "—") +
'</td><td class="hp-hist-actions">' +
completeLegButtonHtml(p) +
'<button type="button" class="btn-secondary hp-btn-detail" data-id="' +
p.id +
'">成交细节</button> ' +
'<button type="button" class="btn-secondary hp-btn-end" data-id="' +
p.id +
'">结束计划</button></td>';
tbody.appendChild(tr);
});
tbody.querySelectorAll(".hp-btn-detail").forEach(function (btn) {
btn.addEventListener("click", function () {
void showPlanDetail(Number(btn.getAttribute("data-id")));
});
});
tbody.querySelectorAll(".hp-btn-complete").forEach(function (btn) {
btn.addEventListener("click", function () {
void completeMissingLeg(Number(btn.getAttribute("data-id")));
});
});
tbody.querySelectorAll(".hp-btn-end").forEach(function (btn) {
btn.addEventListener("click", function () {
void endActivePlan(Number(btn.getAttribute("data-id")));
});
});
} catch (e) {
tbody.innerHTML = '<tr><td colspan="8" class="err">' + (e.message || e) + "</td></tr>";
}
}
function reasonLabel(r) {
const map = {
perp_tp: "永续止盈",
perp_sl: "永续止损",
oo_expiry_loss: "期期到期亏损",
oo_expiry_win: "期期到期盈利",
target_win_leg: "期期平盈利腿",
target_up_win_leg: "期期上破·平盈利腿",
target_down_win_leg: "期期下破·平盈利腿",
oo_rest_closing: "期期全平·清残腿中",
oo_rest_closed: "期期全平·两腿已平",
orphaned_after_tp: "止盈后持有至到期",
orphaned_option_expiry: "残腿到期",
hold_to_expiry: "持有至到期",
expiry: "到期",
manual: "人工结束",
manual_end: "人工结束",
partial_fail: "半腿失败",
cancelled: "已取消",
unfilled: "未成交",
};
return map[r] || r || "—";
}
function roleLabel(role) {
const map = {
perp: "永续腿",
option_hedge: "保险期权",
option_a: "期期腿A",
option_b: "期期腿B",
};
return map[role] || role || "—";
}
function closeModal() {
const m = $("hp-detail-modal");
if (m) m.hidden = true;
}
async function showPlanDetail(planId) {
const modal = $("hp-detail-modal");
const body = $("hp-detail-body");
const title = $("hp-detail-title");
if (!modal || !body) return;
modal.hidden = false;
body.innerHTML = '<p class="muted">加载中…</p>';
if (title) title.textContent = "成交细节 #" + planId;
try {
const d = await apiJson("/api/hedge-plan/" + planId);
const p = d.plan || {};
const legs = d.legs || [];
const typeLabel = p.plan_type === "perp_options" ? "永期对冲" : "期期对冲";
let html = "";
html += '<div class="hp-detail-summary">';
html += "<div><span class=\"muted\">类型</span> " + typeLabel + "</div>";
html += "<div><span class=\"muted\">标的</span> " + (p.underlying || "—");
if (p.direction) html += " · " + (p.direction === "long" ? "做多" : "做空");
html += "</div>";
html += "<div><span class=\"muted\">状态</span> " + (p.status || "—") + " / " + reasonLabel(p.close_reason) + "</div>";
html += "<div><span class=\"muted\">时间</span> " + (p.opened_at || "—") + " → " + (p.closed_at || "—") + "</div>";
html +=
"<div><span class=\"muted\">盈亏</span> 永续 " +
fmt(p.realized_pnl_perp) +
" · 期权 " +
fmt(p.realized_pnl_options) +
" · 合计 <strong class=\"" +
(Number(p.realized_pnl_total) >= 0 ? "hp-pnl-pos" : "hp-pnl-neg") +
'">' +
fmt(p.realized_pnl_total) +
"</strong> ≈U</div>";
if (p.plan_type === "perp_options") {
html +=
"<div><span class=\"muted\">参考价</span> 开 " +
fmt(p.entry_mark) +
" · 止盈 " +
fmt(p.tp) +
" · 止损 " +
fmt(p.sl) +
" · 杠杆 " +
fmt(p.leverage, 0) +
"x · 张数 " +
fmt(p.perp_size, 4) +
"</div>";
} else {
html +=
"<div><span class=\"muted\">目标价</span> 上破 " +
fmt(p.target_price_up || p.target_price) +
" · 下破 " +
fmt(p.target_price_down || p.target_price) +
"</div>";
}
html +=
"<div><span class=\"muted\">权利金合计</span> " +
fmt(p.premium_total, 4) +
" USDC</div>";
html +=
"<div><span class=\"muted\">合约摘要</span> <code>" +
(d.contracts_summary || "—") +
"</code></div>";
html += "</div>";
html += '<table class="options-strike-table hp-detail-legs"><thead><tr>';
html +=
"<th>角色</th><th>合约名称</th><th>方向/类型</th><th>数量</th><th>开仓价</th><th>权利金</th><th>状态</th><th>腿盈亏</th><th>成交号</th><th>平仓原因</th>";
html += "</tr></thead><tbody>";
if (!legs.length) {
html += '<tr><td colspan="10" class="muted">无腿记录</td></tr>';
} else {
legs.forEach(function (leg) {
const contract =
leg.leg_role === "perp"
? leg.symbol || "—"
: leg.inst_id || "—";
const side =
leg.leg_role === "perp"
? leg.side || "—"
: (leg.opt_type || "") + (leg.strike != null ? " K" + fmt(leg.strike, 0) : "");
html += "<tr>";
html += "<td>" + roleLabel(leg.leg_role) + "</td>";
html += "<td><code>" + contract + "</code></td>";
html += "<td>" + side + "</td>";
html += "<td>" + fmt(leg.size, leg.leg_role === "perp" ? 4 : 0) + "</td>";
html += "<td>" + fmt(leg.avg_open, 4) + "</td>";
html += "<td>" + (leg.premium != null ? fmt(leg.premium, 4) : "—") + "</td>";
html += "<td>" + legStatusLabel(leg.status) + "</td>";
html += "<td>" + fmt(leg.realized_pnl, 4) + "</td>";
html += "<td><code class=\"hp-ord\">" + (leg.exchange_ord_id || "—") + "</code></td>";
html += "<td>" + reasonLabel(leg.close_reason) + "</td>";
html += "</tr>";
});
}
html += "</tbody></table>";
if (p.note) {
html += '<p class="hp-detail-note"><span class="muted">备注</span> ' + String(p.note) + "</p>";
}
body.innerHTML = html;
} catch (e) {
body.innerHTML = '<p class="err">' + (e.message || e) + "</p>";
}
}
async function deletePlan(planId) {
if (!window.confirm("确认删除历史计划 #" + planId + "?此操作不可恢复。")) return;
try {
await apiJson("/api/hedge-plan/" + planId, { method: "DELETE" });
await loadHistory();
if (state.tab === "stats") await loadStats();
} catch (e) {
window.alert(e.message || String(e));
}
}
function metricCard(title, m) {
if (!m || !m.count) {
return (
'<div class="hp-stats-card"><h3>' +
title +
'</h3><p class="muted">暂无已结束样本</p></div>'
);
}
const wr = m.win_rate == null ? "—" : (Number(m.win_rate) * 100).toFixed(1) + "%";
let pf = "—";
if (m.profit_factor_infinite) pf = "∞";
else if (m.profit_factor != null) pf = fmt(m.profit_factor, 2);
return (
'<div class="hp-stats-card"><h3>' +
title +
"</h3><ul class=\"hp-stats-list\">" +
"<li><span>笔数</span><strong>" +
m.count +
"</strong></li>" +
"<li><span>胜率</span><strong>" +
wr +
"</strong> <span class=\"muted\">(" +
m.wins +
"/" +
m.count +
")</span></li>" +
"<li><span>净盈亏≈U</span><strong class=\"" +
(Number(m.net_pnl) >= 0 ? "hp-pnl-pos" : "hp-pnl-neg") +
'">' +
fmt(m.net_pnl) +
"</strong></li>" +
"<li><span>盈亏比</span><strong>" +
pf +
"</strong> <span class=\"muted\">毛利/|毛亏|</span></li>" +
"<li><span>最大盈利</span><strong class=\"hp-pnl-pos\">" +
fmt(m.max_profit) +
"</strong></li>" +
"<li><span>最大亏损</span><strong class=\"hp-pnl-neg\">" +
fmt(m.max_loss) +
"</strong></li>" +
"<li><span>最大回撤</span><strong>" +
fmt(m.max_drawdown) +
"</strong></li>" +
"<li><span>平均保费</span><strong>" +
fmt(m.avg_premium, 4) +
"</strong></li>" +
"</ul></div>"
);
}
async function loadStats() {
const box = $("hp-stats-box");
if (!box) return;
try {
const d = await apiJson("/api/hedge-plan/stats");
const by = d.by_type || {};
let html = '<div class="hp-stats-grid">';
html +=
'<div class="hp-stats-card hp-stats-card--overview"><h3>总览</h3><p>活跃 <strong>' +
(d.active || 0) +
"</strong> · 已结 <strong>" +
(d.closed_count || 0) +
'</strong> · 合计 <strong class="' +
(Number(d.closed_pnl_total) >= 0 ? "hp-pnl-pos" : "hp-pnl-neg") +
'">' +
fmt(d.closed_pnl_total) +
"</strong> ≈U</p></div>";
html += metricCard("永期对冲", by.perp_options);
html += metricCard("期期对冲", by.options_options);
html += "</div>";
const poB = (by.perp_options && by.perp_options.buckets) || {};
const ooB = (by.options_options && by.options_options.buckets) || {};
if ((poB.tp && poB.tp.count) || (poB.sl && poB.sl.count) || (ooB.expiry_loss && ooB.expiry_loss.count)) {
html += '<div class="hp-stats-grid hp-stats-grid--sub">';
if (poB.tp && poB.tp.count) html += metricCard("永期·止盈桶", poB.tp);
if (poB.sl && poB.sl.count) html += metricCard("永期·止损桶", poB.sl);
if (ooB.expiry_loss && ooB.expiry_loss.count) html += metricCard("期期·到期亏损", ooB.expiry_loss);
if (ooB.expiry_win && ooB.expiry_win.count) html += metricCard("期期·到期盈利", ooB.expiry_win);
html += "</div>";
}
box.innerHTML = html;
} catch (e) {
box.textContent = e.message || String(e);
}
}
async function startPlan(planType, fromPreviewModal) {
const isOo = planType === "options_options";
const startBtn = $("hp-preview-start");
try {
let body;
if (isOo) {
if (!state.legA || !state.legB) throw new Error("请选用两条期权腿");
const up = Number(($("hp-target-up") && $("hp-target-up").value) || 0);
const down = Number(($("hp-target-down") && $("hp-target-down").value) || 0);
if (!up || !down) throw new Error("请填写上破与下破目标价");
if (up <= down) throw new Error("上破目标价必须大于下破目标价");
body = {
plan_type: "options_options",
underlying: state.underlying,
target_price_up: up,
target_price_down: down,
target_price: up,
oo_close_mode: state.ooCloseModeEnabled ? state.ooCloseMode : "hold_expiry",
leg_a: legPayload(state.legA, ooSheets("hp-oo-sheets-a")),
leg_b: legPayload(state.legB, ooSheets("hp-oo-sheets-b")),
};
} 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",
underlying: state.underlying,
direction: getDirection(),
entry: entry,
tp: tp,
sl: sl,
contracts: contracts,
sheets: sheets,
opt_inst_id: state.selected.inst_id,
opt_type: state.selected.opt_type,
strike: state.selected.strike,
exchange_symbol: (state.market && state.market.exchange_symbol) || "",
leverage: 10,
margin: state.market && state.market.full_margin_sizing && state.market.full_margin_sizing.margin_capital,
};
}
if (!fromPreviewModal) {
if (!window.confirm("确认启动对冲计划并真实下单?\n(将按期权账户/合约账户分别下单)")) return;
}
if (startBtn) startBtn.disabled = true;
const d = await apiJson("/api/hedge-plan/start", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
setGateLine(d.gates);
closePreviewModal();
if (d.partial) {
alert(
(d.msg || "半腿失败,已挂待补") +
(d.plan_id ? "\n计划 #" + d.plan_id : "") +
"\n请到「进行中的计划」补开缺失腿"
);
state.tab = "active";
syncTabUI();
void loadActivePlans();
} else {
alert("计划已启动 #" + (d.plan_id || "") + (d.dry_run ? " (dry_run)" : ""));
}
void loadGates();
} catch (e) {
alert(e.message || String(e));
syncPreviewStartBtn();
}
}
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();
syncPoDirUI();
syncOoSizeModeUI();
syncOoCloseModeUI();
updateOoBudgetLine(null);
bind();
void refreshAll();
})();