fc83905cf6
Show the default five chain rows without a scroll box, add funding transfer under manual pick, and wire sell-back-coin above positions via hedge APIs. Co-authored-by: Cursor <cursoragent@cursor.com>
3928 lines
140 KiB
JavaScript
3928 lines
140 KiB
JavaScript
/**
|
||
* 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 state = {
|
||
tab: pickDefaultTab(),
|
||
mode: showPerp ? "perp_options" : showOo ? "options_options" : "perp_options",
|
||
posTab: "live",
|
||
underlying: root.getAttribute("data-default-underly") || "ETH",
|
||
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", // profit=盈利金额出场 | target=目标位出场
|
||
opLevTouched: false,
|
||
opRatioTouched: false,
|
||
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 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", 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;
|
||
}
|
||
const perpEth = eth / ratio;
|
||
const contracts = perpEth / cs;
|
||
return {
|
||
usable: usable,
|
||
eth_qty: eth,
|
||
perp_qty: perpEth,
|
||
sheets: sheets,
|
||
contracts: contracts,
|
||
premium_est: a > 0 ? a * sheets * ct : 0,
|
||
ratio: ratio,
|
||
};
|
||
}
|
||
|
||
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 coin = (state.underlying || "ETH").toUpperCase();
|
||
const prec = amtPrec != null ? amtPrec : coin === "BTC" ? 4 : 2;
|
||
return (
|
||
"期权 <strong>" +
|
||
fmt(sized.eth_qty, 2) +
|
||
"</strong> " +
|
||
coin +
|
||
" / " +
|
||
sized.sheets +
|
||
" 张 → 永续 <strong>" +
|
||
fmt(sized.perp_qty, 4) +
|
||
"</strong> " +
|
||
coin +
|
||
" (" +
|
||
fmt(sized.contracts, prec) +
|
||
" 张) · 比例 " +
|
||
fmt(sized.ratio, 2)
|
||
);
|
||
}
|
||
|
||
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, """) +
|
||
'">买一/流动性 <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, """) +
|
||
'">' +
|
||
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">组合净盈亏(扣开平手续费·永续盘口均价)</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;
|
||
el.textContent = text || "";
|
||
el.classList.toggle("is-err", kind === "err");
|
||
el.classList.toggle("is-ok", kind === "ok");
|
||
el.classList.toggle("muted", !kind);
|
||
}
|
||
|
||
async function submitSellCoin() {
|
||
const uly = String(state.underlying || "ETH").toUpperCase();
|
||
if (!window.confirm("确认市价卖出交易账户全部可用 " + uly + " 换回 USDT?")) return;
|
||
setSellCoinMsg("卖回中…", null);
|
||
const btn = $("hp-sell-coin-btn");
|
||
if (btn) 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");
|
||
return;
|
||
}
|
||
const sold =
|
||
d.sell && d.sell.coin_sold != null
|
||
? "已卖 " + d.sell.coin_sold + " " + uly
|
||
: d.msg || "卖回成功";
|
||
setSellCoinMsg(sold, "ok");
|
||
await loadChain();
|
||
void loadLivePositions();
|
||
} catch (e) {
|
||
setSellCoinMsg(e.message || "卖回失败", "err");
|
||
} finally {
|
||
if (btn) btn.disabled = false;
|
||
}
|
||
}
|
||
|
||
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();
|
||
const d = await apiJson(
|
||
"/api/hedge-plan/market?base=" +
|
||
encodeURIComponent(state.underlying) +
|
||
"&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;须买一流动性且扣费净利>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";
|
||
}
|
||
}
|
||
}
|
||
|
||
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);
|
||
});
|
||
if (isOptionPrimary() && !state.poStrikeExpandAll && state.moneyFilter === "all") {
|
||
const hintTr = document.createElement("tr");
|
||
hintTr.innerHTML =
|
||
'<td colspan="' +
|
||
cols +
|
||
'" class="muted opt-strike-hint">默认显示约 5 档(平值 + 实值2 + 虚值2) · 勾选「展开全部」查看该到期更多行权价</td>';
|
||
tbody.appendChild(hintTr);
|
||
}
|
||
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) {
|
||
const next = (uly || "ETH").toUpperCase();
|
||
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 () {
|
||
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 () {
|
||
state.exitMode = b.getAttribute("data-exit") === "target" ? "target" : "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, """) +
|
||
"\"><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, """) +
|
||
"\"><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 = state.exitMode === "target" ? "target" : "profit";
|
||
let optPts = 0;
|
||
let perpPts = 0;
|
||
let profitTarget = 0;
|
||
if (exitMode === "profit") {
|
||
profitTarget = numInput("hp-profit-target", NaN);
|
||
if (!(profitTarget > 0)) {
|
||
setPoOrderErr("请填写目标盈利金额(USDT,扣双边手续费后净利)", "hp-profit-target");
|
||
throw new Error("请填写目标盈利金额(USDT,扣双边手续费后净利)");
|
||
}
|
||
} else {
|
||
optPts = numInput("hp-opt-target-pts", NaN);
|
||
perpPts = numInput("hp-perp-target-pts", NaN);
|
||
if (!(optPts > 0) || !(perpPts > 0)) {
|
||
setPoOrderErr("请填写期权/永续目标位点数(须大于0)", !(optPts > 0) ? "hp-opt-target-pts" : "hp-perp-target-pts");
|
||
throw new Error("请填写期权/永续目标位点数(须大于0)");
|
||
}
|
||
}
|
||
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,
|
||
underlying: state.underlying,
|
||
direction: getDirection(),
|
||
entry: 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.underlying),
|
||
option_leverage: 0,
|
||
premium_budget: Math.max(premEst / 0.95, premEst),
|
||
option_perp_ratio: numInput("hp-opt-perp-ratio", 2),
|
||
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();
|
||
}
|
||
|
||
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);
|
||
})();
|