Compare commits
6 Commits
main
...
6fad68f7b1
| Author | SHA1 | Date | |
|---|---|---|---|
| 6fad68f7b1 | |||
| 14a7adae1f | |||
| 24bb8532c4 | |||
| c514a75026 | |||
| 5c3969674a | |||
| 3b56e15fb1 |
@@ -1165,14 +1165,9 @@
|
|||||||
fillExpSelect($("hp-oo-exp-select"), d);
|
fillExpSelect($("hp-oo-exp-select"), d);
|
||||||
renderListStrikes();
|
renderListStrikes();
|
||||||
renderTStrikes();
|
renderTStrikes();
|
||||||
if (d.index_px) {
|
// 期期盈亏比默认 2,不再用指数自动填上破/下破
|
||||||
const idx = Number(d.index_px);
|
if ($("hp-oo-rr") && !$("hp-oo-rr").value) {
|
||||||
if ($("hp-target-up") && !$("hp-target-up").value) {
|
$("hp-oo-rr").value = "2";
|
||||||
$("hp-target-up").value = String(Math.round(idx * 1.03));
|
|
||||||
}
|
|
||||||
if ($("hp-target-down") && !$("hp-target-down").value) {
|
|
||||||
$("hp-target-down").value = String(Math.round(idx * 0.97));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1581,8 +1576,7 @@
|
|||||||
if ($("hp-contracts")) $("hp-contracts").value = "";
|
if ($("hp-contracts")) $("hp-contracts").value = "";
|
||||||
if ($("hp-tp")) $("hp-tp").value = "";
|
if ($("hp-tp")) $("hp-tp").value = "";
|
||||||
if ($("hp-sl")) $("hp-sl").value = "";
|
if ($("hp-sl")) $("hp-sl").value = "";
|
||||||
if ($("hp-target-up")) $("hp-target-up").value = "";
|
if ($("hp-oo-rr")) $("hp-oo-rr").value = "2";
|
||||||
if ($("hp-target-down")) $("hp-target-down").value = "";
|
|
||||||
if ($("hp-sel-inst")) $("hp-sel-inst").textContent = "—";
|
if ($("hp-sel-inst")) $("hp-sel-inst").textContent = "—";
|
||||||
if ($("hp-premium-line")) $("hp-premium-line").textContent = "";
|
if ($("hp-premium-line")) $("hp-premium-line").textContent = "";
|
||||||
if ($("hp-oo-sheets-a")) {
|
if ($("hp-oo-sheets-a")) {
|
||||||
@@ -1618,16 +1612,12 @@
|
|||||||
if (!matchesOoMoneyFilter(state.legA) || !matchesOoMoneyFilter(state.legB)) {
|
if (!matchesOoMoneyFilter(state.legA) || !matchesOoMoneyFilter(state.legB)) {
|
||||||
throw new Error("期期两腿须为平值或虚值,不可选实值");
|
throw new Error("期期两腿须为平值或虚值,不可选实值");
|
||||||
}
|
}
|
||||||
const up = Number(($("hp-target-up") && $("hp-target-up").value) || 0);
|
const rr = numInput("hp-oo-rr", 2);
|
||||||
const down = Number(($("hp-target-down") && $("hp-target-down").value) || 0);
|
if (!(rr > 0)) throw new Error("请填写盈亏比(相对权利金,默认2)");
|
||||||
if (!up || !down) throw new Error("请填写上破与下破目标价");
|
|
||||||
if (up <= down) throw new Error("上破目标价必须大于下破目标价");
|
|
||||||
body = {
|
body = {
|
||||||
plan_type: "options_options",
|
plan_type: "options_options",
|
||||||
target_price_up: up,
|
oo_profit_rr: rr,
|
||||||
target_price_down: down,
|
index_px: indexPx() || 0,
|
||||||
target_price: up,
|
|
||||||
index_px: indexPx() || (up + down) / 2,
|
|
||||||
leg_a: legPayload(state.legA, ooSheets("hp-oo-sheets-a")),
|
leg_a: legPayload(state.legA, ooSheets("hp-oo-sheets-a")),
|
||||||
leg_b: legPayload(state.legB, ooSheets("hp-oo-sheets-b")),
|
leg_b: legPayload(state.legB, ooSheets("hp-oo-sheets-b")),
|
||||||
};
|
};
|
||||||
@@ -1718,6 +1708,21 @@
|
|||||||
" · 保费 " +
|
" · 保费 " +
|
||||||
fmt(s.premium_paid) +
|
fmt(s.premium_paid) +
|
||||||
(s.hedge_ratio_at_sl != null ? " · 止损对冲率 " + fmt(s.hedge_ratio_at_sl) + "%" : "");
|
(s.hedge_ratio_at_sl != null ? " · 止损对冲率 " + fmt(s.hedge_ratio_at_sl) + "%" : "");
|
||||||
|
} else {
|
||||||
|
const rr = s.oo_profit_rr != null ? s.oo_profit_rr : s.rr_target;
|
||||||
|
const tgt = s.target_profit != null ? s.target_profit : s.at_target_total;
|
||||||
|
if (rr != null) {
|
||||||
|
summary.innerHTML =
|
||||||
|
"盈亏比 ×" +
|
||||||
|
fmt(rr, 2) +
|
||||||
|
" · 目标盈利 " +
|
||||||
|
fmtPnlHtml(tgt) +
|
||||||
|
" · 到期现价 " +
|
||||||
|
fmtPnlHtml(s.expiry_flat_total) +
|
||||||
|
" · 保费 " +
|
||||||
|
fmt(s.premium_paid) +
|
||||||
|
'<span class="muted">(达标全平;不达标等到期)</span>' +
|
||||||
|
(s.expiry_is_loss ? " · 到期现价情景为亏" : "");
|
||||||
} else {
|
} else {
|
||||||
const upTot = s.at_target_up_total != null ? s.at_target_up_total : s.at_target_total;
|
const upTot = s.at_target_up_total != null ? s.at_target_up_total : s.at_target_total;
|
||||||
const dnTot = s.at_target_down_total;
|
const dnTot = s.at_target_down_total;
|
||||||
@@ -1743,6 +1748,7 @@
|
|||||||
(s.expiry_is_loss ? " · 到期无盈利(记总亏损)" : "");
|
(s.expiry_is_loss ? " · 到期无盈利(记总亏损)" : "");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if (!tbody) return;
|
if (!tbody) return;
|
||||||
tbody.innerHTML = "";
|
tbody.innerHTML = "";
|
||||||
(d.scenarios || []).forEach(function (sc) {
|
(d.scenarios || []).forEach(function (sc) {
|
||||||
@@ -2057,8 +2063,7 @@
|
|||||||
"hp-tp",
|
"hp-tp",
|
||||||
"hp-sl",
|
"hp-sl",
|
||||||
"hp-sheets",
|
"hp-sheets",
|
||||||
"hp-target-up",
|
"hp-oo-rr",
|
||||||
"hp-target-down",
|
|
||||||
]);
|
]);
|
||||||
if ($("hp-preview-btn"))
|
if ($("hp-preview-btn"))
|
||||||
$("hp-preview-btn").addEventListener("click", function () {
|
$("hp-preview-btn").addEventListener("click", function () {
|
||||||
@@ -2171,6 +2176,9 @@
|
|||||||
if (p.plan_type === "perp_options") {
|
if (p.plan_type === "perp_options") {
|
||||||
return "止盈 " + fmt(p.tp) + " · 止损 " + fmt(p.sl);
|
return "止盈 " + fmt(p.tp) + " · 止损 " + fmt(p.sl);
|
||||||
}
|
}
|
||||||
|
if (p.oo_profit_rr != null && Number(p.oo_profit_rr) > 0) {
|
||||||
|
return "盈亏比 ×" + fmt(p.oo_profit_rr, 2) + "(达标全平)";
|
||||||
|
}
|
||||||
return "上破 " + fmt(p.target_price_up || p.target_price) + " · 下破 " + fmt(p.target_price_down || p.target_price);
|
return "上破 " + fmt(p.target_price_up || p.target_price) + " · 下破 " + fmt(p.target_price_down || p.target_price);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2330,6 +2338,8 @@
|
|||||||
target_win_leg: "期期平盈利腿",
|
target_win_leg: "期期平盈利腿",
|
||||||
target_up_win_leg: "期期上破·平盈利腿",
|
target_up_win_leg: "期期上破·平盈利腿",
|
||||||
target_down_win_leg: "期期下破·平盈利腿",
|
target_down_win_leg: "期期下破·平盈利腿",
|
||||||
|
oo_rr_target: "期期盈亏比达标",
|
||||||
|
oo_rr_closing: "期期盈亏比平仓中",
|
||||||
oo_rest_closing: "期期全平·清残腿中",
|
oo_rest_closing: "期期全平·清残腿中",
|
||||||
oo_rest_closed: "期期全平·两腿已平",
|
oo_rest_closed: "期期全平·两腿已平",
|
||||||
orphaned_after_tp: "止盈后持有至到期",
|
orphaned_after_tp: "止盈后持有至到期",
|
||||||
@@ -2404,6 +2414,11 @@
|
|||||||
"x · 张数 " +
|
"x · 张数 " +
|
||||||
fmt(p.perp_size, 4) +
|
fmt(p.perp_size, 4) +
|
||||||
"</div>";
|
"</div>";
|
||||||
|
} else if (p.oo_profit_rr != null && Number(p.oo_profit_rr) > 0) {
|
||||||
|
html +=
|
||||||
|
"<div><span class=\"muted\">盈亏比</span> ×" +
|
||||||
|
fmt(p.oo_profit_rr, 2) +
|
||||||
|
"(浮盈达标全平;不达标等到期)</div>";
|
||||||
} else {
|
} else {
|
||||||
html +=
|
html +=
|
||||||
"<div><span class=\"muted\">目标价</span> 上破 " +
|
"<div><span class=\"muted\">目标价</span> 上破 " +
|
||||||
@@ -2626,17 +2641,13 @@
|
|||||||
if (!matchesOoMoneyFilter(state.legA) || !matchesOoMoneyFilter(state.legB)) {
|
if (!matchesOoMoneyFilter(state.legA) || !matchesOoMoneyFilter(state.legB)) {
|
||||||
throw new Error("期期两腿须为平值或虚值,不可选实值");
|
throw new Error("期期两腿须为平值或虚值,不可选实值");
|
||||||
}
|
}
|
||||||
const up = Number(($("hp-target-up") && $("hp-target-up").value) || 0);
|
const rr = numInput("hp-oo-rr", 2);
|
||||||
const down = Number(($("hp-target-down") && $("hp-target-down").value) || 0);
|
if (!(rr > 0)) throw new Error("请填写盈亏比(相对权利金,默认2)");
|
||||||
if (!up || !down) throw new Error("请填写上破与下破目标价");
|
|
||||||
if (up <= down) throw new Error("上破目标价必须大于下破目标价");
|
|
||||||
body = {
|
body = {
|
||||||
plan_type: "options_options",
|
plan_type: "options_options",
|
||||||
underlying: state.underlying,
|
underlying: state.underlying,
|
||||||
target_price_up: up,
|
oo_profit_rr: rr,
|
||||||
target_price_down: down,
|
index_px: indexPx() || 0,
|
||||||
target_price: up,
|
|
||||||
index_px: indexPx() || (up + down) / 2,
|
|
||||||
oo_close_mode: state.ooCloseModeEnabled ? state.ooCloseMode : "hold_expiry",
|
oo_close_mode: state.ooCloseModeEnabled ? state.ooCloseMode : "hold_expiry",
|
||||||
oo_sheets_mode: state.ooSheetsMode || "same_sheets",
|
oo_sheets_mode: state.ooSheetsMode || "same_sheets",
|
||||||
leg_a: legPayload(state.legA, ooSheets("hp-oo-sheets-a")),
|
leg_a: legPayload(state.legA, ooSheets("hp-oo-sheets-a")),
|
||||||
|
|||||||
+416
-106
@@ -7,6 +7,10 @@
|
|||||||
root.setAttribute("data-options-booted", "1");
|
root.setAttribute("data-options-booted", "1");
|
||||||
|
|
||||||
const panelCache = (window.__optionsPanelCache = window.__optionsPanelCache || {});
|
const panelCache = (window.__optionsPanelCache = window.__optionsPanelCache || {});
|
||||||
|
if (!panelCache.quoteWatcherId) {
|
||||||
|
panelCache.quoteWatcherId =
|
||||||
|
"w" + Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
|
||||||
|
}
|
||||||
|
|
||||||
const state = {
|
const state = {
|
||||||
underlying: root.dataset.defaultUnderly || "ETH",
|
underlying: root.dataset.defaultUnderly || "ETH",
|
||||||
@@ -37,9 +41,20 @@
|
|||||||
let selectSeq = 0;
|
let selectSeq = 0;
|
||||||
let refreshAllTimer = null;
|
let refreshAllTimer = null;
|
||||||
let pendingRefreshTimer = null;
|
let pendingRefreshTimer = null;
|
||||||
|
let chainSoftTimer = null;
|
||||||
|
let lastChainSoftAt = 0;
|
||||||
|
let chainQuotedAt = 0;
|
||||||
|
let quoteLiveEs = null;
|
||||||
|
let quoteLiveReconnectTimer = null;
|
||||||
|
let quoteLiveOk = false;
|
||||||
|
let quoteLiveWsOk = false;
|
||||||
|
let lastOrderQuoteLiveAt = 0;
|
||||||
let pendingTtlSeconds = 600;
|
let pendingTtlSeconds = 600;
|
||||||
const POSITIONS_STALE_MS = 45000;
|
const POSITIONS_STALE_MS = 45000;
|
||||||
const PENDING_POLL_MS = 8000;
|
const PENDING_POLL_MS = 8000;
|
||||||
|
/** SSE/WS 断开时的 REST 兜底;连上后停用 */
|
||||||
|
const CHAIN_SOFT_POLL_MS = 30000;
|
||||||
|
const ORDER_QUOTE_LIVE_MIN_MS = 800;
|
||||||
const orderPanelHome = (function () {
|
const orderPanelHome = (function () {
|
||||||
const host = document.getElementById("opt-order-panel-host");
|
const host = document.getElementById("opt-order-panel-host");
|
||||||
return host ? host.parentElement : null;
|
return host ? host.parentElement : null;
|
||||||
@@ -321,7 +336,7 @@
|
|||||||
[
|
[
|
||||||
"opt-sheets-amount",
|
"opt-sheets-amount",
|
||||||
"opt-eth-amount",
|
"opt-eth-amount",
|
||||||
"opt-target-idx",
|
"opt-profit-rr",
|
||||||
].forEach(function (id) {
|
].forEach(function (id) {
|
||||||
harden(document.getElementById(id));
|
harden(document.getElementById(id));
|
||||||
});
|
});
|
||||||
@@ -632,6 +647,15 @@
|
|||||||
if (el) el.textContent = fmt(buf, 2);
|
if (el) el.textContent = fmt(buf, 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fmtChainQuotedAt() {
|
||||||
|
if (!chainQuotedAt) return "";
|
||||||
|
const d = new Date(chainQuotedAt);
|
||||||
|
const pad = function (n) {
|
||||||
|
return n < 10 ? "0" + n : String(n);
|
||||||
|
};
|
||||||
|
return pad(d.getHours()) + ":" + pad(d.getMinutes()) + ":" + pad(d.getSeconds());
|
||||||
|
}
|
||||||
|
|
||||||
function renderIndexLine() {
|
function renderIndexLine() {
|
||||||
const idx = state.chain && state.chain.index_px;
|
const idx = state.chain && state.chain.index_px;
|
||||||
const dte = state.chain && state.chain.chain_max_dte_days;
|
const dte = state.chain && state.chain.chain_max_dte_days;
|
||||||
@@ -645,12 +669,234 @@
|
|||||||
const line = document.getElementById("opt-index-line");
|
const line = document.getElementById("opt-index-line");
|
||||||
if (line) {
|
if (line) {
|
||||||
const liqHint = askLiqFilterOn() ? "仅显示卖一深度≥1张" : "显示全部卖一(含估算~)";
|
const liqHint = askLiqFilterOn() ? "仅显示卖一深度≥1张" : "显示全部卖一(含估算~)";
|
||||||
|
let liveHint = "";
|
||||||
|
if (quoteLiveOk && quoteLiveWsOk) {
|
||||||
|
liveHint = chainQuotedAt
|
||||||
|
? " · WS实时 " + fmtChainQuotedAt()
|
||||||
|
: " · WS实时";
|
||||||
|
} else if (quoteLiveOk) {
|
||||||
|
liveHint = " · 推送已连,等待 OKX WS…";
|
||||||
|
} else if (chainQuotedAt) {
|
||||||
|
liveHint = " · 链报价 " + fmtChainQuotedAt() + "(REST兜底)";
|
||||||
|
}
|
||||||
line.textContent =
|
line.textContent =
|
||||||
"指数 " + state.underlying + " ≈ " + fmt(idx, 2) +
|
"指数 " + state.underlying + " ≈ " + fmt(idx, 2) +
|
||||||
" · 默认最近一期 · " + liqHint + " · 实值含平值 · 虚值=价外";
|
" · 默认最近一期 · " + liqHint + " · 实值含平值 · 虚值=价外" + liveHint;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function findChainContract(instId) {
|
||||||
|
if (!state.chain || !instId) return null;
|
||||||
|
const exps = state.chain.expiries || [];
|
||||||
|
for (let i = 0; i < exps.length; i++) {
|
||||||
|
const contracts = exps[i].contracts || [];
|
||||||
|
for (let j = 0; j < contracts.length; j++) {
|
||||||
|
if (String(contracts[j].inst_id) === String(instId)) return contracts[j];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentExpiryContracts() {
|
||||||
|
if (!state.chain) return [];
|
||||||
|
const expMs = (document.getElementById("opt-exp-select") || {}).value;
|
||||||
|
const exp = (state.chain.expiries || []).find(function (e) {
|
||||||
|
return String(e.exp_time) === String(expMs);
|
||||||
|
});
|
||||||
|
return (exp && exp.contracts) || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function watchCurrentExpiryQuotes() {
|
||||||
|
if (!state.chain) return;
|
||||||
|
const expMs = (document.getElementById("opt-exp-select") || {}).value;
|
||||||
|
const contracts = currentExpiryContracts().map(function (c) {
|
||||||
|
return {
|
||||||
|
inst_id: c.inst_id,
|
||||||
|
opt_type: c.opt_type,
|
||||||
|
strike: c.strike,
|
||||||
|
tick_sz: c.tick_sz,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
if (!contracts.length) return;
|
||||||
|
try {
|
||||||
|
await apiJson("/api/options/quotes/watch", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
underlying: state.underlying,
|
||||||
|
exp_time: expMs,
|
||||||
|
contracts: contracts,
|
||||||
|
index_inst_id: state.underlying + "-USD",
|
||||||
|
watcher_id: panelCache.quoteWatcherId,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
} catch (_) {
|
||||||
|
/* ignore watch errors; soft poll fallback remains */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchListRowDom(instId, c) {
|
||||||
|
const tr = document.querySelector('#opt-strike-tbody tr.opt-strike-row[data-inst="' + instId + '"]');
|
||||||
|
if (!tr || !c) return;
|
||||||
|
const indexPx = state.chain && state.chain.index_px;
|
||||||
|
const tds = tr.children;
|
||||||
|
if (tds.length < 8) return;
|
||||||
|
tds[3].textContent = "";
|
||||||
|
tds[3].className = "opt-px-sz";
|
||||||
|
tds[3].innerHTML = fmtPxSz(c.ask, c.ask_sz, c.ask_estimated);
|
||||||
|
tds[4].className = "opt-chain-lev";
|
||||||
|
tds[4].textContent = fmtChainLeverage(calcAskLeverage(indexPx, c.ask));
|
||||||
|
tds[5].className = "opt-px-sz";
|
||||||
|
tds[5].innerHTML = fmtPxSz(c.bid, c.bid_sz);
|
||||||
|
tds[6].textContent = c.expiry_be_px != null ? fmt(c.expiry_be_px, 0) : "—";
|
||||||
|
tds[7].className = distBeClass(c.dist_expiry_be);
|
||||||
|
tds[7].textContent = fmtDist(c.dist_expiry_be);
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchTRowDom(instId, c) {
|
||||||
|
if (!c) return;
|
||||||
|
const callTr = document.querySelector(
|
||||||
|
'#opt-strike-tbody tr.opt-strike-row-t[data-call-inst="' + instId + '"]'
|
||||||
|
);
|
||||||
|
const putTr = document.querySelector(
|
||||||
|
'#opt-strike-tbody tr.opt-strike-row-t[data-put-inst="' + instId + '"]'
|
||||||
|
);
|
||||||
|
const tr = callTr || putTr;
|
||||||
|
if (!tr) return;
|
||||||
|
const callInst = tr.getAttribute("data-call-inst");
|
||||||
|
const putInst = tr.getAttribute("data-put-inst");
|
||||||
|
const call = callInst ? findChainContract(callInst) : null;
|
||||||
|
const put = putInst ? findChainContract(putInst) : null;
|
||||||
|
const callOk = call && (!askLiqFilterOn() || hasAskLiquidity(call)) ? call : null;
|
||||||
|
const putOk = put && (!askLiqFilterOn() || hasAskLiquidity(put)) ? put : null;
|
||||||
|
const tds = tr.children;
|
||||||
|
if (tds.length < 9) return;
|
||||||
|
tds[0].innerHTML = callOk ? fmtPxSz(callOk.ask, callOk.ask_sz, callOk.ask_estimated) : "—";
|
||||||
|
tds[7].innerHTML = putOk ? fmtPxSz(putOk.ask, putOk.ask_sz, putOk.ask_estimated) : "—";
|
||||||
|
const combined = straddleAskPerUnit(callOk && callOk.ask, putOk && putOk.ask);
|
||||||
|
tds[4].innerHTML = formatStraddlePremiumCell(callOk && callOk.ask, putOk && putOk.ask);
|
||||||
|
tds[5].innerHTML = formatStraddleBand(tr.getAttribute("data-strike"), combined);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyLiveQuotes(payload) {
|
||||||
|
if (!payload || !state.chain) return;
|
||||||
|
const uly = String(state.underlying || "").toUpperCase();
|
||||||
|
if (payload.indexes && payload.indexes[uly] != null && Number.isFinite(Number(payload.indexes[uly]))) {
|
||||||
|
state.chain.index_px = Number(payload.indexes[uly]);
|
||||||
|
} else if (payload.underlying && String(payload.underlying).toUpperCase() === uly) {
|
||||||
|
if (payload.index_px != null && Number.isFinite(Number(payload.index_px))) {
|
||||||
|
state.chain.index_px = Number(payload.index_px);
|
||||||
|
}
|
||||||
|
} else if (payload.underlying && String(payload.underlying).toUpperCase() !== uly) {
|
||||||
|
// 别的标的推送:仍可 patch 本页已有合约
|
||||||
|
}
|
||||||
|
const quotes = payload.quotes || [];
|
||||||
|
quotes.forEach(function (q) {
|
||||||
|
const instId = q && q.inst_id;
|
||||||
|
if (!instId) return;
|
||||||
|
if (q.underlying && String(q.underlying).toUpperCase() !== uly) return;
|
||||||
|
const c = findChainContract(instId);
|
||||||
|
if (!c) return;
|
||||||
|
if (q.ask !== undefined) c.ask = q.ask;
|
||||||
|
if (q.bid !== undefined) c.bid = q.bid;
|
||||||
|
if (q.ask_sz !== undefined) c.ask_sz = q.ask_sz;
|
||||||
|
if (q.bid_sz !== undefined) c.bid_sz = q.bid_sz;
|
||||||
|
if (q.mark_px !== undefined) c.mark_px = q.mark_px;
|
||||||
|
if (q.ask_estimated !== undefined) c.ask_estimated = !!q.ask_estimated;
|
||||||
|
if (q.expiry_be_px !== undefined) c.expiry_be_px = q.expiry_be_px;
|
||||||
|
if (q.dist_expiry_be !== undefined) c.dist_expiry_be = q.dist_expiry_be;
|
||||||
|
if (state.chainView === "t") patchTRowDom(instId, c);
|
||||||
|
else patchListRowDom(instId, c);
|
||||||
|
});
|
||||||
|
if (payload.ts) chainQuotedAt = Number(payload.ts) || Date.now();
|
||||||
|
else if (quotes.length || payload.index_px != null) chainQuotedAt = Date.now();
|
||||||
|
quoteLiveWsOk = payload.ws_ok !== false;
|
||||||
|
renderIndexLine();
|
||||||
|
if (state.selectedInst && quotes.some(function (q) { return q && q.inst_id === state.selectedInst; })) {
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastOrderQuoteLiveAt >= ORDER_QUOTE_LIVE_MIN_MS) {
|
||||||
|
lastOrderQuoteLiveAt = now;
|
||||||
|
void selectContract(state.selectedInst, null, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopQuoteLiveStream() {
|
||||||
|
if (quoteLiveReconnectTimer) {
|
||||||
|
clearTimeout(quoteLiveReconnectTimer);
|
||||||
|
quoteLiveReconnectTimer = null;
|
||||||
|
}
|
||||||
|
if (quoteLiveEs) {
|
||||||
|
try { quoteLiveEs.close(); } catch (_) {}
|
||||||
|
quoteLiveEs = null;
|
||||||
|
}
|
||||||
|
quoteLiveOk = false;
|
||||||
|
quoteLiveWsOk = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function startQuoteLiveStream() {
|
||||||
|
if (quoteLiveEs) return;
|
||||||
|
if (typeof EventSource === "undefined") return;
|
||||||
|
try {
|
||||||
|
quoteLiveEs = new EventSource("/api/options/quotes/stream");
|
||||||
|
} catch (_) {
|
||||||
|
quoteLiveOk = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
quoteLiveEs.addEventListener("quotes", function (ev) {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(ev.data || "{}");
|
||||||
|
quoteLiveOk = true;
|
||||||
|
if (data.reason === "connect") {
|
||||||
|
quoteLiveWsOk = !!data.ws_ok;
|
||||||
|
renderIndexLine();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
applyLiveQuotes(data);
|
||||||
|
} catch (_) {}
|
||||||
|
});
|
||||||
|
quoteLiveEs.onopen = function () {
|
||||||
|
quoteLiveOk = true;
|
||||||
|
renderIndexLine();
|
||||||
|
void watchCurrentExpiryQuotes();
|
||||||
|
};
|
||||||
|
quoteLiveEs.onerror = function () {
|
||||||
|
quoteLiveOk = false;
|
||||||
|
quoteLiveWsOk = false;
|
||||||
|
renderIndexLine();
|
||||||
|
stopQuoteLiveStream();
|
||||||
|
quoteLiveReconnectTimer = setTimeout(function () {
|
||||||
|
quoteLiveReconnectTimer = null;
|
||||||
|
startQuoteLiveStream();
|
||||||
|
}, 8000);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function softRefreshChainThrottled(force) {
|
||||||
|
if (document.hidden) return;
|
||||||
|
if (!document.getElementById("options-root")) return;
|
||||||
|
// WS 推送正常时不靠 REST 刷卖一,避免 50011;仅结构兜底可 force
|
||||||
|
if (!force && quoteLiveOk && quoteLiveWsOk) return;
|
||||||
|
const now = Date.now();
|
||||||
|
if (!force && now - lastChainSoftAt < CHAIN_SOFT_POLL_MS) return;
|
||||||
|
lastChainSoftAt = now;
|
||||||
|
void loadChain({ soft: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function startChainSoftPoll() {
|
||||||
|
if (chainSoftTimer) return;
|
||||||
|
chainSoftTimer = setInterval(function () {
|
||||||
|
if (!document.getElementById("options-root")) {
|
||||||
|
if (chainSoftTimer) {
|
||||||
|
clearInterval(chainSoftTimer);
|
||||||
|
chainSoftTimer = null;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
softRefreshChainThrottled(false);
|
||||||
|
}, CHAIN_SOFT_POLL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
function pickNearestExpiry(exps) {
|
function pickNearestExpiry(exps) {
|
||||||
if (!exps || !exps.length) return "";
|
if (!exps || !exps.length) return "";
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
@@ -894,11 +1140,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function updateOrderEstimates() {
|
function updateOrderEstimates() {
|
||||||
const levEl = document.getElementById("opt-order-leverage");
|
|
||||||
const valueEl = document.getElementById("opt-est-value");
|
const valueEl = document.getElementById("opt-est-value");
|
||||||
const profitEl = document.getElementById("opt-est-profit");
|
const profitEl = document.getElementById("opt-est-profit");
|
||||||
const targetLevEl = document.getElementById("opt-est-leverage");
|
const levEl = document.getElementById("opt-order-leverage");
|
||||||
const targetEl = document.getElementById("opt-target-idx");
|
const rrEl = document.getElementById("opt-profit-rr");
|
||||||
const q = state.orderQuote;
|
const q = state.orderQuote;
|
||||||
if (!q || !q.ok || !q.can_open) {
|
if (!q || !q.ok || !q.can_open) {
|
||||||
if (levEl) levEl.textContent = "—";
|
if (levEl) levEl.textContent = "—";
|
||||||
@@ -907,7 +1152,6 @@
|
|||||||
profitEl.textContent = "—";
|
profitEl.textContent = "—";
|
||||||
profitEl.className = "v";
|
profitEl.className = "v";
|
||||||
}
|
}
|
||||||
if (targetLevEl) targetLevEl.textContent = "—";
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const sz = q.sizing || {};
|
const sz = q.sizing || {};
|
||||||
@@ -916,30 +1160,19 @@
|
|||||||
const lev = calcContractLeverage(q.index_px, ethAmount, premium);
|
const lev = calcContractLeverage(q.index_px, ethAmount, premium);
|
||||||
if (levEl) levEl.textContent = fmtLeverage(lev);
|
if (levEl) levEl.textContent = fmtLeverage(lev);
|
||||||
|
|
||||||
if (valueEl && profitEl && targetEl) {
|
if (valueEl && profitEl && rrEl) {
|
||||||
const targetRaw = targetEl.value;
|
const rrRaw = rrEl.value;
|
||||||
if (targetRaw === "" || targetRaw == null) {
|
const rr = rrRaw === "" || rrRaw == null ? NaN : Number(rrRaw);
|
||||||
|
if (!Number.isFinite(rr) || rr <= 0 || !(Number(premium) > 0)) {
|
||||||
valueEl.textContent = "—";
|
valueEl.textContent = "—";
|
||||||
profitEl.textContent = "—";
|
profitEl.textContent = "—";
|
||||||
profitEl.className = "v";
|
profitEl.className = "v";
|
||||||
if (targetLevEl) targetLevEl.textContent = "—";
|
|
||||||
} else {
|
} else {
|
||||||
const value = estimateExpiryValue(q.opt_type, q.strike, Number(targetRaw), ethAmount);
|
const targetProfit = Number(premium) * rr;
|
||||||
const profit = estimateExpiryProfit(q.opt_type, q.strike, Number(targetRaw), ethAmount, premium);
|
const needRecycle = Number(premium) + targetProfit;
|
||||||
if (value == null || Number.isNaN(value)) {
|
valueEl.textContent = fmtUsdc(needRecycle) + " USDC";
|
||||||
valueEl.textContent = "—";
|
profitEl.textContent = fmtUsdcSigned(targetProfit);
|
||||||
} else {
|
profitEl.className = "v " + pnlCls(targetProfit);
|
||||||
valueEl.textContent = fmtUsdc(value) + " USDC";
|
|
||||||
}
|
|
||||||
if (profit == null || Number.isNaN(profit)) {
|
|
||||||
profitEl.textContent = "—";
|
|
||||||
profitEl.className = "v";
|
|
||||||
} else {
|
|
||||||
profitEl.textContent = fmtUsdcSigned(profit);
|
|
||||||
profitEl.className = "v " + pnlCls(profit);
|
|
||||||
}
|
|
||||||
const targetLev = calcContractLeverage(Number(targetRaw), ethAmount, premium);
|
|
||||||
if (targetLevEl) targetLevEl.textContent = fmtLeverage(targetLev);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1217,8 +1450,14 @@
|
|||||||
const uly = state.underlying;
|
const uly = state.underlying;
|
||||||
const seq = ++chainLoadSeq;
|
const seq = ++chainLoadSeq;
|
||||||
const btn = document.getElementById("opt-load-chain");
|
const btn = document.getElementById("opt-load-chain");
|
||||||
if (btn && !soft) btn.disabled = true;
|
const hadChain = chainHasExpiries(state.chain) && state.chain.underlying === uly;
|
||||||
if (!soft) {
|
if (btn && !soft) {
|
||||||
|
btn.disabled = true;
|
||||||
|
if (!btn.dataset.origText) btn.dataset.origText = btn.textContent || "刷新链";
|
||||||
|
btn.textContent = "刷新中…";
|
||||||
|
}
|
||||||
|
// 已有链时不先清空表格,避免「白屏等很久」的体感
|
||||||
|
if (!soft && !hadChain) {
|
||||||
setExpirySelectStatus("加载到期日中…");
|
setExpirySelectStatus("加载到期日中…");
|
||||||
const tbody = document.getElementById("opt-strike-tbody");
|
const tbody = document.getElementById("opt-strike-tbody");
|
||||||
if (tbody) {
|
if (tbody) {
|
||||||
@@ -1229,16 +1468,34 @@
|
|||||||
try {
|
try {
|
||||||
let d = null;
|
let d = null;
|
||||||
let lastMsg = "";
|
let lastMsg = "";
|
||||||
for (let attempt = 0; attempt < 2; attempt++) {
|
const expMs = (document.getElementById("opt-exp-select") || {}).value || "";
|
||||||
|
// WS 已热时走 fast,跳过最慢的整家族 REST tickers
|
||||||
|
const useFast = soft || quoteLiveWsOk || hadChain;
|
||||||
|
const maxAttempts = soft ? 2 : 3;
|
||||||
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||||
if (seq !== chainLoadSeq) return;
|
if (seq !== chainLoadSeq) return;
|
||||||
d = await apiJson("/api/options/chain?underlying=" + encodeURIComponent(uly));
|
let url =
|
||||||
|
"/api/options/chain?underlying=" +
|
||||||
|
encodeURIComponent(uly) +
|
||||||
|
(useFast ? "&fast=1" : "");
|
||||||
|
if (expMs) url += "&exp_time=" + encodeURIComponent(expMs);
|
||||||
|
d = await apiJson(url);
|
||||||
if (seq !== chainLoadSeq) return;
|
if (seq !== chainLoadSeq) return;
|
||||||
if (d && d.ok && chainHasExpiries(d)) break;
|
if (d && d.ok && chainHasExpiries(d)) break;
|
||||||
lastMsg = (d && (d.msg || d.chain_error)) || "暂无到期日";
|
lastMsg = (d && (d.msg || d.chain_error)) || "暂无到期日";
|
||||||
|
const rateLimited =
|
||||||
|
!!(d && d.rate_limited) ||
|
||||||
|
/50011|Too Many Requests|过于频繁/i.test(String(lastMsg || ""));
|
||||||
d = null;
|
d = null;
|
||||||
if (attempt === 0) {
|
if (attempt < maxAttempts - 1) {
|
||||||
if (!soft) setExpirySelectStatus("重试加载到期日…");
|
if (!soft && !hadChain) {
|
||||||
await new Promise(function (resolve) { setTimeout(resolve, 400); });
|
setExpirySelectStatus(
|
||||||
|
rateLimited ? "OKX 限频,稍后重试…" : "重试加载到期日…"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await new Promise(function (resolve) {
|
||||||
|
setTimeout(resolve, rateLimited ? 1200 * (attempt + 1) : 300);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (seq !== chainLoadSeq) return;
|
if (seq !== chainLoadSeq) return;
|
||||||
@@ -1247,28 +1504,37 @@
|
|||||||
if (!soft) {
|
if (!soft) {
|
||||||
renderExpiries();
|
renderExpiries();
|
||||||
renderStrikes();
|
renderStrikes();
|
||||||
|
void watchCurrentExpiryQuotes();
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (soft) return;
|
if (soft) return;
|
||||||
setExpirySelectStatus("选择到期日");
|
setExpirySelectStatus("选择到期日");
|
||||||
const tbody = document.getElementById("opt-strike-tbody");
|
const tbody = document.getElementById("opt-strike-tbody");
|
||||||
|
const friendly =
|
||||||
|
/50011|Too Many Requests|过于频繁/i.test(String(lastMsg || ""))
|
||||||
|
? "OKX 请求过于频繁,请稍后再点「刷新链」"
|
||||||
|
: lastMsg || "暂无到期日,请点「刷新链」";
|
||||||
if (tbody) {
|
if (tbody) {
|
||||||
tbody.innerHTML =
|
tbody.innerHTML =
|
||||||
'<tr><td colspan="' + strikeTableColspan() + '" class="muted">' +
|
'<tr><td colspan="' +
|
||||||
(lastMsg || "暂无到期日,请点「刷新链」") +
|
strikeTableColspan() +
|
||||||
|
'" class="muted">' +
|
||||||
|
friendly +
|
||||||
"</td></tr>";
|
"</td></tr>";
|
||||||
}
|
}
|
||||||
alert(lastMsg || "加载到期日失败,请点「刷新链」重试");
|
alert(friendly);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const keepExp = soft ? (document.getElementById("opt-exp-select") || {}).value : "";
|
const keepExp = soft || hadChain ? (document.getElementById("opt-exp-select") || {}).value : "";
|
||||||
state.chain = d;
|
state.chain = d;
|
||||||
panelCache.chain = d;
|
panelCache.chain = d;
|
||||||
panelCache.underlying = uly;
|
panelCache.underlying = uly;
|
||||||
panelCache.optType = state.optType;
|
panelCache.optType = state.optType;
|
||||||
|
chainQuotedAt = Date.now();
|
||||||
|
lastChainSoftAt = chainQuotedAt;
|
||||||
syncAskLiqFilterFromChain(d);
|
syncAskLiqFilterFromChain(d);
|
||||||
if (!soft) {
|
if (!soft && !hadChain) {
|
||||||
state.selectedInst = null;
|
state.selectedInst = null;
|
||||||
resetMoneyFilterToAll();
|
resetMoneyFilterToAll();
|
||||||
state.strikeExpandAll = false;
|
state.strikeExpandAll = false;
|
||||||
@@ -1278,16 +1544,19 @@
|
|||||||
}
|
}
|
||||||
updateUnderlyingLabel();
|
updateUnderlyingLabel();
|
||||||
renderExpiries();
|
renderExpiries();
|
||||||
if (soft && keepExp) {
|
if (keepExp) {
|
||||||
const sel = document.getElementById("opt-exp-select");
|
const sel = document.getElementById("opt-exp-select");
|
||||||
if (sel && Array.from(sel.options).some(function (o) { return o.value === keepExp; })) {
|
if (sel && Array.from(sel.options).some(function (o) { return o.value === keepExp; })) {
|
||||||
sel.value = keepExp;
|
sel.value = keepExp;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// soft 时保留 selectedInst;renderStrikes 会先 park 再按 prevSelected 静默重挂下单面板
|
// soft/已有链时保留 selectedInst;renderStrikes 会先 park 再按 prevSelected 静默重挂下单面板
|
||||||
renderStrikes();
|
renderStrikes();
|
||||||
|
void watchCurrentExpiryQuotes();
|
||||||
|
startQuoteLiveStream();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (seq !== chainLoadSeq || soft) return;
|
if (seq !== chainLoadSeq || soft) return;
|
||||||
|
if (hadChain) return;
|
||||||
setExpirySelectStatus("选择到期日");
|
setExpirySelectStatus("选择到期日");
|
||||||
const tbody = document.getElementById("opt-strike-tbody");
|
const tbody = document.getElementById("opt-strike-tbody");
|
||||||
if (tbody) {
|
if (tbody) {
|
||||||
@@ -1297,7 +1566,10 @@
|
|||||||
"</td></tr>";
|
"</td></tr>";
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (seq === chainLoadSeq && btn) btn.disabled = false;
|
if (seq === chainLoadSeq && btn) {
|
||||||
|
btn.disabled = false;
|
||||||
|
if (btn.dataset.origText) btn.textContent = btn.dataset.origText;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1329,15 +1601,13 @@
|
|||||||
} else if (mode === "sheets") {
|
} else if (mode === "sheets") {
|
||||||
body.sheets = parseInt(document.getElementById("opt-sheets-amount").value, 10);
|
body.sheets = parseInt(document.getElementById("opt-sheets-amount").value, 10);
|
||||||
}
|
}
|
||||||
const tgtRaw = (document.getElementById("opt-target-idx").value || "").trim();
|
const rrRaw = (document.getElementById("opt-profit-rr").value || "").trim();
|
||||||
if (tgtRaw !== "") {
|
const rr = rrRaw === "" ? 2 : parseFloat(rrRaw);
|
||||||
const tgt = parseFloat(tgtRaw);
|
if (!Number.isFinite(rr) || rr <= 0) {
|
||||||
if (!Number.isFinite(tgt) || tgt <= 0) {
|
alert("盈亏比无效");
|
||||||
alert("目标位无效");
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
body.target_index = tgt;
|
body.profit_rr = rr;
|
||||||
}
|
|
||||||
const d = await apiJson("/api/options/open", {
|
const d = await apiJson("/api/options/open", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
@@ -1428,15 +1698,17 @@
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTargetEstimateHtml(optType, strike, targetIdx, ethAmount, premiumPaid) {
|
function formatRrEstimateHtml(rr, premiumPaid) {
|
||||||
const value = estimateExpiryValue(optType, strike, targetIdx, ethAmount);
|
const r = Number(rr);
|
||||||
const profit = estimateExpiryProfit(optType, strike, targetIdx, ethAmount, premiumPaid);
|
const prem = Number(premiumPaid);
|
||||||
if (value == null && profit == null) return "";
|
if (!Number.isFinite(r) || r <= 0 || !Number.isFinite(prem) || prem <= 0) return "";
|
||||||
|
const profit = Math.round(prem * r * 100) / 100;
|
||||||
|
const need = Math.round((prem + profit) * 100) / 100;
|
||||||
let html = '<span class="opt-target-est">';
|
let html = '<span class="opt-target-est">';
|
||||||
html += '<span class="opt-target-est-item"><span class="k">价值</span><span class="v">' +
|
html += '<span class="opt-target-est-item"><span class="k">目标盈利</span><span class="v ' + pnlCls(profit) + '">' +
|
||||||
(value == null ? "—" : fmtUsdc(value) + " USDC") + "</span></span>";
|
fmtUsdcSigned(profit) + "</span></span>";
|
||||||
html += '<span class="opt-target-est-item"><span class="k">预估盈利</span><span class="v ' + pnlCls(profit) + '">' +
|
html += '<span class="opt-target-est-item"><span class="k">需回收</span><span class="v">' +
|
||||||
(profit == null ? "—" : fmtUsdcSigned(profit)) + "</span></span>";
|
fmtUsdc(need) + " USDC</span></span>";
|
||||||
html += "</span>";
|
html += "</span>";
|
||||||
return html;
|
return html;
|
||||||
}
|
}
|
||||||
@@ -1444,47 +1716,73 @@
|
|||||||
function renderTargetDelegateRow(p) {
|
function renderTargetDelegateRow(p) {
|
||||||
const inst = p.inst_id || "";
|
const inst = p.inst_id || "";
|
||||||
const hedgeTarget = p.hedge_plan_target || null;
|
const hedgeTarget = p.hedge_plan_target || null;
|
||||||
if (hedgeTarget && Number(hedgeTarget.target_index) > 0) {
|
if (hedgeTarget && hedgeTarget.managed_by === "hedge_plan") {
|
||||||
const side = (p.opt_type || hedgeTarget.opt_type || "").toUpperCase() === "P" ? "Put ≤" : "Call ≥";
|
const rr = hedgeTarget.oo_profit_rr != null ? Number(hedgeTarget.oo_profit_rr) : null;
|
||||||
|
const armedTxt =
|
||||||
|
rr != null && Number.isFinite(rr) && rr > 0
|
||||||
|
? "盈亏比 ×" + fmt(rr, 2)
|
||||||
|
: hedgeTarget.target_index != null
|
||||||
|
? "目标 " + fmt(hedgeTarget.target_index, 1)
|
||||||
|
: "托管中";
|
||||||
return (
|
return (
|
||||||
'<div class="opt-target-row opt-target-row--managed">' +
|
'<div class="opt-target-row opt-target-row--managed">' +
|
||||||
'<span class="opt-target-row-label">对冲计划</span>' +
|
'<span class="opt-target-row-label">对冲计划</span>' +
|
||||||
'<span class="opt-target-armed">计划 #' +
|
'<span class="opt-target-armed">计划 #' +
|
||||||
hedgeTarget.plan_id +
|
hedgeTarget.plan_id +
|
||||||
" · " +
|
" · " +
|
||||||
side +
|
armedTxt +
|
||||||
" " +
|
|
||||||
fmt(hedgeTarget.target_index, 1) +
|
|
||||||
"</span>" +
|
"</span>" +
|
||||||
'<span class="muted opt-target-row-hint">进行中 · 由对冲计划监控,到位后仅平盈利腿</span>' +
|
'<span class="muted opt-target-row-hint">进行中 · 由对冲计划监控</span>' +
|
||||||
"</div>"
|
"</div>"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const tgt = p.target_index != null && p.target_index !== "" ? Number(p.target_index) : null;
|
const rrArmed =
|
||||||
const armed = tgt != null && Number.isFinite(tgt) && tgt > 0;
|
p.profit_rr != null && p.profit_rr !== ""
|
||||||
const ethAmt = posEthAmount(p);
|
? Number(p.profit_rr)
|
||||||
|
: p.target_monitor && p.target_monitor.profit_rr != null
|
||||||
|
? Number(p.target_monitor.profit_rr)
|
||||||
|
: null;
|
||||||
|
const armed = rrArmed != null && Number.isFinite(rrArmed) && rrArmed > 0;
|
||||||
const prem = p.premium_paid;
|
const prem = p.premium_paid;
|
||||||
|
const draft =
|
||||||
|
state.targetDraftByInst[inst] != null
|
||||||
|
? String(state.targetDraftByInst[inst])
|
||||||
|
: armed
|
||||||
|
? String(rrArmed)
|
||||||
|
: "2";
|
||||||
const estHtml = armed
|
const estHtml = armed
|
||||||
? formatTargetEstimateHtml(p.opt_type, p.strike, tgt, ethAmt, prem)
|
? formatRrEstimateHtml(rrArmed, prem)
|
||||||
: '<span class="opt-target-est opt-target-est--idle"></span>';
|
: '<span class="opt-target-est opt-target-est--idle"></span>';
|
||||||
return (
|
return (
|
||||||
'<div class="opt-target-row" data-inst="' + inst + '"' +
|
'<div class="opt-target-row" data-inst="' +
|
||||||
' data-opt-type="' + (p.opt_type || "") + '"' +
|
inst +
|
||||||
' data-strike="' + (p.strike != null ? p.strike : "") + '"' +
|
'"' +
|
||||||
' data-eth="' + (ethAmt != null ? ethAmt : "") + '"' +
|
' data-prem="' +
|
||||||
' data-prem="' + (prem != null ? prem : "") + '"' +
|
(prem != null ? prem : "") +
|
||||||
' data-armed-target="' + (armed ? tgt : "") + '">' +
|
'"' +
|
||||||
|
' data-armed-rr="' +
|
||||||
|
(armed ? rrArmed : "") +
|
||||||
|
'">' +
|
||||||
'<span class="opt-target-row-label">委托</span>' +
|
'<span class="opt-target-row-label">委托</span>' +
|
||||||
'<input type="number" class="opt-pos-target-input" data-inst="' + inst + '" step="0.1" min="0" placeholder="监控目标指数" value="' +
|
'<input type="number" class="opt-pos-target-input" data-inst="' +
|
||||||
(state.targetDraftByInst[inst] != null ? String(state.targetDraftByInst[inst]) : "") + '">' +
|
inst +
|
||||||
'<button type="button" class="btn-secondary opt-target-set-btn" data-inst="' + inst + '">设定</button>' +
|
'" step="0.1" min="0.1" placeholder="盈亏比" value="' +
|
||||||
'<button type="button" class="btn-secondary opt-target-cancel-btn" data-inst="' + inst + '"' + (armed ? "" : " disabled") + ">取消</button>" +
|
draft +
|
||||||
(armed
|
'">' +
|
||||||
? '<span class="opt-target-armed">目标 ' + fmt(tgt, 1) + "</span>"
|
'<button type="button" class="btn-secondary opt-target-set-btn" data-inst="' +
|
||||||
: "") +
|
inst +
|
||||||
|
'">设定</button>' +
|
||||||
|
'<button type="button" class="btn-secondary opt-target-cancel-btn" data-inst="' +
|
||||||
|
inst +
|
||||||
|
'"' +
|
||||||
|
(armed ? "" : " disabled") +
|
||||||
|
">取消</button>" +
|
||||||
|
(armed ? '<span class="opt-target-armed">盈亏比 ×' + fmt(rrArmed, 2) + "</span>" : "") +
|
||||||
estHtml +
|
estHtml +
|
||||||
'<span class="muted opt-target-row-hint">' +
|
'<span class="muted opt-target-row-hint">' +
|
||||||
(armed ? "监控中 · 到位按买一限价平" : "输入后设定 · 到位按买一限价平 · 到期即止损") +
|
(armed
|
||||||
|
? "监控中 · 买一浮盈达盈亏比后全平"
|
||||||
|
: "默认2 · 买一浮盈达盈亏比×权利金后全平 · 不达标等到期") +
|
||||||
"</span>" +
|
"</span>" +
|
||||||
"</div>"
|
"</div>"
|
||||||
);
|
);
|
||||||
@@ -1496,20 +1794,14 @@
|
|||||||
if (!est) return;
|
if (!est) return;
|
||||||
const inp = row.querySelector(".opt-pos-target-input");
|
const inp = row.querySelector(".opt-pos-target-input");
|
||||||
const typed = inp ? String(inp.value || "").trim() : "";
|
const typed = inp ? String(inp.value || "").trim() : "";
|
||||||
const armed = row.getAttribute("data-armed-target") || "";
|
const armed = row.getAttribute("data-armed-rr") || "";
|
||||||
const targetRaw = typed !== "" ? typed : armed;
|
const rrRaw = typed !== "" ? typed : armed;
|
||||||
if (targetRaw === "") {
|
if (rrRaw === "") {
|
||||||
est.className = "opt-target-est opt-target-est--idle";
|
est.className = "opt-target-est opt-target-est--idle";
|
||||||
est.innerHTML = "";
|
est.innerHTML = "";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const html = formatTargetEstimateHtml(
|
const html = formatRrEstimateHtml(rrRaw, row.getAttribute("data-prem"));
|
||||||
row.getAttribute("data-opt-type"),
|
|
||||||
row.getAttribute("data-strike"),
|
|
||||||
targetRaw,
|
|
||||||
row.getAttribute("data-eth"),
|
|
||||||
row.getAttribute("data-prem")
|
|
||||||
);
|
|
||||||
if (!html) {
|
if (!html) {
|
||||||
est.className = "opt-target-est opt-target-est--idle";
|
est.className = "opt-target-est opt-target-est--idle";
|
||||||
est.innerHTML = "";
|
est.innerHTML = "";
|
||||||
@@ -1641,9 +1933,9 @@
|
|||||||
const row = card ? card.querySelector(".opt-target-row") : null;
|
const row = card ? card.querySelector(".opt-target-row") : null;
|
||||||
const inp = card ? card.querySelector(".opt-pos-target-input") : null;
|
const inp = card ? card.querySelector(".opt-pos-target-input") : null;
|
||||||
const raw = inp ? String(inp.value || "").trim() : "";
|
const raw = inp ? String(inp.value || "").trim() : "";
|
||||||
const tgt = parseFloat(raw);
|
const rr = raw === "" ? 2 : parseFloat(raw);
|
||||||
if (!Number.isFinite(tgt) || tgt <= 0) {
|
if (!Number.isFinite(rr) || rr <= 0) {
|
||||||
alert("请输入有效目标指数价");
|
alert("请输入有效盈亏比(相对权利金,默认2)");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (btn) btn.disabled = true;
|
if (btn) btn.disabled = true;
|
||||||
@@ -1651,17 +1943,16 @@
|
|||||||
const d = await apiJson("/api/options/target", {
|
const d = await apiJson("/api/options/target", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ inst_id: inst, target_index: tgt }),
|
body: JSON.stringify({ inst_id: inst, profit_rr: rr }),
|
||||||
});
|
});
|
||||||
if (!d.ok) {
|
if (!d.ok) {
|
||||||
alert(d.msg || "设定失败");
|
alert(d.msg || "设定失败");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
delete state.targetDraftByInst[inst];
|
delete state.targetDraftByInst[inst];
|
||||||
if (inp) inp.value = "";
|
if (inp) inp.value = String(rr);
|
||||||
if (row) {
|
if (row) {
|
||||||
row.setAttribute("data-armed-target", String(tgt));
|
row.setAttribute("data-armed-rr", String(rr));
|
||||||
updatePosTargetEstimate(row);
|
|
||||||
}
|
}
|
||||||
await refreshAllPositions();
|
await refreshAllPositions();
|
||||||
} finally {
|
} finally {
|
||||||
@@ -1701,12 +1992,22 @@
|
|||||||
}
|
}
|
||||||
box.hidden = false;
|
box.hidden = false;
|
||||||
host.innerHTML = rows.map(function (t) {
|
host.innerHTML = rows.map(function (t) {
|
||||||
const side = (t.opt_type || "").toUpperCase() === "P" ? "Put ≤" : "Call ≥";
|
|
||||||
const managed = t.managed_by === "hedge_plan";
|
const managed = t.managed_by === "hedge_plan";
|
||||||
|
let rule;
|
||||||
|
if (t.profit_rr != null && Number(t.profit_rr) > 0) {
|
||||||
|
rule = "盈亏比 ×" + fmt(t.profit_rr, 2);
|
||||||
|
} else if (t.oo_profit_rr != null && Number(t.oo_profit_rr) > 0) {
|
||||||
|
rule = "盈亏比 ×" + fmt(t.oo_profit_rr, 2);
|
||||||
|
} else if (t.target_index != null && Number(t.target_index) > 0) {
|
||||||
|
const side = (t.opt_type || "").toUpperCase() === "P" ? "Put ≤" : "Call ≥";
|
||||||
|
rule = side + " " + fmt(t.target_index, 1);
|
||||||
|
} else {
|
||||||
|
rule = "委托中";
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
'<div class="opt-target-mon-item' + (managed ? " opt-target-mon-item--managed" : "") + '">' +
|
'<div class="opt-target-mon-item' + (managed ? " opt-target-mon-item--managed" : "") + '">' +
|
||||||
'<code class="opt-target-mon-inst" title="' + (t.inst_id || "") + '">' + (t.inst_id || "") + "</code>" +
|
'<code class="opt-target-mon-inst" title="' + (t.inst_id || "") + '">' + (t.inst_id || "") + "</code>" +
|
||||||
'<span class="opt-target-mon-rule">' + side + " " + fmt(t.target_index, 1) + "</span>" +
|
'<span class="opt-target-mon-rule">' + rule + "</span>" +
|
||||||
(managed
|
(managed
|
||||||
? '<span class="opt-target-mon-managed">对冲计划 #' + (t.plan_id || "") + " · 进行中</span>"
|
? '<span class="opt-target-mon-managed">对冲计划 #' + (t.plan_id || "") + " · 进行中</span>"
|
||||||
: '<button type="button" class="btn-secondary opt-target-mon-cancel" data-inst="' + (t.inst_id || "") + '">取消</button>') +
|
: '<button type="button" class="btn-secondary opt-target-mon-cancel" data-inst="' + (t.inst_id || "") + '">取消</button>') +
|
||||||
@@ -1887,20 +2188,23 @@
|
|||||||
paintPositions(list);
|
paintPositions(list);
|
||||||
const fromPos = list.reduce(function (targets, p) {
|
const fromPos = list.reduce(function (targets, p) {
|
||||||
if (!p) return targets;
|
if (!p) return targets;
|
||||||
if (p.target_index != null) {
|
if (p.profit_rr != null || p.target_index != null) {
|
||||||
targets.push({
|
targets.push({
|
||||||
id: p.target_monitor_id,
|
id: p.target_monitor_id,
|
||||||
inst_id: p.inst_id,
|
inst_id: p.inst_id,
|
||||||
opt_type: p.opt_type,
|
opt_type: p.opt_type,
|
||||||
target_index: p.target_index,
|
target_index: p.target_index,
|
||||||
|
profit_rr: p.profit_rr,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const hedgeTarget = p.hedge_plan_target;
|
const hedgeTarget = p.hedge_plan_target;
|
||||||
if (hedgeTarget && hedgeTarget.target_index != null) {
|
if (hedgeTarget) {
|
||||||
targets.push({
|
targets.push({
|
||||||
inst_id: p.inst_id,
|
inst_id: p.inst_id,
|
||||||
opt_type: p.opt_type || hedgeTarget.opt_type,
|
opt_type: p.opt_type || hedgeTarget.opt_type,
|
||||||
target_index: hedgeTarget.target_index,
|
target_index: hedgeTarget.target_index,
|
||||||
|
oo_profit_rr: hedgeTarget.oo_profit_rr,
|
||||||
|
profit_rr: hedgeTarget.oo_profit_rr,
|
||||||
plan_id: hedgeTarget.plan_id,
|
plan_id: hedgeTarget.plan_id,
|
||||||
managed_by: hedgeTarget.managed_by,
|
managed_by: hedgeTarget.managed_by,
|
||||||
});
|
});
|
||||||
@@ -2162,6 +2466,7 @@
|
|||||||
const expandCb = document.getElementById("opt-strike-expand-all");
|
const expandCb = document.getElementById("opt-strike-expand-all");
|
||||||
if (expandCb) expandCb.checked = false;
|
if (expandCb) expandCb.checked = false;
|
||||||
renderStrikes();
|
renderStrikes();
|
||||||
|
void watchCurrentExpiryQuotes();
|
||||||
}
|
}
|
||||||
|
|
||||||
function bootOptionsPanel() {
|
function bootOptionsPanel() {
|
||||||
@@ -2172,6 +2477,8 @@
|
|||||||
updateUnderlyingLabel();
|
updateUnderlyingLabel();
|
||||||
refreshPendingOrders();
|
refreshPendingOrders();
|
||||||
startPendingOrdersPoll();
|
startPendingOrdersPoll();
|
||||||
|
startChainSoftPoll();
|
||||||
|
startQuoteLiveStream();
|
||||||
const hasCache =
|
const hasCache =
|
||||||
chainHasExpiries(panelCache.chain) &&
|
chainHasExpiries(panelCache.chain) &&
|
||||||
panelCache.underlying === state.underlying &&
|
panelCache.underlying === state.underlying &&
|
||||||
@@ -2181,8 +2488,9 @@
|
|||||||
renderExpiries();
|
renderExpiries();
|
||||||
renderStrikes();
|
renderStrikes();
|
||||||
refreshAllPositions();
|
refreshAllPositions();
|
||||||
// 后台静默刷新,避免缓存过期后到期日变空
|
void watchCurrentExpiryQuotes();
|
||||||
loadChain({ soft: true });
|
// 后台静默刷新结构;卖一优先走 WS
|
||||||
|
softRefreshChainThrottled(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
requestAnimationFrame(function () {
|
requestAnimationFrame(function () {
|
||||||
@@ -2286,17 +2594,17 @@
|
|||||||
}
|
}
|
||||||
bindOrderDialogChrome();
|
bindOrderDialogChrome();
|
||||||
|
|
||||||
["opt-sheets-amount", "opt-eth-amount", "opt-target-idx"].forEach(function (id) {
|
["opt-sheets-amount", "opt-eth-amount", "opt-profit-rr"].forEach(function (id) {
|
||||||
const el = document.getElementById(id);
|
const el = document.getElementById(id);
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
el.addEventListener("change", function () {
|
el.addEventListener("change", function () {
|
||||||
if (id === "opt-target-idx") {
|
if (id === "opt-profit-rr") {
|
||||||
updateEstimatedProfit();
|
updateEstimatedProfit();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (state.selectedInst) selectContract(state.selectedInst, null, true);
|
if (state.selectedInst) selectContract(state.selectedInst, null, true);
|
||||||
});
|
});
|
||||||
if (id === "opt-target-idx") {
|
if (id === "opt-profit-rr") {
|
||||||
el.addEventListener("input", updateEstimatedProfit);
|
el.addEventListener("input", updateEstimatedProfit);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -2306,6 +2614,8 @@
|
|||||||
window.OptionsPanelLive = {
|
window.OptionsPanelLive = {
|
||||||
refreshSoft: function () {
|
refreshSoft: function () {
|
||||||
refreshAllPositions();
|
refreshAllPositions();
|
||||||
|
// 有 WS 实时报价时不再 REST 刷链;断开时才兜底
|
||||||
|
softRefreshChainThrottled(false);
|
||||||
},
|
},
|
||||||
refreshChain: loadChain,
|
refreshChain: loadChain,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -219,38 +219,42 @@
|
|||||||
const hint = closeGateHint(closePreview);
|
const hint = closeGateHint(closePreview);
|
||||||
return hint ? '<div class="muted opt-bid-invalid-hint">' + hint + "</div>" : "";
|
return hint ? '<div class="muted opt-bid-invalid-hint">' + hint + "</div>" : "";
|
||||||
})() +
|
})() +
|
||||||
(p.target_index != null
|
(p.profit_rr != null || p.target_index != null
|
||||||
? (function () {
|
? (function () {
|
||||||
const eth = p.eth_amount != null ? Number(p.eth_amount)
|
const hedgeTarget = p.hedge_plan_target || null;
|
||||||
: (Number(p.pos) > 0 ? Number(p.pos) * Number(p.ct_mult || 0.01) : null);
|
const managed = hedgeTarget && hedgeTarget.managed_by === "hedge_plan";
|
||||||
const strike = Number(p.strike);
|
const rr =
|
||||||
const tgt = Number(p.target_index);
|
managed && hedgeTarget.oo_profit_rr != null
|
||||||
|
? Number(hedgeTarget.oo_profit_rr)
|
||||||
|
: p.profit_rr != null
|
||||||
|
? Number(p.profit_rr)
|
||||||
|
: null;
|
||||||
const prem = Number(p.premium_paid);
|
const prem = Number(p.premium_paid);
|
||||||
let profit = null;
|
let profit = null;
|
||||||
let value = null;
|
let need = null;
|
||||||
if (Number.isFinite(tgt) && Number.isFinite(strike) && eth > 0) {
|
if (rr != null && Number.isFinite(rr) && rr > 0 && Number.isFinite(prem) && prem > 0) {
|
||||||
const o = String(p.opt_type || "").toUpperCase();
|
profit = Math.round(prem * rr * 100) / 100;
|
||||||
const intrinsic = o === "C" ? Math.max(0, tgt - strike) : o === "P" ? Math.max(0, strike - tgt) : null;
|
need = Math.round((prem + profit) * 100) / 100;
|
||||||
if (intrinsic != null) {
|
|
||||||
value = Math.round(intrinsic * eth * 100) / 100;
|
|
||||||
if (!hidePnl && Number.isFinite(prem)) profit = Math.round((value - prem) * 100) / 100;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
const profitTxt = profit == null ? "—" : ((profit > 0 ? "+" : "") + fmtUsdc(profit) + " USDC");
|
const profitTxt = profit == null ? "—" : ((profit > 0 ? "+" : "") + fmtUsdc(profit) + " USDC");
|
||||||
const profitCls = profit > 0 ? " pnl-pos" : profit < 0 ? " pnl-neg" : "";
|
const profitCls = profit > 0 ? " pnl-pos" : profit < 0 ? " pnl-neg" : "";
|
||||||
const hedgeTarget = p.hedge_plan_target || null;
|
|
||||||
const managed = hedgeTarget && hedgeTarget.managed_by === "hedge_plan";
|
|
||||||
const profitSpan = hidePnl
|
const profitSpan = hidePnl
|
||||||
? ""
|
? ""
|
||||||
: '<span class="pos-value' + profitCls + '">预估盈利 ' + profitTxt + "</span>";
|
: '<span class="pos-value' + profitCls + '">目标盈利 ' + profitTxt + "</span>";
|
||||||
|
const ruleTxt =
|
||||||
|
rr != null && Number.isFinite(rr) && rr > 0
|
||||||
|
? "盈亏比 ×" + fmt(rr, 2)
|
||||||
|
: p.target_index != null
|
||||||
|
? "目标 " + fmt(p.target_index, 1)
|
||||||
|
: "委托中";
|
||||||
return (
|
return (
|
||||||
'<div class="opt-target-row opt-target-row--ro' + (managed ? " opt-target-row--managed" : "") + '">' +
|
'<div class="opt-target-row opt-target-row--ro' + (managed ? " opt-target-row--managed" : "") + '">' +
|
||||||
'<span class="opt-target-row-label">' + (managed ? "对冲计划 #" + hedgeTarget.plan_id : "委托") + "</span>" +
|
'<span class="opt-target-row-label">' + (managed ? "对冲计划 #" + hedgeTarget.plan_id : "委托") + "</span>" +
|
||||||
'<span class="pos-value">目标 ' + fmt(p.target_index, 1) + "</span>" +
|
'<span class="pos-value">' + ruleTxt + "</span>" +
|
||||||
'<span class="pos-value">价值 ' + (value == null ? "—" : fmtUsdc(value) + " USDC") + "</span>" +
|
(need != null ? '<span class="pos-value">需回收 ' + fmtUsdc(need) + " USDC</span>" : "") +
|
||||||
profitSpan +
|
profitSpan +
|
||||||
'<span class="muted opt-target-row-hint">' +
|
'<span class="muted opt-target-row-hint">' +
|
||||||
(managed ? "进行中 · 由对冲计划监控,到位后仅平盈利腿" : "监控中 · 到位按买一限价平") +
|
(managed ? "进行中 · 由对冲计划监控" : "监控中 · 买一浮盈达盈亏比后全平") +
|
||||||
"</span></div>"
|
"</span></div>"
|
||||||
);
|
);
|
||||||
})()
|
})()
|
||||||
|
|||||||
@@ -76,6 +76,8 @@
|
|||||||
target_win_leg: "期期平盈利腿",
|
target_win_leg: "期期平盈利腿",
|
||||||
target_up_win_leg: "期期上破·平盈利腿",
|
target_up_win_leg: "期期上破·平盈利腿",
|
||||||
target_down_win_leg: "期期下破·平盈利腿",
|
target_down_win_leg: "期期下破·平盈利腿",
|
||||||
|
oo_rr_target: "期期盈亏比达标",
|
||||||
|
oo_rr_closing: "期期盈亏比平仓中",
|
||||||
oo_rest_closing: "期期全平·清残腿中",
|
oo_rest_closing: "期期全平·清残腿中",
|
||||||
oo_rest_closed: "期期全平·两腿已平",
|
oo_rest_closed: "期期全平·两腿已平",
|
||||||
orphaned_after_tp: "止盈后持有至到期",
|
orphaned_after_tp: "止盈后持有至到期",
|
||||||
|
|||||||
+121
-18
@@ -25,6 +25,14 @@ _OKX_OPTION_ERR_ZH: dict[str, str] = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_OPTIONS_BALANCE_CACHE: dict[str, Any] = {"updated_at": 0.0, "data": None}
|
_OPTIONS_BALANCE_CACHE: dict[str, Any] = {"updated_at": 0.0, "data": None}
|
||||||
|
# 期权合约列表变化慢;短缓存+限频退避,避免 50011 拖垮期权链
|
||||||
|
_INSTRUMENTS_CACHE: dict[str, dict[str, Any]] = {}
|
||||||
|
_INSTRUMENTS_CACHE_LOCK = threading.Lock()
|
||||||
|
_INSTRUMENTS_CACHE_TTL_SEC = 90.0
|
||||||
|
_INSTRUMENTS_STALE_SEC = 600.0
|
||||||
|
_TICKERS_CACHE: dict[str, dict[str, Any]] = {}
|
||||||
|
_TICKERS_CACHE_LOCK = threading.Lock()
|
||||||
|
_TICKERS_CACHE_TTL_SEC = 8.0
|
||||||
|
|
||||||
|
|
||||||
def invalidate_options_balance_cache() -> None:
|
def invalidate_options_balance_cache() -> None:
|
||||||
@@ -32,6 +40,14 @@ def invalidate_options_balance_cache() -> None:
|
|||||||
_OPTIONS_BALANCE_CACHE["data"] = None
|
_OPTIONS_BALANCE_CACHE["data"] = None
|
||||||
|
|
||||||
|
|
||||||
|
def invalidate_option_instruments_cache(inst_family: str | None = None) -> None:
|
||||||
|
with _INSTRUMENTS_CACHE_LOCK:
|
||||||
|
if inst_family:
|
||||||
|
_INSTRUMENTS_CACHE.pop(str(inst_family), None)
|
||||||
|
else:
|
||||||
|
_INSTRUMENTS_CACHE.clear()
|
||||||
|
|
||||||
|
|
||||||
def _okx_trade_error_message(exc: BaseException | None = None, resp: Any = None) -> str:
|
def _okx_trade_error_message(exc: BaseException | None = None, resp: Any = None) -> str:
|
||||||
row: dict[str, Any] | None = None
|
row: dict[str, Any] | None = None
|
||||||
if isinstance(resp, dict):
|
if isinstance(resp, dict):
|
||||||
@@ -645,24 +661,105 @@ def fetch_index_price(ex: ccxt.okx, uly: str) -> float | None:
|
|||||||
def fetch_option_instruments(
|
def fetch_option_instruments(
|
||||||
ex: ccxt.okx,
|
ex: ccxt.okx,
|
||||||
inst_family: str,
|
inst_family: str,
|
||||||
|
*,
|
||||||
|
force: bool = False,
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
rows = ex.public_get_public_instruments(
|
"""拉取 live 期权合约列表;短 TTL 缓存,遇 50011 退避重试并可回退过期缓存."""
|
||||||
{"instType": "OPTION", "instFamily": inst_family}
|
family = (inst_family or "").strip()
|
||||||
|
if not family:
|
||||||
|
return []
|
||||||
|
now = time.time()
|
||||||
|
with _INSTRUMENTS_CACHE_LOCK:
|
||||||
|
cached = _INSTRUMENTS_CACHE.get(family)
|
||||||
|
if (
|
||||||
|
not force
|
||||||
|
and cached
|
||||||
|
and now - float(cached.get("updated_at") or 0) < _INSTRUMENTS_CACHE_TTL_SEC
|
||||||
|
and isinstance(cached.get("rows"), list)
|
||||||
|
and cached["rows"]
|
||||||
|
):
|
||||||
|
return list(cached["rows"])
|
||||||
|
|
||||||
|
last_err: BaseException | None = None
|
||||||
|
rows: list[dict[str, Any]] = []
|
||||||
|
for attempt in range(4):
|
||||||
|
try:
|
||||||
|
raw = ex.public_get_public_instruments(
|
||||||
|
{"instType": "OPTION", "instFamily": family}
|
||||||
).get("data") or []
|
).get("data") or []
|
||||||
return [r for r in rows if isinstance(r, dict) and r.get("state") == "live"]
|
rows = [r for r in raw if isinstance(r, dict) and r.get("state") == "live"]
|
||||||
|
last_err = None
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
last_err = e
|
||||||
|
if _is_okx_rate_limit(e) and attempt < 3:
|
||||||
|
time.sleep(0.8 * (2**attempt))
|
||||||
|
continue
|
||||||
|
break
|
||||||
|
|
||||||
|
if rows:
|
||||||
|
with _INSTRUMENTS_CACHE_LOCK:
|
||||||
|
_INSTRUMENTS_CACHE[family] = {"updated_at": time.time(), "rows": list(rows)}
|
||||||
|
return rows
|
||||||
|
|
||||||
|
# 限频/短暂失败:优先用未过期太久的缓存,避免整页「拉取失败」
|
||||||
|
if cached and isinstance(cached.get("rows"), list) and cached["rows"]:
|
||||||
|
age = now - float(cached.get("updated_at") or 0)
|
||||||
|
if age < _INSTRUMENTS_STALE_SEC and (
|
||||||
|
last_err is None or _is_okx_rate_limit(last_err) or not rows
|
||||||
|
):
|
||||||
|
return list(cached["rows"])
|
||||||
|
|
||||||
|
if last_err is not None:
|
||||||
|
raise last_err
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
def fetch_option_tickers(ex: ccxt.okx, inst_family: str) -> dict[str, dict[str, Any]]:
|
def fetch_option_tickers(
|
||||||
|
ex: ccxt.okx,
|
||||||
|
inst_family: str,
|
||||||
|
*,
|
||||||
|
force: bool = False,
|
||||||
|
) -> dict[str, dict[str, Any]]:
|
||||||
|
family = (inst_family or "").strip()
|
||||||
|
if not family:
|
||||||
|
return {}
|
||||||
|
now = time.time()
|
||||||
|
with _TICKERS_CACHE_LOCK:
|
||||||
|
cached = _TICKERS_CACHE.get(family)
|
||||||
|
if (
|
||||||
|
not force
|
||||||
|
and cached
|
||||||
|
and now - float(cached.get("updated_at") or 0) < _TICKERS_CACHE_TTL_SEC
|
||||||
|
and isinstance(cached.get("rows"), dict)
|
||||||
|
and cached["rows"]
|
||||||
|
):
|
||||||
|
return dict(cached["rows"])
|
||||||
|
|
||||||
out: dict[str, dict[str, Any]] = {}
|
out: dict[str, dict[str, Any]] = {}
|
||||||
|
last_err: BaseException | None = None
|
||||||
|
for attempt in range(3):
|
||||||
try:
|
try:
|
||||||
rows = ex.public_get_market_tickers(
|
rows = ex.public_get_market_tickers(
|
||||||
{"instType": "OPTION", "instFamily": inst_family}
|
{"instType": "OPTION", "instFamily": family}
|
||||||
).get("data") or []
|
).get("data") or []
|
||||||
for r in rows:
|
for r in rows:
|
||||||
if isinstance(r, dict) and r.get("instId"):
|
if isinstance(r, dict) and r.get("instId"):
|
||||||
out[str(r["instId"])] = r
|
out[str(r["instId"])] = r
|
||||||
except Exception:
|
if out:
|
||||||
pass
|
with _TICKERS_CACHE_LOCK:
|
||||||
|
_TICKERS_CACHE[family] = {"updated_at": time.time(), "rows": dict(out)}
|
||||||
|
return out
|
||||||
|
except Exception as e:
|
||||||
|
last_err = e
|
||||||
|
if _is_okx_rate_limit(e) and attempt < 2:
|
||||||
|
time.sleep(0.6 * (attempt + 1))
|
||||||
|
continue
|
||||||
|
break
|
||||||
|
if cached and isinstance(cached.get("rows"), dict) and cached["rows"]:
|
||||||
|
return dict(cached["rows"])
|
||||||
|
if last_err is not None and _is_okx_rate_limit(last_err):
|
||||||
|
return out
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
@@ -674,6 +771,9 @@ def build_option_chain(
|
|||||||
itm_only: bool = True,
|
itm_only: bool = True,
|
||||||
itm_max_dist_usd: float = 30.0,
|
itm_max_dist_usd: float = 30.0,
|
||||||
index_px: float | None = None,
|
index_px: float | None = None,
|
||||||
|
tickers_override: dict[str, dict[str, Any]] | None = None,
|
||||||
|
fetch_tickers: bool = True,
|
||||||
|
force_tickers: bool = False,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
u = (underlying or "ETH").upper()
|
u = (underlying or "ETH").upper()
|
||||||
family = f"{u}-USD_UM"
|
family = f"{u}-USD_UM"
|
||||||
@@ -683,23 +783,24 @@ def build_option_chain(
|
|||||||
max_ms = now_ms + max_dte_days * 86400 * 1000
|
max_ms = now_ms + max_dte_days * 86400 * 1000
|
||||||
instruments_err = ""
|
instruments_err = ""
|
||||||
instruments: list[dict[str, Any]] = []
|
instruments: list[dict[str, Any]] = []
|
||||||
for attempt in range(2):
|
rate_limited = False
|
||||||
try:
|
try:
|
||||||
instruments = fetch_option_instruments(ex, family)
|
instruments = fetch_option_instruments(ex, family)
|
||||||
instruments_err = ""
|
if not instruments:
|
||||||
if instruments:
|
|
||||||
break
|
|
||||||
instruments_err = "期权合约列表为空"
|
instruments_err = "期权合约列表为空"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
instruments = []
|
instruments = []
|
||||||
instruments_err = str(e) or e.__class__.__name__
|
instruments_err = str(e) or e.__class__.__name__
|
||||||
if attempt == 0:
|
rate_limited = _is_okx_rate_limit(e)
|
||||||
time.sleep(0.35)
|
if rate_limited:
|
||||||
continue
|
instruments_err = "OKX 请求过于频繁(50011),请稍后点「刷新链」重试"
|
||||||
break
|
tickers: dict[str, dict[str, Any]] = {}
|
||||||
if attempt == 0 and not instruments:
|
if fetch_tickers:
|
||||||
time.sleep(0.35)
|
tickers = fetch_option_tickers(ex, family, force=force_tickers)
|
||||||
tickers = fetch_option_tickers(ex, family)
|
if tickers_override:
|
||||||
|
for iid, row in tickers_override.items():
|
||||||
|
if isinstance(row, dict) and iid:
|
||||||
|
tickers[str(iid)] = {**(tickers.get(str(iid)) or {}), **row}
|
||||||
expiries: dict[str, list[dict[str, Any]]] = {}
|
expiries: dict[str, list[dict[str, Any]]] = {}
|
||||||
skipped_no_index = 0
|
skipped_no_index = 0
|
||||||
for meta in instruments:
|
for meta in instruments:
|
||||||
@@ -777,6 +878,8 @@ def build_option_chain(
|
|||||||
"expiries": exp_list,
|
"expiries": exp_list,
|
||||||
"instruments_count": len(instruments),
|
"instruments_count": len(instruments),
|
||||||
}
|
}
|
||||||
|
if rate_limited:
|
||||||
|
out["rate_limited"] = True
|
||||||
if not exp_list:
|
if not exp_list:
|
||||||
if instruments_err:
|
if instruments_err:
|
||||||
out["chain_error"] = f"拉取期权合约失败: {instruments_err}"
|
out["chain_error"] = f"拉取期权合约失败: {instruments_err}"
|
||||||
|
|||||||
@@ -0,0 +1,199 @@
|
|||||||
|
"""OKX 公共 WebSocket(同步线程):订阅 tickers / index-tickers,自动重连."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from collections.abc import Callable
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
OKX_PUBLIC_WS_URL = "wss://ws.okx.com:8443/ws/v5/public"
|
||||||
|
_SUBSCRIBE_CHUNK = 40
|
||||||
|
_APP_PING_SEC = 20.0
|
||||||
|
|
||||||
|
|
||||||
|
class OkxPublicWs:
|
||||||
|
"""单连接公共 WS;set_subscriptions 全量对齐目标频道."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
on_data: Callable[[dict[str, Any]], None],
|
||||||
|
url: str = OKX_PUBLIC_WS_URL,
|
||||||
|
name: str = "okx-public-ws",
|
||||||
|
) -> None:
|
||||||
|
self._on_data = on_data
|
||||||
|
self._url = url
|
||||||
|
self._name = name
|
||||||
|
self._lock = threading.RLock()
|
||||||
|
self._desired: dict[str, dict[str, str]] = {}
|
||||||
|
self._active: set[str] = set()
|
||||||
|
self._stop = threading.Event()
|
||||||
|
self._thread: threading.Thread | None = None
|
||||||
|
self._ws: Any = None
|
||||||
|
self._connected = False
|
||||||
|
self._last_msg_at = 0.0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def connected(self) -> bool:
|
||||||
|
return self._connected
|
||||||
|
|
||||||
|
@property
|
||||||
|
def last_msg_at(self) -> float:
|
||||||
|
return self._last_msg_at
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
if self._thread and self._thread.is_alive():
|
||||||
|
return
|
||||||
|
self._stop.clear()
|
||||||
|
self._thread = threading.Thread(target=self._run_loop, name=self._name, daemon=True)
|
||||||
|
self._thread.start()
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
self._stop.set()
|
||||||
|
ws = self._ws
|
||||||
|
if ws is not None:
|
||||||
|
try:
|
||||||
|
ws.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if self._thread and self._thread.is_alive():
|
||||||
|
self._thread.join(timeout=3.0)
|
||||||
|
|
||||||
|
def set_subscriptions(self, args: list[dict[str, str]]) -> None:
|
||||||
|
desired: dict[str, dict[str, str]] = {}
|
||||||
|
for raw in args:
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
continue
|
||||||
|
channel = str(raw.get("channel") or "").strip()
|
||||||
|
inst_id = str(raw.get("instId") or "").strip()
|
||||||
|
if not channel or not inst_id:
|
||||||
|
continue
|
||||||
|
key = f"{channel}:{inst_id}"
|
||||||
|
desired[key] = {"channel": channel, "instId": inst_id}
|
||||||
|
with self._lock:
|
||||||
|
self._desired = desired
|
||||||
|
ws = self._ws
|
||||||
|
connected = self._connected
|
||||||
|
active = set(self._active)
|
||||||
|
if connected and ws is not None:
|
||||||
|
self._sync_subs(ws, active, desired)
|
||||||
|
|
||||||
|
def _sync_subs(
|
||||||
|
self,
|
||||||
|
ws: Any,
|
||||||
|
active: set[str],
|
||||||
|
desired: dict[str, dict[str, str]],
|
||||||
|
) -> None:
|
||||||
|
unsub_args: list[dict[str, str]] = []
|
||||||
|
for key in active - set(desired.keys()):
|
||||||
|
channel, _, inst_id = key.partition(":")
|
||||||
|
if channel and inst_id:
|
||||||
|
unsub_args.append({"channel": channel, "instId": inst_id})
|
||||||
|
sub_args = [desired[k] for k in (set(desired.keys()) - active)]
|
||||||
|
if unsub_args:
|
||||||
|
self._send_op(ws, "unsubscribe", unsub_args)
|
||||||
|
if sub_args:
|
||||||
|
self._send_op(ws, "subscribe", sub_args)
|
||||||
|
with self._lock:
|
||||||
|
self._active = set(desired.keys())
|
||||||
|
|
||||||
|
def _send_op(self, ws: Any, op: str, args: list[dict[str, str]]) -> None:
|
||||||
|
for i in range(0, len(args), _SUBSCRIBE_CHUNK):
|
||||||
|
chunk = args[i : i + _SUBSCRIBE_CHUNK]
|
||||||
|
try:
|
||||||
|
ws.send(json.dumps({"op": op, "args": chunk}, ensure_ascii=False))
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("%s %s failed: %s", self._name, op, e)
|
||||||
|
return
|
||||||
|
if i + _SUBSCRIBE_CHUNK < len(args):
|
||||||
|
time.sleep(0.08)
|
||||||
|
|
||||||
|
def _run_loop(self) -> None:
|
||||||
|
try:
|
||||||
|
import websocket
|
||||||
|
except ImportError:
|
||||||
|
logger.error("%s: websocket-client not installed", self._name)
|
||||||
|
return
|
||||||
|
backoff = 1.0
|
||||||
|
while not self._stop.is_set():
|
||||||
|
opened = False
|
||||||
|
try:
|
||||||
|
self._connected = False
|
||||||
|
with self._lock:
|
||||||
|
self._active.clear()
|
||||||
|
|
||||||
|
def on_open(ws: Any) -> None:
|
||||||
|
nonlocal opened
|
||||||
|
opened = True
|
||||||
|
self._connected = True
|
||||||
|
self._last_msg_at = time.time()
|
||||||
|
with self._lock:
|
||||||
|
desired = dict(self._desired)
|
||||||
|
self._sync_subs(ws, set(), desired)
|
||||||
|
|
||||||
|
def on_message(_ws: Any, message: str) -> None:
|
||||||
|
self._last_msg_at = time.time()
|
||||||
|
if message == "pong":
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
payload = json.loads(message)
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
return
|
||||||
|
if payload.get("event") in ("subscribe", "unsubscribe", "error"):
|
||||||
|
if payload.get("event") == "error":
|
||||||
|
logger.warning("%s event error: %s", self._name, payload)
|
||||||
|
return
|
||||||
|
if payload.get("arg") and payload.get("data") is not None:
|
||||||
|
try:
|
||||||
|
self._on_data(payload)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("%s on_data failed", self._name)
|
||||||
|
|
||||||
|
def on_error(_ws: Any, error: Any) -> None:
|
||||||
|
logger.warning("%s error: %s", self._name, error)
|
||||||
|
|
||||||
|
def on_close(_ws: Any, *_args: Any) -> None:
|
||||||
|
self._connected = False
|
||||||
|
|
||||||
|
self._ws = websocket.WebSocketApp(
|
||||||
|
self._url,
|
||||||
|
on_open=on_open,
|
||||||
|
on_message=on_message,
|
||||||
|
on_error=on_error,
|
||||||
|
on_close=on_close,
|
||||||
|
)
|
||||||
|
ping_stop = threading.Event()
|
||||||
|
|
||||||
|
def ping_loop() -> None:
|
||||||
|
while not self._stop.is_set() and not ping_stop.is_set():
|
||||||
|
ws = self._ws
|
||||||
|
if ws is not None and self._connected:
|
||||||
|
try:
|
||||||
|
ws.send("ping")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if ping_stop.wait(_APP_PING_SEC):
|
||||||
|
break
|
||||||
|
|
||||||
|
ping_thread = threading.Thread(
|
||||||
|
target=ping_loop, name=f"{self._name}-ping", daemon=True
|
||||||
|
)
|
||||||
|
ping_thread.start()
|
||||||
|
self._ws.run_forever(ping_interval=0)
|
||||||
|
ping_stop.set()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("%s run failed: %s", self._name, e)
|
||||||
|
finally:
|
||||||
|
self._connected = False
|
||||||
|
self._ws = None
|
||||||
|
if self._stop.is_set():
|
||||||
|
break
|
||||||
|
time.sleep(backoff)
|
||||||
|
backoff = 1.0 if opened else min(30.0, backoff * 1.7)
|
||||||
|
|
||||||
@@ -444,6 +444,7 @@ def _hedge_ratio(opt_pnl: float, perp_pnl: float) -> Optional[float]:
|
|||||||
|
|
||||||
def build_options_options_preview(
|
def build_options_options_preview(
|
||||||
*,
|
*,
|
||||||
|
profit_rr: float | None = None,
|
||||||
target_price: float | None = None,
|
target_price: float | None = None,
|
||||||
target_price_up: float | None = None,
|
target_price_up: float | None = None,
|
||||||
target_price_down: float | None = None,
|
target_price_down: float | None = None,
|
||||||
@@ -451,7 +452,11 @@ def build_options_options_preview(
|
|||||||
leg_a: dict[str, Any],
|
leg_a: dict[str, Any],
|
||||||
leg_b: dict[str, Any],
|
leg_b: dict[str, Any],
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""期期情景:上破/下破目标价 / 到期现价 / 最大保费损耗."""
|
"""期期情景:盈亏比达标 / 到期现价 / 最大保费损耗.
|
||||||
|
|
||||||
|
profit_rr=2 表示目标盈利=2×权利金;中途不达标则等到期.
|
||||||
|
仍接受旧上破/下破参数仅作兼容测算.
|
||||||
|
"""
|
||||||
|
|
||||||
def _leg_pnl(leg: dict[str, Any], spot: float) -> float:
|
def _leg_pnl(leg: dict[str, Any], spot: float) -> float:
|
||||||
return option_expiry_pnl(
|
return option_expiry_pnl(
|
||||||
@@ -463,15 +468,73 @@ def build_options_options_preview(
|
|||||||
premium_paid=float(leg.get("premium_paid") or 0),
|
premium_paid=float(leg.get("premium_paid") or 0),
|
||||||
)
|
)
|
||||||
|
|
||||||
# 兼容旧单目标:若未传上下目标则用 target_price 填两边
|
prem = float(leg_a.get("premium_paid") or 0) + float(leg_b.get("premium_paid") or 0)
|
||||||
|
a_flat = _leg_pnl(leg_a, index_px)
|
||||||
|
b_flat = _leg_pnl(leg_b, index_px)
|
||||||
|
flat_total = a_flat + b_flat
|
||||||
|
|
||||||
|
rr = None
|
||||||
|
if profit_rr not in (None, ""):
|
||||||
|
try:
|
||||||
|
rr = float(profit_rr)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
rr = None
|
||||||
|
if rr is not None and rr > 0:
|
||||||
|
target_pnl = rr * prem
|
||||||
|
return {
|
||||||
|
"plan_type": "options_options",
|
||||||
|
"premium_paid": round(prem, 6),
|
||||||
|
"oo_profit_rr": round(rr, 4),
|
||||||
|
"target_profit": round(target_pnl, 4),
|
||||||
|
"scenarios": [
|
||||||
|
{
|
||||||
|
"id": "rr_target",
|
||||||
|
"label": f"盈亏比×{rr:g}",
|
||||||
|
"spot": None,
|
||||||
|
"leg_a_pnl": None,
|
||||||
|
"leg_b_pnl": None,
|
||||||
|
"total": round(target_pnl, 4),
|
||||||
|
"note": f"两腿合计浮盈≥{rr:g}×权利金({round(prem, 4)})时全平;不达标等到期",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "expiry_flat",
|
||||||
|
"label": "到期·现价(未达标)",
|
||||||
|
"spot": index_px,
|
||||||
|
"leg_a_pnl": round(a_flat, 4),
|
||||||
|
"leg_b_pnl": round(b_flat, 4),
|
||||||
|
"total": round(flat_total, 4),
|
||||||
|
"note": "中途未达盈亏比则持有至到期结算",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "max_premium_loss",
|
||||||
|
"label": "最大保费损耗",
|
||||||
|
"spot": None,
|
||||||
|
"leg_a_pnl": round(-float(leg_a.get("premium_paid") or 0), 4),
|
||||||
|
"leg_b_pnl": round(-float(leg_b.get("premium_paid") or 0), 4),
|
||||||
|
"total": round(-prem, 4),
|
||||||
|
"note": "双腿权利金全部损失",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"summary": {
|
||||||
|
"oo_profit_rr": round(rr, 4),
|
||||||
|
"target_profit": round(target_pnl, 4),
|
||||||
|
"at_target_total": round(target_pnl, 4),
|
||||||
|
"expiry_flat_total": round(flat_total, 4),
|
||||||
|
"premium_paid": round(prem, 6),
|
||||||
|
"expiry_is_loss": flat_total <= 0,
|
||||||
|
"rr_risk_premium": round(prem, 6),
|
||||||
|
"rr_target": round(rr, 4),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# 兼容旧上破/下破测算
|
||||||
up = target_price_up if target_price_up is not None else target_price
|
up = target_price_up if target_price_up is not None else target_price
|
||||||
down = target_price_down if target_price_down is not None else target_price
|
down = target_price_down if target_price_down is not None else target_price
|
||||||
if up is None or down is None:
|
if up is None or down is None:
|
||||||
raise ValueError("缺少上破/下破目标价")
|
raise ValueError("请填写盈亏比(相对权利金,默认2)")
|
||||||
up_f = float(up)
|
up_f = float(up)
|
||||||
down_f = float(down)
|
down_f = float(down)
|
||||||
|
|
||||||
prem = float(leg_a.get("premium_paid") or 0) + float(leg_b.get("premium_paid") or 0)
|
|
||||||
a_up = _leg_pnl(leg_a, up_f)
|
a_up = _leg_pnl(leg_a, up_f)
|
||||||
b_up = _leg_pnl(leg_b, up_f)
|
b_up = _leg_pnl(leg_b, up_f)
|
||||||
at_up = a_up + b_up
|
at_up = a_up + b_up
|
||||||
@@ -482,15 +545,10 @@ def build_options_options_preview(
|
|||||||
at_dn = a_dn + b_dn
|
at_dn = a_dn + b_dn
|
||||||
win_dn = "a" if a_dn >= b_dn else "b"
|
win_dn = "a" if a_dn >= b_dn else "b"
|
||||||
|
|
||||||
a_flat = _leg_pnl(leg_a, index_px)
|
|
||||||
b_flat = _leg_pnl(leg_b, index_px)
|
|
||||||
flat_total = a_flat + b_flat
|
|
||||||
expiry_loss = flat_total if flat_total <= 0 else flat_total
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"plan_type": "options_options",
|
"plan_type": "options_options",
|
||||||
"premium_paid": round(prem, 6),
|
"premium_paid": round(prem, 6),
|
||||||
"target_price": up_f, # 兼容旧字段,取上破
|
"target_price": up_f,
|
||||||
"target_price_up": up_f,
|
"target_price_up": up_f,
|
||||||
"target_price_down": down_f,
|
"target_price_down": down_f,
|
||||||
"winner_at_up": win_up,
|
"winner_at_up": win_up,
|
||||||
@@ -538,10 +596,9 @@ def build_options_options_preview(
|
|||||||
"at_target_up_total": round(at_up, 4),
|
"at_target_up_total": round(at_up, 4),
|
||||||
"at_target_down_total": round(at_dn, 4),
|
"at_target_down_total": round(at_dn, 4),
|
||||||
"at_target_total": round(at_up, 4),
|
"at_target_total": round(at_up, 4),
|
||||||
"expiry_flat_total": round(expiry_loss, 4),
|
"expiry_flat_total": round(flat_total, 4),
|
||||||
"premium_paid": round(prem, 6),
|
"premium_paid": round(prem, 6),
|
||||||
"expiry_is_loss": flat_total <= 0,
|
"expiry_is_loss": flat_total <= 0,
|
||||||
# 盈亏比:盈利/全亏保费(风险=权利金全损)
|
|
||||||
"rr_risk_premium": round(prem, 6),
|
"rr_risk_premium": round(prem, 6),
|
||||||
"rr_at_up": round(at_up / prem, 4) if prem > 0 else None,
|
"rr_at_up": round(at_up / prem, 4) if prem > 0 else None,
|
||||||
"rr_at_down": round(at_dn / prem, 4) if prem > 0 else None,
|
"rr_at_down": round(at_dn / prem, 4) if prem > 0 else None,
|
||||||
|
|||||||
@@ -72,6 +72,8 @@ def init_hedge_plan_tables(conn: sqlite3.Connection) -> None:
|
|||||||
)
|
)
|
||||||
_ensure_column(conn, "hedge_plans", "target_price_up", "REAL")
|
_ensure_column(conn, "hedge_plans", "target_price_up", "REAL")
|
||||||
_ensure_column(conn, "hedge_plans", "target_price_down", "REAL")
|
_ensure_column(conn, "hedge_plans", "target_price_down", "REAL")
|
||||||
|
# 期期:目标盈亏比=目标盈利/权利金(如 2=盈利 2 倍权利金);不达标则等到期
|
||||||
|
_ensure_column(conn, "hedge_plans", "oo_profit_rr", "REAL")
|
||||||
# close_all=盈利腿平后清残腿;hold_expiry=残腿持有至到期(现状)
|
# close_all=盈利腿平后清残腿;hold_expiry=残腿持有至到期(现状)
|
||||||
_ensure_column(conn, "hedge_plans", "oo_close_mode", "TEXT")
|
_ensure_column(conn, "hedge_plans", "oo_close_mode", "TEXT")
|
||||||
# 永期「以期权为主」
|
# 永期「以期权为主」
|
||||||
@@ -272,7 +274,7 @@ def active_options_targets_by_inst(conn: sqlite3.Connection) -> dict[str, dict[s
|
|||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"""
|
"""
|
||||||
SELECT p.id AS plan_id, p.underlying, p.target_price_up, p.target_price_down,
|
SELECT p.id AS plan_id, p.underlying, p.target_price_up, p.target_price_down,
|
||||||
l.inst_id, l.opt_type
|
p.oo_profit_rr, l.inst_id, l.opt_type
|
||||||
FROM hedge_plans p
|
FROM hedge_plans p
|
||||||
JOIN hedge_plan_legs l ON l.plan_id = p.id
|
JOIN hedge_plan_legs l ON l.plan_id = p.id
|
||||||
WHERE p.plan_type = 'options_options'
|
WHERE p.plan_type = 'options_options'
|
||||||
@@ -287,10 +289,36 @@ def active_options_targets_by_inst(conn: sqlite3.Connection) -> dict[str, dict[s
|
|||||||
for raw in rows:
|
for raw in rows:
|
||||||
row = dict(raw)
|
row = dict(raw)
|
||||||
inst_id = str(row.get("inst_id") or "")
|
inst_id = str(row.get("inst_id") or "")
|
||||||
|
if not inst_id or inst_id in out:
|
||||||
|
continue
|
||||||
opt_type = str(row.get("opt_type") or "").upper()
|
opt_type = str(row.get("opt_type") or "").upper()
|
||||||
|
rr = _sf(row.get("oo_profit_rr"))
|
||||||
target = row.get("target_price_up") if opt_type == "C" else row.get("target_price_down")
|
target = row.get("target_price_up") if opt_type == "C" else row.get("target_price_down")
|
||||||
target_f = _sf(target)
|
target_f = _sf(target)
|
||||||
if not inst_id or target_f is None or target_f <= 0 or inst_id in out:
|
# 盈亏比模式无指数目标价;旧上破/下破计划仍透出 target_index 只读展示
|
||||||
|
if rr is not None and rr > 0:
|
||||||
|
out[inst_id] = {
|
||||||
|
"plan_id": int(row["plan_id"]),
|
||||||
|
"inst_id": inst_id,
|
||||||
|
"underlying": row.get("underlying"),
|
||||||
|
"opt_type": opt_type,
|
||||||
|
"target_index": None,
|
||||||
|
"oo_profit_rr": rr,
|
||||||
|
"plan_type": "options_options",
|
||||||
|
"managed_by": "hedge_plan",
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
if target_f is None or target_f <= 0:
|
||||||
|
# 无目标价也标记托管,避免期权页误拆组
|
||||||
|
out[inst_id] = {
|
||||||
|
"plan_id": int(row["plan_id"]),
|
||||||
|
"inst_id": inst_id,
|
||||||
|
"underlying": row.get("underlying"),
|
||||||
|
"opt_type": opt_type,
|
||||||
|
"target_index": None,
|
||||||
|
"plan_type": "options_options",
|
||||||
|
"managed_by": "hedge_plan",
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
out[inst_id] = {
|
out[inst_id] = {
|
||||||
"plan_id": int(row["plan_id"]),
|
"plan_id": int(row["plan_id"]),
|
||||||
|
|||||||
@@ -999,6 +999,8 @@ def _tick_oo_close_rest(
|
|||||||
"target_up_win_leg",
|
"target_up_win_leg",
|
||||||
"target_down_win_leg",
|
"target_down_win_leg",
|
||||||
"oo_rest_closing",
|
"oo_rest_closing",
|
||||||
|
"oo_rr_closing",
|
||||||
|
"oo_rr_target",
|
||||||
"",
|
"",
|
||||||
)
|
)
|
||||||
if reason0 not in allowed_reasons and not (
|
if reason0 not in allowed_reasons and not (
|
||||||
@@ -1049,7 +1051,113 @@ def _tick_oo_close_rest(
|
|||||||
def _tick_oo_target(
|
def _tick_oo_target(
|
||||||
cfg: dict[str, Any], conn: Any, plan: dict[str, Any], legs: list[dict[str, Any]]
|
cfg: dict[str, Any], conn: Any, plan: dict[str, Any], legs: list[dict[str, Any]]
|
||||||
) -> Optional[dict[str, Any]]:
|
) -> Optional[dict[str, Any]]:
|
||||||
"""期期:触及上破或下破目标价时平盈利腿;按平仓模式处理另一腿."""
|
"""期期止盈:优先盈亏比(浮盈≥rr×权利金则两腿全平);否则兼容旧上破/下破."""
|
||||||
|
rr = _sf(plan.get("oo_profit_rr"))
|
||||||
|
if rr is not None and rr > 0:
|
||||||
|
return _tick_oo_rr_target(cfg, conn, plan, legs, rr=float(rr))
|
||||||
|
return _tick_oo_price_target(cfg, conn, plan, legs)
|
||||||
|
|
||||||
|
|
||||||
|
def _tick_oo_rr_target(
|
||||||
|
cfg: dict[str, Any],
|
||||||
|
conn: Any,
|
||||||
|
plan: dict[str, Any],
|
||||||
|
legs: list[dict[str, Any]],
|
||||||
|
*,
|
||||||
|
rr: float,
|
||||||
|
) -> Optional[dict[str, Any]]:
|
||||||
|
"""浮盈(买一回收−权利金)≥盈亏比×总权利金 → 两腿全平;不达标则等到期."""
|
||||||
|
open_legs = _oo_option_legs(legs, statuses=("open",))
|
||||||
|
if len(open_legs) < 1:
|
||||||
|
return None
|
||||||
|
premium = float(plan.get("premium_total") or 0)
|
||||||
|
if premium <= 0:
|
||||||
|
premium = sum(float(x.get("premium") or 0) for x in open_legs)
|
||||||
|
if premium <= 0:
|
||||||
|
return None
|
||||||
|
need = float(rr) * premium
|
||||||
|
quote_fn = cfg.get("quote_option_contract")
|
||||||
|
ex = cfg.get("exchange_options")
|
||||||
|
if not callable(quote_fn) or ex is None:
|
||||||
|
return None
|
||||||
|
idx = _index_px(cfg, str(plan.get("underlying") or "ETH"))
|
||||||
|
total_pnl = 0.0
|
||||||
|
missing_bid = 0
|
||||||
|
for leg in open_legs:
|
||||||
|
inst = str(leg.get("inst_id") or "")
|
||||||
|
bid = None
|
||||||
|
try:
|
||||||
|
q = quote_fn(ex, inst) if inst else {}
|
||||||
|
if isinstance(q, dict) and q.get("ok"):
|
||||||
|
bid = _sf(q.get("bid"))
|
||||||
|
except Exception:
|
||||||
|
bid = None
|
||||||
|
if bid is None or float(bid) <= 0:
|
||||||
|
missing_bid += 1
|
||||||
|
# 无买一时用内在价值兜底,避免短暂无盘口卡住;两腿都无买一则本轮跳过
|
||||||
|
total_pnl += _estimate_leg_close_pnl(leg, idx, None)
|
||||||
|
else:
|
||||||
|
total_pnl += _estimate_leg_close_pnl(leg, idx, float(bid))
|
||||||
|
if missing_bid >= len(open_legs):
|
||||||
|
return None
|
||||||
|
if total_pnl + 1e-9 < need:
|
||||||
|
return None
|
||||||
|
|
||||||
|
acted = False
|
||||||
|
for leg in list(open_legs):
|
||||||
|
close_r = _sell_option(
|
||||||
|
cfg, inst_id=str(leg.get("inst_id") or ""), sheets=float(leg.get("size") or 1)
|
||||||
|
)
|
||||||
|
if not close_r.get("ok"):
|
||||||
|
notify_hedge(
|
||||||
|
cfg,
|
||||||
|
build_hedge_alert_message(
|
||||||
|
title="期期盈亏比达标·平仓失败(将重试)",
|
||||||
|
plan_id=plan.get("id"),
|
||||||
|
detail=(
|
||||||
|
f"目标 {rr:g}×权利金={need:.4f};估算浮盈 {total_pnl:.4f}; "
|
||||||
|
f"{close_r.get('msg') or close_r}"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
update_plan(conn, int(plan["id"]), close_reason="oo_rr_closing")
|
||||||
|
return {
|
||||||
|
"plan_id": plan["id"],
|
||||||
|
"msg": "盈亏比达标但平仓失败",
|
||||||
|
"close": close_r,
|
||||||
|
"retry": True,
|
||||||
|
"rr": rr,
|
||||||
|
"need": need,
|
||||||
|
"mtm": total_pnl,
|
||||||
|
}
|
||||||
|
bid = _sf(close_r.get("bid"))
|
||||||
|
est = _estimate_leg_close_pnl(leg, idx, bid)
|
||||||
|
pnl = _option_leg_pnl_after_close(cfg, leg, fallback=est)
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE hedge_plan_legs SET status=?, close_reason=?, closed_at=?, realized_pnl=? WHERE id=?",
|
||||||
|
("closed", "oo_rr_target", _now(), round(pnl, 4), leg["id"]),
|
||||||
|
)
|
||||||
|
acted = True
|
||||||
|
|
||||||
|
if not acted:
|
||||||
|
return None
|
||||||
|
legs2 = get_plan_legs(conn, int(plan["id"]))
|
||||||
|
still_open = _oo_option_legs(legs2, statuses=("open", "hold_to_expiry"))
|
||||||
|
if still_open:
|
||||||
|
update_plan(conn, int(plan["id"]), close_reason="oo_rr_closing")
|
||||||
|
return {
|
||||||
|
"plan_id": plan["id"],
|
||||||
|
"msg": "盈亏比达标·部分已平,继续重试",
|
||||||
|
"remaining": len(still_open),
|
||||||
|
"rr": rr,
|
||||||
|
}
|
||||||
|
return _finalize_oo_all_closed(cfg, conn, plan, legs2, reason="oo_rr_target")
|
||||||
|
|
||||||
|
|
||||||
|
def _tick_oo_price_target(
|
||||||
|
cfg: dict[str, Any], conn: Any, plan: dict[str, Any], legs: list[dict[str, Any]]
|
||||||
|
) -> Optional[dict[str, Any]]:
|
||||||
|
"""旧逻辑:触及上破或下破目标价时平盈利腿;按平仓模式处理另一腿."""
|
||||||
idx = _index_px(cfg, str(plan.get("underlying") or "ETH"))
|
idx = _index_px(cfg, str(plan.get("underlying") or "ETH"))
|
||||||
if idx is None:
|
if idx is None:
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -45,6 +45,15 @@ def build_hedge_start_message(plan: dict[str, Any], *, legs: Optional[list[dict[
|
|||||||
f"💎 期权保费合计:{_fmt(plan.get('premium_total'), 4)} USDC",
|
f"💎 期权保费合计:{_fmt(plan.get('premium_total'), 4)} USDC",
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
|
rr = plan.get("oo_profit_rr")
|
||||||
|
if rr not in (None, ""):
|
||||||
|
lines.extend(
|
||||||
|
[
|
||||||
|
f"🎯 盈亏比:{_fmt(rr)}×权利金(达标全平;不达标等到期)",
|
||||||
|
f"💎 期权保费合计:{_fmt(plan.get('premium_total'), 4)} USDC",
|
||||||
|
]
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
lines.extend(
|
lines.extend(
|
||||||
[
|
[
|
||||||
@@ -81,6 +90,8 @@ def build_hedge_end_message(plan: dict[str, Any]) -> str:
|
|||||||
"target_win_leg": "期期已平盈利腿(中间态)",
|
"target_win_leg": "期期已平盈利腿(中间态)",
|
||||||
"target_up_win_leg": "期期上破·已平盈利腿",
|
"target_up_win_leg": "期期上破·已平盈利腿",
|
||||||
"target_down_win_leg": "期期下破·已平盈利腿",
|
"target_down_win_leg": "期期下破·已平盈利腿",
|
||||||
|
"oo_rr_target": "期期盈亏比达标·两腿已平",
|
||||||
|
"oo_rr_closing": "期期盈亏比达标·平仓中",
|
||||||
"oo_rest_closing": "期期全平·清残腿中",
|
"oo_rest_closing": "期期全平·清残腿中",
|
||||||
"oo_rest_closed": "期期全平·两腿已平",
|
"oo_rest_closed": "期期全平·两腿已平",
|
||||||
"oo_expiry_loss": "期期到期无盈利·总亏损",
|
"oo_expiry_loss": "期期到期无盈利·总亏损",
|
||||||
@@ -153,7 +164,18 @@ def notify_plan_end(cfg: dict[str, Any], conn: Any, plan: dict[str, Any]) -> boo
|
|||||||
"target_up_win_leg",
|
"target_up_win_leg",
|
||||||
"target_down_win_leg",
|
"target_down_win_leg",
|
||||||
"oo_rest_closing",
|
"oo_rest_closing",
|
||||||
|
"oo_rr_closing",
|
||||||
) and (plan.get("status") or "") != "closed":
|
) and (plan.get("status") or "") != "closed":
|
||||||
|
if "oo_rr" in str(plan.get("close_reason") or ""):
|
||||||
|
notify_hedge(
|
||||||
|
cfg,
|
||||||
|
build_hedge_alert_message(
|
||||||
|
title="期期盈亏比达标·平仓进行中",
|
||||||
|
plan_id=plan.get("id"),
|
||||||
|
detail=f"盈亏比 {_fmt(plan.get('oo_profit_rr'))}×权利金",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return True
|
||||||
side = "上破" if "up" in str(plan.get("close_reason")) else (
|
side = "上破" if "up" in str(plan.get("close_reason")) else (
|
||||||
"下破" if "down" in str(plan.get("close_reason")) else "目标价"
|
"下破" if "down" in str(plan.get("close_reason")) else "目标价"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1146,6 +1146,17 @@ def validate_start_body(plan_type: str, body: dict[str, Any]) -> Optional[str]:
|
|||||||
b = body.get("leg_b") or {}
|
b = body.get("leg_b") or {}
|
||||||
if not a.get("inst_id") or not b.get("inst_id"):
|
if not a.get("inst_id") or not b.get("inst_id"):
|
||||||
return "请选用两条期权腿"
|
return "请选用两条期权腿"
|
||||||
|
rr_raw = body.get("oo_profit_rr")
|
||||||
|
if rr_raw in (None, ""):
|
||||||
|
rr_raw = body.get("profit_rr")
|
||||||
|
if rr_raw not in (None, ""):
|
||||||
|
try:
|
||||||
|
rr = float(rr_raw)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return "盈亏比无效"
|
||||||
|
if rr <= 0:
|
||||||
|
return "盈亏比须大于 0"
|
||||||
|
else:
|
||||||
up = body.get("target_price_up")
|
up = body.get("target_price_up")
|
||||||
down = body.get("target_price_down")
|
down = body.get("target_price_down")
|
||||||
legacy = body.get("target_price")
|
legacy = body.get("target_price")
|
||||||
@@ -1154,7 +1165,7 @@ def validate_start_body(plan_type: str, body: dict[str, Any]) -> Optional[str]:
|
|||||||
if down in (None, "") and legacy not in (None, ""):
|
if down in (None, "") and legacy not in (None, ""):
|
||||||
down = legacy
|
down = legacy
|
||||||
if up in (None, "") or down in (None, ""):
|
if up in (None, "") or down in (None, ""):
|
||||||
return "请填写上破与下破目标价"
|
return "请填写盈亏比(相对权利金,默认2)"
|
||||||
try:
|
try:
|
||||||
if float(up) <= float(down):
|
if float(up) <= float(down):
|
||||||
return "上破目标价必须大于下破目标价"
|
return "上破目标价必须大于下破目标价"
|
||||||
@@ -1179,11 +1190,6 @@ def validate_start_body(plan_type: str, body: dict[str, Any]) -> Optional[str]:
|
|||||||
return {"opt_type": opt_type, "strike": strike}
|
return {"opt_type": opt_type, "strike": strike}
|
||||||
|
|
||||||
index_px = body.get("index_px")
|
index_px = body.get("index_px")
|
||||||
if index_px in (None, ""):
|
|
||||||
try:
|
|
||||||
index_px = (float(up) + float(down)) / 2.0
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
index_px = None
|
|
||||||
money_err = validate_oo_legs_moneyness(
|
money_err = validate_oo_legs_moneyness(
|
||||||
_leg_for_money(a),
|
_leg_for_money(a),
|
||||||
_leg_for_money(b),
|
_leg_for_money(b),
|
||||||
|
|||||||
@@ -537,27 +537,25 @@ def _persist_oo(cfg: dict[str, Any], result: dict[str, Any], body: dict[str, Any
|
|||||||
premium = (float(a.get("premium") or 0) if a_ok else 0.0) + (
|
premium = (float(a.get("premium") or 0) if a_ok else 0.0) + (
|
||||||
float(b.get("premium") or 0) if b_ok else 0.0
|
float(b.get("premium") or 0) if b_ok else 0.0
|
||||||
)
|
)
|
||||||
|
rr_raw = body.get("oo_profit_rr")
|
||||||
|
if rr_raw in (None, ""):
|
||||||
|
rr_raw = body.get("profit_rr")
|
||||||
|
try:
|
||||||
|
oo_rr = float(rr_raw) if rr_raw not in (None, "") else 2.0
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
oo_rr = 2.0
|
||||||
|
if oo_rr <= 0:
|
||||||
|
oo_rr = 2.0
|
||||||
plan_id = insert_plan(
|
plan_id = insert_plan(
|
||||||
conn,
|
conn,
|
||||||
{
|
{
|
||||||
"plan_type": "options_options",
|
"plan_type": "options_options",
|
||||||
"status": "partial" if is_partial else "active",
|
"status": "partial" if is_partial else "active",
|
||||||
"underlying": str(body.get("underlying") or "ETH").upper(),
|
"underlying": str(body.get("underlying") or "ETH").upper(),
|
||||||
"target_price": float(
|
"target_price": None,
|
||||||
body.get("target_price_up")
|
"target_price_up": None,
|
||||||
or body.get("target_price")
|
"target_price_down": None,
|
||||||
or 0
|
"oo_profit_rr": oo_rr,
|
||||||
),
|
|
||||||
"target_price_up": float(
|
|
||||||
body.get("target_price_up")
|
|
||||||
or body.get("target_price")
|
|
||||||
or 0
|
|
||||||
),
|
|
||||||
"target_price_down": float(
|
|
||||||
body.get("target_price_down")
|
|
||||||
or body.get("target_price")
|
|
||||||
or 0
|
|
||||||
),
|
|
||||||
"sizing_mode_at_open": load_position_sizing_mode(),
|
"sizing_mode_at_open": load_position_sizing_mode(),
|
||||||
"premium_total": premium,
|
"premium_total": premium,
|
||||||
"oo_close_mode": _normalize_oo_close_mode(body.get("oo_close_mode")),
|
"oo_close_mode": _normalize_oo_close_mode(body.get("oo_close_mode")),
|
||||||
@@ -1238,20 +1236,24 @@ def _preview_po(body: dict[str, Any]) -> dict[str, Any]:
|
|||||||
def _preview_oo(body: dict[str, Any]) -> dict[str, Any]:
|
def _preview_oo(body: dict[str, Any]) -> dict[str, Any]:
|
||||||
from lib.hedge_plan.hedge_plan_moneyness_lib import validate_oo_legs_moneyness
|
from lib.hedge_plan.hedge_plan_moneyness_lib import validate_oo_legs_moneyness
|
||||||
|
|
||||||
up = body.get("target_price_up")
|
rr_raw = body.get("oo_profit_rr")
|
||||||
down = body.get("target_price_down")
|
if rr_raw in (None, ""):
|
||||||
legacy = body.get("target_price")
|
rr_raw = body.get("profit_rr")
|
||||||
if up in (None, "") and legacy not in (None, ""):
|
rr = None
|
||||||
up = legacy
|
if rr_raw not in (None, ""):
|
||||||
if down in (None, "") and legacy not in (None, ""):
|
try:
|
||||||
down = legacy
|
rr = float(rr_raw)
|
||||||
if up in (None, "") or down in (None, ""):
|
except (TypeError, ValueError) as e:
|
||||||
raise ValueError("请填写上破与下破目标价")
|
raise ValueError("盈亏比无效") from e
|
||||||
up_f = float(up)
|
if rr <= 0:
|
||||||
down_f = float(down)
|
raise ValueError("盈亏比须大于 0")
|
||||||
if up_f <= down_f:
|
|
||||||
raise ValueError("上破目标价必须大于下破目标价")
|
index_px = body.get("index_px")
|
||||||
index_px = float(body.get("index_px") or ((up_f + down_f) / 2))
|
try:
|
||||||
|
index_px_f = float(index_px) if index_px not in (None, "") else 0.0
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
index_px_f = 0.0
|
||||||
|
|
||||||
leg_a = body.get("leg_a") or {}
|
leg_a = body.get("leg_a") or {}
|
||||||
leg_b = body.get("leg_b") or {}
|
leg_b = body.get("leg_b") or {}
|
||||||
for name, leg in (("leg_a", leg_a), ("leg_b", leg_b)):
|
for name, leg in (("leg_a", leg_a), ("leg_b", leg_b)):
|
||||||
@@ -1265,13 +1267,38 @@ def _preview_oo(body: dict[str, Any]) -> dict[str, Any]:
|
|||||||
)
|
)
|
||||||
if leg.get("premium_paid") is None:
|
if leg.get("premium_paid") is None:
|
||||||
raise ValueError(f"缺少 {name} 权利金")
|
raise ValueError(f"缺少 {name} 权利金")
|
||||||
money_err = validate_oo_legs_moneyness(leg_a, leg_b, index_px=index_px)
|
money_err = validate_oo_legs_moneyness(leg_a, leg_b, index_px=index_px_f or None)
|
||||||
if money_err:
|
if money_err:
|
||||||
raise ValueError(money_err)
|
raise ValueError(money_err)
|
||||||
|
|
||||||
|
if rr is not None:
|
||||||
|
return build_options_options_preview(
|
||||||
|
profit_rr=rr,
|
||||||
|
index_px=index_px_f,
|
||||||
|
leg_a=leg_a,
|
||||||
|
leg_b=leg_b,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 兼容旧上破/下破
|
||||||
|
up = body.get("target_price_up")
|
||||||
|
down = body.get("target_price_down")
|
||||||
|
legacy = body.get("target_price")
|
||||||
|
if up in (None, "") and legacy not in (None, ""):
|
||||||
|
up = legacy
|
||||||
|
if down in (None, "") and legacy not in (None, ""):
|
||||||
|
down = legacy
|
||||||
|
if up in (None, "") or down in (None, ""):
|
||||||
|
raise ValueError("请填写盈亏比(相对权利金,默认2)")
|
||||||
|
up_f = float(up)
|
||||||
|
down_f = float(down)
|
||||||
|
if up_f <= down_f:
|
||||||
|
raise ValueError("上破目标价必须大于下破目标价")
|
||||||
|
if index_px_f <= 0:
|
||||||
|
index_px_f = (up_f + down_f) / 2
|
||||||
return build_options_options_preview(
|
return build_options_options_preview(
|
||||||
target_price_up=up_f,
|
target_price_up=up_f,
|
||||||
target_price_down=down_f,
|
target_price_down=down_f,
|
||||||
index_px=index_px,
|
index_px=index_px_f,
|
||||||
leg_a=leg_a,
|
leg_a=leg_a,
|
||||||
leg_b=leg_b,
|
leg_b=leg_b,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -213,7 +213,7 @@
|
|||||||
<div class="tip-collapse-body rule-tip">
|
<div class="tip-collapse-body rule-tip">
|
||||||
<p><strong>账户</strong>:两腿都在<strong>期权账户</strong>。可用预算 = min(交易 USDC × 对冲缓冲 <strong id="hp-oo-buf-ratio">{{ '%.2f'|format(hedge_plan_budget_buffer|default(0.95)|float) }}</strong>, 单笔预算);可在 env「对冲预算缓冲比例」改。</p>
|
<p><strong>账户</strong>:两腿都在<strong>期权账户</strong>。可用预算 = min(交易 USDC × 对冲缓冲 <strong id="hp-oo-buf-ratio">{{ '%.2f'|format(hedge_plan_budget_buffer|default(0.95)|float) }}</strong>, 单笔预算);可在 env「对冲预算缓冲比例」改。</p>
|
||||||
<p><strong>下单</strong>:选 Call + Put 后「计算」再「启动」。启动会再拉卖一并按最新价重算张数,IOC 完全成交才算成功;资金不足可在右侧划转。</p>
|
<p><strong>下单</strong>:选 Call + Put 后「计算」再「启动」。启动会再拉卖一并按最新价重算张数,IOC 完全成交才算成功;资金不足可在右侧划转。</p>
|
||||||
<p><strong>板块</strong>:左填上破/下破与张数模式(同张数/做多/做空);右 T 型选腿。<strong>两腿仅允许平值或虚值</strong>(禁实值)。「全平」= 盈利腿平后清另一腿;「到期平」= 另一腿持有至到期。</p>
|
<p><strong>板块</strong>:左填<strong>盈亏比</strong>(相对权利金,默认 2=盈利 2 倍权利金)与张数模式(同张数/做多/做空);右 T 型选腿。<strong>两腿仅允许平值或虚值</strong>(禁实值)。中途浮盈达盈亏比→两腿全平;不达标→等到期。「全平/到期平」仅兼容旧上破下破计划残腿处理。</p>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
<div class="form-row hp-uly-row">
|
<div class="form-row hp-uly-row">
|
||||||
@@ -221,8 +221,7 @@
|
|||||||
<button type="button" class="btn-secondary hp-uly-btn-oo" data-uly="BTC">BTC</button>
|
<button type="button" class="btn-secondary hp-uly-btn-oo" data-uly="BTC">BTC</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-row hp-target-row hp-oo-target-row">
|
<div class="form-row hp-target-row hp-oo-target-row">
|
||||||
<label>上破目标 <input type="number" step="any" id="hp-target-up" placeholder="向上突破" autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other" /></label>
|
<label title="目标盈利 = 盈亏比 × 两腿权利金合计;例 2=赚满 2 倍权利金后全平">盈亏比 <input type="number" step="0.1" min="0.1" id="hp-oo-rr" value="2" placeholder="默认2" autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other" /></label>
|
||||||
<label>下破目标 <input type="number" step="any" id="hp-target-down" placeholder="向下突破" autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other" /></label>
|
|
||||||
<span id="hp-oo-index" class="hp-oo-index" aria-live="polite">指数 —</span>
|
<span id="hp-oo-index" class="hp-oo-index" aria-live="polite">指数 —</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="hp-oo-controls">
|
<div class="hp-oo-controls">
|
||||||
@@ -406,4 +405,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script src="/static/hedge_plan.js?v=46"></script>
|
<script src="/static/hedge_plan.js?v=47"></script>
|
||||||
|
|||||||
@@ -123,21 +123,31 @@ def _resolve_options_source(conn, inst_id: str) -> tuple[str, str, int | None]:
|
|||||||
|
|
||||||
def _format_options_target(p: dict[str, Any]) -> str:
|
def _format_options_target(p: dict[str, Any]) -> str:
|
||||||
hedge = p.get("hedge_plan_target") if isinstance(p.get("hedge_plan_target"), dict) else None
|
hedge = p.get("hedge_plan_target") if isinstance(p.get("hedge_plan_target"), dict) else None
|
||||||
opt_type = str(p.get("opt_type") or p.get("optType") or "").upper()
|
|
||||||
if hedge:
|
if hedge:
|
||||||
ot = str(hedge.get("opt_type") or opt_type).upper()
|
rr = _safe_float(hedge.get("oo_profit_rr") or hedge.get("profit_rr"))
|
||||||
|
pid = hedge.get("plan_id")
|
||||||
|
if rr is not None and rr > 0:
|
||||||
|
return f"对冲#{pid} 盈亏比×{rr:g}" if pid is not None else f"盈亏比×{rr:g}"
|
||||||
|
ot = str(hedge.get("opt_type") or p.get("opt_type") or p.get("optType") or "").upper()
|
||||||
side = "Put ≤" if ot == "P" else "Call ≥"
|
side = "Put ≤" if ot == "P" else "Call ≥"
|
||||||
tgt = _safe_float(hedge.get("target_index"))
|
tgt = _safe_float(hedge.get("target_index"))
|
||||||
pid = hedge.get("plan_id")
|
|
||||||
if tgt is not None:
|
if tgt is not None:
|
||||||
return f"对冲#{pid} {side} {tgt:g}" if pid is not None else f"{side} {tgt:g}"
|
return f"对冲#{pid} {side} {tgt:g}" if pid is not None else f"{side} {tgt:g}"
|
||||||
|
mon = p.get("target_monitor") if isinstance(p.get("target_monitor"), dict) else None
|
||||||
|
rr = _safe_float(p.get("profit_rr"))
|
||||||
|
if rr is None and mon:
|
||||||
|
rr = _safe_float(mon.get("profit_rr"))
|
||||||
|
if rr is not None and rr > 0:
|
||||||
|
return f"盈亏比×{rr:g}"
|
||||||
tgt = _safe_float(p.get("target_index"))
|
tgt = _safe_float(p.get("target_index"))
|
||||||
|
if tgt is None and mon:
|
||||||
|
tgt = _safe_float(mon.get("target_index"))
|
||||||
if tgt is not None and tgt > 0:
|
if tgt is not None and tgt > 0:
|
||||||
|
opt_type = str(p.get("opt_type") or p.get("optType") or "").upper()
|
||||||
side = "Put ≤" if opt_type == "P" else "Call ≥"
|
side = "Put ≤" if opt_type == "P" else "Call ≥"
|
||||||
return f"{side} {tgt:g}"
|
return f"{side} {tgt:g}"
|
||||||
return "—"
|
return "—"
|
||||||
|
|
||||||
|
|
||||||
def _format_options_item(p: dict[str, Any], *, conn=None) -> dict[str, Any]:
|
def _format_options_item(p: dict[str, Any], *, conn=None) -> dict[str, Any]:
|
||||||
inst = str(p.get("inst_id") or p.get("instId") or "-").strip() or "-"
|
inst = str(p.get("inst_id") or p.get("instId") or "-").strip() or "-"
|
||||||
opt_type = str(p.get("opt_type") or p.get("optType") or "").upper()
|
opt_type = str(p.get("opt_type") or p.get("optType") or "").upper()
|
||||||
|
|||||||
@@ -72,11 +72,14 @@ def fetch_light_option_positions_for_dashboard(cfg: dict[str, Any]) -> list[dict
|
|||||||
mon = tgt_map.get(str(row.get("inst_id") or ""))
|
mon = tgt_map.get(str(row.get("inst_id") or ""))
|
||||||
if mon:
|
if mon:
|
||||||
row["target_index"] = mon.get("target_index")
|
row["target_index"] = mon.get("target_index")
|
||||||
|
row["profit_rr"] = mon.get("profit_rr")
|
||||||
row["target_monitor_id"] = mon.get("id")
|
row["target_monitor_id"] = mon.get("id")
|
||||||
row["target_monitor"] = mon
|
row["target_monitor"] = mon
|
||||||
hedge_target = hedge_target_map.get(str(row.get("inst_id") or ""))
|
hedge_target = hedge_target_map.get(str(row.get("inst_id") or ""))
|
||||||
if hedge_target:
|
if hedge_target:
|
||||||
row["hedge_plan_target"] = hedge_target
|
row["hedge_plan_target"] = hedge_target
|
||||||
|
if hedge_target.get("oo_profit_rr") is not None and row.get("profit_rr") is None:
|
||||||
|
row["profit_rr"] = hedge_target.get("oo_profit_rr")
|
||||||
if not mon:
|
if not mon:
|
||||||
row["target_index"] = hedge_target.get("target_index")
|
row["target_index"] = hedge_target.get("target_index")
|
||||||
rows.append(row)
|
rows.append(row)
|
||||||
|
|||||||
@@ -38,14 +38,15 @@ def build_options_hub_snapshot(cfg: dict[str, Any]) -> dict[str, Any]:
|
|||||||
mon = tgt_map.get(str(p.get("inst_id") or ""))
|
mon = tgt_map.get(str(p.get("inst_id") or ""))
|
||||||
if mon:
|
if mon:
|
||||||
p["target_index"] = mon.get("target_index")
|
p["target_index"] = mon.get("target_index")
|
||||||
|
p["profit_rr"] = mon.get("profit_rr")
|
||||||
p["target_monitor_id"] = mon.get("id")
|
p["target_monitor_id"] = mon.get("id")
|
||||||
p["target_monitor"] = mon
|
p["target_monitor"] = mon
|
||||||
hedge_target = hedge_target_map.get(str(p.get("inst_id") or ""))
|
hedge_target = hedge_target_map.get(str(p.get("inst_id") or ""))
|
||||||
if hedge_target:
|
if hedge_target:
|
||||||
p["hedge_plan_target"] = hedge_target
|
p["hedge_plan_target"] = hedge_target
|
||||||
if not mon:
|
if not mon:
|
||||||
# 中控卡片共用 target_index 只读展示;实际平仓仍由对冲计划监控处理。
|
|
||||||
p["target_index"] = hedge_target.get("target_index")
|
p["target_index"] = hedge_target.get("target_index")
|
||||||
|
p["profit_rr"] = hedge_target.get("oo_profit_rr")
|
||||||
try:
|
try:
|
||||||
from lib.instance.instance_dashboard_lib import (
|
from lib.instance.instance_dashboard_lib import (
|
||||||
_format_options_target,
|
_format_options_target,
|
||||||
|
|||||||
@@ -455,6 +455,7 @@ def options_monitor_loop(
|
|||||||
conn,
|
conn,
|
||||||
positions,
|
positions,
|
||||||
close_fn=target_close_fn,
|
close_fn=target_close_fn,
|
||||||
|
bid_fn=ticker_bid_fn,
|
||||||
send_wechat=send_wechat,
|
send_wechat=send_wechat,
|
||||||
account_label=account_label,
|
account_label=account_label,
|
||||||
cfg={"send_wechat": send_wechat, "account_label": account_label},
|
cfg={"send_wechat": send_wechat, "account_label": account_label},
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ def build_options_open_message(
|
|||||||
premium_paid: Any = None,
|
premium_paid: Any = None,
|
||||||
open_quote: Any = None,
|
open_quote: Any = None,
|
||||||
target_index: Any = None,
|
target_index: Any = None,
|
||||||
|
profit_rr: Any = None,
|
||||||
signal_note: str = "",
|
signal_note: str = "",
|
||||||
trade_id: Any = None,
|
trade_id: Any = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
@@ -73,7 +74,12 @@ def build_options_open_message(
|
|||||||
f"权利金:{_fmt(premium_paid)} USDC",
|
f"权利金:{_fmt(premium_paid)} USDC",
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
if target_index is not None and str(target_index).strip() != "":
|
if profit_rr is not None and str(profit_rr).strip() != "":
|
||||||
|
try:
|
||||||
|
lines.append(f"盈亏比:×{float(profit_rr):g}(达标全平;不达标等到期)")
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
lines.append(f"盈亏比:{profit_rr}")
|
||||||
|
elif target_index is not None and str(target_index).strip() != "":
|
||||||
try:
|
try:
|
||||||
lines.append(f"目标指数:{float(target_index):g}")
|
lines.append(f"目标指数:{float(target_index):g}")
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
@@ -96,6 +102,7 @@ def build_options_close_message(
|
|||||||
realized_pnl: Any = None,
|
realized_pnl: Any = None,
|
||||||
close_quote: Any = None,
|
close_quote: Any = None,
|
||||||
target_index: Any = None,
|
target_index: Any = None,
|
||||||
|
profit_rr: Any = None,
|
||||||
trigger_idx: Any = None,
|
trigger_idx: Any = None,
|
||||||
trade_id: Any = None,
|
trade_id: Any = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
@@ -116,7 +123,12 @@ def build_options_close_message(
|
|||||||
f"实现盈亏:{_fmt(realized_pnl, 4)} USDC",
|
f"实现盈亏:{_fmt(realized_pnl, 4)} USDC",
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
if target_index is not None and str(target_index).strip() != "":
|
if profit_rr is not None and str(profit_rr).strip() != "":
|
||||||
|
try:
|
||||||
|
lines.append(f"盈亏比:×{float(profit_rr):g}")
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
lines.append(f"盈亏比:{profit_rr}")
|
||||||
|
elif target_index is not None and str(target_index).strip() != "":
|
||||||
try:
|
try:
|
||||||
lines.append(f"目标指数:{float(target_index):g}")
|
lines.append(f"目标指数:{float(target_index):g}")
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
@@ -141,6 +153,7 @@ def notify_options_open(
|
|||||||
premium_paid: Any = None,
|
premium_paid: Any = None,
|
||||||
open_quote: Any = None,
|
open_quote: Any = None,
|
||||||
target_index: Any = None,
|
target_index: Any = None,
|
||||||
|
profit_rr: Any = None,
|
||||||
signal_note: str = "",
|
signal_note: str = "",
|
||||||
) -> bool:
|
) -> bool:
|
||||||
ensure_options_notify_columns(conn) if conn is not None else None
|
ensure_options_notify_columns(conn) if conn is not None else None
|
||||||
@@ -160,6 +173,7 @@ def notify_options_open(
|
|||||||
premium_paid=premium_paid,
|
premium_paid=premium_paid,
|
||||||
open_quote=open_quote,
|
open_quote=open_quote,
|
||||||
target_index=target_index,
|
target_index=target_index,
|
||||||
|
profit_rr=profit_rr,
|
||||||
signal_note=signal_note,
|
signal_note=signal_note,
|
||||||
trade_id=trade_id,
|
trade_id=trade_id,
|
||||||
)
|
)
|
||||||
@@ -196,6 +210,7 @@ def notify_options_close(
|
|||||||
realized_pnl: Any = None,
|
realized_pnl: Any = None,
|
||||||
close_quote: Any = None,
|
close_quote: Any = None,
|
||||||
target_index: Any = None,
|
target_index: Any = None,
|
||||||
|
profit_rr: Any = None,
|
||||||
trigger_idx: Any = None,
|
trigger_idx: Any = None,
|
||||||
force: bool = False,
|
force: bool = False,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
@@ -257,6 +272,7 @@ def notify_options_close(
|
|||||||
realized_pnl=total_pnl,
|
realized_pnl=total_pnl,
|
||||||
close_quote=close_quote if close_quote is not None else head.get("close_quote"),
|
close_quote=close_quote if close_quote is not None else head.get("close_quote"),
|
||||||
target_index=target_index,
|
target_index=target_index,
|
||||||
|
profit_rr=profit_rr,
|
||||||
trigger_idx=trigger_idx,
|
trigger_idx=trigger_idx,
|
||||||
trade_id=head.get("id") if len(rows) == 1 else None,
|
trade_id=head.get("id") if len(rows) == 1 else None,
|
||||||
)
|
)
|
||||||
@@ -286,6 +302,7 @@ def notify_options_close(
|
|||||||
realized_pnl=realized_pnl,
|
realized_pnl=realized_pnl,
|
||||||
close_quote=close_quote,
|
close_quote=close_quote,
|
||||||
target_index=target_index,
|
target_index=target_index,
|
||||||
|
profit_rr=profit_rr,
|
||||||
trigger_idx=trigger_idx,
|
trigger_idx=trigger_idx,
|
||||||
trade_id=trade_id,
|
trade_id=trade_id,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,509 @@
|
|||||||
|
"""期权链实时报价:OKX 公共 WS tickers → 内存缓存 → SSE 推前端."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import queue
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from collections.abc import Iterator
|
||||||
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
from lib.exchange.okx_public_ws_lib import OkxPublicWs
|
||||||
|
from lib.options.options_pricing_lib import (
|
||||||
|
expiry_breakeven_from_ask,
|
||||||
|
idx_distance_to_be,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
OPTIONS_QUOTE_SSE_HEARTBEAT_SEC = float(os.getenv("OKX_OPTIONS_QUOTE_SSE_HEARTBEAT_SEC", "20"))
|
||||||
|
OPTIONS_QUOTE_FLUSH_MS = float(os.getenv("OKX_OPTIONS_QUOTE_FLUSH_MS", "120"))
|
||||||
|
# OKX 单连接约 240 频道;当前到期日合约 + 指数通常够用
|
||||||
|
OPTIONS_QUOTE_MAX_INST = int(os.getenv("OKX_OPTIONS_QUOTE_MAX_INST", "220"))
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_float(v: Any) -> float | None:
|
||||||
|
try:
|
||||||
|
if v is None or v == "":
|
||||||
|
return None
|
||||||
|
return float(v)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class OptionsQuoteLive:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._lock = threading.RLock()
|
||||||
|
self._watchers: dict[str, dict[str, Any]] = {}
|
||||||
|
self._meta: dict[str, dict[str, Any]] = {}
|
||||||
|
self._tickers: dict[str, dict[str, Any]] = {}
|
||||||
|
self._index_by_uly: dict[str, float] = {}
|
||||||
|
self._index_insts: set[str] = set()
|
||||||
|
self._dirty_inst: set[str] = set()
|
||||||
|
self._dirty_index: set[str] = set()
|
||||||
|
self._version = 0
|
||||||
|
self._subscribers: list[queue.Queue[str | None]] = []
|
||||||
|
self._stop = threading.Event()
|
||||||
|
self._flush_thread: threading.Thread | None = None
|
||||||
|
ws_url = (os.getenv("OKX_PUBLIC_WS_URL") or "").strip() or None
|
||||||
|
self._ws = OkxPublicWs(
|
||||||
|
on_data=self._on_ws_data,
|
||||||
|
name="okx-options-quote-ws",
|
||||||
|
**({"url": ws_url} if ws_url else {}),
|
||||||
|
)
|
||||||
|
self._started = False
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
if self._started:
|
||||||
|
return
|
||||||
|
self._started = True
|
||||||
|
self._stop.clear()
|
||||||
|
self._ws.start()
|
||||||
|
self._flush_thread = threading.Thread(
|
||||||
|
target=self._flush_loop, name="options-quote-flush", daemon=True
|
||||||
|
)
|
||||||
|
self._flush_thread.start()
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
self._stop.set()
|
||||||
|
self._ws.stop()
|
||||||
|
self._broadcast(close=True)
|
||||||
|
self._started = False
|
||||||
|
|
||||||
|
def status(self) -> dict[str, Any]:
|
||||||
|
with self._lock:
|
||||||
|
uly = ""
|
||||||
|
exp = ""
|
||||||
|
index_inst = ""
|
||||||
|
if self._watchers:
|
||||||
|
last = next(reversed(list(self._watchers.values())))
|
||||||
|
uly = str(last.get("underlying") or "")
|
||||||
|
exp = str(last.get("exp_time") or "")
|
||||||
|
index_inst = str(last.get("index_inst") or "")
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"started": self._started,
|
||||||
|
"ws_ok": self._ws.connected,
|
||||||
|
"underlying": uly,
|
||||||
|
"index_inst": index_inst,
|
||||||
|
"index_px": self._index_by_uly.get(uly),
|
||||||
|
"watch_exp": exp,
|
||||||
|
"watch_count": len(self._meta),
|
||||||
|
"watcher_count": len(self._watchers),
|
||||||
|
"version": self._version,
|
||||||
|
"last_msg_at": self._ws.last_msg_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
def watch(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
underlying: str,
|
||||||
|
exp_time: str | int | None,
|
||||||
|
contracts: list[dict[str, Any]],
|
||||||
|
index_inst_id: str | None = None,
|
||||||
|
watcher_id: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
u = (underlying or "ETH").upper()
|
||||||
|
index_id = (index_inst_id or f"{u}-USD").strip()
|
||||||
|
wid = (watcher_id or "default").strip() or "default"
|
||||||
|
meta: dict[str, dict[str, Any]] = {}
|
||||||
|
for c in contracts or []:
|
||||||
|
if not isinstance(c, dict):
|
||||||
|
continue
|
||||||
|
inst_id = str(c.get("inst_id") or c.get("instId") or "").strip()
|
||||||
|
if not inst_id:
|
||||||
|
continue
|
||||||
|
meta[inst_id] = {
|
||||||
|
"inst_id": inst_id,
|
||||||
|
"opt_type": str(c.get("opt_type") or c.get("optType") or "").upper(),
|
||||||
|
"strike": _safe_float(c.get("strike")),
|
||||||
|
"tick_sz": c.get("tick_sz") or c.get("tickSz"),
|
||||||
|
"underlying": u,
|
||||||
|
}
|
||||||
|
if len(meta) >= max(1, OPTIONS_QUOTE_MAX_INST):
|
||||||
|
break
|
||||||
|
with self._lock:
|
||||||
|
self._watchers[wid] = {
|
||||||
|
"underlying": u,
|
||||||
|
"exp_time": str(exp_time or ""),
|
||||||
|
"index_inst": index_id,
|
||||||
|
"meta": meta,
|
||||||
|
}
|
||||||
|
self._rebuild_subscriptions_locked()
|
||||||
|
if not self._started:
|
||||||
|
self.start()
|
||||||
|
return self.status()
|
||||||
|
|
||||||
|
def _rebuild_subscriptions_locked(self) -> None:
|
||||||
|
merged: dict[str, dict[str, Any]] = {}
|
||||||
|
index_insts: set[str] = set()
|
||||||
|
for w in self._watchers.values():
|
||||||
|
index_insts.add(str(w.get("index_inst") or ""))
|
||||||
|
for inst_id, m in (w.get("meta") or {}).items():
|
||||||
|
if inst_id not in merged:
|
||||||
|
merged[inst_id] = dict(m)
|
||||||
|
if len(merged) >= max(1, OPTIONS_QUOTE_MAX_INST):
|
||||||
|
break
|
||||||
|
if len(merged) >= max(1, OPTIONS_QUOTE_MAX_INST):
|
||||||
|
break
|
||||||
|
index_insts = {x for x in index_insts if x}
|
||||||
|
self._meta = merged
|
||||||
|
self._index_insts = index_insts
|
||||||
|
keep = set(merged.keys())
|
||||||
|
for k in list(self._tickers.keys()):
|
||||||
|
if k not in keep:
|
||||||
|
self._tickers.pop(k, None)
|
||||||
|
args = [{"channel": "tickers", "instId": iid} for iid in merged]
|
||||||
|
for iid in sorted(index_insts):
|
||||||
|
args.append({"channel": "index-tickers", "instId": iid})
|
||||||
|
# 订阅可能分片 sleep,不能堵 Flask 请求线程
|
||||||
|
threading.Thread(
|
||||||
|
target=self._ws.set_subscriptions,
|
||||||
|
args=(args,),
|
||||||
|
name="okx-options-quote-sub",
|
||||||
|
daemon=True,
|
||||||
|
).start()
|
||||||
|
|
||||||
|
def as_okx_tickers(self, underlying: str | None = None) -> dict[str, dict[str, Any]]:
|
||||||
|
"""转成 build_option_chain 可用的 OKX ticker 字段."""
|
||||||
|
u = (underlying or "").upper()
|
||||||
|
out: dict[str, dict[str, Any]] = {}
|
||||||
|
with self._lock:
|
||||||
|
for inst_id, q in self._tickers.items():
|
||||||
|
if u and str(q.get("underlying") or "").upper() not in ("", u):
|
||||||
|
continue
|
||||||
|
row: dict[str, Any] = {"instId": inst_id}
|
||||||
|
if q.get("ask") is not None and not q.get("ask_estimated"):
|
||||||
|
row["askPx"] = q.get("ask")
|
||||||
|
row["askSz"] = q.get("ask_sz")
|
||||||
|
if q.get("bid") is not None:
|
||||||
|
row["bidPx"] = q.get("bid")
|
||||||
|
row["bidSz"] = q.get("bid_sz")
|
||||||
|
if q.get("mark_px") is not None:
|
||||||
|
row["markPx"] = q.get("mark_px")
|
||||||
|
out[inst_id] = row
|
||||||
|
return out
|
||||||
|
|
||||||
|
def index_px_for(self, underlying: str) -> float | None:
|
||||||
|
u = (underlying or "").upper()
|
||||||
|
with self._lock:
|
||||||
|
return self._index_by_uly.get(u)
|
||||||
|
|
||||||
|
def is_ws_fresh(self, *, max_age_sec: float = 15.0) -> bool:
|
||||||
|
if not self._ws.connected:
|
||||||
|
return False
|
||||||
|
last = float(self._ws.last_msg_at or 0)
|
||||||
|
return last > 0 and (time.time() - last) <= max_age_sec
|
||||||
|
|
||||||
|
def schedule_seed_from_chain(
|
||||||
|
self,
|
||||||
|
chain: dict[str, Any],
|
||||||
|
*,
|
||||||
|
exp_time: str | int | None = None,
|
||||||
|
watcher_id: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
threading.Thread(
|
||||||
|
target=self.seed_from_chain,
|
||||||
|
kwargs={"chain": chain, "exp_time": exp_time, "watcher_id": watcher_id},
|
||||||
|
name="options-quote-seed",
|
||||||
|
daemon=True,
|
||||||
|
).start()
|
||||||
|
|
||||||
|
def seed_from_chain(
|
||||||
|
self,
|
||||||
|
chain: dict[str, Any],
|
||||||
|
*,
|
||||||
|
exp_time: str | int | None = None,
|
||||||
|
watcher_id: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""REST 拉链后预填报价,并默认监视指定/最近到期."""
|
||||||
|
if not isinstance(chain, dict):
|
||||||
|
return
|
||||||
|
u = str(chain.get("underlying") or "ETH").upper()
|
||||||
|
index_px = _safe_float(chain.get("index_px"))
|
||||||
|
expiries = chain.get("expiries") or []
|
||||||
|
target = None
|
||||||
|
if exp_time is not None and str(exp_time):
|
||||||
|
for e in expiries:
|
||||||
|
if str(e.get("exp_time")) == str(exp_time):
|
||||||
|
target = e
|
||||||
|
break
|
||||||
|
if target is None and expiries:
|
||||||
|
target = expiries[0]
|
||||||
|
contracts = list((target or {}).get("contracts") or [])
|
||||||
|
if index_px is not None:
|
||||||
|
with self._lock:
|
||||||
|
self._index_by_uly[u] = index_px
|
||||||
|
self._dirty_index.add(u)
|
||||||
|
for c in contracts:
|
||||||
|
inst_id = str(c.get("inst_id") or "").strip()
|
||||||
|
if not inst_id:
|
||||||
|
continue
|
||||||
|
patch = {
|
||||||
|
"inst_id": inst_id,
|
||||||
|
"ask": c.get("ask"),
|
||||||
|
"bid": c.get("bid"),
|
||||||
|
"ask_sz": c.get("ask_sz"),
|
||||||
|
"bid_sz": c.get("bid_sz"),
|
||||||
|
"mark_px": c.get("mark_px"),
|
||||||
|
"ask_estimated": bool(c.get("ask_estimated")),
|
||||||
|
"expiry_be_px": c.get("expiry_be_px"),
|
||||||
|
"dist_expiry_be": c.get("dist_expiry_be"),
|
||||||
|
"underlying": u,
|
||||||
|
}
|
||||||
|
with self._lock:
|
||||||
|
self._tickers[inst_id] = patch
|
||||||
|
self._dirty_inst.add(inst_id)
|
||||||
|
self.watch(
|
||||||
|
underlying=u,
|
||||||
|
exp_time=(target or {}).get("exp_time"),
|
||||||
|
contracts=contracts,
|
||||||
|
index_inst_id=f"{u}-USD",
|
||||||
|
watcher_id=watcher_id or f"seed:{u}",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _on_ws_data(self, payload: dict[str, Any]) -> None:
|
||||||
|
arg = payload.get("arg") or {}
|
||||||
|
channel = str(arg.get("channel") or "")
|
||||||
|
rows = payload.get("data") or []
|
||||||
|
if not isinstance(rows, list) or not rows:
|
||||||
|
return
|
||||||
|
if channel == "index-tickers":
|
||||||
|
row = rows[0] if isinstance(rows[0], dict) else {}
|
||||||
|
px = _safe_float(row.get("idxPx"))
|
||||||
|
inst = str(row.get("instId") or arg.get("instId") or "")
|
||||||
|
uly = inst.split("-")[0].upper() if inst else ""
|
||||||
|
if px is None or not uly:
|
||||||
|
return
|
||||||
|
with self._lock:
|
||||||
|
if self._index_by_uly.get(uly) == px:
|
||||||
|
return
|
||||||
|
self._index_by_uly[uly] = px
|
||||||
|
self._dirty_index.add(uly)
|
||||||
|
return
|
||||||
|
if channel != "tickers":
|
||||||
|
return
|
||||||
|
for row in rows:
|
||||||
|
if not isinstance(row, dict):
|
||||||
|
continue
|
||||||
|
inst_id = str(row.get("instId") or arg.get("instId") or "").strip()
|
||||||
|
if not inst_id:
|
||||||
|
continue
|
||||||
|
patch = self._ticker_to_patch(inst_id, row)
|
||||||
|
with self._lock:
|
||||||
|
prev = self._tickers.get(inst_id) or {}
|
||||||
|
if (
|
||||||
|
prev.get("ask") == patch.get("ask")
|
||||||
|
and prev.get("bid") == patch.get("bid")
|
||||||
|
and prev.get("ask_sz") == patch.get("ask_sz")
|
||||||
|
and prev.get("bid_sz") == patch.get("bid_sz")
|
||||||
|
and prev.get("mark_px") == patch.get("mark_px")
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
self._tickers[inst_id] = patch
|
||||||
|
self._dirty_inst.add(inst_id)
|
||||||
|
|
||||||
|
def _ticker_to_patch(self, inst_id: str, row: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
ask = _safe_float(row.get("askPx"))
|
||||||
|
bid = _safe_float(row.get("bidPx"))
|
||||||
|
ask_sz = _safe_float(row.get("askSz"))
|
||||||
|
bid_sz = _safe_float(row.get("bidSz"))
|
||||||
|
mark = _safe_float(row.get("markPx"))
|
||||||
|
ask_estimated = False
|
||||||
|
with self._lock:
|
||||||
|
meta = dict(self._meta.get(inst_id) or {})
|
||||||
|
uly = str(meta.get("underlying") or inst_id.split("-")[0] or "").upper()
|
||||||
|
index_px = self._index_by_uly.get(uly)
|
||||||
|
if ask is None and mark is not None and mark > 0:
|
||||||
|
ask = mark
|
||||||
|
ask_estimated = True
|
||||||
|
ask_sz = None
|
||||||
|
if bid is None and mark is not None and mark > 0:
|
||||||
|
bid = mark
|
||||||
|
be = expiry_breakeven_from_ask(
|
||||||
|
opt_type=str(meta.get("opt_type") or ""),
|
||||||
|
strike=meta.get("strike"),
|
||||||
|
ask_px=None if ask_estimated else ask,
|
||||||
|
mark_px=mark,
|
||||||
|
)
|
||||||
|
dist = idx_distance_to_be(index_px, be)
|
||||||
|
return {
|
||||||
|
"inst_id": inst_id,
|
||||||
|
"underlying": uly,
|
||||||
|
"ask": ask,
|
||||||
|
"bid": bid,
|
||||||
|
"ask_sz": ask_sz,
|
||||||
|
"bid_sz": bid_sz,
|
||||||
|
"mark_px": mark,
|
||||||
|
"ask_estimated": ask_estimated,
|
||||||
|
"expiry_be_px": be,
|
||||||
|
"dist_expiry_be": dist,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _flush_loop(self) -> None:
|
||||||
|
interval = max(0.05, OPTIONS_QUOTE_FLUSH_MS / 1000.0)
|
||||||
|
while not self._stop.is_set():
|
||||||
|
if self._stop.wait(interval):
|
||||||
|
break
|
||||||
|
event = self._build_flush_event()
|
||||||
|
if event is None:
|
||||||
|
continue
|
||||||
|
self._broadcast(event)
|
||||||
|
|
||||||
|
def _build_flush_event(self) -> str | None:
|
||||||
|
with self._lock:
|
||||||
|
if not self._dirty_inst and not self._dirty_index:
|
||||||
|
return None
|
||||||
|
dirty_uly = set(self._dirty_index)
|
||||||
|
self._dirty_index.clear()
|
||||||
|
quotes: list[dict[str, Any]] = []
|
||||||
|
for inst_id in list(self._dirty_inst):
|
||||||
|
q = self._tickers.get(inst_id)
|
||||||
|
if q:
|
||||||
|
quotes.append(dict(q))
|
||||||
|
self._dirty_inst.clear()
|
||||||
|
for uly in dirty_uly:
|
||||||
|
index_px = self._index_by_uly.get(uly)
|
||||||
|
if index_px is None:
|
||||||
|
continue
|
||||||
|
for inst_id, q in list(self._tickers.items()):
|
||||||
|
if str(q.get("underlying") or "").upper() != uly:
|
||||||
|
continue
|
||||||
|
be = q.get("expiry_be_px")
|
||||||
|
dist = idx_distance_to_be(index_px, be if be is not None else None)
|
||||||
|
if q.get("dist_expiry_be") != dist:
|
||||||
|
q2 = dict(q)
|
||||||
|
q2["dist_expiry_be"] = dist
|
||||||
|
self._tickers[inst_id] = q2
|
||||||
|
quotes.append(q2)
|
||||||
|
self._version += 1
|
||||||
|
# 多标的时 index_px 取「最近一次 watch」的标的,前端仍以 payload.underlying 过滤
|
||||||
|
uly = ""
|
||||||
|
exp = ""
|
||||||
|
if self._watchers:
|
||||||
|
last = next(reversed(list(self._watchers.values())))
|
||||||
|
uly = str(last.get("underlying") or "")
|
||||||
|
exp = str(last.get("exp_time") or "")
|
||||||
|
# 若本批只有单一 underlying 的 quotes/index,优先用它
|
||||||
|
quote_ulys = {str(q.get("underlying") or "").upper() for q in quotes if q.get("underlying")}
|
||||||
|
if len(dirty_uly) == 1:
|
||||||
|
uly = next(iter(dirty_uly))
|
||||||
|
elif len(quote_ulys) == 1:
|
||||||
|
uly = next(iter(quote_ulys))
|
||||||
|
payload = {
|
||||||
|
"ok": True,
|
||||||
|
"live": True,
|
||||||
|
"ws_ok": self._ws.connected,
|
||||||
|
"version": self._version,
|
||||||
|
"underlying": uly,
|
||||||
|
"watch_exp": exp,
|
||||||
|
"index_px": self._index_by_uly.get(uly),
|
||||||
|
"indexes": dict(self._index_by_uly),
|
||||||
|
"quotes": quotes,
|
||||||
|
"ts": int(time.time() * 1000),
|
||||||
|
}
|
||||||
|
return json.dumps(payload, ensure_ascii=False)
|
||||||
|
|
||||||
|
def _broadcast(self, event: str | None = None, *, close: bool = False) -> None:
|
||||||
|
with self._lock:
|
||||||
|
subs = list(self._subscribers)
|
||||||
|
dead: list[queue.Queue[str | None]] = []
|
||||||
|
for q in subs:
|
||||||
|
try:
|
||||||
|
q.put_nowait(None if close else event)
|
||||||
|
except Exception:
|
||||||
|
dead.append(q)
|
||||||
|
if dead:
|
||||||
|
with self._lock:
|
||||||
|
for q in dead:
|
||||||
|
if q in self._subscribers:
|
||||||
|
self._subscribers.remove(q)
|
||||||
|
|
||||||
|
def _subscribe(self) -> queue.Queue[str | None]:
|
||||||
|
q: queue.Queue[str | None] = queue.Queue(maxsize=64)
|
||||||
|
with self._lock:
|
||||||
|
self._subscribers.append(q)
|
||||||
|
return q
|
||||||
|
|
||||||
|
def _unsubscribe(self, q: queue.Queue[str | None]) -> None:
|
||||||
|
with self._lock:
|
||||||
|
if q in self._subscribers:
|
||||||
|
self._subscribers.remove(q)
|
||||||
|
|
||||||
|
def iter_sse(self) -> Iterator[str]:
|
||||||
|
q = self._subscribe()
|
||||||
|
try:
|
||||||
|
yield self._format_event(
|
||||||
|
{
|
||||||
|
"ok": True,
|
||||||
|
"reason": "connect",
|
||||||
|
**self.status(),
|
||||||
|
"quotes": [],
|
||||||
|
"ts": int(time.time() * 1000),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
raw = q.get(timeout=OPTIONS_QUOTE_SSE_HEARTBEAT_SEC)
|
||||||
|
except queue.Empty:
|
||||||
|
yield ": heartbeat\n\n"
|
||||||
|
continue
|
||||||
|
if raw is None:
|
||||||
|
break
|
||||||
|
yield f"event: quotes\ndata: {raw}\n\n"
|
||||||
|
finally:
|
||||||
|
self._unsubscribe(q)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _format_event(data: dict[str, Any]) -> str:
|
||||||
|
return "event: quotes\ndata: " + json.dumps(data, ensure_ascii=False) + "\n\n"
|
||||||
|
|
||||||
|
|
||||||
|
options_quote_live = OptionsQuoteLive()
|
||||||
|
|
||||||
|
|
||||||
|
def start_options_quote_live() -> OptionsQuoteLive:
|
||||||
|
options_quote_live.start()
|
||||||
|
return options_quote_live
|
||||||
|
|
||||||
|
|
||||||
|
def register_options_quote_live_routes(app: Any, login_required: Callable) -> None:
|
||||||
|
from flask import Response, jsonify, request, stream_with_context
|
||||||
|
|
||||||
|
start_options_quote_live()
|
||||||
|
|
||||||
|
@app.route("/api/options/quotes/stream")
|
||||||
|
@login_required
|
||||||
|
def api_options_quotes_stream():
|
||||||
|
return Response(
|
||||||
|
stream_with_context(options_quote_live.iter_sse()),
|
||||||
|
mimetype="text/event-stream",
|
||||||
|
headers={
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
"Connection": "keep-alive",
|
||||||
|
"X-Accel-Buffering": "no",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.route("/api/options/quotes/watch", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def api_options_quotes_watch():
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
contracts = data.get("contracts") or []
|
||||||
|
if not contracts and data.get("inst_ids"):
|
||||||
|
contracts = [{"inst_id": x} for x in (data.get("inst_ids") or [])]
|
||||||
|
st = options_quote_live.watch(
|
||||||
|
underlying=str(data.get("underlying") or "ETH"),
|
||||||
|
exp_time=data.get("exp_time"),
|
||||||
|
contracts=contracts,
|
||||||
|
index_inst_id=data.get("index_inst_id"),
|
||||||
|
watcher_id=str(data.get("watcher_id") or "default"),
|
||||||
|
)
|
||||||
|
return jsonify({"ok": True, **st})
|
||||||
|
|
||||||
|
@app.route("/api/options/quotes/status")
|
||||||
|
@login_required
|
||||||
|
def api_options_quotes_status():
|
||||||
|
return jsonify(options_quote_live.status())
|
||||||
@@ -61,6 +61,14 @@ def install_options_trading(app: Flask, repo_root: str, app_module: Any) -> None
|
|||||||
register_options_routes(app, cfg)
|
register_options_routes(app, cfg)
|
||||||
_register_options_hub_bridge(app, cfg)
|
_register_options_hub_bridge(app, cfg)
|
||||||
if enabled:
|
if enabled:
|
||||||
|
try:
|
||||||
|
from lib.options.options_quote_live_lib import register_options_quote_live_routes
|
||||||
|
|
||||||
|
register_options_quote_live_routes(app, cfg["login_required"])
|
||||||
|
except Exception as e:
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logging.getLogger(__name__).exception("options quote live init failed: %s", e)
|
||||||
_start_monitor_thread(app, cfg)
|
_start_monitor_thread(app, cfg)
|
||||||
|
|
||||||
|
|
||||||
@@ -366,6 +374,24 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
|||||||
u = (request.args.get("underlying") or cfg["default_underly"]).upper()
|
u = (request.args.get("underlying") or cfg["default_underly"]).upper()
|
||||||
# 热更新:链展示天数每次读 env,保存后刷新链即可
|
# 热更新:链展示天数每次读 env,保存后刷新链即可
|
||||||
chain_max_dte = _env_float("OKX_OPTIONS_CHAIN_MAX_DTE_DAYS", float(cfg.get("chain_max_dte_days") or 14))
|
chain_max_dte = _env_float("OKX_OPTIONS_CHAIN_MAX_DTE_DAYS", float(cfg.get("chain_max_dte_days") or 14))
|
||||||
|
fast = (request.args.get("fast") or "").strip().lower() in ("1", "true", "yes")
|
||||||
|
force_tickers = (request.args.get("force_tickers") or "").strip().lower() in ("1", "true", "yes")
|
||||||
|
watch_exp = (request.args.get("exp_time") or "").strip() or None
|
||||||
|
live_index = None
|
||||||
|
live_tickers = None
|
||||||
|
ws_fresh = False
|
||||||
|
try:
|
||||||
|
from lib.options.options_quote_live_lib import options_quote_live
|
||||||
|
|
||||||
|
ws_fresh = options_quote_live.is_ws_fresh()
|
||||||
|
live_index = options_quote_live.index_px_for(u)
|
||||||
|
live_tickers = options_quote_live.as_okx_tickers(u) or None
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# fast: WS 已热则跳过整家族 REST tickers(最慢的一步),用 WS 缓存覆盖
|
||||||
|
fetch_tickers = True
|
||||||
|
if fast and ws_fresh and not force_tickers:
|
||||||
|
fetch_tickers = False
|
||||||
try:
|
try:
|
||||||
chain = cfg["build_option_chain"](
|
chain = cfg["build_option_chain"](
|
||||||
ex,
|
ex,
|
||||||
@@ -373,6 +399,10 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
|||||||
max_dte_days=chain_max_dte,
|
max_dte_days=chain_max_dte,
|
||||||
itm_only=False,
|
itm_only=False,
|
||||||
itm_max_dist_usd=cfg["itm_max_dist"],
|
itm_max_dist_usd=cfg["itm_max_dist"],
|
||||||
|
index_px=live_index,
|
||||||
|
tickers_override=live_tickers,
|
||||||
|
fetch_tickers=fetch_tickers,
|
||||||
|
force_tickers=force_tickers,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({"ok": False, "msg": f"加载期权链失败: {e}"})
|
return jsonify({"ok": False, "msg": f"加载期权链失败: {e}"})
|
||||||
@@ -381,6 +411,13 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
|||||||
# 热更新:每次读 env,保存配置后刷新链即可生效
|
# 热更新:每次读 env,保存配置后刷新链即可生效
|
||||||
ask_liq_filter = _env_bool("OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED", True)
|
ask_liq_filter = _env_bool("OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED", True)
|
||||||
budget_buffer = _env_float("OKX_OPTIONS_BUDGET_BUFFER", 0.95)
|
budget_buffer = _env_float("OKX_OPTIONS_BUDGET_BUFFER", 0.95)
|
||||||
|
if expiries:
|
||||||
|
try:
|
||||||
|
from lib.options.options_quote_live_lib import options_quote_live
|
||||||
|
|
||||||
|
options_quote_live.schedule_seed_from_chain(chain, exp_time=watch_exp)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
if not expiries:
|
if not expiries:
|
||||||
return jsonify(
|
return jsonify(
|
||||||
{
|
{
|
||||||
@@ -391,6 +428,8 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
|||||||
"ask_liq_filter_enabled": ask_liq_filter,
|
"ask_liq_filter_enabled": ask_liq_filter,
|
||||||
"budget_buffer": budget_buffer,
|
"budget_buffer": budget_buffer,
|
||||||
"trade_budget": cfg["trade_budget"],
|
"trade_budget": cfg["trade_budget"],
|
||||||
|
"chain_fast": fast,
|
||||||
|
"ws_fresh": ws_fresh,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return jsonify(
|
return jsonify(
|
||||||
@@ -401,6 +440,10 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
|||||||
"ask_liq_filter_enabled": ask_liq_filter,
|
"ask_liq_filter_enabled": ask_liq_filter,
|
||||||
"budget_buffer": budget_buffer,
|
"budget_buffer": budget_buffer,
|
||||||
"trade_budget": cfg["trade_budget"],
|
"trade_budget": cfg["trade_budget"],
|
||||||
|
"quote_live": True,
|
||||||
|
"chain_fast": fast,
|
||||||
|
"ws_fresh": ws_fresh,
|
||||||
|
"tickers_fetched": fetch_tickers,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -646,6 +689,17 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
|||||||
mode = (data.get("mode") or "budget_full").strip()
|
mode = (data.get("mode") or "budget_full").strip()
|
||||||
signal_note = (data.get("signal_note") or "").strip()
|
signal_note = (data.get("signal_note") or "").strip()
|
||||||
target_index = None
|
target_index = None
|
||||||
|
profit_rr = None
|
||||||
|
raw_rr = data.get("profit_rr")
|
||||||
|
if raw_rr is None or str(raw_rr).strip() == "":
|
||||||
|
raw_rr = data.get("oo_profit_rr")
|
||||||
|
if raw_rr is not None and str(raw_rr).strip() != "":
|
||||||
|
try:
|
||||||
|
profit_rr = float(raw_rr)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return jsonify({"ok": False, "msg": "盈亏比无效"})
|
||||||
|
if profit_rr <= 0:
|
||||||
|
return jsonify({"ok": False, "msg": "盈亏比须大于 0"})
|
||||||
raw_target = data.get("target_index")
|
raw_target = data.get("target_index")
|
||||||
if raw_target is not None and str(raw_target).strip() != "":
|
if raw_target is not None and str(raw_target).strip() != "":
|
||||||
try:
|
try:
|
||||||
@@ -654,6 +708,9 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
|||||||
return jsonify({"ok": False, "msg": "目标位无效"})
|
return jsonify({"ok": False, "msg": "目标位无效"})
|
||||||
if target_index <= 0:
|
if target_index <= 0:
|
||||||
return jsonify({"ok": False, "msg": "目标位无效"})
|
return jsonify({"ok": False, "msg": "目标位无效"})
|
||||||
|
# 未显式传目标时默认盈亏比 2
|
||||||
|
if profit_rr is None and target_index is None:
|
||||||
|
profit_rr = 2.0
|
||||||
if not inst_id:
|
if not inst_id:
|
||||||
return jsonify({"ok": False, "msg": "缺少 inst_id"})
|
return jsonify({"ok": False, "msg": "缺少 inst_id"})
|
||||||
q = cfg["quote_option_contract"](ex, inst_id)
|
q = cfg["quote_option_contract"](ex, inst_id)
|
||||||
@@ -820,13 +877,14 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
trade_id = int(cur.lastrowid)
|
trade_id = int(cur.lastrowid)
|
||||||
if target_index is not None:
|
if profit_rr is not None or target_index is not None:
|
||||||
from lib.options.options_target_lib import upsert_target_monitor
|
from lib.options.options_target_lib import upsert_target_monitor
|
||||||
|
|
||||||
target_mon = upsert_target_monitor(
|
target_mon = upsert_target_monitor(
|
||||||
conn,
|
conn,
|
||||||
inst_id=inst_id,
|
inst_id=inst_id,
|
||||||
target_index=target_index,
|
target_index=target_index,
|
||||||
|
profit_rr=profit_rr,
|
||||||
underlying=u,
|
underlying=u,
|
||||||
opt_type=str(opt_type) if opt_type else None,
|
opt_type=str(opt_type) if opt_type else None,
|
||||||
trade_id=trade_id,
|
trade_id=trade_id,
|
||||||
@@ -854,6 +912,7 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
|||||||
premium_paid=sizing.get("total_premium"),
|
premium_paid=sizing.get("total_premium"),
|
||||||
open_quote=fill_px,
|
open_quote=fill_px,
|
||||||
target_index=target_index,
|
target_index=target_index,
|
||||||
|
profit_rr=profit_rr,
|
||||||
signal_note=signal_note,
|
signal_note=signal_note,
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
@@ -969,11 +1028,14 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
|||||||
mon = tgt_map.get(inst)
|
mon = tgt_map.get(inst)
|
||||||
if mon:
|
if mon:
|
||||||
row["target_index"] = mon.get("target_index")
|
row["target_index"] = mon.get("target_index")
|
||||||
|
row["profit_rr"] = mon.get("profit_rr")
|
||||||
row["target_monitor_id"] = mon.get("id")
|
row["target_monitor_id"] = mon.get("id")
|
||||||
row["target_monitor"] = mon
|
row["target_monitor"] = mon
|
||||||
hedge_target = hedge_target_map.get(inst)
|
hedge_target = hedge_target_map.get(inst)
|
||||||
if hedge_target:
|
if hedge_target:
|
||||||
row["hedge_plan_target"] = hedge_target
|
row["hedge_plan_target"] = hedge_target
|
||||||
|
if hedge_target.get("oo_profit_rr") is not None:
|
||||||
|
row.setdefault("profit_rr", hedge_target.get("oo_profit_rr"))
|
||||||
try:
|
try:
|
||||||
from lib.instance.instance_dashboard_lib import _resolve_options_source
|
from lib.instance.instance_dashboard_lib import _resolve_options_source
|
||||||
|
|
||||||
@@ -1031,12 +1093,28 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
|||||||
conn_h.close()
|
conn_h.close()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({"ok": False, "msg": f"对冲托管校验失败: {e}"})
|
return jsonify({"ok": False, "msg": f"对冲托管校验失败: {e}"})
|
||||||
|
profit_rr = None
|
||||||
|
target_index = None
|
||||||
|
raw_rr = data.get("profit_rr")
|
||||||
|
if raw_rr is None or str(raw_rr).strip() == "":
|
||||||
|
raw_rr = data.get("oo_profit_rr")
|
||||||
|
if raw_rr is not None and str(raw_rr).strip() != "":
|
||||||
try:
|
try:
|
||||||
target_index = float(data.get("target_index"))
|
profit_rr = float(raw_rr)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return jsonify({"ok": False, "msg": "盈亏比无效"})
|
||||||
|
if profit_rr <= 0:
|
||||||
|
return jsonify({"ok": False, "msg": "盈亏比须大于 0"})
|
||||||
|
raw_tgt = data.get("target_index")
|
||||||
|
if raw_tgt is not None and str(raw_tgt).strip() != "":
|
||||||
|
try:
|
||||||
|
target_index = float(raw_tgt)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
return jsonify({"ok": False, "msg": "目标位无效"})
|
return jsonify({"ok": False, "msg": "目标位无效"})
|
||||||
if target_index <= 0:
|
if target_index <= 0:
|
||||||
return jsonify({"ok": False, "msg": "目标位无效"})
|
return jsonify({"ok": False, "msg": "目标位无效"})
|
||||||
|
if profit_rr is None and target_index is None:
|
||||||
|
profit_rr = 2.0
|
||||||
raw = cfg["fetch_option_positions"](ex)
|
raw = cfg["fetch_option_positions"](ex)
|
||||||
if raw is None:
|
if raw is None:
|
||||||
return jsonify({"ok": False, "msg": "获取期权持仓失败"})
|
return jsonify({"ok": False, "msg": "获取期权持仓失败"})
|
||||||
@@ -1065,6 +1143,7 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
|
|||||||
conn,
|
conn,
|
||||||
inst_id=inst_id,
|
inst_id=inst_id,
|
||||||
target_index=target_index,
|
target_index=target_index,
|
||||||
|
profit_rr=profit_rr,
|
||||||
underlying=str(underlying) if underlying else None,
|
underlying=str(underlying) if underlying else None,
|
||||||
opt_type=str(opt_type) if opt_type else None,
|
opt_type=str(opt_type) if opt_type else None,
|
||||||
trade_id=trade_id,
|
trade_id=trade_id,
|
||||||
@@ -1411,7 +1490,24 @@ def _start_monitor_thread(app: Flask, cfg: dict[str, Any]) -> None:
|
|||||||
raw = cfg["fetch_option_positions"](ex)
|
raw = cfg["fetch_option_positions"](ex)
|
||||||
if raw is None:
|
if raw is None:
|
||||||
return []
|
return []
|
||||||
return [cfg["format_position_row"](p) for p in raw]
|
rows = [cfg["format_position_row"](p) for p in raw]
|
||||||
|
try:
|
||||||
|
from lib.options.options_db import sum_open_premium_paid
|
||||||
|
|
||||||
|
conn = cfg["get_db"]()
|
||||||
|
try:
|
||||||
|
for row in rows:
|
||||||
|
inst = str(row.get("inst_id") or "")
|
||||||
|
if not inst:
|
||||||
|
continue
|
||||||
|
paid = sum_open_premium_paid(conn, inst)
|
||||||
|
if paid is not None:
|
||||||
|
row["premium_paid"] = paid
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return rows
|
||||||
|
|
||||||
def _sync(conn):
|
def _sync(conn):
|
||||||
from lib.exchange.okx_options_lib import fetch_option_position_history
|
from lib.exchange.okx_options_lib import fetch_option_position_history
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
"""期权目标位委托:指数目标价仅用于监控触发;触发后按买一限价平仓(无止损,到期结算)."""
|
"""期权目标委托:盈亏比×权利金触发后按买一限价平仓(无止损,到期结算).
|
||||||
|
|
||||||
|
兼容旧「目标指数」委托:无 profit_rr 时仍按指数到位触发.
|
||||||
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import time
|
import time
|
||||||
from typing import Any, Callable
|
from typing import Any, Callable
|
||||||
|
|
||||||
from lib.options.options_db import init_options_tables
|
from lib.options.options_db import init_options_tables, sum_open_premium_paid
|
||||||
from lib.options.options_pricing_lib import close_ref_prices, fetch_option_mark_px
|
from lib.options.options_pricing_lib import close_ref_prices, fetch_option_mark_px
|
||||||
|
|
||||||
|
|
||||||
@@ -18,6 +21,18 @@ def _safe_float(v: Any) -> float | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_column(conn: sqlite3.Connection, table: str, col: str, typedef: str) -> None:
|
||||||
|
rows = conn.execute(f"PRAGMA table_info({table})").fetchall()
|
||||||
|
names: set[str] = set()
|
||||||
|
for r in rows:
|
||||||
|
try:
|
||||||
|
names.add(str(r["name"]))
|
||||||
|
except (TypeError, KeyError, IndexError):
|
||||||
|
names.add(str(r[1]))
|
||||||
|
if col not in names:
|
||||||
|
conn.execute(f"ALTER TABLE {table} ADD COLUMN {col} {typedef}")
|
||||||
|
|
||||||
|
|
||||||
def _pos_close_refs(ex: Any, pos: dict[str, Any], quote: dict[str, Any] | None = None) -> tuple[float | None, float | None]:
|
def _pos_close_refs(ex: Any, pos: dict[str, Any], quote: dict[str, Any] | None = None) -> tuple[float | None, float | None]:
|
||||||
from lib.exchange.okx_options_lib import option_fields_from_inst_id
|
from lib.exchange.okx_options_lib import option_fields_from_inst_id
|
||||||
|
|
||||||
@@ -63,21 +78,44 @@ def ensure_target_tables(conn: sqlite3.Connection) -> None:
|
|||||||
ON options_target_monitors(status)
|
ON options_target_monitors(status)
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
# 盈亏比=目标盈利/权利金;如 2=盈利 2 倍权利金.有值时优先生效,target_index 可置 0
|
||||||
|
_ensure_column(conn, "options_target_monitors", "profit_rr", "REAL")
|
||||||
|
|
||||||
|
|
||||||
def target_hit(*, opt_type: str | None, index_px: float, target_index: float) -> bool:
|
def target_hit(*, opt_type: str | None, index_px: float, target_index: float) -> bool:
|
||||||
"""Call:指数涨到/超过目标平仓;Put:指数跌到/低于目标平仓."""
|
"""旧逻辑:Call 指数≥目标;Put 指数≤目标."""
|
||||||
ot = (opt_type or "").strip().upper()
|
ot = (opt_type or "").strip().upper()
|
||||||
if ot == "P":
|
if ot == "P":
|
||||||
return index_px <= target_index
|
return index_px <= target_index
|
||||||
return index_px >= target_index
|
return index_px >= target_index
|
||||||
|
|
||||||
|
|
||||||
|
def profit_rr_hit(
|
||||||
|
*,
|
||||||
|
premium: float,
|
||||||
|
bid: float | None,
|
||||||
|
sheets: float,
|
||||||
|
ct_mult: float,
|
||||||
|
profit_rr: float,
|
||||||
|
) -> bool:
|
||||||
|
"""买一回收 − 权利金 ≥ 盈亏比 × 权利金."""
|
||||||
|
if premium <= 0 or profit_rr <= 0:
|
||||||
|
return False
|
||||||
|
if bid is None or float(bid) <= 0:
|
||||||
|
return False
|
||||||
|
if sheets <= 0 or ct_mult <= 0:
|
||||||
|
return False
|
||||||
|
recycle = float(bid) * float(sheets) * float(ct_mult)
|
||||||
|
pnl = recycle - float(premium)
|
||||||
|
return pnl + 1e-9 >= float(profit_rr) * float(premium)
|
||||||
|
|
||||||
|
|
||||||
def upsert_target_monitor(
|
def upsert_target_monitor(
|
||||||
conn: sqlite3.Connection,
|
conn: sqlite3.Connection,
|
||||||
*,
|
*,
|
||||||
inst_id: str,
|
inst_id: str,
|
||||||
target_index: float,
|
target_index: float | None = None,
|
||||||
|
profit_rr: float | None = None,
|
||||||
underlying: str | None = None,
|
underlying: str | None = None,
|
||||||
opt_type: str | None = None,
|
opt_type: str | None = None,
|
||||||
trade_id: int | None = None,
|
trade_id: int | None = None,
|
||||||
@@ -87,9 +125,18 @@ def upsert_target_monitor(
|
|||||||
inst_id = (inst_id or "").strip()
|
inst_id = (inst_id or "").strip()
|
||||||
if not inst_id:
|
if not inst_id:
|
||||||
return {"ok": False, "msg": "缺少 inst_id"}
|
return {"ok": False, "msg": "缺少 inst_id"}
|
||||||
if target_index is None or float(target_index) <= 0:
|
|
||||||
return {"ok": False, "msg": "目标位无效"}
|
rr = _safe_float(profit_rr)
|
||||||
target_index = float(target_index)
|
tgt = _safe_float(target_index)
|
||||||
|
if rr is not None and rr > 0:
|
||||||
|
tgt_store = float(tgt) if tgt is not None and tgt > 0 else 0.0
|
||||||
|
rr_store = float(rr)
|
||||||
|
elif tgt is not None and tgt > 0:
|
||||||
|
tgt_store = float(tgt)
|
||||||
|
rr_store = None
|
||||||
|
else:
|
||||||
|
return {"ok": False, "msg": "请填写盈亏比(相对权利金,默认2)"}
|
||||||
|
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
"""
|
"""
|
||||||
SELECT id FROM options_target_monitors
|
SELECT id FROM options_target_monitors
|
||||||
@@ -104,6 +151,7 @@ def upsert_target_monitor(
|
|||||||
"""
|
"""
|
||||||
UPDATE options_target_monitors
|
UPDATE options_target_monitors
|
||||||
SET target_index = ?,
|
SET target_index = ?,
|
||||||
|
profit_rr = ?,
|
||||||
underlying = COALESCE(?, underlying),
|
underlying = COALESCE(?, underlying),
|
||||||
opt_type = COALESCE(?, opt_type),
|
opt_type = COALESCE(?, opt_type),
|
||||||
trade_id = COALESCE(?, trade_id),
|
trade_id = COALESCE(?, trade_id),
|
||||||
@@ -115,14 +163,13 @@ def upsert_target_monitor(
|
|||||||
triggered_at = NULL
|
triggered_at = NULL
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
""",
|
""",
|
||||||
(target_index, underlying, opt_type, trade_id, sheets, int(row["id"])),
|
(tgt_store, rr_store, underlying, opt_type, trade_id, sheets, int(row["id"])),
|
||||||
)
|
)
|
||||||
mon_id = int(row["id"])
|
mon_id = int(row["id"])
|
||||||
# 同一合约其他进行中的委托取消,避免双轨触发重复推送
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
UPDATE options_target_monitors
|
UPDATE options_target_monitors
|
||||||
SET status = 'cancelled', message = '被新目标位覆盖'
|
SET status = 'cancelled', message = '被新目标委托覆盖'
|
||||||
WHERE inst_id = ? AND id != ? AND status IN ('active', 'closing')
|
WHERE inst_id = ? AND id != ? AND status IN ('active', 'closing')
|
||||||
""",
|
""",
|
||||||
(inst_id, mon_id),
|
(inst_id, mon_id),
|
||||||
@@ -131,13 +178,21 @@ def upsert_target_monitor(
|
|||||||
cur = conn.execute(
|
cur = conn.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO options_target_monitors
|
INSERT INTO options_target_monitors
|
||||||
(inst_id, underlying, opt_type, target_index, trade_id, sheets, status)
|
(inst_id, underlying, opt_type, target_index, profit_rr, trade_id, sheets, status)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, 'active')
|
VALUES (?, ?, ?, ?, ?, ?, ?, 'active')
|
||||||
""",
|
""",
|
||||||
(inst_id, underlying, opt_type, target_index, trade_id, sheets),
|
(inst_id, underlying, opt_type, tgt_store, rr_store, trade_id, sheets),
|
||||||
)
|
)
|
||||||
mon_id = int(cur.lastrowid)
|
mon_id = int(cur.lastrowid)
|
||||||
return {"ok": True, "id": mon_id, "inst_id": inst_id, "target_index": target_index}
|
out: dict[str, Any] = {
|
||||||
|
"ok": True,
|
||||||
|
"id": mon_id,
|
||||||
|
"inst_id": inst_id,
|
||||||
|
"target_index": tgt_store if tgt_store > 0 else None,
|
||||||
|
}
|
||||||
|
if rr_store is not None:
|
||||||
|
out["profit_rr"] = rr_store
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def cancel_target_monitor(conn: sqlite3.Connection, *, inst_id: str | None = None, monitor_id: int | None = None) -> int:
|
def cancel_target_monitor(conn: sqlite3.Connection, *, inst_id: str | None = None, monitor_id: int | None = None) -> int:
|
||||||
@@ -166,12 +221,19 @@ def cancel_target_monitor(conn: sqlite3.Connection, *, inst_id: str | None = Non
|
|||||||
|
|
||||||
|
|
||||||
def _row_to_target(r: sqlite3.Row) -> dict[str, Any]:
|
def _row_to_target(r: sqlite3.Row) -> dict[str, Any]:
|
||||||
|
tgt = _safe_float(r["target_index"])
|
||||||
|
rr = None
|
||||||
|
try:
|
||||||
|
rr = _safe_float(r["profit_rr"])
|
||||||
|
except (KeyError, IndexError):
|
||||||
|
rr = None
|
||||||
return {
|
return {
|
||||||
"id": int(r["id"]),
|
"id": int(r["id"]),
|
||||||
"inst_id": r["inst_id"],
|
"inst_id": r["inst_id"],
|
||||||
"underlying": r["underlying"],
|
"underlying": r["underlying"],
|
||||||
"opt_type": r["opt_type"],
|
"opt_type": r["opt_type"],
|
||||||
"target_index": _safe_float(r["target_index"]),
|
"target_index": tgt if tgt is not None and tgt > 0 else None,
|
||||||
|
"profit_rr": rr if rr is not None and rr > 0 else None,
|
||||||
"trade_id": r["trade_id"],
|
"trade_id": r["trade_id"],
|
||||||
"sheets": r["sheets"],
|
"sheets": r["sheets"],
|
||||||
"status": r["status"],
|
"status": r["status"],
|
||||||
@@ -180,16 +242,16 @@ def _row_to_target(r: sqlite3.Row) -> dict[str, Any]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
_TARGET_SELECT = (
|
||||||
|
"SELECT id, inst_id, underlying, opt_type, target_index, profit_rr, trade_id, sheets, "
|
||||||
|
"status, message, created_at FROM options_target_monitors"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def list_active_targets(conn: sqlite3.Connection) -> list[dict[str, Any]]:
|
def list_active_targets(conn: sqlite3.Connection) -> list[dict[str, Any]]:
|
||||||
ensure_target_tables(conn)
|
ensure_target_tables(conn)
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"""
|
f"{_TARGET_SELECT} WHERE status = 'active' ORDER BY id DESC"
|
||||||
SELECT id, inst_id, underlying, opt_type, target_index, trade_id, sheets,
|
|
||||||
status, message, created_at
|
|
||||||
FROM options_target_monitors
|
|
||||||
WHERE status = 'active'
|
|
||||||
ORDER BY id DESC
|
|
||||||
"""
|
|
||||||
).fetchall()
|
).fetchall()
|
||||||
return [_row_to_target(r) for r in rows]
|
return [_row_to_target(r) for r in rows]
|
||||||
|
|
||||||
@@ -198,13 +260,7 @@ def list_closing_targets(conn: sqlite3.Connection) -> list[dict[str, Any]]:
|
|||||||
"""已挂出平仓单、等待成交的目标(不再重复推送微信)."""
|
"""已挂出平仓单、等待成交的目标(不再重复推送微信)."""
|
||||||
ensure_target_tables(conn)
|
ensure_target_tables(conn)
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"""
|
f"{_TARGET_SELECT} WHERE status = 'closing' ORDER BY id DESC"
|
||||||
SELECT id, inst_id, underlying, opt_type, target_index, trade_id, sheets,
|
|
||||||
status, message, created_at
|
|
||||||
FROM options_target_monitors
|
|
||||||
WHERE status = 'closing'
|
|
||||||
ORDER BY id DESC
|
|
||||||
"""
|
|
||||||
).fetchall()
|
).fetchall()
|
||||||
return [_row_to_target(r) for r in rows]
|
return [_row_to_target(r) for r in rows]
|
||||||
|
|
||||||
@@ -286,23 +342,23 @@ def close_option_by_bid_depth(
|
|||||||
inst_id,
|
inst_id,
|
||||||
sheets=sheets,
|
sheets=sheets,
|
||||||
require_recycle_gate=True,
|
require_recycle_gate=True,
|
||||||
signal_note="目标位平仓",
|
signal_note="盈亏比平仓",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _notify_target_close(
|
def _notify_target_close(
|
||||||
cfg: dict[str, Any] | None,
|
cfg: dict[str, Any] | None,
|
||||||
send_wechat: Callable[[str], None] | None,
|
send_wechat: Callable[[str], None] | None,
|
||||||
*,
|
*,
|
||||||
account_label: str,
|
account_label: str,
|
||||||
inst_id: str,
|
inst_id: str,
|
||||||
target: float,
|
target: float | None,
|
||||||
idx: float,
|
profit_rr: float | None,
|
||||||
|
idx: float | None,
|
||||||
result: dict[str, Any],
|
result: dict[str, Any],
|
||||||
conn: Any = None,
|
conn: Any = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""目标位平仓推送:优先走统一平仓必发(幂等);无 cfg 时回退旧文案."""
|
"""目标平仓推送:优先走统一平仓必发(幂等);无 cfg 时回退旧文案."""
|
||||||
if result.get("fully_closed") or result.get("already_flat"):
|
if result.get("fully_closed") or result.get("already_flat"):
|
||||||
if cfg is not None:
|
if cfg is not None:
|
||||||
try:
|
try:
|
||||||
@@ -312,12 +368,13 @@ def _notify_target_close(
|
|||||||
cfg,
|
cfg,
|
||||||
conn,
|
conn,
|
||||||
inst_id=inst_id,
|
inst_id=inst_id,
|
||||||
reason="目标位平仓",
|
reason="盈亏比平仓" if profit_rr else "目标位平仓",
|
||||||
sheets=result.get("submitted_sheets"),
|
sheets=result.get("submitted_sheets"),
|
||||||
premium_received=result.get("premium_received"),
|
premium_received=result.get("premium_received"),
|
||||||
close_quote=result.get("locked_bid_px") or result.get("bid"),
|
close_quote=result.get("locked_bid_px") or result.get("bid"),
|
||||||
target_index=target,
|
target_index=target,
|
||||||
trigger_idx=idx,
|
trigger_idx=idx,
|
||||||
|
profit_rr=profit_rr,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -325,14 +382,20 @@ def _notify_target_close(
|
|||||||
if not send_wechat:
|
if not send_wechat:
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
|
if profit_rr is not None and profit_rr > 0:
|
||||||
|
rule = f"盈亏比×{profit_rr:g}"
|
||||||
|
elif target is not None:
|
||||||
|
rule = f"目标指数:{target:g}"
|
||||||
|
else:
|
||||||
|
rule = "目标委托"
|
||||||
send_wechat(
|
send_wechat(
|
||||||
"\n".join(
|
"\n".join(
|
||||||
[
|
[
|
||||||
"【OKX期权·目标位平仓】",
|
"【OKX期权·盈亏比平仓】" if profit_rr else "【OKX期权·目标位平仓】",
|
||||||
f"账户:{account_label}",
|
f"账户:{account_label}",
|
||||||
f"合约:{inst_id}",
|
f"合约:{inst_id}",
|
||||||
f"目标指数:{target:g}",
|
rule,
|
||||||
f"触发指数:{idx:g}",
|
f"触发指数:{idx:g}" if idx is not None else "触发指数:—",
|
||||||
f"提交张数:{result.get('submitted_sheets') or '—'}",
|
f"提交张数:{result.get('submitted_sheets') or '—'}",
|
||||||
f"预估收回:{result.get('premium_received') if result.get('premium_received') is not None else '—'} USDC",
|
f"预估收回:{result.get('premium_received') if result.get('premium_received') is not None else '—'} USDC",
|
||||||
f"状态:{'已全平' if (result.get('fully_closed') or result.get('already_flat')) else '挂单中/部分'}",
|
f"状态:{'已全平' if (result.get('fully_closed') or result.get('already_flat')) else '挂单中/部分'}",
|
||||||
@@ -354,18 +417,77 @@ def _result_fully_done(result: dict[str, Any]) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _monitor_should_close(
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
mon: dict[str, Any],
|
||||||
|
pos: dict[str, Any],
|
||||||
|
*,
|
||||||
|
bid_fn: Callable[[str], float | None] | None,
|
||||||
|
index_fn: Callable[[dict[str, Any]], float | None] | None,
|
||||||
|
) -> tuple[bool, float | None]:
|
||||||
|
"""返回 (是否触发, 当前指数)."""
|
||||||
|
inst_id = str(mon.get("inst_id") or "")
|
||||||
|
rr = _safe_float(mon.get("profit_rr"))
|
||||||
|
if index_fn is not None:
|
||||||
|
idx = index_fn(pos)
|
||||||
|
else:
|
||||||
|
idx = _safe_float(pos.get("idx_px") or pos.get("idxPx"))
|
||||||
|
|
||||||
|
if rr is not None and rr > 0:
|
||||||
|
premium = sum_open_premium_paid(conn, inst_id)
|
||||||
|
if premium is None or premium <= 0:
|
||||||
|
premium = _safe_float(pos.get("premium_paid"))
|
||||||
|
sheets = _safe_float(mon.get("sheets"))
|
||||||
|
if sheets is None or sheets <= 0:
|
||||||
|
sheets = _safe_float(pos.get("pos") or pos.get("avail_pos") or pos.get("availPos"))
|
||||||
|
ct = _safe_float(pos.get("ct_mult") or pos.get("ctMult")) or 0.01
|
||||||
|
bid = None
|
||||||
|
if bid_fn is not None:
|
||||||
|
try:
|
||||||
|
bid = bid_fn(inst_id)
|
||||||
|
except Exception:
|
||||||
|
bid = None
|
||||||
|
if bid is None:
|
||||||
|
bid = _safe_float(pos.get("bid_px") or pos.get("bidPx") or pos.get("bid"))
|
||||||
|
preview = pos.get("close_preview") if isinstance(pos.get("close_preview"), dict) else {}
|
||||||
|
if bid is None:
|
||||||
|
bid = _safe_float(preview.get("bid") or preview.get("best_bid"))
|
||||||
|
if premium is None or sheets is None:
|
||||||
|
return False, idx
|
||||||
|
return (
|
||||||
|
profit_rr_hit(
|
||||||
|
premium=float(premium),
|
||||||
|
bid=bid,
|
||||||
|
sheets=float(sheets),
|
||||||
|
ct_mult=float(ct),
|
||||||
|
profit_rr=float(rr),
|
||||||
|
),
|
||||||
|
idx,
|
||||||
|
)
|
||||||
|
|
||||||
|
target = _safe_float(mon.get("target_index"))
|
||||||
|
if target is None or target <= 0 or idx is None:
|
||||||
|
return False, idx
|
||||||
|
opt_type = mon.get("opt_type") or pos.get("opt_type") or pos.get("optType")
|
||||||
|
return (
|
||||||
|
target_hit(opt_type=str(opt_type) if opt_type else None, index_px=idx, target_index=target),
|
||||||
|
idx,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def run_options_target_closes(
|
def run_options_target_closes(
|
||||||
conn: sqlite3.Connection,
|
conn: sqlite3.Connection,
|
||||||
positions: list[dict[str, Any]],
|
positions: list[dict[str, Any]],
|
||||||
*,
|
*,
|
||||||
close_fn: Callable[[str], dict[str, Any]],
|
close_fn: Callable[[str], dict[str, Any]],
|
||||||
index_fn: Callable[[dict[str, Any]], float | None] | None = None,
|
index_fn: Callable[[dict[str, Any]], float | None] | None = None,
|
||||||
|
bid_fn: Callable[[str], float | None] | None = None,
|
||||||
send_wechat: Callable[[str], None] | None = None,
|
send_wechat: Callable[[str], None] | None = None,
|
||||||
account_label: str = "OKX期权",
|
account_label: str = "OKX期权",
|
||||||
cfg: dict[str, Any] | None = None,
|
cfg: dict[str, Any] | None = None,
|
||||||
) -> int:
|
) -> int:
|
||||||
"""
|
"""
|
||||||
扫描 active 目标委托;指数到位后限价平仓.
|
扫描 active 目标委托;盈亏比达标(或旧指数到位)后限价平仓.
|
||||||
状态先 commit 再推微信,避免 sync 失败回滚导致同一笔反复推送.
|
状态先 commit 再推微信,避免 sync 失败回滚导致同一笔反复推送.
|
||||||
未完全成交进入 closing,仅重试平仓不再推送.
|
未完全成交进入 closing,仅重试平仓不再推送.
|
||||||
返回本次新触发(并推送)的条数.
|
返回本次新触发(并推送)的条数.
|
||||||
@@ -412,7 +534,7 @@ def run_options_target_closes(
|
|||||||
status="triggered",
|
status="triggered",
|
||||||
trigger_idx=idx,
|
trigger_idx=idx,
|
||||||
close_ord_id=result.get("close_ord_id"),
|
close_ord_id=result.get("close_ord_id"),
|
||||||
message="目标位限价平仓完成",
|
message="盈亏比限价平仓完成",
|
||||||
)
|
)
|
||||||
_commit_monitor(conn)
|
_commit_monitor(conn)
|
||||||
continue
|
continue
|
||||||
@@ -429,8 +551,7 @@ def run_options_target_closes(
|
|||||||
triggered = 0
|
triggered = 0
|
||||||
for mon in list_active_targets(conn):
|
for mon in list_active_targets(conn):
|
||||||
inst_id = str(mon.get("inst_id") or "")
|
inst_id = str(mon.get("inst_id") or "")
|
||||||
target = _safe_float(mon.get("target_index"))
|
if not inst_id:
|
||||||
if not inst_id or target is None:
|
|
||||||
continue
|
continue
|
||||||
if inst_id in hedge_managed:
|
if inst_id in hedge_managed:
|
||||||
mark_monitor(
|
mark_monitor(
|
||||||
@@ -444,17 +565,15 @@ def run_options_target_closes(
|
|||||||
pos = pos_by_inst.get(inst_id)
|
pos = pos_by_inst.get(inst_id)
|
||||||
if not pos:
|
if not pos:
|
||||||
continue
|
continue
|
||||||
if index_fn is not None:
|
should, idx = _monitor_should_close(
|
||||||
idx = index_fn(pos)
|
conn, mon, pos, bid_fn=bid_fn, index_fn=index_fn
|
||||||
else:
|
)
|
||||||
idx = _safe_float(pos.get("idx_px") or pos.get("idxPx"))
|
if not should:
|
||||||
if idx is None:
|
|
||||||
continue
|
|
||||||
opt_type = mon.get("opt_type") or pos.get("opt_type") or pos.get("optType")
|
|
||||||
if not target_hit(opt_type=str(opt_type) if opt_type else None, index_px=idx, target_index=target):
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
result = close_fn(inst_id)
|
result = close_fn(inst_id)
|
||||||
|
rr = _safe_float(mon.get("profit_rr"))
|
||||||
|
target = _safe_float(mon.get("target_index"))
|
||||||
if result.get("already_flat"):
|
if result.get("already_flat"):
|
||||||
mark_monitor(conn, int(mon["id"]), status="expired", trigger_idx=idx, message="持仓已平")
|
mark_monitor(conn, int(mon["id"]), status="expired", trigger_idx=idx, message="持仓已平")
|
||||||
_commit_monitor(conn)
|
_commit_monitor(conn)
|
||||||
@@ -472,15 +591,19 @@ def run_options_target_closes(
|
|||||||
|
|
||||||
done = _result_fully_done(result)
|
done = _result_fully_done(result)
|
||||||
status = "triggered" if done else "closing"
|
status = "triggered" if done else "closing"
|
||||||
|
hit_msg = (
|
||||||
|
"盈亏比达标限价平仓"
|
||||||
|
if (rr is not None and rr > 0)
|
||||||
|
else "目标位触发限价平仓"
|
||||||
|
)
|
||||||
mark_monitor(
|
mark_monitor(
|
||||||
conn,
|
conn,
|
||||||
int(mon["id"]),
|
int(mon["id"]),
|
||||||
status=status,
|
status=status,
|
||||||
trigger_idx=idx,
|
trigger_idx=idx,
|
||||||
close_ord_id=result.get("close_ord_id"),
|
close_ord_id=result.get("close_ord_id"),
|
||||||
message="目标位触发限价平仓" if done else "目标位已挂买一限价,等待成交",
|
message=hit_msg if done else "已挂买一限价,等待成交",
|
||||||
)
|
)
|
||||||
# 关键:先落库,再推送——否则后续 sync 异常回滚会让同一笔反复推微信
|
|
||||||
_commit_monitor(conn)
|
_commit_monitor(conn)
|
||||||
triggered += 1
|
triggered += 1
|
||||||
_notify_target_close(
|
_notify_target_close(
|
||||||
@@ -489,6 +612,7 @@ def run_options_target_closes(
|
|||||||
account_label=account_label,
|
account_label=account_label,
|
||||||
inst_id=inst_id,
|
inst_id=inst_id,
|
||||||
target=target,
|
target=target,
|
||||||
|
profit_rr=rr,
|
||||||
idx=idx,
|
idx=idx,
|
||||||
result=result,
|
result=result,
|
||||||
conn=conn,
|
conn=conn,
|
||||||
|
|||||||
@@ -107,17 +107,15 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="options-estimate-row">
|
<div class="options-estimate-row">
|
||||||
<div class="opt-est-main">
|
<div class="opt-est-main">
|
||||||
<label class="btn-secondary opt-order-chip" for="opt-target-idx">目标位(指数)</label>
|
<label class="btn-secondary opt-order-chip" for="opt-profit-rr" title="目标盈利=盈亏比×权利金;例2=赚满2倍权利金后全平">盈亏比</label>
|
||||||
<input type="number" id="opt-target-idx" class="opt-target-idx" step="0.1" min="0" placeholder="达价限价平仓"
|
<input type="number" id="opt-profit-rr" class="opt-target-idx" step="0.1" min="0.1" value="2" placeholder="默认2"
|
||||||
autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other">
|
autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other">
|
||||||
<span class="k">预计价值</span>
|
<span class="k">目标盈利</span>
|
||||||
<span id="opt-est-value" class="v">—</span>
|
|
||||||
<span class="k">盈利</span>
|
|
||||||
<span id="opt-est-profit" class="v">—</span>
|
<span id="opt-est-profit" class="v">—</span>
|
||||||
<span class="k">目标杠杆</span>
|
<span class="k">需回收</span>
|
||||||
<span id="opt-est-leverage" class="v" title="目标位名义价值÷权利金">—</span>
|
<span id="opt-est-value" class="v" title="权利金+目标盈利">—</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="muted opt-est-note">目标价=监控指数;到位后按买一限价平仓;无止损,到期即止损</span>
|
<span class="muted opt-est-note">按买一浮盈达盈亏比×权利金后限价全平;不达标等到期;无止损</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-row options-order-mode-row">
|
<div class="form-row options-order-mode-row">
|
||||||
<div class="opt-size-mode-bar">
|
<div class="opt-size-mode-bar">
|
||||||
@@ -324,4 +322,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script src="/static/options_expiry_countdown.js?v=1"></script>
|
<script src="/static/options_expiry_countdown.js?v=1"></script>
|
||||||
<script src="/static/options_panel.js?v=54"></script>
|
<script src="/static/options_panel.js?v=59"></script>
|
||||||
|
|||||||
@@ -416,4 +416,4 @@
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/static/options_review.js?v=23"></script>
|
<script src="/static/options_review.js?v=24"></script>
|
||||||
|
|||||||
@@ -3930,6 +3930,19 @@
|
|||||||
|
|
||||||
function renderOptionsTargetCell(target) {
|
function renderOptionsTargetCell(target) {
|
||||||
if (!target) return "<td>—</td>";
|
if (!target) return "<td>—</td>";
|
||||||
|
const rr =
|
||||||
|
target.profit_rr != null
|
||||||
|
? Number(target.profit_rr)
|
||||||
|
: target.oo_profit_rr != null
|
||||||
|
? Number(target.oo_profit_rr)
|
||||||
|
: null;
|
||||||
|
if (rr != null && Number.isFinite(rr) && rr > 0) {
|
||||||
|
const txt = `盈亏比×${fmt(rr, 2)}`;
|
||||||
|
if (target.managed_by === "hedge_plan") {
|
||||||
|
return `<td class="hub-opt-target-cell is-on is-hedge" title="由对冲计划监控">对冲#${esc(target.plan_id)} ${esc(txt)}</td>`;
|
||||||
|
}
|
||||||
|
return `<td class="hub-opt-target-cell is-on" title="盈亏比监控">${esc(txt)}</td>`;
|
||||||
|
}
|
||||||
const side = String(target.opt_type || "").toUpperCase() === "P" ? "Put≤" : "Call≥";
|
const side = String(target.opt_type || "").toUpperCase() === "P" ? "Put≤" : "Call≥";
|
||||||
const px = target.target_index != null ? fmt(target.target_index, 1) : "—";
|
const px = target.target_index != null ? fmt(target.target_index, 1) : "—";
|
||||||
if (target.managed_by === "hedge_plan") {
|
if (target.managed_by === "hedge_plan") {
|
||||||
@@ -3942,7 +3955,7 @@
|
|||||||
if (!pos.length) return '<div class="empty-hint hub-slot-pos">暂无期权持仓</div>';
|
if (!pos.length) return '<div class="empty-hint hub-slot-pos">暂无期权持仓</div>';
|
||||||
const showPnl = showAccountPnlPref();
|
const showPnl = showAccountPnlPref();
|
||||||
let html = '<div class="table-wrap hub-options-table-wrap"><table class="hub-options-table"><thead><tr>';
|
let html = '<div class="table-wrap hub-options-table-wrap"><table class="hub-options-table"><thead><tr>';
|
||||||
html += "<th>合约</th><th>类型</th><th>张数</th><th>到期倒计时</th><th>目标监控</th>";
|
html += "<th>合约</th><th>类型</th><th>张数</th><th>到期倒计时</th><th>盈亏比</th>";
|
||||||
if (showPnl) html += "<th>净盈亏</th><th>收益率</th>";
|
if (showPnl) html += "<th>净盈亏</th><th>收益率</th>";
|
||||||
html += "</tr></thead><tbody>";
|
html += "</tr></thead><tbody>";
|
||||||
pos.forEach((p) => {
|
pos.forEach((p) => {
|
||||||
|
|||||||
@@ -115,7 +115,7 @@
|
|||||||
<span class="plan-radio-row" id="plan-create-direction"></span>
|
<span class="plan-radio-row" id="plan-create-direction"></span>
|
||||||
</label>
|
</label>
|
||||||
<label class="plan-field">
|
<label class="plan-field">
|
||||||
<span>目标位</span>
|
<span>盈亏比</span>
|
||||||
<input id="plan-create-target" type="text" placeholder="如 68500" autocomplete="off" data-lpignore="true" data-1p-ignore="true" data-form-type="other" />
|
<input id="plan-create-target" type="text" placeholder="如 68500" autocomplete="off" data-lpignore="true" data-1p-ignore="true" data-form-type="other" />
|
||||||
</label>
|
</label>
|
||||||
<label class="plan-field">
|
<label class="plan-field">
|
||||||
@@ -1765,8 +1765,8 @@
|
|||||||
<script src="/assets/ai_review_render.js?v=3"></script>
|
<script src="/assets/ai_review_render.js?v=3"></script>
|
||||||
<script src="/assets/time_close_ui.js?v=3"></script>
|
<script src="/assets/time_close_ui.js?v=3"></script>
|
||||||
<script src="/assets/options_expiry_countdown.js?v=1"></script>
|
<script src="/assets/options_expiry_countdown.js?v=1"></script>
|
||||||
<script src="/assets/options_position_cards.js?v=4"></script>
|
<script src="/assets/options_position_cards.js?v=5"></script>
|
||||||
<script src="/assets/backup.js?v=1"></script>
|
<script src="/assets/backup.js?v=1"></script>
|
||||||
<script src="/assets/app.js?v=20260807-opt-float"></script>
|
<script src="/assets/app.js?v=20260811-opt-rr"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
flask>=3.0,<4
|
flask>=3.0,<4
|
||||||
requests>=2.31,<3
|
requests>=2.31,<3
|
||||||
ccxt>=4.2,<5
|
ccxt>=4.2,<5
|
||||||
|
websocket-client>=1.6,<2
|
||||||
werkzeug>=3.0,<4
|
werkzeug>=3.0,<4
|
||||||
PySocks>=1.7,<2
|
PySocks>=1.7,<2
|
||||||
Pillow>=10.0,<12
|
Pillow>=10.0,<12
|
||||||
|
|||||||
@@ -102,8 +102,7 @@ class TestHedgePlanCalc(unittest.TestCase):
|
|||||||
a = {"opt_type": "C", "strike": 3300, "sheets": 1, "ct_mult": 0.01, "premium_paid": 5}
|
a = {"opt_type": "C", "strike": 3300, "sheets": 1, "ct_mult": 0.01, "premium_paid": 5}
|
||||||
b = {"opt_type": "P", "strike": 3100, "sheets": 1, "ct_mult": 0.01, "premium_paid": 5}
|
b = {"opt_type": "P", "strike": 3100, "sheets": 1, "ct_mult": 0.01, "premium_paid": 5}
|
||||||
p = build_options_options_preview(
|
p = build_options_options_preview(
|
||||||
target_price_up=3500,
|
profit_rr=2,
|
||||||
target_price_down=3000,
|
|
||||||
index_px=3200,
|
index_px=3200,
|
||||||
leg_a=a,
|
leg_a=a,
|
||||||
leg_b=b,
|
leg_b=b,
|
||||||
@@ -111,11 +110,11 @@ class TestHedgePlanCalc(unittest.TestCase):
|
|||||||
self.assertEqual(p["summary"]["premium_paid"], 10)
|
self.assertEqual(p["summary"]["premium_paid"], 10)
|
||||||
self.assertTrue(p["summary"]["expiry_is_loss"])
|
self.assertTrue(p["summary"]["expiry_is_loss"])
|
||||||
self.assertEqual(p["summary"]["rr_risk_premium"], 10)
|
self.assertEqual(p["summary"]["rr_risk_premium"], 10)
|
||||||
self.assertIsNotNone(p["summary"]["rr_at_up"])
|
self.assertEqual(p["summary"]["oo_profit_rr"], 2)
|
||||||
self.assertAlmostEqual(p["summary"]["rr_at_up"], p["summary"]["at_target_up_total"] / 10, places=4)
|
self.assertAlmostEqual(p["summary"]["target_profit"], 20.0, places=4)
|
||||||
self.assertEqual(len(p["scenarios"]), 4)
|
self.assertEqual(len(p["scenarios"]), 3)
|
||||||
self.assertEqual(p["scenarios"][0]["id"], "target_up")
|
self.assertEqual(p["scenarios"][0]["id"], "rr_target")
|
||||||
self.assertEqual(p["scenarios"][1]["id"], "target_down")
|
self.assertEqual(p["scenarios"][1]["id"], "expiry_flat")
|
||||||
|
|
||||||
def test_oo_legacy_single_target_still_works(self):
|
def test_oo_legacy_single_target_still_works(self):
|
||||||
a = {"opt_type": "C", "strike": 3300, "sheets": 1, "ct_mult": 0.01, "premium_paid": 5}
|
a = {"opt_type": "C", "strike": 3300, "sheets": 1, "ct_mult": 0.01, "premium_paid": 5}
|
||||||
@@ -124,6 +123,21 @@ class TestHedgePlanCalc(unittest.TestCase):
|
|||||||
self.assertEqual(p["target_price_up"], 3500)
|
self.assertEqual(p["target_price_up"], 3500)
|
||||||
self.assertEqual(p["target_price_down"], 3500)
|
self.assertEqual(p["target_price_down"], 3500)
|
||||||
|
|
||||||
|
def test_oo_legacy_up_down_rr_fields(self):
|
||||||
|
a = {"opt_type": "C", "strike": 3300, "sheets": 1, "ct_mult": 0.01, "premium_paid": 5}
|
||||||
|
b = {"opt_type": "P", "strike": 3100, "sheets": 1, "ct_mult": 0.01, "premium_paid": 5}
|
||||||
|
p = build_options_options_preview(
|
||||||
|
target_price_up=3500,
|
||||||
|
target_price_down=3000,
|
||||||
|
index_px=3200,
|
||||||
|
leg_a=a,
|
||||||
|
leg_b=b,
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(p["summary"]["rr_at_up"])
|
||||||
|
self.assertAlmostEqual(p["summary"]["rr_at_up"], p["summary"]["at_target_up_total"] / 10, places=4)
|
||||||
|
self.assertEqual(p["scenarios"][0]["id"], "target_up")
|
||||||
|
self.assertEqual(p["scenarios"][1]["id"], "target_down")
|
||||||
|
|
||||||
def test_perp_short_pnl(self):
|
def test_perp_short_pnl(self):
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
perp_pnl(direction="short", entry=100, exit_px=90, contracts=1, contract_size=1),
|
perp_pnl(direction="short", entry=100, exit_px=90, contracts=1, contract_size=1),
|
||||||
|
|||||||
@@ -155,6 +155,32 @@ class TestHedgeHistoryStats(unittest.TestCase):
|
|||||||
self.assertEqual(targets["ETH-USD_UM-260719-1850-P"]["target_index"], 1800)
|
self.assertEqual(targets["ETH-USD_UM-260719-1850-P"]["target_index"], 1800)
|
||||||
self.assertEqual(targets["ETH-USD_UM-260719-1890-C"]["managed_by"], "hedge_plan")
|
self.assertEqual(targets["ETH-USD_UM-260719-1890-C"]["managed_by"], "hedge_plan")
|
||||||
|
|
||||||
|
def test_active_options_targets_rr_mode_marks_managed(self):
|
||||||
|
conn = _mem()
|
||||||
|
pid = insert_plan(
|
||||||
|
conn,
|
||||||
|
{
|
||||||
|
"plan_type": "options_options",
|
||||||
|
"status": "active",
|
||||||
|
"underlying": "ETH",
|
||||||
|
"oo_profit_rr": 2,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
insert_leg(
|
||||||
|
conn,
|
||||||
|
{
|
||||||
|
"plan_id": pid,
|
||||||
|
"leg_role": "option_a",
|
||||||
|
"inst_id": "ETH-USD_UM-260719-1890-C",
|
||||||
|
"opt_type": "C",
|
||||||
|
"status": "open",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
targets = active_options_targets_by_inst(conn)
|
||||||
|
self.assertEqual(targets["ETH-USD_UM-260719-1890-C"]["managed_by"], "hedge_plan")
|
||||||
|
self.assertIsNone(targets["ETH-USD_UM-260719-1890-C"]["target_index"])
|
||||||
|
self.assertEqual(targets["ETH-USD_UM-260719-1890-C"]["oo_profit_rr"], 2.0)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"""期权合约列表缓存与限频退避."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from lib.exchange import okx_options_lib as m
|
||||||
|
|
||||||
|
|
||||||
|
class FetchOptionInstrumentsCacheTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
m.invalidate_option_instruments_cache()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
m.invalidate_option_instruments_cache()
|
||||||
|
|
||||||
|
def test_cache_hit_skips_second_api_call(self):
|
||||||
|
ex = MagicMock()
|
||||||
|
ex.public_get_public_instruments.return_value = {
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"instId": "ETH-USD_UM-260812-2000-C",
|
||||||
|
"state": "live",
|
||||||
|
"expTime": "9999999999999",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
a = m.fetch_option_instruments(ex, "ETH-USD_UM")
|
||||||
|
b = m.fetch_option_instruments(ex, "ETH-USD_UM")
|
||||||
|
self.assertEqual(len(a), 1)
|
||||||
|
self.assertEqual(len(b), 1)
|
||||||
|
self.assertEqual(ex.public_get_public_instruments.call_count, 1)
|
||||||
|
|
||||||
|
@patch("lib.exchange.okx_options_lib.time.sleep", return_value=None)
|
||||||
|
def test_rate_limit_falls_back_to_stale_cache(self, _sleep):
|
||||||
|
ex = MagicMock()
|
||||||
|
ex.public_get_public_instruments.return_value = {
|
||||||
|
"data": [{"instId": "ETH-USD_UM-260812-2000-C", "state": "live"}]
|
||||||
|
}
|
||||||
|
first = m.fetch_option_instruments(ex, "ETH-USD_UM")
|
||||||
|
self.assertEqual(len(first), 1)
|
||||||
|
# 过期 TTL,但仍在 stale 窗口
|
||||||
|
with m._INSTRUMENTS_CACHE_LOCK:
|
||||||
|
m._INSTRUMENTS_CACHE["ETH-USD_UM"]["updated_at"] = time.time() - 120
|
||||||
|
ex.public_get_public_instruments.side_effect = Exception(
|
||||||
|
'okx {"msg":"Too Many Requests","code":"50011"}'
|
||||||
|
)
|
||||||
|
second = m.fetch_option_instruments(ex, "ETH-USD_UM")
|
||||||
|
self.assertEqual(len(second), 1)
|
||||||
|
self.assertEqual(second[0]["instId"], "ETH-USD_UM-260812-2000-C")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
"""options_quote_live_lib 单元测试."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
from lib.options.options_quote_live_lib import OptionsQuoteLive
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeWs:
|
||||||
|
connected = True
|
||||||
|
last_msg_at = 0.0
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def set_subscriptions(self, args) -> None:
|
||||||
|
self.last_args = list(args)
|
||||||
|
|
||||||
|
|
||||||
|
def test_ticker_patch_and_flush():
|
||||||
|
live = OptionsQuoteLive()
|
||||||
|
live._ws = _FakeWs() # type: ignore[assignment]
|
||||||
|
live._started = True
|
||||||
|
live.watch(
|
||||||
|
underlying="ETH",
|
||||||
|
exp_time="1",
|
||||||
|
contracts=[{"inst_id": "ETH-USD-260811-2500-C", "opt_type": "C", "strike": 2500}],
|
||||||
|
index_inst_id="ETH-USD",
|
||||||
|
)
|
||||||
|
live._on_ws_data(
|
||||||
|
{
|
||||||
|
"arg": {"channel": "tickers", "instId": "ETH-USD-260811-2500-C"},
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"instId": "ETH-USD-260811-2500-C",
|
||||||
|
"askPx": "12.5",
|
||||||
|
"askSz": "3",
|
||||||
|
"bidPx": "11.0",
|
||||||
|
"bidSz": "2",
|
||||||
|
"markPx": "12.0",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
live._on_ws_data(
|
||||||
|
{
|
||||||
|
"arg": {"channel": "index-tickers", "instId": "ETH-USD"},
|
||||||
|
"data": [{"idxPx": "2600"}],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
raw = live._build_flush_event()
|
||||||
|
assert raw is not None
|
||||||
|
payload = json.loads(raw)
|
||||||
|
assert payload["index_px"] == 2600.0
|
||||||
|
assert payload["quotes"]
|
||||||
|
q = next(x for x in payload["quotes"] if x["inst_id"] == "ETH-USD-260811-2500-C")
|
||||||
|
assert q["ask"] == 12.5
|
||||||
|
assert q["ask_sz"] == 3.0
|
||||||
|
assert q["expiry_be_px"] == 2512.5
|
||||||
|
st = live.status()
|
||||||
|
assert st["watch_count"] == 1
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
"""期权目标位委托单元测试."""
|
"""期权目标委托单元测试(盈亏比 + 旧指数兼容)."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import sqlite3
|
import sqlite3
|
||||||
@@ -8,6 +8,7 @@ from lib.options.options_target_lib import (
|
|||||||
ensure_target_tables,
|
ensure_target_tables,
|
||||||
list_active_targets,
|
list_active_targets,
|
||||||
list_closing_targets,
|
list_closing_targets,
|
||||||
|
profit_rr_hit,
|
||||||
run_options_target_closes,
|
run_options_target_closes,
|
||||||
target_hit,
|
target_hit,
|
||||||
upsert_target_monitor,
|
upsert_target_monitor,
|
||||||
@@ -21,7 +22,67 @@ class OptionsTargetLibTests(unittest.TestCase):
|
|||||||
self.assertTrue(target_hit(opt_type="P", index_px=1800, target_index=1850))
|
self.assertTrue(target_hit(opt_type="P", index_px=1800, target_index=1850))
|
||||||
self.assertFalse(target_hit(opt_type="P", index_px=1900, target_index=1850))
|
self.assertFalse(target_hit(opt_type="P", index_px=1900, target_index=1850))
|
||||||
|
|
||||||
def test_upsert_and_trigger_close(self):
|
def test_profit_rr_hit(self):
|
||||||
|
# premium=10, rr=2 → need pnl≥20 → recycle≥30 → bid*sheets*ct ≥30
|
||||||
|
self.assertTrue(
|
||||||
|
profit_rr_hit(premium=10, bid=30, sheets=1, ct_mult=1, profit_rr=2)
|
||||||
|
)
|
||||||
|
self.assertFalse(
|
||||||
|
profit_rr_hit(premium=10, bid=29.9, sheets=1, ct_mult=1, profit_rr=2)
|
||||||
|
)
|
||||||
|
self.assertFalse(
|
||||||
|
profit_rr_hit(premium=10, bid=None, sheets=1, ct_mult=1, profit_rr=2)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_upsert_rr_and_trigger_close(self):
|
||||||
|
conn = sqlite3.connect(":memory:")
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
ensure_target_tables(conn)
|
||||||
|
out = upsert_target_monitor(
|
||||||
|
conn,
|
||||||
|
inst_id="ETH-USD_UM-260717-1900-C",
|
||||||
|
profit_rr=2,
|
||||||
|
opt_type="C",
|
||||||
|
sheets=1,
|
||||||
|
)
|
||||||
|
self.assertTrue(out["ok"])
|
||||||
|
self.assertEqual(out.get("profit_rr"), 2.0)
|
||||||
|
self.assertEqual(len(list_active_targets(conn)), 1)
|
||||||
|
|
||||||
|
closed = []
|
||||||
|
|
||||||
|
def close_fn(inst_id: str):
|
||||||
|
closed.append(inst_id)
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"submitted_sheets": 1,
|
||||||
|
"premium_received": 30.0,
|
||||||
|
"close_ord_id": "oid1",
|
||||||
|
"fully_closed": True,
|
||||||
|
"remaining_sheets": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
# bid=30, ct=1 → pnl=20 ≥ 2*10; premium 来自持仓字段
|
||||||
|
n = run_options_target_closes(
|
||||||
|
conn,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"inst_id": "ETH-USD_UM-260717-1900-C",
|
||||||
|
"idx_px": 1885,
|
||||||
|
"opt_type": "C",
|
||||||
|
"pos": 1,
|
||||||
|
"ct_mult": 1,
|
||||||
|
"premium_paid": 10,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
close_fn=close_fn,
|
||||||
|
bid_fn=lambda _i: 30.0,
|
||||||
|
)
|
||||||
|
self.assertEqual(n, 1)
|
||||||
|
self.assertEqual(closed, ["ETH-USD_UM-260717-1900-C"])
|
||||||
|
self.assertEqual(len(list_active_targets(conn)), 0)
|
||||||
|
|
||||||
|
def test_upsert_and_trigger_close_legacy_index(self):
|
||||||
conn = sqlite3.connect(":memory:")
|
conn = sqlite3.connect(":memory:")
|
||||||
conn.row_factory = sqlite3.Row
|
conn.row_factory = sqlite3.Row
|
||||||
ensure_target_tables(conn)
|
ensure_target_tables(conn)
|
||||||
@@ -107,7 +168,6 @@ class OptionsTargetLibTests(unittest.TestCase):
|
|||||||
self.assertEqual(len(list_active_targets(conn)), 0)
|
self.assertEqual(len(list_active_targets(conn)), 0)
|
||||||
self.assertEqual(len(list_closing_targets(conn)), 1)
|
self.assertEqual(len(list_closing_targets(conn)), 1)
|
||||||
|
|
||||||
# 模拟后续 sync 异常也不会再推:closing 重试静默
|
|
||||||
n2 = run_options_target_closes(
|
n2 = run_options_target_closes(
|
||||||
conn,
|
conn,
|
||||||
pos,
|
pos,
|
||||||
@@ -120,7 +180,6 @@ class OptionsTargetLibTests(unittest.TestCase):
|
|||||||
self.assertEqual(len(list_closing_targets(conn)), 0)
|
self.assertEqual(len(list_closing_targets(conn)), 0)
|
||||||
|
|
||||||
def test_commit_before_wechat_survives_later_rollback(self):
|
def test_commit_before_wechat_survives_later_rollback(self):
|
||||||
"""状态在推送前已 commit,外层异常回滚不应让委托回到 active."""
|
|
||||||
conn = sqlite3.connect(":memory:")
|
conn = sqlite3.connect(":memory:")
|
||||||
conn.row_factory = sqlite3.Row
|
conn.row_factory = sqlite3.Row
|
||||||
ensure_target_tables(conn)
|
ensure_target_tables(conn)
|
||||||
@@ -149,12 +208,10 @@ class OptionsTargetLibTests(unittest.TestCase):
|
|||||||
close_fn=close_fn,
|
close_fn=close_fn,
|
||||||
send_wechat=notices.append,
|
send_wechat=notices.append,
|
||||||
)
|
)
|
||||||
# 模拟 loop 后续 sync 抛错后 close 未再 commit —— 但 status 已提前 commit
|
|
||||||
conn.rollback()
|
conn.rollback()
|
||||||
self.assertEqual(len(notices), 1)
|
self.assertEqual(len(notices), 1)
|
||||||
self.assertEqual(len(list_active_targets(conn)), 0)
|
self.assertEqual(len(list_active_targets(conn)), 0)
|
||||||
|
|
||||||
# 下一轮不应再次触发推送
|
|
||||||
n2 = run_options_target_closes(
|
n2 = run_options_target_closes(
|
||||||
conn,
|
conn,
|
||||||
[{"inst_id": "ETH-USD_UM-260715-1870-P", "idx_px": 1860, "opt_type": "P"}],
|
[{"inst_id": "ETH-USD_UM-260715-1870-P", "idx_px": 1860, "opt_type": "P"}],
|
||||||
|
|||||||
Reference in New Issue
Block a user