Lock hedge-plan option selection to ITM/ATM (perp) and ATM/OTM (OO).
Server validate on preview/start, UI filters and recommend templates, plus usability/security audit doc. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+171
-14
@@ -22,7 +22,8 @@
|
||||
tab: pickDefaultTab(),
|
||||
mode: showPerp ? "perp_options" : showOo ? "options_options" : "perp_options",
|
||||
underlying: root.getAttribute("data-default-underly") || "ETH",
|
||||
moneyFilter: "all",
|
||||
moneyFilter: "itm", // 永期锁定:实值+平值
|
||||
ooMoneyFilter: "atm_otm", // 期期锁定:平值+虚值
|
||||
chain: null,
|
||||
selected: null,
|
||||
legA: null,
|
||||
@@ -144,12 +145,83 @@
|
||||
}
|
||||
|
||||
function matchesMoneyFilter(c) {
|
||||
const f = state.moneyFilter || "all";
|
||||
if (f === "all") return true;
|
||||
// 永期:仅实值/平值(禁虚值)
|
||||
const f = state.moneyFilter || "itm";
|
||||
const m = (c.moneyness || "").toLowerCase();
|
||||
if (f === "itm") return m === "itm" || m === "atm";
|
||||
if (f === "atm") return m === "atm";
|
||||
if (f === "otm") return m === "otm";
|
||||
return true;
|
||||
return m === "itm" || m === "atm";
|
||||
}
|
||||
|
||||
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 optTypeForDirection(dir) {
|
||||
@@ -174,6 +246,10 @@
|
||||
const on = b.getAttribute("data-money") === state.moneyFilter;
|
||||
b.classList.toggle("active", on);
|
||||
});
|
||||
document.querySelectorAll(".hp-oo-money-btn").forEach(function (b) {
|
||||
const on = b.getAttribute("data-oo-money") === state.ooMoneyFilter;
|
||||
b.classList.toggle("active", on);
|
||||
});
|
||||
}
|
||||
|
||||
function syncTabUI() {
|
||||
@@ -705,6 +781,11 @@
|
||||
|
||||
function pickContract(c) {
|
||||
if (!c) return;
|
||||
const m = (c.moneyness || "").toLowerCase();
|
||||
if (m === "otm") {
|
||||
alert("永期保险腿须为实值或平值,不可选虚值");
|
||||
return;
|
||||
}
|
||||
state.selected = c;
|
||||
const el = $("hp-sel-inst");
|
||||
if (el) el.textContent = c.inst_id;
|
||||
@@ -815,15 +896,18 @@
|
||||
const tr = document.createElement("tr");
|
||||
const call = row.call;
|
||||
const put = row.put;
|
||||
const callAsk = call ? fmtPxSz(call.ask, call.ask_sz, call.ask_estimated, call.tick_sz) : "—";
|
||||
const putAsk = put ? fmtPxSz(put.ask, put.ask_sz, put.ask_estimated, put.tick_sz) : "—";
|
||||
const callOk = call && matchesOoMoneyFilter(call);
|
||||
const putOk = put && matchesOoMoneyFilter(put);
|
||||
if (!callOk && !putOk) return;
|
||||
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) : "—";
|
||||
tr.innerHTML =
|
||||
'<td class="opt-t-call opt-px-sz">' +
|
||||
callAsk +
|
||||
'</td><td class="opt-t-call">' +
|
||||
(call ? moneynessBadge(call) : "—") +
|
||||
(callOk ? moneynessBadge(call) : "—") +
|
||||
"</td><td>" +
|
||||
(call
|
||||
(callOk
|
||||
? '<button type="button" class="btn-secondary hp-oo-pick" data-side="C" data-inst="' +
|
||||
call.inst_id +
|
||||
'">Call</button>'
|
||||
@@ -831,11 +915,11 @@
|
||||
'</td><td class="opt-t-strike"><strong>' +
|
||||
row.strike +
|
||||
'</strong></td><td class="opt-t-put">' +
|
||||
(put ? moneynessBadge(put) : "—") +
|
||||
(putOk ? moneynessBadge(put) : "—") +
|
||||
'</td><td class="opt-t-put opt-px-sz">' +
|
||||
putAsk +
|
||||
"</td><td>" +
|
||||
(put
|
||||
(putOk
|
||||
? '<button type="button" class="btn-secondary hp-oo-pick" data-side="P" data-inst="' +
|
||||
put.inst_id +
|
||||
'">Put</button>'
|
||||
@@ -850,7 +934,10 @@
|
||||
const c = (exp2.contracts || []).find(function (x) {
|
||||
return x.inst_id === inst;
|
||||
});
|
||||
if (!c) return;
|
||||
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 {
|
||||
@@ -981,6 +1068,9 @@
|
||||
let body;
|
||||
if (isOo) {
|
||||
if (!state.legA || !state.legB) throw new Error("请选用两条期权腿");
|
||||
if (!matchesOoMoneyFilter(state.legA) || !matchesOoMoneyFilter(state.legB)) {
|
||||
throw new Error("期期两腿须为平值或虚值,不可选实值");
|
||||
}
|
||||
const up = Number(($("hp-target-up") && $("hp-target-up").value) || 0);
|
||||
const down = Number(($("hp-target-down") && $("hp-target-down").value) || 0);
|
||||
if (!up || !down) throw new Error("请填写上破与下破目标价");
|
||||
@@ -990,12 +1080,14 @@
|
||||
target_price_up: up,
|
||||
target_price_down: down,
|
||||
target_price: up,
|
||||
index_px: (state.chain && state.chain.index_px) || (up + down) / 2,
|
||||
index_px: indexPx() || (up + down) / 2,
|
||||
leg_a: legPayload(state.legA, ooSheets("hp-oo-sheets-a")),
|
||||
leg_b: legPayload(state.legB, ooSheets("hp-oo-sheets-b")),
|
||||
};
|
||||
} else {
|
||||
if (!state.selected) throw new Error("请选用期权腿");
|
||||
const 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);
|
||||
@@ -1015,7 +1107,7 @@
|
||||
sheets: sheets,
|
||||
ct_mult: state.selected.ct_mult || 0.01,
|
||||
ask: state.selected.ask,
|
||||
index_px: state.chain && state.chain.index_px,
|
||||
index_px: indexPx() || entry,
|
||||
};
|
||||
}
|
||||
const d = await apiJson("/api/hedge-plan/preview", {
|
||||
@@ -1152,11 +1244,68 @@
|
||||
});
|
||||
document.querySelectorAll(".hp-money-btn").forEach(function (b) {
|
||||
b.addEventListener("click", function () {
|
||||
state.moneyFilter = b.getAttribute("data-money") || "all";
|
||||
const m = b.getAttribute("data-money") || "itm";
|
||||
// 永期禁止选虚值筛选
|
||||
if (m === "otm") {
|
||||
alert("永期保险腿仅允许实值或平值");
|
||||
return;
|
||||
}
|
||||
state.moneyFilter = m === "atm" ? "atm" : "itm";
|
||||
syncMoneyUI();
|
||||
renderListStrikes();
|
||||
});
|
||||
});
|
||||
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 () {
|
||||
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;
|
||||
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;
|
||||
renderOoLegs();
|
||||
autoFillOoSheets();
|
||||
updateOoPremiumLine();
|
||||
});
|
||||
}
|
||||
document.querySelectorAll(".hp-po-dir").forEach(function (b) {
|
||||
b.addEventListener("click", function () {
|
||||
setDirection(b.getAttribute("data-dir") || "long", true);
|
||||
@@ -1734,6 +1883,9 @@
|
||||
let body;
|
||||
if (isOo) {
|
||||
if (!state.legA || !state.legB) throw new Error("请选用两条期权腿");
|
||||
if (!matchesOoMoneyFilter(state.legA) || !matchesOoMoneyFilter(state.legB)) {
|
||||
throw new Error("期期两腿须为平值或虚值,不可选实值");
|
||||
}
|
||||
const up = Number(($("hp-target-up") && $("hp-target-up").value) || 0);
|
||||
const down = Number(($("hp-target-down") && $("hp-target-down").value) || 0);
|
||||
if (!up || !down) throw new Error("请填写上破与下破目标价");
|
||||
@@ -1744,6 +1896,7 @@
|
||||
target_price_up: up,
|
||||
target_price_down: down,
|
||||
target_price: up,
|
||||
index_px: indexPx() || (up + down) / 2,
|
||||
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")),
|
||||
@@ -1751,6 +1904,8 @@
|
||||
};
|
||||
} 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);
|
||||
@@ -1769,6 +1924,8 @@
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user