Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d6bb99b5bb | |||
| 45c7e73db5 | |||
| 37f787e6a9 |
@@ -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)
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 "";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 "";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 = "保存失败";
|
||||||
|
|||||||
Vendored
+9
@@ -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", "保险模式"),
|
||||||
|
|||||||
Vendored
+95
-10
@@ -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}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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 = ?
|
||||||
""",
|
""",
|
||||||
|
|||||||
@@ -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"),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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 '到期结算'
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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]:
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user