Files
CoinOX/lib/common/static/hedge_plan.js
T
dekun 5d161b4220 Add HEDGE_PO_MODE instance modes with shared sim/live path.
Lock one mode per instance/subaccount, cross-asset BTC option/ETH perp sizing, combo-U exit, and ship docs/tests/audit.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-14 20:05:25 +08:00

4030 lines
144 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 "perp_options";
}
const poMode = (root.getAttribute("data-po-mode") || "eth").toLowerCase();
const optionUnderly = (root.getAttribute("data-option-underly") || root.getAttribute("data-default-underly") || "ETH").toUpperCase();
const perpUnderly = (root.getAttribute("data-perp-underly") || optionUnderly).toUpperCase();
const crossAsset = root.getAttribute("data-cross-asset") === "1";
const lockedRatio = (function () {
const raw = root.getAttribute("data-option-perp-ratio");
const n = raw != null && raw !== "" ? Number(raw) : NaN;
return !Number.isNaN(n) && n > 0 ? n : 2;
})();
const state = {
tab: pickDefaultTab(),
mode: showPerp ? "perp_options" : showOo ? "options_options" : "perp_options",
posTab: "live",
underlying: optionUnderly,
poMode: poMode,
optionUnderly: optionUnderly,
perpUnderly: perpUnderly,
crossAsset: crossAsset,
moneyFilter: root.getAttribute("data-option-primary") !== "0" ? "all" : "itm",
ooMoneyFilter: "atm_otm", // 期期锁定:平值+虚值
ooRecommend: null, // atm_straddle | double_otm | null
ooStrikeExpandAll: false, // 默认 Call/Put 各 3 档
poStrikeExpandAll: false, // 永期手动选约:默认平值+实3+虚3
chain: null,
selected: null,
orderDialogOpen: false,
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",
optionPrimary: root.getAttribute("data-option-primary") !== "0",
exitMode: "profit", // 实例模式强制组合盈利金额(U)
opLevTouched: false,
opRatioTouched: crossAsset, // 跨币锁比例,视为已设定
perpLevTouched: false,
tradingUsdc: null,
fundingUsdc: null,
tradeBudgetUsdc: null,
budgetBuffer: (function () {
const raw = root.getAttribute("data-budget-buffer");
const n = raw != null && raw !== "" ? Number(raw) : NaN;
return !Number.isNaN(n) && n > 0 ? n : 0.95;
})(),
previewOk: false,
canStart: false,
previewPlanType: null,
};
function lockPoUnderlyingUI() {
const opt = state.optionUnderly || "ETH";
document.querySelectorAll(".hp-uly-btn").forEach(function (b) {
const uly = (b.getAttribute("data-uly") || "").toUpperCase();
const on = uly === opt;
b.classList.toggle("active", on);
b.disabled = true;
b.title = "由 HEDGE_PO_MODE 锁定(" + (root.getAttribute("data-po-mode-label") || poMode) + ")";
});
const badge = $("hp-po-mode-badge");
if (badge) {
const perp = state.perpUnderly || opt;
badge.textContent =
(root.getAttribute("data-po-mode-label") || poMode) +
(crossAsset ? " · 期权" + opt + "/永续" + perp : "");
}
const ratioEl = $("hp-opt-perp-ratio");
if (ratioEl && lockedRatio > 0) {
ratioEl.value = String(lockedRatio);
if (crossAsset) {
ratioEl.readOnly = true;
ratioEl.title = "跨币模式由 HEDGE_CROSS_NOTIONAL_K 锁定";
}
}
}
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);
});
const hint = $("hp-po-pair-hint");
if (hint) {
if (isOptionPrimary()) {
hint.textContent =
(dir === "short" ? "买 Put + 永续多" : "买 Call + 永续空") +
" · 点「选择」弹窗下单;比例默认 2(期权 1 → 永续 0.5)。";
} else {
hint.textContent = dir === "short" ? "永续空 + 保险 Call" : "永续多 + 保险 Put";
}
}
const typeLab = $("hp-opt-type-label");
if (typeLab && isOptionPrimary()) {
typeLab.textContent = dir === "short" ? "Put" : "Call";
}
}
function setDirection(dir, forceReload) {
const next = (dir || "long").toLowerCase() === "short" ? "short" : "long";
const changed = next !== getDirection();
state.direction = next;
syncPoDirUI();
if (!changed && !forceReload) return;
if (state.orderDialogOpen) closeOrderDialog();
else {
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";
}
/** 条件单杠杆门槛;手动下单传 0 则不筛. */
function optionPrimaryMinLev() {
const minLev = numInput("hp-opt-leverage", 0);
if (!(minLev > 0)) return 0;
if (opMoneyKind() === "otm") return Math.max(minLev, 180);
return minLev;
}
function optionPrimaryLevOk(c) {
const idx = indexPx();
const ask = Number(c && c.ask);
const floor = optionPrimaryMinLev();
if (!(floor > 0)) return true;
if (!(idx > 0) || !(ask > 0)) return false;
return idx / ask >= floor - 1e-9;
}
function matchesMoneyFilter(c) {
const f = state.moneyFilter || "all";
const m = (c.moneyness || "").toLowerCase();
// 保险模式不可选虚值
if (!isOptionPrimary() && f === "otm") return false;
// 手动下单:仅按 全部/实值/虚值 筛选,不做间隔/杠杆条件门槛
if (isOptionPrimary()) {
if (f === "all" || f === "any") return true;
if (f === "itm") return m === "itm" || m === "atm";
if (f === "atm") return m === "atm";
if (f === "otm") return m === "otm";
return true;
}
if (f === "itm") return m === "itm" || m === "atm";
if (f === "atm") return m === "atm";
if (f === "otm") return m === "otm";
return m === "itm" || m === "atm";
}
// 默认共约 5 档:平值 + 实值2 + 虚值2
const PO_ATM_SIDE = 2;
function slicePoListByMoneyness(list) {
if (state.poStrikeExpandAll || !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);
});
const n = PO_ATM_SIDE;
const isPut = optTypeForDirection(getDirection()) === "P";
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 matchesOoMoneyFilter(c) {
// 期期:仅平值/虚值(禁实值)
if (!c) return false;
const m = (c.moneyness || "").toLowerCase();
const f = state.ooMoneyFilter || "atm_otm";
if (f === "atm") return m === "atm";
if (f === "otm") return m === "otm";
return m === "atm" || m === "otm";
}
function indexPx() {
const fromChain = state.chain && Number(state.chain.index_px);
if (fromChain && !Number.isNaN(fromChain) && fromChain > 0) return fromChain;
const fromMkt = state.market && Number(state.market.index_px || state.market.mark);
if (fromMkt && !Number.isNaN(fromMkt) && fromMkt > 0) return fromMkt;
return null;
}
function currentContracts(expSelectId) {
const exp = currentExp(expSelectId);
return (exp && exp.contracts) || [];
}
function pickClosestItmAtm(contracts, want) {
const idx = indexPx();
if (!idx) return null;
const list = (contracts || []).filter(function (c) {
return (
String(c.opt_type || "").toUpperCase() === want &&
matchesMoneyFilter(c)
);
});
if (!list.length) return null;
list.sort(function (a, b) {
return Math.abs(Number(a.strike) - idx) - Math.abs(Number(b.strike) - idx);
});
return list[0];
}
function pickOoTemplate(template) {
const idx = indexPx();
const contracts = currentContracts("hp-oo-exp-select");
if (!idx || !contracts.length) return null;
const preferOtm = template === "double_otm";
function pickSide(want) {
const list = contracts.filter(function (c) {
if (String(c.opt_type || "").toUpperCase() !== want) return false;
if (!matchesOoMoneyFilter(c)) return false;
const m = (c.moneyness || "").toLowerCase();
if (preferOtm) return m === "otm";
return m === "atm" || m === "otm";
});
if (!list.length) return null;
list.sort(function (a, b) {
const ma = (a.moneyness || "").toLowerCase();
const mb = (b.moneyness || "").toLowerCase();
if (!preferOtm) {
if (ma === "atm" && mb !== "atm") return -1;
if (mb === "atm" && ma !== "atm") return 1;
}
return Math.abs(Number(a.strike) - idx) - Math.abs(Number(b.strike) - idx);
});
return list[0];
}
const call = pickSide("C");
const put = pickSide("P");
if (!call || !put) return null;
return { call: call, put: put };
}
function isOptionPrimary() {
return !!state.optionPrimary;
}
function optTypeForDirection(dir) {
if (isOptionPrimary()) {
return dir === "short" ? "P" : "C";
}
return dir === "short" ? "C" : "P";
}
function opMoneyKind() {
const f = state.moneyFilter || "all";
if (f === "otm") return "otm";
if (f === "atm") return "atm";
if (f === "all" || f === "any") return "all";
return "itm";
}
function applyOpDefaultsFromMoney(force) {
const ratioEl = $("hp-opt-perp-ratio");
if (ratioEl && (force || !state.opRatioTouched)) {
// 默认 2:期权名义÷比例=永续名义(例 1 BTC 期权 → 0.5 BTC 永续)
ratioEl.value = "2";
}
}
function syncOptionPrimaryUI() {
const on = isOptionPrimary();
const ins = $("hp-po-fields-insurance");
const op = $("hp-po-fields-option-primary");
// 保险模式只显示开仓价/张数/止盈止损;以期权为主显示三组参数(env 切换,页内不可改)
if (ins) {
ins.classList.toggle("hidden", on);
if (on) ins.setAttribute("hidden", "hidden");
else ins.removeAttribute("hidden");
ins.style.display = on ? "none" : "";
}
if (op) {
op.classList.toggle("hidden", !on);
if (!on) op.setAttribute("hidden", "hidden");
else op.removeAttribute("hidden");
op.style.display = on ? "" : "none";
}
const insMoney = $("hp-po-ins-money");
if (insMoney) {
if (on) {
insMoney.setAttribute("hidden", "hidden");
insMoney.classList.add("hidden");
} else {
insMoney.removeAttribute("hidden");
insMoney.classList.remove("hidden");
}
}
const mktMoney = $("hp-po-mkt-money");
if (mktMoney) {
if (on) {
mktMoney.removeAttribute("hidden");
mktMoney.classList.remove("hidden");
} else {
mktMoney.setAttribute("hidden", "hidden");
mktMoney.classList.add("hidden");
}
}
if (on && (state.moneyFilter === "atm" || !state.moneyFilter)) {
state.moneyFilter = "all";
syncMoneyUI();
}
if (!on && (state.moneyFilter === "otm" || state.moneyFilter === "all")) {
state.moneyFilter = "itm";
syncMoneyUI();
}
if (on) applyOpDefaultsFromMoney(false);
const badge = $("hp-po-mode-badge");
if (badge) {
badge.textContent = on ? "手动下单" : "保险模式";
badge.classList.toggle("is-insurance", !on);
}
const title = $("hp-po-card-title");
if (title) title.textContent = on ? "下单" : "执行参数";
const dirLong = document.querySelector('.hp-po-dir[data-dir="long"]');
const dirShort = document.querySelector('.hp-po-dir[data-dir="short"]');
if (dirLong) {
dirLong.title = on ? "买 Call → 永续空" : "做多永续";
if (on) {
dirLong.innerHTML = '<span class="hp-oo-check" aria-hidden="true">✓</span>看涨 Call';
} else {
dirLong.innerHTML = '<span class="hp-oo-check" aria-hidden="true">✓</span>做多';
}
}
if (dirShort) {
dirShort.title = on ? "买 Put → 永续多" : "做空永续";
if (on) {
dirShort.innerHTML = '<span class="hp-oo-check" aria-hidden="true">✓</span>看跌 Put';
} else {
dirShort.innerHTML = '<span class="hp-oo-check" aria-hidden="true">✓</span>做空';
}
}
syncPoDirUI();
syncPoActionBtn();
}
function hoursFromExpMs(expMs) {
const n = Number(expMs);
if (!n || Number.isNaN(n)) return null;
const ms = n < 1e12 ? n * 1000 : n;
return (ms - Date.now()) / 3600000;
}
function numInput(id, fallback) {
const el = $(id);
const n = Number(el && el.value);
if (Number.isNaN(n)) return fallback;
return n;
}
function computeOpSizing(ask, ctMult, sheetsOverride) {
const budget = numInput("hp-premium-budget", 0);
const ratio = numInput("hp-opt-perp-ratio", lockedRatio || 2);
const cs = Number((state.market && state.market.contract_size) || 0.01);
const usable = budget * 0.95;
const a = Number(ask || 0);
const ct = Number(ctMult || 0.01);
if (!(ct > 0) || !(ratio > 0) || !(cs > 0)) {
return null;
}
let sheets;
let eth;
if (sheetsOverride != null && Number(sheetsOverride) > 0) {
sheets = Math.floor(Number(sheetsOverride) + 1e-12);
if (!(sheets > 0)) return null;
eth = Math.round(sheets * ct * 100) / 100;
} else {
if (!(budget > 0) || !(a > 0)) return null;
eth = Math.floor((usable / a) * 100 + 1e-12) / 100;
if (!(eth > 0)) return null;
sheets = Math.floor(eth / ct + 1e-12);
if (!(sheets > 0)) return null;
eth = Math.round(sheets * ct * 100) / 100;
}
// 跨币:期权币×期权指数×(1/ratio) / 永续指数 = 永续币
let perpQty = eth / ratio;
let contracts = perpQty / cs;
let optNotional = null;
let perpNotional = null;
if (state.crossAsset) {
const optIdx = Number((state.chain && state.chain.index_px) || 0);
const perpIdx = Number((state.market && (state.market.index_px || state.market.mark)) || 0);
if (optIdx > 0 && perpIdx > 0) {
optNotional = eth * optIdx;
perpNotional = optNotional / ratio;
perpQty = perpNotional / perpIdx;
contracts = perpQty / cs;
}
}
return {
usable: usable,
eth_qty: eth,
perp_qty: perpQty,
sheets: sheets,
contracts: contracts,
premium_est: a > 0 ? a * sheets * ct : 0,
ratio: ratio,
option_notional_usdt: optNotional,
perp_notional_usdt: perpNotional,
cross_asset: !!state.crossAsset,
};
}
function orderSheets() {
const right = numInput("hp-order-sheets", 0);
if (right > 0) return Math.floor(right);
const left = numInput("hp-sheets", 0);
return left > 0 ? Math.floor(left) : 0;
}
function syncSheetsInputs(fromId) {
const n = numInput(fromId, 0);
if (!(n > 0)) return;
const v = String(Math.floor(n));
if (fromId !== "hp-sheets" && $("hp-sheets")) $("hp-sheets").value = v;
if (fromId !== "hp-order-sheets" && $("hp-order-sheets")) $("hp-order-sheets").value = v;
}
function currentOpSizing() {
if (!state.selected) return null;
const sheets = orderSheets();
return computeOpSizing(
state.selected.ask,
state.selected.ct_mult || 0.01,
sheets > 0 ? sheets : null
);
}
function formatOpSizingHtml(sized, amtPrec) {
if (!sized) return "";
const optCoin = (state.optionUnderly || state.underlying || "ETH").toUpperCase();
const perpCoin = (state.perpUnderly || state.underlying || "ETH").toUpperCase();
const prec = amtPrec != null ? amtPrec : perpCoin === "BTC" ? 4 : 2;
let html =
"期权 <strong>" +
fmt(sized.eth_qty, 2) +
"</strong> " +
optCoin +
" / " +
sized.sheets +
" 张 → 永续 <strong>" +
fmt(sized.perp_qty, 4) +
"</strong> " +
perpCoin +
" (" +
fmt(sized.contracts, prec) +
" 张) · 比例 " +
fmt(sized.ratio, 2);
if (sized.cross_asset && sized.option_notional_usdt != null) {
html +=
" · 名义U " +
fmt(sized.option_notional_usdt, 0) +
"/" +
fmt(sized.perp_notional_usdt, 0);
}
return html;
}
function defaultPerpLeverage(uly) {
const u = (uly || "ETH").toUpperCase();
const btc = Number(root.getAttribute("data-btc-leverage") || 10);
const eth = Number(root.getAttribute("data-eth-leverage") || 5);
if (u === "BTC") return Number.isFinite(btc) && btc > 0 ? btc : 10;
return Number.isFinite(eth) && eth > 0 ? eth : 5;
}
/**
* 期权开仓最大张数:OKX 交易账户 USDT 共用.
* 每张开仓需:买币(权利金×1.05×指数) + 永续保证金(对冲名义÷杠杆);
* 选最大 N 时须保证扣完买币后剩余仍够永续保证金.
*/
function computeMaxSheets() {
const ask = state.selected && Number(state.selected.ask);
const ct = Number((state.selected && state.selected.ct_mult) || 0.01);
const ratio = numInput("hp-opt-perp-ratio", 2);
const mark = Number(
(state.market && (state.market.mark || state.market.index_px)) || indexPx() || 0
);
const lev = defaultPerpLeverage(state.underlying);
const tradingUsdt = Number(
state.tradingUsdt != null ? state.tradingUsdt : state.tradingUsdc
);
if (!(ask > 0) || !(ct > 0) || !(ratio > 0) || !(mark > 0) || !(lev > 0)) {
return null;
}
if (!(tradingUsdt > 0)) {
return {
max_sheets: 0,
lev: lev,
trading_usdt: tradingUsdt,
buy_usdt_per_sheet: 0,
margin_per_sheet: 0,
total_per_sheet: 0,
};
}
// 买币 USDT/张 = 权利金币 × 1.05 × 指数
const buyUsdtPerSheet = ask * ct * 1.05 * mark;
// 永续名义/张 = (期权币/比例)×指数; 保证金 = 名义÷杠杆
const perpNotionalPerSheet = (ct / ratio) * mark;
const marginPerSheet = perpNotionalPerSheet / lev;
const totalPerSheet = buyUsdtPerSheet + marginPerSheet;
if (!(totalPerSheet > 0)) return null;
const maxSheets = Math.max(0, Math.floor(tradingUsdt / totalPerSheet + 1e-12));
return {
max_sheets: maxSheets,
lev: lev,
trading_usdt: tradingUsdt,
buy_usdt_per_sheet: buyUsdtPerSheet,
margin_per_sheet: marginPerSheet,
total_per_sheet: totalPerSheet,
};
}
function updatePoOpenSummary() {
const maxEl = $("hp-po-max-line");
const perpEl = $("hp-po-perp-auto");
const maxInfo = computeMaxSheets();
const sized = currentOpSizing();
const coin = (state.underlying || "ETH").toUpperCase();
const lev = defaultPerpLeverage(state.underlying);
const mark = Number(
(state.market && (state.market.mark || state.market.index_px)) || indexPx() || 0
);
if (maxEl) {
if (maxInfo) {
maxEl.innerHTML =
"期权开仓最大 <strong>" +
maxInfo.max_sheets +
"</strong> 张 · 交易 USDT " +
fmt(maxInfo.trading_usdt, 2) +
" · 每张约买币 " +
fmt(maxInfo.buy_usdt_per_sheet, 2) +
" + 永续保证金 " +
fmt(maxInfo.margin_per_sheet, 2) +
" (杠杆 " +
lev +
"x)";
} else {
maxEl.textContent =
"选择期权并刷新后显示:期权开仓最大张数(扣买币后剩余须够永续保证金)";
}
}
if (perpEl) {
if (sized) {
const buyUsdt = sized.premium_est * 1.05 * (mark > 0 ? mark : 0);
const marginUsdt =
sized.perp_qty * (mark > 0 ? mark : 0) / (lev > 0 ? lev : 1);
const remain =
Number(state.tradingUsdt != null ? state.tradingUsdt : state.tradingUsdc) -
buyUsdt;
const marginOk = remain + 1e-9 >= marginUsdt;
perpEl.innerHTML =
"永续自动开 <strong>" +
fmt(sized.perp_qty, 4) +
"</strong> " +
coin +
" / <strong>" +
fmt(sized.contracts, coin === "BTC" ? 4 : 2) +
"</strong> 张 · 买币约 " +
fmt(sized.premium_est * 1.05, 4) +
" " +
coin +
"(≈" +
fmt(buyUsdt, 2) +
" U含5%) · 扣后剩 " +
fmt(remain, 2) +
" U · 永续保证金约 " +
fmt(marginUsdt, 2) +
" U" +
(marginOk ? "" : ' <span class="is-err">不足</span>');
} else {
perpEl.textContent = "永续开仓 — (填张数并选择期权后按比例自动算)";
}
}
}
function syncExitModeUI() {
const mode = state.exitMode === "target" ? "target" : "profit";
document.querySelectorAll(".hp-po-exit-mode").forEach(function (b) {
const on = (b.getAttribute("data-exit") || "") === mode;
b.classList.toggle("is-selected", on);
b.classList.toggle("active", on);
});
const profitBox = $("hp-po-exit-profit");
const targetBox = $("hp-po-exit-target");
if (profitBox) {
const show = mode === "profit";
profitBox.classList.toggle("hidden", !show);
if (show) {
profitBox.removeAttribute("hidden");
profitBox.style.display = "";
} else {
profitBox.setAttribute("hidden", "hidden");
profitBox.style.display = "none";
}
}
if (targetBox) {
const show = mode === "target";
targetBox.classList.toggle("hidden", !show);
if (show) {
targetBox.removeAttribute("hidden");
targetBox.style.display = "";
} else {
targetBox.setAttribute("hidden", "hidden");
targetBox.style.display = "none";
}
}
}
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;
// 永续开仓杠杆跟随 env(BTC_LEVERAGE / ALT_LEVERAGE=ETH)
const levEl = $("hp-perp-leverage");
if (levEl && !state.perpLevTouched) {
levEl.value = String(defaultPerpLeverage(uly));
}
}
function syncMoneyUI() {
const moneySel = $("hp-money-select");
if (moneySel && isOptionPrimary()) {
moneySel.value = state.moneyFilter || "all";
}
document.querySelectorAll(".hp-money-btn").forEach(function (b) {
const on = b.getAttribute("data-money") === state.moneyFilter;
b.classList.toggle("active", on);
b.classList.toggle("is-selected", on);
b.setAttribute("aria-pressed", on ? "true" : "false");
});
document.querySelectorAll(".hp-oo-money-btn").forEach(function (b) {
const on = b.getAttribute("data-oo-money") === state.ooMoneyFilter;
b.classList.toggle("active", on);
b.classList.toggle("is-selected", on);
b.setAttribute("aria-pressed", on ? "true" : "false");
});
const expandCb = $("hp-strike-expand-all");
if (expandCb) expandCb.checked = !!state.poStrikeExpandAll;
}
function syncPoActionBtn() {
const btn = $("hp-preview-btn");
if (!btn) return;
if (isOptionPrimary()) {
btn.textContent = "手动下单";
btn.title = "弹窗内确认:按已选合约卖一限价开仓(买期权+对冲永续);非交易所市价单";
} else {
btn.textContent = "计算";
btn.title = "情景测算后再启动";
}
}
function isPoOptionPrimaryPlan(p) {
if (!p || p.plan_type !== "perp_options") return false;
return p.option_primary == 1 || p.option_primary === true || Number(p.option_primary) === 1;
}
function setPoStrategyStatus(kind, planId) {
const el = $("hp-po-strategy-status");
if (!el) return;
el.classList.remove("is-watching", "is-holding", "is-idle");
if (!isOptionPrimary()) {
el.textContent = "";
return;
}
const idPart = planId ? " #" + planId : "";
if (kind === "watching") {
el.textContent = "盯盘中" + idPart;
el.classList.add("is-watching");
} else if (kind === "holding") {
el.textContent = "持仓中" + idPart;
el.classList.add("is-holding");
} else {
el.textContent = "";
el.classList.add("is-idle");
}
}
async function refreshPoStrategyStatus() {
const el = $("hp-po-strategy-status");
if (!el) return;
if (!isOptionPrimary()) {
setPoStrategyStatus("", null);
return;
}
try {
const d = await apiJson("/api/hedge-plan/active");
const uly = (state.underlying || "ETH").toUpperCase();
const rows = (d.plans || []).filter(function (p) {
return isPoOptionPrimaryPlan(p) && String(p.underlying || "").toUpperCase() === uly;
});
if (!rows.length) {
setPoStrategyStatus("idle", null);
return;
}
rows.sort(function (a, b) {
return Number(b.id || 0) - Number(a.id || 0);
});
const p = rows[0];
const st = String(p.status || "");
if (st === "watching") setPoStrategyStatus("watching", p.id);
else if (st === "active" || st === "opening" || st === "partial") setPoStrategyStatus("holding", p.id);
else setPoStrategyStatus("idle", null);
} catch (e) {
/* ignore status refresh errors */
}
}
function syncOoRecommendUI() {
const cur = state.ooRecommend || "";
document.querySelectorAll(".hp-oo-recommend-btn").forEach(function (b) {
const on = (b.getAttribute("data-oo-rec") || "") === cur;
b.classList.toggle("active", on);
b.classList.toggle("is-selected", on);
b.setAttribute("aria-pressed", on ? "true" : "false");
});
}
function syncOoExpandUI() {
const btn = $("hp-oo-expand-all");
if (!btn) return;
const on = !!state.ooStrikeExpandAll;
btn.classList.toggle("active", on);
btn.classList.toggle("is-selected", on);
btn.setAttribute("aria-pressed", on ? "true" : "false");
}
function calcStrikeAskLeverage(strike, ask) {
const k = Number(strike);
const a = Number(ask);
if (!Number.isFinite(k) || k <= 0 || !Number.isFinite(a) || a <= 0) return "—";
return (Math.round((k / a) * 10) / 10).toFixed(1) + "×";
}
/** CoinOX 币本位:卖一为币报价 → 杠杆≈1÷卖一(与期权页一致). */
function calcIndexAskLeverage(ask) {
const a = Number(ask);
if (!(a > 0)) return "—";
return (Math.round((1 / a) * 10) / 10).toFixed(1) + "×";
}
const orderPanelHome = (function () {
const host = document.getElementById("hp-order-panel-host");
return host ? host.parentElement : null;
})();
function orderPanel() {
return $("hp-order-panel");
}
function orderPanelHost() {
return $("hp-order-panel-host");
}
function parkOrderPanel() {
const panel = orderPanel();
const host = orderPanelHost();
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";
state.orderDialogOpen = false;
const tbody = $("hp-strike-tbody");
if (tbody) {
tbody.querySelectorAll(".opt-strike-row").forEach(function (r) {
r.classList.remove("opt-row-selected");
});
tbody.querySelectorAll(".hp-pick").forEach(function (b) {
b.classList.remove("active");
if (b.dataset.origText) b.textContent = b.dataset.origText;
});
}
}
function openOrderDialog(instId) {
const panel = orderPanel();
const host = orderPanelHost();
if (!panel || !host) return false;
if (panel.parentElement !== host) host.appendChild(panel);
if (host.parentElement !== document.body) document.body.appendChild(host);
host.hidden = false;
host.setAttribute("aria-hidden", "false");
panel.style.display = "";
state.orderDialogOpen = true;
const tbody = $("hp-strike-tbody");
if (tbody && instId) {
tbody.querySelectorAll(".opt-strike-row").forEach(function (r) {
r.classList.toggle("opt-row-selected", r.getAttribute("data-inst") === instId);
});
tbody.querySelectorAll(".hp-pick").forEach(function (b) {
if (!b.dataset.origText) b.dataset.origText = "选择";
const on = b.getAttribute("data-inst") === instId;
b.classList.toggle("active", on);
b.textContent = on ? "已选" : b.dataset.origText;
});
}
return true;
}
function closeOrderDialog() {
state.selected = null;
parkOrderPanel();
if ($("hp-sel-inst")) $("hp-sel-inst").textContent = "—";
updatePremiumLine();
updatePerpPnlHint();
}
function clearPoSelection() {
state.selected = null;
if ($("hp-sel-inst")) $("hp-sel-inst").textContent = "—";
if (state.orderDialogOpen) parkOrderPanel();
updatePremiumLine();
updatePerpPnlHint();
}
function setHpPosTab(tab) {
const allowed = { live: 1, plans: 1, pending: 1, history: 1, stats: 1 };
const next = allowed[tab] ? tab : "live";
state.posTab = next;
document.querySelectorAll(".hp-pos-tab").forEach(function (btn) {
const on = btn.getAttribute("data-hp-pos-tab") === next;
btn.classList.toggle("active", on);
btn.setAttribute("aria-selected", on ? "true" : "false");
});
document.querySelectorAll("[data-hp-pos-pane]").forEach(function (pane) {
const on = pane.getAttribute("data-hp-pos-pane") === next;
pane.classList.toggle("is-active", on);
if (on) pane.removeAttribute("hidden");
else pane.setAttribute("hidden", "");
});
if (next === "live") void loadLivePositions();
else if (next === "plans" || next === "pending") void loadActivePlans();
else if (next === "history") void loadHistory();
else if (next === "stats") void loadStats();
}
function refreshHpPosPanel() {
void loadLivePositions();
void loadActivePlans();
void loadHistory();
if (state.posTab === "stats") void loadStats();
}
function pnlClsNum(v) {
if (v == null || Number.isNaN(Number(v))) return "";
const n = Number(v);
if (n > 0) return "hp-pnl-pos";
if (n < 0) return "hp-pnl-neg";
return "";
}
function fmtSigned(v, d) {
if (v == null || Number.isNaN(Number(v))) return "—";
const n = Number(v);
const s = fmt(n, d == null ? 2 : d);
return n > 0 ? "+" + s : s;
}
function fmtDuration(sec) {
if (sec == null || Number.isNaN(Number(sec))) return "—";
let s = Math.max(0, Math.floor(Number(sec)));
const d = Math.floor(s / 86400);
s -= d * 86400;
const h = Math.floor(s / 3600);
s -= h * 3600;
const m = Math.floor(s / 60);
if (d > 0) return d + "天 " + h + "时" + m + "分";
if (h > 0) return h + "时" + m + "分";
return m + "分";
}
function formatExitLine(ex) {
const e = ex || {};
const mode = String(e.mode || "").toLowerCase() === "target" ? "target" : "profit";
if (mode === "profit") {
return (
"出场 · 盈利金额 <strong>" +
(e.profit_target_usdt != null ? fmt(e.profit_target_usdt, 2) + " U" : "—") +
"</strong>"
);
}
return (
"出场 · 目标位 期权±<strong>" +
(e.option_target_points != null ? fmt(e.option_target_points, 0) : "—") +
"</strong> / 永续±<strong>" +
(e.perp_target_points != null ? fmt(e.perp_target_points, 0) : "—") +
"</strong>"
);
}
function renderLivePosCard(card) {
const opt = card.option || {};
const perp = card.perp || {};
const combo = card.combo || {};
const exit = card.exit || {};
const optType = String(opt.opt_type || "").toUpperCase() === "P" ? "Put" : "Call";
const perpSide = String(perp.side || "").toLowerCase() === "short" ? "空" : "多";
const comboCls = pnlClsNum(combo.net_usdt);
const liqWarn = opt.liq_ok === false;
const perpLiqWarn = perp.liq_ok === false;
const unflat = !!card.unflat || String(card.status || "") === "closed";
const bidTxt =
opt.bid != null
? fmt(opt.bid, 4) + (opt.bid_sz != null ? "/" + fmt(opt.bid_sz, 0) : "")
: "—";
const bookLab = String(perp.book_side || "") === "asks" ? "卖一" : "买一";
const perpTopTxt =
perp.top_px != null
? fmt(perp.top_px, 2) + (perp.top_sz != null ? "/" + fmt(perp.top_sz, 0) : "")
: "—";
const canEditExit = !unflat;
return (
'<article class="hp-live-card' +
(unflat ? " hp-live-card--unflat" : "") +
'" data-plan-id="' +
(card.plan_id || "") +
'">' +
'<div class="hp-live-card-head">' +
"<strong>#" +
(card.plan_id || "—") +
" · " +
(card.underlying || "") +
"</strong>" +
(unflat
? '<span class="hp-live-unflat-tag" title="计划已结束但腿仍持仓中">未平仓</span>'
: "") +
"</div>" +
'<div class="hp-live-time-row">' +
"<span>开仓 <strong>" +
(card.opened_at || "—") +
"</strong></span>" +
"<span>持仓时长 <strong>" +
fmtDuration(card.hold_sec) +
"</strong></span>" +
"<span>到期剩余 <strong>" +
fmtDuration(card.remain_sec) +
"</strong></span>" +
"</div>" +
'<div class="hp-live-exit-row">' +
"<span>" +
formatExitLine(exit) +
"</span>" +
(canEditExit
? ' <button type="button" class="btn-secondary hp-btn-exit" data-id="' +
(card.plan_id || "") +
'" data-mode="' +
String(exit.mode || "profit") +
'" data-profit="' +
(exit.profit_target_usdt != null ? exit.profit_target_usdt : "") +
'" data-opt-pts="' +
(exit.option_target_points != null ? exit.option_target_points : "") +
'" data-perp-pts="' +
(exit.perp_target_points != null ? exit.perp_target_points : "") +
'">改出场</button>'
: "") +
"</div>" +
'<div class="hp-live-legs">' +
'<div class="hp-live-leg">' +
'<div class="hp-live-leg-title">期权 · ' +
optType +
"</div>" +
"<code>" +
(opt.inst_id || "—") +
"</code>" +
'<div class="hp-live-leg-grid">' +
"<span>张数 <strong>" +
fmt(opt.sheets, 0) +
"</strong></span>" +
"<span>杠杆 <strong>" +
(opt.leverage != null ? fmt(opt.leverage, 1) + "×" : "—") +
"</strong></span>" +
"<span>开仓 <strong>" +
fmt(opt.avg_open, 4) +
"</strong></span>" +
"<span>标记 <strong>" +
fmt(opt.mark_px, 4) +
"</strong></span>" +
'<span title="权利金(币/U,U按买币参考指数)">权利金 <strong>' +
(opt.premium_coin != null ? fmt(opt.premium_coin, 4) + " 币" : "—") +
(opt.premium_usdt != null ? " / " + fmt(opt.premium_usdt, 2) + " U" : "") +
"</strong></span>" +
'<span class="' +
(liqWarn ? "hp-liq-warn" : "") +
'" title="' +
String(opt.liq_msg || "").replace(/"/g, "&quot;") +
'">买一/流动性 <strong>' +
bidTxt +
(liqWarn ? " · 不足" : "") +
"</strong></span>" +
'<span>净利 <strong class="' +
pnlClsNum(opt.net_usdt) +
'">' +
fmtSigned(opt.net_usdt, 2) +
" U</strong></span>" +
"</div></div>" +
'<div class="hp-live-leg">' +
'<div class="hp-live-leg-title">永续 · ' +
perpSide +
"</div>" +
"<code>" +
(perp.symbol || "—") +
"</code>" +
'<div class="hp-live-leg-grid">' +
"<span>张数 <strong>" +
fmt(perp.contracts, 4) +
"</strong></span>" +
"<span>杠杆 <strong>" +
(perp.leverage != null ? fmt(perp.leverage, 0) + "×" : "—") +
"</strong></span>" +
"<span>开仓 <strong>" +
fmt(perp.avg_open, 2) +
"</strong></span>" +
'<span class="muted" title="仅参考,净利按盘口均价">标记 <strong>' +
fmt(perp.mark_px, 2) +
"</strong></span>" +
'<span class="' +
(perpLiqWarn ? "hp-liq-warn" : "") +
'" title="' +
String(perp.liq_msg || "").replace(/"/g, "&quot;") +
'">' +
bookLab +
"/流动性 <strong>" +
perpTopTxt +
(perpLiqWarn ? " · 不足" : "") +
"</strong></span>" +
'<span title="平仓方向扫盘口估成交均价">估均价 <strong>' +
(perp.vwap != null ? fmt(perp.vwap, 2) : "—") +
"</strong></span>" +
"<span>保证金 <strong>" +
(perp.margin != null ? fmt(perp.margin, 2) + " U" : "—") +
"</strong></span>" +
'<span>净利 <strong class="' +
pnlClsNum(perp.net_usdt) +
'">' +
fmtSigned(perp.net_usdt, 2) +
" U</strong></span>" +
"</div></div>" +
"</div>" +
'<div class="hp-live-combo">' +
'<span class="muted" title="扣开平手续费·永续盘口均价(详见左侧规则说明)">组合净盈亏</span>' +
'<strong class="' +
comboCls +
'">' +
fmtSigned(combo.net_usdt, 2) +
" U</strong>" +
"</div>" +
'<div class="hp-live-actions">' +
'<button type="button" class="btn-secondary hp-btn-detail" data-id="' +
(card.plan_id || "") +
'">成交细节</button> ' +
'<button type="button" class="btn-secondary hp-btn-end" data-id="' +
(card.plan_id || "") +
'">平仓</button>' +
"</div></article>"
);
}
function setExitModalMode(mode) {
const m = mode === "target" ? "target" : "profit";
document.querySelectorAll(".hp-exit-mode-btn").forEach(function (b) {
const on = b.getAttribute("data-exit") === m;
b.classList.toggle("is-selected", on);
});
const profitBox = $("hp-exit-fields-profit");
const targetBox = $("hp-exit-fields-target");
if (profitBox) {
const show = m === "profit";
profitBox.classList.toggle("hidden", !show);
if (show) profitBox.removeAttribute("hidden");
else profitBox.setAttribute("hidden", "hidden");
}
if (targetBox) {
const show = m === "target";
targetBox.classList.toggle("hidden", !show);
if (show) targetBox.removeAttribute("hidden");
else targetBox.setAttribute("hidden", "hidden");
}
}
function openExitModal(btn) {
const modal = $("hp-exit-modal");
if (!modal || !btn) return;
const id = Number(btn.getAttribute("data-id") || 0);
if (!(id > 0)) return;
if ($("hp-exit-plan-id")) $("hp-exit-plan-id").value = String(id);
const mode = btn.getAttribute("data-mode") === "target" ? "target" : "profit";
setExitModalMode(mode);
if ($("hp-exit-profit")) $("hp-exit-profit").value = btn.getAttribute("data-profit") || "";
if ($("hp-exit-opt-pts")) $("hp-exit-opt-pts").value = btn.getAttribute("data-opt-pts") || "";
if ($("hp-exit-perp-pts")) $("hp-exit-perp-pts").value = btn.getAttribute("data-perp-pts") || "";
if ($("hp-exit-title")) $("hp-exit-title").textContent = "改出场 #" + id;
if ($("hp-exit-err")) {
$("hp-exit-err").textContent = "";
$("hp-exit-err").setAttribute("hidden", "");
}
modal.hidden = false;
}
function closeExitModal() {
const modal = $("hp-exit-modal");
if (modal) modal.hidden = true;
}
async function saveExitModal() {
const id = Number(($("hp-exit-plan-id") && $("hp-exit-plan-id").value) || 0);
const err = $("hp-exit-err");
if (!(id > 0)) return;
const modeBtn = document.querySelector(".hp-exit-mode-btn.is-selected");
const mode = modeBtn && modeBtn.getAttribute("data-exit") === "target" ? "target" : "profit";
const body =
mode === "profit"
? {
exit_mode: "profit",
profit_target_usdt: Number(($("hp-exit-profit") && $("hp-exit-profit").value) || 0),
}
: {
exit_mode: "target",
option_target_points: Number(($("hp-exit-opt-pts") && $("hp-exit-opt-pts").value) || 0),
perp_target_points: Number(($("hp-exit-perp-pts") && $("hp-exit-perp-pts").value) || 0),
};
try {
const d = await apiJson("/api/hedge-plan/" + id + "/exit", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
closeExitModal();
alert(d.msg || "出场已更新");
void loadLivePositions();
void loadActivePlans();
} catch (e) {
if (err) {
err.textContent = e.message || String(e);
err.removeAttribute("hidden");
} else {
alert(e.message || String(e));
}
}
}
async function loadLivePositions() {
const box = $("hp-live-pos-list");
if (!box) return;
try {
const d = await apiJson("/api/hedge-plan/live-positions");
const cards = d.cards || [];
if (!cards.length) {
box.innerHTML = '<p class="muted">暂无期权/永续对冲持仓</p>';
return;
}
box.innerHTML = cards.map(renderLivePosCard).join("");
box.querySelectorAll(".hp-btn-detail").forEach(function (btn) {
btn.addEventListener("click", function () {
void showPlanDetail(Number(btn.getAttribute("data-id")));
});
});
box.querySelectorAll(".hp-btn-end").forEach(function (btn) {
btn.addEventListener("click", function () {
void endActivePlan(Number(btn.getAttribute("data-id")));
});
});
box.querySelectorAll(".hp-btn-exit").forEach(function (btn) {
btn.addEventListener("click", function () {
openExitModal(btn);
});
});
} catch (e) {
box.innerHTML = '<p class="err">' + (e.message || e) + "</p>";
}
}
/** 手动选约:不自动代选;仅在已选失效(换 Call/Put 或筛掉)时清空. */
function autoMatchPoOption(force) {
if (!isOptionPrimary()) return;
const want = optTypeForDirection(getDirection());
const sel = state.selected;
const stillOk =
!force &&
sel &&
String(sel.opt_type || "").toUpperCase() === want &&
matchesMoneyFilter(sel);
if (stillOk) return;
clearPoSelection();
}
function findAtmStrikeFromRows(rows, idx) {
if (!rows.length) return null;
if (idx == null || Number.isNaN(Number(idx))) return rows[0].strike;
let best = rows[0].strike;
let bestDist = Math.abs(Number(rows[0].strike) - Number(idx));
rows.forEach(function (row) {
const d = Math.abs(Number(row.strike) - Number(idx));
if (d < bestDist || (d === bestDist && Number(row.strike) < Number(best))) {
bestDist = d;
best = row.strike;
}
});
return best;
}
function sliceOoTRows(prepared, idx) {
// prepared: [{strike, call, put, callOk, putOk}]
if (state.ooStrikeExpandAll || !prepared.length) return prepared;
const n = 3;
const anchor = Number(idx) || Number(findAtmStrikeFromRows(prepared, idx)) || 0;
function nearest(list, count) {
return list
.slice()
.sort(function (a, b) {
return Math.abs(Number(a.strike) - anchor) - Math.abs(Number(b.strike) - anchor);
})
.slice(0, count);
}
const byStrike = {};
nearest(
prepared.filter(function (r) {
return r.callOk;
}),
n
).forEach(function (r) {
byStrike[String(r.strike)] = r;
});
nearest(
prepared.filter(function (r) {
return r.putOk;
}),
n
).forEach(function (r) {
byStrike[String(r.strike)] = r;
});
// 已选用腿始终保留,避免折叠后看不到选中项
[state.legA, state.legB].forEach(function (leg) {
if (!leg) return;
const hit = prepared.find(function (r) {
return (
(r.call && r.call.inst_id === leg.inst_id) ||
(r.put && r.put.inst_id === leg.inst_id)
);
});
if (hit) byStrike[String(hit.strike)] = hit;
});
return Object.keys(byStrike)
.map(function (k) {
return byStrike[k];
})
.sort(function (a, b) {
return Number(a.strike) - Number(b.strike);
});
}
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"].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;
}
if (tab === "perp_options") {
refreshHpPosPanel();
}
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 text = (function () {
if (!gates) return "";
const parts = [
"计仓:" + (gates.is_full_margin ? "全仓" : "非全仓"),
"测算:" + (gates.can_preview ? "可" : "否"),
"开仓:" + (gates.can_start ? "可" : "否"),
];
if (gates.reasons && gates.reasons.length) parts.push(gates.reasons.join("; "));
return parts.join(" · ");
})();
const el = $("hp-gate-line");
if (el) el.textContent = text;
const inline = $("hp-po-gate-inline");
if (inline) inline.textContent = text;
state.canStart = !!(gates && gates.can_start);
syncPreviewStartBtn();
}
function setPoOrderErr(msg, focusId) {
const err = $("hp-po-order-err");
if (err) {
if (msg) {
err.textContent = msg;
err.removeAttribute("hidden");
} else {
err.textContent = "";
err.setAttribute("hidden", "");
}
}
if (focusId && $(focusId)) {
try {
$(focusId).focus();
$(focusId).classList.add("hp-input-invalid");
setTimeout(function () {
if ($(focusId)) $(focusId).classList.remove("hp-input-invalid");
}, 2400);
} catch (e) {}
}
}
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 applyBudgetBuffer(raw) {
if (raw == null || raw === "") return;
const buf = Number(raw);
if (Number.isNaN(buf) || buf <= 0) return;
state.budgetBuffer = buf;
const el = $("hp-oo-buf-ratio");
if (el) el.textContent = fmt(buf, 2);
}
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.trading_usdt != null && acct.trading_usdt !== "") {
state.tradingUsdtEquity = Number(acct.trading_usdt);
} else if (acct.trading_usdc != null && acct.trading_usdc !== "") {
state.tradingUsdtEquity = Number(acct.trading_usdc);
}
if (acct.trading_usdt_avail != null && acct.trading_usdt_avail !== "") {
state.tradingUsdt = Number(acct.trading_usdt_avail);
} else if (state.tradingUsdtEquity != null) {
state.tradingUsdt = state.tradingUsdtEquity;
} else if (acct.trading_usdc != null && acct.trading_usdc !== "") {
state.tradingUsdt = Number(acct.trading_usdc);
}
if (acct.funding_usdt != null && acct.funding_usdt !== "") {
state.fundingUsdt = Number(acct.funding_usdt);
state.fundingUsdc = Number(acct.funding_usdt);
} else if (acct.funding_usdc != null && acct.funding_usdc !== "") {
state.fundingUsdc = Number(acct.funding_usdc);
state.fundingUsdt = 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 !== "") {
applyBudgetBuffer(chain.budget_buffer);
}
const equityTxt = fmt(
state.tradingUsdtEquity != null
? state.tradingUsdtEquity
: acct.trading_usdt != null
? acct.trading_usdt
: acct.trading_usdc,
2
);
const line =
label +
" · 交易 USDT " +
equityTxt +
" · 资金 USDT " +
fmt(acct.funding_usdt != null ? acct.funding_usdt : 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_usdt != null ? acct.funding_usdt : acct.funding_usdc, 2);
if (tradeEl) tradeEl.textContent = equityTxt;
const poFund = $("hp-po-funding-usdt");
const poTrade = $("hp-po-trading-usdt");
if (poFund) poFund.textContent = fmt(acct.funding_usdt != null ? acct.funding_usdt : acct.funding_usdc, 2);
if (poTrade) poTrade.textContent = equityTxt;
state.fundingEth = acct.funding_eth != null ? Number(acct.funding_eth) : null;
state.tradingEth = acct.trading_eth != null ? Number(acct.trading_eth) : null;
state.fundingBtc = acct.funding_btc != null ? Number(acct.funding_btc) : null;
state.tradingBtc = acct.trading_btc != null ? Number(acct.trading_btc) : null;
autoFillOoSheets();
}
function setXferMsg(prefix, text, kind) {
const el = $(prefix + "-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 setOoXferMsg(text, kind) {
setXferMsg("hp-oo", text, kind);
}
function setPoXferMsg(text, kind) {
setXferMsg("hp-po", text, kind);
}
function ooXferDir() {
const sel = $("hp-oo-xfer-dir");
return (sel && sel.value) || "funding_to_trading";
}
function poXferDir() {
const sel = $("hp-po-xfer-dir");
return (sel && sel.value) || "funding_to_trading";
}
function poXferCcy() {
const sel = $("hp-po-xfer-ccy");
return ((sel && sel.value) || "USDT").toUpperCase();
}
function xferAvailFor(ccy, fromAcct) {
const c = String(ccy || "USDT").toUpperCase();
const from = String(fromAcct || "funding").toLowerCase();
if (c === "USDT") {
if (from === "trading") {
return Number(
state.tradingUsdt != null
? state.tradingUsdt
: state.tradingUsdc != null
? state.tradingUsdc
: 0
);
}
return Number(
state.fundingUsdt != null
? state.fundingUsdt
: state.fundingUsdc != null
? state.fundingUsdc
: 0
);
}
if (c === "ETH") {
return Number(from === "trading" ? state.tradingEth || 0 : state.fundingEth || 0);
}
if (c === "BTC") {
return Number(from === "trading" ? state.tradingBtc || 0 : state.fundingBtc || 0);
}
return 0;
}
async function submitHedgeTransfer(opts) {
const prefix = opts.prefix || "hp-oo";
const dir = opts.dir || "funding_to_trading";
const ccy = String(opts.ccy || "USDT").toUpperCase();
const amount = Number(opts.amount || 0);
const setMsg = prefix === "hp-po" ? setPoXferMsg : setOoXferMsg;
const from = dir === "trading_to_funding" ? "trading" : "funding";
const to = dir === "trading_to_funding" ? "funding" : "trading";
if (!(amount > 0)) {
setMsg("请输入有效数量", "err");
return;
}
setMsg("划转中…", null);
try {
const d = await apiJson("/api/hedge-plan/transfer", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ccy: ccy, from: from, to: to, amount: amount }),
});
if (!d.ok) {
setMsg(d.msg || "划转失败", "err");
return;
}
setMsg("划转成功", "ok");
const amtEl = $(prefix + "-xfer-amount");
if (amtEl) amtEl.value = "";
await loadChain();
} catch (e) {
setMsg(e.message || "划转失败", "err");
}
}
async function submitOoUsdcTransfer(amount) {
return submitHedgeTransfer({ prefix: "hp-oo", dir: ooXferDir(), ccy: "USDT", amount: amount });
}
function setSellCoinMsg(text, kind) {
const el = $("hp-sell-coin-msg");
if (!el) return;
const show = !!(text || "").trim();
el.hidden = !show;
el.textContent = text || "";
el.classList.toggle("is-err", kind === "err");
el.classList.toggle("is-ok", kind === "ok");
el.classList.toggle("muted", !kind);
}
function setSellCoinVisible(visible, meta) {
const btn = $("hp-sell-coin-btn");
if (!btn) return;
const on = !!visible;
btn.hidden = !on;
btn.disabled = !on;
if (on && meta) {
const uly = String(meta.underlying || state.underlying || "ETH").toUpperCase();
const avail = meta.available != null ? Number(meta.available) : null;
const minSz = meta.min_sz != null ? Number(meta.min_sz) : null;
let tip = "市价卖出交易账户全部可用 " + uly + " 换回 USDT";
if (avail != null && !Number.isNaN(avail)) tip += " · 可用 " + avail;
if (minSz != null && !Number.isNaN(minSz)) tip += " · 最小 " + minSz;
btn.title = tip;
}
if (!on) setSellCoinMsg("", null);
}
async function refreshSellCoinVisibility() {
const uly = String(state.underlying || "ETH").toUpperCase();
try {
const d = await apiJson(
"/api/hedge-plan/sell-coin-status?underlying=" + encodeURIComponent(uly)
);
if (!d || !d.ok) {
setSellCoinVisible(false);
return;
}
setSellCoinVisible(!!d.sellable, d);
} catch (_) {
setSellCoinVisible(false);
}
}
async function submitSellCoin() {
const uly = String(state.underlying || "ETH").toUpperCase();
const btn = $("hp-sell-coin-btn");
if (!btn || btn.hidden) return;
if (!window.confirm("确认市价卖出交易账户全部可用 " + uly + " 换回 USDT?")) return;
setSellCoinMsg("卖回中…", null);
btn.disabled = true;
try {
const d = await apiJson("/api/hedge-plan/sell-coin", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ underlying: uly }),
});
if (!d.ok) {
setSellCoinMsg(d.msg || "卖回失败", "err");
await refreshSellCoinVisibility();
return;
}
const sold =
d.sell && d.sell.coin_sold != null
? "已卖 " + d.sell.coin_sold + " " + uly
: d.msg || "卖回成功";
setSellCoinMsg(sold, "ok");
await loadChain();
void loadLivePositions();
await refreshSellCoinVisibility();
} catch (e) {
setSellCoinMsg(e.message || "卖回失败", "err");
await refreshSellCoinVisibility();
}
}
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) +
", 单笔) · 缓冲 HEDGE_PLAN_BUDGET_BUFFER" +
(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);
}
if (d.budget_buffer != null) applyBudgetBuffer(d.budget_buffer);
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();
// 永续行情按实例模式永续标的(跨币=ETH);期权链仍用 optionUnderly
const mktBase =
state.mode === "perp_options" ? state.perpUnderly || state.underlying : state.underlying;
const d = await apiJson(
"/api/hedge-plan/market?base=" +
encodeURIComponent(mktBase) +
"&direction=" +
encodeURIComponent(dir) +
"&option_primary=" +
(isOptionPrimary() ? "1" : "0")
);
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) +
" · 永续" +
(d.perp_direction === "short" ? "空" : "多") +
" · 面值 " +
fmt(d.contract_size, 4);
}
const mIdx = d.index_px != null ? Number(d.index_px) : NaN;
if (Number.isFinite(mIdx) && mIdx > 0) {
const idxLine = $("hp-index-line");
if (idxLine && (!state.chain || !state.chain.index_px)) {
idxLine.textContent = "指数 " + fmt(mIdx, 2);
}
const ooIdx = $("hp-oo-index");
if (ooIdx && (!state.chain || !state.chain.index_px)) {
ooIdx.textContent = "指数 " + fmt(mIdx, 2);
}
}
const quoteHtml =
"卖一 " +
fmt(d.ask, 2) +
" · 买一 " +
fmt(d.bid, 2) +
" · 面值 " +
fmt(d.contract_size, 4) +
" · 精度 " +
amtPrec +
" 位" +
(d.perp_direction
? " · 永续方向 " + (d.perp_direction === "short" ? "空" : "多")
: "");
const q = $("hp-perp-quote");
if (q) {
q.innerHTML = quoteHtml;
// 弹窗内行情已压缩到 mark 行,避免重复挤占
if (q.closest && q.closest(".hp-po-order-dialog")) q.setAttribute("hidden", "");
else q.removeAttribute("hidden");
}
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 (isOptionPrimary()) {
const sized = currentOpSizing();
if (sized) {
sz.innerHTML = formatOpSizingHtml(sized, amtPrec);
} else {
sz.textContent = "填写权利金或张数并选择期权后显示定仓(默认比例2:期权÷2=永续)";
}
} else 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 (!isOptionPrimary() && entry && d.entry_ref && !entry.value) entry.value = d.entry_ref;
if (!isOptionPrimary() && 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();
updatePoOpenSummary();
}
function updatePerpPnlHint() {
const el = $("hp-perp-pnl-line");
if (!el) return;
if (isOptionPrimary()) {
if (state.exitMode === "profit") {
const p = numInput("hp-profit-target", NaN);
el.innerHTML = p > 0
? '目标净利 <strong>' + fmt(p, 2) + '</strong> USDT <span class="muted">(扣开平双边手续费后)</span>'
: '<span class="muted">填写目标盈利金额(扣买入+卖出手续费后的组合净利)</span>';
return;
}
const n = numInput("hp-opt-target-pts", NaN);
const m = numInput("hp-perp-target-pts", NaN);
const k = state.selected && Number(state.selected.strike);
if (!(k > 0) || (!(n >= 0) && !(m >= 0))) {
el.innerHTML = '<span class="muted">选用期权并填目标点数后显示 K±N 出场参考</span>';
return;
}
const dir = getDirection();
const optT = n >= 0 ? (dir === "short" ? k - n : k + n) : null;
const perpT = m >= 0 ? (dir === "short" ? k - m : k + m) : null;
el.innerHTML =
"期权目标指数 " +
(optT != null ? fmt(optT, 2) : "—") +
" · 永续目标指数 " +
(perpT != null ? fmt(perpT, 2) : "—") +
' <span class="muted">(相对K;须买一流动性且扣费净利&gt;0)</span>';
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;
// 手动选约:到期列表不过滤(与期权页一致,默认最近一期);条件单 hidden 字段 min-hours=0
const minH = sel.id === "hp-exp-select" ? numInput("hp-min-hours", 0) : 0;
sel.innerHTML = '<option value="">选择到期日</option>';
let firstOk = null;
let skippedNear = 0;
(chain.expiries || []).forEach(function (e) {
const h = hoursFromExpMs(e.exp_time);
if (minH > 0 && h != null && h < minH) {
skippedNear += 1;
return;
}
const opt = document.createElement("option");
opt.value = String(e.exp_time);
const dt = new Date(Number(e.exp_time) < 1e12 ? Number(e.exp_time) * 1000 : Number(e.exp_time));
opt.textContent = dt.toLocaleString() + (h != null ? " · " + fmt(h, 1) + "h" : "");
sel.appendChild(opt);
if (!firstOk) firstOk = e;
});
if (prev) sel.value = prev;
if (!sel.value && firstOk) {
sel.value = String(firstOk.exp_time);
}
if (skippedNear > 0 && minH > 0) {
const tip = document.createElement("option");
tip.disabled = true;
tip.textContent = "(已隐藏 " + skippedNear + " 个不足 " + minH + "h 的到期)";
sel.appendChild(tip);
}
}
async function loadChain() {
// 拉完整链(按 CHAIN_MAX_DTE);永期「最低剩余小时」只在左侧到期下拉里过滤,不影响期期看到明天到期
const url =
"/api/hedge-plan/options-chain?underlying=" + encodeURIComponent(state.underlying);
const d = await apiJson(url);
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) {
// 盈亏比默认2,不随指数自动改写
if ($("hp-profit-rr") && !$("hp-profit-rr").value) {
$("hp-profit-rr").value = "2";
}
}
void refreshSellCoinVisibility();
}
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;
const m = (c.moneyness || "").toLowerCase();
if (!isOptionPrimary() && m === "otm") {
alert("永期保险腿须为实值或平值,不可选虚值");
return;
}
if (isOptionPrimary() && !matchesMoneyFilter(c)) {
alert("不符合当前筛选(全部/实值/虚值),请切换筛选后再选");
return;
}
state.selected = c;
const el = $("hp-sel-inst");
if (el) el.textContent = c.inst_id;
updatePremiumLine();
if (isOptionPrimary()) {
// 手动下单以张数为主:选约后若未填张数则默认 1
if (!(orderSheets() > 0)) {
if ($("hp-sheets")) $("hp-sheets").value = "1";
if ($("hp-order-sheets")) $("hp-order-sheets").value = "1";
} else {
syncSheetsInputs("hp-order-sheets");
if (!(numInput("hp-order-sheets", 0) > 0)) syncSheetsInputs("hp-sheets");
}
updatePerpPnlHint();
updatePoOpenSummary();
}
openOrderDialog(c.inst_id);
void loadMarket();
}
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;
const cols = isOptionPrimary() ? 7 : 6;
tbody.innerHTML = "";
if (!exp) {
tbody.innerHTML = '<tr><td colspan="' + cols + '" class="muted">请选择到期日</td></tr>';
if (isOptionPrimary()) clearPoSelection();
return;
}
const sameType = (exp.contracts || []).filter(function (c) {
return String(c.opt_type || "").toUpperCase() === want;
});
let list = sameType.filter(matchesMoneyFilter);
if (isOptionPrimary() && state.moneyFilter === "all") {
list = slicePoListByMoneyness(list);
}
if (!list.length) {
const idx = indexPx();
let hint = "无匹配合约";
if (sameType.length) {
hint =
"当前筛选下无 " +
(want === "C" ? "Call" : "Put") +
"(链上 " +
sameType.length +
" 档)·可改全部/实值/虚值或展开全部";
} else {
hint =
"该到期无 " +
(want === "C" ? "Call" : "Put") +
(idx ? " · 指数 " + fmt(idx, 2) : "") +
" · 可换到期或点刷新链";
}
tbody.innerHTML = '<tr><td colspan="' + cols + '" class="muted">' + hint + "</td></tr>";
if (isOptionPrimary()) clearPoSelection();
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);
if (isOptionPrimary()) {
tr.innerHTML =
"<td>" +
c.strike +
"</td><td>" +
moneynessBadge(c) +
"</td><td><code class=\"opt-inst-code\">" +
(c.inst_id || "") +
'</code></td><td class="opt-px-sz">' +
fmtPxSz(c.ask, c.ask_sz, c.ask_estimated, c.tick_sz) +
'</td><td class="opt-chain-lev" title="指数÷卖一">' +
calcIndexAskLeverage(c.ask) +
'</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>';
} else {
tr.innerHTML =
"<td>" +
c.strike +
"</td><td>" +
moneynessBadge(c) +
'</td><td class="opt-chain-lev" title="指数÷卖一">' +
calcIndexAskLeverage(c.ask) +
'</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);
});
});
autoMatchPoOption(false);
}
function updatePremiumLine() {
const line = $("hp-premium-line");
if (!line || !state.selected) {
if (line) line.textContent = "";
return;
}
const sheets = Math.max(1, orderSheets() || 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;
// U 以买币参考价(指数)为准,与开仓买币口径一致
const buyPx = Number(
(state.market && (state.market.index_px || state.market.mark)) || indexPx() || 0
);
const premU = buyPx > 0 ? prem * buyPx : null;
line.textContent =
"权利金 ≈ " +
fmt(prem, 4) +
" 币" +
(premU != null ? " / " + fmt(premU, 2) + " U" : "") +
" · 卖一 " +
fmt(ask, 4) +
(buyPx > 0 ? " · 买币参考 " + fmt(buyPx, 2) : "");
}
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 selectedOoLegMap() {
const map = {};
if (state.legA && state.legA.inst_id) map[String(state.legA.inst_id)] = "A";
if (state.legB && state.legB.inst_id) map[String(state.legB.inst_id)] = "B";
return map;
}
function syncOoPickHighlight() {
const tbody = $("hp-oo-tbody");
if (!tbody) return;
const map = selectedOoLegMap();
tbody.querySelectorAll(".hp-oo-pick").forEach(function (btn) {
const inst = String(btn.getAttribute("data-inst") || "");
const leg = map[inst];
const on = !!leg;
btn.classList.toggle("is-selected", on);
btn.classList.toggle("active", on);
btn.setAttribute("aria-pressed", on ? "true" : "false");
const side = String(btn.getAttribute("data-side") || "").toUpperCase();
const base = side === "P" ? "Put" : "Call";
btn.textContent = on ? "腿" + leg + " · " + base : base;
btn.title = on ? "已选用为腿" + leg + "(再点其他合约可改选)" : "选用此 " + base;
});
tbody.querySelectorAll("tr").forEach(function (tr) {
const callBtn = tr.querySelector('.hp-oo-pick[data-side="C"]');
const putBtn = tr.querySelector('.hp-oo-pick[data-side="P"]');
const callOn = !!(callBtn && map[String(callBtn.getAttribute("data-inst") || "")]);
const putOn = !!(putBtn && map[String(putBtn.getAttribute("data-inst") || "")]);
tr.classList.toggle("hp-oo-row-selected", callOn || putOn);
tr.querySelectorAll("td.opt-t-call").forEach(function (td) {
td.classList.toggle("hp-oo-side-selected", callOn);
});
if (callBtn && callBtn.parentElement) {
callBtn.parentElement.classList.toggle("hp-oo-side-selected", callOn);
}
tr.querySelectorAll("td.opt-t-put").forEach(function (td) {
td.classList.toggle("hp-oo-side-selected", putOn);
});
if (putBtn && putBtn.parentElement) {
putBtn.parentElement.classList.toggle("hp-oo-side-selected", putOn);
}
});
}
function renderTStrikes() {
const tbody = $("hp-oo-tbody");
if (!tbody) return;
const exp = currentExp("hp-oo-exp-select");
const cols = 9;
tbody.innerHTML = "";
if (!exp) {
tbody.innerHTML = '<tr><td colspan="' + cols + '" class="muted">请选择到期日</td></tr>';
return;
}
const prepared = [];
buildStraddleRows(exp.contracts).forEach(function (row) {
const callOk = !!(row.call && matchesOoMoneyFilter(row.call));
const putOk = !!(row.put && matchesOoMoneyFilter(row.put));
if (!callOk && !putOk) return;
prepared.push({
strike: row.strike,
call: row.call,
put: row.put,
callOk: callOk,
putOk: putOk,
});
});
const rows = sliceOoTRows(prepared, indexPx());
const selected = selectedOoLegMap();
if (!rows.length) {
tbody.innerHTML = '<tr><td colspan="' + cols + '" class="muted">该筛选下暂无平值/虚值合约</td></tr>';
return;
}
rows.forEach(function (row) {
const tr = document.createElement("tr");
const call = row.call;
const put = row.put;
const callOk = row.callOk;
const putOk = row.putOk;
const callAsk = callOk ? fmtPxSz(call.ask, call.ask_sz, call.ask_estimated, call.tick_sz) : "—";
const putAsk = putOk ? fmtPxSz(put.ask, put.ask_sz, put.ask_estimated, put.tick_sz) : "—";
const callLev = callOk ? calcStrikeAskLeverage(row.strike, call.ask) : "—";
const putLev = putOk ? calcStrikeAskLeverage(row.strike, put.ask) : "—";
const callLeg = callOk ? selected[String(call.inst_id)] : "";
const putLeg = putOk ? selected[String(put.inst_id)] : "";
tr.innerHTML =
'<td class="opt-t-call opt-px-sz">' +
callAsk +
'</td><td class="opt-t-call opt-chain-lev" title="K÷卖一">' +
callLev +
'</td><td class="opt-t-call">' +
(callOk ? moneynessBadge(call) : "—") +
"</td><td>" +
(callOk
? '<button type="button" class="btn-secondary hp-oo-pick' +
(callLeg ? " is-selected active" : "") +
'" data-side="C" data-inst="' +
call.inst_id +
'" aria-pressed="' +
(callLeg ? "true" : "false") +
'">' +
(callLeg ? "腿" + callLeg + " · Call" : "Call") +
"</button>"
: "—") +
'</td><td class="opt-t-strike"><strong>' +
row.strike +
'</strong></td><td class="opt-t-put">' +
(putOk ? moneynessBadge(put) : "—") +
'</td><td class="opt-t-put opt-px-sz">' +
putAsk +
'</td><td class="opt-t-put opt-chain-lev" title="K÷卖一">' +
putLev +
"</td><td>" +
(putOk
? '<button type="button" class="btn-secondary hp-oo-pick' +
(putLeg ? " is-selected active" : "") +
'" data-side="P" data-inst="' +
put.inst_id +
'" aria-pressed="' +
(putLeg ? "true" : "false") +
'">' +
(putLeg ? "腿" + putLeg + " · Put" : "Put") +
"</button>"
: "—") +
"</td>";
tbody.appendChild(tr);
});
if (!state.ooStrikeExpandAll && prepared.length > rows.length) {
const hint = document.createElement("tr");
hint.className = "opt-strike-hint-row";
hint.innerHTML =
'<td colspan="' +
cols +
'" class="muted opt-strike-hint">默认显示 Call/Put 各最近 3 档 · 点「显示全部」查看该到期更多行权价(若当前为「仅平值」会自动切到「平/虚」)</td>';
tbody.appendChild(hint);
} else if (state.ooStrikeExpandAll && state.ooMoneyFilter === "otm") {
const hint = document.createElement("tr");
hint.className = "opt-strike-hint-row";
hint.innerHTML =
'<td colspan="' +
cols +
'" class="muted opt-strike-hint">当前为「仅虚值」筛选 · 切到「平/虚」可看平值档</td>';
tbody.appendChild(hint);
}
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 || !matchesOoMoneyFilter(c)) {
alert("期期仅可选平值或虚值,不可选实值");
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;
}
state.ooRecommend = null;
syncOoRecommendUI();
renderOoLegs();
});
});
syncOoPickHighlight();
}
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();
syncOoPickHighlight();
}
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) +
" 币";
}
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) {
let next = (uly || "ETH").toUpperCase();
// 永期:标的由 HEDGE_PO_MODE 锁定,页内不可切换
if (state.mode === "perp_options") {
next = state.optionUnderly || next;
if (!forceReload && next === state.underlying) return;
}
const changed = next !== state.underlying;
state.underlying = next;
syncUnderlyingUI();
if (!changed && !forceReload) return;
if (state.orderDialogOpen) closeOrderDialog();
else state.selected = null;
state.legA = null;
state.legB = null;
state.ooRecommend = null;
syncOoRecommendUI();
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-profit-rr")) $("hp-profit-rr").value = "2";
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("请选用两条期权腿");
if (!matchesOoMoneyFilter(state.legA) || !matchesOoMoneyFilter(state.legB)) {
throw new Error("期期两腿须为平值或虚值,不可选实值");
}
const rr = Number(($("hp-profit-rr") && $("hp-profit-rr").value) || 0);
if (!(rr > 0)) throw new Error("请填写盈亏比(须大于0,默认2)");
body = {
plan_type: "options_options",
profit_rr: rr,
index_px: indexPx() || 0,
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("请选用期权腿");
if (isOptionPrimary()) {
const sized = currentOpSizing();
if (!sized) throw new Error("请选择期权并填写张数(或权利金预算)");
const optPts = numInput("hp-opt-target-pts", NaN);
const perpPts = numInput("hp-perp-target-pts", NaN);
if (!(optPts > 0) || !(perpPts > 0)) throw new Error("请填写期权/永续目标位点数(须大于0)");
const exp = currentExp("hp-exp-select");
const premEst =
Number(state.selected.ask || 0) *
sized.sheets *
Number(state.selected.ct_mult || 0.01);
const premBudget = numInput("hp-premium-budget", 0);
body = {
plan_type: "perp_options",
option_primary: true,
market_order: 1,
direction: getDirection(),
entry: indexPx() || Number((state.market && state.market.mark) || 0),
contracts: sized.contracts,
sheets: sized.sheets,
contract_size: (state.market && state.market.contract_size) || 0.01,
opt_type: state.selected.opt_type,
strike: state.selected.strike,
ct_mult: state.selected.ct_mult || 0.01,
ask: state.selected.ask,
index_px: indexPx() || 0,
premium_budget: premBudget > 0 ? premBudget : Math.max(premEst / 0.95, premEst),
option_perp_ratio: numInput("hp-opt-perp-ratio", 2),
option_target_points: optPts,
perp_target_points: perpPts,
strike_interval: 0,
min_option_hours: 0,
option_leverage: 0,
leverage: numInput("hp-perp-leverage", defaultPerpLeverage(state.underlying)),
moneyness: (state.selected.moneyness || opMoneyKind() || "all").toLowerCase(),
hours_to_expiry: exp ? hoursFromExpMs(exp.exp_time) : null,
};
} else {
const mSel = (state.selected.moneyness || "").toLowerCase();
if (mSel === "otm") 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: indexPx() || entry,
};
}
}
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" && (d.option_primary || s.opt_target_total != null)) {
const sz = d.sizing || {};
summary.innerHTML =
"期权目标净利 " +
fmtPnlHtml(s.opt_target_total) +
" · 永续目标净利 " +
fmtPnlHtml(s.perp_target_total) +
" · 保费 " +
fmt(s.premium_paid) +
(sz.eth_qty != null ? " · ETH " + fmt(sz.eth_qty, 2) : "") +
(s.perp_direction ? " · 永续" + (s.perp_direction === "short" ? "空" : "多") : "");
} else 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 rrTarget = s.profit_rr != null ? s.profit_rr : null;
let rrLine = "";
if (rrTarget != null) {
rrLine =
" · 目标盈亏比 " +
fmt(rrTarget, 2) +
'<span class="muted">(盈利金额/总权利金)</span>';
} else if (s.rr_at_up != null || s.rr_at_down != null) {
rrLine =
" · 盈亏比 上破 " +
fmtRr(s.rr_at_up) +
(s.at_target_down_total != null ? " / 下破 " + fmtRr(s.rr_at_down) : "") +
'<span class="muted">(亏=全额保费 ' +
fmt(s.rr_risk_premium != null ? s.rr_risk_premium : s.premium_paid) +
")</span>";
}
const aTot = s.at_rr_a_full_total != null ? s.at_rr_a_full_total : s.at_target_up_total;
const bTot = s.at_rr_b_full_total != null ? s.at_rr_b_full_total : s.at_target_down_total;
summary.innerHTML =
(rrTarget != null ? "腿A达标 " : "上破 ") +
fmtPnlHtml(aTot) +
(bTot != null ? (rrTarget != null ? " · 腿B达标 " : " · 下破 ") + fmtPnlHtml(bTot) : "") +
" · 到期现价 " +
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 hardenAmountAutofill(ids) {
(ids || []).forEach(function (id) {
const el = $(id);
if (!el) return;
function wipe() {
const v = String(el.value || "").trim();
// 浏览器常把登录用户名(如 dekun)灌进数量框
if (/^[a-z][a-z0-9._-]{1,31}$/i.test(v)) el.value = "";
}
wipe();
const lockRo = id.indexOf("xfer") >= 0 || el.hasAttribute("readonly");
if (lockRo) {
el.setAttribute("readonly", "readonly");
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);
});
}
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();
if (state.tab === "perp_options") void refreshPoStrategyStatus();
} else {
const el = $("hp-gate-line");
if (el) el.textContent = "";
}
});
});
document.querySelectorAll(".hp-pos-tab").forEach(function (btn) {
btn.addEventListener("click", function () {
setHpPosTab(btn.getAttribute("data-hp-pos-tab") || "live");
});
});
if ($("hp-pos-refresh")) {
$("hp-pos-refresh").addEventListener("click", function () {
refreshHpPosPanel();
});
}
function onOrderDialogClose() {
closeOrderDialog();
}
if ($("hp-order-close-btn")) $("hp-order-close-btn").addEventListener("click", onOrderDialogClose);
if ($("hp-order-cancel-btn")) $("hp-order-cancel-btn").addEventListener("click", onOrderDialogClose);
const orderHost = orderPanelHost();
if (orderHost) {
orderHost.addEventListener("click", function (ev) {
if (ev.target === orderHost) closeOrderDialog();
});
}
document.addEventListener("keydown", function (ev) {
if (ev.key === "Escape" && state.orderDialogOpen) closeOrderDialog();
});
document.querySelectorAll(".hp-uly-btn, .hp-uly-btn-oo").forEach(function (b) {
b.addEventListener("click", function () {
if (b.classList.contains("hp-uly-btn") && state.mode === "perp_options") {
return; // 永期标的由实例模式锁定
}
setUnderlying(b.getAttribute("data-uly") || "ETH", true);
});
});
document.querySelectorAll(".hp-money-btn").forEach(function (b) {
b.addEventListener("click", function () {
const m = b.getAttribute("data-money") || "all";
if (m === "otm" && !isOptionPrimary()) {
alert("永期保险腿仅允许实值或平值;请在 env 将 HEDGE_PLAN_OPTION_PRIMARY=true");
return;
}
if (isOptionPrimary()) {
state.moneyFilter = m === "otm" ? "otm" : m === "itm" ? "itm" : m === "atm" ? "atm" : "all";
} else {
state.moneyFilter = m === "atm" ? "atm" : "itm";
}
if (isOptionPrimary()) applyOpDefaultsFromMoney(false);
syncMoneyUI();
renderListStrikes();
});
});
if ($("hp-strike-expand-all")) {
$("hp-strike-expand-all").addEventListener("change", function () {
state.poStrikeExpandAll = !!$("hp-strike-expand-all").checked;
if (state.poStrikeExpandAll && state.moneyFilter !== "all" && isOptionPrimary()) {
state.moneyFilter = "all";
syncMoneyUI();
}
renderListStrikes();
});
}
if ($("hp-perp-leverage")) {
$("hp-perp-leverage").addEventListener("input", function () {
state.perpLevTouched = true;
});
}
if ($("hp-opt-leverage")) {
$("hp-opt-leverage").addEventListener("input", function () {
state.opLevTouched = true;
if (isOptionPrimary()) renderListStrikes();
});
}
if ($("hp-opt-perp-ratio")) {
$("hp-opt-perp-ratio").addEventListener("input", function () {
state.opRatioTouched = true;
const sz = $("hp-sizing-line");
const sized = currentOpSizing();
if (sz && sized) sz.innerHTML = formatOpSizingHtml(sized);
updatePoOpenSummary();
void loadMarket();
});
}
let _poChainReloadTimer = null;
function schedulePoChainReload() {
if (_poChainReloadTimer) clearTimeout(_poChainReloadTimer);
_poChainReloadTimer = setTimeout(function () {
_poChainReloadTimer = null;
void loadChain();
}, 350);
}
["hp-premium-budget", "hp-strike-interval", "hp-min-hours", "hp-opt-target-pts", "hp-perp-target-pts"].forEach(
function (id) {
const el = $(id);
if (!el) return;
el.addEventListener("input", function () {
if (id === "hp-min-hours" || id === "hp-strike-interval") {
schedulePoChainReload();
}
if (id === "hp-premium-budget") renderListStrikes();
if (state.selected && id === "hp-premium-budget") {
const sized = computeOpSizing(state.selected.ask, state.selected.ct_mult || 0.01);
if (sized && $("hp-sheets")) $("hp-sheets").value = String(sized.sheets);
void loadMarket();
}
updatePerpPnlHint();
});
}
);
document.querySelectorAll(".hp-oo-money-btn").forEach(function (b) {
b.addEventListener("click", function () {
const m = b.getAttribute("data-oo-money") || "atm_otm";
if (m === "itm") {
alert("期期两腿仅允许平值或虚值");
return;
}
state.ooMoneyFilter = m;
syncMoneyUI();
renderTStrikes();
});
});
if ($("hp-recommend-opt")) {
$("hp-recommend-opt").addEventListener("click", function () {
if (isOptionPrimary()) {
autoMatchPoOption(true);
if (!state.selected) {
alert("当前选约条件下无匹配合约,请调整类型/间隔或换到期");
}
return;
}
const want = optTypeForDirection(getDirection());
const c = pickClosestItmAtm(currentContracts("hp-exp-select"), want);
if (!c) {
alert("当前到期日无可用实值/平值合约,请换到期或刷新链");
return;
}
pickContract(c);
});
}
if ($("hp-oo-recommend-atm")) {
$("hp-oo-recommend-atm").addEventListener("click", function () {
const pair = pickOoTemplate("atm_straddle");
if (!pair) {
alert("无法推荐平值跨式,请确认到期日与链数据");
return;
}
state.legA = pair.call;
state.legB = pair.put;
state.ooRecommend = "atm_straddle";
syncOoRecommendUI();
renderOoLegs();
autoFillOoSheets();
updateOoPremiumLine();
});
}
if ($("hp-oo-recommend-otm")) {
$("hp-oo-recommend-otm").addEventListener("click", function () {
const pair = pickOoTemplate("double_otm");
if (!pair) {
alert("无法推荐双虚值,请确认到期日与链数据");
return;
}
state.legA = pair.call;
state.legB = pair.put;
state.ooRecommend = "double_otm";
syncOoRecommendUI();
renderOoLegs();
autoFillOoSheets();
updateOoPremiumLine();
});
}
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);
});
syncOptionPrimaryUI();
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-oo-expand-all")) {
$("hp-oo-expand-all").addEventListener("click", function () {
state.ooStrikeExpandAll = !state.ooStrikeExpandAll;
// 「仅平值」时本来就只有 1~3 档,展开几乎无变化;展开时自动切到平/虚以便看到全部可选档
if (state.ooStrikeExpandAll && state.ooMoneyFilter === "atm") {
state.ooMoneyFilter = "atm_otm";
syncMoneyUI();
}
syncOoExpandUI();
renderTStrikes();
});
}
if ($("hp-exp-select")) $("hp-exp-select").addEventListener("change", renderListStrikes);
if ($("hp-oo-exp-select")) $("hp-oo-exp-select").addEventListener("change", renderTStrikes);
function onSheetsInput(fromId) {
syncSheetsInputs(fromId);
updatePremiumLine();
if (isOptionPrimary()) {
const sz = $("hp-sizing-line");
const sized = currentOpSizing();
if (sz && sized) sz.innerHTML = formatOpSizingHtml(sized);
updatePoOpenSummary();
void loadMarket();
}
}
if ($("hp-sheets")) {
$("hp-sheets").addEventListener("input", function () {
onSheetsInput("hp-sheets");
});
}
if ($("hp-order-sheets")) {
$("hp-order-sheets").addEventListener("input", function () {
onSheetsInput("hp-order-sheets");
});
}
document.querySelectorAll(".hp-po-exit-mode").forEach(function (b) {
b.addEventListener("click", function () {
// 实例模式强制组合盈利金额(U)出场
state.exitMode = "profit";
syncExitModeUI();
updatePerpPnlHint();
});
});
if ($("hp-profit-target")) {
$("hp-profit-target").addEventListener("input", updatePerpPnlHint);
}
syncExitModeUI();
["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);
void submitHedgeTransfer({ prefix: "hp-oo", dir: ooXferDir(), ccy: "USDT", amount: amount });
});
}
if ($("hp-oo-xfer-all")) {
$("hp-oo-xfer-all").addEventListener("click", function () {
const dir = ooXferDir();
const from = dir === "trading_to_funding" ? "trading" : "funding";
const max = xferAvailFor("USDT", from);
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 + " USDT")) return;
const amtEl = $("hp-oo-xfer-amount");
if (amtEl) {
amtEl.removeAttribute("readonly");
amtEl.value = String(amt);
}
void submitHedgeTransfer({ prefix: "hp-oo", dir: dir, ccy: "USDT", amount: amt });
});
}
if ($("hp-po-xfer-btn")) {
$("hp-po-xfer-btn").addEventListener("click", function () {
const amount = Number(($("hp-po-xfer-amount") && $("hp-po-xfer-amount").value) || 0);
void submitHedgeTransfer({
prefix: "hp-po",
dir: poXferDir(),
ccy: poXferCcy(),
amount: amount,
});
});
}
if ($("hp-po-xfer-all")) {
$("hp-po-xfer-all").addEventListener("click", function () {
const dir = poXferDir();
const ccy = poXferCcy();
const from = dir === "trading_to_funding" ? "trading" : "funding";
const max = xferAvailFor(ccy, from);
if (!(max > 0)) {
setPoXferMsg("划出账户可用余额不足", "err");
return;
}
const amt = ccy === "USDT" ? Math.floor(max * 100) / 100 : Math.floor(max * 1e6) / 1e6;
const label = dir === "trading_to_funding" ? "交易 → 资金" : "资金 → 交易";
if (!window.confirm("确认全部划转?\n币种:" + ccy + "\n方向:" + label + "\n数量:" + amt)) return;
const amtEl = $("hp-po-xfer-amount");
if (amtEl) {
amtEl.removeAttribute("readonly");
amtEl.value = String(amt);
}
void submitHedgeTransfer({ prefix: "hp-po", dir: dir, ccy: ccy, amount: amt });
});
}
if ($("hp-sell-coin-btn")) {
$("hp-sell-coin-btn").addEventListener("click", function () {
void submitSellCoin();
});
}
hardenAmountAutofill([
"hp-oo-xfer-amount",
"hp-po-xfer-amount",
"hp-entry",
"hp-contracts",
"hp-tp",
"hp-sl",
"hp-sheets",
"hp-profit-rr",
]);
if ($("hp-preview-btn"))
$("hp-preview-btn").addEventListener("click", function () {
state.mode = "perp_options";
// 以期权为主 = 手动下单(左侧人工选约后按卖一限价开);条件盯盘不再从此入口
if (isOptionPrimary()) void startPlan("perp_options", false);
else 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();
});
}
document.querySelectorAll(".hp-exit-mode-btn").forEach(function (b) {
b.addEventListener("click", function () {
setExitModalMode(b.getAttribute("data-exit") === "target" ? "target" : "profit");
});
});
if ($("hp-exit-close")) $("hp-exit-close").addEventListener("click", closeExitModal);
if ($("hp-exit-cancel")) $("hp-exit-cancel").addEventListener("click", closeExitModal);
if ($("hp-exit-save")) $("hp-exit-save").addEventListener("click", function () {
void saveExitModal();
});
const exitModal = $("hp-exit-modal");
if (exitModal) {
exitModal.addEventListener("click", function (ev) {
if (ev.target === exitModal) closeExitModal();
});
}
}
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" && (p.option_primary == 1 || p.option_primary === true || Number(p.option_primary) === 1)) {
return (
"期权K±" +
fmt(p.option_target_points, 0) +
" · 永续K±" +
fmt(p.perp_target_points, 0)
);
}
if (p.plan_type === "perp_options") {
return "止盈 " + fmt(p.tp) + " · 止损 " + fmt(p.sl);
}
if (p.profit_rr != null && Number(p.profit_rr) > 0) {
return "盈亏比 " + fmt(p.profit_rr, 2);
}
return "上破 " + fmt(p.target_price_up || p.target_price) + " · 下破 " + fmt(p.target_price_down || p.target_price);
}
function activeStatusLabel(p) {
if ((p.status || "") === "watching") {
return '<span class="hp-plan-watching">盯盘中</span>';
}
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将按顺序:平期权(须买一流动性)→平永续→卖币回 USDT。\n买一不足时会中止并保留计划。"
)
) {
return;
}
try {
const d = await apiJson("/api/hedge-plan/" + planId + "/close", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
var tip = d.msg || "已平仓 #" + (d.plan_id || planId);
if (Array.isArray(d.notes) && d.notes.length) {
tip += "\n" + d.notes.join("\n");
}
alert(tip);
void loadLivePositions();
void loadActivePlans();
void loadHistory();
void loadGates();
void refreshPoStrategyStatus();
} catch (e) {
alert(e.message || String(e));
void loadLivePositions();
void loadActivePlans();
}
}
function legStatusLabel(st) {
const map = {
open: "持仓中",
pending: "待补/未成交",
cancelled: "未成交取消",
canceled: "未成交取消",
closed: "已平仓",
};
return map[st] || st || "—";
}
function bindActivePlanRowActions(tbody) {
if (!tbody) return;
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")));
});
});
}
function isPendingPlan(p) {
const st = String((p && p.status) || "").toLowerCase();
return st === "watching" || st === "pending";
}
function renderActivePlanRows(tbody, rows, emptyMsg, colMode) {
if (!tbody) return;
if (!rows.length) {
tbody.innerHTML = '<tr><td colspan="8" class="muted">' + (emptyMsg || "暂无") + "</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 midCol =
colMode === "pending"
? p.note || activeTargetLabel(p) || "盯盘/待成交"
: activeTargetLabel(p);
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>" +
midCol +
"</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);
});
bindActivePlanRowActions(tbody);
}
async function loadActivePlans() {
const liveBody = $("hp-active-tbody");
const pendingBody = $("hp-pending-tbody");
if (!liveBody && !pendingBody) return;
try {
const d = await apiJson("/api/hedge-plan/active");
const rows = d.plans || [];
const pending = rows.filter(isPendingPlan);
const live = rows.filter(function (p) {
return !isPendingPlan(p);
});
renderActivePlanRows(liveBody, live, "暂无当前持仓", "live");
renderActivePlanRows(pendingBody, pending, "暂无当前委托", "pending");
} catch (e) {
const msg = '<tr><td colspan="8" class="err">' + (e.message || e) + "</td></tr>";
if (liveBody) liveBody.innerHTML = msg;
if (pendingBody) pendingBody.innerHTML = msg;
}
}
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: "期期下破·平盈利腿",
profit_rr_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 {
if (p.profit_rr != null && Number(p.profit_rr) > 0) {
html +=
"<div><span class=\"muted\">盈亏比</span> " +
fmt(p.profit_rr, 2) +
" <span class=\"muted\">(盈利金额/总权利金)</span></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) +
" 币</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.posTab === "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 startOptionPrimaryWatch() {
try {
const optPts = numInput("hp-opt-target-pts", NaN);
const perpPts = numInput("hp-perp-target-pts", NaN);
const prem = numInput("hp-premium-budget", 0);
const optLev = numInput("hp-opt-leverage", 200);
if (!(prem > 0)) throw new Error("请填写权利金预算");
if (!(optPts > 0) || !(perpPts > 0)) throw new Error("请填写期权/永续目标位点数(须大于0)");
if (!(optLev > 0)) throw new Error("请填写期权杠杆门槛");
if (!(state.market && state.market.exchange_symbol)) throw new Error("永续行情未就绪,请先刷新");
if (!state.canStart) {
throw new Error("当前不可启动(门禁未满足),请查看上方提示");
}
const msg =
"确认启动盯盘?\n" +
"类型 " +
(opMoneyKind() === "otm" ? "虚值" : opMoneyKind() === "atm" ? "平值" : "实/平") +
" · 间隔 " +
numInput("hp-strike-interval", 15) +
" · 杠杆≥" +
optionPrimaryMinLev() +
"\n达标后自动开仓(非现场立即开)";
if (!window.confirm(msg)) return;
const body = {
plan_type: "perp_options",
option_primary: true,
watch_entry: 1,
underlying: state.underlying,
direction: getDirection(),
exchange_symbol: state.market.exchange_symbol,
contract_size: state.market.contract_size || 0.01,
index_px: indexPx() || Number(state.market.mark || 0),
entry: indexPx() || Number(state.market.mark || 0),
premium_budget: prem,
option_perp_ratio: numInput("hp-opt-perp-ratio", 2),
option_target_points: optPts,
perp_target_points: perpPts,
strike_interval: numInput("hp-strike-interval", 15),
min_option_hours: numInput("hp-min-hours", 36),
option_leverage: optLev,
leverage: numInput("hp-perp-leverage", defaultPerpLeverage(state.underlying)),
moneyness: opMoneyKind(),
};
const d = await apiJson("/api/hedge-plan/start", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
setGateLine(d.gates);
setPoStrategyStatus("watching", d.plan_id || null);
alert((d.msg || "已启动盯盘") + (d.plan_id ? "\n计划 #" + d.plan_id : ""));
void refreshPoStrategyStatus();
void loadGates();
} catch (e) {
alert(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("请选用两条期权腿");
if (!matchesOoMoneyFilter(state.legA) || !matchesOoMoneyFilter(state.legB)) {
throw new Error("期期两腿须为平值或虚值,不可选实值");
}
const rr = Number(($("hp-profit-rr") && $("hp-profit-rr").value) || 0);
if (!(rr > 0)) throw new Error("请填写盈亏比(须大于0,默认2)");
body = {
plan_type: "options_options",
underlying: state.underlying,
profit_rr: rr,
index_px: indexPx() || 0,
oo_close_mode: state.ooCloseModeEnabled ? state.ooCloseMode : "hold_expiry",
oo_sheets_mode: state.ooSheetsMode || "same_sheets",
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("请选用期权腿");
if (isOptionPrimary()) {
setPoOrderErr("");
const sized = currentOpSizing();
if (!sized || !(sized.sheets > 0)) {
setPoOrderErr("请选择期权并填写张数(≥1)", "hp-order-sheets");
throw new Error("请选择期权并填写张数(≥1)");
}
const exitMode = "profit";
let optPts = 0;
let perpPts = 0;
let profitTarget = 0;
profitTarget = numInput("hp-profit-target", NaN);
if (!(profitTarget > 0)) {
setPoOrderErr("请填写目标盈利金额(USDT,组合净利折U)", "hp-profit-target");
throw new Error("请填写目标盈利金额(USDT,组合净利折U)");
}
if (!(state.market && state.market.exchange_symbol)) {
setPoOrderErr("永续行情未就绪,请先点「刷新行情」");
throw new Error("永续行情未就绪,请先点「刷新行情」");
}
if (!state.canStart) {
const gateTxt =
($("hp-po-gate-inline") && $("hp-po-gate-inline").textContent) ||
($("hp-gate-line") && $("hp-gate-line").textContent) ||
"请先刷新行情/检查账户";
setPoOrderErr("门禁未满足,不可开仓: " + gateTxt);
throw new Error("门禁未满足,不可开仓: " + gateTxt);
}
const exp = currentExp("hp-exp-select");
const entry = indexPx() || Number((state.market && state.market.mark) || 0);
const premEst =
Number(state.selected.ask || 0) *
sized.sheets *
Number(state.selected.ct_mult || 0.01);
body = {
plan_type: "perp_options",
option_primary: true,
market_order: 1,
watch_entry: 0,
exit_mode: exitMode,
po_mode: state.poMode,
underlying: state.optionUnderly || state.underlying,
option_underlying: state.optionUnderly || state.underlying,
perp_underlying: state.perpUnderly || state.underlying,
direction: getDirection(),
entry: Number((state.market && state.market.mark) || entry),
contracts: sized.contracts,
sheets: sized.sheets,
contract_size: (state.market && state.market.contract_size) || 0.01,
ct_mult: state.selected.ct_mult || 0.01,
opt_inst_id: state.selected.inst_id,
opt_type: state.selected.opt_type,
strike: state.selected.strike,
ask: state.selected.ask,
index_px: entry,
exchange_symbol: (state.market && state.market.exchange_symbol) || "",
leverage: defaultPerpLeverage(state.perpUnderly || state.underlying),
option_leverage: 0,
premium_budget: Math.max(premEst / 0.95, premEst),
option_perp_ratio: numInput("hp-opt-perp-ratio", lockedRatio),
option_target_points: optPts || 0,
perp_target_points: perpPts || 0,
profit_target_usdt: profitTarget || 0,
strike_interval: 0,
min_option_hours: 0,
moneyness: (state.selected.moneyness || opMoneyKind() || "all").toLowerCase(),
hours_to_expiry: exp ? hoursFromExpMs(exp.exp_time) : null,
spot_buy_buffer: 1.05,
};
} else {
const m = (state.selected.moneyness || "").toLowerCase();
if (m === "otm") 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,
ask: state.selected.ask,
index_px: indexPx() || entry,
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) {
const confMsg = isOptionPrimary()
? "确认手动下单(卖一限价)?\n期权 " +
(state.selected && state.selected.inst_id) +
" × " +
(body.sheets || "") +
" 张 + 永续对冲\n(期权腿币本位 / 永续腿合约账户)"
: "确认启动对冲计划并真实下单?\n(将按期权账户/合约账户分别下单)";
if (!window.confirm(confMsg)) 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();
const refreshHint =
d.refresh && d.refresh.msg ? "\n" + String(d.refresh.msg) : "";
if (state.orderDialogOpen) closeOrderDialog();
if (d.partial) {
alert(
(d.msg || "半腿失败,已挂待补") +
(d.plan_id ? "\n计划 #" + d.plan_id : "") +
refreshHint +
"\n请到右侧「当前持仓」补开缺失腿"
);
} else {
alert(
"计划已启动 #" +
(d.plan_id || "") +
(d.dry_run ? " (dry_run)" : "") +
refreshHint
);
}
if (showPerp) {
state.tab = "perp_options";
syncTabUI();
setHpPosTab(d.partial ? "live" : "live");
}
refreshHpPosPanel();
void loadGates();
if (isOo) void loadChain();
} 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="6" class="err">' + (e.message || e) + "</td></tr>";
}
if (state.tab === "perp_options") void refreshPoStrategyStatus();
}
lockPoUnderlyingUI();
syncTabUI();
syncUnderlyingUI();
syncMoneyUI();
syncOoRecommendUI();
syncOoExpandUI();
syncPoDirUI();
syncOoSizeModeUI();
syncOoCloseModeUI();
applyBudgetBuffer(state.budgetBuffer);
updateOoBudgetLine(null);
bind();
syncOptionPrimaryUI();
setHpPosTab(state.posTab || "live");
parkOrderPanel();
void refreshAll().then(function () {
void refreshPoStrategyStatus();
refreshHpPosPanel();
});
setInterval(function () {
if (state.tab === "perp_options" && isOptionPrimary()) void refreshPoStrategyStatus();
}, 15000);
setInterval(function () {
if (state.tab !== "perp_options") return;
if (state.posTab === "live") void loadLivePositions();
else if (state.posTab === "plans" || state.posTab === "pending") void loadActivePlans();
}, 8000);
})();