13 Commits

Author SHA1 Message Date
dekun 0acba5e351 移除全部对冲计划模块:crypto_okx 改为币本位单独期权执行机。
交易模式锁定 options;okx_trade_mode / 到期盈亏迁入 options;保留期权/桥/关键位/企微与中控预留快照口径。恢复点:backup/before-remove-hedge 与 tag backup-before-remove-hedge-20260930。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-30 23:20:21 +08:00
dekun 8a62cfbb48 修复比特币期权浮盈把币数量当成 U:看板/中控改为按指数折 USDT。
币本位 BTC/ETH 净盈亏须 × 指数后再标 U;并兜底从合约推断 premium_ccy,避免缺失时漏折算。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-30 22:01:41 +08:00
dekun 41c85c36a9 澄清翻倍出场文案:按当前倍数显示需回收公式,避免误读成按1倍算。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-30 21:59:25 +08:00
dekun 06f9b23f44 修复翻倍出场达标却不自动平:对冲异常不再整段跳过,并按买一价×全仓判定。
原先对冲表异常会直接 return 0 导致翻倍出场永不执行;可回收改为买一价×全部张数,买一深度不足时仍会触发分批平仓。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-30 21:54:00 +08:00
dekun a03c208a5f 修复模拟盘期权历史分批平仓盈亏:用卖平成交重算已平记录。
加载历史前按 sim_option_orders(或钱包流水)合计各批收回,纠正只记最后一笔的旧数据。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-24 21:25:31 +08:00
dekun a7015ec5c8 修复期权分批平仓只记最后一笔收回导致盈亏漏算。
每次部分成交累加权利金与张数,全平后再合计 realized_pnl,并优先匹配交易所全平历史。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-24 21:13:05 +08:00
dekun f5104fe68b 修复翻倍/目标位平仓把备注写进平仓时间:纠正 SQL 参数顺序并自动对调历史坏数据。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-16 05:48:42 +08:00
dekun c5cfbb34e4 README 增加新机纯实盘部署文档入口。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-15 12:28:31 +08:00
dekun ea58a4778e 新增新机纯实盘部署文档,并与部署/使用说明互相链接。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-15 12:28:16 +08:00
dekun d16821c7b7 模拟盘无 API Key 时复盘K线改为优先走 OKX 公开行情接口。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-15 12:16:06 +08:00
dekun d6bb99b5bb 移植期权复盘自动K线、时区修复与保存后轮询展示,与今日 crypto_monitor 改动对齐。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-15 11:51:41 +08:00
dekun 45c7e73db5 Hide coin/USDC options env fields by margin mode.
Also toggle close-gate and compound subfields so the settings page only shows relevant controls.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 09:36:13 +08:00
dekun 37f787e6a9 Make options target close gate configurable via env.
Support premium multiplier (coin default 1.05) or net-PnL USDT threshold with index conversion.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 09:00:39 +08:00
61 changed files with 13196 additions and 21171 deletions
+7
View File
@@ -150,6 +150,13 @@ OKX_OPTIONS_TD_MODE=isolated
OKX_OPTIONS_ALLOW_MARKET_CLOSE=false OKX_OPTIONS_ALLOW_MARKET_CLOSE=false
# 对冲买期权等成交超时(秒);超时撤未成交部分,未完全成交则开仓失败 # 对冲买期权等成交超时(秒);超时撤未成交部分,未完全成交则开仓失败
OKX_OPTIONS_OPEN_FILL_TIMEOUT_SEC=12 OKX_OPTIONS_OPEN_FILL_TIMEOUT_SEC=12
# 目标平仓门控(目标位触发后按买一平仓前需满足)
OKX_OPTIONS_CLOSE_GATE_MODE=premium
# premium:可回收≥权利金×倍数;net_pnl:净盈亏(买一可回收−权利金)折USDT≥下限
OKX_OPTIONS_CLOSE_RECYCLE_MULT=2
OKX_OPTIONS_CLOSE_RECYCLE_MULT_COIN=1.05
OKX_OPTIONS_CLOSE_NET_PNL_MIN_USDT=0
OKX_OPTIONS_CLOSE_HOLD_SECONDS=120
# ============================================================================= # =============================================================================
# 对冲计划(仅 OKX;由 OKX_TRADE_MODE 控制是否启用;详见 docs/对冲计划开发方案.md) # 对冲计划(仅 OKX;由 OKX_TRADE_MODE 控制是否启用;详见 docs/对冲计划开发方案.md)
+1 -1
View File
@@ -20,7 +20,7 @@ curl -fsSL https://git.bz121.com/dekun/crypto_okx/raw/branch/main/deploy/manage.
安装到 `/opt/crypto_okx`,PM2 进程名 `crypto_okx`,默认端口 **5004**. 安装到 `/opt/crypto_okx`,PM2 进程名 `crypto_okx`,默认端口 **5004**.
详见 **[部署文档.md](./部署文档.md)**、**[deploy/README.md](./deploy/README.md)**. 详见 **[部署文档.md](./部署文档.md)**、**[新机纯实盘部署.md](./新机纯实盘部署.md)**、**[deploy/README.md](./deploy/README.md)**.
## 环境要求 ## 环境要求
+7547 -7454
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+95 -18
View File
@@ -351,7 +351,7 @@
body.appendChild(panelsWrap); body.appendChild(panelsWrap);
body.dataset.envModeSectionIdx = String(modeSectionIdx); body.dataset.envModeSectionIdx = String(modeSectionIdx);
bindTradeModeAutoRefresh(body); bindTradeModeAutoRefresh(body);
bindCompoundBudgetVisibility(body); bindOptionsSectionVisibility(body);
return body; return body;
} }
@@ -363,27 +363,104 @@
return input ? input.closest(".env-field-row") : null; return input ? input.closest(".env-field-row") : null;
} }
function syncCompoundBudgetVisibility(body) { function envFieldValue(body, key, fallback) {
const input = body && body.querySelector('.env-field-input[data-env-key="' + key + '"]');
if (!input) return fallback;
return String(input.value || fallback || "").trim();
}
function envTruthy(v) {
return ["1", "true", "yes", "on"].indexOf(String(v || "").toLowerCase()) >= 0;
}
function setEnvRowHidden(row, hide) {
if (!row) return;
row.hidden = !!hide;
row.style.display = hide ? "none" : "";
}
var COIN_ONLY_ENV_KEYS = [
"OKX_OPTIONS_COIN_COMPOUND",
"OKX_OPTIONS_COIN_BUDGET_USDT",
"OKX_OPTIONS_COIN_MAX_USDT_ENABLED",
"OKX_OPTIONS_COIN_MAX_USDT",
"OKX_OPTIONS_COIN_SPOT_BUY_BUFFER",
"OKX_OPTIONS_CLOSE_RECYCLE_MULT_COIN",
];
var USDC_ONLY_ENV_KEYS = [
"OKX_OPTIONS_TRADE_BUDGET_USDC",
"OKX_OPTIONS_COMPOUND_FULL_ENABLED",
"OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED",
"OKX_OPTIONS_COMPOUND_FULL_CAP_USDC",
"OKX_OPTIONS_CLOSE_RECYCLE_MULT",
];
function syncOptionsSectionVisibility(body) {
if (!body) return; if (!body) return;
const compoundSel = body.querySelector( var margin = (envFieldValue(body, "OKX_OPTIONS_MARGIN_MODE", "coin") || "coin").toLowerCase();
'.env-field-input[data-env-key="OKX_OPTIONS_COMPOUND_FULL_ENABLED"]' var isCoin = margin !== "usdc";
); var gateMode = (envFieldValue(body, "OKX_OPTIONS_CLOSE_GATE_MODE", "premium") || "premium").toLowerCase();
const budgetRow = envFieldRowByKey(body, "OKX_OPTIONS_TRADE_BUDGET_USDC"); var compoundOn = envTruthy(envFieldValue(body, "OKX_OPTIONS_COMPOUND_FULL_ENABLED", "true"));
if (!budgetRow) return; var coinCompoundOn = envTruthy(envFieldValue(body, "OKX_OPTIONS_COIN_COMPOUND", "true"));
const compoundOn = !compoundSel || String(compoundSel.value || "").toLowerCase() === "true"; var coinMaxOn = envTruthy(envFieldValue(body, "OKX_OPTIONS_COIN_MAX_USDT_ENABLED", "false"));
budgetRow.hidden = compoundOn; var compoundCapOn = envTruthy(envFieldValue(body, "OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED", "false"));
budgetRow.style.display = compoundOn ? "none" : "";
COIN_ONLY_ENV_KEYS.forEach(function (key) {
setEnvRowHidden(envFieldRowByKey(body, key), !isCoin);
});
USDC_ONLY_ENV_KEYS.forEach(function (key) {
setEnvRowHidden(envFieldRowByKey(body, key), isCoin);
});
// 门控子项:按模式显隐(仍受本位过滤)
if (isCoin) {
setEnvRowHidden(envFieldRowByKey(body, "OKX_OPTIONS_CLOSE_RECYCLE_MULT_COIN"), gateMode !== "premium");
} else {
setEnvRowHidden(envFieldRowByKey(body, "OKX_OPTIONS_CLOSE_RECYCLE_MULT"), gateMode !== "premium");
}
setEnvRowHidden(envFieldRowByKey(body, "OKX_OPTIONS_CLOSE_NET_PNL_MIN_USDT"), gateMode !== "net_pnl");
if (!isCoin) {
setEnvRowHidden(envFieldRowByKey(body, "OKX_OPTIONS_TRADE_BUDGET_USDC"), compoundOn);
setEnvRowHidden(envFieldRowByKey(body, "OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED"), !compoundOn);
setEnvRowHidden(
envFieldRowByKey(body, "OKX_OPTIONS_COMPOUND_FULL_CAP_USDC"),
!(compoundOn && compoundCapOn)
);
}
if (isCoin) {
setEnvRowHidden(envFieldRowByKey(body, "OKX_OPTIONS_COIN_BUDGET_USDT"), coinCompoundOn);
setEnvRowHidden(envFieldRowByKey(body, "OKX_OPTIONS_COIN_MAX_USDT"), !coinMaxOn);
}
}
function bindOptionsSectionVisibility(body) {
if (!body) return;
syncOptionsSectionVisibility(body);
var keys = [
"OKX_OPTIONS_MARGIN_MODE",
"OKX_OPTIONS_CLOSE_GATE_MODE",
"OKX_OPTIONS_COMPOUND_FULL_ENABLED",
"OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED",
"OKX_OPTIONS_COIN_COMPOUND",
"OKX_OPTIONS_COIN_MAX_USDT_ENABLED",
];
keys.forEach(function (key) {
var el = body.querySelector('.env-field-input[data-env-key="' + key + '"]');
if (!el || el.dataset.optionsVisBound === "1") return;
el.dataset.optionsVisBound = "1";
el.addEventListener("change", function () {
syncOptionsSectionVisibility(body);
});
});
}
function syncCompoundBudgetVisibility(body) {
syncOptionsSectionVisibility(body);
} }
function bindCompoundBudgetVisibility(body) { function bindCompoundBudgetVisibility(body) {
if (!body) return; bindOptionsSectionVisibility(body);
syncCompoundBudgetVisibility(body);
const compoundSel = body.querySelector(
'.env-field-input[data-env-key="OKX_OPTIONS_COMPOUND_FULL_ENABLED"]'
);
if (!compoundSel || compoundSel.dataset.compoundBudgetBound === "1") return;
compoundSel.dataset.compoundBudgetBound = "1";
compoundSel.addEventListener("change", () => syncCompoundBudgetVisibility(body));
} }
function bindTradeModeAutoRefresh(body) { function bindTradeModeAutoRefresh(body) {
+18 -2
View File
@@ -832,7 +832,7 @@
const gate = preview.close_gate || {}; const gate = preview.close_gate || {};
// 2× 只是目标平仓门控,本身不会自动平;手动买一平不拦截 // 2× 只是目标平仓门控,本身不会自动平;手动买一平不拦截
if (preview.close_gate_blocked || (gate.ready === false && !gate.passed)) { if (preview.close_gate_blocked || (gate.ready === false && !gate.passed)) {
return "目标门控: " + (preview.close_gate_msg || gate.msg || "可回收需≥2×权利金并持续2分钟"); return "目标门控: " + (preview.close_gate_msg || gate.msg || "目标平仓门控未过");
} }
return ""; return "";
} }
@@ -1674,6 +1674,21 @@
return fmt(n, 2) + "倍"; return fmt(n, 2) + "倍";
} }
function profitExitHint(mult, req, premCcy) {
const n = Number(mult);
const m = Number.isFinite(n) && n > 0 ? n : 1;
const needFactor = 1 + m;
const factorTxt = Math.abs(needFactor - Math.round(needFactor)) < 1e-9
? String(Math.round(needFactor))
: fmt(needFactor, 2);
let hint = formatProfitExitMultLabel(m) + "=盈利达权利金×" + fmt(m, 2)
+ " → 需回收≥权利金×" + factorTxt;
if (req != null) {
hint += " · 即≥" + fmtPremiumAmt(req, premCcy) + " " + premCcy;
}
return hint;
}
function renderProfitExitRow(p) { function renderProfitExitRow(p) {
const inst = p.inst_id || ""; const inst = p.inst_id || "";
if (p.hedge_plan_target) { if (p.hedge_plan_target) {
@@ -1694,6 +1709,7 @@
const statePe = String(p.profit_exit_state || (enabled ? "active" : "idle")); const statePe = String(p.profit_exit_state || (enabled ? "active" : "idle"));
const req = p.profit_exit_required_recycle; const req = p.profit_exit_required_recycle;
const premCcy = posPremiumCcy(p); const premCcy = posPremiumCcy(p);
const hintMult = Number.isFinite(multNum) && multNum > 0 ? multNum : serverMult;
let statusTxt = enabled ? ("监控中 · " + multLabel) : "未开启"; let statusTxt = enabled ? ("监控中 · " + multLabel) : "未开启";
if (enabled && statePe === "closing") statusTxt = "平仓挂单中 · " + multLabel; if (enabled && statePe === "closing") statusTxt = "平仓挂单中 · " + multLabel;
return ( return (
@@ -1710,7 +1726,7 @@
'<span class="opt-target-armed">' + statusTxt + "</span>" + '<span class="opt-target-armed">' + statusTxt + "</span>" +
'<span class="muted opt-target-row-hint">' + '<span class="muted opt-target-row-hint">' +
(enabled (enabled
? ("1倍=盈利=权利金" + (req != null ? (" · 需回收≥" + fmtPremiumAmt(req, premCcy) + " " + premCcy) : "")) ? profitExitHint(hintMult, req, premCcy)
: "开启后自选倍数;达标按买一限价平;可随时关闭") + : "开启后自选倍数;达标按买一限价平;可随时关闭") +
"</span>" + "</span>" +
"</div>" "</div>"
+1 -1
View File
@@ -118,7 +118,7 @@
} }
const gate = preview.close_gate || {}; const gate = preview.close_gate || {};
if (preview.close_gate_blocked || (gate.ready === false && !gate.passed)) { if (preview.close_gate_blocked || (gate.ready === false && !gate.passed)) {
return "目标门控: " + (preview.close_gate_msg || gate.msg || "可回收需≥2×权利金并持续2分钟"); return "目标门控: " + (preview.close_gate_msg || gate.msg || "目标平仓门控未过");
} }
return ""; return "";
} }
+139 -17
View File
@@ -582,31 +582,103 @@
} }
} }
var detailChartPollTimer = null;
var detailChartPollTradeId = 0;
function hideDetail() { function hideDetail() {
hideLightbox(); hideLightbox();
stopDetailChartPoll();
var backdrop = $("or-detail-backdrop"); var backdrop = $("or-detail-backdrop");
if (backdrop) backdrop.hidden = true; if (backdrop) backdrop.hidden = true;
} }
function openDetail(tradeId) { function stopDetailChartPoll() {
if (detailChartPollTimer) {
clearTimeout(detailChartPollTimer);
detailChartPollTimer = null;
}
detailChartPollTradeId = 0;
}
function tradeHasAutoChart(t) {
var images = ((t && t.entry) || {}).images || [];
for (var i = 0; i < images.length; i++) {
var tf = String((images[i] && images[i].tf) || "").trim();
var file = String((images[i] && images[i].file) || "").trim();
if (tf === "auto" && file) return true;
}
return false;
}
function openDetail(tradeId, opts) {
opts = opts || {};
var waitAuto = !!opts.waitAutoChart;
var backdrop = $("or-detail-backdrop"); var backdrop = $("or-detail-backdrop");
if (!backdrop) return; if (!backdrop) return;
stopDetailChartPoll();
backdrop.hidden = false; backdrop.hidden = false;
($("or-detail-title") || {}).textContent = "加载中…"; ($("or-detail-title") || {}).textContent = "加载中…";
($("or-detail-meta") || {}).innerHTML = ""; ($("or-detail-meta") || {}).innerHTML = "";
($("or-detail-text") || {}).innerHTML = ""; ($("or-detail-text") || {}).innerHTML = "";
($("or-detail-images") || {}).innerHTML = ""; ($("or-detail-images") || {}).innerHTML = waitAuto
? '<div class="or-detail-img-cell"><span class="or-detail-img-label">自动K线</span><div class="or-detail-img-miss">生成中…</div></div>'
: "";
fetch("/api/options/review/trades/" + tradeId, { credentials: "same-origin" }) function applyTrade(trade, pendingAuto) {
.then(function (r) { renderDetail(trade, { pendingAuto: !!pendingAuto });
}
function fetchOnce() {
return fetch("/api/options/review/trades/" + tradeId, {
credentials: "same-origin",
}).then(function (r) {
return r.json(); return r.json();
}) });
}
fetchOnce()
.then(function (data) { .then(function (data) {
if (!data.ok || !data.trade) { if (!data.ok || !data.trade) {
($("or-detail-title") || {}).textContent = "加载失败"; ($("or-detail-title") || {}).textContent = "加载失败";
return; return;
} }
renderDetail(data.trade); if (!waitAuto || tradeHasAutoChart(data.trade)) {
applyTrade(data.trade, false);
return;
}
applyTrade(data.trade, true);
detailChartPollTradeId = tradeId;
var tries = 0;
var maxTries = 20;
function poll() {
if (detailChartPollTradeId !== tradeId) return;
tries += 1;
fetchOnce()
.then(function (again) {
if (detailChartPollTradeId !== tradeId) return;
if (again.ok && again.trade && tradeHasAutoChart(again.trade)) {
stopDetailChartPoll();
applyTrade(again.trade, false);
loadReviewed({ sync: false, soft: true });
return;
}
if (tries >= maxTries) {
stopDetailChartPoll();
applyTrade((again && again.trade) || data.trade, false);
return;
}
detailChartPollTimer = setTimeout(poll, 1500);
})
.catch(function () {
if (detailChartPollTradeId !== tradeId) return;
if (tries >= maxTries) {
stopDetailChartPoll();
return;
}
detailChartPollTimer = setTimeout(poll, 1500);
});
}
detailChartPollTimer = setTimeout(poll, 1200);
}) })
.catch(function () { .catch(function () {
($("or-detail-title") || {}).textContent = "加载失败"; ($("or-detail-title") || {}).textContent = "加载失败";
@@ -626,9 +698,29 @@
return base + encodeURIComponent(name); return base + encodeURIComponent(name);
} }
function renderDetailImages(images) { function chartTfLabel(tf) {
if (tf === "auto") return "自动K线";
if (tf === "_") return "截图";
return tf;
}
function setChartOptionsVisible(visible) {
var box = $("or-chart-options");
var hint = $("or-chart-hint");
if (box) box.classList.toggle("hidden", !visible);
if (hint) hint.classList.toggle("hidden", !visible);
}
function resetChartOptions() {
var cb = $("or-exchange-chart");
if (cb) cb.checked = false;
}
function renderDetailImages(images, opts) {
var imagesHost = $("or-detail-images"); var imagesHost = $("or-detail-images");
if (!imagesHost) return; if (!imagesHost) return;
opts = opts || {};
var pendingAuto = !!opts.pendingAuto;
var byTf = {}; var byTf = {};
(images || []).forEach(function (img) { (images || []).forEach(function (img) {
var tf = String((img && img.tf) || "").trim(); var tf = String((img && img.tf) || "").trim();
@@ -637,16 +729,29 @@
var key = tf || "_"; var key = tf || "_";
byTf[key] = file; byTf[key] = file;
}); });
var order = ["5m", "15m", "1h", "4h"]; // 互斥:有自动K线只显示自动;否则只显示手动四周期
var hasAuto = !!byTf.auto;
if (pendingAuto && !hasAuto) {
imagesHost.innerHTML =
'<div class="or-detail-img-cell">' +
'<span class="or-detail-img-label">自动K线</span>' +
'<div class="or-detail-img-miss">生成中…</div>' +
"</div>";
return;
}
var order = hasAuto ? ["auto"] : ["5m", "15m", "1h", "4h"];
var keys = order.slice(); var keys = order.slice();
Object.keys(byTf).forEach(function (k) { if (!hasAuto) {
if (keys.indexOf(k) < 0) keys.push(k); Object.keys(byTf).forEach(function (k) {
}); if (k === "auto") return;
if (keys.indexOf(k) < 0) keys.push(k);
});
}
var cells = keys var cells = keys
.map(function (tf) { .map(function (tf) {
var file = byTf[tf]; var file = byTf[tf];
if (!file) { if (!file) {
if (order.indexOf(tf) < 0) return ""; if (hasAuto || order.indexOf(tf) < 0) return "";
return ( return (
'<div class="or-detail-img-cell">' + '<div class="or-detail-img-cell">' +
'<span class="or-detail-img-label">' + '<span class="or-detail-img-label">' +
@@ -657,7 +762,7 @@
); );
} }
var src = optionsJournalImgSrc(file); var src = optionsJournalImgSrc(file);
var label = escapeHtml(tf === "_" ? "截图" : tf); var label = escapeHtml(chartTfLabel(tf));
return ( return (
'<div class="or-detail-img-cell">' + '<div class="or-detail-img-cell">' +
'<span class="or-detail-img-label">' + '<span class="or-detail-img-label">' +
@@ -698,7 +803,7 @@
}); });
} }
function renderDetail(t) { function renderDetail(t, opts) {
var e = t.entry || {}; var e = t.entry || {};
reviewedCache[t.id] = t; reviewedCache[t.id] = t;
($("or-detail-title") || {}).textContent = ($("or-detail-title") || {}).textContent =
@@ -782,7 +887,7 @@
var imagesHost = $("or-detail-images"); var imagesHost = $("or-detail-images");
if (imagesHost) { if (imagesHost) {
renderDetailImages(e.images || []); renderDetailImages(e.images || [], opts || {});
} }
} }
@@ -992,6 +1097,8 @@
($("or-f-note") || {}).value = ""; ($("or-f-note") || {}).value = "";
setMoodTags(""); setMoodTags("");
resetUploadSlots(); resetUploadSlots();
resetChartOptions();
setChartOptionsVisible(false);
var summary = $("or-journal-summary"); var summary = $("or-journal-summary");
if (summary) { if (summary) {
summary.textContent = "截图槽位与合约复盘相同(5m / 15m / 1h / 4h)."; summary.textContent = "截图槽位与合约复盘相同(5m / 15m / 1h / 4h).";
@@ -1053,6 +1160,8 @@
setSelectValue($("or-f-entry"), e.entry_logic || ""); setSelectValue($("or-f-entry"), e.entry_logic || "");
($("or-f-note") || {}).value = e.note || ""; ($("or-f-note") || {}).value = e.note || "";
setMoodTags(e.mistake_tags); setMoodTags(e.mistake_tags);
resetChartOptions();
setChartOptionsVisible(t.source_type === "option_spot");
var summary = $("or-journal-summary"); var summary = $("or-journal-summary");
if (summary) { if (summary) {
@@ -1068,6 +1177,8 @@
} }
(e.images || []).forEach(function (img) { (e.images || []).forEach(function (img) {
var tf = String((img && img.tf) || "").trim();
if (tf === "auto") return;
var hidden = document.querySelector( var hidden = document.querySelector(
'#or-upload-slots .or-upload-hidden[data-tf="' + img.tf + '"]' '#or-upload-slots .or-upload-hidden[data-tf="' + img.tf + '"]'
); );
@@ -1167,6 +1278,15 @@
note: ($("or-f-note") || {}).value || "", note: ($("or-f-note") || {}).value || "",
images: collectImages(), images: collectImages(),
}; };
var chartBox = $("or-chart-options");
var chartVisible = chartBox && !chartBox.classList.contains("hidden");
if (chartVisible && ($("or-exchange-chart") || {}).checked) {
payload.journal_exchange_chart = true;
payload.journal_chart_tf1 = ($("or-chart-tf1") || {}).value || "";
payload.journal_chart_tf2 = ($("or-chart-tf2") || {}).value || "";
payload.journal_chart_limit = ($("or-chart-limit") || {}).value || "";
payload.journal_chart_anchor = ($("or-chart-anchor") || {}).value || "close";
}
($("or-save-status") || {}).textContent = "保存中…"; ($("or-save-status") || {}).textContent = "保存中…";
fetch("/api/options/review/entry", { fetch("/api/options/review/entry", {
method: "POST", method: "POST",
@@ -1182,10 +1302,12 @@
($("or-save-status") || {}).textContent = data.msg || "保存失败"; ($("or-save-status") || {}).textContent = data.msg || "保存失败";
return; return;
} }
($("or-save-status") || {}).textContent = "已保存"; ($("or-save-status") || {}).textContent = data.chart_queued
? "已保存,K线图后台生成中"
: "已保存";
hideJournalForm(); hideJournalForm();
reloadAll(); reloadAll();
openDetail(tradeId); openDetail(tradeId, { waitAutoChart: !!data.chart_queued });
}) })
.catch(function () { .catch(function () {
($("or-save-status") || {}).textContent = "保存失败"; ($("or-save-status") || {}).textContent = "保存失败";
+9
View File
@@ -106,6 +106,11 @@ HOT_RELOAD_EXACT = frozenset({
"OKX_OPTIONS_COIN_MAX_USDT_ENABLED", "OKX_OPTIONS_COIN_MAX_USDT_ENABLED",
"OKX_OPTIONS_COIN_MAX_USDT", "OKX_OPTIONS_COIN_MAX_USDT",
"OKX_OPTIONS_COIN_SPOT_BUY_BUFFER", "OKX_OPTIONS_COIN_SPOT_BUY_BUFFER",
"OKX_OPTIONS_CLOSE_GATE_MODE",
"OKX_OPTIONS_CLOSE_RECYCLE_MULT",
"OKX_OPTIONS_CLOSE_RECYCLE_MULT_COIN",
"OKX_OPTIONS_CLOSE_NET_PNL_MIN_USDT",
"OKX_OPTIONS_CLOSE_HOLD_SECONDS",
"OKX_TRADE_MODE", "OKX_TRADE_MODE",
"MAX_ACTIVE_HEDGE_PLANS", "MAX_ACTIVE_HEDGE_PLANS",
"HEDGE_PLAN_LIVE_ORDER", "HEDGE_PLAN_LIVE_ORDER",
@@ -171,6 +176,10 @@ SELECT_OPTIONS: dict[str, tuple[tuple[str, str], ...]] = {
("coin", "币本位(USDT买币桥)"), ("coin", "币本位(USDT买币桥)"),
("usdc", "USDC(USDⓈ权利金)"), ("usdc", "USDC(USDⓈ权利金)"),
), ),
"OKX_OPTIONS_CLOSE_GATE_MODE": (
("premium", "权利金倍数"),
("net_pnl", "净盈亏(USDT)"),
),
"HEDGE_PLAN_OPTION_PRIMARY": ( "HEDGE_PLAN_OPTION_PRIMARY": (
("true", "以期权为主"), ("true", "以期权为主"),
("false", "保险模式"), ("false", "保险模式"),
+99 -14
View File
@@ -150,6 +150,31 @@ _OPTIONS_SECTION: dict[str, Any] = {
"链上仅显示有卖一", "链上仅显示有卖一",
"默认 true;开启后隐藏无卖一深度或深度不足1张的合约(含标记价估算行)", "默认 true;开启后隐藏无卖一深度或深度不足1张的合约(含标记价估算行)",
), ),
(
"OKX_OPTIONS_CLOSE_GATE_MODE",
"目标平仓门控模式",
"premium=可回收≥权利金×倍数(币本位默认×1.05);net_pnl=净盈亏折USDT≥阈值",
),
(
"OKX_OPTIONS_CLOSE_RECYCLE_MULT",
"门控权利金倍数(USDC)",
"仅 premium 模式;USDC 权利金仓默认 2",
),
(
"OKX_OPTIONS_CLOSE_RECYCLE_MULT_COIN",
"门控权利金倍数(币本位)",
"仅 premium 模式;ETH/BTC 权利金仓默认 1.05",
),
(
"OKX_OPTIONS_CLOSE_NET_PNL_MIN_USDT",
"门控净盈亏下限(U)",
"仅 net_pnl 模式;买一可回收−权利金折 USDT 后需≥此值;0 或 1 等",
),
(
"OKX_OPTIONS_CLOSE_HOLD_SECONDS",
"门控持续秒数",
"条件达标后需持续满足的秒数,默认 120",
),
], ],
} }
@@ -259,6 +284,11 @@ _RUNTIME_ENV_DEFAULTS: dict[str, str] = {
"OKX_OPTIONS_MAX_DTE_DAYS": "2", "OKX_OPTIONS_MAX_DTE_DAYS": "2",
"OKX_OPTIONS_MAX_ACTIVE_POSITIONS": "0", "OKX_OPTIONS_MAX_ACTIVE_POSITIONS": "0",
"OKX_OPTIONS_MARGIN_MODE": "coin", "OKX_OPTIONS_MARGIN_MODE": "coin",
"OKX_OPTIONS_CLOSE_GATE_MODE": "premium",
"OKX_OPTIONS_CLOSE_RECYCLE_MULT": "2",
"OKX_OPTIONS_CLOSE_RECYCLE_MULT_COIN": "1.05",
"OKX_OPTIONS_CLOSE_NET_PNL_MIN_USDT": "0",
"OKX_OPTIONS_CLOSE_HOLD_SECONDS": "120",
"OKX_TRADE_MODE": "options", "OKX_TRADE_MODE": "options",
"HEDGE_PLAN_ENABLED": "false", "HEDGE_PLAN_ENABLED": "false",
"MAX_ACTIVE_HEDGE_PLANS": "1", "MAX_ACTIVE_HEDGE_PLANS": "1",
@@ -277,11 +307,11 @@ def _effective_env_value(key: str, file_values: dict[str, str], schema_default:
# 展示值必须与运行时 get_okx_trade_mode() 一致,避免未写入时默认 options 静默改模式 # 展示值必须与运行时 get_okx_trade_mode() 一致,避免未写入时默认 options 静默改模式
file_val = str(file_values.get(key) or "").strip() if key in file_values else "" file_val = str(file_values.get(key) or "").strip() if key in file_values else ""
if file_val: if file_val:
from lib.hedge_plan.okx_trade_mode_lib import normalize_okx_trade_mode from lib.options.okx_trade_mode_lib import normalize_okx_trade_mode
return normalize_okx_trade_mode(file_val) or file_val return normalize_okx_trade_mode(file_val) or file_val
try: try:
from lib.hedge_plan.okx_trade_mode_lib import get_okx_trade_mode from lib.options.okx_trade_mode_lib import get_okx_trade_mode
return get_okx_trade_mode() return get_okx_trade_mode()
except Exception: except Exception:
@@ -368,7 +398,7 @@ def _build_field(
def _okx_mode_for_env_ui() -> str: def _okx_mode_for_env_ui() -> str:
try: try:
from lib.hedge_plan.okx_trade_mode_lib import get_okx_trade_mode from lib.options.okx_trade_mode_lib import get_okx_trade_mode
return get_okx_trade_mode() return get_okx_trade_mode()
except Exception: except Exception:
@@ -401,7 +431,7 @@ def ui_sections_for_exchange(
sections.append({"title": "交易所与实盘", "fields": live_fields}) sections.append({"title": "交易所与实盘", "fields": live_fields})
sections.extend(_SHARED_SECTIONS) sections.extend(_SHARED_SECTIONS)
if ex in _MODE_SECTION.get("exchanges", frozenset()): if ex in _MODE_SECTION.get("exchanges", frozenset()):
from lib.hedge_plan.okx_trade_mode_lib import normalize_okx_trade_mode from lib.options.okx_trade_mode_lib import normalize_okx_trade_mode
m = normalize_okx_trade_mode(mode) if mode else "" m = normalize_okx_trade_mode(mode) if mode else ""
if not m: if not m:
@@ -450,7 +480,7 @@ def build_env_ui_payload(
_build_field(key, label, note, schema, values) _build_field(key, label, note, schema, values)
for key, label, note in sec["fields"] for key, label, note in sec["fields"]
] ]
fields = _mark_compound_budget_hidden(fields) fields = _mark_options_section_hidden(fields)
groups.append({ groups.append({
"title": sec["title"], "title": sec["title"],
"fields": fields, "fields": fields,
@@ -459,18 +489,68 @@ def build_env_ui_payload(
return groups return groups
def _mark_compound_budget_hidden(fields: list[dict[str, Any]]) -> list[dict[str, Any]]: # 币本位专属 / USDC 专属(配置页互斥隐藏)
"""全仓复利开启时标记单笔预算为 hidden(供 SSR/前端隐藏;切换开关仍可再显示).""" _COIN_ONLY_ENV_KEYS = frozenset({
compound_on = True "OKX_OPTIONS_COIN_COMPOUND",
"OKX_OPTIONS_COIN_BUDGET_USDT",
"OKX_OPTIONS_COIN_MAX_USDT_ENABLED",
"OKX_OPTIONS_COIN_MAX_USDT",
"OKX_OPTIONS_COIN_SPOT_BUY_BUFFER",
"OKX_OPTIONS_CLOSE_RECYCLE_MULT_COIN",
})
_USDC_ONLY_ENV_KEYS = frozenset({
"OKX_OPTIONS_TRADE_BUDGET_USDC",
"OKX_OPTIONS_COMPOUND_FULL_ENABLED",
"OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED",
"OKX_OPTIONS_COMPOUND_FULL_CAP_USDC",
"OKX_OPTIONS_CLOSE_RECYCLE_MULT",
})
def _field_current(fields: list[dict[str, Any]], key: str, default: str = "") -> str:
for f in fields: for f in fields:
if f.get("key") == "OKX_OPTIONS_COMPOUND_FULL_ENABLED": if f.get("key") == key:
compound_on = _env_truthy(str(f.get("current") or f.get("default") or "true")) return str(f.get("current") or f.get("default") or default).strip()
break return default
if not compound_on:
return fields
def _mark_options_section_hidden(fields: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""按本位/门控模式/全仓复利标记 hidden(供 SSR/前端;切换开关仍可再显示)."""
margin = (_field_current(fields, "OKX_OPTIONS_MARGIN_MODE", "coin") or "coin").lower()
is_coin = margin != "usdc"
gate_mode = (_field_current(fields, "OKX_OPTIONS_CLOSE_GATE_MODE", "premium") or "premium").lower()
compound_on = _env_truthy(_field_current(fields, "OKX_OPTIONS_COMPOUND_FULL_ENABLED", "true"))
coin_compound_on = _env_truthy(_field_current(fields, "OKX_OPTIONS_COIN_COMPOUND", "true"))
coin_max_on = _env_truthy(_field_current(fields, "OKX_OPTIONS_COIN_MAX_USDT_ENABLED", "false"))
compound_cap_on = _env_truthy(
_field_current(fields, "OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED", "false")
)
out: list[dict[str, Any]] = [] out: list[dict[str, Any]] = []
for f in fields: for f in fields:
if f.get("key") == "OKX_OPTIONS_TRADE_BUDGET_USDC": key = str(f.get("key") or "")
hide = False
if is_coin and key in _USDC_ONLY_ENV_KEYS:
hide = True
elif (not is_coin) and key in _COIN_ONLY_ENV_KEYS:
hide = True
elif key == "OKX_OPTIONS_CLOSE_RECYCLE_MULT" and gate_mode != "premium":
hide = True
elif key == "OKX_OPTIONS_CLOSE_RECYCLE_MULT_COIN" and gate_mode != "premium":
hide = True
elif key == "OKX_OPTIONS_CLOSE_NET_PNL_MIN_USDT" and gate_mode != "net_pnl":
hide = True
elif key == "OKX_OPTIONS_TRADE_BUDGET_USDC" and compound_on:
hide = True
elif key == "OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED" and not compound_on:
hide = True
elif key == "OKX_OPTIONS_COMPOUND_FULL_CAP_USDC" and not (compound_on and compound_cap_on):
hide = True
elif key == "OKX_OPTIONS_COIN_BUDGET_USDT" and coin_compound_on:
hide = True
elif key == "OKX_OPTIONS_COIN_MAX_USDT" and not coin_max_on:
hide = True
if hide:
item = dict(f) item = dict(f)
item["hidden"] = True item["hidden"] = True
out.append(item) out.append(item)
@@ -479,6 +559,11 @@ def _mark_compound_budget_hidden(fields: list[dict[str, Any]]) -> list[dict[str,
return out return out
def _mark_compound_budget_hidden(fields: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""兼容旧调用名."""
return _mark_options_section_hidden(fields)
def filter_updates_for_ui(exchange_key: str, updates: dict[str, str]) -> dict[str, str]: def filter_updates_for_ui(exchange_key: str, updates: dict[str, str]) -> dict[str, str]:
allowed = ui_allowed_keys(exchange_key) allowed = ui_allowed_keys(exchange_key)
return {k: v for k, v in (updates or {}).items() if k in allowed} return {k: v for k, v in (updates or {}).items() if k in allowed}
+7 -10
View File
@@ -213,16 +213,10 @@ def option_history_row_key(
def _ms_to_iso(ms: Any) -> str | None: def _ms_to_iso(ms: Any) -> str | None:
val = _safe_float(ms) """OKX ms → 业务时区墙钟(默认 Asia/Shanghai);勿用系统本地时区."""
if val is None or val <= 0: from lib.options.options_time_lib import ms_to_options_app_str
return None
try:
from datetime import datetime, timezone
dt = datetime.fromtimestamp(int(val) / 1000.0, tz=timezone.utc).astimezone() return ms_to_options_app_str(ms)
return dt.strftime("%Y-%m-%d %H:%M:%S")
except (TypeError, ValueError, OSError):
return None
def option_instrument_meta_cached( def option_instrument_meta_cached(
@@ -1601,6 +1595,9 @@ def resolve_option_close_from_history(
u_i, row = item u_i, row = item
c_ms = _safe_float(row.get("cTime")) c_ms = _safe_float(row.get("cTime"))
parts: list[float] = [] parts: list[float] = []
# 全平/强平/ADL 优先于部分平仓(同合约多条时避免只吃到 type=1)
close_type = str(row.get("type") or "").strip()
parts.append(0.0 if close_type in ("2", "3", "6") else 1.0)
# 张数优先:同合约多笔时最稳,且不受本地/交易所时区偏差影响 # 张数优先:同合约多笔时最稳,且不受本地/交易所时区偏差影响
if want_sheets is not None: if want_sheets is not None:
hist_sheets = _safe_float(row.get("closeTotalPos")) hist_sheets = _safe_float(row.get("closeTotalPos"))
@@ -1615,7 +1612,7 @@ def resolve_option_close_from_history(
parts.append(float(abs(int(c_ms) - int(open_ms)))) parts.append(float(abs(int(c_ms) - int(open_ms))))
if close_ms is not None: if close_ms is not None:
parts.append(float(abs(u_i - int(close_ms)))) parts.append(float(abs(u_i - int(close_ms))))
if not parts: if len(parts) <= 1:
parts.append(float(-u_i)) parts.append(float(-u_i))
# 同距时偏向更晚平仓 # 同距时偏向更晚平仓
parts.append(float(-u_i)) parts.append(float(-u_i))
-1
View File
@@ -1 +0,0 @@
# hedge_plan package
@@ -1,86 +0,0 @@
"""对冲计划与单独期权开仓互斥门控.
默认开启:有进行中对冲计划时禁止单独开期权;有纯期权持仓时禁止启动对冲计划.
关闭 HEDGE_PLAN_OPTIONS_MUTUAL_EXCLUSIVE 后两边可同时开.
"""
from __future__ import annotations
import os
from typing import Any, Callable, Optional
def _env_bool(key: str, default: bool = False) -> bool:
v = (os.getenv(key) or "").strip().lower()
if not v:
return default
return v in ("1", "true", "yes", "on")
def mutual_exclusive_enabled() -> bool:
return _env_bool("HEDGE_PLAN_OPTIONS_MUTUAL_EXCLUSIVE", True)
def block_standalone_option_open_msg(conn: Any) -> Optional[str]:
"""若应拦截单独开期权,返回中文原因;否则 None."""
if not mutual_exclusive_enabled():
return None
try:
from lib.hedge_plan.hedge_plan_db import count_active_plans, init_hedge_plan_tables
init_hedge_plan_tables(conn)
if count_active_plans(conn) > 0:
return "存在进行中对冲计划,禁止单独开期权(可在 env「对冲与期权互斥门控」关闭)"
except Exception:
return "互斥门控校验失败,暂禁止单独开期权"
return None
def _pos_nonzero(raw: dict[str, Any]) -> bool:
try:
return abs(float(raw.get("pos") or 0)) > 1e-12
except (TypeError, ValueError):
return False
def has_standalone_option_position(conn: Any, raw_positions: list[dict[str, Any]] | None) -> bool:
"""交易所期权持仓中,是否存在未挂在进行中对冲计划腿上的仓位."""
if not raw_positions:
return False
from lib.instance.instance_dashboard_lib import _resolve_options_source
for p in raw_positions:
if not isinstance(p, dict) or not _pos_nonzero(p):
continue
inst = str(p.get("instId") or p.get("inst_id") or "").strip()
if not inst:
continue
source, _, _ = _resolve_options_source(conn, inst)
if source == "option":
return True
return False
def block_hedge_plan_start_msg(
conn: Any,
*,
fetch_positions: Optional[Callable[[Any], Any]] = None,
exchange: Any = None,
raw_positions: list[dict[str, Any]] | None = None,
) -> Optional[str]:
"""若应拦截启动对冲计划,返回中文原因;否则 None."""
if not mutual_exclusive_enabled():
return None
rows = raw_positions
if rows is None:
if fetch_positions is None or exchange is None:
return None
try:
rows = fetch_positions(exchange) or []
except Exception:
return "获取期权持仓失败,暂禁止启动对冲计划"
try:
if has_standalone_option_position(conn, rows):
return "存在单独期权持仓,禁止启动对冲计划(可在 env「对冲与期权互斥门控」关闭)"
except Exception:
return "互斥门控校验失败,暂禁止启动对冲计划"
return None
-782
View File
@@ -1,782 +0,0 @@
"""对冲计划:情景测算与全仓建议仓(纯函数,无 IO)."""
from __future__ import annotations
from typing import Any, Optional
def _f(v: Any) -> Optional[float]:
if v is None or v == "":
return None
try:
return float(v)
except (TypeError, ValueError):
return None
def perp_coin_amount(*, contracts: float, contract_size: float) -> float:
return float(contracts) * float(contract_size or 1.0)
def perp_pnl(
*,
direction: str,
entry: float,
exit_px: float,
contracts: float,
contract_size: float,
) -> float:
coins = perp_coin_amount(contracts=contracts, contract_size=contract_size)
d = (direction or "long").strip().lower()
if d == "short":
return (float(entry) - float(exit_px)) * coins
return (float(exit_px) - float(entry)) * coins
def option_premium_total(*, ask: float, sheets: float, ct_mult: float) -> float:
"""卖一报价为每 1 币;权利金 = ask × 张数 × ct_mult."""
return float(ask) * float(sheets) * float(ct_mult or 0.01)
def option_expiry_pnl(
*,
opt_type: str,
strike: float,
spot: float,
sheets: float,
ct_mult: float,
premium_paid: float,
) -> float:
o = (opt_type or "").strip().upper()
intrinsic_per_coin = 0.0
if o in ("C", "CALL"):
intrinsic_per_coin = max(0.0, float(spot) - float(strike))
elif o in ("P", "PUT"):
intrinsic_per_coin = max(0.0, float(strike) - float(spot))
else:
return -float(premium_paid)
value = intrinsic_per_coin * float(sheets) * float(ct_mult or 0.01)
return value - float(premium_paid)
def spot_from_expiry_intrinsic_profit(
*,
opt_type: str,
strike: float,
sheets: float,
ct_mult: float,
premium_paid: float,
profit: float,
) -> float | None:
"""按到期实值反推现货价:使该腿到期盈亏 ≈ profit.
到期价值=实值×张数×乘数;盈亏=价值−权利金 → 实值/币=(profit+权利金)/(张数×乘数).
Call: spot=K+实值/币; Put: spot=K−实值/币.
"""
try:
k = float(strike)
n = float(sheets or 0)
ct = float(ct_mult or 0.01)
prem = float(premium_paid or 0)
pnl = float(profit)
except (TypeError, ValueError):
return None
denom = n * ct
if denom <= 0:
return None
need = (pnl + prem) / denom
if need < 0:
need = 0.0
o = (opt_type or "").strip().upper()
if o in ("C", "CALL"):
return round(k + need, 2)
if o in ("P", "PUT"):
return round(k - need, 2)
return None
def suggest_contracts_from_notional(
*,
notional: float,
entry: float,
contract_size: float,
) -> float:
if entry <= 0 or contract_size <= 0 or notional <= 0:
return 0.0
return float(notional) / (float(entry) * float(contract_size))
def floor_contracts_to_precision(contracts: float, decimals: int) -> float:
"""按交易所张数精度向下取整,避免建议张数超过可用保证金."""
import math
raw = float(contracts or 0.0)
if raw <= 0:
return 0.0
try:
d = int(decimals)
except (TypeError, ValueError):
d = 0
if d <= 0:
return float(math.floor(raw + 1e-12))
scale = 10**d
return math.floor(raw * scale + 1e-12) / scale
def option_unit_cost_usdc(*, ask: float, ct_mult: float) -> float:
"""单张权利金(USDC) = 卖一价 × ct_mult."""
a = _f(ask)
if a is None or a <= 0:
return 0.0
return float(a) * float(ct_mult or 0.01)
def resolve_oo_budget_usdc(
*,
trading_usdc: Any,
trade_budget_usdc: Any,
buffer_ratio: Any = 0.95,
) -> dict[str, Any]:
"""期期可用预算 = min(交易户×buffer, 单笔预算)."""
import math
trading = _f(trading_usdc)
cap = _f(trade_budget_usdc)
buf = _f(buffer_ratio)
if buf is None or buf <= 0:
buf = 0.95
if buf > 1:
buf = 1.0
trading_cap = None if trading is None else max(0.0, float(trading) * float(buf))
trade_cap = None if cap is None else max(0.0, float(cap))
if trading_cap is None and trade_cap is None:
return {
"ok": False,
"budget_usdc": 0.0,
"trading_cap": None,
"trade_budget_cap": None,
"buffer_ratio": float(buf),
"msg": "缺少交易户余额与单笔预算",
}
if trading_cap is None:
budget = float(trade_cap or 0.0)
elif trade_cap is None:
budget = float(trading_cap)
else:
budget = min(float(trading_cap), float(trade_cap))
budget = float(math.floor(budget * 1e6 + 1e-12) / 1e6)
return {
"ok": budget > 0,
"budget_usdc": budget,
"trading_cap": None if trading_cap is None else round(float(trading_cap), 6),
"trade_budget_cap": None if trade_cap is None else round(float(trade_cap), 6),
"buffer_ratio": float(buf),
"msg": "" if budget > 0 else "可用预算为 0",
}
def _cap_sheets_by_ask_depth(sheets: int, ask_sz: Any) -> int:
import math
n = max(0, int(sheets))
depth = _f(ask_sz)
if depth is None:
return n
if depth <= 0:
return 0
return min(n, int(math.floor(float(depth) + 1e-12)))
def _normalize_oo_sheets_mode(mode: str) -> str:
m = (mode or "same_sheets").strip().lower()
if m in ("long_bias", "bias_long", "long", "做多"):
return "long_bias"
if m in ("short_bias", "bias_short", "short", "做空"):
return "short_bias"
# 旧「均分」兼容:按预算 50/50(页面已移除)
if m in ("split", "equal_budget", "split_budget", "均分"):
return "split_budget"
return "same_sheets"
def _normalize_oo_bias_split_by(raw: Any) -> str:
v = str(raw or "budget").strip().lower()
if v in ("sheets", "qty", "quantity", "张数"):
return "sheets"
return "budget"
def _clamp_oo_bias_ratio(raw: Any, default: float = 0.7) -> float:
try:
r = float(raw)
except (TypeError, ValueError):
r = float(default)
if r <= 0 or r >= 1:
r = float(default)
return r
def _oo_call_put_leg_index(opt_type_a: str, opt_type_b: str) -> tuple[Optional[str], Optional[str], str]:
"""返回 (call_side, put_side, err);side 为 'a'/'b'."""
a = (opt_type_a or "").strip().upper()
b = (opt_type_b or "").strip().upper()
if a.startswith("C"):
a = "C"
elif a.startswith("P"):
a = "P"
if b.startswith("C"):
b = "C"
elif b.startswith("P"):
b = "P"
if {a, b} != {"C", "P"}:
return None, None, "做多/做空需一腿 Call、一腿 Put"
call_side = "a" if a == "C" else "b"
put_side = "b" if call_side == "a" else "a"
return call_side, put_side, ""
def suggest_oo_sheets(
*,
mode: str,
budget_usdc: float,
ask_a: float,
ct_mult_a: float = 0.01,
ask_sz_a: Any = None,
opt_type_a: str = "",
ask_b: float,
ct_mult_b: float = 0.01,
ask_sz_b: Any = None,
opt_type_b: str = "",
bias_split_by: str = "budget",
bias_ratio: float = 0.7,
) -> dict[str, Any]:
"""期期建议张数:same_sheets / long_bias / short_bias(及旧 split_budget)."""
import math
m = _normalize_oo_sheets_mode(mode)
split_by = _normalize_oo_bias_split_by(bias_split_by)
ratio = _clamp_oo_bias_ratio(bias_ratio)
budget = max(0.0, float(budget_usdc or 0.0))
cost_a = option_unit_cost_usdc(ask=ask_a, ct_mult=ct_mult_a)
cost_b = option_unit_cost_usdc(ask=ask_b, ct_mult=ct_mult_b)
def _fail(msg: str, n_a: int = 0, n_b: int = 0) -> dict[str, Any]:
return {
"mode": m,
"sheets_a": n_a,
"sheets_b": n_b,
"cost_a": round(cost_a, 8),
"cost_b": round(cost_b, 8),
"premium_est": round(cost_a * n_a + cost_b * n_b, 6),
"ok": False,
"msg": msg,
"bias_split_by": split_by,
"bias_ratio": ratio,
}
if budget <= 0:
return _fail("可用预算为 0")
if cost_a <= 0 or cost_b <= 0:
return _fail("缺少有效卖一价,无法建议张数")
pair = cost_a + cost_b
n_pair = int(math.floor(budget / pair + 1e-12)) if pair > 0 else 0
# 与同张数一致:先按预算得 n,再各自深度封顶后取 min
n_same = min(
_cap_sheets_by_ask_depth(n_pair, ask_sz_a),
_cap_sheets_by_ask_depth(n_pair, ask_sz_b),
)
if m == "same_sheets":
n_a = n_same
n_b = n_same
elif m == "split_budget":
half = budget / 2.0
n_a = int(math.floor(half / cost_a + 1e-12))
n_b = int(math.floor(half / cost_b + 1e-12))
n_a = _cap_sheets_by_ask_depth(n_a, ask_sz_a)
n_b = _cap_sheets_by_ask_depth(n_b, ask_sz_b)
else:
call_side, put_side, err = _oo_call_put_leg_index(opt_type_a, opt_type_b)
if err:
return _fail(err)
major_is_call = m == "long_bias"
if split_by == "sheets":
# 总张数 = 同张数两侧合计(每腿 n → 共 2n),再按比例拆到 Call/Put
total = int(n_same) * 2
if total < 2:
return _fail("同张数总规模不足 2,无法按比例拆分")
major_n = int(round(total * ratio))
major_n = max(1, min(major_n, total - 1))
minor_n = total - major_n
n_call = major_n if major_is_call else minor_n
n_put = minor_n if major_is_call else major_n
else:
maj_budget = budget * ratio
min_budget = budget * (1.0 - ratio)
cost_call = cost_a if call_side == "a" else cost_b
cost_put = cost_b if call_side == "a" else cost_a
if major_is_call:
n_call = int(math.floor(maj_budget / cost_call + 1e-12)) if cost_call > 0 else 0
n_put = int(math.floor(min_budget / cost_put + 1e-12)) if cost_put > 0 else 0
else:
n_put = int(math.floor(maj_budget / cost_put + 1e-12)) if cost_put > 0 else 0
n_call = int(math.floor(min_budget / cost_call + 1e-12)) if cost_call > 0 else 0
n_a = n_call if call_side == "a" else n_put
n_b = n_put if call_side == "a" else n_call
n_a = _cap_sheets_by_ask_depth(n_a, ask_sz_a)
n_b = _cap_sheets_by_ask_depth(n_b, ask_sz_b)
prem = cost_a * n_a + cost_b * n_b
ok = n_a >= 1 and n_b >= 1
msg = "" if ok else "预算不够开 1+1(或卖一深度不足)"
return {
"mode": m,
"sheets_a": n_a,
"sheets_b": n_b,
"cost_a": round(cost_a, 8),
"cost_b": round(cost_b, 8),
"premium_est": round(prem, 6),
"ok": ok,
"msg": msg,
"bias_split_by": split_by,
"bias_ratio": ratio,
}
def build_perp_options_preview(
*,
direction: str,
entry: float,
tp: float,
sl: float,
contracts: float,
contract_size: float,
opt_type: str,
strike: float,
sheets: float,
ct_mult: float,
premium_paid: float,
index_px: Optional[float] = None,
) -> dict[str, Any]:
"""
永期情景.
止盈账:永续止盈盈利 - 权利金.
止损账:期权到期内在(按 SL 价) - 永续止损亏损额.
"""
d = (direction or "long").strip().lower()
pnl_tp_perp = perp_pnl(
direction=d, entry=entry, exit_px=tp, contracts=contracts, contract_size=contract_size
)
pnl_sl_perp = perp_pnl(
direction=d, entry=entry, exit_px=sl, contracts=contracts, contract_size=contract_size
)
# 止盈统计口径
tp_total = float(pnl_tp_perp) - float(premium_paid)
# 止损:期权按 SL 价结算内在 - |永续亏损|
opt_at_sl = option_expiry_pnl(
opt_type=opt_type,
strike=strike,
spot=sl,
sheets=sheets,
ct_mult=ct_mult,
premium_paid=premium_paid,
)
sl_total = float(opt_at_sl) - abs(float(pnl_sl_perp)) if pnl_sl_perp < 0 else float(opt_at_sl) + float(
pnl_sl_perp
)
# 有符号相加更稳:期权盈亏 + 永续盈亏
sl_total_signed = float(opt_at_sl) + float(pnl_sl_perp)
spot = float(index_px) if index_px is not None else float(entry)
opt_flat = option_expiry_pnl(
opt_type=opt_type,
strike=strike,
spot=spot,
sheets=sheets,
ct_mult=ct_mult,
premium_paid=premium_paid,
)
flat_total = 0.0 + float(opt_flat)
opt_at_tp = option_expiry_pnl(
opt_type=opt_type,
strike=strike,
spot=tp,
sheets=sheets,
ct_mult=ct_mult,
premium_paid=premium_paid,
)
return {
"plan_type": "perp_options",
"direction": d,
"contracts": contracts,
"coin_amount": perp_coin_amount(contracts=contracts, contract_size=contract_size),
"premium_paid": round(float(premium_paid), 6),
"scenarios": [
{
"id": "tp",
"label": "止盈(计划结束口径)",
"spot": tp,
"perp_pnl": round(pnl_tp_perp, 4),
"options_pnl": round(-float(premium_paid), 4),
"total": round(tp_total, 4),
"note": "止盈盈利 − 权利金;期权可不强平",
},
{
"id": "sl",
"label": "止损(计划结束口径)",
"spot": sl,
"perp_pnl": round(pnl_sl_perp, 4),
"options_pnl": round(opt_at_sl, 4),
"total": round(sl_total_signed, 4),
"note": "期权盈利 − 永续亏损(有符号相加);期权须强平",
},
{
"id": "flat",
"label": "到期·现价附近",
"spot": spot,
"perp_pnl": 0.0,
"options_pnl": round(opt_flat, 4),
"total": round(flat_total, 4),
"note": "示意:永续未动,期权按到期内在",
},
{
"id": "expiry_tp",
"label": "到期·止盈价",
"spot": tp,
"perp_pnl": round(pnl_tp_perp, 4),
"options_pnl": round(opt_at_tp, 4),
"total": round(pnl_tp_perp + opt_at_tp, 4),
"note": "若期权拿到 TP 价到期(参考)",
},
{
"id": "expiry_sl",
"label": "到期·止损价",
"spot": sl,
"perp_pnl": round(pnl_sl_perp, 4),
"options_pnl": round(opt_at_sl, 4),
"total": round(pnl_sl_perp + opt_at_sl, 4),
"note": "与止损口径相近(期权用内在)",
},
],
"summary": {
"tp_total": round(tp_total, 4),
"sl_total": round(sl_total_signed, 4),
"premium_paid": round(float(premium_paid), 4),
"hedge_ratio_at_sl": _hedge_ratio(opt_at_sl, pnl_sl_perp),
},
}
def _hedge_ratio(opt_pnl: float, perp_pnl: float) -> Optional[float]:
loss = abs(float(perp_pnl)) if float(perp_pnl) < 0 else 0.0
if loss <= 1e-12:
return None
if float(opt_pnl) <= 0:
return 0.0
return round(float(opt_pnl) / loss * 100.0, 2)
def build_options_options_preview(
*,
target_price: float | None = None,
target_price_up: float | None = None,
target_price_down: float | None = None,
profit_rr: float | None = None,
index_px: float,
leg_a: dict[str, Any],
leg_b: dict[str, Any],
) -> dict[str, Any]:
"""期期情景:盈亏比达标 / 到期现价 / 最大保费损耗.
新口径优先 profit_rr(盈利金额/总权利金);若未传则兼容旧上/下破目标价.
残值按亏损腿本合约权利金的 20% 计.
"""
def _leg_pnl(leg: dict[str, Any], spot: float) -> float:
return option_expiry_pnl(
opt_type=str(leg.get("opt_type") or ""),
strike=float(leg["strike"]),
spot=spot,
sheets=float(leg.get("sheets") or 0),
ct_mult=float(leg.get("ct_mult") or 0.01),
premium_paid=float(leg.get("premium_paid") or 0),
)
prem_a = float(leg_a.get("premium_paid") or 0)
prem_b = float(leg_b.get("premium_paid") or 0)
prem = prem_a + prem_b
rr = float(profit_rr) if profit_rr is not None else None
# 新:盈亏比情景(不依赖指数上下破价)
if rr is not None and rr > 0:
# 盈利腿达 RR:盈利金额 = rr × 总权利金;亏损腿按全亏 / 本合约残值20%回收
win_profit = rr * prem
a_at_a = win_profit
b_at_a_full = -prem_b
b_at_a_res = -prem_b * 0.8 # 本合约回收 20%
b_at_b = win_profit
a_at_b_full = -prem_a
a_at_b_res = -prem_a * 0.8
spot_a = spot_from_expiry_intrinsic_profit(
opt_type=str(leg_a.get("opt_type") or ""),
strike=float(leg_a["strike"]),
sheets=float(leg_a.get("sheets") or 0),
ct_mult=float(leg_a.get("ct_mult") or 0.01),
premium_paid=prem_a,
profit=win_profit,
)
spot_b = spot_from_expiry_intrinsic_profit(
opt_type=str(leg_b.get("opt_type") or ""),
strike=float(leg_b["strike"]),
sheets=float(leg_b.get("sheets") or 0),
ct_mult=float(leg_b.get("ct_mult") or 0.01),
premium_paid=prem_b,
profit=win_profit,
)
a_flat = _leg_pnl(leg_a, index_px)
b_flat = _leg_pnl(leg_b, index_px)
flat_total = a_flat + b_flat
return {
"plan_type": "options_options",
"premium_paid": round(prem, 6),
"profit_rr": rr,
"target_price": None,
"target_price_up": None,
"target_price_down": None,
"winner_at_up": "a",
"winner_at_down": "b",
"winner_at_target": "a",
"scenarios": [
{
"id": "rr_leg_a_full",
"label": f"腿A达盈亏比{rr:g}(亏腿全损)",
"spot": spot_a,
"leg_a_pnl": round(a_at_a, 4),
"leg_b_pnl": round(b_at_a_full, 4),
"total": round(a_at_a + b_at_a_full, 4),
"note": "现货=到期实值反推;盈利=总权利金×盈亏比;亏腿本合约全亏",
},
{
"id": "rr_leg_b_full",
"label": f"腿B达盈亏比{rr:g}(亏腿全损)",
"spot": spot_b,
"leg_a_pnl": round(a_at_b_full, 4),
"leg_b_pnl": round(b_at_b, 4),
"total": round(a_at_b_full + b_at_b, 4),
"note": "现货=到期实值反推;盈利=总权利金×盈亏比;亏腿本合约全亏",
},
{
"id": "rr_leg_a_residual",
"label": f"腿A达盈亏比{rr:g}(亏腿残值20%)",
"spot": spot_a,
"leg_a_pnl": round(a_at_a, 4),
"leg_b_pnl": round(b_at_a_res, 4),
"total": round(a_at_a + b_at_a_res, 4),
"note": "现货同腿A达标反推;亏腿买一回收约本合约权利金20%",
},
{
"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": "无盈利则记总亏损结束" if flat_total <= 0 else "到期仍可能有净值",
},
{
"id": "max_premium_loss",
"label": "最大保费损耗",
"spot": None,
"leg_a_pnl": round(-prem_a, 4),
"leg_b_pnl": round(-prem_b, 4),
"total": round(-prem, 4),
"note": "双腿权利金全部损失",
},
],
"summary": {
"profit_rr": rr,
"spot_at_rr_a": spot_a,
"spot_at_rr_b": spot_b,
"at_rr_a_full_total": round(a_at_a + b_at_a_full, 4),
"at_rr_b_full_total": round(a_at_b_full + b_at_b, 4),
"at_rr_a_residual_total": round(a_at_a + b_at_a_res, 4),
"at_target_up_total": round(a_at_a + b_at_a_full, 4),
"at_target_down_total": round(a_at_b_full + b_at_b, 4),
"at_target_total": round(a_at_a + b_at_a_full, 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_at_up": round((a_at_a + b_at_a_full) / prem, 4) if prem > 0 else None,
"rr_at_down": round((a_at_b_full + b_at_b) / prem, 4) if prem > 0 else None,
},
}
# 兼容旧单目标:若未传上下目标则用 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
if up is None or down is None:
raise ValueError("缺少盈亏比或上破/下破目标价")
up_f = float(up)
down_f = float(down)
a_up = _leg_pnl(leg_a, up_f)
b_up = _leg_pnl(leg_b, up_f)
at_up = a_up + b_up
win_up = "a" if a_up >= b_up else "b"
a_dn = _leg_pnl(leg_a, down_f)
b_dn = _leg_pnl(leg_b, down_f)
at_dn = a_dn + b_dn
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 {
"plan_type": "options_options",
"premium_paid": round(prem, 6),
"target_price": up_f, # 兼容旧字段,取上破
"target_price_up": up_f,
"target_price_down": down_f,
"winner_at_up": win_up,
"winner_at_down": win_dn,
"winner_at_target": win_up,
"scenarios": [
{
"id": "target_up",
"label": "上破目标",
"spot": up_f,
"leg_a_pnl": round(a_up, 4),
"leg_b_pnl": round(b_up, 4),
"total": round(at_up, 4),
"note": f"盈利方≈腿{win_up.upper()}(可平);亏损方默认到期",
},
{
"id": "target_down",
"label": "下破目标",
"spot": down_f,
"leg_a_pnl": round(a_dn, 4),
"leg_b_pnl": round(b_dn, 4),
"total": round(at_dn, 4),
"note": f"盈利方≈腿{win_dn.upper()}(可平);亏损方默认到期",
},
{
"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": "无盈利则记总亏损结束" if flat_total <= 0 else "到期仍可能有净值",
},
{
"id": "max_premium_loss",
"label": "最大保费损耗",
"spot": None,
"leg_a_pnl": round(-prem_a, 4),
"leg_b_pnl": round(-prem_b, 4),
"total": round(-prem, 4),
"note": "双腿权利金全部损失",
},
],
"summary": {
"at_target_up_total": round(at_up, 4),
"at_target_down_total": round(at_dn, 4),
"at_target_total": round(at_up, 4),
"expiry_flat_total": round(expiry_loss, 4),
"premium_paid": round(prem, 6),
"expiry_is_loss": flat_total <= 0,
# 盈亏比:盈利/全亏保费(风险=权利金全损)
"rr_risk_premium": round(prem, 6),
"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,
},
}
def gate_status(
*,
hedge_enabled: bool,
sizing_mode: str,
plan_type: str,
options_enabled: bool,
live_order: bool = False,
live_trading: bool = False,
active_count: int = 0,
max_active: int = 1,
show_perp_options: bool = True,
show_options_options: bool = True,
mutual_exclusive: bool = True,
has_standalone_option: bool = False,
) -> dict[str, Any]:
from lib.trade.position_sizing_lib import is_full_margin_mode
full = is_full_margin_mode(sizing_mode)
pt = (plan_type or "").strip().lower()
can_preview = True
can_start = True
reasons: list[str] = []
if not hedge_enabled:
can_start = False
reasons.append("对冲计划未启用(HEDGE_PLAN_ENABLED)")
if not options_enabled:
can_preview = False
can_start = False
reasons.append("期权模块未启用")
if pt == "perp_options" and not show_perp_options:
can_preview = False
can_start = False
reasons.append("永期对冲已隐藏(HEDGE_PLAN_SHOW_PERP_OPTIONS)")
if pt == "options_options" and not show_options_options:
can_preview = False
can_start = False
reasons.append("期期对冲已隐藏(HEDGE_PLAN_SHOW_OPTIONS_OPTIONS)")
if not live_order:
can_start = False
reasons.append("未允许对冲真实下单(HEDGE_PLAN_LIVE_ORDER)")
if active_count >= max(1, int(max_active or 1)):
can_start = False
reasons.append(f"活跃计划已达上限({max_active})")
if mutual_exclusive and has_standalone_option:
can_start = False
reasons.append("存在单独期权持仓,禁止启动对冲计划(互斥门控)")
if pt == "perp_options":
if not full:
can_start = False
reasons.append("永期开仓仅全仓模式可用(当前可测算)")
if not live_trading:
can_start = False
reasons.append("未开启实盘(LIVE_TRADING_ENABLED)")
elif pt == "options_options":
pass
else:
can_start = False
reasons.append("未知计划类型")
if can_start:
reasons = []
return {
"hedge_enabled": hedge_enabled,
"options_enabled": options_enabled,
"sizing_mode": sizing_mode,
"is_full_margin": full,
"plan_type": pt,
"live_order": live_order,
"live_trading": live_trading,
"active_count": active_count,
"max_active": max_active,
"show_perp_options": bool(show_perp_options),
"show_options_options": bool(show_options_options),
"mutual_exclusive": bool(mutual_exclusive),
"has_standalone_option": bool(has_standalone_option),
"can_preview": can_preview,
"can_start": can_start,
"reasons": reasons,
}
-468
View File
@@ -1,468 +0,0 @@
"""对冲计划 SQLite 表."""
from __future__ import annotations
import sqlite3
from typing import Any, Optional
def init_hedge_plan_tables(conn: sqlite3.Connection) -> None:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS hedge_plans (
id INTEGER PRIMARY KEY AUTOINCREMENT,
plan_type TEXT NOT NULL,
status TEXT NOT NULL,
underlying TEXT NOT NULL,
direction TEXT,
entry_mark REAL,
tp REAL,
sl REAL,
target_price REAL,
sizing_mode_at_open TEXT,
perp_size REAL,
margin REAL,
leverage REAL,
premium_total REAL,
realized_pnl_perp REAL,
realized_pnl_options REAL,
realized_pnl_total REAL,
stats_bucket TEXT,
close_reason TEXT,
wechat_start_sent INTEGER DEFAULT 0,
wechat_end_sent INTEGER DEFAULT 0,
note TEXT,
preview_json TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
opened_at TIMESTAMP,
closed_at TIMESTAMP
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS hedge_plan_legs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
plan_id INTEGER NOT NULL,
leg_role TEXT NOT NULL,
symbol TEXT,
inst_id TEXT,
opt_type TEXT,
strike REAL,
side TEXT,
size REAL,
avg_open REAL,
premium REAL,
status TEXT,
linked_monitor_id INTEGER,
options_trade_id INTEGER,
exchange_ord_id TEXT,
realized_pnl REAL,
close_reason TEXT,
opened_at TIMESTAMP,
closed_at TIMESTAMP,
FOREIGN KEY(plan_id) REFERENCES hedge_plans(id)
)
"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_hedge_plans_status ON hedge_plans(status)"
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_hedge_plan_legs_plan ON hedge_plan_legs(plan_id)"
)
_ensure_column(conn, "hedge_plans", "target_price_up", "REAL")
_ensure_column(conn, "hedge_plans", "target_price_down", "REAL")
# 期期出场:盈利金额/总权利金(默认2);有值则走盈亏比监控,旧单仍用上/下破价
_ensure_column(conn, "hedge_plans", "profit_rr", "REAL")
# close_all=残值平(本合约权利金≤20%且有买一);hold_expiry=残腿持有至到期
_ensure_column(conn, "hedge_plans", "oo_close_mode", "TEXT")
# 永期「以期权为主」
_ensure_column(conn, "hedge_plans", "option_primary", "INTEGER")
_ensure_column(conn, "hedge_plans", "option_target_points", "REAL")
_ensure_column(conn, "hedge_plans", "perp_target_points", "REAL")
_ensure_column(conn, "hedge_plans", "option_perp_ratio", "REAL")
_ensure_column(conn, "hedge_plans", "premium_budget", "REAL")
_ensure_column(conn, "hedge_plans", "strike_interval", "REAL")
_ensure_column(conn, "hedge_plans", "min_option_hours", "REAL")
_ensure_column(conn, "hedge_plans", "option_moneyness", "TEXT")
_ensure_column(conn, "hedge_plans", "option_leverage", "REAL")
_ensure_column(conn, "hedge_plans", "perp_direction", "TEXT")
_ensure_column(conn, "hedge_plan_legs", "ct_mult", "REAL")
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}")
_ACTIVE_STATUSES = ("opening", "active", "partial", "watching")
def count_active_plans(conn: sqlite3.Connection, plan_type: Optional[str] = None) -> int:
statuses = ",".join(f"'{s}'" for s in _ACTIVE_STATUSES)
if plan_type:
row = conn.execute(
f"SELECT COUNT(1) AS c FROM hedge_plans WHERE status IN ({statuses}) AND plan_type=?",
(plan_type,),
).fetchone()
else:
row = conn.execute(
f"SELECT COUNT(1) AS c FROM hedge_plans WHERE status IN ({statuses})"
).fetchone()
return int((row["c"] if row else 0) or 0)
def insert_plan(conn: sqlite3.Connection, row: dict[str, Any]) -> int:
cols = list(row.keys())
placeholders = ",".join(["?"] * len(cols))
conn.execute(
f"INSERT INTO hedge_plans ({','.join(cols)}) VALUES ({placeholders})",
[row[c] for c in cols],
)
return int(conn.execute("SELECT last_insert_rowid()").fetchone()[0])
def insert_leg(conn: sqlite3.Connection, row: dict[str, Any]) -> int:
cols = list(row.keys())
placeholders = ",".join(["?"] * len(cols))
conn.execute(
f"INSERT INTO hedge_plan_legs ({','.join(cols)}) VALUES ({placeholders})",
[row[c] for c in cols],
)
return int(conn.execute("SELECT last_insert_rowid()").fetchone()[0])
def update_plan(conn: sqlite3.Connection, plan_id: int, **fields: Any) -> None:
if not fields:
return
sets = ", ".join(f"{k}=?" for k in fields)
conn.execute(f"UPDATE hedge_plans SET {sets} WHERE id=?", [*fields.values(), plan_id])
def update_leg(conn: sqlite3.Connection, leg_id: int, **fields: Any) -> None:
if not fields:
return
sets = ", ".join(f"{k}=?" for k in fields)
conn.execute(f"UPDATE hedge_plan_legs SET {sets} WHERE id=?", [*fields.values(), int(leg_id)])
def missing_leg_role(legs: list[dict[str, Any]]) -> Optional[str]:
for leg in legs or []:
if str(leg.get("status") or "").strip().lower() == "pending":
role = str(leg.get("leg_role") or "").strip()
if role:
return role
return None
def list_plans(
conn: sqlite3.Connection,
*,
status: Optional[str] = None,
plan_type: Optional[str] = None,
underlying: Optional[str] = None,
limit: int = 50,
) -> list[dict[str, Any]]:
wheres: list[str] = []
args: list[Any] = []
if status:
wheres.append("status=?")
args.append(status)
if plan_type:
wheres.append("plan_type=?")
args.append(plan_type)
if underlying:
wheres.append("underlying=?")
args.append(underlying)
where = (" WHERE " + " AND ".join(wheres)) if wheres else ""
rows = conn.execute(
f"SELECT * FROM hedge_plans{where} ORDER BY id DESC LIMIT ?",
[*args, int(limit)],
).fetchall()
return [dict(r) for r in rows]
def get_plan(conn: sqlite3.Connection, plan_id: int) -> Optional[dict[str, Any]]:
row = conn.execute("SELECT * FROM hedge_plans WHERE id=?", (plan_id,)).fetchone()
return dict(row) if row else None
def get_plan_legs(conn: sqlite3.Connection, plan_id: int) -> list[dict[str, Any]]:
rows = conn.execute(
"SELECT * FROM hedge_plan_legs WHERE plan_id=? ORDER BY id", (plan_id,)
).fetchall()
return [dict(r) for r in rows]
def delete_plan(conn: sqlite3.Connection, plan_id: int) -> dict[str, Any]:
"""删除已结束/失败/取消的计划及其腿;活跃计划拒绝删除."""
plan = get_plan(conn, int(plan_id))
if not plan:
return {"ok": False, "msg": "计划不存在"}
st = str(plan.get("status") or "")
if st in ("opening", "active", "partial", "watching"):
return {"ok": False, "msg": "进行中的计划不可删除,请先结束"}
conn.execute("DELETE FROM hedge_plan_legs WHERE plan_id=?", (int(plan_id),))
conn.execute("DELETE FROM hedge_plans WHERE id=?", (int(plan_id),))
return {"ok": True, "deleted_id": int(plan_id)}
def legs_contract_summary(legs: list[dict[str, Any]]) -> str:
parts: list[str] = []
for leg in legs:
role = str(leg.get("leg_role") or "")
st = str(leg.get("status") or "").strip().lower()
if st == "pending":
suffix = "(待补)"
elif st in ("cancelled", "canceled"):
suffix = "(未成交)"
else:
suffix = ""
if role == "perp":
name = str(leg.get("symbol") or "永续")
parts.append(f"永续 {name}{suffix}")
else:
inst = str(leg.get("inst_id") or "")
ot = str(leg.get("opt_type") or "").upper()
strike = leg.get("strike")
label = inst or (f"{ot}{strike}" if ot or strike is not None else role)
parts.append(f"{label}{suffix}")
return " · ".join(parts) if parts else "—"
def attach_legs_to_plans(conn: sqlite3.Connection, plans: list[dict[str, Any]]) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
for p in plans:
legs = get_plan_legs(conn, int(p["id"]))
row = dict(p)
row["legs"] = legs
summary = legs_contract_summary(legs)
if str(p.get("status") or "") == "watching" and (not legs or summary == "—"):
money = str(p.get("option_moneyness") or "otm")
money_lab = {"itm": "实/平", "atm": "平值", "otm": "虚值"}.get(money, money)
parts = [f"盯盘·{money_lab}"]
try:
if p.get("strike_interval") not in (None, ""):
parts.append(f"间隔{float(p.get('strike_interval')):g}")
except (TypeError, ValueError):
pass
try:
if p.get("option_leverage") not in (None, ""):
parts.append(f"杠杆≥{float(p.get('option_leverage')):g}")
except (TypeError, ValueError):
pass
summary = "·".join(parts)
row["contracts_summary"] = summary
row["missing_leg"] = missing_leg_role(legs)
out.append(row)
return out
def active_options_targets_by_inst(conn: sqlite3.Connection) -> dict[str, dict[str, Any]]:
"""返回由进行中「期期对冲」托管的期权目标,仅供期权页只读展示。
这些目标由 hedge_plan_monitor_lib 执行,绝不能写入 options_target_monitors,
否则两套监控会同时尝试平掉同一条期权腿。
"""
rows = conn.execute(
"""
SELECT p.id AS plan_id, p.underlying, p.target_price_up, p.target_price_down,
p.profit_rr, l.inst_id, l.opt_type
FROM hedge_plans p
JOIN hedge_plan_legs l ON l.plan_id = p.id
WHERE p.plan_type = 'options_options'
AND p.status IN ('opening', 'active', 'partial')
AND l.status = 'open'
AND l.inst_id IS NOT NULL
AND l.inst_id != ''
ORDER BY p.id DESC, l.id DESC
"""
).fetchall()
out: dict[str, dict[str, Any]] = {}
for raw in rows:
row = dict(raw)
inst_id = str(row.get("inst_id") or "")
opt_type = str(row.get("opt_type") or "").upper()
if not inst_id or inst_id in out:
continue
profit_rr = _sf(row.get("profit_rr"))
if profit_rr is not None and profit_rr > 0:
out[inst_id] = {
"plan_id": int(row["plan_id"]),
"inst_id": inst_id,
"underlying": row.get("underlying"),
"opt_type": opt_type,
"profit_rr": profit_rr,
"target_index": None,
"exit_mode": "profit_rr",
"managed_by": "hedge_plan",
}
continue
target = row.get("target_price_up") if opt_type == "C" else row.get("target_price_down")
target_f = _sf(target)
if target_f is None or target_f <= 0:
continue
out[inst_id] = {
"plan_id": int(row["plan_id"]),
"inst_id": inst_id,
"underlying": row.get("underlying"),
"opt_type": opt_type,
"target_index": target_f,
"plan_type": "options_options",
"managed_by": "hedge_plan",
}
return out
def active_hedge_option_inst_ids(conn: sqlite3.Connection) -> set[str]:
"""进行中对冲计划托管的期权合约,禁止单独期权页 close/target 拆组."""
rows = conn.execute(
"""
SELECT DISTINCT l.inst_id
FROM hedge_plan_legs l
JOIN hedge_plans p ON p.id = l.plan_id
WHERE p.status IN ('opening', 'active', 'partial')
AND l.status IN ('open', 'hold_to_expiry')
AND l.inst_id IS NOT NULL
AND TRIM(l.inst_id) != ''
AND (
l.leg_role LIKE 'option%'
OR (l.opt_type IS NOT NULL AND TRIM(l.opt_type) != '')
)
"""
).fetchall()
return {str(r[0]).strip() for r in rows if r and r[0]}
def _sf(v: Any) -> Optional[float]:
try:
if v is None or v == "":
return None
return float(v)
except (TypeError, ValueError):
return None
def _metrics_from_pnls(rows: list[dict[str, Any]]) -> dict[str, Any]:
"""对一组已结束计划计算胜率/盈亏比/最大盈亏/最大回撤."""
pnls: list[float] = []
timed: list[tuple[str, float]] = []
for r in rows:
pnl = _sf(r.get("realized_pnl_total"))
if pnl is None:
continue
pnls.append(pnl)
t = str(r.get("closed_at") or r.get("opened_at") or r.get("created_at") or "")
timed.append((t, pnl))
n = len(pnls)
if n == 0:
return {
"count": 0,
"wins": 0,
"losses": 0,
"win_rate": None,
"net_pnl": 0.0,
"avg_pnl": None,
"avg_premium": None,
"profit_factor": None,
"max_profit": None,
"max_loss": None,
"max_drawdown": None,
}
wins = [x for x in pnls if x > 0]
losses = [x for x in pnls if x < 0]
gross_win = sum(wins)
gross_loss = abs(sum(losses))
if gross_loss > 0:
profit_factor = round(gross_win / gross_loss, 4)
elif gross_win > 0:
profit_factor = None # 全胜,标无限
else:
profit_factor = 0.0
timed.sort(key=lambda x: x[0] or "")
cum = 0.0
peak = 0.0
mdd = 0.0
for _, p in timed:
cum += p
if cum > peak:
peak = cum
dd = peak - cum
if dd > mdd:
mdd = dd
premiums = [_sf(r.get("premium_total")) for r in rows]
premiums_f = [x for x in premiums if x is not None]
return {
"count": n,
"wins": len(wins),
"losses": len(losses),
"win_rate": round(len(wins) / n, 4),
"net_pnl": round(sum(pnls), 4),
"avg_pnl": round(sum(pnls) / n, 4),
"avg_premium": round(sum(premiums_f) / len(premiums_f), 4) if premiums_f else None,
"profit_factor": profit_factor,
"profit_factor_infinite": bool(gross_loss <= 0 and gross_win > 0),
"max_profit": round(max(pnls), 4),
"max_loss": round(min(pnls), 4),
"max_drawdown": round(mdd, 4),
}
def stats_summary(conn: sqlite3.Connection) -> dict[str, Any]:
reason_rows = conn.execute(
"""
SELECT plan_type, close_reason, COUNT(1) AS n,
COALESCE(SUM(realized_pnl_total), 0) AS pnl
FROM hedge_plans
WHERE status='closed'
GROUP BY plan_type, close_reason
"""
).fetchall()
closed_rows = [
dict(r)
for r in conn.execute(
"SELECT * FROM hedge_plans WHERE status='closed' ORDER BY COALESCE(closed_at, opened_at, created_at), id"
).fetchall()
]
active = count_active_plans(conn)
overall = _metrics_from_pnls(closed_rows)
by_type = {
"perp_options": _metrics_from_pnls(
[r for r in closed_rows if r.get("plan_type") == "perp_options"]
),
"options_options": _metrics_from_pnls(
[r for r in closed_rows if r.get("plan_type") == "options_options"]
),
}
# 永期止盈/止损分桶
po = [r for r in closed_rows if r.get("plan_type") == "perp_options"]
by_type["perp_options"]["buckets"] = {
"tp": _metrics_from_pnls([r for r in po if r.get("close_reason") == "perp_tp"]),
"sl": _metrics_from_pnls([r for r in po if r.get("close_reason") == "perp_sl"]),
}
oo = [r for r in closed_rows if r.get("plan_type") == "options_options"]
by_type["options_options"]["buckets"] = {
"expiry_loss": _metrics_from_pnls(
[r for r in oo if r.get("close_reason") == "oo_expiry_loss"]
),
"expiry_win": _metrics_from_pnls(
[r for r in oo if r.get("close_reason") == "oo_expiry_win"]
),
}
return {
"active": active,
"closed_count": overall["count"],
"closed_pnl_total": overall["net_pnl"],
"overall": overall,
"by_type": by_type,
"by_reason": [dict(r) for r in reason_rows],
}
-285
View File
@@ -1,285 +0,0 @@
"""对冲计划虚实值选约与校验.
永期(perp_options):期权腿仅允许实值或平值(禁虚值).
期期(options_options):两腿仅允许平值或虚值(禁实值).
"""
from __future__ import annotations
import os
from typing import Any, Optional
def _env_float(name: str, default: float) -> float:
try:
return float(os.getenv(name) or default)
except (TypeError, ValueError):
return float(default)
def itm_max_dist_usd() -> float:
"""过深实值上限(USD).优先对冲专用,否则回退期权页."""
raw = (os.getenv("HEDGE_PLAN_ITM_MAX_DIST_USD") or "").strip()
if raw:
try:
return max(0.0, float(raw))
except ValueError:
pass
return max(0.0, _env_float("OKX_OPTIONS_ITM_MAX_DIST_USD", 30.0))
def min_option_hours() -> float:
return max(0.0, _env_float("HEDGE_PLAN_MIN_OPTION_HOURS", 8.0))
def min_option_leverage() -> float:
"""指数/卖一 最低杠杆门槛;0=不启用."""
return max(0.0, _env_float("HEDGE_PLAN_MIN_OPTION_LEVERAGE", 0.0))
def _sf(v: Any) -> Optional[float]:
if v is None or v == "":
return None
try:
return float(v)
except (TypeError, ValueError):
return None
def normalize_opt_type(opt_type: Any, inst_id: str = "") -> str:
o = str(opt_type or "").strip().upper()
if o in ("C", "CALL"):
return "C"
if o in ("P", "PUT"):
return "P"
inst = str(inst_id or "").upper()
if inst.endswith("-C") or inst.endswith("-CALL"):
return "C"
if inst.endswith("-P") or inst.endswith("-PUT"):
return "P"
return ""
def classify_moneyness(*, opt_type: str, strike: float, index_px: float) -> str:
"""itm / atm / otm / unknown.与 options_pricing_lib.option_moneyness 同口径."""
from lib.options.options_pricing_lib import option_moneyness
return option_moneyness(opt_type=opt_type, strike=strike, index_px=index_px)
def is_itm_or_atm(*, opt_type: str, strike: float, index_px: float) -> bool:
"""Call: K<=S(+atm 带);Put: K>=S.用 classify 结果含 atm/itm."""
m = classify_moneyness(opt_type=opt_type, strike=strike, index_px=index_px)
if m in ("itm", "atm"):
return True
# 几何兜底(与 eth_hedge_sim 一致),避免 atm 带边界漏判
o = normalize_opt_type(opt_type)
k = float(strike)
s = float(index_px)
if o == "C":
return k <= s + 1e-9
if o == "P":
return k >= s - 1e-9
return False
def is_atm_or_otm(*, opt_type: str, strike: float, index_px: float) -> bool:
m = classify_moneyness(opt_type=opt_type, strike=strike, index_px=index_px)
if m in ("atm", "otm"):
return True
o = normalize_opt_type(opt_type)
k = float(strike)
s = float(index_px)
if o == "C":
return k >= s - 1e-9 # 平值带内或虚值
if o == "P":
return k <= s + 1e-9
return False
def itm_depth_usd(*, opt_type: str, strike: float, index_px: float) -> float:
o = normalize_opt_type(opt_type)
k = float(strike)
s = float(index_px)
if o == "C" and k < s:
return s - k
if o == "P" and k > s:
return k - s
return 0.0
def parse_strike_from_inst(inst_id: str) -> Optional[float]:
"""从 OKX 合约名解析行权价: ETH-USD-260731-1800-P."""
parts = str(inst_id or "").strip().upper().split("-")
if len(parts) < 5:
return None
return _sf(parts[-2])
def pick_itm_or_atm_contract(
contracts: list[dict[str, Any]],
*,
opt_type: str,
index_px: float,
itm_max_dist: Optional[float] = None,
) -> Optional[dict[str, Any]]:
"""在合约列表中选距标的最近的实值/平值腿."""
want = normalize_opt_type(opt_type)
if not want or index_px <= 0:
return None
max_dist = itm_max_dist if itm_max_dist is not None else itm_max_dist_usd()
cands: list[tuple[float, float, dict[str, Any]]] = []
for c in contracts or []:
if normalize_opt_type(c.get("opt_type"), str(c.get("inst_id") or "")) != want:
continue
k = _sf(c.get("strike"))
if k is None:
continue
if not is_itm_or_atm(opt_type=want, strike=k, index_px=index_px):
continue
depth = itm_depth_usd(opt_type=want, strike=k, index_px=index_px)
if max_dist > 0 and depth > max_dist:
continue
cands.append((abs(k - index_px), k, c))
if not cands:
return None
cands.sort(key=lambda x: (x[0], x[1]))
return cands[0][2]
def pick_atm_or_otm_contract(
contracts: list[dict[str, Any]],
*,
opt_type: str,
index_px: float,
prefer: str = "atm",
) -> Optional[dict[str, Any]]:
"""选平值或虚值腿.prefer=atm 取距标的最近;prefer=otm 取最近虚值(不含实值)."""
want = normalize_opt_type(opt_type)
if not want or index_px <= 0:
return None
prefer_l = (prefer or "atm").strip().lower()
cands: list[tuple[float, float, dict[str, Any]]] = []
for c in contracts or []:
if normalize_opt_type(c.get("opt_type"), str(c.get("inst_id") or "")) != want:
continue
k = _sf(c.get("strike"))
if k is None:
continue
if not is_atm_or_otm(opt_type=want, strike=k, index_px=index_px):
continue
m = classify_moneyness(opt_type=want, strike=k, index_px=index_px)
if prefer_l == "otm" and m != "otm":
continue
if prefer_l == "atm" and m == "otm":
# 仍可入选,但排序靠后(先 atm)
cands.append((1_000_000 + abs(k - index_px), k, c))
else:
cands.append((abs(k - index_px), k, c))
if not cands:
return None
cands.sort(key=lambda x: (x[0], x[1]))
return cands[0][2]
def recommend_oo_legs(
contracts: list[dict[str, Any]],
*,
index_px: float,
template: str = "atm_straddle",
) -> Optional[tuple[dict[str, Any], dict[str, Any]]]:
"""期期推荐两腿.atm_straddle=最近平值 Call+Put;double_otm=最近虚值 Call+Put."""
tpl = (template or "atm_straddle").strip().lower()
prefer = "otm" if tpl in ("double_otm", "otm_otm", "otm") else "atm"
call = pick_atm_or_otm_contract(
contracts, opt_type="C", index_px=index_px, prefer=prefer
)
put = pick_atm_or_otm_contract(
contracts, opt_type="P", index_px=index_px, prefer=prefer
)
if not call or not put:
return None
if str(call.get("inst_id") or "") == str(put.get("inst_id") or ""):
return None
return call, put
def validate_po_option_moneyness(
*,
opt_type: str,
strike: Any,
index_px: Any,
ask: Any = None,
hours_to_expiry: Any = None,
) -> Optional[str]:
"""永期保险腿校验;返回错误文案或 None."""
o = normalize_opt_type(opt_type)
k = _sf(strike)
s = _sf(index_px)
if o not in ("C", "P"):
return "期权类型无效"
if k is None or s is None or s <= 0:
return "行权价或指数无效,无法校验虚实值"
if not is_itm_or_atm(opt_type=o, strike=k, index_px=s):
return "永期保险腿须为实值或平值,不可选虚值"
max_dist = itm_max_dist_usd()
depth = itm_depth_usd(opt_type=o, strike=k, index_px=s)
if max_dist > 0 and depth > max_dist:
return f"实值过深(距现价 {depth:.1f}U > {max_dist:.0f}U),请换更接近平值的档"
min_h = min_option_hours()
h = _sf(hours_to_expiry)
if min_h > 0 and h is not None and h < min_h:
return f"剩余到期约 {h:.1f}h,低于最低 {min_h:.0f}h"
min_lev = min_option_leverage()
a = _sf(ask)
if min_lev > 0 and a is not None and a > 0:
lev = s / a
if lev < min_lev:
return f"期权杠杆 S/ask≈{lev:.0f} 低于门槛 {min_lev:.0f}"
return None
def validate_oo_leg_moneyness(
*,
opt_type: str,
strike: Any,
index_px: Any,
role: str = "腿",
) -> Optional[str]:
o = normalize_opt_type(opt_type)
k = _sf(strike)
s = _sf(index_px)
if o not in ("C", "P"):
return f"{role}期权类型无效"
if k is None or s is None or s <= 0:
return f"{role}行权价或指数无效,无法校验虚实值"
m = classify_moneyness(opt_type=o, strike=k, index_px=s)
if m == "itm":
return f"{role}须为平值或虚值,不可选实值"
if not is_atm_or_otm(opt_type=o, strike=k, index_px=s):
return f"{role}须为平值或虚值"
return None
def validate_oo_legs_moneyness(
leg_a: dict[str, Any],
leg_b: dict[str, Any],
*,
index_px: Any,
) -> Optional[str]:
err = validate_oo_leg_moneyness(
opt_type=leg_a.get("opt_type"),
strike=leg_a.get("strike"),
index_px=index_px,
role="腿A",
)
if err:
return err
err = validate_oo_leg_moneyness(
opt_type=leg_b.get("opt_type"),
strike=leg_b.get("strike"),
index_px=index_px,
role="腿B",
)
if err:
return err
return None
File diff suppressed because it is too large Load Diff
-216
View File
@@ -1,216 +0,0 @@
"""对冲计划企业微信推送(起止必发,幂等落库标记)."""
from __future__ import annotations
from typing import Any, Callable, Optional
from lib.hedge_plan.hedge_plan_db import update_plan
def _fmt(v: Any, d: int = 2) -> str:
try:
if v is None or v == "":
return "—"
return f"{float(v):.{d}f}"
except (TypeError, ValueError):
return str(v)
def _type_label(plan_type: str) -> str:
return "永期对冲" if (plan_type or "") == "perp_options" else "期期对冲"
def _dir_label(direction: str) -> str:
d = (direction or "").lower()
if d == "long":
return "做多"
if d == "short":
return "做空"
return "—"
def build_hedge_start_message(plan: dict[str, Any], *, legs: Optional[list[dict[str, Any]]] = None) -> str:
pt = plan.get("plan_type") or ""
lines = [
f"🟢 对冲计划启动 #{plan.get('id')}",
f"📌 类型:{_type_label(pt)}",
f"🪙 标的:{plan.get('underlying') or '—'}",
]
if pt == "perp_options":
lines.extend(
[
f"📈 方向:{_dir_label(plan.get('direction') or '')}",
f"💵 开仓参考:{_fmt(plan.get('entry_mark'))}",
f"🎯 止盈:{_fmt(plan.get('tp'))}|止损:{_fmt(plan.get('sl'))}",
f"📦 永续张数:{_fmt(plan.get('perp_size'), 4)}|杠杆:{_fmt(plan.get('leverage'), 0)}x",
f"💎 期权保费合计:{_fmt(plan.get('premium_total'), 4)} USDC",
]
)
else:
rr = plan.get("profit_rr")
if rr not in (None, ""):
lines.extend(
[
f"🎯 盈亏比:{_fmt(rr)} (盈利金额/总权利金)",
f"💎 期权保费合计:{_fmt(plan.get('premium_total'), 4)} USDC",
]
)
else:
lines.extend(
[
f"🎯 上破:{_fmt(plan.get('target_price_up') or plan.get('target_price'))}"
f"|下破:{_fmt(plan.get('target_price_down') or plan.get('target_price'))}",
f"💎 期权保费合计:{_fmt(plan.get('premium_total'), 4)} USDC",
]
)
if legs:
for leg in legs:
role = leg.get("leg_role") or ""
if role == "perp":
lines.append(f"· 永续腿 {leg.get('symbol') or ''} ×{_fmt(leg.get('size'), 4)}")
else:
lines.append(
f"· {role} {(leg.get('opt_type') or '')} K{_fmt(leg.get('strike'), 0)} "
f"×{_fmt(leg.get('size'), 0)}张 {leg.get('inst_id') or ''}"
)
lines.append("📎 独立模块推送,不进普通交易复盘")
return "\n".join(lines)
def build_hedge_end_message(plan: dict[str, Any]) -> str:
reason = plan.get("close_reason") or "—"
total = plan.get("realized_pnl_total")
try:
tv = float(total) if total is not None else None
except (TypeError, ValueError):
tv = None
head = "🔴" if (tv is not None and tv < 0) else "🟢"
reason_map = {
"perp_tp": "永续止盈(期权默认不平)",
"perp_sl": "永续止损(期权强制平)",
"target_win_leg": "期期已平盈利腿(中间态)",
"target_up_win_leg": "期期上破·已平盈利腿",
"target_down_win_leg": "期期下破·已平盈利腿",
"profit_rr_win_leg": "期期盈亏比达标·已平盈利腿",
"oo_rest_closing": "期期残值平·清亏损腿中",
"oo_rest_closed": "期期残值平·两腿已平",
"oo_expiry_loss": "期期到期无盈利·总亏损",
"oo_expiry_win": "期期到期仍盈利",
"expiry": "到期收口",
"manual": "人工结束",
"partial_fail": "半腿失败收尾",
"cancelled": "已取消",
}
lines = [
f"{head} 对冲计划结束 #{plan.get('id')}",
f"📌 类型:{_type_label(plan.get('plan_type') or '')}",
f"🪙 标的:{plan.get('underlying') or '—'}",
f"📎 原因:{reason_map.get(reason, reason)}",
f"💰 合计≈U:{_fmt(total)}",
f"· 永续分项:{_fmt(plan.get('realized_pnl_perp'))} USDT",
f"· 期权分项:{_fmt(plan.get('realized_pnl_options'))} USDC(≈U 1:1)",
f"⏱ 开仓:{plan.get('opened_at') or '—'}|结束:{plan.get('closed_at') or '—'}",
]
return "\n".join(lines)
def build_hedge_alert_message(
*,
title: str,
plan_id: Any = None,
detail: str = "",
) -> str:
lines = [f"⚠️ 对冲计划告警{(' #' + str(plan_id)) if plan_id else ''}", f"📌 {title}"]
if detail:
lines.append(str(detail)[:800])
return "\n".join(lines)
def notify_hedge(
cfg: dict[str, Any],
content: str,
) -> bool:
send: Optional[Callable[[str], Any]] = cfg.get("send_wechat")
if not callable(send):
return False
try:
send(content)
return True
except Exception:
return False
def notify_plan_start(
cfg: dict[str, Any],
conn: Any,
plan: dict[str, Any],
legs: Optional[list[dict[str, Any]]] = None,
) -> bool:
if int(plan.get("wechat_start_sent") or 0):
return False
ok = notify_hedge(cfg, build_hedge_start_message(plan, legs=legs))
if ok and plan.get("id") is not None:
update_plan(conn, int(plan["id"]), wechat_start_sent=1)
plan["wechat_start_sent"] = 1
return ok
def notify_plan_end(cfg: dict[str, Any], conn: Any, plan: dict[str, Any]) -> bool:
if int(plan.get("wechat_end_sent") or 0):
return False
# 中间态 target_win_leg 不算正式结束推送(用告警)
if (plan.get("close_reason") or "") in (
"target_win_leg",
"target_up_win_leg",
"target_down_win_leg",
"profit_rr_win_leg",
"oo_rest_closing",
) and (plan.get("status") or "") != "closed":
cr = str(plan.get("close_reason") or "")
if "profit_rr" in cr:
side = "盈亏比达标"
elif "up" in cr:
side = "上破"
elif "down" in cr:
side = "下破"
else:
side = "目标"
mode = (plan.get("oo_close_mode") or "").strip().lower()
if mode in ("close_all", "全平", "残值平"):
rest_txt = "另一腿残值平(本合约权利金≤20%且有买一,失败重试)"
else:
rest_txt = "另一腿到期平(持有至到期结算)"
rr = plan.get("profit_rr")
if rr not in (None, ""):
detail = f"盈亏比 {_fmt(rr)} (盈利金额/总权利金)"
else:
detail = (
f"上破 {_fmt(plan.get('target_price_up') or plan.get('target_price'))}"
f"|下破 {_fmt(plan.get('target_price_down') or plan.get('target_price'))}"
)
notify_hedge(
cfg,
build_hedge_alert_message(
title=f"期期{side}已平盈利腿 · {rest_txt}",
plan_id=plan.get("id"),
detail=detail,
),
)
return True
ok = notify_hedge(cfg, build_hedge_end_message(plan))
if ok and plan.get("id") is not None:
update_plan(conn, int(plan["id"]), wechat_end_sent=1)
plan["wechat_end_sent"] = 1
return ok
def notify_partial_fail(cfg: dict[str, Any], *, plan_type: str, msg: str, results: Any = None) -> bool:
detail = msg
if results:
try:
detail = f"{msg}\n路径结果:{results}"[:800]
except Exception:
pass
return notify_hedge(
cfg,
build_hedge_alert_message(title=f"{_type_label(plan_type)}半腿失败", detail=detail),
)
@@ -1,527 +0,0 @@
"""永期「以期权为主」:定仓、方向映射、目标位与净利口径(纯函数为主)."""
from __future__ import annotations
import math
import os
from typing import Any, Optional
PREMIUM_EXEC_FACTOR = 0.95
DEFAULT_MIN_HOURS = 36.0
DEFAULT_STRIKE_INTERVAL = 15.0
DEFAULT_PERP_LEVERAGE = 100
DEFAULT_OPT_LEVERAGE_ITM_ATM = 100.0
DEFAULT_OPT_LEVERAGE_OTM = 200.0
DEFAULT_RATIO_ITM_ATM = 2.0
DEFAULT_RATIO_OTM = 4.0
OTM_LEV_FLOOR = 180.0
def _sf(v: Any) -> Optional[float]:
if v is None or v == "":
return None
try:
return float(v)
except (TypeError, ValueError):
return None
def is_option_primary(body_or_plan: dict[str, Any] | None) -> bool:
if not body_or_plan:
return False
v = body_or_plan.get("option_primary")
if v in (True, 1, "1", "true", "yes", "on"):
return True
try:
return int(v or 0) == 1
except (TypeError, ValueError):
return False
def fee_rate() -> float:
try:
return max(0.0, float(os.getenv("HEDGE_PLAN_FEE_RATE") or os.getenv("OKX_TAKER_FEE") or "0.0005"))
except (TypeError, ValueError):
return 0.0005
def floor2(v: float) -> float:
"""ETH 数量向下取两位小数."""
if v <= 0:
return 0.0
return math.floor(float(v) * 100.0 + 1e-12) / 100.0
def opt_type_for_view(direction: str) -> str:
"""看法做多→Call,做空→Put."""
return "P" if str(direction or "").strip().lower() == "short" else "C"
def perp_direction_for_view(direction: str) -> str:
"""看法做多→永续空,做空→永续多."""
return "long" if str(direction or "").strip().lower() == "short" else "short"
def default_opt_leverage(moneyness: str) -> float:
m = (moneyness or "").strip().lower()
return DEFAULT_OPT_LEVERAGE_OTM if m == "otm" else DEFAULT_OPT_LEVERAGE_ITM_ATM
def default_ratio(moneyness: str) -> float:
m = (moneyness or "").strip().lower()
return DEFAULT_RATIO_OTM if m == "otm" else DEFAULT_RATIO_ITM_ATM
def effective_min_opt_leverage(moneyness: str, configured: Any) -> float:
cfg = _sf(configured)
base = cfg if cfg is not None and cfg > 0 else default_opt_leverage(moneyness)
if (moneyness or "").strip().lower() == "otm":
return max(base, OTM_LEV_FLOOR)
return base
def hours_to_expiry_from_ms(exp_ms: Any, *, now_ms: Optional[float] = None) -> Optional[float]:
exp = _sf(exp_ms)
if exp is None or exp <= 0:
return None
# OKX exp 多为毫秒
if exp < 1e12:
exp *= 1000.0
now = now_ms if now_ms is not None else __import__("time").time() * 1000.0
return (exp - now) / 3600000.0
def target_hit(*, view_side: str, index_px: float, strike: float, points: float) -> bool:
"""相对 K 的点数目标:做多 index≥K+N;做空 index≤K−N.点数须 >0."""
n = float(points or 0)
k = float(strike)
s = float(index_px)
if n <= 0 or k <= 0 or s <= 0:
return False
side = str(view_side or "").strip().lower()
if side == "short":
return s <= (k - n)
return s >= (k + n)
def option_bid_liquidity_ok(bid: Any, bid_sz: Any, *, need_sheets: float = 0) -> tuple[bool, str]:
b = _sf(bid)
if b is None or b <= 0:
return False, "暂无买一报价,无法平期权"
sz = _sf(bid_sz)
if sz is not None and sz <= 0:
return False, "买一深度为 0,无法平期权"
need = float(need_sheets or 0)
if need > 0 and sz is not None and sz + 1e-12 < need:
return False, f"买一深度不足(需 {need:g} 张,买一 {sz:g})"
return True, ""
def size_from_premium(
*,
premium_budget: float,
ask: float,
ct_mult: float,
ratio: float,
contract_size: float,
exec_factor: float = PREMIUM_EXEC_FACTOR,
) -> dict[str, Any]:
"""权利金×0.95 → ETH 两位小数 → 期权张 → 永续跟比例."""
budget = float(premium_budget or 0)
a = float(ask or 0)
ct = float(ct_mult or 0.01)
r = float(ratio or 0)
cs = float(contract_size or 0.01)
usable = budget * float(exec_factor or PREMIUM_EXEC_FACTOR)
if budget <= 0 or a <= 0 or ct <= 0 or r <= 0 or cs <= 0:
return {
"ok": False,
"msg": "定仓参数无效",
"usable_premium": round(usable, 4),
"eth_qty": 0.0,
"sheets": 0.0,
"perp_eth": 0.0,
"contracts": 0.0,
}
# ask 为每 1 币权利金;ETH 数量 = usable / ask
eth_qty = floor2(usable / a)
if eth_qty <= 0:
return {
"ok": False,
"msg": "权利金不足以买入 0.01 ETH 名义期权",
"usable_premium": round(usable, 4),
"eth_qty": 0.0,
"sheets": 0.0,
"perp_eth": 0.0,
"contracts": 0.0,
}
sheets = eth_qty / ct
# 张数向下取整到整数张(OKX 期权常见整张)
sheets_i = float(math.floor(sheets + 1e-12))
if sheets_i <= 0:
return {
"ok": False,
"msg": "换算期权张数不足 1 张",
"usable_premium": round(usable, 4),
"eth_qty": eth_qty,
"sheets": 0.0,
"perp_eth": 0.0,
"contracts": 0.0,
}
# 用整张回写 ETH,保持与下单一致
eth_qty = round(sheets_i * ct, 2)
perp_eth = eth_qty / r
contracts = perp_eth / cs
premium_est = a * sheets_i * ct
return {
"ok": True,
"msg": "",
"usable_premium": round(usable, 4),
"eth_qty": eth_qty,
"sheets": sheets_i,
"perp_eth": round(perp_eth, 6),
"contracts": contracts,
"premium_est": round(premium_est, 4),
"ratio": r,
"exec_factor": float(exec_factor or PREMIUM_EXEC_FACTOR),
}
def estimate_combo_net_pnl(
*,
view_side: str,
strike: float,
index_px: float,
ask_open: float,
bid: float,
sheets: float,
ct_mult: float,
perp_direction: str,
perp_entry: float,
perp_mark: float,
contracts: float,
contract_size: float,
fee: Optional[float] = None,
) -> dict[str, Any]:
"""组合净利(扣费);平仓/卖出手续费按买入费率估算."""
fr = fee if fee is not None else fee_rate()
ct = float(ct_mult or 0.01)
sh = float(sheets or 0)
a = float(ask_open or 0)
b = float(bid or 0)
premium = a * sh * ct
opt_proceeds = b * sh * ct
opt_open_fee = premium * fr
opt_close_fee = opt_proceeds * fr # 卖出费用按买入费率
opt_net = opt_proceeds - premium - opt_open_fee - opt_close_fee
coins = float(contracts or 0) * float(contract_size or 0.01)
entry = float(perp_entry or 0)
mark = float(perp_mark or 0)
pd = str(perp_direction or "").strip().lower()
if pd == "short":
perp_gross = (entry - mark) * coins
else:
perp_gross = (mark - entry) * coins
perp_notional_open = abs(entry * coins)
perp_notional_close = abs(mark * coins)
perp_open_fee = perp_notional_open * fr
perp_close_fee = perp_notional_close * fr
perp_net = perp_gross - perp_open_fee - perp_close_fee
total = opt_net + perp_net
return {
"opt_net": round(opt_net, 4),
"perp_net": round(perp_net, 4),
"net": round(total, 4),
"fee_rate": fr,
"premium": round(premium, 4),
"opt_proceeds": round(opt_proceeds, 4),
}
def validate_option_primary_moneyness(
*,
opt_type: str,
strike: Any,
index_px: Any,
ask: Any = None,
moneyness: str = "atm",
strike_interval: Any = DEFAULT_STRIKE_INTERVAL,
min_hours: Any = DEFAULT_MIN_HOURS,
hours_to_expiry: Any = None,
min_opt_leverage: Any = None,
) -> Optional[str]:
from lib.hedge_plan.hedge_plan_moneyness_lib import (
classify_moneyness,
is_atm_or_otm,
is_itm_or_atm,
normalize_opt_type,
)
o = normalize_opt_type(opt_type)
k = _sf(strike)
s = _sf(index_px)
if o not in ("C", "P"):
return "期权类型无效"
if k is None or s is None or s <= 0:
return "行权价或指数无效"
m_want = (moneyness or "atm").strip().lower()
m_got = classify_moneyness(opt_type=o, strike=k, index_px=s)
if m_want == "itm":
if not is_itm_or_atm(opt_type=o, strike=k, index_px=s):
return "所选须为实值或平值"
elif m_want == "atm":
# 平值:距指数在间隔内即可(不强制 classify==atm)
pass
elif m_want == "otm":
if m_got == "itm":
return "虚值模式不可选实值"
if not is_atm_or_otm(opt_type=o, strike=k, index_px=s):
return "虚值模式须选虚值或平值档"
else:
return "期权类型(实/平/虚)无效"
interval = float(_sf(strike_interval) or DEFAULT_STRIKE_INTERVAL)
if interval > 0 and abs(k - s) > interval + 1e-9:
return f"行权价偏离指数 {abs(k - s):.1f} > 间隔 {interval:.0f}"
min_h = float(_sf(min_hours) or DEFAULT_MIN_HOURS)
h = _sf(hours_to_expiry)
if min_h > 0 and h is not None and h < min_h:
return f"剩余到期约 {h:.1f}h,低于最短 {min_h:.0f}h"
a = _sf(ask)
min_lev = effective_min_opt_leverage(m_want if m_want != "atm" else m_got or "atm", min_opt_leverage)
if min_lev > 0 and a is not None and a > 0:
lev = s / a
if lev < min_lev:
return f"期权杠杆 S/ask≈{lev:.0f} 低于门槛 {min_lev:.0f}"
return None
def validate_option_primary_watch(body: dict[str, Any]) -> Optional[str]:
"""盯盘启动校验:只要参数,不要求已选具体合约."""
need = (
"direction",
"exchange_symbol",
"premium_budget",
"option_target_points",
"perp_target_points",
"option_perp_ratio",
"option_leverage",
)
for k in need:
if body.get(k) in (None, ""):
return f"缺少字段: {k}"
try:
if float(body["premium_budget"]) <= 0:
return "权利金须大于 0"
if float(body["option_target_points"]) <= 0 or float(body["perp_target_points"]) <= 0:
return "目标位点数须大于 0"
if float(body["option_perp_ratio"]) <= 0:
return "期权永续比例须大于 0"
if float(body["option_leverage"]) <= 0:
return "期权杠杆须大于 0"
lev_perp = _sf(body.get("leverage"))
if lev_perp is not None and lev_perp <= 0:
return "永续杠杆须大于 0"
except (TypeError, ValueError):
return "数值字段无效"
direction = str(body.get("direction") or "").strip().lower()
if direction not in ("long", "short"):
return "方向须为 long 或 short"
moneyness = str(body.get("moneyness") or body.get("option_moneyness") or "otm").strip().lower()
if moneyness not in ("itm", "atm", "otm"):
return "期权类型(实/平/虚)无效"
return None
def validate_option_primary_start(body: dict[str, Any]) -> Optional[str]:
need = (
"direction",
"contracts",
"opt_inst_id",
"sheets",
"exchange_symbol",
"premium_budget",
"option_target_points",
"perp_target_points",
"option_perp_ratio",
)
for k in need:
if body.get(k) in (None, ""):
return f"缺少字段: {k}"
try:
if float(body["contracts"]) <= 0 or float(body["sheets"]) <= 0:
return "张数必须大于 0"
if float(body["premium_budget"]) <= 0:
return "权利金须大于 0"
if float(body["option_target_points"]) <= 0 or float(body["perp_target_points"]) <= 0:
return "目标位点数须大于 0"
if float(body["option_perp_ratio"]) <= 0:
return "期权永续比例须大于 0"
except (TypeError, ValueError):
return "数值字段无效"
direction = str(body.get("direction") or "").strip().lower()
if direction not in ("long", "short"):
return "方向须为 long 或 short"
opt_type = str(body.get("opt_type") or "").strip().upper()
if not opt_type:
inst = str(body.get("opt_inst_id") or "")
if inst.upper().endswith("-P"):
opt_type = "P"
elif inst.upper().endswith("-C"):
opt_type = "C"
want = opt_type_for_view(direction)
if opt_type != want:
return f"以期权为主时做{'多' if direction == 'long' else '空'}须用 {'Call' if want == 'C' else 'Put'}"
moneyness = str(body.get("moneyness") or body.get("option_moneyness") or "otm").strip().lower()
from lib.hedge_plan.hedge_plan_moneyness_lib import parse_strike_from_inst
strike = body.get("strike")
if strike in (None, ""):
strike = parse_strike_from_inst(str(body.get("opt_inst_id") or ""))
index_px = body.get("index_px") or body.get("entry")
return validate_option_primary_moneyness(
opt_type=opt_type,
strike=strike,
index_px=index_px,
ask=body.get("ask"),
moneyness=moneyness,
strike_interval=body.get("strike_interval", DEFAULT_STRIKE_INTERVAL),
min_hours=body.get("min_option_hours", DEFAULT_MIN_HOURS),
hours_to_expiry=body.get("hours_to_expiry"),
min_opt_leverage=body.get("option_leverage") or body.get("min_opt_leverage"),
)
def pick_option_primary_candidate(
chain: dict[str, Any],
*,
direction: str,
moneyness: str = "otm",
strike_interval: Any = DEFAULT_STRIKE_INTERVAL,
min_hours: Any = DEFAULT_MIN_HOURS,
min_opt_leverage: Any = None,
) -> Optional[dict[str, Any]]:
"""从期权链挑最近达标合约(间隔+虚实值+杠杆门)."""
from lib.hedge_plan.hedge_plan_moneyness_lib import classify_moneyness
want = opt_type_for_view(direction)
m_want = (moneyness or "otm").strip().lower()
interval = float(_sf(strike_interval) or DEFAULT_STRIKE_INTERVAL)
min_h = float(_sf(min_hours) or DEFAULT_MIN_HOURS)
try:
idx = float(chain.get("index_px") or 0)
except (TypeError, ValueError):
idx = 0.0
if idx <= 0:
return None
best: Optional[dict[str, Any]] = None
best_dist: Optional[float] = None
for exp in chain.get("expiries") or []:
h = hours_to_expiry_from_ms(exp.get("exp_time"))
if min_h > 0 and h is not None and h < min_h:
continue
for c in exp.get("contracts") or []:
if str(c.get("opt_type") or "").upper() != want:
continue
try:
k = float(c.get("strike") or 0)
ask = float(c.get("ask") or 0)
except (TypeError, ValueError):
continue
if k <= 0 or ask <= 0:
continue
if interval > 0 and abs(k - idx) > interval + 1e-9:
continue
m_got = classify_moneyness(opt_type=want, strike=k, index_px=idx)
if m_want == "itm" and m_got not in ("itm", "atm"):
continue
if m_want == "atm" and m_got != "atm":
continue
if m_want == "otm" and m_got == "itm":
continue
min_lev = effective_min_opt_leverage(m_want if m_want != "atm" else (m_got or "atm"), min_opt_leverage)
if min_lev > 0 and idx / ask < min_lev - 1e-9:
continue
dist = abs(k - idx)
if best is None or best_dist is None or dist < best_dist:
best = {
**dict(c),
"hours_to_expiry": h,
"exp_time": exp.get("exp_time"),
"moneyness": m_got,
"index_px": idx,
"leverage": round(idx / ask, 1),
}
best_dist = dist
return best
def build_option_primary_preview(body: dict[str, Any]) -> dict[str, Any]:
"""情景:期权目标 / 永续目标粗估净利."""
view = str(body.get("direction") or "long").lower()
strike = float(body["strike"])
n = float(body.get("option_target_points") or 0)
m = float(body.get("perp_target_points") or 0)
ask = float(body.get("ask") or 0)
sheets = float(body.get("sheets") or 0)
ct = float(body.get("ct_mult") or 0.01)
contracts = float(body.get("contracts") or 0)
cs = float(body.get("contract_size") or 0.01)
entry = float(body.get("entry") or body.get("index_px") or 0)
perp_dir = perp_direction_for_view(view)
# 粗估到点时期权卖价:按内在价值近似(下限 0)
def intrinsic(spot: float) -> float:
o = opt_type_for_view(view)
if o == "C":
return max(0.0, spot - strike)
return max(0.0, strike - spot)
scenarios = []
for label, pts, reason in (
("期权目标", n, "opt_target_points"),
("永续目标", m, "perp_target_points"),
):
spot = strike + pts if view != "short" else strike - pts
bid_est = max(ask * 0.5, intrinsic(spot) * 0.85) # 保守估价
net = estimate_combo_net_pnl(
view_side=view,
strike=strike,
index_px=spot,
ask_open=ask,
bid=bid_est,
sheets=sheets,
ct_mult=ct,
perp_direction=perp_dir,
perp_entry=entry,
perp_mark=spot,
contracts=contracts,
contract_size=cs,
)
scenarios.append(
{
"label": label,
"reason": reason,
"index": spot,
"perp_pnl": net["perp_net"],
"options_pnl": net["opt_net"],
"total": net["net"],
"note": "扣费净利估价;平仓费按买入费率",
}
)
premium = ask * sheets * ct
return {
"plan_type": "perp_options",
"option_primary": True,
"summary": {
"premium_paid": round(premium, 4),
"usable_premium": round(float(body.get("premium_budget") or 0) * PREMIUM_EXEC_FACTOR, 4),
"opt_target_total": scenarios[0]["total"] if scenarios else None,
"perp_target_total": scenarios[1]["total"] if len(scenarios) > 1 else None,
"perp_direction": perp_dir,
"opt_type": opt_type_for_view(view),
},
"scenarios": scenarios,
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-217
View File
@@ -1,217 +0,0 @@
"""对冲计划结算辅助:到期内在价值与期权腿收口."""
from __future__ import annotations
import os
import time
from datetime import datetime
from typing import Any, Callable, Optional
from zoneinfo import ZoneInfo
from lib.exchange.okx_options_lib import normalize_option_exp_ms, resolve_option_close_from_history
from lib.hedge_plan.hedge_plan_calc_lib import option_expiry_pnl
_APP_TZ = ZoneInfo((os.getenv("APP_TIMEZONE") or os.getenv("TZ") or "Asia/Shanghai").strip() or "Asia/Shanghai")
def _sf(v: Any) -> Optional[float]:
try:
if v is None or v == "":
return None
return float(v)
except (TypeError, ValueError):
return None
def leg_exp_ms(leg: dict[str, Any]) -> Optional[int]:
return normalize_option_exp_ms(leg.get("exp_time"), str(leg.get("inst_id") or ""))
def leg_is_expired(leg: dict[str, Any], *, now_ms: Optional[int] = None) -> bool:
exp = leg_exp_ms(leg)
if exp is None:
return False
now = int(now_ms if now_ms is not None else time.time() * 1000)
return now >= int(exp)
def settle_option_leg_at_spot(leg: dict[str, Any], spot: float) -> float:
"""按到期结算口径估算腿盈亏(USDC)."""
premium = float(leg.get("premium") or 0)
strike = _sf(leg.get("strike"))
if strike is None:
return -premium
sheets = float(leg.get("size") or 1)
# ct_mult 未入库时默认 0.01
ct = float(leg.get("ct_mult") or 0.01)
return float(
option_expiry_pnl(
opt_type=str(leg.get("opt_type") or "P"),
strike=float(strike),
spot=float(spot),
sheets=sheets,
ct_mult=ct,
premium_paid=premium,
)
)
def all_option_legs_expired(legs: list[dict[str, Any]], *, now_ms: Optional[int] = None) -> bool:
opts = [
x
for x in legs
if str(x.get("leg_role") or "").startswith("option")
and str(x.get("status") or "") in ("open", "hold_to_expiry")
]
if not opts:
return False
return all(leg_is_expired(x, now_ms=now_ms) for x in opts)
def _parse_opened_ms(raw: Any) -> Optional[int]:
"""墙钟开仓时间 → UTC ms.库内时间为业务时区(默认 Asia/Shanghai),不可当 UTC."""
if raw is None or raw == "":
return None
s = str(raw).strip()
if not s:
return None
for fmt, ln in (("%Y-%m-%d %H:%M:%S", 19), ("%Y-%m-%d %H:%M:%f", 26), ("%Y-%m-%d %H:%M", 16)):
try:
dt = datetime.strptime(s[:ln], fmt).replace(tzinfo=_APP_TZ)
return int(dt.timestamp() * 1000)
except ValueError:
continue
return None
def resolve_option_leg_realized_pnl(
*,
ex: Any = None,
leg: dict[str, Any],
fallback: Optional[float] = None,
fetch_history_fn: Optional[Callable[[str], list[dict[str, Any]]]] = None,
hist_rows: Optional[list[dict[str, Any]]] = None,
) -> tuple[Optional[float], str]:
"""
期权腿已实现盈亏:优先 OKX positions-history realizedPnl.
返回 (pnl, source) source=exchange|fallback|none.
"""
inst_id = str(leg.get("inst_id") or "").strip()
open_ms = _parse_opened_ms(leg.get("opened_at"))
rows = hist_rows
if rows is None and inst_id:
try:
if callable(fetch_history_fn):
rows = fetch_history_fn(inst_id)
elif ex is not None:
from lib.exchange.okx_options_lib import fetch_option_position_history
rows = fetch_option_position_history(ex, inst_id)
except Exception:
rows = None
if rows:
close_ms = _parse_opened_ms(leg.get("closed_at"))
sheets = _sf(leg.get("size")) or _sf(leg.get("sheets"))
info = resolve_option_close_from_history(
rows, open_ms=open_ms, close_ms=close_ms, sheets=sheets
)
pnl = _sf((info or {}).get("realized_pnl")) if info else None
if pnl is not None:
return round(float(pnl), 4), "exchange"
if fallback is not None:
return round(float(fallback), 4), "fallback"
return None, "none"
def backfill_hedge_option_legs_realized_pnl(
conn: Any,
hist_rows: list[dict[str, Any]],
*,
update_plan_fn: Optional[Callable[..., Any]] = None,
) -> dict[str, int]:
"""用交易所历史覆盖已平期权腿盈亏,并重算已结束计划合计."""
from lib.hedge_plan.hedge_plan_db import get_plan, get_plan_legs, update_plan
by_inst: dict[str, list[dict[str, Any]]] = {}
for raw in hist_rows or []:
if not isinstance(raw, dict):
continue
inst = str(raw.get("instId") or "").strip()
if inst:
by_inst.setdefault(inst, []).append(raw)
legs = conn.execute(
"""
SELECT * FROM hedge_plan_legs
WHERE status = 'closed'
AND inst_id IS NOT NULL AND TRIM(inst_id) != ''
AND (leg_role LIKE 'option%' OR opt_type IS NOT NULL)
ORDER BY id DESC
LIMIT 400
"""
).fetchall()
updated_legs = 0
touched_plans: set[int] = set()
for row in legs:
leg = dict(row)
inst = str(leg.get("inst_id") or "").strip()
if not inst or inst not in by_inst:
continue
pnl, src = resolve_option_leg_realized_pnl(
leg=leg,
hist_rows=by_inst[inst],
fallback=None,
)
if src != "exchange" or pnl is None:
continue
local = _sf(leg.get("realized_pnl"))
if local is not None and abs(local - pnl) < 1e-6:
continue
conn.execute(
"UPDATE hedge_plan_legs SET realized_pnl=? WHERE id=?",
(pnl, int(leg["id"])),
)
updated_legs += 1
touched_plans.add(int(leg["plan_id"]))
updated_plans = 0
updater = update_plan_fn or update_plan
for pid in touched_plans:
plan = get_plan(conn, pid)
if not plan or str(plan.get("status") or "") != "closed":
continue
plan_legs = get_plan_legs(conn, pid)
opt_sum = 0.0
for lg in plan_legs:
role = str(lg.get("leg_role") or "")
if not (role.startswith("option") or lg.get("opt_type")):
continue
if str(lg.get("status") or "") != "closed":
continue
opt_sum += float(_sf(lg.get("realized_pnl")) or 0.0)
perp = float(_sf(plan.get("realized_pnl_perp")) or 0.0)
ptype = str(plan.get("plan_type") or "")
if ptype == "options_options":
total = opt_sum
kwargs: dict[str, Any] = {
"realized_pnl_options": round(opt_sum, 4),
"realized_pnl_total": round(total, 4),
}
else:
total = perp + opt_sum
kwargs = {
"realized_pnl_perp": round(perp, 4),
"realized_pnl_options": round(opt_sum, 4),
"realized_pnl_total": round(total, 4),
}
old_total = _sf(plan.get("realized_pnl_total"))
old_opts = _sf(plan.get("realized_pnl_options"))
if (
old_total is not None
and abs(old_total - total) < 1e-6
and old_opts is not None
and abs(old_opts - opt_sum) < 1e-6
):
continue
updater(conn, pid, **kwargs)
updated_plans += 1
return {"legs": updated_legs, "plans": updated_plans}
-96
View File
@@ -1,96 +0,0 @@
"""OKX 期权/对冲模式(env: OKX_TRADE_MODE).
options → 仅单独期权(隐藏对冲导航与对冲 env 配置)
options_options → 仅期期对冲(不可单独开期权)
perp_options → 永期对冲(含合约腿);产品 UI 已隐藏,仅兼容旧 .env / 历史计划
"""
from __future__ import annotations
import os
from typing import Optional
MODE_OPTIONS = "options"
MODE_PERP = "perp_options"
MODE_OO = "options_options"
VALID_MODES = frozenset({MODE_OPTIONS, MODE_PERP, MODE_OO})
# 配置页可选模式(不含永期)
UI_MODES = frozenset({MODE_OPTIONS, MODE_OO})
_ALIASES = {
"option": MODE_OPTIONS,
"standalone": MODE_OPTIONS,
"期权": MODE_OPTIONS,
"单独期权": MODE_OPTIONS,
"po": MODE_PERP,
"perp": MODE_PERP,
"永期": MODE_PERP,
"永期对冲": MODE_PERP,
"oo": MODE_OO,
"期期": MODE_OO,
"期期对冲": MODE_OO,
}
def _env_bool(name: str, default: bool = False) -> bool:
raw = os.getenv(name)
if raw is None or str(raw).strip() == "":
return default
return str(raw).strip().lower() in ("1", "true", "yes", "on")
def normalize_okx_trade_mode(raw: Optional[str]) -> str:
s = str(raw or "").strip().lower()
if s in VALID_MODES:
return s
if s in _ALIASES:
return _ALIASES[s]
return ""
def legacy_infer_okx_trade_mode() -> str:
"""未配置 OKX_TRADE_MODE 时默认单独期权(对冲关闭).
旧 HEDGE_PLAN_* 开关不再自动打开对冲,避免误开;需显式设 OKX_TRADE_MODE=options_options.
"""
return MODE_OPTIONS
def get_okx_trade_mode() -> str:
m = normalize_okx_trade_mode(os.getenv("OKX_TRADE_MODE"))
if m:
return m
return legacy_infer_okx_trade_mode()
def hedge_module_enabled() -> bool:
return get_okx_trade_mode() in (MODE_PERP, MODE_OO)
def show_perp_options() -> bool:
return get_okx_trade_mode() == MODE_PERP
def show_options_options() -> bool:
return get_okx_trade_mode() == MODE_OO
def standalone_options_open_allowed() -> bool:
return get_okx_trade_mode() == MODE_OPTIONS
def mode_label(mode: Optional[str] = None) -> str:
m = mode or get_okx_trade_mode()
return {
MODE_OPTIONS: "单独期权",
MODE_PERP: "永期对冲",
MODE_OO: "期期对冲",
}.get(m, m or "—")
def block_standalone_open_by_mode_msg() -> Optional[str]:
if standalone_options_open_allowed():
return None
return (
f"当前交易模式为「{mode_label()}」,不可单独开期权;"
"请在 env「交易模式」切换为「单独期权」"
)
@@ -1,408 +0,0 @@
<div class="hedge-plan-page-wrap" style="grid-column:1/-1" id="hedge-plan-root"
data-default-underly="{{ options_default_underly | default('ETH') }}"
data-hedge-enabled="{{ '1' if hedge_plan_enabled else '0' }}"
data-options-enabled="{{ '1' if options_enabled else '0' }}"
data-show-perp="{{ '1' if hedge_plan_show_perp_options else '0' }}"
data-show-oo="{{ '1' if hedge_plan_show_options_options else '0' }}"
data-oo-close-mode-enabled="{{ '1' if hedge_plan_oo_close_mode_enabled else '0' }}"
data-option-primary="{{ '1' if hedge_plan_option_primary|default(true) else '0' }}"
data-budget-buffer="{{ hedge_plan_budget_buffer | default(0.95) }}"
data-sizing-mode="{{ position_sizing_mode | default('risk') }}"
data-is-full-margin="{{ '1' if position_sizing_mode == 'full_margin' else '0' }}">
{% if not hedge_plan_enabled %}
<div class="flash" style="margin-bottom:12px">对冲计划未启用:请在 <code>env配置 → 对冲计划</code> 打开 <code>HEDGE_PLAN_ENABLED</code>(可热更).</div>
{% endif %}
{% if not options_enabled %}
<div class="flash" style="margin-bottom:12px">期权模块未启用,无法拉期权链.请先配置期权账户.</div>
{% endif %}
{% if hedge_plan_enabled and not hedge_plan_show_perp_options and not hedge_plan_show_options_options %}
<div class="flash" style="margin-bottom:12px">永期与期期 Tab 均已隐藏:请在 env「期权/对冲模式」切换交易模式;进行中/历史仍可查看.</div>
{% endif %}
<div class="card hp-head-card">
<div class="hp-head-row">
<h2 class="hp-title">对冲计划 <span class="muted hp-title-sub">测算 · 下单</span>
<a class="muted" href="/options/guide" target="_blank" rel="noopener" style="font-size:13px;font-weight:500;margin-left:8px">期权开平仓与监控说明</a>
</h2>
<button type="button" class="btn-secondary" id="hp-refresh" title="刷新永续行情与期权链">刷新行情</button>
</div>
<div class="hp-tabs" role="tablist" aria-label="对冲计划分类">
{% if hedge_plan_show_perp_options %}
<button type="button" class="hp-tab" role="tab" aria-selected="false" data-tab="perp_options">永期对冲</button>
{% endif %}
{% if hedge_plan_show_options_options %}
<button type="button" class="hp-tab" role="tab" aria-selected="false" data-tab="options_options">期期对冲</button>
{% endif %}
<button type="button" class="hp-tab" role="tab" aria-selected="false" data-tab="active">进行中的计划</button>
<button type="button" class="hp-tab" role="tab" aria-selected="false" data-tab="history">历史记录</button>
<button type="button" class="hp-tab" role="tab" aria-selected="false" data-tab="stats">统计分析</button>
</div>
<p class="muted" id="hp-gate-line"></p>
<p class="muted hp-acct-hint" id="hp-acct-hint">永续腿→合约账户 · 期权腿→期权账户</p>
</div>
<div id="hp-tab-perp_options" class="hp-tab-panel" role="tabpanel">
<div class="options-dual-grid" id="hp-po-layout">
<div class="card hp-po-perp-card">
<h2>
<span id="hp-po-mode-badge" class="hp-po-mode-badge">以期权为主</span>
<span id="hp-po-card-title">执行参数</span>
· <span id="hp-perp-uly-label">ETH</span>
<span class="muted hp-acct-tag" id="hp-perp-acct-tag">合约账户</span>
</h2>
<details class="tip-collapse hp-rule-collapse">
<summary class="tip-collapse-summary">规则说明</summary>
<div class="tip-collapse-body rule-tip">
<p><strong>账户</strong>:永续腿走<strong>合约账户</strong>(USDT);期权腿走<strong>期权账户</strong>(USDC)。两账户分开下单、资金不互通。</p>
<p><strong>模式</strong>:在 env <code>HEDGE_PLAN_OPTION_PRIMARY</code> 切换(true=以期权为主 / false=保险模式);标题前标识当前模式。</p>
<p><strong>保险模式</strong>:做多配 Put、做空配 Call;左填开仓/止盈止损;仅实值/平值;交易所 TP/SL 出场。</p>
<p><strong>以期权为主</strong>:填参后点「策略启动」进入<strong>盯盘</strong>(非现场开仓);杠杆/间隔达标后自动先开期权再市价永续。右侧列表仅展示达标候选。</p>
</div>
</details>
<div class="form-row hp-uly-row">
<button type="button" class="btn-secondary hp-uly-btn active" data-uly="ETH">ETH</button>
<button type="button" class="btn-secondary hp-uly-btn" data-uly="BTC">BTC</button>
</div>
<div class="hp-po-top">
<div class="hp-oo-seg hp-po-dir-seg" role="group" aria-label="方向">
<button type="button" class="btn-secondary hp-po-dir is-selected" data-dir="long" title="做多=买Call+永续空"><span class="hp-oo-check" aria-hidden="true">✓</span>做多</button>
<button type="button" class="btn-secondary hp-po-dir" data-dir="short" title="做空=买Put+永续多"><span class="hp-oo-check" aria-hidden="true">✓</span>做空</button>
</div>
</div>
<div class="hp-po-fields hidden" id="hp-po-fields-insurance" hidden>
<label class="hp-po-field">
<span class="hp-po-field-lab">开仓价 <em>USDT</em></span>
<input type="number" step="any" id="hp-entry" placeholder="入场价" autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other" />
</label>
<label class="hp-po-field">
<span class="hp-po-field-lab">张数 <em>合约张</em></span>
<input type="number" step="any" id="hp-contracts" placeholder="数量" autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other" />
</label>
<label class="hp-po-field hp-po-field--tp">
<span class="hp-po-field-lab">止盈 <em>USDT</em></span>
<input type="number" step="any" id="hp-tp" placeholder="目标价" autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other" />
</label>
<label class="hp-po-field hp-po-field--sl">
<span class="hp-po-field-lab">止损 <em>USDT</em></span>
<input type="number" step="any" id="hp-sl" placeholder="保护价" autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other" />
</label>
</div>
<div id="hp-po-fields-option-primary">
<section class="hp-po-section" aria-labelledby="hp-po-sec-capital">
<h3 id="hp-po-sec-capital" class="hp-po-section-title">资金与杠杆配置</h3>
<div class="hp-po-fields hp-po-fields--section hp-po-fields--capital">
<label class="hp-po-field">
<span class="hp-po-field-lab">权利金 <em>USDC</em></span>
<input type="number" step="any" id="hp-premium-budget" placeholder="预算(执行×0.95)" autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other" />
</label>
<label class="hp-po-field">
<span class="hp-po-field-lab">永续杠杆</span>
<input type="number" step="1" id="hp-perp-leverage" value="100" autocomplete="off" inputmode="numeric" data-lpignore="true" data-1p-ignore="true" data-form-type="other" />
</label>
<label class="hp-po-field">
<span class="hp-po-field-lab">期权杠杆 <em>启动校验</em></span>
<input type="number" step="1" id="hp-opt-leverage" value="100" autocomplete="off" inputmode="numeric" data-lpignore="true" data-1p-ignore="true" data-form-type="other" />
</label>
</div>
</section>
<section class="hp-po-section" aria-labelledby="hp-po-sec-select">
<h3 id="hp-po-sec-select" class="hp-po-section-title">选约条件</h3>
<div class="hp-po-fields hp-po-fields--section hp-po-fields--select">
<label class="hp-po-field">
<span class="hp-po-field-lab">到期时间 <em>最短h</em></span>
<input type="number" step="1" id="hp-min-hours" value="36" autocomplete="off" inputmode="numeric" data-lpignore="true" data-1p-ignore="true" data-form-type="other" />
</label>
<label class="hp-po-field">
<span class="hp-po-field-lab">期权间隔 <em>点</em></span>
<input type="number" step="any" id="hp-strike-interval" value="15" autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other" />
</label>
<label class="hp-po-field hp-po-field--type">
<span class="hp-po-field-lab">类型</span>
<select id="hp-money-select" aria-label="虚实值类型">
<option value="otm" selected>虚值</option>
<option value="itm">实值/平值</option>
<option value="atm">仅平值</option>
</select>
</label>
<label class="hp-po-field">
<span class="hp-po-field-lab">比例 <em>期权:永续</em></span>
<input type="number" step="any" id="hp-opt-perp-ratio" value="2" autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other" />
</label>
</div>
</section>
<section class="hp-po-section" aria-labelledby="hp-po-sec-exit">
<h3 id="hp-po-sec-exit" class="hp-po-section-title">出场条件</h3>
<div class="hp-po-fields hp-po-fields--section">
<label class="hp-po-field">
<span class="hp-po-field-lab">期权目标位 <em>相对K</em></span>
<input type="number" step="any" id="hp-opt-target-pts" placeholder="点数" autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other" />
</label>
<label class="hp-po-field">
<span class="hp-po-field-lab">永续目标位 <em>相对K</em></span>
<input type="number" step="any" id="hp-perp-target-pts" placeholder="点数" autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-form-type="other" />
</label>
</div>
</section>
</div>
<div class="hp-po-summary">
<div id="hp-perp-pnl-line" class="hp-po-pnl"></div>
<div id="hp-sizing-line" class="muted hp-po-sizing"></div>
</div>
</div>
<div class="card hp-po-right-card">
<div class="hp-po-right-stack">
<div class="hp-po-inner-card hp-po-perp-quote-card">
<h2>永续行情 <span class="muted hp-acct-tag">合约账户</span></h2>
<div class="hp-po-quote-head">
<span id="hp-po-mark" class="hp-po-mark" aria-live="polite">标记 —</span>
</div>
<p id="hp-perp-quote" class="muted hp-po-meta">加载中…</p>
<p id="hp-po-perp-quote-right" class="muted hp-po-meta" hidden></p>
</div>
<div class="hp-po-inner-card hp-opt-card">
<h2>期权 · <span id="hp-opt-type-label">Call</span> <span class="muted hp-acct-tag" id="hp-opt-acct-tag">期权账户</span></h2>
<div class="form-row hp-opt-toolbar hp-po-opt-toolbar">
<select id="hp-exp-select"><option value="">选择到期日</option></select>
<span class="hp-po-ins-money" id="hp-po-ins-money" hidden>
<button type="button" class="btn-secondary hp-money-btn active" data-money="itm" title="实值+平值">实值/平值</button>
<button type="button" class="btn-secondary hp-money-btn" data-money="atm" title="仅平值">仅平值</button>
</span>
<button type="button" class="btn-secondary" id="hp-recommend-opt" title="按当前类型自动匹配最近合约">自动匹配</button>
<button type="button" class="btn-secondary" id="hp-load-chain">刷新链</button>
<span id="hp-index-line" class="hp-po-index" aria-live="polite">指数 —</span>
</div>
<div class="options-strike-table-wrap hp-strike-table-wrap--6">
<table class="options-strike-table" id="hp-strike-table">
<thead>
<tr>
<th>行权价</th>
<th>实虚值</th>
<th title="指数÷卖一">杠杆</th>
<th>卖一/张</th>
<th>买一/张</th>
<th>操作</th>
</tr>
</thead>
<tbody id="hp-strike-tbody">
<tr><td colspan="6" class="muted">请刷新期权链</td></tr>
</tbody>
</table>
</div>
<div class="form-row hp-pick-row">
<label>已选 <code id="hp-sel-inst">—</code></label>
<label>张数 <span class="hp-unit">期权张</span> <input type="number" step="1" min="1" id="hp-sheets" value="1" autocomplete="off" inputmode="numeric" data-lpignore="true" data-1p-ignore="true" data-form-type="other" /></label>
<span class="muted" id="hp-premium-line"></span>
</div>
<div id="hp-opt-bal-line" class="muted hp-po-meta hp-opt-bal-line"></div>
</div>
</div>
<div class="form-row hp-action-row hp-po-action-row">
<span id="hp-po-strategy-status" class="hp-po-strategy-status" aria-live="polite"></span>
<button type="button" class="primary" id="hp-preview-btn" title="以期权为主=盯盘启动">策略启动</button>
</div>
</div>
</div>
</div>
<div id="hp-tab-options_options" class="hp-tab-panel hidden" role="tabpanel" hidden>
<div class="options-dual-grid" id="hp-oo-layout">
<div class="card">
<h2>期期参数 · <span id="hp-oo-uly-label">ETH</span> <span class="muted hp-acct-tag">期权账户</span></h2>
<details class="tip-collapse hp-rule-collapse">
<summary class="tip-collapse-summary">规则说明</summary>
<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>:选 Call + Put 后「计算」再「启动」。启动会再拉卖一并按最新价重算张数,IOC 完全成交才算成功;资金不足可在右侧划转。</p>
<p><strong>板块</strong>:左填<strong>盈亏比</strong>(盈利金额÷总权利金,默认2)与张数模式(同张数/做多/做空);右 T 型选腿。<strong>两腿仅允许平值或虚值</strong>(禁实值)。出场:盈利腿达盈亏比即平;亏损腿「残值平」=本合约权利金跌至20%且有买一时平,「到期平」=持有至到期。</p>
</div>
</details>
<div class="form-row hp-uly-row">
<button type="button" class="btn-secondary hp-uly-btn-oo active" data-uly="ETH">ETH</button>
<button type="button" class="btn-secondary hp-uly-btn-oo" data-uly="BTC">BTC</button>
</div>
<div class="form-row hp-target-row hp-oo-target-row">
<label title="盈利金额 / 总权利金">盈亏比 <input type="number" step="0.1" min="0.1" id="hp-profit-rr" value="2" placeholder="默认2" 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>
</div>
<div class="hp-oo-controls">
<div class="hp-oo-ctrl">
<span class="hp-oo-ctrl-lab">张数</span>
<div class="hp-oo-seg" role="group" aria-label="自动张数">
<button type="button" class="btn-secondary hp-oo-size-mode is-selected" data-oo-size="same_sheets" title="两腿同张数,总权利金≤预算"><span class="hp-oo-check" aria-hidden="true">✓</span>同张数</button>
<button type="button" class="btn-secondary hp-oo-size-mode" data-oo-size="long_bias" title="偏多:Call 占比更高(比例见 env)"><span class="hp-oo-check" aria-hidden="true">✓</span>做多</button>
<button type="button" class="btn-secondary hp-oo-size-mode" data-oo-size="short_bias" title="偏空:Put 占比更高(比例见 env)"><span class="hp-oo-check" aria-hidden="true">✓</span>做空</button>
</div>
</div>
<div class="hp-oo-ctrl" id="hp-oo-close-mode-row">
<span class="hp-oo-ctrl-lab" title="仅控制盈利腿平掉后的另一腿">平仓</span>
<div class="hp-oo-seg" role="group" aria-label="平仓模式">
<button type="button" class="btn-secondary hp-oo-close-mode is-selected" data-oo-close="close_all" title="盈利腿平后:亏损腿本合约权利金跌至20%且有买一时平掉(失败重试)"><span class="hp-oo-check" aria-hidden="true">✓</span>残值平</button>
<button type="button" class="btn-secondary hp-oo-close-mode" data-oo-close="hold_expiry" title="盈利腿平后另一腿持有至到期"><span class="hp-oo-check" aria-hidden="true">✓</span>到期平</button>
</div>
</div>
</div>
<p class="muted hp-oo-meta" id="hp-oo-budget-line"></p>
<div id="hp-oo-legs" class="hp-oo-legs">
<div class="hp-oo-leg-row" data-leg="a">
<div class="muted" id="hp-oo-leg-a-info">腿A: 尚未选用</div>
<label>张数 <span class="hp-unit">期权张</span>
<input type="number" step="1" min="0" id="hp-oo-sheets-a" value="1" disabled autocomplete="off" inputmode="numeric" data-lpignore="true" data-1p-ignore="true" data-form-type="other" />
</label>
</div>
<div class="hp-oo-leg-row" data-leg="b">
<div class="muted" id="hp-oo-leg-b-info">腿B: 尚未选用</div>
<label>张数 <span class="hp-unit">期权张</span>
<input type="number" step="1" min="0" id="hp-oo-sheets-b" value="1" disabled autocomplete="off" inputmode="numeric" data-lpignore="true" data-1p-ignore="true" data-form-type="other" />
</label>
</div>
<p class="muted" id="hp-oo-prem-line"></p>
</div>
</div>
<div class="card hp-opt-card">
<h2>期权 T 型报价</h2>
<div class="form-row">
<select id="hp-oo-exp-select"><option value="">选择到期日</option></select>
<button type="button" class="btn-secondary hp-oo-money-btn is-selected active" data-oo-money="atm_otm" title="平值+虚值" aria-pressed="true"><span class="hp-oo-check" aria-hidden="true">✓</span>平/虚</button>
<button type="button" class="btn-secondary hp-oo-money-btn" data-oo-money="atm" title="仅平值" aria-pressed="false"><span class="hp-oo-check" aria-hidden="true">✓</span>仅平值</button>
<button type="button" class="btn-secondary hp-oo-money-btn" data-oo-money="otm" title="仅虚值" aria-pressed="false"><span class="hp-oo-check" aria-hidden="true">✓</span>仅虚值</button>
<button type="button" class="btn-secondary hp-oo-recommend-btn" id="hp-oo-recommend-atm" data-oo-rec="atm_straddle" title="最近平值 Call+Put" aria-pressed="false"><span class="hp-oo-check" aria-hidden="true">✓</span>推荐跨式</button>
<button type="button" class="btn-secondary hp-oo-recommend-btn" id="hp-oo-recommend-otm" data-oo-rec="double_otm" title="最近虚值 Call+Put" aria-pressed="false"><span class="hp-oo-check" aria-hidden="true">✓</span>推荐双虚</button>
<button type="button" class="btn-secondary" id="hp-oo-load-chain">刷新链</button>
<button type="button" class="btn-secondary hp-oo-expand-btn" id="hp-oo-expand-all" title="展开该到期全部平值/虚值行权价;若当前为「仅平值」会自动切到「平/虚」" aria-pressed="false"><span class="hp-oo-check" aria-hidden="true">✓</span>显示全部</button>
</div>
<div class="options-strike-table-wrap hp-oo-table-wrap" id="hp-oo-table-wrap">
<table class="options-strike-table options-strike-table--t" id="hp-oo-table">
<thead>
<tr>
<th colspan="4" class="opt-t-head-call">Call</th>
<th class="opt-t-head-mid">行权</th>
<th colspan="4" class="opt-t-head-put">Put</th>
</tr>
<tr>
<th>卖一/张</th><th title="行权价÷卖一">杠杆</th><th>实虚值</th><th>选用</th>
<th>K</th>
<th>实虚值</th><th>卖一/张</th><th title="行权价÷卖一">杠杆</th><th>选用</th>
</tr>
</thead>
<tbody id="hp-oo-tbody">
<tr><td colspan="9" class="muted">请刷新期权链</td></tr>
</tbody>
</table>
</div>
<div class="hp-oo-transfer hp-oo-transfer--compact" id="hp-oo-transfer">
<div class="hp-oo-transfer-bals muted">
<span>资金 <strong id="hp-oo-funding-usdc">—</strong></span>
<span class="hp-oo-transfer-sep">·</span>
<span>交易 <strong id="hp-oo-trading-usdc">—</strong></span>
<span class="hp-oo-transfer-unit">USDC</span>
<span class="muted" id="hp-oo-xfer-msg"></span>
</div>
<div class="form-row hp-oo-transfer-form" autocomplete="off">
{# 诱饵账号框:避免浏览器把划转数量当成登录用户名填 dekun #}
<input type="text" name="username" autocomplete="username" tabindex="-1" aria-hidden="true"
style="position:absolute;left:-9999px;width:1px;height:1px;opacity:0" value="">
<select id="hp-oo-xfer-dir" aria-label="划转方向" autocomplete="off">
<option value="funding_to_trading" selected>资金 → 交易</option>
<option value="trading_to_funding">交易 → 资金</option>
</select>
<input type="number" id="hp-oo-xfer-amount" name="cm_hp_xfer_amt" min="0.01" step="0.01" placeholder="数量"
autocomplete="off" inputmode="decimal" data-lpignore="true" data-1p-ignore="true" data-bwignore="true" data-form-type="other" readonly />
<button type="button" class="btn-secondary btn-sm" id="hp-oo-xfer-all">全部</button>
<button type="button" class="btn-primary btn-sm" id="hp-oo-xfer-btn">划转</button>
</div>
</div>
<div class="form-row hp-action-row">
<button type="button" class="primary" id="hp-preview-btn-oo">计算</button>
</div>
</div>
</div>
</div>
<div id="hp-tab-active" class="hp-tab-panel hidden" role="tabpanel" hidden>
<div class="card">
<h2>进行中的计划</h2>
<p class="muted">仅显示已启动但尚未结束的计划;可查看每条腿的当前记录状态。</p>
<div class="options-strike-table-wrap">
<table class="options-strike-table hp-history-table">
<thead>
<tr>
<th>ID</th><th>类型</th><th>标的</th><th>合约</th><th>状态</th><th>目标/止盈止损</th><th>开仓</th><th>操作</th>
</tr>
</thead>
<tbody id="hp-active-tbody">
<tr><td colspan="8" class="muted">加载中…</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<div id="hp-tab-history" class="hp-tab-panel hidden" role="tabpanel" hidden>
<div class="card">
<h2>历史记录</h2>
<p class="muted">独立对冲计划历史(与普通交易记录分离)。点「成交细节」查看合约名与成交字段。</p>
<div class="options-strike-table-wrap">
<table class="options-strike-table hp-history-table">
<thead>
<tr>
<th>ID</th><th>类型</th><th>标的</th><th>合约</th><th>状态</th><th>合计≈U</th><th>原因</th><th>开仓</th><th>结束</th><th>操作</th>
</tr>
</thead>
<tbody id="hp-history-tbody">
<tr><td colspan="10" class="muted">加载中…</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<div id="hp-tab-stats" class="hp-tab-panel hidden" role="tabpanel" hidden>
<div class="card">
<h2>统计分析</h2>
<p class="muted">按永期 / 期期分别统计:胜率、盈亏比、最大盈利、最大亏损、最大回撤(按结束时间累积)</p>
<div id="hp-stats-box" class="muted">加载中…</div>
</div>
</div>
<div id="hp-detail-modal" class="hp-modal-backdrop" hidden>
<div class="hp-modal" role="dialog" aria-modal="true" aria-labelledby="hp-detail-title">
<div class="hp-modal-head">
<h3 id="hp-detail-title">成交细节</h3>
<button type="button" class="btn-secondary" id="hp-detail-close">关闭</button>
</div>
<div id="hp-detail-body" class="hp-modal-body muted">加载中…</div>
</div>
</div>
<div id="hp-preview-modal" class="hp-modal-backdrop" hidden>
<div class="hp-modal hp-preview-modal" role="dialog" aria-modal="true" aria-labelledby="hp-preview-title">
<div class="hp-modal-head">
<h3 id="hp-preview-title">情景测算</h3>
<button type="button" class="btn-secondary" id="hp-preview-cancel-x" aria-label="关闭">关闭</button>
</div>
<div id="hp-preview-summary" class="muted hp-preview-summary"></div>
<div class="options-strike-table-wrap">
<table class="options-strike-table" id="hp-result-table">
<thead>
<tr>
<th>情景</th>
<th>现货价</th>
<th id="hp-preview-mid-th">永续/腿盈亏</th>
<th>期权盈亏</th>
<th>合计≈U</th>
<th>说明</th>
</tr>
</thead>
<tbody id="hp-result-tbody">
<tr><td colspan="6" class="muted">计算中…</td></tr>
</tbody>
</table>
</div>
<div class="form-row hp-preview-actions">
<button type="button" class="btn-secondary" id="hp-preview-cancel">取消</button>
<button type="button" class="primary" id="hp-preview-start" disabled title="需开启 HEDGE_PLAN_LIVE_ORDER 等门禁">启动计划</button>
</div>
</div>
</div>
</div>
<script src="/static/hedge_plan.js?v=46"></script>
File diff suppressed because it is too large Load Diff
+144 -144
View File
@@ -1,144 +1,144 @@
"""实例顶栏 / 系统设置区块显示开关(存 SQLite,即时生效).""" """实例顶栏 / 系统设置区块显示开关(存 SQLite,即时生效)."""
from __future__ import annotations from __future__ import annotations
from typing import Any, Callable, Optional from typing import Any, Callable, Optional
from lib.instance.runtime_settings_lib import runtime_get_prefix, runtime_set_many, with_db from lib.instance.runtime_settings_lib import runtime_get_prefix, runtime_set_many, with_db
DISPLAY_RUNTIME_PREFIX = "display." DISPLAY_RUNTIME_PREFIX = "display."
DEFAULT_INSTANCE_DISPLAY: dict[str, bool] = { DEFAULT_INSTANCE_DISPLAY: dict[str, bool] = {
"show_nav_dashboard": False, "show_nav_dashboard": False,
"show_nav_account_ledger": False, "show_nav_account_ledger": False,
"show_nav_key_monitor": True, "show_nav_key_monitor": True,
"show_nav_trade": False, # 实盘下单界面已移除 "show_nav_trade": False, # 实盘下单界面已移除
"show_nav_strategy": False, "show_nav_strategy": False,
"show_nav_strategy_records": False, "show_nav_strategy_records": False,
"show_nav_records": False, # 永续交易记录与复盘已移除 "show_nav_records": False, # 永续交易记录与复盘已移除
"show_nav_stats": False, "show_nav_stats": False,
"show_nav_risk_policy": True, "show_nav_risk_policy": True,
"show_nav_system_guide": False, "show_nav_system_guide": False,
"show_nav_env_config": True, "show_nav_env_config": True,
"show_nav_options": True, "show_nav_options": True,
"show_nav_options_review": True, "show_nav_options_review": True,
"show_nav_hedge_plan": True, "show_nav_hedge_plan": False,
"show_settings_transfer": False, # 永续资金划转默认隐藏 "show_settings_transfer": False, # 永续资金划转默认隐藏
"show_settings_export": True, "show_settings_export": True,
"show_settings_password": True, "show_settings_password": True,
"show_settings_options_swap": True, "show_settings_options_swap": True,
"show_settings_options_transfer": True, "show_settings_options_transfer": True,
} }
DISPLAY_LABELS: dict[str, str] = { DISPLAY_LABELS: dict[str, str] = {
"show_nav_dashboard": "数据看板", "show_nav_dashboard": "数据看板",
"show_nav_account_ledger": "账户流水", "show_nav_account_ledger": "账户流水",
"show_nav_key_monitor": "关键位监控", "show_nav_key_monitor": "关键位监控",
"show_nav_trade": "实盘下单", "show_nav_trade": "实盘下单",
"show_nav_strategy": "策略交易", "show_nav_strategy": "策略交易",
"show_nav_strategy_records": "策略交易记录", "show_nav_strategy_records": "策略交易记录",
"show_nav_records": "交易记录与复盘", "show_nav_records": "交易记录与复盘",
"show_nav_stats": "统计分析", "show_nav_stats": "统计分析",
"show_nav_risk_policy": "风控说明", "show_nav_risk_policy": "风控说明",
"show_nav_system_guide": "系统说明", "show_nav_system_guide": "系统说明",
"show_nav_env_config": "env配置", "show_nav_env_config": "env配置",
"show_nav_options": "期权", "show_nav_options": "期权",
"show_nav_options_review": "期权复盘", "show_nav_options_review": "期权复盘",
"show_nav_hedge_plan": "对冲计划", "show_nav_hedge_plan": "对冲计划",
"show_settings_transfer": "永续资金划转", "show_settings_transfer": "永续资金划转",
"show_settings_export": "数据导出", "show_settings_export": "数据导出",
"show_settings_password": "账户密码修改", "show_settings_password": "账户密码修改",
"show_settings_options_swap": "期权币种兑换", "show_settings_options_swap": "期权币种兑换",
"show_settings_options_transfer": "期权资金划转", "show_settings_options_transfer": "期权资金划转",
} }
NAV_TAB_ALLOWED: dict[str, str] = { NAV_TAB_ALLOWED: dict[str, str] = {
"dashboard": "show_nav_dashboard", "dashboard": "show_nav_dashboard",
"account_ledger": "show_nav_account_ledger", "account_ledger": "show_nav_account_ledger",
"key_monitor": "show_nav_key_monitor", "key_monitor": "show_nav_key_monitor",
"trade": "show_nav_trade", "trade": "show_nav_trade",
"strategy": "show_nav_strategy", "strategy": "show_nav_strategy",
"strategy_records": "show_nav_strategy_records", "strategy_records": "show_nav_strategy_records",
"records": "show_nav_records", "records": "show_nav_records",
"stats": "show_nav_stats", "stats": "show_nav_stats",
"risk_policy": "show_nav_risk_policy", "risk_policy": "show_nav_risk_policy",
"system_guide": "show_nav_system_guide", "system_guide": "show_nav_system_guide",
"env_config": "show_nav_env_config", "env_config": "show_nav_env_config",
"options": "show_nav_options", "options": "show_nav_options",
"options_review": "show_nav_options_review", "options_review": "show_nav_options_review",
"hedge_plan": "show_nav_hedge_plan", "hedge_plan": "show_nav_hedge_plan",
} }
def normalize_display_prefs(raw: dict | None) -> dict[str, bool]: def normalize_display_prefs(raw: dict | None) -> dict[str, bool]:
out = dict(DEFAULT_INSTANCE_DISPLAY) out = dict(DEFAULT_INSTANCE_DISPLAY)
if isinstance(raw, dict): if isinstance(raw, dict):
for key in DEFAULT_INSTANCE_DISPLAY: for key in DEFAULT_INSTANCE_DISPLAY:
if key in raw: if key in raw:
out[key] = bool(raw[key]) out[key] = bool(raw[key])
return out return out
def _load_from_conn(conn) -> dict[str, bool]: def _load_from_conn(conn) -> dict[str, bool]:
stored = runtime_get_prefix(conn, DISPLAY_RUNTIME_PREFIX) stored = runtime_get_prefix(conn, DISPLAY_RUNTIME_PREFIX)
merged: dict[str, Any] = {} merged: dict[str, Any] = {}
for key in DEFAULT_INSTANCE_DISPLAY: for key in DEFAULT_INSTANCE_DISPLAY:
sk = key sk = key
if sk in stored: if sk in stored:
merged[key] = stored[sk].strip().lower() in ("1", "true", "yes", "on") merged[key] = stored[sk].strip().lower() in ("1", "true", "yes", "on")
return normalize_display_prefs(merged) return normalize_display_prefs(merged)
def get_display_prefs(get_db: Callable) -> dict[str, bool]: def get_display_prefs(get_db: Callable) -> dict[str, bool]:
return with_db(get_db, _load_from_conn) return with_db(get_db, _load_from_conn)
def save_display_prefs(get_db: Callable, prefs: dict) -> dict[str, bool]: def save_display_prefs(get_db: Callable, prefs: dict) -> dict[str, bool]:
normalized = normalize_display_prefs(prefs) normalized = normalize_display_prefs(prefs)
def _save(conn): def _save(conn):
mapping = {DISPLAY_RUNTIME_PREFIX + k: ("1" if v else "0") for k, v in normalized.items()} mapping = {DISPLAY_RUNTIME_PREFIX + k: ("1" if v else "0") for k, v in normalized.items()}
runtime_set_many(conn, mapping) runtime_set_many(conn, mapping)
return normalized return normalized
return with_db(get_db, _save) return with_db(get_db, _save)
def display_prefs_template_context(get_db: Callable) -> dict[str, Any]: def display_prefs_template_context(get_db: Callable) -> dict[str, Any]:
prefs = get_display_prefs(get_db) prefs = get_display_prefs(get_db)
return {"display": prefs, "display_meta": display_meta_for_ui()} return {"display": prefs, "display_meta": display_meta_for_ui()}
def tab_allowed(tab: str, display: Optional[dict[str, bool]] = None) -> bool: def tab_allowed(tab: str, display: Optional[dict[str, bool]] = None) -> bool:
prefs = normalize_display_prefs(display or {}) prefs = normalize_display_prefs(display or {})
t = (tab or "").strip() t = (tab or "").strip()
if t in ("trade", "records", "stats"): if t in ("trade", "records", "stats"):
return False # 实盘下单 / 交易记录复盘 / 统计分析 已移除 return False # 实盘下单 / 交易记录复盘 / 统计分析 已移除
key = NAV_TAB_ALLOWED.get(t) key = NAV_TAB_ALLOWED.get(t)
if not key: if not key:
return True return True
return bool(prefs.get(key, True)) return bool(prefs.get(key, True))
def display_meta_for_ui() -> list[dict[str, Any]]: def display_meta_for_ui() -> list[dict[str, Any]]:
nav_keys = [ nav_keys = [
"show_nav_dashboard", "show_nav_dashboard",
"show_nav_account_ledger", "show_nav_account_ledger",
"show_nav_key_monitor", "show_nav_key_monitor",
"show_nav_risk_policy", "show_nav_risk_policy",
"show_nav_system_guide", "show_nav_system_guide",
"show_nav_env_config", "show_nav_env_config",
"show_nav_options", "show_nav_options",
"show_nav_options_review", "show_nav_options_review",
"show_nav_hedge_plan", "show_nav_hedge_plan",
] ]
settings_keys = [ settings_keys = [
"show_settings_transfer", "show_settings_transfer",
"show_settings_export", "show_settings_export",
"show_settings_password", "show_settings_password",
"show_settings_options_swap", "show_settings_options_swap",
"show_settings_options_transfer", "show_settings_options_transfer",
] ]
return [ return [
{"group": "顶栏导航", "entries": [{"key": k, "label": DISPLAY_LABELS[k]} for k in nav_keys]}, {"group": "顶栏导航", "entries": [{"key": k, "label": DISPLAY_LABELS[k]} for k in nav_keys]},
{"group": "系统设置区块", "entries": [{"key": k, "label": DISPLAY_LABELS[k]} for k in settings_keys]}, {"group": "系统设置区块", "entries": [{"key": k, "label": DISPLAY_LABELS[k]} for k in settings_keys]},
] ]
-1
View File
@@ -40,7 +40,6 @@ _COMMON_STATIC_ASSETS: dict[str, str] = {
"options_settings.js": "application/javascript; charset=utf-8", "options_settings.js": "application/javascript; charset=utf-8",
"options_position_cards.js": "application/javascript; charset=utf-8", "options_position_cards.js": "application/javascript; charset=utf-8",
"options_review.js": "application/javascript; charset=utf-8", "options_review.js": "application/javascript; charset=utf-8",
"hedge_plan.js": "application/javascript; charset=utf-8",
"order_entry_model.js": "application/javascript; charset=utf-8", "order_entry_model.js": "application/javascript; charset=utf-8",
"focus_chart_page.js": "application/javascript; charset=utf-8", "focus_chart_page.js": "application/javascript; charset=utf-8",
"focus_chart_page.css": "text/css; charset=utf-8", "focus_chart_page.css": "text/css; charset=utf-8",
+1 -1
View File
@@ -155,7 +155,7 @@ const ORDER_ENTRY_MODEL_CODE_TO_CATEGORY = {{ entry_model_code_to_category | toj
<script> <script>
window.__INSTANCE_DISPLAY__ = {{ display | tojson }}; window.__INSTANCE_DISPLAY__ = {{ display | tojson }};
</script> </script>
<script src="/static/instance_settings_prefs.js?v=23"></script> <script src="/static/instance_settings_prefs.js?v=24"></script>
<script src="/static/instance_live.js?v=7"></script> <script src="/static/instance_live.js?v=7"></script>
<script src="/static/instance_embed.js?v=32"></script> <script src="/static/instance_embed.js?v=32"></script>
<script src="/static/instance_mobile_nav.js?v=2"></script> <script src="/static/instance_mobile_nav.js?v=2"></script>
+1 -1
View File
@@ -2097,6 +2097,6 @@ document.addEventListener("DOMContentLoaded", function () {
}); });
{% endif %} {% endif %}
</script> </script>
<script src="/static/instance_settings_prefs.js?v=23"></script> <script src="/static/instance_settings_prefs.js?v=24"></script>
</body> </body>
</html> </html>
+74
View File
@@ -0,0 +1,74 @@
"""OKX 交易模式(币本位期权专用机:仅单独期权).
历史 .env 若仍为 perp_options / options_options,读取时归一为 options,
对冲模块已移除,不可再启用.
"""
from __future__ import annotations
import os
from typing import Optional
MODE_OPTIONS = "options"
# 兼容旧配置名,不再作为可选模式
MODE_PERP = "perp_options"
MODE_OO = "options_options"
VALID_MODES = frozenset({MODE_OPTIONS})
UI_MODES = frozenset({MODE_OPTIONS})
_ALIASES = {
"option": MODE_OPTIONS,
"standalone": MODE_OPTIONS,
"期权": MODE_OPTIONS,
"单独期权": MODE_OPTIONS,
"po": MODE_OPTIONS,
"perp": MODE_OPTIONS,
"perp_options": MODE_OPTIONS,
"永期": MODE_OPTIONS,
"永期对冲": MODE_OPTIONS,
"oo": MODE_OPTIONS,
"options_options": MODE_OPTIONS,
"期期": MODE_OPTIONS,
"期期对冲": MODE_OPTIONS,
}
def normalize_okx_trade_mode(raw: Optional[str] = None) -> str:
s = str(raw or "").strip().lower()
if s in VALID_MODES:
return s
if s in _ALIASES:
return _ALIASES[s]
# 未知或空 → 单独期权
return MODE_OPTIONS
def legacy_infer_okx_trade_mode() -> str:
return MODE_OPTIONS
def get_okx_trade_mode() -> str:
return normalize_okx_trade_mode(os.getenv("OKX_TRADE_MODE"))
def hedge_module_enabled() -> bool:
return False
def show_perp_options() -> bool:
return False
def show_options_options() -> bool:
return False
def standalone_options_open_allowed() -> bool:
return True
def mode_label(mode: Optional[str] = None) -> str:
return "单独期权"
def block_standalone_open_by_mode_msg() -> Optional[str]:
return None
+106 -15
View File
@@ -145,6 +145,10 @@ def close_option_by_bid1(
premium_paid = _open_premium_paid(cfg, inst_id) premium_paid = _open_premium_paid(cfg, inst_id)
if premium_paid is None: if premium_paid is None:
premium_paid = _safe_float(pos.get("premium_paid")) premium_paid = _safe_float(pos.get("premium_paid"))
from lib.options.options_notify_lib import resolve_premium_ccy
premium_ccy = resolve_premium_ccy(pos.get("premium_ccy"), inst_id=inst_id)
index_px = _safe_float(pos.get("idxPx")) or _safe_float(q.get("index_px"))
# 已有未成交卖平单:等成交,不撤不重挂 # 已有未成交卖平单:等成交,不撤不重挂
try: try:
@@ -194,7 +198,13 @@ def close_option_by_bid1(
) )
if preview.get("bid_invalid") or preview.get("auto_close_blocked"): if preview.get("bid_invalid") or preview.get("auto_close_blocked"):
# 不撤他人挂单:仅拒绝本轮下单 # 不撤他人挂单:仅拒绝本轮下单
update_close_gate(inst_id, recycle_usdc=None, premium_paid=premium_paid) update_close_gate(
inst_id,
recycle_usdc=None,
premium_paid=premium_paid,
premium_ccy=premium_ccy,
index_px=index_px,
)
return { return {
"ok": False, "ok": False,
"msg": preview.get("bid_invalid_reason") or "暂无有效买盘,禁止平仓", "msg": preview.get("bid_invalid_reason") or "暂无有效买盘,禁止平仓",
@@ -208,7 +218,13 @@ def close_option_by_bid1(
bid_px = _safe_float(q.get("bid")) bid_px = _safe_float(q.get("bid"))
stub, stub_reason = is_stub_bid_px(bid_px, mark_px=mark_px, intrinsic_px=intrinsic_px) stub, stub_reason = is_stub_bid_px(bid_px, mark_px=mark_px, intrinsic_px=intrinsic_px)
if stub or bid_px is None or bid_px <= 0: if stub or bid_px is None or bid_px <= 0:
update_close_gate(inst_id, recycle_usdc=None, premium_paid=premium_paid) update_close_gate(
inst_id,
recycle_usdc=None,
premium_paid=premium_paid,
premium_ccy=premium_ccy,
index_px=index_px,
)
return { return {
"ok": False, "ok": False,
"msg": stub_reason or "暂无买一,无法限价平仓", "msg": stub_reason or "暂无买一,无法限价平仓",
@@ -231,7 +247,13 @@ def close_option_by_bid1(
stub_lv, stub_lv_reason = is_stub_bid_px(level_px, mark_px=mark_px, intrinsic_px=intrinsic_px) stub_lv, stub_lv_reason = is_stub_bid_px(level_px, mark_px=mark_px, intrinsic_px=intrinsic_px)
if stub_lv: if stub_lv:
update_close_gate(inst_id, recycle_usdc=None, premium_paid=premium_paid) update_close_gate(
inst_id,
recycle_usdc=None,
premium_paid=premium_paid,
premium_ccy=premium_ccy,
index_px=index_px,
)
return { return {
"ok": False, "ok": False,
"msg": stub_lv_reason or "暂无有效买盘,禁止平仓", "msg": stub_lv_reason or "暂无有效买盘,禁止平仓",
@@ -240,16 +262,18 @@ def close_option_by_bid1(
"liquidity_blocked": True, "liquidity_blocked": True,
} }
# 自动平仓:2×权利金门控(首次);通过后同仓续批只验流动性 # 自动平仓:目标门控(首次);通过后同仓续批只验流动性
gate = update_close_gate( gate = update_close_gate(
inst_id, inst_id,
recycle_usdc=_safe_float(preview.get("total_received")), recycle_usdc=_safe_float(preview.get("total_received")),
premium_paid=premium_paid, premium_paid=premium_paid,
premium_ccy=premium_ccy,
index_px=index_px,
) )
if require_recycle_gate and not is_close_gate_passed(inst_id) and not gate.get("ready"): if require_recycle_gate and not is_close_gate_passed(inst_id) and not gate.get("ready"):
return { return {
"ok": False, "ok": False,
"msg": gate.get("msg") or "平仓门控未就绪(需可回收≥2×权利金并持续一段时间)", "msg": gate.get("msg") or "平仓门控未就绪(需满足目标门控并持续一段时间)",
"stopped_reason": "close_gate", "stopped_reason": "close_gate",
"auto_close_blocked": True, "auto_close_blocked": True,
"close_gate": gate, "close_gate": gate,
@@ -282,7 +306,8 @@ def close_option_by_bid1(
px = float(order.get("px", locked_bid_px)) px = float(order.get("px", locked_bid_px))
oid = str((order.get("data") or {}).get("ordId") or "") oid = str((order.get("data") or {}).get("ordId") or "")
prem_recv = round(total_premium(px, level_sheets * ct_mult), 4) # 本批预估收回(按挂单张数);成交后按实际减仓张数重算并入累计
prem_recv_submit = round(total_premium(px, level_sheets * ct_mult), 4)
time.sleep(0.6) time.sleep(0.6)
invalidate_option_positions_cache() invalidate_option_positions_cache()
raw2 = cfg["fetch_option_positions"](ex) raw2 = cfg["fetch_option_positions"](ex)
@@ -301,14 +326,47 @@ def close_option_by_bid1(
reduced = max(0, before_avail - after_avail) reduced = max(0, before_avail - after_avail)
remaining_pos = after_avail remaining_pos = after_avail
fully_closed = remaining_pos < 1 fully_closed = remaining_pos < 1
filled_sheets = min(reduced, level_sheets) if reduced > 0 else 0
# 全平但减仓读数异常时回退挂单张数,避免漏记本批
if fully_closed and filled_sheets < 1:
filled_sheets = level_sheets
batch_recv = (
round(total_premium(px, filled_sheets * ct_mult), 4) if filled_sheets > 0 else 0.0
)
total_recv = batch_recv
close_px = px
merged_ord = oid or None
if fully_closed: if fully_closed:
clear_close_gate(inst_id) clear_close_gate(inst_id)
conn = cfg["get_db"]() conn = cfg["get_db"]()
try: try:
from lib.options.options_db import init_options_tables from lib.options.options_db import (
add_open_close_accum,
get_open_close_accum,
init_options_tables,
)
init_options_tables(conn) init_options_tables(conn)
# 先并入本批,再读累计(含此前分批)
if filled_sheets > 0:
add_open_close_accum(
conn,
inst_id,
recv=batch_recv,
sheets=filled_sheets,
px=px,
ord_id=oid or None,
)
accum = get_open_close_accum(conn, inst_id)
total_recv = round(float(accum.get("recv") or 0), 4)
accum_sheets = int(accum.get("sheets") or 0)
accum_notional = float(accum.get("notional") or 0)
if accum_sheets > 0 and accum_notional > 0:
close_px = round(accum_notional / accum_sheets, 8)
merged_ord = str(accum.get("close_ord_id") or "").strip() or (oid or None)
open_rows = conn.execute( open_rows = conn.execute(
""" """
SELECT id, premium_paid FROM options_trades SELECT id, premium_paid FROM options_trades
@@ -322,16 +380,25 @@ def close_option_by_bid1(
for i, row in enumerate(open_rows): for i, row in enumerate(open_rows):
paid = float(row["premium_paid"] or 0) paid = float(row["premium_paid"] or 0)
if i == len(open_rows) - 1: if i == len(open_rows) - 1:
recv = round(prem_recv - allocated, 4) recv = round(total_recv - allocated, 4)
elif total_paid > 0: elif total_paid > 0:
recv = round(prem_recv * (paid / total_paid), 4) recv = round(total_recv * (paid / total_paid), 4)
allocated += recv allocated += recv
else: else:
recv = round(prem_recv / len(open_rows), 4) recv = round(total_recv / len(open_rows), 4)
allocated += recv allocated += recv
pnl = round(recv - paid, 4) pnl = round(recv - paid, 4)
note_sql = "" note_sql = ""
params: list[Any] = [px, recv, pnl, oid or None] from lib.options.options_time_lib import options_app_now_str
# 占位顺序必须与 SQL 一致: close_quote/recv/pnl/ord/closed_at/[signal_note]/id
params: list[Any] = [
close_px,
recv,
pnl,
merged_ord,
options_app_now_str(),
]
if signal_note and i == len(open_rows) - 1: if signal_note and i == len(open_rows) - 1:
note_sql = """, note_sql = """,
signal_note = CASE signal_note = CASE
@@ -344,7 +411,9 @@ def close_option_by_bid1(
f""" f"""
UPDATE options_trades UPDATE options_trades
SET status = 'closed', close_quote = ?, premium_received = ?, SET status = 'closed', close_quote = ?, premium_received = ?,
realized_pnl = ?, close_ord_id = ?, closed_at = CURRENT_TIMESTAMP realized_pnl = ?, close_ord_id = ?, closed_at = ?,
close_recv_accum = 0, close_sheets_accum = 0,
close_notional_accum = 0
{note_sql} {note_sql}
WHERE id = ? WHERE id = ?
""", """,
@@ -353,6 +422,27 @@ def close_option_by_bid1(
conn.commit() conn.commit()
finally: finally:
conn.close() conn.close()
elif filled_sheets > 0:
# 部分成交:持久化本批收回,下次全平合计
conn = cfg["get_db"]()
try:
from lib.options.options_db import add_open_close_accum, init_options_tables
init_options_tables(conn)
add_open_close_accum(
conn,
inst_id,
recv=batch_recv,
sheets=filled_sheets,
px=px,
ord_id=oid or None,
)
conn.commit()
finally:
conn.close()
if require_recycle_gate:
# 自动平已挂过单:同仓续批只验流动性
mark_close_gate_passed(inst_id)
elif require_recycle_gate: elif require_recycle_gate:
# 自动平已挂过单:同仓续批只验流动性 # 自动平已挂过单:同仓续批只验流动性
mark_close_gate_passed(inst_id) mark_close_gate_passed(inst_id)
@@ -364,11 +454,12 @@ def close_option_by_bid1(
"bid": px, "bid": px,
"locked_bid_px": locked_bid_px, "locked_bid_px": locked_bid_px,
"submitted_sheets": level_sheets, "submitted_sheets": level_sheets,
"filled_or_reduced_sheets": min(reduced, level_sheets) if reduced else 0, "filled_or_reduced_sheets": filled_sheets,
"remaining_sheets": remaining_pos, "remaining_sheets": remaining_pos,
"premium_received": prem_recv, "premium_received": total_recv if fully_closed else (batch_recv or prem_recv_submit),
"batch_premium_received": batch_recv,
"stopped_reason": None if fully_closed else ("partial_bid1" if reduced > 0 else "order_not_filled"), "stopped_reason": None if fully_closed else ("partial_bid1" if reduced > 0 else "order_not_filled"),
"close_ord_id": oid or None, "close_ord_id": merged_ord if fully_closed else (oid or None),
"fully_closed": fully_closed, "fully_closed": fully_closed,
"msg": ( "msg": (
f"已按买一 {locked_bid_px:g} 提交 {level_sheets} 张" f"已按买一 {locked_bid_px:g} 提交 {level_sheets} 张"
+126 -20
View File
@@ -1,4 +1,4 @@
"""期权按买盘平仓门控:可回收需 ≥ N×权利金,并持续持有一段时间后才允许平仓.""" """期权按买盘平仓门控:权利金倍数或净盈亏(USDT)达标并持续 hold 秒后才允许目标平仓."""
from __future__ import annotations from __future__ import annotations
import os import os
@@ -14,12 +14,32 @@ def _env_float(key: str, default: float) -> float:
return default return default
# 可回收 ≥ 权利金 × 倍数,且该状态持续满 hold_seconds 才允许按买盘平仓 def _close_gate_mode() -> str:
raw = (os.getenv("OKX_OPTIONS_CLOSE_GATE_MODE") or "premium").strip().lower()
return raw if raw in ("premium", "net_pnl") else "premium"
def _close_recycle_mult(premium_ccy: str) -> float:
ccy = (premium_ccy or "USDC").strip().upper()
if ccy in ("ETH", "BTC"):
return _env_float("OKX_OPTIONS_CLOSE_RECYCLE_MULT_COIN", 1.05)
return _env_float("OKX_OPTIONS_CLOSE_RECYCLE_MULT", 2.0)
def _close_hold_seconds() -> float:
return _env_float("OKX_OPTIONS_CLOSE_HOLD_SECONDS", 120.0)
def _close_net_pnl_min_usdt() -> float:
return _env_float("OKX_OPTIONS_CLOSE_NET_PNL_MIN_USDT", 0.0)
# 兼容旧引用
CLOSE_RECYCLE_MIN_MULT = _env_float("OKX_OPTIONS_CLOSE_RECYCLE_MULT", 2.0) CLOSE_RECYCLE_MIN_MULT = _env_float("OKX_OPTIONS_CLOSE_RECYCLE_MULT", 2.0)
CLOSE_RECYCLE_HOLD_SECONDS = _env_float("OKX_OPTIONS_CLOSE_HOLD_SECONDS", 120.0) CLOSE_RECYCLE_HOLD_SECONDS = _env_float("OKX_OPTIONS_CLOSE_HOLD_SECONDS", 120.0)
_lock = threading.Lock() _lock = threading.Lock()
# inst_id -> {"ok_since": float|None, "recycle": float, "premium": float, "updated": float} # inst_id -> gate state
_gates: dict[str, dict[str, Any]] = {} _gates: dict[str, dict[str, Any]] = {}
@@ -41,7 +61,7 @@ def clear_close_gate(inst_id: str | None = None) -> None:
def mark_close_gate_passed(inst_id: str) -> None: def mark_close_gate_passed(inst_id: str) -> None:
"""标记同仓已通过 2× 门控,续批平仓只验流动性.""" """标记同仓已通过门控,续批平仓只验流动性."""
inst = (inst_id or "").strip() inst = (inst_id or "").strip()
if not inst: if not inst:
return return
@@ -68,6 +88,30 @@ def _fmt_gate_amt(v: float, *, ccy: str) -> str:
return f"{float(v):.4f}" return f"{float(v):.4f}"
def _fmt_usdt(v: float) -> str:
sign = "+" if v >= 0 else ""
return f"{sign}{float(v):.2f}"
def _net_pnl_usdt(
*,
recv: float | None,
prem: float | None,
premium_ccy: str,
index_px: float | None,
) -> float | None:
if recv is None or prem is None:
return None
net = float(recv) - float(prem)
ccy = (premium_ccy or "USDC").strip().upper() or "USDC"
if ccy in ("ETH", "BTC"):
idx = _safe_float(index_px)
if idx is None or idx <= 0:
return None
return net * float(idx)
return net
def update_close_gate( def update_close_gate(
inst_id: str, inst_id: str,
*, *,
@@ -77,9 +121,14 @@ def update_close_gate(
min_mult: float | None = None, min_mult: float | None = None,
hold_seconds: float | None = None, hold_seconds: float | None = None,
premium_ccy: str | None = None, premium_ccy: str | None = None,
index_px: float | None = None,
gate_mode: str | None = None,
net_pnl_min_usdt: float | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
""" """
根据当前买盘可回收金额刷新门控. 根据当前买盘可回收金额刷新门控.
premium: 可回收 ≥ 权利金×倍数(币本位默认×1.05,USDC 默认×2)
net_pnl: 净盈亏(买一可回收−权利金)折 USDT ≥ 阈值(默认 0U)
条件不满足时重置计时;满足时从首次满足起累计持续时间. 条件不满足时重置计时;满足时从首次满足起累计持续时间.
""" """
inst = (inst_id or "").strip() inst = (inst_id or "").strip()
@@ -91,15 +140,16 @@ def update_close_gate(
"msg": "缺少合约", "msg": "缺少合约",
} }
ts = float(now if now is not None else time.time()) ts = float(now if now is not None else time.time())
mult = float(min_mult if min_mult is not None else CLOSE_RECYCLE_MIN_MULT) mode = (gate_mode or _close_gate_mode()).strip().lower()
hold = float(hold_seconds if hold_seconds is not None else CLOSE_RECYCLE_HOLD_SECONDS) if mode not in ("premium", "net_pnl"):
if mult <= 0: mode = "premium"
mult = 2.0 hold = float(hold_seconds if hold_seconds is not None else _close_hold_seconds())
if hold < 0: if hold < 0:
hold = 0.0 hold = 0.0
with _lock: with _lock:
prev_ccy = (_gates.get(inst) or {}).get("premium_ccy") prev = _gates.get(inst) or {}
prev_ccy = prev.get("premium_ccy")
ccy = (premium_ccy or prev_ccy or "USDC").strip().upper() or "USDC" ccy = (premium_ccy or prev_ccy or "USDC").strip().upper() or "USDC"
if ccy not in ("ETH", "BTC", "USDC"): if ccy not in ("ETH", "BTC", "USDC"):
ccy = "USDC" ccy = "USDC"
@@ -107,21 +157,44 @@ def update_close_gate(
prem = _safe_float(premium_paid) prem = _safe_float(premium_paid)
recv = _safe_float(recycle_usdc) recv = _safe_float(recycle_usdc)
mult = float(min_mult if min_mult is not None else _close_recycle_mult(ccy))
if mult <= 0:
mult = _close_recycle_mult(ccy)
min_pnl_u = float(net_pnl_min_usdt if net_pnl_min_usdt is not None else _close_net_pnl_min_usdt())
idx = _safe_float(index_px)
if idx is None:
idx = _safe_float(prev.get("index_px"))
need = round(prem * mult, need_decimals) if prem is not None and prem > 0 else None need = round(prem * mult, need_decimals) if prem is not None and prem > 0 else None
recycle_ok = bool( net_pnl_u = _net_pnl_usdt(recv=recv, prem=prem, premium_ccy=ccy, index_px=idx)
prem is not None and prem > 0 and recv is not None and need is not None and recv + 1e-12 >= need
) if mode == "net_pnl":
if prem is None or prem <= 0:
condition_ok = False
elif recv is None:
condition_ok = False
elif net_pnl_u is None:
condition_ok = False
else:
condition_ok = bool(net_pnl_u + 1e-9 >= min_pnl_u)
else:
condition_ok = bool(
prem is not None and prem > 0 and recv is not None and need is not None and recv + 1e-12 >= need
)
with _lock: with _lock:
prev = _gates.get(inst) or {} prev = _gates.get(inst) or {}
prev_mode = prev.get("gate_mode")
ok_since = prev.get("ok_since") ok_since = prev.get("ok_since")
if recycle_ok: if prev_mode != mode:
ok_since = None
if condition_ok:
if ok_since is None: if ok_since is None:
ok_since = ts ok_since = ts
else: else:
ok_since = None ok_since = None
held = (ts - float(ok_since)) if ok_since is not None else 0.0 held = (ts - float(ok_since)) if ok_since is not None else 0.0
ready = bool(recycle_ok and held + 1e-9 >= hold) ready = bool(condition_ok and held + 1e-9 >= hold)
prev_passed = bool(prev.get("passed")) prev_passed = bool(prev.get("passed"))
passed = prev_passed or ready passed = prev_passed or ready
state = { state = {
@@ -134,15 +207,38 @@ def update_close_gate(
"hold_seconds": hold, "hold_seconds": hold,
"passed": passed, "passed": passed,
"premium_ccy": ccy, "premium_ccy": ccy,
"gate_mode": mode,
"net_pnl_usdt": net_pnl_u,
"net_pnl_min_usdt": min_pnl_u,
"index_px": idx,
} }
_gates[inst] = state _gates[inst] = state
remain = max(0.0, hold - held) if recycle_ok and not ready else None remain = max(0.0, hold - held) if condition_ok and not ready else None
if prem is None or prem <= 0: if mode == "net_pnl":
if prem is None or prem <= 0:
msg = "缺少权利金,无法校验平仓门控"
elif recv is None:
msg = "暂无有效买盘可回收金额"
elif net_pnl_u is None:
msg = "缺少指数价,无法将币本位净盈亏折算为 USDT"
elif not condition_ok:
msg = (
f"净盈亏 {_fmt_usdt(net_pnl_u)} U(估) < {min_pnl_u:g} U,"
f"目标平仓门控未过"
)
elif not ready:
msg = (
f"净盈亏 {_fmt_usdt(net_pnl_u)} U(估) ≥ {min_pnl_u:g} U,"
f"需再持续 {remain:.0f}s(已 {held:.0f}/{hold:.0f}s)门控才通过"
)
else:
msg = f"净盈亏 ≥ {min_pnl_u:g} U 且持续≥{hold:.0f}s,目标触达后可按买一平仓"
elif prem is None or prem <= 0:
msg = "缺少权利金,无法校验平仓门控" msg = "缺少权利金,无法校验平仓门控"
elif recv is None: elif recv is None:
msg = "暂无有效买盘可回收金额" msg = "暂无有效买盘可回收金额"
elif not recycle_ok: elif not condition_ok:
msg = ( msg = (
f"可回收 {_fmt_gate_amt(recv, ccy=ccy)} {ccy} < 权利金×{mult:g}" f"可回收 {_fmt_gate_amt(recv, ccy=ccy)} {ccy} < 权利金×{mult:g}"
f"({_fmt_gate_amt(need, ccy=ccy)}),目标平仓门控未过" f"({_fmt_gate_amt(need, ccy=ccy)}),目标平仓门控未过"
@@ -160,19 +256,23 @@ def update_close_gate(
"ok": True, "ok": True,
"ready": ready, "ready": ready,
"passed": passed, "passed": passed,
"recycle_ok": recycle_ok, "recycle_ok": condition_ok,
"recycle_usdc": recv, "recycle_usdc": recv,
"premium_paid": prem, "premium_paid": prem,
"need_recycle_usdc": need, "need_recycle_usdc": need,
"premium_ccy": ccy, "premium_ccy": ccy,
"min_mult": mult, "min_mult": mult,
"hold_seconds": hold, "hold_seconds": hold,
"held_seconds": round(held, 1) if recycle_ok else 0.0, "held_seconds": round(held, 1) if condition_ok else 0.0,
"remain_seconds": round(remain, 1) if remain is not None else None, "remain_seconds": round(remain, 1) if remain is not None else None,
"ok_since": ok_since, "ok_since": ok_since,
"msg": msg, "msg": msg,
"auto_close_blocked": auto_blocked, "auto_close_blocked": auto_blocked,
"close_gate_blocked": auto_blocked, "close_gate_blocked": auto_blocked,
"gate_mode": mode,
"net_pnl_usdt": net_pnl_u,
"net_pnl_min_usdt": min_pnl_u,
"index_px": idx,
} }
@@ -182,12 +282,13 @@ def check_close_gate(
recycle_usdc: float | None = None, recycle_usdc: float | None = None,
premium_paid: float | None = None, premium_paid: float | None = None,
premium_ccy: str | None = None, premium_ccy: str | None = None,
index_px: float | None = None,
refresh: bool = True, refresh: bool = True,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""检查是否允许平仓;默认先用最新回收/权利金刷新.""" """检查是否允许平仓;默认先用最新回收/权利金刷新."""
inst = (inst_id or "").strip() inst = (inst_id or "").strip()
if refresh: if refresh:
if recycle_usdc is None or premium_paid is None or premium_ccy is None: if recycle_usdc is None or premium_paid is None or premium_ccy is None or index_px is None:
with _lock: with _lock:
prev = _gates.get(inst) or {} prev = _gates.get(inst) or {}
if recycle_usdc is None: if recycle_usdc is None:
@@ -196,11 +297,14 @@ def check_close_gate(
premium_paid = prev.get("premium") premium_paid = prev.get("premium")
if premium_ccy is None: if premium_ccy is None:
premium_ccy = prev.get("premium_ccy") premium_ccy = prev.get("premium_ccy")
if index_px is None:
index_px = prev.get("index_px")
return update_close_gate( return update_close_gate(
inst, inst,
recycle_usdc=recycle_usdc, recycle_usdc=recycle_usdc,
premium_paid=premium_paid, premium_paid=premium_paid,
premium_ccy=premium_ccy, premium_ccy=premium_ccy,
index_px=index_px,
) )
with _lock: with _lock:
prev = _gates.get(inst) prev = _gates.get(inst)
@@ -210,10 +314,12 @@ def check_close_gate(
recycle_usdc=recycle_usdc, recycle_usdc=recycle_usdc,
premium_paid=premium_paid, premium_paid=premium_paid,
premium_ccy=premium_ccy, premium_ccy=premium_ccy,
index_px=index_px,
) )
return update_close_gate( return update_close_gate(
inst, inst,
recycle_usdc=recycle_usdc if recycle_usdc is not None else prev.get("recycle"), recycle_usdc=recycle_usdc if recycle_usdc is not None else prev.get("recycle"),
premium_paid=premium_paid if premium_paid is not None else prev.get("premium"), premium_paid=premium_paid if premium_paid is not None else prev.get("premium"),
premium_ccy=premium_ccy if premium_ccy is not None else prev.get("premium_ccy"), premium_ccy=premium_ccy if premium_ccy is not None else prev.get("premium_ccy"),
index_px=index_px if index_px is not None else prev.get("index_px"),
) )
+5 -2
View File
@@ -400,6 +400,8 @@ def open_coin_option_buy_full(
def _insert_coin_trade(conn: Any, **kwargs: Any) -> int: def _insert_coin_trade(conn: Any, **kwargs: Any) -> int:
from lib.options.options_time_lib import options_app_now_str
pe = 1 if kwargs.get("profit_exit_enabled") else 0 pe = 1 if kwargs.get("profit_exit_enabled") else 0
pe_mult = float(kwargs.get("profit_exit_mult") or 1.0) pe_mult = float(kwargs.get("profit_exit_mult") or 1.0)
pe_state = "active" if pe else "idle" pe_state = "active" if pe else "idle"
@@ -409,8 +411,8 @@ def _insert_coin_trade(conn: Any, **kwargs: Any) -> int:
inst_id, underlying, opt_type, strike, exp_time, sheets, eth_amount, inst_id, underlying, opt_type, strike, exp_time, sheets, eth_amount,
open_quote, premium_paid, status, signal_note, exchange_ord_id, open_quote, premium_paid, status, signal_note, exchange_ord_id,
margin_mode, premium_ccy, bridge_id, budget_usdt, margin_mode, premium_ccy, bridge_id, budget_usdt,
profit_exit_enabled, profit_exit_mult, profit_exit_state profit_exit_enabled, profit_exit_mult, profit_exit_state, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'open', ?, ?, 'coin', ?, ?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'open', ?, ?, 'coin', ?, ?, ?, ?, ?, ?, ?)
""", """,
( (
kwargs["inst_id"], kwargs["inst_id"],
@@ -430,6 +432,7 @@ def _insert_coin_trade(conn: Any, **kwargs: Any) -> int:
pe, pe,
pe_mult, pe_mult,
pe_state, pe_state,
options_app_now_str(),
), ),
) )
conn.commit() conn.commit()
+77 -85
View File
@@ -1,85 +1,77 @@
"""实例数据看板用的轻量期权持仓(无余额/历史;含 close_preview 供净盈亏).""" """实例数据看板用的轻量期权持仓(无余额/历史;含 close_preview 供净盈亏)."""
from __future__ import annotations from __future__ import annotations
from typing import Any from typing import Any
def fetch_light_option_positions_for_dashboard(cfg: dict[str, Any]) -> list[dict[str, Any]]: def fetch_light_option_positions_for_dashboard(cfg: dict[str, Any]) -> list[dict[str, Any]]:
""" """
拉期权持仓 + 本地目标/对冲标注 + 买一净盈亏预览,供看板后台聚合. 拉期权持仓 + 本地目标/对冲标注 + 买一净盈亏预览,供看板后台聚合.
不走 options dashboard snapshot(避免余额/历史). 不走 options dashboard snapshot(避免余额/历史).
""" """
if not cfg.get("enabled"): if not cfg.get("enabled"):
return [] return []
ex = cfg.get("exchange_options") ex = cfg.get("exchange_options")
ready_fn = cfg.get("options_api_ready") ready_fn = cfg.get("options_api_ready")
if not callable(ready_fn): if not callable(ready_fn):
return [] return []
ok, _reason = ready_fn(ex) ok, _reason = ready_fn(ex)
if not ok: if not ok:
return [] return []
fetch_fn = cfg.get("fetch_option_positions") fetch_fn = cfg.get("fetch_option_positions")
if not callable(fetch_fn): if not callable(fetch_fn):
return [] return []
raw = fetch_fn(ex) raw = fetch_fn(ex)
if raw is None: if raw is None:
return [] return []
if not raw: if not raw:
return [] return []
from lib.options.options_db import init_options_tables, sum_open_premium_paid from lib.options.options_db import init_options_tables, sum_open_premium_paid
from lib.options.options_history_lib import enrich_position_row_display from lib.options.options_history_lib import enrich_position_row_display
from lib.options.options_positions_lib import attach_close_preview from lib.options.options_positions_lib import attach_close_preview
meta_cache: dict[str, dict[str, Any] | None] = {} meta_cache: dict[str, dict[str, Any] | None] = {}
rows: list[dict[str, Any]] = [] rows: list[dict[str, Any]] = []
get_db = cfg.get("get_db") get_db = cfg.get("get_db")
if not callable(get_db): if not callable(get_db):
for p in raw: for p in raw:
if not isinstance(p, dict): if not isinstance(p, dict):
continue continue
row = enrich_position_row_display(cfg, ex, p, meta_cache=meta_cache) row = enrich_position_row_display(cfg, ex, p, meta_cache=meta_cache)
attach_close_preview(cfg, ex, row) attach_close_preview(cfg, ex, row)
rows.append(row) rows.append(row)
return rows return rows
conn = get_db() conn = get_db()
try: try:
init_options_tables(conn) init_options_tables(conn)
try: try:
from lib.hedge_plan.hedge_plan_db import active_options_targets_by_inst from lib.options.options_target_lib import targets_by_inst
from lib.options.options_target_lib import targets_by_inst
tgt_map = targets_by_inst(conn)
tgt_map = targets_by_inst(conn) except Exception:
hedge_target_map = active_options_targets_by_inst(conn) tgt_map = {}
except Exception:
tgt_map = {} for p in raw:
hedge_target_map = {} if not isinstance(p, dict):
continue
for p in raw: inst = str(p.get("instId") or "").strip()
if not isinstance(p, dict): premium_override = sum_open_premium_paid(conn, inst) if inst else None
continue row = enrich_position_row_display(
inst = str(p.get("instId") or "").strip() cfg,
premium_override = sum_open_premium_paid(conn, inst) if inst else None ex,
row = enrich_position_row_display( p,
cfg, meta_cache=meta_cache,
ex, premium_override=premium_override,
p, )
meta_cache=meta_cache, attach_close_preview(cfg, ex, row, premium_paid=premium_override)
premium_override=premium_override, mon = tgt_map.get(str(row.get("inst_id") or ""))
) if mon:
attach_close_preview(cfg, ex, row, premium_paid=premium_override) row["target_index"] = mon.get("target_index")
mon = tgt_map.get(str(row.get("inst_id") or "")) row["target_monitor_id"] = mon.get("id")
if mon: row["target_monitor"] = mon
row["target_index"] = mon.get("target_index") rows.append(row)
row["target_monitor_id"] = mon.get("id") finally:
row["target_monitor"] = mon conn.close()
hedge_target = hedge_target_map.get(str(row.get("inst_id") or "")) return rows
if hedge_target:
row["hedge_plan_target"] = hedge_target
if not mon:
row["target_index"] = hedge_target.get("target_index")
rows.append(row)
finally:
conn.close()
return rows
+87
View File
@@ -105,6 +105,10 @@ def init_options_tables(conn: sqlite3.Connection) -> None:
"ALTER TABLE options_trades ADD COLUMN premium_ccy TEXT DEFAULT 'USDC'", "ALTER TABLE options_trades ADD COLUMN premium_ccy TEXT DEFAULT 'USDC'",
"ALTER TABLE options_trades ADD COLUMN bridge_id INTEGER", "ALTER TABLE options_trades ADD COLUMN bridge_id INTEGER",
"ALTER TABLE options_trades ADD COLUMN budget_usdt REAL", "ALTER TABLE options_trades ADD COLUMN budget_usdt REAL",
# 分批平仓累计:全平前写在最早一笔 open 上,全平后并入 premium_received
"ALTER TABLE options_trades ADD COLUMN close_recv_accum REAL DEFAULT 0",
"ALTER TABLE options_trades ADD COLUMN close_sheets_accum INTEGER DEFAULT 0",
"ALTER TABLE options_trades ADD COLUMN close_notional_accum REAL DEFAULT 0",
): ):
try: try:
conn.execute(ddl) conn.execute(ddl)
@@ -154,3 +158,86 @@ def sum_open_sheets(conn: sqlite3.Connection, inst_id: str) -> int | None:
if not row or int(row["n"] or 0) < 1: if not row or int(row["n"] or 0) < 1:
return None return None
return int(row["total"] or 0) return int(row["total"] or 0)
def _earliest_open_trade_id(conn: sqlite3.Connection, inst_id: str) -> int | None:
inst = (inst_id or "").strip()
if not inst:
return None
row = conn.execute(
"""
SELECT id FROM options_trades
WHERE inst_id = ? AND status = 'open'
ORDER BY id ASC
LIMIT 1
""",
(inst,),
).fetchone()
return int(row["id"]) if row else None
def get_open_close_accum(conn: sqlite3.Connection, inst_id: str) -> dict[str, float | int | str | None]:
"""读取分批平仓累计(挂在最早一笔 open 上)."""
tid = _earliest_open_trade_id(conn, inst_id)
if tid is None:
return {"recv": 0.0, "sheets": 0, "notional": 0.0, "close_ord_id": None}
row = conn.execute(
"""
SELECT close_recv_accum, close_sheets_accum, close_notional_accum, close_ord_id
FROM options_trades WHERE id = ?
""",
(tid,),
).fetchone()
if not row:
return {"recv": 0.0, "sheets": 0, "notional": 0.0, "close_ord_id": None}
return {
"recv": float(row["close_recv_accum"] or 0),
"sheets": int(row["close_sheets_accum"] or 0),
"notional": float(row["close_notional_accum"] or 0),
"close_ord_id": str(row["close_ord_id"] or "").strip() or None,
}
def add_open_close_accum(
conn: sqlite3.Connection,
inst_id: str,
*,
recv: float,
sheets: int,
px: float,
ord_id: str | None = None,
) -> bool:
"""分批平仓成交后累加收回权利金/张数/名义(供全平 VWAP 与总盈亏)."""
tid = _earliest_open_trade_id(conn, inst_id)
if tid is None:
return False
sheets_i = max(0, int(sheets or 0))
recv_f = float(recv or 0)
px_f = float(px or 0)
if sheets_i < 1 and abs(recv_f) < 1e-15:
return False
notional_add = px_f * sheets_i if sheets_i > 0 and px_f > 0 else 0.0
oid = str(ord_id or "").strip()
prev = conn.execute(
"SELECT close_ord_id FROM options_trades WHERE id = ?", (tid,)
).fetchone()
prev_oids = str((prev["close_ord_id"] if prev else None) or "").strip()
if oid:
parts = [p for p in prev_oids.split(",") if p] if prev_oids else []
if oid not in parts:
parts.append(oid)
merged_oid = ",".join(parts)
else:
merged_oid = prev_oids or None
conn.execute(
"""
UPDATE options_trades
SET close_recv_accum = COALESCE(close_recv_accum, 0) + ?,
close_sheets_accum = COALESCE(close_sheets_accum, 0) + ?,
close_notional_accum = COALESCE(close_notional_accum, 0) + ?,
close_ord_id = ?
WHERE id = ?
""",
(recv_f, sheets_i, notional_add, merged_oid, tid),
)
return True
+3 -12
View File
@@ -28,15 +28,12 @@ def build_options_hub_snapshot(cfg: dict[str, Any]) -> dict[str, Any]:
try: try:
conn = cfg["get_db"]() conn = cfg["get_db"]()
try: try:
from lib.hedge_plan.hedge_plan_db import active_options_targets_by_inst
from lib.options.options_profit_exit_lib import profit_exit_by_inst from lib.options.options_profit_exit_lib import profit_exit_by_inst
from lib.options.options_target_lib import list_active_targets, list_closing_targets, targets_by_inst from lib.options.options_target_lib import list_active_targets, list_closing_targets, targets_by_inst
target_monitors = list_active_targets(conn) + list_closing_targets(conn) target_monitors = list_active_targets(conn) + list_closing_targets(conn)
tgt_map = targets_by_inst(conn) tgt_map = targets_by_inst(conn)
hedge_target_map = active_options_targets_by_inst(conn)
profit_exit_map = profit_exit_by_inst(conn) profit_exit_map = profit_exit_by_inst(conn)
target_monitors.extend(hedge_target_map.values())
for pe in profit_exit_map.values(): for pe in profit_exit_map.values():
if pe.get("profit_exit_enabled"): if pe.get("profit_exit_enabled"):
target_monitors.append( target_monitors.append(
@@ -59,12 +56,6 @@ def build_options_hub_snapshot(cfg: dict[str, Any]) -> dict[str, Any]:
p["profit_exit_mult"] = pe.get("profit_exit_mult") p["profit_exit_mult"] = pe.get("profit_exit_mult")
p["profit_exit_state"] = pe.get("profit_exit_state") p["profit_exit_state"] = pe.get("profit_exit_state")
p["profit_exit_required_recycle"] = pe.get("required_recycle") p["profit_exit_required_recycle"] = pe.get("required_recycle")
hedge_target = hedge_target_map.get(str(p.get("inst_id") or ""))
if hedge_target:
p["hedge_plan_target"] = hedge_target
if not mon:
# 中控卡片共用 target_index 只读展示;实际平仓仍由对冲计划监控处理。
p["target_index"] = hedge_target.get("target_index")
try: try:
from lib.instance.instance_dashboard_lib import ( from lib.instance.instance_dashboard_lib import (
_format_options_target, _format_options_target,
@@ -85,13 +76,13 @@ def build_options_hub_snapshot(cfg: dict[str, Any]) -> dict[str, Any]:
conn.close() conn.close()
except Exception: except Exception:
target_monitors = [] target_monitors = []
from lib.options.options_positions_lib import display_pnl_from_option_row from lib.options.options_positions_lib import display_pnl_usdt_from_option_row
upl_total = 0.0 upl_total = 0.0
has_upl = False has_upl = False
for p in positions: for p in positions:
# 与持仓卡展示一致:优先买一净盈亏,残档回退交易所 upl # 合计一律折 U:币本位 × 指数,避免 BTC/ETH 币数量与 USDC 混加
pnl = display_pnl_from_option_row(p) pnl = display_pnl_usdt_from_option_row(p)
if pnl is None: if pnl is None:
continue continue
has_upl = True has_upl = True
+8 -24
View File
@@ -1,16 +1,12 @@
"""期权持仓监控:浮盈翻倍微信提醒 + 平仓/到期状态同步.""" """期权持仓监控:浮盈翻倍微信提醒 + 平仓/到期状态同步."""
from __future__ import annotations from __future__ import annotations
import os
import sqlite3 import sqlite3
import time import time
from datetime import datetime, timezone
from typing import Any, Callable from typing import Any, Callable
from zoneinfo import ZoneInfo
from lib.exchange.okx_options_lib import normalize_option_exp_ms, resolve_option_close_from_history from lib.exchange.okx_options_lib import normalize_option_exp_ms, resolve_option_close_from_history
from lib.options.options_time_lib import ms_to_options_app_str, options_app_now_str, options_wall_clock_to_ms
_APP_TZ = ZoneInfo((os.getenv("APP_TIMEZONE") or os.getenv("TZ") or "Asia/Shanghai").strip() or "Asia/Shanghai")
def _safe_float(v: Any) -> float | None: def _safe_float(v: Any) -> float | None:
@@ -151,18 +147,7 @@ def run_options_profit_alerts(
def _created_at_ms(created_at: Any) -> int | None: def _created_at_ms(created_at: Any) -> int | None:
"""墙钟 created_at → UTC ms.库内时间为业务时区(默认 Asia/Shanghai),不可当 UTC.""" """墙钟 created_at → UTC ms.库内时间为业务时区(默认 Asia/Shanghai),不可当 UTC."""
if not created_at: return options_wall_clock_to_ms(created_at)
return None
raw = str(created_at).strip()
if not raw:
return None
for fmt, ln in (("%Y-%m-%d %H:%M:%S", 19), ("%Y-%m-%d %H:%M:%f", 26), ("%Y-%m-%d %H:%M", 16)):
try:
dt = datetime.strptime(raw[:ln], fmt).replace(tzinfo=_APP_TZ)
return int(dt.timestamp() * 1000)
except ValueError:
continue
return None
def _group_key_for_closed_trade(row: Any) -> str: def _group_key_for_closed_trade(row: Any) -> str:
@@ -327,21 +312,20 @@ def sync_open_options_trades(
prem_recv = round(paid + float(realized_pnl), 4) prem_recv = round(paid + float(realized_pnl), 4)
close_ms = close_info.get("close_ms") close_ms = close_info.get("close_ms")
if close_ms: if close_ms:
closed_at = datetime.fromtimestamp(int(close_ms) / 1000, tz=timezone.utc).strftime( closed_at = ms_to_options_app_str(close_ms)
"%Y-%m-%d %H:%M:%S"
)
elif exp_ms is not None and now_ms >= int(exp_ms): elif exp_ms is not None and now_ms >= int(exp_ms):
close_reason = "expired" close_reason = "expired"
close_quote = 0.0 close_quote = 0.0
prem_recv = 0.0 prem_recv = 0.0
realized_pnl = round(-paid, 4) realized_pnl = round(-paid, 4)
if exp_ms: if exp_ms:
closed_at = datetime.fromtimestamp(int(exp_ms) / 1000, tz=timezone.utc).strftime( closed_at = ms_to_options_app_str(exp_ms)
"%Y-%m-%d %H:%M:%S"
)
else: else:
continue continue
if not closed_at:
closed_at = options_app_now_str()
conn.execute( conn.execute(
""" """
UPDATE options_trades UPDATE options_trades
@@ -350,7 +334,7 @@ def sync_open_options_trades(
premium_received = ?, premium_received = ?,
realized_pnl = ?, realized_pnl = ?,
close_ord_id = COALESCE(?, close_ord_id), close_ord_id = COALESCE(?, close_ord_id),
closed_at = COALESCE(?, closed_at, CURRENT_TIMESTAMP), closed_at = COALESCE(?, closed_at),
signal_note = CASE signal_note = CASE
WHEN ? = 'expired' AND (signal_note IS NULL OR TRIM(signal_note) = '') WHEN ? = 'expired' AND (signal_note IS NULL OR TRIM(signal_note) = '')
THEN '到期结算' THEN '到期结算'
+1 -1
View File
@@ -74,7 +74,7 @@ def option_position_limit_block_msg(
- 拉持仓失败:拒绝开仓(避免绕过上限) - 拉持仓失败:拒绝开仓(避免绕过上限)
""" """
try: try:
from lib.hedge_plan.okx_trade_mode_lib import standalone_options_open_allowed from lib.options.okx_trade_mode_lib import standalone_options_open_allowed
# 对冲模式用 MAX_ACTIVE_HEDGE_PLANS 管「组数」,不占用期权笔数上限 # 对冲模式用 MAX_ACTIVE_HEDGE_PLANS 管「组数」,不占用期权笔数上限
if max_active is None and not standalone_options_open_allowed(): if max_active is None and not standalone_options_open_allowed():
+22 -3
View File
@@ -54,10 +54,15 @@ def attach_close_preview(
max_levels=1, max_levels=1,
) )
premium_ccy = str(row.get("premium_ccy") or "USDC").strip().upper() or "USDC" premium_ccy = str(row.get("premium_ccy") or "USDC").strip().upper() or "USDC"
# 残档时不累计 2×门控;有效买一时刷新计时(仅自动平仓需要) index_px = _safe_float(row.get("idx_px") or row.get("idxPx") or row.get("index_px"))
# 残档时不累计门控;有效买一时刷新计时(仅自动平仓需要)
if preview.get("bid_invalid") or preview.get("auto_close_blocked"): if preview.get("bid_invalid") or preview.get("auto_close_blocked"):
gate = update_close_gate( gate = update_close_gate(
inst_id, recycle_usdc=None, premium_paid=paid, premium_ccy=premium_ccy inst_id,
recycle_usdc=None,
premium_paid=paid,
premium_ccy=premium_ccy,
index_px=index_px,
) )
preview["close_gate"] = gate preview["close_gate"] = gate
preview["close_gate_blocked"] = True preview["close_gate_blocked"] = True
@@ -70,6 +75,7 @@ def attach_close_preview(
recycle_usdc=_safe_float(preview.get("total_received")), recycle_usdc=_safe_float(preview.get("total_received")),
premium_paid=paid, premium_paid=paid,
premium_ccy=premium_ccy, premium_ccy=premium_ccy,
index_px=index_px,
) )
passed = bool(gate.get("passed") or is_close_gate_passed(inst_id) or gate.get("ready")) passed = bool(gate.get("passed") or is_close_gate_passed(inst_id) or gate.get("ready"))
preview["close_gate"] = gate preview["close_gate"] = gate
@@ -119,11 +125,24 @@ def display_pnl_from_option_row(row: dict[str, Any]) -> float | None:
def display_pnl_usdt_from_option_row(row: dict[str, Any]) -> float | None: def display_pnl_usdt_from_option_row(row: dict[str, Any]) -> float | None:
"""顶栏「实时盈亏」用:币本位净盈亏按指数折 USDT;USDC 仓原样.""" """顶栏/看板「实时盈亏」用:币本位净盈亏按指数折 USDT;USDC 仓原样.
BTC/ETH 币本位若不折算,会被界面标成 U 而数值仍是币数量(BTC 尤为离谱).
"""
pnl = display_pnl_from_option_row(row) pnl = display_pnl_from_option_row(row)
if pnl is None: if pnl is None:
return None return None
ccy = str(row.get("premium_ccy") or "USDC").strip().upper() or "USDC" ccy = str(row.get("premium_ccy") or "USDC").strip().upper() or "USDC"
if not ccy or ccy == "USDC":
# 兜底:按合约推断币本位,避免 premium_ccy 缺失时把 BTC 盈亏当 U
inst = str(row.get("inst_id") or row.get("instId") or "")
mode = str(row.get("margin_mode") or "").strip().lower()
if mode == "coin" or (
"-USD-" in inst.upper() and "_UM" not in inst.upper()
):
underly = str(row.get("underlying") or (inst.split("-")[0] if inst else "") or "").upper()
if underly in ("ETH", "BTC"):
ccy = underly
if ccy in ("ETH", "BTC"): if ccy in ("ETH", "BTC"):
idx = _safe_float(row.get("idx_px") or row.get("idxPx") or row.get("index_px")) idx = _safe_float(row.get("idx_px") or row.get("idxPx") or row.get("index_px"))
if idx is None or idx <= 0: if idx is None or idx <= 0:
+22
View File
@@ -28,6 +28,28 @@ def premium_per_sheet(quote_per_unit: float, ct_mult: float = 0.01) -> float:
return float(quote_per_unit) * float(ct_mult) return float(quote_per_unit) * float(ct_mult)
def option_expiry_pnl(
*,
opt_type: str,
strike: float,
spot: float,
sheets: float,
ct_mult: float,
premium_paid: float,
) -> float:
"""到期按实值估算盈亏(权利金币种口径);模拟盘到期结算等复用."""
o = (opt_type or "").strip().upper()
intrinsic_per_coin = 0.0
if o in ("C", "CALL"):
intrinsic_per_coin = max(0.0, float(spot) - float(strike))
elif o in ("P", "PUT"):
intrinsic_per_coin = max(0.0, float(strike) - float(spot))
else:
return -float(premium_paid)
value = intrinsic_per_coin * float(sheets) * float(ct_mult or 0.01)
return value - float(premium_paid)
def format_quote_liquidity(px: float | None, sz: float | None, *, px_decimals: int = 4) -> str | None: def format_quote_liquidity(px: float | None, sz: float | None, *, px_decimals: int = 4) -> str | None:
"""盘口展示:价格/张数,如 17.2/150.""" """盘口展示:价格/张数,如 17.2/150."""
if px is None: if px is None:
+21 -21
View File
@@ -211,13 +211,34 @@ def _estimate_recycle(
pos: dict[str, Any], pos: dict[str, Any],
premium_paid: float | None, premium_paid: float | None,
) -> float | None: ) -> float | None:
"""翻倍出场用可回收估算.
用买一价 × 全部持仓张数(不吃深度上限):深度不够时仍应触发分批平仓;
残档/无效买一则返回 None(禁止按假买盘自动平).
"""
from lib.options.options_positions_lib import attach_close_preview from lib.options.options_positions_lib import attach_close_preview
from lib.options.options_pricing_lib import total_premium
row = dict(pos) row = dict(pos)
attach_close_preview(cfg, ex, row, premium_paid=premium_paid) attach_close_preview(cfg, ex, row, premium_paid=premium_paid)
preview = row.get("close_preview") if isinstance(row.get("close_preview"), dict) else {} preview = row.get("close_preview") if isinstance(row.get("close_preview"), dict) else {}
if preview.get("bid_invalid"): if preview.get("bid_invalid"):
return None return None
levels = preview.get("levels") or []
ct_mult = float(row.get("ct_mult") or 0.01)
sheets = int(
abs(
_safe_float(row.get("avail_pos") if row.get("avail_pos") is not None else row.get("pos"))
or 0
)
)
if levels and sheets > 0 and ct_mult > 0:
try:
px = float(levels[0].get("px") or 0)
except (TypeError, ValueError, AttributeError):
px = 0.0
if px > 0:
return round(total_premium(px, sheets * ct_mult), 8)
return _safe_float(preview.get("total_received")) return _safe_float(preview.get("total_received"))
@@ -289,33 +310,12 @@ def run_options_profit_exits(
"""扫描开启翻倍出场的 open 仓;买一可回收达标后限价平仓.返回本次新触发条数.""" """扫描开启翻倍出场的 open 仓;买一可回收达标后限价平仓.返回本次新触发条数."""
ensure_profit_exit_columns(conn) ensure_profit_exit_columns(conn)
pos_by_inst = {str(p.get("inst_id") or p.get("instId") or ""): p for p in positions} pos_by_inst = {str(p.get("inst_id") or p.get("instId") or ""): p for p in positions}
hedge_managed: set[str] = set()
try:
from lib.hedge_plan.hedge_plan_db import active_hedge_option_inst_ids, init_hedge_plan_tables
init_hedge_plan_tables(conn)
hedge_managed = active_hedge_option_inst_ids(conn)
except Exception:
return 0
rules = profit_exit_by_inst(conn) rules = profit_exit_by_inst(conn)
triggered = 0 triggered = 0
for inst_id, info in list(rules.items()): for inst_id, info in list(rules.items()):
if not inst_id: if not inst_id:
continue continue
if inst_id in hedge_managed:
_mark_state(conn, inst_id, "idle")
conn.execute(
"""
UPDATE options_trades
SET profit_exit_enabled = 0, profit_exit_state = 'idle'
WHERE inst_id = ? AND status = 'open'
""",
(inst_id,),
)
_commit(conn)
continue
pos = pos_by_inst.get(inst_id) pos = pos_by_inst.get(inst_id)
if not pos: if not pos:
# 本轮暂无持仓:不改状态(避免接口抖动误标 done);真正平完由 sync 收尾 # 本轮暂无持仓:不改状态(避免接口抖动误标 done);真正平完由 sync 收尾
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,137 @@
"""期权复盘自动 K 线:后台线程生成标的永续双周期拼图,写回 images_json."""
from __future__ import annotations
import logging
import threading
from typing import Any, Callable, Optional
from lib.options.options_review_images_lib import (
images_json_dumps,
parse_options_review_images_json,
)
logger = logging.getLogger(__name__)
OPTIONS_REVIEW_AUTO_CHART_TF = "auto"
def underlying_to_okx_perp_symbol(
underlying: str,
*,
normalize_symbol_input_fn: Callable[[str], str],
normalize_okx_symbol_fn: Callable[[str], str],
) -> str:
"""ETH / ETH-USD / ETH-USDT-SWAP → ETH/USDT:USDT."""
raw = str(underlying or "").strip().upper()
if not raw:
return ""
base = raw.split("-", 1)[0].split("/", 1)[0].strip()
if not base:
return ""
guess = normalize_symbol_input_fn(base) or f"{base}/USDT"
return str(normalize_okx_symbol_fn(guess) or "").strip()
def merge_auto_chart_into_images(
existing: Optional[list],
filename: str,
) -> list[dict[str, str]]:
"""保留手动槽位,用 tf=auto 放置拼图(方案甲一张总图)."""
fname = str(filename or "").strip()
out: list[dict[str, str]] = []
for item in existing or []:
if not isinstance(item, dict):
continue
tf = str(item.get("tf") or "").strip()
file = str(item.get("file") or "").strip()
if not file or tf == OPTIONS_REVIEW_AUTO_CHART_TF:
continue
out.append({"tf": tf, "file": file})
if fname:
out.insert(0, {"tf": OPTIONS_REVIEW_AUTO_CHART_TF, "file": fname})
return out
def build_options_review_chart_filename(trade_id: int) -> str:
return f"options_journal_chart_{int(trade_id)}.png"
def schedule_options_review_exchange_chart(
*,
trade_id: int,
exchange_symbol: str,
title_prefix: str,
journal_tfs: list[str],
journal_limit: int,
marker_payload: dict[str, Any],
upload_folder: str,
generate_chart_fn: Callable[..., str | None],
get_db_fn: Callable[[], Any],
now_str_fn: Callable[[], str],
) -> None:
"""保存后立即返回;线程内画图并合并进 options_review_entries.images_json."""
tid = int(trade_id or 0)
if tid <= 0 or not callable(generate_chart_fn) or not callable(get_db_fn):
return
symbol = str(exchange_symbol or "").strip()
if not symbol:
return
tfs = [str(x).strip() for x in (journal_tfs or []) if str(x).strip()]
if not tfs:
return
def _run() -> None:
try:
chart_fname = build_options_review_chart_filename(tid)
saved = generate_chart_fn(
symbol,
title_prefix,
timeframes=tfs,
limit=journal_limit,
out_dir=upload_folder,
filename=chart_fname,
filename_prefix="options_journal_chart",
marker_payload=marker_payload,
marker_timeframes={x.lower() for x in tfs},
layout="vertical",
)
if not saved:
logger.warning("options review chart async empty trade_id=%s", tid)
return
conn = get_db_fn()
try:
row = conn.execute(
"SELECT images_json FROM options_review_entries WHERE trade_id=?",
(tid,),
).fetchone()
if not row:
logger.warning(
"options review chart async missing entry trade_id=%s", tid
)
return
images = parse_options_review_images_json(row["images_json"])
merged = merge_auto_chart_into_images(images, saved)
primary = merged[0]["file"] if merged else saved
conn.execute(
"""UPDATE options_review_entries
SET images_json=?, image=?, updated_at=?
WHERE trade_id=?""",
(
images_json_dumps(merged),
primary,
now_str_fn(),
tid,
),
)
conn.commit()
finally:
conn.close()
except Exception:
logger.exception("options review chart async failed trade_id=%s", tid)
threading.Thread(
target=_run,
name=f"options-review-chart-{tid}",
daemon=True,
).start()
+15 -29
View File
@@ -586,32 +586,11 @@ def _mark_option_legs_excluded(
def sync_hedge_plans_closed(conn: sqlite3.Connection) -> dict[str, Any]: def sync_hedge_plans_closed(conn: sqlite3.Connection) -> dict[str, Any]:
"""从本地 hedge_plans 导入已结束计划(计划级).""" """对冲模块已移除:不再导入对冲计划到复盘."""
init_options_review_tables(conn) init_options_review_tables(conn)
from lib.hedge_plan.hedge_plan_db import get_plan_legs, init_hedge_plan_tables, list_plans return {"ok": True, "fetched": 0, "inserted": 0, "updated": 0, "skipped": 0, "removed": True}
init_hedge_plan_tables(conn)
plans = list_plans(conn, status="closed", limit=500)
inserted = updated = skipped = 0
for plan in plans:
legs = get_plan_legs(conn, int(plan["id"]))
action = upsert_hedge_plan_row(conn, plan, legs)
if action == "inserted":
inserted += 1
elif action == "updated":
updated += 1
else:
skipped += 1
last_id = max((int(p["id"]) for p in plans), default=0)
set_sync_state(conn, "hedge_last_sync_at", _now_str())
set_sync_state(conn, "hedge_last_plan_id", str(last_id))
return {
"ok": True,
"fetched": len(plans),
"inserted": inserted,
"updated": updated,
"skipped": skipped,
}
def sync_all_review_sources( def sync_all_review_sources(
@@ -643,22 +622,29 @@ def ensure_local_review_synced(
backfill_exchange_pnl: bool = True, backfill_exchange_pnl: bool = True,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""列表/统计前轻量刷新本地源;有交易所时先用历史仓位盈亏覆盖本地再导入复盘.""" """列表/统计前轻量刷新本地源;有交易所时先用历史仓位盈亏覆盖本地再导入复盘."""
from lib.options.options_time_lib import (
migrate_options_utc_wall_clock_to_app,
repair_swapped_closed_at_signal_note,
)
ts_migrate = migrate_options_utc_wall_clock_to_app(conn)
note_swap = repair_swapped_closed_at_signal_note(conn)
if backfill_exchange_pnl and ex is not None: if backfill_exchange_pnl and ex is not None:
try: try:
from lib.exchange.okx_options_lib import fetch_all_option_positions_history from lib.exchange.okx_options_lib import fetch_all_option_positions_history
from lib.hedge_plan.hedge_plan_settle_lib import (
backfill_hedge_option_legs_realized_pnl,
)
from lib.options.options_monitor_lib import ( from lib.options.options_monitor_lib import (
backfill_closed_options_realized_pnl_from_history, backfill_closed_options_realized_pnl_from_history,
) )
hist = fetch_all_option_positions_history(ex, limit=200) hist = fetch_all_option_positions_history(ex, limit=200)
backfill_closed_options_realized_pnl_from_history(conn, hist) backfill_closed_options_realized_pnl_from_history(conn, hist)
backfill_hedge_option_legs_realized_pnl(conn, hist)
except Exception: except Exception:
pass pass
return sync_all_review_sources(conn, ex=ex, from_exchange=False) out = sync_all_review_sources(conn, ex=ex, from_exchange=False)
if isinstance(out, dict):
out["ts_migrate"] = ts_migrate
out["note_swap"] = note_swap
return out
def _row_to_dict(row: Any) -> dict[str, Any]: def _row_to_dict(row: Any) -> dict[str, Any]:
+143 -1
View File
@@ -32,7 +32,7 @@ from lib.options.options_review_lib import (
def _review_source_for_mode(requested: str | None) -> str | None: def _review_source_for_mode(requested: str | None) -> str | None:
"""按当前交易模式钳制复盘 source_type;不允许跨模式窥探.""" """按当前交易模式钳制复盘 source_type;不允许跨模式窥探."""
try: try:
from lib.hedge_plan.okx_trade_mode_lib import get_okx_trade_mode from lib.options.okx_trade_mode_lib import get_okx_trade_mode
mode = get_okx_trade_mode() mode = get_okx_trade_mode()
except Exception: except Exception:
@@ -93,6 +93,141 @@ def _require_ex(cfg: dict[str, Any]):
return ex, "" return ex, ""
def _truthy(raw: Any) -> bool:
return str(raw or "").strip().lower() in ("1", "true", "on", "yes")
def _prepare_review_entry_images(
conn: Any,
trade_id: int,
data: dict[str, Any],
) -> dict[str, Any]:
"""客户端只提交手动槽位;未重新勾选自动K线时保留已有 auto 附图."""
from lib.options.options_review_chart_async_lib import OPTIONS_REVIEW_AUTO_CHART_TF
from lib.options.options_review_images_lib import parse_options_review_images_json
images = data.get("images") if isinstance(data.get("images"), list) else []
cleaned: list[dict[str, str]] = []
for item in images:
if not isinstance(item, dict):
continue
tf = str(item.get("tf") or "").strip()
file = str(item.get("file") or "").strip()
if not file or tf == OPTIONS_REVIEW_AUTO_CHART_TF:
continue
cleaned.append({"tf": tf, "file": file})
want_chart = _truthy(data.get("journal_exchange_chart")) and not cleaned
if not want_chart:
row = conn.execute(
"SELECT images_json FROM options_review_entries WHERE trade_id=?",
(int(trade_id),),
).fetchone()
if row:
for item in parse_options_review_images_json(row["images_json"]):
if str(item.get("tf") or "").strip() == OPTIONS_REVIEW_AUTO_CHART_TF:
cleaned.insert(0, dict(item))
break
out = dict(data)
out["images"] = cleaned
return out
def _maybe_schedule_options_review_chart(
cfg: dict[str, Any],
trade: dict[str, Any],
data: dict[str, Any],
) -> bool:
"""纯期权 + 无手动截图 + 勾选自动K线 → 后台生成标的永续双周期拼图(只钉进/出场)."""
if str(trade.get("source_type") or "").strip() != "option_spot":
return False
images = data.get("images") if isinstance(data.get("images"), list) else []
has_manual = any(str((x or {}).get("file") or "").strip() for x in images)
if has_manual or not _truthy(data.get("journal_exchange_chart")):
return False
app_module = cfg.get("app_module")
if app_module is None:
return False
if not bool(getattr(app_module, "ORDER_CHART_ENABLED", True)):
return False
generate_fn = getattr(app_module, "generate_multi_timeframe_chart_png", None)
if not callable(generate_fn):
return False
from lib.instance.journal_chart_lib import (
parse_journal_chart_anchor,
parse_journal_chart_limit,
parse_journal_chart_timeframes,
)
from lib.options.options_review_chart_async_lib import (
schedule_options_review_exchange_chart,
underlying_to_okx_perp_symbol,
)
from lib.options.options_review_images_lib import options_review_upload_dir
from lib.options.options_review_lib import _now_str
fallback_tfs = list(getattr(app_module, "ORDER_CHART_TFS", None) or [])[:2]
journal_tfs = parse_journal_chart_timeframes(
data.get("journal_chart_tf1"),
data.get("journal_chart_tf2"),
fallback_tfs or None,
)
journal_limit = parse_journal_chart_limit(
data.get("journal_chart_limit"),
int(getattr(app_module, "ORDER_CHART_LIMIT", 100) or 100),
)
chart_anchor = parse_journal_chart_anchor(data.get("journal_chart_anchor"))
underlying = str(trade.get("underlying") or data.get("underlying") or "").strip()
exchange_symbol = underlying_to_okx_perp_symbol(
underlying,
normalize_symbol_input_fn=app_module.normalize_symbol_input,
normalize_okx_symbol_fn=app_module.normalize_okx_symbol,
)
if not exchange_symbol:
return False
to_ms = getattr(app_module, "_local_input_datetime_to_ms", None)
if not callable(to_ms):
return False
entry_ts = to_ms(trade.get("opened_at"))
exit_ts = to_ms(trade.get("closed_at"))
if entry_ts is None and exit_ts is None:
return False
now_ms = None
app_now = getattr(app_module, "app_now", None)
if callable(app_now):
try:
now_ms = int(app_now().timestamp() * 1000)
except Exception:
now_ms = None
inst = str(trade.get("inst_id") or underlying or f"#{trade.get('id') or ''}").strip()
upload_folder = options_review_upload_dir(cfg["upload_folder"])
schedule_options_review_exchange_chart(
trade_id=int(trade["id"]),
exchange_symbol=exchange_symbol,
title_prefix=f"{underlying or 'OPT'} {inst} review",
journal_tfs=journal_tfs,
journal_limit=journal_limit,
marker_payload={
"entry_ts_ms": entry_ts,
"exit_ts_ms": exit_ts,
# 只按时间钉进/出场,不传价格与止损
"chart_anchor": chart_anchor,
"now_ts_ms": now_ms,
},
upload_folder=upload_folder,
generate_chart_fn=generate_fn,
get_db_fn=cfg["get_db"],
now_str_fn=_now_str,
)
return True
def register_options_review_routes(app: Flask, cfg: dict[str, Any], repo_root: str) -> None: def register_options_review_routes(app: Flask, cfg: dict[str, Any], repo_root: str) -> None:
lr = cfg["login_required"] lr = cfg["login_required"]
@@ -216,9 +351,16 @@ def register_options_review_routes(app: Flask, cfg: dict[str, Any], repo_root: s
return jsonify({"ok": False, "msg": "trade_id 无效"}), 400 return jsonify({"ok": False, "msg": "trade_id 无效"}), 400
conn = cfg["get_db"]() conn = cfg["get_db"]()
try: try:
data = _prepare_review_entry_images(conn, trade_id, data)
out = save_review_entry(conn, trade_id, data) out = save_review_entry(conn, trade_id, data)
if out.get("ok"): if out.get("ok"):
conn.commit() conn.commit()
chart_queued = _maybe_schedule_options_review_chart(
cfg, out.get("trade") or {}, data
)
if chart_queued:
out = dict(out)
out["chart_queued"] = True
return jsonify(out), (200 if out.get("ok") else 400) return jsonify(out), (200 if out.get("ok") else 400)
finally: finally:
conn.close() conn.close()
+474 -501
View File
@@ -1,501 +1,474 @@
"""期权目标位委托:指数目标价仅用于监控触发;触发后按买一限价平仓(无止损,到期结算).""" """期权目标位委托:指数目标价仅用于监控触发;触发后按买一限价平仓(无止损,到期结算)."""
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
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
def _safe_float(v: Any) -> float | None: def _safe_float(v: Any) -> float | None:
if v is None or v == "": if v is None or v == "":
return None return None
try: try:
return float(v) return float(v)
except (TypeError, ValueError): except (TypeError, ValueError):
return None return None
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
inst_id = str(pos.get("instId") or pos.get("inst_id") or "") inst_id = str(pos.get("instId") or pos.get("inst_id") or "")
mark = _safe_float(pos.get("markPx")) or _safe_float((quote or {}).get("mark_px") or (quote or {}).get("mark")) mark = _safe_float(pos.get("markPx")) or _safe_float((quote or {}).get("mark_px") or (quote or {}).get("mark"))
if mark is None: if mark is None:
mark = fetch_option_mark_px(ex, inst_id) mark = fetch_option_mark_px(ex, inst_id)
opt_type = pos.get("optType") or (quote or {}).get("opt_type") opt_type = pos.get("optType") or (quote or {}).get("opt_type")
strike = _safe_float(pos.get("stk")) or _safe_float((quote or {}).get("strike")) strike = _safe_float(pos.get("stk")) or _safe_float((quote or {}).get("strike"))
if not opt_type or strike is None: if not opt_type or strike is None:
pt, ps = option_fields_from_inst_id(inst_id) pt, ps = option_fields_from_inst_id(inst_id)
opt_type = opt_type or pt opt_type = opt_type or pt
if strike is None: if strike is None:
strike = ps strike = ps
idx = _safe_float(pos.get("idxPx")) or _safe_float((quote or {}).get("index_px")) idx = _safe_float(pos.get("idxPx")) or _safe_float((quote or {}).get("index_px"))
return close_ref_prices(mark_px=mark, opt_type=str(opt_type or ""), strike=strike, index_px=idx) return close_ref_prices(mark_px=mark, opt_type=str(opt_type or ""), strike=strike, index_px=idx)
def ensure_target_tables(conn: sqlite3.Connection) -> None: def ensure_target_tables(conn: sqlite3.Connection) -> None:
init_options_tables(conn) init_options_tables(conn)
conn.execute( conn.execute(
""" """
CREATE TABLE IF NOT EXISTS options_target_monitors ( CREATE TABLE IF NOT EXISTS options_target_monitors (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
inst_id TEXT NOT NULL, inst_id TEXT NOT NULL,
underlying TEXT, underlying TEXT,
opt_type TEXT, opt_type TEXT,
target_index REAL NOT NULL, target_index REAL NOT NULL,
trade_id INTEGER, trade_id INTEGER,
sheets INTEGER, sheets INTEGER,
status TEXT DEFAULT 'active', status TEXT DEFAULT 'active',
trigger_idx REAL, trigger_idx REAL,
close_ord_id TEXT, close_ord_id TEXT,
message TEXT, message TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
triggered_at TIMESTAMP triggered_at TIMESTAMP
) )
""" """
) )
conn.execute( conn.execute(
""" """
CREATE INDEX IF NOT EXISTS idx_options_target_monitors_status CREATE INDEX IF NOT EXISTS idx_options_target_monitors_status
ON options_target_monitors(status) ON options_target_monitors(status)
""" """
) )
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 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,
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,
sheets: int | None = None, sheets: int | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
ensure_target_tables(conn) ensure_target_tables(conn)
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: if target_index is None or float(target_index) <= 0:
return {"ok": False, "msg": "目标位无效"} return {"ok": False, "msg": "目标位无效"}
target_index = float(target_index) target_index = float(target_index)
row = conn.execute( row = conn.execute(
""" """
SELECT id FROM options_target_monitors SELECT id FROM options_target_monitors
WHERE inst_id = ? AND status IN ('active', 'closing') WHERE inst_id = ? AND status IN ('active', 'closing')
ORDER BY CASE status WHEN 'active' THEN 0 WHEN 'closing' THEN 1 ELSE 2 END, id DESC ORDER BY CASE status WHEN 'active' THEN 0 WHEN 'closing' THEN 1 ELSE 2 END, id DESC
LIMIT 1 LIMIT 1
""", """,
(inst_id,), (inst_id,),
).fetchone() ).fetchone()
if row: if row:
conn.execute( conn.execute(
""" """
UPDATE options_target_monitors UPDATE options_target_monitors
SET target_index = ?, SET target_index = ?,
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),
sheets = COALESCE(?, sheets), sheets = COALESCE(?, sheets),
status = 'active', status = 'active',
trigger_idx = NULL, trigger_idx = NULL,
close_ord_id = NULL, close_ord_id = NULL,
message = NULL, message = NULL,
triggered_at = NULL triggered_at = NULL
WHERE id = ? WHERE id = ?
""", """,
(target_index, underlying, opt_type, trade_id, sheets, int(row["id"])), (target_index, 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),
) )
else: else:
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, trade_id, sheets, status)
VALUES (?, ?, ?, ?, ?, ?, 'active') VALUES (?, ?, ?, ?, ?, ?, 'active')
""", """,
(inst_id, underlying, opt_type, target_index, trade_id, sheets), (inst_id, underlying, opt_type, target_index, 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} return {"ok": True, "id": mon_id, "inst_id": inst_id, "target_index": target_index}
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:
ensure_target_tables(conn) ensure_target_tables(conn)
if monitor_id is not None: if monitor_id is not None:
cur = conn.execute( cur = conn.execute(
""" """
UPDATE options_target_monitors UPDATE options_target_monitors
SET status = 'cancelled', message = '手动取消' SET status = 'cancelled', message = '手动取消'
WHERE id = ? AND status IN ('active', 'closing') WHERE id = ? AND status IN ('active', 'closing')
""", """,
(int(monitor_id),), (int(monitor_id),),
) )
return int(cur.rowcount or 0) return int(cur.rowcount or 0)
if inst_id: if inst_id:
cur = conn.execute( cur = conn.execute(
""" """
UPDATE options_target_monitors UPDATE options_target_monitors
SET status = 'cancelled', message = '手动取消' SET status = 'cancelled', message = '手动取消'
WHERE inst_id = ? AND status IN ('active', 'closing') WHERE inst_id = ? AND status IN ('active', 'closing')
""", """,
(inst_id.strip(),), (inst_id.strip(),),
) )
return int(cur.rowcount or 0) return int(cur.rowcount or 0)
return 0 return 0
def _row_to_target(r: sqlite3.Row) -> dict[str, Any]: def _row_to_target(r: sqlite3.Row) -> dict[str, Any]:
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": _safe_float(r["target_index"]),
"trade_id": r["trade_id"], "trade_id": r["trade_id"],
"sheets": r["sheets"], "sheets": r["sheets"],
"status": r["status"], "status": r["status"],
"message": r["message"], "message": r["message"],
"created_at": r["created_at"], "created_at": r["created_at"],
} }
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(
""" """
SELECT id, inst_id, underlying, opt_type, target_index, trade_id, sheets, SELECT id, inst_id, underlying, opt_type, target_index, trade_id, sheets,
status, message, created_at status, message, created_at
FROM options_target_monitors FROM options_target_monitors
WHERE status = 'active' WHERE status = 'active'
ORDER BY id DESC ORDER BY id DESC
""" """
).fetchall() ).fetchall()
return [_row_to_target(r) for r in rows] return [_row_to_target(r) for r in rows]
def list_closing_targets(conn: sqlite3.Connection) -> list[dict[str, Any]]: 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(
""" """
SELECT id, inst_id, underlying, opt_type, target_index, trade_id, sheets, SELECT id, inst_id, underlying, opt_type, target_index, trade_id, sheets,
status, message, created_at status, message, created_at
FROM options_target_monitors FROM options_target_monitors
WHERE status = 'closing' WHERE status = 'closing'
ORDER BY id DESC ORDER BY id DESC
""" """
).fetchall() ).fetchall()
return [_row_to_target(r) for r in rows] return [_row_to_target(r) for r in rows]
def targets_by_inst(conn: sqlite3.Connection) -> dict[str, dict[str, Any]]: def targets_by_inst(conn: sqlite3.Connection) -> dict[str, dict[str, Any]]:
"""UI/持仓挂载:active 与 closing 都算进行中.""" """UI/持仓挂载:active 与 closing 都算进行中."""
out: dict[str, dict[str, Any]] = {} out: dict[str, dict[str, Any]] = {}
for t in list_closing_targets(conn) + list_active_targets(conn): for t in list_closing_targets(conn) + list_active_targets(conn):
inst = str(t.get("inst_id") or "") inst = str(t.get("inst_id") or "")
if inst and inst not in out: if inst and inst not in out:
out[inst] = t out[inst] = t
return out return out
def mark_monitor( def mark_monitor(
conn: sqlite3.Connection, conn: sqlite3.Connection,
monitor_id: int, monitor_id: int,
*, *,
status: str, status: str,
trigger_idx: float | None = None, trigger_idx: float | None = None,
close_ord_id: str | None = None, close_ord_id: str | None = None,
message: str | None = None, message: str | None = None,
) -> None: ) -> None:
conn.execute( conn.execute(
""" """
UPDATE options_target_monitors UPDATE options_target_monitors
SET status = ?, SET status = ?,
trigger_idx = COALESCE(?, trigger_idx), trigger_idx = COALESCE(?, trigger_idx),
close_ord_id = COALESCE(?, close_ord_id), close_ord_id = COALESCE(?, close_ord_id),
message = COALESCE(?, message), message = COALESCE(?, message),
triggered_at = CASE triggered_at = CASE
WHEN ? IN ('triggered', 'expired', 'closing') THEN COALESCE(triggered_at, CURRENT_TIMESTAMP) WHEN ? IN ('triggered', 'expired', 'closing') THEN COALESCE(triggered_at, CURRENT_TIMESTAMP)
ELSE triggered_at ELSE triggered_at
END END
WHERE id = ? WHERE id = ?
""", """,
(status, trigger_idx, close_ord_id, message, status, int(monitor_id)), (status, trigger_idx, close_ord_id, message, status, int(monitor_id)),
) )
def cancel_orphans_without_position( def cancel_orphans_without_position(
conn: sqlite3.Connection, conn: sqlite3.Connection,
*, *,
live_inst_ids: set[str], live_inst_ids: set[str],
) -> int: ) -> int:
"""持仓已消失的目标委托标记为 expired(到期/已平),不挂止损.""" """持仓已消失的目标委托标记为 expired(到期/已平),不挂止损."""
ensure_target_tables(conn) ensure_target_tables(conn)
rows = list_active_targets(conn) + list_closing_targets(conn) rows = list_active_targets(conn) + list_closing_targets(conn)
n = 0 n = 0
for t in rows: for t in rows:
inst = str(t.get("inst_id") or "") inst = str(t.get("inst_id") or "")
if inst and inst not in live_inst_ids: if inst and inst not in live_inst_ids:
mark_monitor(conn, int(t["id"]), status="expired", message="持仓已平/到期,委托结束") mark_monitor(conn, int(t["id"]), status="expired", message="持仓已平/到期,委托结束")
n += 1 n += 1
return n return n
def _commit_monitor(conn: sqlite3.Connection) -> None: def _commit_monitor(conn: sqlite3.Connection) -> None:
"""状态变更立刻落库,避免后续 sync 异常回滚后重复触发/推送.""" """状态变更立刻落库,避免后续 sync 异常回滚后重复触发/推送."""
try: try:
conn.commit() conn.commit()
except Exception: except Exception:
pass pass
def close_option_by_bid_depth( def close_option_by_bid_depth(
cfg: dict[str, Any], cfg: dict[str, Any],
ex: Any, ex: Any,
inst_id: str, inst_id: str,
*, *,
sheets: int | None = None, sheets: int | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""目标触发后只锁买一限价卖出;需过 2×门控(通过后同仓续批只验流动性).""" """目标触发后只锁买一限价卖出;需过 2×门控(通过后同仓续批只验流动性)."""
from lib.options.options_close_exec_lib import close_option_by_bid1 from lib.options.options_close_exec_lib import close_option_by_bid1
return close_option_by_bid1( return close_option_by_bid1(
cfg, cfg,
ex, ex,
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,
idx: float, idx: float,
result: dict[str, Any], result: dict[str, Any],
conn: Any = None, conn: Any = None,
) -> None: ) -> None:
"""目标位平仓推送:优先走统一平仓必发(幂等);无 cfg 时回退旧文案.""" """目标位平仓推送:优先走统一平仓必发(幂等);无 cfg 时回退旧文案."""
from lib.options.options_notify_lib import resolve_premium_ccy from lib.options.options_notify_lib import resolve_premium_ccy
ccy = resolve_premium_ccy(inst_id=inst_id) ccy = resolve_premium_ccy(inst_id=inst_id)
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:
from lib.options.options_notify_lib import notify_options_close from lib.options.options_notify_lib import notify_options_close
notify_options_close( notify_options_close(
cfg, cfg,
conn, conn,
inst_id=inst_id, inst_id=inst_id,
reason="目标位平仓", reason="目标位平仓",
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,
premium_ccy=ccy, premium_ccy=ccy,
) )
return return
except Exception: except Exception:
pass pass
if not send_wechat: if not send_wechat:
return return
try: try:
recv = result.get("premium_received") recv = result.get("premium_received")
send_wechat( send_wechat(
"\n".join( "\n".join(
[ [
"【OKX期权·目标位平仓】", "【OKX期权·目标位平仓】",
f"账户:{account_label}", f"账户:{account_label}",
f"合约:{inst_id}", f"合约:{inst_id}",
f"目标指数:{target:g}", f"目标指数:{target:g}",
f"触发指数:{idx:g}", f"触发指数:{idx:g}",
f"提交张数:{result.get('submitted_sheets') or '—'}", f"提交张数:{result.get('submitted_sheets') or '—'}",
f"预估收回:{recv if recv is not None else '—'} {ccy}", f"预估收回:{recv if recv is not None else '—'} {ccy}",
f"状态:{'已全平' if (result.get('fully_closed') or result.get('already_flat')) else '挂单中/部分'}", f"状态:{'已全平' if (result.get('fully_closed') or result.get('already_flat')) else '挂单中/部分'}",
] ]
) )
) )
except Exception: except Exception:
pass pass
def _result_fully_done(result: dict[str, Any]) -> bool: def _result_fully_done(result: dict[str, Any]) -> bool:
if result.get("already_flat"): if result.get("already_flat"):
return True return True
if result.get("fully_closed"): if result.get("fully_closed"):
return True return True
remaining = result.get("remaining_sheets") remaining = result.get("remaining_sheets")
if remaining is not None and int(remaining) <= 0 and result.get("ok"): if remaining is not None and int(remaining) <= 0 and result.get("ok"):
return True return True
return False return False
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,
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,仅重试平仓不再推送.
返回本次新触发(并推送)的条数. 返回本次新触发(并推送)的条数.
""" """
ensure_target_tables(conn) ensure_target_tables(conn)
pos_by_inst = {str(p.get("inst_id") or p.get("instId") or ""): p for p in positions} pos_by_inst = {str(p.get("inst_id") or p.get("instId") or ""): p for p in positions}
live_ids = {k for k in pos_by_inst if k} live_ids = {k for k in pos_by_inst if k}
hedge_managed: set[str] = set() cancel_orphans_without_position(conn, live_inst_ids=live_ids)
try: _commit_monitor(conn)
from lib.hedge_plan.hedge_plan_db import active_hedge_option_inst_ids, init_hedge_plan_tables
# 先处理已挂单等待成交的,绝不再发微信
init_hedge_plan_tables(conn) for mon in list_closing_targets(conn):
hedge_managed = active_hedge_option_inst_ids(conn) inst_id = str(mon.get("inst_id") or "")
except Exception: if not inst_id:
# fail-closed:本轮不执行任何单独目标平仓,避免误平对冲腿 continue
return 0 if inst_id not in pos_by_inst:
cancel_orphans_without_position(conn, live_inst_ids=live_ids) mark_monitor(conn, int(mon["id"]), status="expired", message="持仓已平")
_commit_monitor(conn) _commit_monitor(conn)
continue
# 先处理已挂单等待成交的,绝不再发微信 result = close_fn(inst_id)
for mon in list_closing_targets(conn): idx = _safe_float(pos_by_inst[inst_id].get("idx_px") or pos_by_inst[inst_id].get("idxPx"))
inst_id = str(mon.get("inst_id") or "") if result.get("already_flat") or _result_fully_done(result):
if not inst_id: mark_monitor(
continue conn,
if inst_id in hedge_managed: int(mon["id"]),
mark_monitor( status="triggered",
conn, trigger_idx=idx,
int(mon["id"]), close_ord_id=result.get("close_ord_id"),
status="expired", message="目标位限价平仓完成",
message="已移交对冲计划托管,跳过单独目标平仓", )
) _commit_monitor(conn)
_commit_monitor(conn) continue
continue mark_monitor(
if inst_id not in pos_by_inst: conn,
mark_monitor(conn, int(mon["id"]), status="expired", message="持仓已平") int(mon["id"]),
_commit_monitor(conn) status="closing",
continue trigger_idx=idx,
result = close_fn(inst_id) close_ord_id=result.get("close_ord_id"),
idx = _safe_float(pos_by_inst[inst_id].get("idx_px") or pos_by_inst[inst_id].get("idxPx")) message=str(result.get("msg") or result.get("stopped_reason") or "等待买一成交"),
if result.get("already_flat") or _result_fully_done(result): )
mark_monitor( _commit_monitor(conn)
conn,
int(mon["id"]), triggered = 0
status="triggered", for mon in list_active_targets(conn):
trigger_idx=idx, inst_id = str(mon.get("inst_id") or "")
close_ord_id=result.get("close_ord_id"), target = _safe_float(mon.get("target_index"))
message="目标位限价平仓完成", if not inst_id or target is None:
) continue
_commit_monitor(conn) pos = pos_by_inst.get(inst_id)
continue if not pos:
mark_monitor( continue
conn, if index_fn is not None:
int(mon["id"]), idx = index_fn(pos)
status="closing", else:
trigger_idx=idx, idx = _safe_float(pos.get("idx_px") or pos.get("idxPx"))
close_ord_id=result.get("close_ord_id"), if idx is None:
message=str(result.get("msg") or result.get("stopped_reason") or "等待买一成交"), continue
) opt_type = mon.get("opt_type") or pos.get("opt_type") or pos.get("optType")
_commit_monitor(conn) if not target_hit(opt_type=str(opt_type) if opt_type else None, index_px=idx, target_index=target):
continue
triggered = 0
for mon in list_active_targets(conn): result = close_fn(inst_id)
inst_id = str(mon.get("inst_id") or "") if result.get("already_flat"):
target = _safe_float(mon.get("target_index")) mark_monitor(conn, int(mon["id"]), status="expired", trigger_idx=idx, message="持仓已平")
if not inst_id or target is None: _commit_monitor(conn)
continue continue
if inst_id in hedge_managed: if not result.get("ok"):
mark_monitor( mark_monitor(
conn, conn,
int(mon["id"]), int(mon["id"]),
status="expired", status="active",
message="已移交对冲计划托管,跳过单独目标平仓", trigger_idx=idx,
) message=str(result.get("msg") or result.get("stopped_reason") or "平仓未完成,将重试"),
_commit_monitor(conn) )
continue _commit_monitor(conn)
pos = pos_by_inst.get(inst_id) continue
if not pos:
continue done = _result_fully_done(result)
if index_fn is not None: status = "triggered" if done else "closing"
idx = index_fn(pos) mark_monitor(
else: conn,
idx = _safe_float(pos.get("idx_px") or pos.get("idxPx")) int(mon["id"]),
if idx is None: status=status,
continue trigger_idx=idx,
opt_type = mon.get("opt_type") or pos.get("opt_type") or pos.get("optType") close_ord_id=result.get("close_ord_id"),
if not target_hit(opt_type=str(opt_type) if opt_type else None, index_px=idx, target_index=target): message="目标位触发限价平仓" if done else "目标位已挂买一限价,等待成交",
continue )
# 关键:先落库,再推送——否则后续 sync 异常回滚会让同一笔反复推微信
result = close_fn(inst_id) _commit_monitor(conn)
if result.get("already_flat"): triggered += 1
mark_monitor(conn, int(mon["id"]), status="expired", trigger_idx=idx, message="持仓已平") _notify_target_close(
_commit_monitor(conn) cfg,
continue send_wechat,
if not result.get("ok"): account_label=account_label,
mark_monitor( inst_id=inst_id,
conn, target=target,
int(mon["id"]), idx=idx,
status="active", result=result,
trigger_idx=idx, conn=conn,
message=str(result.get("msg") or result.get("stopped_reason") or "平仓未完成,将重试"), )
) return triggered
_commit_monitor(conn)
continue
done = _result_fully_done(result)
status = "triggered" if done else "closing"
mark_monitor(
conn,
int(mon["id"]),
status=status,
trigger_idx=idx,
close_ord_id=result.get("close_ord_id"),
message="目标位触发限价平仓" if done else "目标位已挂买一限价,等待成交",
)
# 关键:先落库,再推送——否则后续 sync 异常回滚会让同一笔反复推微信
_commit_monitor(conn)
triggered += 1
_notify_target_close(
cfg,
send_wechat,
account_label=account_label,
inst_id=inst_id,
target=target,
idx=idx,
result=result,
conn=conn,
)
return triggered
+182
View File
@@ -0,0 +1,182 @@
"""期权业务时区墙钟:库内统一按 APP_TIMEZONE(默认 Asia/Shanghai)存储,不可当 UTC."""
from __future__ import annotations
import os
import sqlite3
from datetime import datetime, timezone
from typing import Any, Optional
from zoneinfo import ZoneInfo
_APP_TZ = ZoneInfo(
(os.getenv("APP_TIMEZONE") or os.getenv("TZ") or "Asia/Shanghai").strip()
or "Asia/Shanghai"
)
_MIGRATE_FLAG = "options_ts_utc_wall_to_app_done"
_SWAP_REPAIR_FLAG = "options_closed_at_note_swap_done"
def options_app_tz() -> ZoneInfo:
return _APP_TZ
def options_app_now_str() -> str:
return datetime.now(_APP_TZ).strftime("%Y-%m-%d %H:%M:%S")
def ms_to_options_app_str(ms: Any) -> Optional[str]:
"""交易所 ms(UTC 绝对时间) → 业务时区墙钟字符串."""
try:
val = float(ms)
except (TypeError, ValueError):
return None
if val <= 0:
return None
try:
dt = datetime.fromtimestamp(int(val) / 1000.0, tz=timezone.utc).astimezone(_APP_TZ)
return dt.strftime("%Y-%m-%d %H:%M:%S")
except (TypeError, ValueError, OSError, OverflowError):
return None
def options_wall_clock_to_ms(raw: Any) -> Optional[int]:
"""业务时区墙钟 → UTC ms(与合约复盘 _local_input_datetime_to_ms 同口径)."""
if raw is None or raw == "":
return None
s = str(raw).strip().replace("T", " ")
for fmt, ln in (("%Y-%m-%d %H:%M:%S", 19), ("%Y-%m-%d %H:%M", 16)):
try:
dt = datetime.strptime(s[:ln], fmt).replace(tzinfo=_APP_TZ)
return int(dt.timestamp() * 1000)
except ValueError:
continue
return None
def utc_wall_clock_to_app_str(raw: Any) -> Optional[str]:
"""把误存成 UTC 墙钟的字符串改写为业务时区墙钟(+8 等)."""
if raw is None or raw == "":
return None
s = str(raw).strip().replace("T", " ")
for fmt, ln in (("%Y-%m-%d %H:%M:%S", 19), ("%Y-%m-%d %H:%M", 16)):
try:
dt = datetime.strptime(s[:ln], fmt).replace(tzinfo=timezone.utc)
return dt.astimezone(_APP_TZ).strftime("%Y-%m-%d %H:%M:%S")
except ValueError:
continue
return None
def migrate_options_utc_wall_clock_to_app(conn: sqlite3.Connection) -> dict[str, int]:
"""一次性:历史 UTC 墙钟(SQLite CURRENT_TIMESTAMP / 旧 monitor)改为业务时区.
幂等:同步状态 options_ts_utc_wall_to_app_done=1 后跳过.
"""
from lib.options.options_review_db import init_options_review_tables
from lib.options.options_review_lib import get_sync_state, set_sync_state
init_options_review_tables(conn)
if get_sync_state(conn, _MIGRATE_FLAG) == "1":
return {"skipped": 1, "trades": 0, "review": 0, "hidden": 0}
def _shift_table(table: str, cols: tuple[str, ...]) -> int:
try:
rows = conn.execute(
f"SELECT rowid AS _rid, {', '.join(cols)} FROM {table}"
).fetchall()
except sqlite3.Error:
return 0
n = 0
for row in rows:
sets: list[str] = []
vals: list[Any] = []
for col in cols:
old = row[col]
new = utc_wall_clock_to_app_str(old)
if new and str(old or "").strip() != new:
sets.append(f"{col}=?")
vals.append(new)
if not sets:
continue
vals.append(int(row["_rid"]))
conn.execute(
f"UPDATE {table} SET {', '.join(sets)} WHERE rowid=?",
vals,
)
n += 1
return n
trades = 0
try:
from lib.options.options_db import init_options_tables
init_options_tables(conn)
trades = _shift_table("options_trades", ("created_at", "closed_at"))
except Exception:
trades = 0
review = _shift_table("options_review_trades", ("opened_at", "closed_at"))
hidden = _shift_table("options_review_hidden", ("closed_at",))
set_sync_state(conn, _MIGRATE_FLAG, "1")
return {"skipped": 0, "trades": trades, "review": review, "hidden": hidden}
def repair_swapped_closed_at_signal_note(conn: sqlite3.Connection) -> dict[str, int]:
"""修复 close_option_by_bid1 曾把 signal_note 写入 closed_at 的坏行.
典型表现: closed_at='翻倍出场'/'目标位平仓', signal_note 反而是时间戳.
幂等:同步状态 options_closed_at_note_swap_done=1 后仍扫描可修行(新坏行可再修).
"""
from lib.options.options_review_db import init_options_review_tables
from lib.options.options_review_lib import set_sync_state
init_options_review_tables(conn)
trades_fixed = 0
review_fixed = 0
try:
from lib.options.options_db import init_options_tables
init_options_tables(conn)
rows = conn.execute(
"""
SELECT id, closed_at, signal_note
FROM options_trades
WHERE status = 'closed'
AND closed_at IS NOT NULL
AND TRIM(closed_at) != ''
"""
).fetchall()
for row in rows:
closed = str(row["closed_at"] or "").strip()
note = str(row["signal_note"] or "").strip()
if options_wall_clock_to_ms(closed) is not None:
continue
if options_wall_clock_to_ms(note) is None:
continue
conn.execute(
"""
UPDATE options_trades
SET closed_at = ?, signal_note = ?
WHERE id = ?
""",
(note, closed, int(row["id"])),
)
trades_fixed += 1
hk = f"local_opt:{int(row['id'])}"
cur = conn.execute(
"""
UPDATE options_review_trades
SET closed_at = ?
WHERE history_key = ?
AND (closed_at IS NULL OR TRIM(closed_at) = '' OR closed_at = ?)
""",
(note, hk, closed),
)
review_fixed += int(cur.rowcount or 0)
except Exception:
pass
# 复盘表里仍残留非时间戳 closed_at 的,若能从 local_opt 源修好则已处理;否则仅标记完成
set_sync_state(conn, _SWAP_REPAIR_FLAG, "1")
return {"trades": trades_fixed, "review": review_fixed}
@@ -327,6 +327,37 @@
</div> </div>
<p class="sub journal-upload-hint">可只传部分周期;选文件后即时上传</p> <p class="sub journal-upload-hint">可只传部分周期;选文件后即时上传</p>
<div class="form-row or-chart-options journal-chart-options hidden" id="or-chart-options" style="flex-wrap:wrap;align-items:center">
<label style="display:flex;align-items:center;gap:6px;font-size:.82rem">
<input type="checkbox" id="or-exchange-chart" value="true">
保存后后台生成 K 线图并作为截图(不阻塞保存)
</label>
<label style="font-size:.82rem;color:#9aa">周期1</label>
<select id="or-chart-tf1" style="min-width:72px">
{% for tf in journal_chart_tf_choices %}
<option value="{{ tf }}" {% if tf == journal_chart_default_tf1 %}selected{% endif %}>{{ tf }}</option>
{% endfor %}
</select>
<label style="font-size:.82rem;color:#9aa">周期2</label>
<select id="or-chart-tf2" style="min-width:72px">
{% for tf in journal_chart_tf_choices %}
<option value="{{ tf }}" {% if tf == journal_chart_default_tf2 %}selected{% endif %}>{{ tf }}</option>
{% endfor %}
</select>
<label style="font-size:.82rem;color:#9aa">K线数</label>
<select id="or-chart-limit" style="min-width:72px">
{% for n in [100, 150, 200, 250, 300, 400, 500] %}
<option value="{{ n }}" {% if n == journal_chart_default_limit %}selected{% endif %}>{{ n }}</option>
{% endfor %}
</select>
<label style="font-size:.82rem;color:#9aa">K线截止</label>
<select id="or-chart-anchor" style="min-width:96px" title="K线窗口右端对齐的时间">
<option value="close" {% if journal_chart_default_anchor == 'close' %}selected{% endif %}>平仓时间</option>
<option value="now" {% if journal_chart_default_anchor == 'now' %}selected{% endif %}>当前时间</option>
</select>
</div>
<div class="sub or-chart-hint hidden" id="or-chart-hint" style="font-size:.72rem;margin-top:2px;margin-bottom:0">纯期权:按标的永续画双周期上下图;只按时间标记进场/出场,不标止损;有手动截图时不自动生成</div>
<div class="or-mood-grid mood-grid"> <div class="or-mood-grid mood-grid">
<label><input type="checkbox" class="or-mood" value="怕踏空">怕踏空</label> <label><input type="checkbox" class="or-mood" value="怕踏空">怕踏空</label>
<label><input type="checkbox" class="or-mood" value="报复开仓">报复开仓</label> <label><input type="checkbox" class="or-mood" value="报复开仓">报复开仓</label>
+214 -2
View File
@@ -12,7 +12,19 @@ from lib.sim.wallets_lib import InsufficientFunds, SimWallets
def _now() -> str: def _now() -> str:
return datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S") """业务时区墙钟(默认 Asia/Shanghai);勿用 utcnow,否则复盘K线标记会偏 8 小时."""
try:
from lib.options.options_time_lib import options_app_now_str
return options_app_now_str()
except Exception:
from zoneinfo import ZoneInfo
tz = ZoneInfo(
(os.getenv("APP_TIMEZONE") or os.getenv("TZ") or "Asia/Shanghai").strip()
or "Asia/Shanghai"
)
return datetime.now(tz).strftime("%Y-%m-%d %H:%M:%S")
def _ticker_bid_ask(exchange: Any, symbol: str) -> tuple[float, float]: def _ticker_bid_ask(exchange: Any, symbol: str) -> tuple[float, float]:
@@ -692,7 +704,7 @@ class SimBroker:
expiry_ms_from_inst_id, expiry_ms_from_inst_id,
option_fields_from_inst_id, option_fields_from_inst_id,
) )
from lib.hedge_plan.hedge_plan_calc_lib import option_expiry_pnl from lib.options.options_pricing_lib import option_expiry_pnl
now = int(now_ms if now_ms is not None else time.time() * 1000) now = int(now_ms if now_ms is not None else time.time() * 1000)
settled: list[dict[str, Any]] = [] settled: list[dict[str, Any]] = []
@@ -890,6 +902,200 @@ class SimBroker:
rows.append(row) rows.append(row)
return rows return rows
@staticmethod
def _sells_from_ledger(
conn: Any,
*,
inst: str,
opened: str,
closed: str,
) -> list[Any]:
"""从 sim_ledger_entries 的 option_close 备注解析卖平成交."""
import re
try:
params: list[Any] = [f"%sell {inst} %"]
sql = """
SELECT note, ts AS created_at
FROM sim_ledger_entries
WHERE kind = 'option_close' AND note LIKE ?
"""
if opened:
sql += " AND ts >= ?"
params.append(opened[:19])
if closed:
sql += " AND ts <= ?"
params.append((closed[:16] + ":59") if len(closed) >= 16 else closed)
sql += " ORDER BY ts ASC, id ASC"
rows = conn.execute(sql, params).fetchall()
except Exception:
return []
out: list[dict[str, Any]] = []
pat = re.compile(
rf"sell\s+{re.escape(inst)}\s+x(?P<sheets>[0-9.]+)@(?P<px>[0-9.]+)",
re.I,
)
for i, r in enumerate(rows):
note = str(r["note"] or "")
m = pat.search(note)
if not m:
continue
try:
sh = float(m.group("sheets"))
px = float(m.group("px"))
except (TypeError, ValueError):
continue
if sh <= 0 or px <= 0:
continue
out.append(
{
"ord_id": f"ledger-{i}",
"sheets": sh,
"avg_px": px,
"created_at": r["created_at"],
}
)
return out
def repair_closed_options_pnl_from_sim_orders(self, *, limit: int = 300) -> int:
"""用 sim_option_orders 卖平成交重算已平仓收回(修复分批平仓只记最后一笔).
返回更新条数.历史页/复盘合成前调用,使旧数据也能自愈.
"""
from lib.options.options_db import init_options_tables
from lib.options.options_pricing_lib import total_premium
from lib.sim.db_lib import init_sim_tables
conn = self.get_db()
updated = 0
try:
init_options_tables(conn)
init_sim_tables(conn)
# 表不存在时跳过(极旧库)
try:
conn.execute("SELECT 1 FROM sim_option_orders LIMIT 1").fetchone()
except Exception:
return 0
trades = conn.execute(
"""
SELECT id, inst_id, sheets, eth_amount, premium_paid, premium_received,
realized_pnl, close_quote, created_at, closed_at, close_ord_id
FROM options_trades
WHERE status = 'closed'
ORDER BY id DESC
LIMIT ?
""",
(int(max(1, min(int(limit), 500))),),
).fetchall()
for tr in trades:
inst = str(tr["inst_id"] or "").strip()
if not inst:
continue
want_sheets = float(tr["sheets"] or 0)
if want_sheets <= 0:
continue
eth_amt = float(tr["eth_amount"] or 0)
ct_mult = (eth_amt / want_sheets) if eth_amt > 0 else 0.01
if ct_mult <= 0:
ct_mult = 0.01
opened = str(tr["created_at"] or "").strip()
closed = str(tr["closed_at"] or "").strip()
# 时间窗:开仓后、平仓后 2 分钟内(含下单延迟)
params: list[Any] = [inst]
sql = """
SELECT ord_id, sheets, avg_px, created_at
FROM sim_option_orders
WHERE inst_id = ? AND lower(side) = 'sell'
"""
if opened:
sql += " AND created_at >= ?"
params.append(opened[:19])
if closed:
# 字符串时间比较;闭区间放宽到同分钟及之后的短尾
sql += " AND created_at <= ?"
params.append((closed[:16] + ":59") if len(closed) >= 16 else closed)
sql += " ORDER BY created_at ASC, ord_id ASC"
sells = conn.execute(sql, params).fetchall()
if not sells:
# 回退:从钱包流水 note 解析 sell INST x张数@价
sells = self._sells_from_ledger(
conn, inst=inst, opened=opened, closed=closed
)
if not sells:
continue
selected: list[Any] = []
sheets_sum = 0.0
for o in sells:
selected.append(o)
sheets_sum += float(o["sheets"] or 0)
if sheets_sum + 1e-9 >= want_sheets:
break
if sheets_sum + 1e-6 < want_sheets * 0.5:
# 成交张数明显不足,不瞎改
continue
recv = 0.0
notional = 0.0
oids: list[str] = []
for o in selected:
sh = float(o["sheets"] or 0)
px = float(o["avg_px"] or 0)
if sh <= 0 or px <= 0:
continue
recv += total_premium(px, sh * ct_mult)
notional += px * sh
oid = str(o["ord_id"] or "").strip()
if oid and oid not in oids:
oids.append(oid)
if not oids:
continue
total_recv = round(recv, 8)
paid = float(tr["premium_paid"] or 0) if tr["premium_paid"] is not None else None
if paid is None:
continue
new_pnl = round(total_recv - paid, 8)
close_px = round(notional / sheets_sum, 8) if sheets_sum > 0 else None
old_recv = (
float(tr["premium_received"])
if tr["premium_received"] is not None
else None
)
old_pnl = float(tr["realized_pnl"]) if tr["realized_pnl"] is not None else None
# 已与合计一致则跳过
if (
old_recv is not None
and old_pnl is not None
and abs(old_recv - total_recv) < 1e-8
and abs(old_pnl - new_pnl) < 1e-8
):
continue
# 仅当卖笔数≥2 或合计收回明显高于本地(漏记)时覆盖
if len(selected) < 2 and old_recv is not None and total_recv <= old_recv + 1e-10:
continue
conn.execute(
"""
UPDATE options_trades
SET premium_received = ?,
realized_pnl = ?,
close_quote = COALESCE(?, close_quote),
close_ord_id = ?
WHERE id = ?
""",
(
total_recv,
new_pnl,
close_px,
",".join(oids) if oids else tr["close_ord_id"],
int(tr["id"]),
),
)
updated += 1
if updated:
conn.commit()
finally:
conn.close()
return updated
def option_positions_history_okx_rows( def option_positions_history_okx_rows(
self, self,
exchange: Any = None, exchange: Any = None,
@@ -901,6 +1107,12 @@ class SimBroker:
实盘 positions-history 在模拟模式不可用,期权历史/复盘依赖此合成数据. 实盘 positions-history 在模拟模式不可用,期权历史/复盘依赖此合成数据.
""" """
# 先按卖平成交修复分批漏计,再合成展示
try:
self.repair_closed_options_pnl_from_sim_orders(limit=max(int(limit), 200))
except Exception:
pass
from datetime import datetime from datetime import datetime
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
+1 -1
View File
@@ -51,7 +51,7 @@ def install_sim_trading(app: Flask, repo_root: str, app_module: Any = None) -> N
apply_sim_hooks(app_module) apply_sim_hooks(app_module)
# 若 options / hedge 已安装, 补丁其 cfg, 并刷新永续下单引用 # 若 options / hedge 已安装, 补丁其 cfg, 并刷新永续下单引用
for key in ("options_cfg", "hedge_plan_cfg"): for key in ("options_cfg",):
cfg = app.extensions.get(key) cfg = app.extensions.get(key)
if isinstance(cfg, dict): if isinstance(cfg, dict):
patch_options_cfg(cfg) patch_options_cfg(cfg)
+40
View File
@@ -0,0 +1,40 @@
"""OKX 公开 K 线小测(无网络):instId / bar 映射."""
from __future__ import annotations
import unittest
class OkxPublicOhlcvMappingTests(unittest.TestCase):
def test_swap_inst_and_bar(self):
# 避免 import 整站 app:直接复刻映射逻辑做契约校验
def swap_inst(exchange_symbol: str) -> str:
sym = str(exchange_symbol or "").strip().upper()
if not sym:
return ""
if "/" in sym:
base, rest = sym.split("/", 1)
quote = rest.split(":", 1)[0].strip() or "USDT"
return f"{base.strip()}-{quote}-SWAP"
if sym.endswith("-SWAP"):
return sym
return sym
def public_bar(timeframe: str) -> str:
low = str(timeframe or "").strip().lower()
if low.endswith("m") and low[:-1].isdigit():
return low
if low.endswith("h") and low[:-1].isdigit():
return f"{low[:-1]}H"
if low.endswith("d") and low[:-1].isdigit():
return f"{low[:-1]}D"
return low or "15m"
self.assertEqual(swap_inst("ETH/USDT:USDT"), "ETH-USDT-SWAP")
self.assertEqual(swap_inst("BTC/USDT:USDT"), "BTC-USDT-SWAP")
self.assertEqual(public_bar("15m"), "15m")
self.assertEqual(public_bar("1h"), "1H")
self.assertEqual(public_bar("4h"), "4H")
if __name__ == "__main__":
unittest.main()
+45
View File
@@ -0,0 +1,45 @@
"""币本位期权浮盈折 U(BTC/ETH 不得把币数量直接当 U)."""
from __future__ import annotations
import unittest
from lib.options.options_positions_lib import display_pnl_usdt_from_option_row
class DisplayPnlUsdtTests(unittest.TestCase):
def test_btc_coin_pnl_times_index(self):
row = {
"inst_id": "BTC-USD-260327-100000-P",
"premium_ccy": "BTC",
"margin_mode": "coin",
"idx_px": 100000.0,
"upl": -0.001,
"close_preview": {"bid_invalid": True},
}
# 残档回退 upl: -0.001 BTC × 100000 = -100 U
self.assertAlmostEqual(display_pnl_usdt_from_option_row(row), -100.0, places=4)
def test_btc_infers_ccy_when_premium_ccy_missing(self):
row = {
"inst_id": "BTC-USD-260327-100000-C",
"margin_mode": "coin",
"underlying": "BTC",
"idx_px": 95000.0,
"upl": 0.002,
"close_preview": {"bid_invalid": True},
}
self.assertAlmostEqual(display_pnl_usdt_from_option_row(row), 190.0, places=4)
def test_usdc_unchanged(self):
row = {
"inst_id": "BTC-USD_UM-260327-100000-C",
"premium_ccy": "USDC",
"margin_mode": "usdc",
"upl": -12.5,
"close_preview": {"bid_invalid": True},
}
self.assertAlmostEqual(display_pnl_usdt_from_option_row(row), -12.5, places=4)
if __name__ == "__main__":
unittest.main()
+160
View File
@@ -0,0 +1,160 @@
"""分批平仓:累计各批收回权利金后再算盈亏."""
from __future__ import annotations
import sqlite3
import unittest
from unittest.mock import patch
from lib.options.options_close_exec_lib import close_option_by_bid1
from lib.options.options_db import (
add_open_close_accum,
get_open_close_accum,
init_options_tables,
)
def _mem_db() -> sqlite3.Connection:
conn = sqlite3.connect(":memory:")
conn.row_factory = sqlite3.Row
init_options_tables(conn)
return conn
class CloseAccumDbTests(unittest.TestCase):
def test_accum_on_earliest_open(self):
conn = _mem_db()
conn.execute(
"""
INSERT INTO options_trades
(inst_id, underlying, opt_type, sheets, eth_amount, premium_paid, status)
VALUES ('ETH-USD-260927-2700-P', 'ETH', 'P', 10, 0.1, 0.02, 'open')
"""
)
conn.commit()
self.assertTrue(
add_open_close_accum(
conn, "ETH-USD-260927-2700-P", recv=0.005, sheets=5, px=0.05, ord_id="a1"
)
)
self.assertTrue(
add_open_close_accum(
conn, "ETH-USD-260927-2700-P", recv=0.004, sheets=5, px=0.04, ord_id="a2"
)
)
acc = get_open_close_accum(conn, "ETH-USD-260927-2700-P")
self.assertAlmostEqual(float(acc["recv"]), 0.009, places=6)
self.assertEqual(int(acc["sheets"]), 10)
self.assertAlmostEqual(float(acc["notional"]), 0.05 * 5 + 0.04 * 5, places=6)
self.assertEqual(acc["close_ord_id"], "a1,a2")
class TwoStepCloseExecTests(unittest.TestCase):
def test_two_partial_closes_sum_premium_received(self):
conn = _mem_db()
inst = "ETH-USD-260927-2700-P"
conn.execute(
"""
INSERT INTO options_trades
(inst_id, underlying, opt_type, strike, sheets, eth_amount,
open_quote, premium_paid, status, premium_ccy, margin_mode)
VALUES (?, 'ETH', 'P', 2700, 10, 0.1, 0.2, 0.02, 'open', 'ETH', 'coin')
""",
(inst,),
)
conn.commit()
# 持仓:第一次 10→5,第二次 5→0
pos_seq = [
[{"instId": inst, "availPos": "10", "pos": "10", "mgnMode": "isolated", "idxPx": "2500"}],
[{"instId": inst, "availPos": "5", "pos": "5", "mgnMode": "isolated", "idxPx": "2500"}],
[{"instId": inst, "availPos": "5", "pos": "5", "mgnMode": "isolated", "idxPx": "2500"}],
[], # 第二次下单后已平
]
pos_i = {"n": 0}
def fetch_positions(_ex):
i = pos_i["n"]
pos_i["n"] = min(i + 1, len(pos_seq) - 1)
return pos_seq[i]
order_px = {"n": 0}
def place_limit(_ex, **kwargs):
order_px["n"] += 1
px = 0.05 if order_px["n"] == 1 else 0.04
return {"ok": True, "px": px, "data": {"ordId": f"ord{order_px['n']}"}}
def quote(_ex, _inst):
return {"ok": True, "tick_sz": "0.0001", "ct_mult": 0.01, "bid": 0.05, "index_px": 2500}
def book(_ex, _inst, _n):
# 每次只够吃一半深度,强制分两批
return {"bids": [{"px": 0.05, "sz": 5}]} if order_px["n"] < 1 else {"bids": [{"px": 0.04, "sz": 5}]}
cfg = {
"get_db": lambda: _reuse_conn(conn),
"quote_option_contract": quote,
"fetch_option_positions": fetch_positions,
"fetch_option_book_depth": book,
"place_option_limit_order": place_limit,
"td_mode": "isolated",
}
with patch("lib.options.options_close_exec_lib.time.sleep", return_value=None), patch(
"lib.exchange.okx_options_lib.invalidate_option_positions_cache"
), patch(
"lib.options.options_pricing_lib.fetch_option_mark_px", return_value=0.05
), patch(
"lib.options.options_close_exec_lib._pos_close_refs", return_value=(0.05, 0.0)
):
r1 = close_option_by_bid1(cfg, ex=object(), inst_id=inst, sheets=10)
self.assertTrue(r1.get("ok"), r1)
self.assertFalse(r1.get("fully_closed"), r1)
open_row = conn.execute(
"SELECT close_recv_accum, status, realized_pnl FROM options_trades WHERE inst_id=?",
(inst,),
).fetchone()
self.assertEqual(open_row["status"], "open")
self.assertGreater(float(open_row["close_recv_accum"] or 0), 0)
self.assertIsNone(open_row["realized_pnl"])
# 第二次买一深度
def book2(_ex, _inst, _n):
return {"bids": [{"px": 0.04, "sz": 5}]}
cfg["fetch_option_book_depth"] = book2
r2 = close_option_by_bid1(cfg, ex=object(), inst_id=inst, sheets=5)
self.assertTrue(r2.get("ok"), r2)
self.assertTrue(r2.get("fully_closed"), r2)
closed = conn.execute(
"""
SELECT premium_paid, premium_received, realized_pnl, status, close_recv_accum
FROM options_trades WHERE inst_id=?
""",
(inst,),
).fetchone()
self.assertEqual(closed["status"], "closed")
# ct_mult=0.01: 5张×0.05 + 5张×0.04 = 0.0025 + 0.002 = 0.0045
self.assertAlmostEqual(float(closed["premium_received"]), 0.0045, places=6)
self.assertAlmostEqual(float(closed["realized_pnl"]), 0.0045 - 0.02, places=6)
self.assertEqual(float(closed["close_recv_accum"] or 0), 0.0)
# 若只记第二批,recv 会是 0.002,pnl=-0.018;合计应为 -0.0155
self.assertNotAlmostEqual(float(closed["premium_received"]), 0.002, places=6)
def _reuse_conn(conn: sqlite3.Connection) -> sqlite3.Connection:
"""get_db 每次返回同一连接;close 时 no-op,避免 :memory: 被关掉."""
class _Proxy:
def __getattr__(self, name):
return getattr(conn, name)
def close(self):
return None
return _Proxy() # type: ignore[return-value]
if __name__ == "__main__":
unittest.main()
+41
View File
@@ -0,0 +1,41 @@
"""翻倍出场:买一价×全仓张数判定达标(不受买一深度截断)."""
from __future__ import annotations
import unittest
from unittest.mock import patch
from lib.options.options_profit_exit_lib import _estimate_recycle, profit_exit_hit
class ProfitExitHitTests(unittest.TestCase):
def test_hit_at_0_3_mult(self):
# 0.3倍: recycle >= paid * 1.3
self.assertTrue(profit_exit_hit(premium_paid=1.0, recycle_usdc=1.3, mult=0.3))
self.assertFalse(profit_exit_hit(premium_paid=1.0, recycle_usdc=1.29, mult=0.3))
def test_estimate_uses_full_sheets_not_depth(self):
pos = {"inst_id": "ETH-USD-260927-2700-P", "pos": 100, "ct_mult": 0.01}
preview = {
"bid_invalid": False,
"levels": [{"px": 0.2, "sheets": 10}], # 深度只够 10 张
"total_received": 0.02, # 仅深度内
}
def fake_attach(cfg, ex, row, premium_paid=None):
row["close_preview"] = preview
row["ct_mult"] = 0.01
row["pos"] = 100
return row
with patch(
"lib.options.options_positions_lib.attach_close_preview",
side_effect=fake_attach,
):
recv = _estimate_recycle({}, object(), pos, 1.0)
# 应按 100 张 × 0.01 × 0.2 = 0.2,而非深度截断的 0.02
self.assertAlmostEqual(float(recv), 0.2, places=6)
self.assertTrue(profit_exit_hit(premium_paid=0.15, recycle_usdc=float(recv), mult=0.3))
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,157 @@
"""期权复盘自动 K 线:单元测试."""
from __future__ import annotations
import sqlite3
import unittest
from unittest.mock import MagicMock, patch
from lib.options.options_review_chart_async_lib import (
OPTIONS_REVIEW_AUTO_CHART_TF,
build_options_review_chart_filename,
merge_auto_chart_into_images,
schedule_options_review_exchange_chart,
underlying_to_okx_perp_symbol,
)
from lib.options.options_review_db import init_options_review_tables
from lib.options.options_review_lib import save_review_entry, upsert_option_history_row
def _conn() -> sqlite3.Connection:
c = sqlite3.connect(":memory:")
c.row_factory = sqlite3.Row
init_options_review_tables(c)
return c
class OptionsReviewChartAsyncTests(unittest.TestCase):
def test_underlying_to_okx_perp_symbol(self):
self.assertEqual(
underlying_to_okx_perp_symbol(
"ETH-USD",
normalize_symbol_input_fn=lambda s: f"{s}/USDT",
normalize_okx_symbol_fn=lambda s: f"{s.split('/')[0]}/USDT:USDT"
if "/" in s
else s,
),
"ETH/USDT:USDT",
)
def test_merge_auto_chart_keeps_manual_slots(self):
merged = merge_auto_chart_into_images(
[
{"tf": "auto", "file": "old.png"},
{"tf": "15m", "file": "m15.png"},
],
"new_auto.png",
)
self.assertEqual(merged[0]["tf"], OPTIONS_REVIEW_AUTO_CHART_TF)
self.assertEqual(merged[0]["file"], "new_auto.png")
self.assertEqual(merged[1], {"tf": "15m", "file": "m15.png"})
def test_chart_filename(self):
self.assertEqual(
build_options_review_chart_filename(12), "options_journal_chart_12.png"
)
@patch("lib.options.options_review_chart_async_lib.threading.Thread")
def test_schedule_starts_daemon_thread(self, mock_thread_cls):
thread = MagicMock()
mock_thread_cls.return_value = thread
schedule_options_review_exchange_chart(
trade_id=7,
exchange_symbol="ETH/USDT:USDT",
title_prefix="t",
journal_tfs=["15m", "1h"],
journal_limit=100,
marker_payload={},
upload_folder="/tmp",
generate_chart_fn=MagicMock(),
get_db_fn=MagicMock(),
now_str_fn=lambda: "2026-09-15 12:00:00",
)
mock_thread_cls.assert_called_once()
self.assertTrue(mock_thread_cls.call_args.kwargs.get("daemon"))
thread.start.assert_called_once()
def test_writeback_merges_auto_image(self):
import tempfile
from pathlib import Path
from lib.options.options_review_images_lib import parse_options_review_images_json
with tempfile.TemporaryDirectory() as td:
db_path = Path(td) / "t.db"
def get_db():
c = sqlite3.connect(str(db_path))
c.row_factory = sqlite3.Row
return c
conn = get_db()
init_options_review_tables(conn)
upsert_option_history_row(
conn,
{
"history_key": "ex:chart1",
"inst_id": "ETH-USD-260328-2000-C",
"underlying": "ETH",
"opt_type": "C",
"realized_pnl": 1.0,
"created_at": "2026-09-01 10:00:00",
"closed_at": "2026-09-01 12:00:00",
},
)
trade_id = conn.execute(
"SELECT id FROM options_review_trades WHERE history_key='ex:chart1'"
).fetchone()["id"]
save_review_entry(
conn,
trade_id,
{
"strategy_tag": "测试",
"images": [{"tf": "15m", "file": "options_journal_x_15m.png"}],
},
)
conn.commit()
conn.close()
chart_name = build_options_review_chart_filename(trade_id)
generate = MagicMock(return_value=chart_name)
with patch(
"lib.options.options_review_chart_async_lib.threading.Thread"
) as mock_thread_cls:
thread = MagicMock()
mock_thread_cls.return_value = thread
schedule_options_review_exchange_chart(
trade_id=trade_id,
exchange_symbol="ETH/USDT:USDT",
title_prefix="ETH review",
journal_tfs=["15m", "1h"],
journal_limit=200,
marker_payload={"entry_ts_ms": 1, "exit_ts_ms": 2},
upload_folder=td,
generate_chart_fn=generate,
get_db_fn=get_db,
now_str_fn=lambda: "2026-09-15 12:00:00",
)
target = mock_thread_cls.call_args.kwargs["target"]
target()
conn = get_db()
try:
row = conn.execute(
"SELECT images_json, image FROM options_review_entries WHERE trade_id=?",
(trade_id,),
).fetchone()
self.assertEqual(row["image"], chart_name)
images = parse_options_review_images_json(row["images_json"])
self.assertEqual(images[0]["tf"], "auto")
self.assertEqual(images[0]["file"], chart_name)
self.assertTrue(any(x.get("tf") == "15m" for x in images))
finally:
conn.close()
if __name__ == "__main__":
unittest.main()
+92
View File
@@ -0,0 +1,92 @@
"""期权业务时区与 UTC 墙钟迁移单测."""
from __future__ import annotations
import sqlite3
import unittest
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
from lib.options.options_db import init_options_tables
from lib.options.options_review_db import init_options_review_tables
from lib.options.options_review_lib import get_sync_state, upsert_option_history_row
from lib.options.options_time_lib import (
migrate_options_utc_wall_clock_to_app,
ms_to_options_app_str,
options_wall_clock_to_ms,
utc_wall_clock_to_app_str,
)
class OptionsTimeLibTests(unittest.TestCase):
def test_ms_to_shanghai_wall_clock(self):
# 2026-09-13 21:41:57 UTC → 2026-09-14 05:41:57 Asia/Shanghai
ms = int(
datetime(2026, 9, 13, 21, 41, 57, tzinfo=timezone.utc).timestamp() * 1000
)
self.assertEqual(ms_to_options_app_str(ms), "2026-09-14 05:41:57")
def test_utc_wall_to_app(self):
self.assertEqual(
utc_wall_clock_to_app_str("2026-09-13 21:41:57"),
"2026-09-14 05:41:57",
)
def test_wall_clock_to_ms_roundtrip(self):
raw = "2026-09-14 05:41:57"
ms = options_wall_clock_to_ms(raw)
self.assertIsNotNone(ms)
dt = datetime.fromtimestamp(ms / 1000.0, tz=ZoneInfo("Asia/Shanghai"))
self.assertEqual(dt.strftime("%Y-%m-%d %H:%M:%S"), raw)
def test_migrate_once(self):
conn = sqlite3.connect(":memory:")
conn.row_factory = sqlite3.Row
init_options_tables(conn)
init_options_review_tables(conn)
conn.execute(
"""
INSERT INTO options_trades(
inst_id, underlying, opt_type, sheets, eth_amount, premium_paid, status,
created_at, closed_at
) VALUES ('ETH-USD-260917-2525-P', 'ETH', 'P', 1, 0.01, 1, 'closed',
'2026-09-13 21:41:57', '2026-09-14 02:31:00')
"""
)
upsert_option_history_row(
conn,
{
"history_key": "local_opt:1",
"inst_id": "ETH-USD-260917-2525-P",
"underlying": "ETH",
"opt_type": "P",
"realized_pnl": 0.5,
"created_at": "2026-09-13 21:41:57",
"closed_at": "2026-09-14 02:31:00",
},
)
conn.commit()
first = migrate_options_utc_wall_clock_to_app(conn)
self.assertEqual(first["skipped"], 0)
self.assertGreaterEqual(first["trades"], 1)
self.assertGreaterEqual(first["review"], 1)
row = conn.execute(
"SELECT created_at, closed_at FROM options_trades WHERE id=1"
).fetchone()
self.assertEqual(row["created_at"], "2026-09-14 05:41:57")
self.assertEqual(row["closed_at"], "2026-09-14 10:31:00")
rev = conn.execute(
"SELECT opened_at, closed_at FROM options_review_trades WHERE history_key='local_opt:1'"
).fetchone()
self.assertEqual(rev["opened_at"], "2026-09-14 05:41:57")
self.assertEqual(rev["closed_at"], "2026-09-14 10:31:00")
self.assertEqual(get_sync_state(conn, "options_ts_utc_wall_to_app_done"), "1")
second = migrate_options_utc_wall_clock_to_app(conn)
self.assertEqual(second["skipped"], 1)
if __name__ == "__main__":
unittest.main()
+52
View File
@@ -0,0 +1,52 @@
"""对冲拆除后的冒烟:交易模式锁定单独期权;hedge_plan 包已移除."""
from __future__ import annotations
import importlib
import unittest
class RemoveHedgeSmokeTests(unittest.TestCase):
def test_trade_mode_always_options(self):
from lib.options.okx_trade_mode_lib import (
get_okx_trade_mode,
hedge_module_enabled,
standalone_options_open_allowed,
block_standalone_open_by_mode_msg,
normalize_okx_trade_mode,
)
self.assertEqual(normalize_okx_trade_mode("options_options"), "options")
self.assertEqual(normalize_okx_trade_mode("perp_options"), "options")
self.assertEqual(get_okx_trade_mode(), "options")
self.assertFalse(hedge_module_enabled())
self.assertTrue(standalone_options_open_allowed())
self.assertIsNone(block_standalone_open_by_mode_msg())
def test_hedge_plan_package_gone(self):
with self.assertRaises(ModuleNotFoundError):
importlib.import_module("lib.hedge_plan")
def test_option_expiry_pnl_available(self):
from lib.options.options_pricing_lib import option_expiry_pnl
pnl = option_expiry_pnl(
opt_type="P",
strike=2700,
spot=2600,
sheets=10,
ct_mult=0.01,
premium_paid=1.0,
)
# intrinsic = 100 * 10 * 0.01 = 10; pnl = 10 - 1 = 9
self.assertAlmostEqual(pnl, 9.0, places=6)
def test_profit_exit_no_hedge_import(self):
import inspect
from lib.options import options_profit_exit_lib as m
src = inspect.getsource(m)
self.assertNotIn("hedge_plan", src)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,95 @@
"""模拟盘:用分批卖平成交修复已平仓漏计盈亏."""
from __future__ import annotations
import sqlite3
import unittest
from lib.options.options_db import init_options_tables
from lib.sim.broker_lib import SimBroker
from lib.sim.db_lib import init_sim_tables
class RepairSimPartialClosePnlTests(unittest.TestCase):
def test_repair_sums_two_sell_orders(self):
conn = sqlite3.connect(":memory:")
conn.row_factory = sqlite3.Row
init_options_tables(conn)
init_sim_tables(conn)
inst = "ETH-USD-260927-2700-P"
# 开仓 228 张,权利金 0.399;旧逻辑只记了第二笔收回 0.2449 → pnl=-0.1541
conn.execute(
"""
INSERT INTO options_trades(
inst_id, underlying, opt_type, strike, sheets, eth_amount,
open_quote, premium_paid, premium_received, realized_pnl,
status, premium_ccy, created_at, closed_at
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)
""",
(
inst,
"ETH",
"P",
2700,
228,
2.28,
0.175,
0.399,
0.2449,
-0.1541,
"closed",
"ETH",
"2026-09-24 07:09:19",
"2026-09-24 20:59:20",
),
)
# 两笔卖平各 114 张
conn.execute(
"""
INSERT INTO sim_option_orders(
ord_id, inst_id, side, sheets, avg_px, state, acc_fill_sz, created_at
) VALUES (?,?,?,?,?,'filled',?,?)
""",
("sim-a", inst, "sell", 114, 0.12, 114, "2026-09-24 15:00:00"),
)
conn.execute(
"""
INSERT INTO sim_option_orders(
ord_id, inst_id, side, sheets, avg_px, state, acc_fill_sz, created_at
) VALUES (?,?,?,?,?,'filled',?,?)
""",
("sim-b", inst, "sell", 114, 0.1074, 114, "2026-09-24 20:59:10"),
)
conn.commit()
def get_db():
return conn
# SimBroker 每次 close 自己的连接;测试里复用同一 :memory: 需禁止 close
class _Proxy:
def __getattr__(self, name):
return getattr(conn, name)
def close(self):
return None
broker = SimBroker(lambda: _Proxy()) # type: ignore[arg-type]
n = broker.repair_closed_options_pnl_from_sim_orders()
self.assertEqual(n, 1)
row = conn.execute(
"SELECT premium_received, realized_pnl, close_ord_id FROM options_trades WHERE inst_id=?",
(inst,),
).fetchone()
# ct_mult=2.28/228=0.01; recv=114*0.12*0.01 + 114*0.1074*0.01 = 0.1368+0.122436=0.259236
self.assertAlmostEqual(float(row["premium_received"]), 0.259236, places=6)
self.assertAlmostEqual(float(row["realized_pnl"]), 0.259236 - 0.399, places=6)
self.assertIn("sim-a", str(row["close_ord_id"]))
self.assertIn("sim-b", str(row["close_ord_id"]))
# 历史合成应读到修复后的盈亏
hist = broker.option_positions_history_okx_rows(limit=10)
self.assertEqual(len(hist), 1)
self.assertAlmostEqual(float(hist[0]["realizedPnl"]), 0.259236 - 0.399, places=6)
if __name__ == "__main__":
unittest.main()
+1 -1
View File
@@ -4,7 +4,7 @@
基于 Flask 的 **OKX 期权 / 对冲** Web 控制台.与中控、其它交易所实例无关. 基于 Flask 的 **OKX 期权 / 对冲** Web 控制台.与中控、其它交易所实例无关.
**部署 / PM2 / SOCKS** 见 **[部署文档.md](./部署文档.md)**: **部署 / PM2 / SOCKS** 见 **[部署文档.md](./部署文档.md)**;**新机纯实盘**见 **[新机纯实盘部署.md](./新机纯实盘部署.md)**:
```bash ```bash
curl -fsSL https://git.bz121.com/dekun/crypto_okx/raw/branch/main/deploy/manage.sh | bash curl -fsSL https://git.bz121.com/dekun/crypto_okx/raw/branch/main/deploy/manage.sh | bash
+215
View File
@@ -0,0 +1,215 @@
# 新机纯实盘部署指南(crypto_okx)
适用场景:**新开一台服务器,直接接 OKX 实盘 API,不做同机模拟→实盘切换**。
当前推荐用法:
| 机器 | 用途 |
|------|------|
| 现有模拟机 | 不接 API Key,本地模拟撮合 + 公开行情 |
| **本机(新机)** | 只跑实盘,从部署起就是 `live` |
与主仓 `crypto_monitor` **隔离**:请用独立子账户 / 独立 API Key,避免双实例同 Key 抢仓。
通用安装步骤见 [部署文档.md](./部署文档.md);页面操作见 [使用说明.md](./使用说明.md)。
---
## 1. 上线前准备
1. OKX **实盘**账户(非演示盘),建议专用子账户、小资金。
2. API Key 权限:交易(期权 + 如需币本位则含现货);按需开提现(一般不必)。
3. IP 白名单绑定到新机公网 IP。
4. 确认持仓模式 / 保证金模式与下文 `.env` 一致(如双向 + 全仓)。
5. 确认本机或经 SOCKS 能访问 `https://www.okx.com`。
---
## 2. 一键部署
新机 root:
```bash
curl -fsSL https://git.bz121.com/dekun/crypto_okx/raw/branch/main/deploy/manage.sh | bash
```
菜单选 **1. 一键部署**。默认路径 `/opt/crypto_okx`,端口 `5004`。
或手动:
```bash
cd /opt
git clone https://git.bz121.com/dekun/crypto_okx.git crypto_okx
cd /opt/crypto_okx
cp -n .env.example .env
bash deploy/setup_env.sh --install-system-deps
```
---
## 3. 纯实盘 `.env` 必改项
编辑:
```bash
nano /opt/crypto_okx/.env
```
### 3.1 模式:直接 live(不要 sim)
```env
# 启动即实盘(不要写成 sim)
SIM_DEFAULT_MODE=live
# 允许真实下单
LIVE_TRADING_ENABLED=true
```
说明:
- 新机不要依赖页面「切换到实盘」;部署时就把 `SIM_DEFAULT_MODE=live` 写好。
- 期权路径主要认 API Key;`LIVE_TRADING_ENABLED` 仍建议保持 `true`,与文档口径一致。
### 3.2 实盘 API(必填)
```env
OKX_API_KEY=你的实盘Key
OKX_API_SECRET=你的实盘Secret
OKX_API_PASSPHRASE=你的Passphrase
OKX_OPTIONS_ENABLED=true
EXCHANGE_DISPLAY_NAME=OKX·实盘
```
不要用演示盘 Key;本仓库未接 `x-simulated-trading`。
### 3.3 交易模式与预算(按策略改)
单独期权(常见起步):
```env
OKX_TRADE_MODE=options
OKX_OPTIONS_MARGIN_MODE=coin
OKX_OPTIONS_COIN_BUDGET_USDT=10
OKX_OPTIONS_TRADE_BUDGET_USDC=10
```
若做对冲,再改为 `perp_options` / `options_options`,并打开:
```env
HEDGE_PLAN_LIVE_ORDER=true
```
起步建议先 `HEDGE_PLAN_LIVE_ORDER=false` 或页面 dry_run,跑通单独期权后再开对冲真下单。
### 3.4 代理(按网络需要)
若服务器直连 OKX 不稳定:
```env
OKX_SOCKS_PROXY=socks5h://127.0.0.1:1080
```
并先在本机建好 SSH SOCKS(见部署文档)。
---
## 4. 启动与确认
```bash
cd /opt/crypto_okx
pm2 restart crypto_okx --update-env
# 若尚未创建进程,用一键脚本或:
# pm2 start ecosystem.config.cjs
pm2 status
pm2 logs crypto_okx --lines 50
```
打开:`http://<新机IP>:5004`
核对:
1. 能登录。
2. 顶栏为 **实盘**(`okx_live`),**没有**「模拟」徽章。
3. 资金条能读到交易所余额(不是本地模拟钱包初始值)。
4. 系统设置 → 模拟资金:当前模式应为 **live**(勿再切回 sim)。
可用接口自检:
```bash
curl -s -u '用户名:密码' http://127.0.0.1:5004/api/sim/status
# 期望含 "mode":"live"
```
---
## 5. 小额冒烟(必做)
在真钱前用 **最小张数 / 最小预算**:
1. `/options` 开 1 张(或最小允许张数)→ 看交易所成交与本地记录。
2. 持仓出现在页面监控。
3. 平仓 → 余额变化符合预期。
4. 若币本位:确认买币桥 → 开期权 → 平 → 卖回(或按你策略保留币)。
5. `/options/review`:复盘列表有记录;可选勾选自动 K 线(公开行情,有 Key 也可画)。
任一步异常:**先停手**,查 `pm2 logs crypto_okx`,不要加大仓位。
---
## 6. 与模拟机、主仓的隔离
| 不要做 | 原因 |
|--------|------|
| 模拟机与实盘机共用同一 API Key | 双实例监控/下单互相干扰 |
| 与正在跑的 `crypto_monitor` 共用同一 Key | 同上 |
| 在实盘机再切回 sim「玩玩」 | 易混仓、混认知;模拟请继续用旧模拟机 |
| 把 `.env` 提交进 Git | 泄密 |
---
## 7. 日常更新(实盘机)
```bash
cd /opt/crypto_okx
git pull
pm2 restart crypto_okx --update-env
git log -1 --oneline
```
或:
```bash
bash /opt/crypto_okx/deploy/pull_and_restart.sh
```
`git pull` **不会**覆盖 `.env`。大版本更新前可:`cp .env .env.backup.$(date +%Y%m%d)`。
---
## 8. 故障快速对照
| 现象 | 排查 |
|------|------|
| 顶栏仍显示模拟 | `.env` 是否 `SIM_DEFAULT_MODE=live`;是否重启 `--update-env`;设置页是否被改回 sim |
| 读不到余额 / 下单失败 | Key、权限、IP 白名单、SOCKS、OKX 账户是否实盘 |
| 能开单但监控不对 | 是否与其它实例同 Key;持仓模式是否与 `OKX_POS_MODE` 一致 |
| 复盘无自动 K 线 | 服务器能否访问 OKX 公开行情;看日志是否画图失败 |
---
## 9. 推荐启动清单(可打印)
- [ ] 新机已部署 `/opt/crypto_okx`
- [ ] `SIM_DEFAULT_MODE=live`
- [ ] `LIVE_TRADING_ENABLED=true`
- [ ] 实盘 `OKX_API_*` 已填,且非演示盘
- [ ] Key 与主仓 / 模拟机隔离
- [ ] 顶栏确认 live,资金为交易所余额
- [ ] 小额开平冒烟通过
- [ ] 企业微信 / 监控按需开启
---
## 免责声明
实盘有亏损风险。请遵守当地法规与交易所条款;操作与资金安全自负。
+2
View File
@@ -4,6 +4,8 @@
**功能与页面操作**见同目录 **[使用说明.md](./使用说明.md)**.脚本细节见 **[deploy/README.md](./deploy/README.md)**. **功能与页面操作**见同目录 **[使用说明.md](./使用说明.md)**.脚本细节见 **[deploy/README.md](./deploy/README.md)**.
**新开服务器、直接接 API 跑实盘(不与模拟同机切换)**见 **[新机纯实盘部署.md](./新机纯实盘部署.md)**.
--- ---
## 一键部署(推荐) ## 一键部署(推荐)