3 Commits

Author SHA1 Message Date
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
23 changed files with 1538 additions and 427 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)
+94 -17
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();
var compoundOn = envTruthy(envFieldValue(body, "OKX_OPTIONS_COMPOUND_FULL_ENABLED", "true"));
var coinCompoundOn = envTruthy(envFieldValue(body, "OKX_OPTIONS_COIN_COMPOUND", "true"));
var coinMaxOn = envTruthy(envFieldValue(body, "OKX_OPTIONS_COIN_MAX_USDT_ENABLED", "false"));
var compoundCapOn = envTruthy(envFieldValue(body, "OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED", "false"));
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)
); );
const budgetRow = envFieldRowByKey(body, "OKX_OPTIONS_TRADE_BUDGET_USDC"); }
if (!budgetRow) return; if (isCoin) {
const compoundOn = !compoundSel || String(compoundSel.value || "").toLowerCase() === "true"; setEnvRowHidden(envFieldRowByKey(body, "OKX_OPTIONS_COIN_BUDGET_USDT"), coinCompoundOn);
budgetRow.hidden = compoundOn; setEnvRowHidden(envFieldRowByKey(body, "OKX_OPTIONS_COIN_MAX_USDT"), !coinMaxOn);
budgetRow.style.display = compoundOn ? "none" : ""; }
}
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) {
+1 -1
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 "";
} }
+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 "";
} }
+136 -14
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();
if (!hasAuto) {
Object.keys(byTf).forEach(function (k) { Object.keys(byTf).forEach(function (k) {
if (k === "auto") return;
if (keys.indexOf(k) < 0) keys.push(k); 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", "保险模式"),
+95 -10
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",
@@ -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}
+3 -9
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(
+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>
+33 -6
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,
@@ -339,12 +363,15 @@ def close_option_by_bid1(
ELSE signal_note ELSE signal_note
END""" END"""
params.append(signal_note) params.append(signal_note)
from lib.options.options_time_lib import options_app_now_str
params.append(options_app_now_str())
params.append(int(row["id"])) params.append(int(row["id"]))
conn.execute( conn.execute(
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 = ?
{note_sql} {note_sql}
WHERE id = ? WHERE id = ?
""", """,
+123 -17
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)
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 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 mode == "net_pnl":
if prem is None or prem <= 0: if prem is None or prem <= 0:
msg = "缺少权利金,无法校验平仓门控" msg = "缺少权利金,无法校验平仓门控"
elif recv is None: elif recv is None:
msg = "暂无有效买盘可回收金额" msg = "暂无有效买盘可回收金额"
elif not recycle_ok: 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 = "缺少权利金,无法校验平仓门控"
elif recv is None:
msg = "暂无有效买盘可回收金额"
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()
+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 '到期结算'
+8 -2
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
+9 -4
View File
@@ -1218,13 +1218,15 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
opt_type = meta.get("optType") opt_type = meta.get("optType")
open_underlying = u open_underlying = u
open_opt_type = opt_type open_opt_type = opt_type
from lib.options.options_time_lib import options_app_now_str
cur = conn.execute( cur = conn.execute(
""" """
INSERT INTO options_trades INSERT INTO options_trades
(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,
profit_exit_enabled, profit_exit_mult, profit_exit_state) profit_exit_enabled, profit_exit_mult, profit_exit_state, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'open', ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?)
""", """,
( (
inst_id, inst_id,
@@ -1241,6 +1243,7 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
1 if profit_exit_enabled else 0, 1 if profit_exit_enabled else 0,
profit_exit_mult if profit_exit_enabled else 1.0, profit_exit_mult if profit_exit_enabled else 1.0,
"active" if profit_exit_enabled else "idle", "active" if profit_exit_enabled else "idle",
options_app_now_str(),
), ),
) )
trade_id = int(cur.lastrowid) trade_id = int(cur.lastrowid)
@@ -1341,6 +1344,8 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
try: try:
conn = cfg["get_db"]() conn = cfg["get_db"]()
try: try:
from lib.options.options_time_lib import options_app_now_str
init_options_tables(conn) init_options_tables(conn)
conn.execute( conn.execute(
""" """
@@ -1350,10 +1355,10 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
WHEN signal_note IS NULL OR TRIM(signal_note) = '' THEN '委托撤销' WHEN signal_note IS NULL OR TRIM(signal_note) = '' THEN '委托撤销'
ELSE signal_note ELSE signal_note
END, END,
closed_at = CURRENT_TIMESTAMP closed_at = ?
WHERE inst_id = ? AND exchange_ord_id = ? AND status = 'open' WHERE inst_id = ? AND exchange_ord_id = ? AND status = 'open'
""", """,
(inst_id, ord_id), (options_app_now_str(), inst_id, ord_id),
) )
conn.commit() conn.commit()
finally: finally:
@@ -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()
+7 -1
View File
@@ -643,6 +643,9 @@ 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
ts_migrate = migrate_options_utc_wall_clock_to_app(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
@@ -658,7 +661,10 @@ def ensure_local_review_synced(
backfill_hedge_option_legs_realized_pnl(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
return out
def _row_to_dict(row: Any) -> dict[str, Any]: def _row_to_dict(row: Any) -> dict[str, Any]:
+142
View File
@@ -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()
+121
View File
@@ -0,0 +1,121 @@
"""期权业务时区墙钟:库内统一按 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"
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}
@@ -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>
@@ -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()