feat(hedge): add option-primary mode for perp+options plans

Add UI switch for Call+short/Put+long, premium x0.95 sizing, option-first open, and K+/-points exits with fee-aware net PnL.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-08-09 08:29:26 +08:00
parent 0b8e5a0914
commit afe361ce47
11 changed files with 1835 additions and 142 deletions
+399 -77
View File
@@ -37,6 +37,9 @@
ooCloseModeEnabled: root.getAttribute("data-oo-close-mode-enabled") !== "0",
ooCloseMode: "close_all",
direction: "long",
optionPrimary: false,
opLevTouched: false,
opRatioTouched: false,
tradingUsdc: null,
fundingUsdc: null,
tradeBudgetUsdc: null,
@@ -147,9 +150,20 @@
}
function matchesMoneyFilter(c) {
// 永期:仅实值/平值(禁虚值)
const f = state.moneyFilter || "itm";
const m = (c.moneyness || "").toLowerCase();
if (!isOptionPrimary() && f === "otm") return false;
if (isOptionPrimary()) {
const idx = indexPx();
const interval = numInput("hp-strike-interval", 15);
if (idx && interval > 0 && Math.abs(Number(c.strike) - idx) > interval + 1e-9) {
return false;
}
const ask = Number(c.ask || 0);
const minLev = numInput("hp-opt-leverage", f === "otm" ? 200 : 100);
const levFloor = f === "otm" ? Math.max(minLev, 180) : minLev;
if (idx && ask > 0 && levFloor > 0 && idx / ask < levFloor) return false;
}
if (f === "itm") return m === "itm" || m === "atm";
if (f === "atm") return m === "atm";
if (f === "otm") return m === "otm";
@@ -226,10 +240,138 @@
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 || "itm";
if (f === "otm") return "otm";
if (f === "atm") return "atm";
return "itm";
}
function applyOpDefaultsFromMoney(force) {
const kind = opMoneyKind();
const levEl = $("hp-opt-leverage");
const ratioEl = $("hp-opt-perp-ratio");
if (levEl && (force || !state.opLevTouched)) {
levEl.value = kind === "otm" ? "200" : "100";
}
if (ratioEl && (force || !state.opRatioTouched)) {
ratioEl.value = kind === "otm" ? "4" : "2";
}
}
function syncOptionPrimaryUI() {
const on = isOptionPrimary();
document.querySelectorAll(".hp-po-mode").forEach(function (b) {
const v = b.getAttribute("data-option-primary") === "1";
b.classList.toggle("is-selected", v === on);
b.classList.toggle("active", v === on);
});
const ins = $("hp-po-fields-insurance");
const op = $("hp-po-fields-option-primary");
if (ins) {
ins.classList.toggle("hidden", on);
if (on) ins.setAttribute("hidden", "hidden");
else ins.removeAttribute("hidden");
}
if (op) {
op.classList.toggle("hidden", !on);
if (!on) op.setAttribute("hidden", "hidden");
else op.removeAttribute("hidden");
}
const otmBtn = document.querySelector(".hp-money-otm");
if (otmBtn) {
otmBtn.classList.toggle("hidden", !on);
if (!on) otmBtn.setAttribute("hidden", "hidden");
else otmBtn.removeAttribute("hidden");
}
if (!on && state.moneyFilter === "otm") {
state.moneyFilter = "itm";
syncMoneyUI();
}
if (on) applyOpDefaultsFromMoney(false);
const title = $("hp-po-card-title");
if (title) title.textContent = on ? "执行参数" : "永续";
const rightQ = $("hp-po-perp-quote-right");
if (rightQ) {
rightQ.classList.toggle("hidden", !on);
if (!on) rightQ.setAttribute("hidden", "hidden");
else rightQ.removeAttribute("hidden");
}
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 (dirShort) dirShort.title = on ? "做空=买Put+永续多" : "做空永续";
}
function setOptionPrimary(on, forceReload) {
const next = !!on;
const changed = next !== isOptionPrimary();
state.optionPrimary = next;
if (changed) {
state.opLevTouched = false;
state.opRatioTouched = false;
state.selected = null;
if ($("hp-sel-inst")) $("hp-sel-inst").textContent = "—";
}
syncOptionPrimaryUI();
if (!changed && !forceReload) return;
void loadMarket().then(function () {
return loadChain();
});
}
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) {
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 (!(budget > 0) || !(a > 0) || !(ct > 0) || !(ratio > 0) || !(cs > 0)) {
return null;
}
let eth = Math.floor((usable / a) * 100 + 1e-12) / 100;
if (!(eth > 0)) return null;
let 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,
sheets: sheets,
contracts: contracts,
premium_est: a * sheets * ct,
ratio: ratio,
};
}
function syncUnderlyingUI() {
const uly = state.underlying || "ETH";
document.querySelectorAll(".hp-uly-btn, .hp-uly-btn-oo").forEach(function (b) {
@@ -728,7 +870,9 @@
"/api/hedge-plan/market?base=" +
encodeURIComponent(state.underlying) +
"&direction=" +
encodeURIComponent(dir)
encodeURIComponent(dir) +
"&option_primary=" +
(isOptionPrimary() ? "1" : "0")
);
state.market = d;
setGateLine(d.gates);
@@ -738,20 +882,26 @@
const amtPrec = d.amount_precision != null ? Number(d.amount_precision) : 4;
const markEl = $("hp-po-mark");
if (markEl) markEl.textContent = "标记 " + fmt(d.mark, 2);
const quoteHtml =
"可用 <strong>" +
fmt(d.available_usdt, 2) +
"</strong> USDT · 卖一 " +
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 =
"可用 <strong>" +
fmt(d.available_usdt, 2) +
"</strong> USDT · 卖一 " +
fmt(d.ask, 2) +
" · 买一 " +
fmt(d.bid, 2) +
" · 面值 " +
fmt(d.contract_size, 4) +
" · 精度 " +
amtPrec +
" 位";
if (q) q.innerHTML = quoteHtml;
const rightQ = $("hp-po-perp-quote-right");
if (rightQ && isOptionPrimary()) {
rightQ.innerHTML = "永续行情 · " + quoteHtml;
}
const contractsInput = $("hp-contracts");
if (contractsInput) {
@@ -760,7 +910,25 @@
}
const sz = $("hp-sizing-line");
if (sz) {
if (d.full_margin_sizing) {
if (isOptionPrimary()) {
const sized =
state.selected &&
computeOpSizing(state.selected.ask, state.selected.ct_mult || 0.01);
if (sized) {
sz.innerHTML =
"执行预算 <strong>" +
fmt(sized.usable, 2) +
"</strong> · 期权 ETH <strong>" +
fmt(sized.eth_qty, 2) +
"</strong> / " +
sized.sheets +
" 张 · 永续 <strong>" +
fmt(sized.contracts, amtPrec) +
"</strong> 张";
} else {
sz.textContent = "填写权利金并选用期权后显示定仓(权利金×0.95,ETH两位小数)";
}
} else if (d.full_margin_sizing) {
const s = d.full_margin_sizing;
sz.innerHTML =
"全仓建议 <strong>" +
@@ -777,8 +945,8 @@
}
}
const entry = $("hp-entry");
if (entry && d.entry_ref && !entry.value) entry.value = d.entry_ref;
if (contractsInput && d.suggest_contracts != null && !contractsInput.value) {
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");
@@ -789,6 +957,25 @@
function updatePerpPnlHint() {
const el = $("hp-perp-pnl-line");
if (!el) return;
if (isOptionPrimary()) {
const n = numInput("hp-opt-target-pts", NaN);
const m = numInput("hp-perp-target-pts", NaN);
const k = state.selected && Number(state.selected.strike);
if (!(k > 0) || (!(n >= 0) && !(m >= 0))) {
el.innerHTML = '<span class="muted">选用期权并填目标点数后显示 K±N 出场参考</span>';
return;
}
const dir = getDirection();
const optT = n >= 0 ? (dir === "short" ? k - n : k + n) : null;
const perpT = m >= 0 ? (dir === "short" ? k - m : k + m) : null;
el.innerHTML =
"期权目标指数 " +
(optT != null ? fmt(optT, 2) : "—") +
" · 永续目标指数 " +
(perpT != null ? fmt(perpT, 2) : "—") +
' <span class="muted">(相对K;期权目标需买一且扣费净利&gt;0)</span>';
return;
}
const entry = Number(($("hp-entry") && $("hp-entry").value) || NaN);
const tp = Number(($("hp-tp") && $("hp-tp").value) || NaN);
const sl = Number(($("hp-sl") && $("hp-sl").value) || NaN);
@@ -828,17 +1015,23 @@
function fillExpSelect(sel, chain) {
if (!sel) return;
const prev = sel.value;
const isPoSel = sel.id === "hp-exp-select";
const minH = isPoSel && isOptionPrimary() ? numInput("hp-min-hours", 36) : 0;
sel.innerHTML = '<option value="">选择到期日</option>';
let firstOk = null;
(chain.expiries || []).forEach(function (e) {
const h = hoursFromExpMs(e.exp_time);
if (minH > 0 && h != null && h < minH) return;
const opt = document.createElement("option");
opt.value = String(e.exp_time);
const dt = new Date(Number(e.exp_time));
opt.textContent = dt.toLocaleString();
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 && chain.expiries && chain.expiries[0]) {
sel.value = String(chain.expiries[0].exp_time);
if (!sel.value && firstOk) {
sel.value = String(firstOk.exp_time);
}
}
@@ -879,10 +1072,14 @@
function pickContract(c) {
if (!c) return;
const m = (c.moneyness || "").toLowerCase();
if (m === "otm") {
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;
@@ -896,6 +1093,12 @@
});
}
updatePremiumLine();
if (isOptionPrimary()) {
const sized = computeOpSizing(c.ask, c.ct_mult || 0.01);
if (sized && $("hp-sheets")) $("hp-sheets").value = String(sized.sheets);
void loadMarket();
updatePerpPnlHint();
}
}
function renderListStrikes() {
@@ -1280,29 +1483,62 @@
};
} else {
if (!state.selected) throw new Error("请选用期权腿");
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,
};
if (isOptionPrimary()) {
const sized = computeOpSizing(state.selected.ask, state.selected.ct_mult || 0.01);
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");
body = {
plan_type: "perp_options",
option_primary: true,
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: numInput("hp-premium-budget", 0),
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: numInput("hp-opt-leverage", 100),
leverage: numInput("hp-perp-leverage", 100),
moneyness: opMoneyKind(),
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",
@@ -1312,7 +1548,18 @@
setGateLine(d.gates);
const s = d.summary || {};
if (summary) {
if (d.plan_type === "perp_options") {
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) +
@@ -1439,16 +1686,53 @@
document.querySelectorAll(".hp-money-btn").forEach(function (b) {
b.addEventListener("click", function () {
const m = b.getAttribute("data-money") || "itm";
// 永期禁止选虚值筛选
if (m === "otm") {
alert("永期保险腿仅允许实值或平值");
if (m === "otm" && !isOptionPrimary()) {
alert("永期保险腿仅允许实值或平值;请先打开「以期权为主」");
return;
}
state.moneyFilter = m === "atm" ? "atm" : "itm";
state.moneyFilter = m === "otm" ? "otm" : m === "atm" ? "atm" : "itm";
if (isOptionPrimary()) applyOpDefaultsFromMoney(false);
syncMoneyUI();
renderListStrikes();
});
});
document.querySelectorAll(".hp-po-mode").forEach(function (b) {
b.addEventListener("click", function () {
setOptionPrimary(b.getAttribute("data-option-primary") === "1", true);
});
});
if ($("hp-opt-leverage")) {
$("hp-opt-leverage").addEventListener("input", function () {
state.opLevTouched = true;
renderListStrikes();
});
}
if ($("hp-opt-perp-ratio")) {
$("hp-opt-perp-ratio").addEventListener("input", function () {
state.opRatioTouched = true;
if (state.selected) {
const sized = computeOpSizing(state.selected.ask, state.selected.ct_mult || 0.01);
if (sized && $("hp-sheets")) $("hp-sheets").value = String(sized.sheets);
}
void loadMarket();
});
}
["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" && state.chain) fillExpSelect($("hp-exp-select"), state.chain);
if (id === "hp-strike-interval" || id === "hp-premium-budget") renderListStrikes();
if (state.selected && (id === "hp-premium-budget" || id === "hp-strike-interval")) {
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";
@@ -1513,6 +1797,7 @@
const el = $(id);
if (el) el.addEventListener("input", updatePerpPnlHint);
});
syncOptionPrimaryUI();
if ($("hp-refresh"))
$("hp-refresh").addEventListener("click", function () {
void refreshAll();
@@ -2109,32 +2394,69 @@
};
} else {
if (!state.selected) throw new Error("请选用期权腿");
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 (isOptionPrimary()) {
const sized = computeOpSizing(state.selected.ask, state.selected.ct_mult || 0.01);
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 entry = indexPx() || Number((state.market && state.market.mark) || 0);
body = {
plan_type: "perp_options",
option_primary: true,
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: numInput("hp-perp-leverage", 100),
option_leverage: numInput("hp-opt-leverage", 100),
premium_budget: numInput("hp-premium-budget", 0),
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),
moneyness: opMoneyKind(),
hours_to_expiry: exp ? hoursFromExpMs(exp.exp_time) : null,
};
} 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) {
if (!window.confirm("确认启动对冲计划并真实下单?\n(将按期权账户/合约账户分别下单)")) return;