Compare commits

..

10 Commits

Author SHA1 Message Date
dekun 61808a5374 修复 BTC 币本位期权净盈亏被 round4 抹成 0 的精度问题。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-02 14:51:59 +08:00
dekun bc86723b39 修复实例顶栏盈亏闪烁:资金快照与持仓轮询不再一条按币×指数、一条按U。
两条路径抢写实时盈亏会导致一会正常一会上万;统一走 paintRealtimePnlFromSnapshot。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-30 22:40:28 +08:00
dekun 232e5e4b92 修复实例顶栏实时盈亏二次折算:币本位 options 浮盈已是 U,前端不再乘指数。
约 +4.3U 被当成 ETH 再 ×2700 显示成 +1.1万U;顶栏改为直接按 U 展示/与永续相加。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-30 22:35:53 +08:00
dekun d2d431e4c9 修复币本位期权浮盈二次折算:中控汇总与实例统计把已折 U 的数再乘指数。
约 -6.5U 被当成 ETH 再 ×2700 显示成 -1.7万U;汇总框直接按 U 展示,stats 不再对 open_float 二次换算。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-30 22:30:24 +08:00
dekun 32746b0a34 修复 OKX 期权翻倍出场与币本位浮盈:中控/看板按指数折 U,避免 BTC 币数量当美金。
翻倍出场按全仓买一判定且对冲表异常不再整段跳过;面板按当前倍数展示需回收公式。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-30 22:17:02 +08:00
dekun bd4adc9eca 修复翻倍/目标位平仓把备注写进平仓时间:纠正 SQL 参数顺序并自动对调历史坏数据。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-16 05:48:43 +08:00
dekun a38a89e274 修复期权复盘保存后立刻弹详情时自动K线尚未生成就显示未上传的问题。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-15 11:28:09 +08:00
dekun 72ad8ea7dc 修复期权开平仓时间按 UTC 入库导致复盘K线标记偏8小时:统一业务时区并一次性迁移。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-15 11:20:39 +08:00
dekun 0132ca4ca1 期权复盘详情附图改为自动/手动互斥显示,有自动K线时不再并排空槽。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-15 11:12:42 +08:00
dekun 8f37d02494 期权复盘支持纯期权自动生成标的永续双周期K线,按时间标记进场出场。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-15 11:06:44 +08:00
26 changed files with 1231 additions and 206 deletions
+17 -1
View File
@@ -1977,6 +1977,21 @@
return fmt(n, 2) + "倍"; return fmt(n, 2) + "倍";
} }
function profitExitHint(mult, req, premCcy) {
const n = Number(mult);
const m = Number.isFinite(n) && n > 0 ? n : 1;
const needFactor = 1 + m;
const factorTxt = Math.abs(needFactor - Math.round(needFactor)) < 1e-9
? String(Math.round(needFactor))
: fmt(needFactor, 2);
let hint = formatProfitExitMultLabel(m) + "=盈利达权利金×" + fmt(m, 2)
+ " → 需回收≥权利金×" + factorTxt;
if (req != null) {
hint += " · 即≥" + fmtPremiumAmt(req, premCcy) + " " + premCcy;
}
return hint;
}
function renderProfitExitRow(p) { function renderProfitExitRow(p) {
const inst = p.inst_id || ""; const inst = p.inst_id || "";
if (p.hedge_plan_target) { if (p.hedge_plan_target) {
@@ -1997,6 +2012,7 @@
const statePe = String(p.profit_exit_state || (enabled ? "active" : "idle")); const statePe = String(p.profit_exit_state || (enabled ? "active" : "idle"));
const req = p.profit_exit_required_recycle; const req = p.profit_exit_required_recycle;
const premCcy = posPremiumCcy(p); const premCcy = posPremiumCcy(p);
const hintMult = Number.isFinite(multNum) && multNum > 0 ? multNum : serverMult;
let statusTxt = enabled ? ("监控中 · " + multLabel) : "未开启"; let statusTxt = enabled ? ("监控中 · " + multLabel) : "未开启";
if (enabled && statePe === "closing") statusTxt = "平仓挂单中 · " + multLabel; if (enabled && statePe === "closing") statusTxt = "平仓挂单中 · " + multLabel;
return ( return (
@@ -2013,7 +2029,7 @@
'<span class="opt-target-armed">' + statusTxt + "</span>" + '<span class="opt-target-armed">' + statusTxt + "</span>" +
'<span class="muted opt-target-row-hint">' + '<span class="muted opt-target-row-hint">' +
(enabled (enabled
? ("1倍=盈利=权利金" + (req != null ? (" · 需回收≥" + fmtPremiumAmt(req, premCcy) + " " + premCcy) : "")) ? profitExitHint(hintMult, req, premCcy)
: "开启后自选倍数;达标按买一限价平;可随时关闭") + : "开启后自选倍数;达标按买一限价平;可随时关闭") +
"</span>" + "</span>" +
"</div>" "</div>"
+139 -17
View File
@@ -582,31 +582,103 @@
} }
} }
var detailChartPollTimer = null;
var detailChartPollTradeId = 0;
function hideDetail() { function hideDetail() {
hideLightbox(); hideLightbox();
stopDetailChartPoll();
var backdrop = $("or-detail-backdrop"); var backdrop = $("or-detail-backdrop");
if (backdrop) backdrop.hidden = true; if (backdrop) backdrop.hidden = true;
} }
function openDetail(tradeId) { function stopDetailChartPoll() {
if (detailChartPollTimer) {
clearTimeout(detailChartPollTimer);
detailChartPollTimer = null;
}
detailChartPollTradeId = 0;
}
function tradeHasAutoChart(t) {
var images = ((t && t.entry) || {}).images || [];
for (var i = 0; i < images.length; i++) {
var tf = String((images[i] && images[i].tf) || "").trim();
var file = String((images[i] && images[i].file) || "").trim();
if (tf === "auto" && file) return true;
}
return false;
}
function openDetail(tradeId, opts) {
opts = opts || {};
var waitAuto = !!opts.waitAutoChart;
var backdrop = $("or-detail-backdrop"); var backdrop = $("or-detail-backdrop");
if (!backdrop) return; if (!backdrop) return;
stopDetailChartPoll();
backdrop.hidden = false; backdrop.hidden = false;
($("or-detail-title") || {}).textContent = "加载中…"; ($("or-detail-title") || {}).textContent = "加载中…";
($("or-detail-meta") || {}).innerHTML = ""; ($("or-detail-meta") || {}).innerHTML = "";
($("or-detail-text") || {}).innerHTML = ""; ($("or-detail-text") || {}).innerHTML = "";
($("or-detail-images") || {}).innerHTML = ""; ($("or-detail-images") || {}).innerHTML = waitAuto
? '<div class="or-detail-img-cell"><span class="or-detail-img-label">自动K线</span><div class="or-detail-img-miss">生成中…</div></div>'
: "";
fetch("/api/options/review/trades/" + tradeId, { credentials: "same-origin" }) function applyTrade(trade, pendingAuto) {
.then(function (r) { renderDetail(trade, { pendingAuto: !!pendingAuto });
}
function fetchOnce() {
return fetch("/api/options/review/trades/" + tradeId, {
credentials: "same-origin",
}).then(function (r) {
return r.json(); return r.json();
}) });
}
fetchOnce()
.then(function (data) { .then(function (data) {
if (!data.ok || !data.trade) { if (!data.ok || !data.trade) {
($("or-detail-title") || {}).textContent = "加载失败"; ($("or-detail-title") || {}).textContent = "加载失败";
return; return;
} }
renderDetail(data.trade); if (!waitAuto || tradeHasAutoChart(data.trade)) {
applyTrade(data.trade, false);
return;
}
applyTrade(data.trade, true);
detailChartPollTradeId = tradeId;
var tries = 0;
var maxTries = 20;
function poll() {
if (detailChartPollTradeId !== tradeId) return;
tries += 1;
fetchOnce()
.then(function (again) {
if (detailChartPollTradeId !== tradeId) return;
if (again.ok && again.trade && tradeHasAutoChart(again.trade)) {
stopDetailChartPoll();
applyTrade(again.trade, false);
loadReviewed({ sync: false, soft: true });
return;
}
if (tries >= maxTries) {
stopDetailChartPoll();
applyTrade((again && again.trade) || data.trade, false);
return;
}
detailChartPollTimer = setTimeout(poll, 1500);
})
.catch(function () {
if (detailChartPollTradeId !== tradeId) return;
if (tries >= maxTries) {
stopDetailChartPoll();
return;
}
detailChartPollTimer = setTimeout(poll, 1500);
});
}
detailChartPollTimer = setTimeout(poll, 1200);
}) })
.catch(function () { .catch(function () {
($("or-detail-title") || {}).textContent = "加载失败"; ($("or-detail-title") || {}).textContent = "加载失败";
@@ -626,9 +698,29 @@
return base + encodeURIComponent(name); return base + encodeURIComponent(name);
} }
function renderDetailImages(images) { function chartTfLabel(tf) {
if (tf === "auto") return "自动K线";
if (tf === "_") return "截图";
return tf;
}
function setChartOptionsVisible(visible) {
var box = $("or-chart-options");
var hint = $("or-chart-hint");
if (box) box.classList.toggle("hidden", !visible);
if (hint) hint.classList.toggle("hidden", !visible);
}
function resetChartOptions() {
var cb = $("or-exchange-chart");
if (cb) cb.checked = false;
}
function renderDetailImages(images, opts) {
var imagesHost = $("or-detail-images"); var imagesHost = $("or-detail-images");
if (!imagesHost) return; if (!imagesHost) return;
opts = opts || {};
var pendingAuto = !!opts.pendingAuto;
var byTf = {}; var byTf = {};
(images || []).forEach(function (img) { (images || []).forEach(function (img) {
var tf = String((img && img.tf) || "").trim(); var tf = String((img && img.tf) || "").trim();
@@ -637,16 +729,29 @@
var key = tf || "_"; var key = tf || "_";
byTf[key] = file; byTf[key] = file;
}); });
var order = ["5m", "15m", "1h", "4h"]; // 互斥:有自动K线只显示自动;否则只显示手动四周期
var hasAuto = !!byTf.auto;
if (pendingAuto && !hasAuto) {
imagesHost.innerHTML =
'<div class="or-detail-img-cell">' +
'<span class="or-detail-img-label">自动K线</span>' +
'<div class="or-detail-img-miss">生成中…</div>' +
"</div>";
return;
}
var order = hasAuto ? ["auto"] : ["5m", "15m", "1h", "4h"];
var keys = order.slice(); var keys = order.slice();
Object.keys(byTf).forEach(function (k) { if (!hasAuto) {
if (keys.indexOf(k) < 0) keys.push(k); Object.keys(byTf).forEach(function (k) {
}); if (k === "auto") return;
if (keys.indexOf(k) < 0) keys.push(k);
});
}
var cells = keys var cells = keys
.map(function (tf) { .map(function (tf) {
var file = byTf[tf]; var file = byTf[tf];
if (!file) { if (!file) {
if (order.indexOf(tf) < 0) return ""; if (hasAuto || order.indexOf(tf) < 0) return "";
return ( return (
'<div class="or-detail-img-cell">' + '<div class="or-detail-img-cell">' +
'<span class="or-detail-img-label">' + '<span class="or-detail-img-label">' +
@@ -657,7 +762,7 @@
); );
} }
var src = optionsJournalImgSrc(file); var src = optionsJournalImgSrc(file);
var label = escapeHtml(tf === "_" ? "截图" : tf); var label = escapeHtml(chartTfLabel(tf));
return ( return (
'<div class="or-detail-img-cell">' + '<div class="or-detail-img-cell">' +
'<span class="or-detail-img-label">' + '<span class="or-detail-img-label">' +
@@ -698,7 +803,7 @@
}); });
} }
function renderDetail(t) { function renderDetail(t, opts) {
var e = t.entry || {}; var e = t.entry || {};
reviewedCache[t.id] = t; reviewedCache[t.id] = t;
($("or-detail-title") || {}).textContent = ($("or-detail-title") || {}).textContent =
@@ -782,7 +887,7 @@
var imagesHost = $("or-detail-images"); var imagesHost = $("or-detail-images");
if (imagesHost) { if (imagesHost) {
renderDetailImages(e.images || []); renderDetailImages(e.images || [], opts || {});
} }
} }
@@ -992,6 +1097,8 @@
($("or-f-note") || {}).value = ""; ($("or-f-note") || {}).value = "";
setMoodTags(""); setMoodTags("");
resetUploadSlots(); resetUploadSlots();
resetChartOptions();
setChartOptionsVisible(false);
var summary = $("or-journal-summary"); var summary = $("or-journal-summary");
if (summary) { if (summary) {
summary.textContent = "截图槽位与合约复盘相同(5m / 15m / 1h / 4h)."; summary.textContent = "截图槽位与合约复盘相同(5m / 15m / 1h / 4h).";
@@ -1053,6 +1160,8 @@
setSelectValue($("or-f-entry"), e.entry_logic || ""); setSelectValue($("or-f-entry"), e.entry_logic || "");
($("or-f-note") || {}).value = e.note || ""; ($("or-f-note") || {}).value = e.note || "";
setMoodTags(e.mistake_tags); setMoodTags(e.mistake_tags);
resetChartOptions();
setChartOptionsVisible(t.source_type === "option_spot");
var summary = $("or-journal-summary"); var summary = $("or-journal-summary");
if (summary) { if (summary) {
@@ -1068,6 +1177,8 @@
} }
(e.images || []).forEach(function (img) { (e.images || []).forEach(function (img) {
var tf = String((img && img.tf) || "").trim();
if (tf === "auto") return;
var hidden = document.querySelector( var hidden = document.querySelector(
'#or-upload-slots .or-upload-hidden[data-tf="' + img.tf + '"]' '#or-upload-slots .or-upload-hidden[data-tf="' + img.tf + '"]'
); );
@@ -1167,6 +1278,15 @@
note: ($("or-f-note") || {}).value || "", note: ($("or-f-note") || {}).value || "",
images: collectImages(), images: collectImages(),
}; };
var chartBox = $("or-chart-options");
var chartVisible = chartBox && !chartBox.classList.contains("hidden");
if (chartVisible && ($("or-exchange-chart") || {}).checked) {
payload.journal_exchange_chart = true;
payload.journal_chart_tf1 = ($("or-chart-tf1") || {}).value || "";
payload.journal_chart_tf2 = ($("or-chart-tf2") || {}).value || "";
payload.journal_chart_limit = ($("or-chart-limit") || {}).value || "";
payload.journal_chart_anchor = ($("or-chart-anchor") || {}).value || "close";
}
($("or-save-status") || {}).textContent = "保存中…"; ($("or-save-status") || {}).textContent = "保存中…";
fetch("/api/options/review/entry", { fetch("/api/options/review/entry", {
method: "POST", method: "POST",
@@ -1182,10 +1302,12 @@
($("or-save-status") || {}).textContent = data.msg || "保存失败"; ($("or-save-status") || {}).textContent = data.msg || "保存失败";
return; return;
} }
($("or-save-status") || {}).textContent = "已保存"; ($("or-save-status") || {}).textContent = data.chart_queued
? "已保存,K线图后台生成中"
: "已保存";
hideJournalForm(); hideJournalForm();
reloadAll(); reloadAll();
openDetail(tradeId); openDetail(tradeId, { waitAutoChart: !!data.chart_queued });
}) })
.catch(function () { .catch(function () {
($("or-save-status") || {}).textContent = "保存失败"; ($("or-save-status") || {}).textContent = "保存失败";
+3 -9
View File
@@ -214,16 +214,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(
+11 -59
View File
@@ -74,71 +74,30 @@ def options_balances_usdt_equiv(options_snap: dict[str, Any] | None) -> dict[str
def options_float_pnl_usdt(options_snap: dict[str, Any] | None) -> Optional[float]: def options_float_pnl_usdt(options_snap: dict[str, Any] | None) -> Optional[float]:
"""期权浮盈合计(USDT).币本位按指数换算,勿把 ETH/BTC 数量当 U.""" """期权浮盈合计(USDT).币本位按指数换算,勿把 ETH/BTC 数量当 U.
优先逐仓 display_pnl_usdt_from_option_row(已含 BTC/ETH × 指数);
回退 upl_total_usdc 时按「已是 USDT」处理——实例 hub 快照合计已折 U,
不可再 × 指数,否则币本位会双计.
"""
snap = options_snap if isinstance(options_snap, dict) else {} snap = options_snap if isinstance(options_snap, dict) else {}
if snap.get("enabled") is False or snap.get("ok") is False: if snap.get("enabled") is False or snap.get("ok") is False:
return None return None
def _safe(v: Any) -> float | None:
if v is None or v == "":
return None
try:
return float(v)
except (TypeError, ValueError):
return None
def _index_px() -> float | None:
px = _safe(snap.get("options_index_px") or snap.get("index_px"))
if px is not None and px > 0:
return px
for p in snap.get("positions") or []:
if not isinstance(p, dict):
continue
px = _safe(p.get("idx_px") or p.get("idxPx"))
if px is not None and px > 0:
return px
return None
def _row_is_coin(p: dict[str, Any]) -> bool:
ccy = str(p.get("premium_ccy") or "").strip().upper()
if ccy in ("ETH", "BTC"):
return True
if str(p.get("margin_mode") or "").strip().lower() == "coin":
return True
mid = str(p.get("inst_id") or "")
return "-USD-" in mid.upper() and "_UM" not in mid.upper()
mode = str(snap.get("options_margin_mode") or snap.get("margin_mode") or "").strip().lower()
snap_coin = mode == "coin"
idx = _index_px()
try: try:
from lib.options.options_positions_lib import display_pnl_from_option_row from lib.options.options_positions_lib import display_pnl_usdt_from_option_row
total_u = 0.0 total_u = 0.0
found = False found = False
missing_fx = False
for p in snap.get("positions") or []: for p in snap.get("positions") or []:
if not isinstance(p, dict): if not isinstance(p, dict):
continue continue
pnl = display_pnl_from_option_row(p) pnl = display_pnl_usdt_from_option_row(p)
if pnl is None: if pnl is None:
continue continue
found = True found = True
if snap_coin or _row_is_coin(p): total_u += float(pnl)
px = _safe(p.get("idx_px") or p.get("idxPx")) if found:
if px is None or px <= 0:
px = idx
if px is None or px <= 0:
missing_fx = True
continue
total_u += float(pnl) * float(px)
else:
total_u += float(pnl)
if found and not missing_fx:
return round(total_u, 4)
if found and missing_fx and abs(total_u) > 1e-12:
# 部分腿已换算成功时仍返回可得合计
return round(total_u, 4) return round(total_u, 4)
except Exception: except Exception:
pass pass
@@ -146,16 +105,9 @@ def options_float_pnl_usdt(options_snap: dict[str, Any] | None) -> Optional[floa
upl = snap.get("upl_total_usdc") upl = snap.get("upl_total_usdc")
if upl is not None: if upl is not None:
try: try:
raw = float(upl) return round(float(upl), 4)
except (TypeError, ValueError): except (TypeError, ValueError):
return None return None
if snap_coin or any(
isinstance(p, dict) and _row_is_coin(p) for p in (snap.get("positions") or [])
):
if idx is None or idx <= 0:
return None
return round(raw * float(idx), 4)
return round(raw, 4)
return None return None
+3 -3
View File
@@ -162,12 +162,12 @@ def _format_options_item(p: dict[str, Any], *, conn=None) -> dict[str, Any]:
inst = str(p.get("inst_id") or p.get("instId") or "-").strip() or "-" inst = str(p.get("inst_id") or p.get("instId") or "-").strip() or "-"
opt_type = str(p.get("opt_type") or p.get("optType") or "").upper() opt_type = str(p.get("opt_type") or p.get("optType") or "").upper()
label = "Call" if opt_type == "C" else "Put" if opt_type == "P" else (opt_type or "OPT") label = "Call" if opt_type == "C" else "Put" if opt_type == "P" else (opt_type or "OPT")
# 看板期权列:优先买一净盈亏,残档回退交易所 upl # 看板期权列:统一折成 U(币本位 BTC/ETH 权利金盈亏 × 指数)
pnl = None pnl = None
try: try:
from lib.options.options_positions_lib import display_pnl_from_option_row from lib.options.options_positions_lib import display_pnl_usdt_from_option_row
pnl = display_pnl_from_option_row(p) pnl = display_pnl_usdt_from_option_row(p)
except Exception: except Exception:
pnl = None pnl = None
pos = _safe_float(p.get("pos")) pos = _safe_float(p.get("pos"))
+8 -19
View File
@@ -1161,18 +1161,15 @@ function paintRealtimePnlFromSnapshot(data){
const spotPx = data.options_index_px != null ? Number(data.options_index_px) : null; const spotPx = data.options_index_px != null ? Number(data.options_index_px) : null;
if (coinMode) { if (coinMode) {
if (opt != null && !Number.isNaN(Number(opt))) { if (opt != null && !Number.isNaN(Number(opt))) {
// options_unrealized_pnl 已是 USDT(后端按指数折算),勿再 × 指数
if (perp != null && Math.abs(Number(perp)) >= 0.005) { if (perp != null && Math.abs(Number(perp)) >= 0.005) {
// 顶栏只显示合计 U:永续 U + 期权币盈亏×指数 const totalU = Number(perp) + Number(opt);
let totalU = Number(perp);
if (Number.isFinite(spotPx) && spotPx > 0) {
totalU += Number(opt) * spotPx;
}
const uAbs = Math.abs(totalU).toFixed(2); const uAbs = Math.abs(totalU).toFixed(2);
const uSign = totalU < 0 ? "-" : totalU > 0 ? "+" : ""; const uSign = totalU < 0 ? "-" : totalU > 0 ? "+" : "";
const text = `${uSign}${uAbs}U`; const text = `${uSign}${uAbs}U`;
lastRealtimePnl = Number(opt); lastRealtimePnl = Number(opt);
lastRealtimePnlUnit = underly; lastRealtimePnlUnit = "U";
lastRealtimePnlSpotPx = spotPx; lastRealtimePnlSpotPx = null;
document.querySelectorAll('[data-funds-field="realtime-pnl"]').forEach((pnlEl) => { document.querySelectorAll('[data-funds-field="realtime-pnl"]').forEach((pnlEl) => {
pnlEl.innerText = text; pnlEl.innerText = text;
pnlEl.classList.toggle("pnl-pos", totalU > 0); pnlEl.classList.toggle("pnl-pos", totalU > 0);
@@ -1180,11 +1177,11 @@ function paintRealtimePnlFromSnapshot(data){
}); });
return; return;
} }
paintRealtimePnl(opt, underly, spotPx); paintRealtimePnl(opt, "U");
return; return;
} }
// 期权盈亏拉取失败时保留上次有效值,避免顶栏闪成 0/— // 期权盈亏拉取失败时保留上次有效值,避免顶栏闪成 0/—
if (lastRealtimePnl != null && (lastRealtimePnlUnit === "ETH" || lastRealtimePnlUnit === "BTC")) { if (lastRealtimePnl != null && lastRealtimePnlUnit === "U") {
return; return;
} }
} }
@@ -1307,16 +1304,8 @@ function applyAccountSnapshot(data){
setFundsFieldText("options-trading-usdc", optTrading); setFundsFieldText("options-trading-usdc", optTrading);
} }
if(typeof data.unrealized_pnl !== "undefined" || typeof data.options_unrealized_pnl !== "undefined"){ if(typeof data.unrealized_pnl !== "undefined" || typeof data.options_unrealized_pnl !== "undefined"){
const coinMode = String(data.options_margin_mode || "").toLowerCase() === "coin"; // 与 order_prices 轮询共用:options_unrealized_pnl 已是 U,勿再按 ETH×指数(否则顶栏会在正确/上万之间闪烁)
if (coinMode && data.options_unrealized_pnl != null && !Number.isNaN(Number(data.options_unrealized_pnl))) { paintRealtimePnlFromSnapshot(data);
paintRealtimePnl(
data.options_unrealized_pnl,
String(data.options_underly || "ETH").toUpperCase() || "ETH",
data.options_index_px
);
} else if (typeof data.unrealized_pnl !== "undefined") {
updateRealtimePnl(data.unrealized_pnl, "U");
}
} }
if(typeof data.total !== "undefined" && data.total !== null){ if(typeof data.total !== "undefined" && data.total !== null){
setFundsFieldText("stat-total", String(data.total)); setFundsFieldText("stat-total", String(data.total));
+8 -21
View File
@@ -1642,19 +1642,15 @@ function paintRealtimePnlFromSnapshot(data){
const spotPx = data.options_index_px != null ? Number(data.options_index_px) : null; const spotPx = data.options_index_px != null ? Number(data.options_index_px) : null;
if (coinMode) { if (coinMode) {
if (opt != null && !Number.isNaN(Number(opt))) { if (opt != null && !Number.isNaN(Number(opt))) {
// 币本位期权盈亏单位为币,勿与永续 U 混加成「xxU」 // options_unrealized_pnl 已是 USDT(后端按指数折算),勿再 × 指数
if (perp != null && Math.abs(Number(perp)) >= 0.005) { if (perp != null && Math.abs(Number(perp)) >= 0.005) {
// 顶栏只显示合计 U:永续 U + 期权币盈亏×指数 const totalU = Number(perp) + Number(opt);
let totalU = Number(perp);
if (Number.isFinite(spotPx) && spotPx > 0) {
totalU += Number(opt) * spotPx;
}
const uAbs = Math.abs(totalU).toFixed(2); const uAbs = Math.abs(totalU).toFixed(2);
const uSign = totalU < 0 ? "-" : totalU > 0 ? "+" : ""; const uSign = totalU < 0 ? "-" : totalU > 0 ? "+" : "";
const text = `${uSign}${uAbs}U`; const text = `${uSign}${uAbs}U`;
lastRealtimePnl = Number(opt); lastRealtimePnl = Number(opt);
lastRealtimePnlUnit = underly; lastRealtimePnlUnit = "U";
lastRealtimePnlSpotPx = spotPx; lastRealtimePnlSpotPx = null;
document.querySelectorAll('[data-funds-field="realtime-pnl"]').forEach((pnlEl) => { document.querySelectorAll('[data-funds-field="realtime-pnl"]').forEach((pnlEl) => {
pnlEl.innerText = text; pnlEl.innerText = text;
pnlEl.classList.toggle("pnl-pos", totalU > 0); pnlEl.classList.toggle("pnl-pos", totalU > 0);
@@ -1662,11 +1658,11 @@ function paintRealtimePnlFromSnapshot(data){
}); });
return; return;
} }
paintRealtimePnl(opt, underly, spotPx); paintRealtimePnl(opt, "U");
return; return;
} }
// 期权盈亏拉取失败时保留上次有效值,避免顶栏闪成 0/— // 期权盈亏拉取失败时保留上次有效值,避免顶栏闪成 0/—
if (lastRealtimePnl != null && (lastRealtimePnlUnit === "ETH" || lastRealtimePnlUnit === "BTC")) { if (lastRealtimePnl != null && lastRealtimePnlUnit === "U") {
return; return;
} }
} }
@@ -1789,17 +1785,8 @@ function applyAccountSnapshot(data){
setFundsFieldText("options-trading-usdc", optTrading); setFundsFieldText("options-trading-usdc", optTrading);
} }
if(typeof data.unrealized_pnl !== "undefined" || typeof data.options_unrealized_pnl !== "undefined"){ if(typeof data.unrealized_pnl !== "undefined" || typeof data.options_unrealized_pnl !== "undefined"){
const coinMode = String(data.options_margin_mode || "").toLowerCase() === "coin"; // 与 order_prices 轮询共用:options_unrealized_pnl 已是 U,勿再按 ETH×指数(否则顶栏会在正确/上万之间闪烁)
if (coinMode && data.options_unrealized_pnl != null && !Number.isNaN(Number(data.options_unrealized_pnl))) { paintRealtimePnlFromSnapshot(data);
// 币本位:顶栏跟持仓卡同口径(期权净盈亏),勿用混加后的 unrealized_pnl(会被抹成 0.00)
paintRealtimePnl(
data.options_unrealized_pnl,
String(data.options_underly || "ETH").toUpperCase() || "ETH",
data.options_index_px
);
} else if (typeof data.unrealized_pnl !== "undefined") {
updateRealtimePnl(data.unrealized_pnl, "U");
}
} }
if(typeof data.total !== "undefined" && data.total !== null){ if(typeof data.total !== "undefined" && data.total !== null){
setFundsFieldText("stat-total", String(data.total)); setFundsFieldText("stat-total", String(data.total));
+20 -7
View File
@@ -11,9 +11,11 @@ from lib.options.options_close_gate_lib import (
update_close_gate, update_close_gate,
) )
from lib.options.options_pricing_lib import ( from lib.options.options_pricing_lib import (
PREMIUM_DECIMALS_COIN,
estimate_close_by_bids, estimate_close_by_bids,
fetch_option_mark_px, fetch_option_mark_px,
is_stub_bid_px, is_stub_bid_px,
round_premium_amount,
total_premium, total_premium,
) )
@@ -304,7 +306,9 @@ def close_option_by_bid1(
px = float(order.get("px", locked_bid_px)) px = float(order.get("px", locked_bid_px))
oid = str((order.get("data") or {}).get("ordId") or "") oid = str((order.get("data") or {}).get("ordId") or "")
prem_recv = round(total_premium(px, level_sheets * ct_mult), 4) prem_recv = round_premium_amount(
total_premium(px, level_sheets * ct_mult), decimals=PREMIUM_DECIMALS_COIN
) or 0.0
time.sleep(0.6) time.sleep(0.6)
invalidate_option_positions_cache() invalidate_option_positions_cache()
raw2 = cfg["fetch_option_positions"](ex) raw2 = cfg["fetch_option_positions"](ex)
@@ -344,16 +348,25 @@ def close_option_by_bid1(
for i, row in enumerate(open_rows): for i, row in enumerate(open_rows):
paid = float(row["premium_paid"] or 0) paid = float(row["premium_paid"] or 0)
if i == len(open_rows) - 1: if i == len(open_rows) - 1:
recv = round(prem_recv - allocated, 4) recv = round_premium_amount(
prem_recv - allocated, decimals=PREMIUM_DECIMALS_COIN
) or 0.0
elif total_paid > 0: elif total_paid > 0:
recv = round(prem_recv * (paid / total_paid), 4) recv = round_premium_amount(
prem_recv * (paid / total_paid), decimals=PREMIUM_DECIMALS_COIN
) or 0.0
allocated += recv allocated += recv
else: else:
recv = round(prem_recv / len(open_rows), 4) recv = round_premium_amount(
prem_recv / len(open_rows), decimals=PREMIUM_DECIMALS_COIN
) or 0.0
allocated += recv allocated += recv
pnl = round(recv - paid, 4) pnl = round_premium_amount(recv - paid, decimals=PREMIUM_DECIMALS_COIN) or 0.0
note_sql = "" note_sql = ""
params: list[Any] = [px, recv, pnl, oid or None] from lib.options.options_time_lib import options_app_now_str
# 占位顺序必须与 SQL 一致: close_quote/recv/pnl/ord/closed_at/[signal_note]/id
params: list[Any] = [px, recv, pnl, oid or None, options_app_now_str()]
if signal_note and i == len(open_rows) - 1: if signal_note and i == len(open_rows) - 1:
note_sql = """, note_sql = """,
signal_note = CASE signal_note = CASE
@@ -366,7 +379,7 @@ def close_option_by_bid1(
f""" f"""
UPDATE options_trades UPDATE options_trades
SET status = 'closed', close_quote = ?, premium_received = ?, SET status = 'closed', close_quote = ?, premium_received = ?,
realized_pnl = ?, close_ord_id = ?, closed_at = CURRENT_TIMESTAMP realized_pnl = ?, close_ord_id = ?, closed_at = ?
{note_sql} {note_sql}
WHERE id = ? WHERE id = ?
""", """,
+5 -2
View File
@@ -407,6 +407,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"
@@ -416,8 +418,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"],
@@ -437,6 +439,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()
+3 -3
View File
@@ -85,13 +85,13 @@ def build_options_hub_snapshot(cfg: dict[str, Any]) -> dict[str, Any]:
conn.close() conn.close()
except Exception: except Exception:
target_monitors = [] target_monitors = []
from lib.options.options_positions_lib import display_pnl_from_option_row from lib.options.options_positions_lib import display_pnl_usdt_from_option_row
upl_total = 0.0 upl_total = 0.0
has_upl = False has_upl = False
for p in positions: for p in positions:
# 与持仓卡展示一致:优先买一净盈亏,残档回退交易所 upl # 合计一律折 U:币本位 × 指数,避免 BTC/ETH 币数量与 USDC 混加
pnl = display_pnl_from_option_row(p) pnl = display_pnl_usdt_from_option_row(p)
if pnl is None: if pnl is None:
continue continue
has_upl = True has_upl = True
+8 -24
View File
@@ -1,16 +1,12 @@
"""期权持仓监控:浮盈翻倍微信提醒 + 平仓/到期状态同步.""" """期权持仓监控:浮盈翻倍微信提醒 + 平仓/到期状态同步."""
from __future__ import annotations from __future__ import annotations
import os
import sqlite3 import sqlite3
import time import time
from datetime import datetime, timezone
from typing import Any, Callable from typing import Any, Callable
from zoneinfo import ZoneInfo
from lib.exchange.okx_options_lib import normalize_option_exp_ms, resolve_option_close_from_history from lib.exchange.okx_options_lib import normalize_option_exp_ms, resolve_option_close_from_history
from lib.options.options_time_lib import ms_to_options_app_str, options_app_now_str, options_wall_clock_to_ms
_APP_TZ = ZoneInfo((os.getenv("APP_TIMEZONE") or os.getenv("TZ") or "Asia/Shanghai").strip() or "Asia/Shanghai")
def _safe_float(v: Any) -> float | None: def _safe_float(v: Any) -> float | None:
@@ -133,18 +129,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:
@@ -309,21 +294,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
@@ -332,7 +316,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 '到期结算'
+37 -5
View File
@@ -95,6 +95,8 @@ def forget_close_gate_for_inst(inst_id: str) -> None:
def net_pnl_from_display_row(row: dict[str, Any]) -> float | None: def net_pnl_from_display_row(row: dict[str, Any]) -> float | None:
"""与持仓卡「净盈亏」同口径:买一可回收 − 权利金;残档买一则无净值.""" """与持仓卡「净盈亏」同口径:买一可回收 − 权利金;残档买一则无净值."""
from lib.options.options_pricing_lib import PREMIUM_DECIMALS_COIN, round_premium_amount
preview = row.get("close_preview") if isinstance(row.get("close_preview"), dict) else {} preview = row.get("close_preview") if isinstance(row.get("close_preview"), dict) else {}
if preview.get("bid_invalid"): if preview.get("bid_invalid"):
return None return None
@@ -112,7 +114,9 @@ def net_pnl_from_display_row(row: dict[str, Any]) -> float | None:
recv = _safe_float(preview.get("total_received")) recv = _safe_float(preview.get("total_received"))
paid = _safe_float(row.get("premium_paid")) paid = _safe_float(row.get("premium_paid"))
if covered > 0 and recv is not None and paid is not None: if covered > 0 and recv is not None and paid is not None:
return round(recv - paid, 4) ccy = str(row.get("premium_ccy") or "").strip().upper()
decimals = PREMIUM_DECIMALS_COIN if ccy in ("ETH", "BTC") else None
return round_premium_amount(recv - paid, ccy=ccy or None, decimals=decimals)
return None return None
@@ -124,14 +128,42 @@ def display_pnl_from_option_row(row: dict[str, Any]) -> float | None:
return _safe_float(row.get("upl")) return _safe_float(row.get("upl"))
def display_pnl_usdt_from_option_row(row: dict[str, Any]) -> float | None:
"""顶栏/看板/中控「实时盈亏」用:币本位净盈亏按指数折 USDT;USDC 仓原样.
BTC/ETH 币本位若不折算,会被界面标成 U 而数值仍是币数量(BTC 尤为离谱).
"""
pnl = display_pnl_from_option_row(row)
if pnl is None:
return None
ccy = str(row.get("premium_ccy") or "USDC").strip().upper() or "USDC"
if not ccy or ccy == "USDC":
# 兜底:按合约推断币本位,避免 premium_ccy 缺失时把 BTC 盈亏当 U
inst = str(row.get("inst_id") or row.get("instId") or "")
mode = str(row.get("margin_mode") or "").strip().lower()
if mode == "coin" or (
"-USD-" in inst.upper() and "_UM" not in inst.upper()
):
underly = str(row.get("underlying") or (inst.split("-")[0] if inst else "") or "").upper()
if underly in ("ETH", "BTC"):
ccy = underly
if ccy in ("ETH", "BTC"):
idx = _safe_float(row.get("idx_px") or row.get("idxPx") or row.get("index_px"))
if idx is None or idx <= 0:
# 无指数时无法折算,跳过该仓以免把「币数量」当成 U
return None
return float(pnl) * float(idx)
return float(pnl)
def sum_options_net_pnl_usdc( def sum_options_net_pnl_usdc(
cfg: dict[str, Any], cfg: dict[str, Any],
ex: Any, ex: Any,
raw_positions: list[dict[str, Any]] | None = None, raw_positions: list[dict[str, Any]] | None = None,
) -> float | None: ) -> float | None:
""" """
期权浮盈合计(USDC),与顶栏实时盈亏/中控口径对齐为「净盈亏」: 期权浮盈合计(USDT/USDC 口径),与顶栏实时盈亏/中控对齐:
各仓买一可回收 − 权利金之和;残档则回退该仓交易所 upl. 各仓买一可回收 − 权利金;币本位再 × 指数折成 U;残档回退该仓 upl(同样折算).
获取失败返回 None;无持仓返回 0. 获取失败返回 None;无持仓返回 0.
""" """
raw = raw_positions raw = raw_positions
@@ -145,12 +177,12 @@ def sum_options_net_pnl_usdc(
total = 0.0 total = 0.0
found = False found = False
for p in positions: for p in positions:
pnl = display_pnl_from_option_row(p) pnl = display_pnl_usdt_from_option_row(p)
if pnl is None: if pnl is None:
continue continue
found = True found = True
total += float(pnl) total += float(pnl)
return round(total, 8) if found else (0.0 if not positions else None) return round(total, 4) if found else (0.0 if not positions else None)
def build_display_option_positions( def build_display_option_positions(
+24 -5
View File
@@ -4,6 +4,24 @@ from __future__ import annotations
import math import math
from typing import Any from typing import Any
# 币本位权利金常为 1e-4~1e-5 量级(尤其 BTC);round(...,4) 会把净盈亏抹成 0
PREMIUM_DECIMALS_COIN = 8
PREMIUM_DECIMALS_FIAT = 4
def round_premium_amount(v: float | None, *, ccy: str | None = None, decimals: int | None = None) -> float | None:
"""权利金/回收/币盈亏入账精度:ETH/BTC 用 8 位,USDC 等用法币 4 位."""
if v is None:
return None
try:
n = float(v)
except (TypeError, ValueError):
return None
if decimals is None:
unit = (ccy or "").strip().upper()
decimals = PREMIUM_DECIMALS_COIN if unit in ("ETH", "BTC") else PREMIUM_DECIMALS_FIAT
return round(n, int(decimals))
def ct_mult_from_meta(meta: dict[str, Any] | None) -> float: def ct_mult_from_meta(meta: dict[str, Any] | None) -> float:
if not meta: if not meta:
@@ -276,7 +294,7 @@ def estimate_close_by_bids(
"available_sheets": sz, "available_sheets": sz,
"sheets": take, "sheets": take,
"eth_amount": eth_amount, "eth_amount": eth_amount,
"received": round(received, 4), "received": round_premium_amount(received, decimals=PREMIUM_DECIMALS_COIN),
} }
) )
total_received += received total_received += received
@@ -288,15 +306,16 @@ def estimate_close_by_bids(
estimated_pnl_ratio_pct = None estimated_pnl_ratio_pct = None
if premium_paid is not None and covered > 0: if premium_paid is not None and covered > 0:
paid = float(premium_paid) paid = float(premium_paid)
estimated_pnl = round(total_received - paid, 4) # 必须用 8 位:BTC 权利金常约 1e-4,round(...,4) 会把盈亏抹成 0
if paid > 0: estimated_pnl = round_premium_amount(total_received - paid, decimals=PREMIUM_DECIMALS_COIN)
if paid > 0 and estimated_pnl is not None:
estimated_pnl_ratio_pct = round(estimated_pnl / paid * 100.0, 2) estimated_pnl_ratio_pct = round(estimated_pnl / paid * 100.0, 2)
return { return {
"levels": levels, "levels": levels,
"covered_sheets": covered, "covered_sheets": covered,
"uncovered_sheets": remaining, "uncovered_sheets": remaining,
"total_received": round(total_received, 4), "total_received": round_premium_amount(total_received, decimals=PREMIUM_DECIMALS_COIN),
"avg_px": round(avg_px, 4) if avg_px is not None else None, "avg_px": round(avg_px, 8) if avg_px is not None else None,
"estimated_pnl": estimated_pnl, "estimated_pnl": estimated_pnl,
"estimated_pnl_ratio_pct": estimated_pnl_ratio_pct, "estimated_pnl_ratio_pct": estimated_pnl_ratio_pct,
"bid_invalid": False, "bid_invalid": False,
+26 -2
View File
@@ -59,11 +59,13 @@ def profit_exit_hit(
def required_recycle_usdc(premium_paid: float, mult: float) -> float | None: def required_recycle_usdc(premium_paid: float, mult: float) -> float | None:
from lib.options.options_pricing_lib import PREMIUM_DECIMALS_COIN, round_premium_amount
prem = float(premium_paid or 0) prem = float(premium_paid or 0)
m = float(mult or 0) m = float(mult or 0)
if prem <= 0 or m <= 0: if prem <= 0 or m <= 0:
return None return None
return round(prem * (1.0 + m), 4) return round_premium_amount(prem * (1.0 + m), decimals=PREMIUM_DECIMALS_COIN)
def set_profit_exit( def set_profit_exit(
@@ -211,13 +213,34 @@ def _estimate_recycle(
pos: dict[str, Any], pos: dict[str, Any],
premium_paid: float | None, premium_paid: float | None,
) -> float | None: ) -> float | None:
"""翻倍出场用可回收估算.
用买一价 × 全部持仓张数(不吃深度上限):深度不够时仍应触发分批平仓;
残档/无效买一则返回 None(禁止按假买盘自动平).
"""
from lib.options.options_positions_lib import attach_close_preview from lib.options.options_positions_lib import attach_close_preview
from lib.options.options_pricing_lib import total_premium
row = dict(pos) row = dict(pos)
attach_close_preview(cfg, ex, row, premium_paid=premium_paid) attach_close_preview(cfg, ex, row, premium_paid=premium_paid)
preview = row.get("close_preview") if isinstance(row.get("close_preview"), dict) else {} preview = row.get("close_preview") if isinstance(row.get("close_preview"), dict) else {}
if preview.get("bid_invalid"): if preview.get("bid_invalid"):
return None return None
levels = preview.get("levels") or []
ct_mult = float(row.get("ct_mult") or 0.01)
sheets = int(
abs(
_safe_float(row.get("avail_pos") if row.get("avail_pos") is not None else row.get("pos"))
or 0
)
)
if levels and sheets > 0 and ct_mult > 0:
try:
px = float(levels[0].get("px") or 0)
except (TypeError, ValueError, AttributeError):
px = 0.0
if px > 0:
return round(total_premium(px, sheets * ct_mult), 8)
return _safe_float(preview.get("total_received")) return _safe_float(preview.get("total_received"))
@@ -303,7 +326,8 @@ def run_options_profit_exits(
init_hedge_plan_tables(conn) init_hedge_plan_tables(conn)
hedge_managed = active_hedge_option_inst_ids(conn) hedge_managed = active_hedge_option_inst_ids(conn)
except Exception: except Exception:
return 0 # 对冲表异常时不得整段跳过翻倍出场
hedge_managed = set()
rules = profit_exit_by_inst(conn) rules = profit_exit_by_inst(conn)
triggered = 0 triggered = 0
+18 -18
View File
@@ -1209,7 +1209,11 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
sizing = dict(sizing) sizing = dict(sizing)
sizing["sheets"] = sheets sizing["sheets"] = sheets
sizing["eth_amount"] = round(sheets * ct_mult, 8) sizing["eth_amount"] = round(sheets * ct_mult, 8)
sizing["total_premium"] = round(fill_px * sheets * ct_mult, 4) from lib.options.options_pricing_lib import PREMIUM_DECIMALS_COIN, round_premium_amount
sizing["total_premium"] = round_premium_amount(
fill_px * sheets * ct_mult, decimals=PREMIUM_DECIMALS_COIN
)
conn = cfg["get_db"]() conn = cfg["get_db"]()
trade_id = None trade_id = None
target_mon = None target_mon = None
@@ -1218,6 +1222,7 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
try: try:
init_options_tables(conn) init_options_tables(conn)
from lib.options.options_profit_exit_lib import ensure_profit_exit_columns from lib.options.options_profit_exit_lib import ensure_profit_exit_columns
from lib.options.options_time_lib import options_app_now_str
ensure_profit_exit_columns(conn) ensure_profit_exit_columns(conn)
meta = q.get("meta") or {} meta = q.get("meta") or {}
@@ -1230,8 +1235,8 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
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,
@@ -1248,6 +1253,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)
@@ -1348,6 +1354,8 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
conn = cfg["get_db"]() conn = cfg["get_db"]()
try: try:
init_options_tables(conn) init_options_tables(conn)
from lib.options.options_time_lib import options_app_now_str
conn.execute( conn.execute(
""" """
UPDATE options_trades UPDATE options_trades
@@ -1356,10 +1364,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:
@@ -1842,32 +1850,24 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
except Exception: except Exception:
index_px = None index_px = None
stats = compute_options_stats_from_history(history, index_px=index_px) stats = compute_options_stats_from_history(history, index_px=index_px)
# sum_options_net_pnl_usdc 已按指数折 U;勿再 × 指数(否则顶栏会变成上万假亏)
open_float = sum_options_net_pnl_usdc(cfg, ex, raw_live) open_float = sum_options_net_pnl_usdc(cfg, ex, raw_live)
# 币本位浮盈为币数量,折算为 U 再与已平合计
if open_float is not None and str(stats.get("pnl_unit") or "") == "U":
px = index_px
if px is None or px <= 0:
for h in history:
try:
px = float(h.get("idx_px") or 0)
except (TypeError, ValueError):
px = 0
if px > 0:
break
if px and px > 0:
open_float = round(float(open_float) * float(px), 4)
net_realized = _safe_float(stats.get("net_realized_pnl")) or 0.0 net_realized = _safe_float(stats.get("net_realized_pnl")) or 0.0
total_pnl = None total_pnl = None
if open_float is not None: if open_float is not None:
total_pnl = round(net_realized + float(open_float), 4) total_pnl = round(net_realized + float(open_float), 4)
elif stats.get("total_closed"): elif stats.get("total_closed"):
total_pnl = round(net_realized, 4) total_pnl = round(net_realized, 4)
from lib.options.options_margin_mode_lib import is_coin_margin_mode
pnl_unit = "U" if is_coin_margin_mode() else str(stats.get("pnl_unit") or "USDC")
return jsonify( return jsonify(
{ {
"ok": True, "ok": True,
**stats, **stats,
"open_float_pnl": open_float, "open_float_pnl": open_float,
"total_pnl": total_pnl, "total_pnl": total_pnl,
"pnl_unit": pnl_unit,
} }
) )
@@ -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()
+9
View File
@@ -715,6 +715,13 @@ def ensure_local_review_synced(
backfill_exchange_pnl: bool = True, backfill_exchange_pnl: bool = True,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""列表/统计前轻量刷新本地源;有交易所时先用历史仓位盈亏覆盖本地再导入复盘.""" """列表/统计前轻量刷新本地源;有交易所时先用历史仓位盈亏覆盖本地再导入复盘."""
from lib.options.options_time_lib import (
migrate_options_utc_wall_clock_to_app,
repair_swapped_closed_at_signal_note,
)
ts_migrate = migrate_options_utc_wall_clock_to_app(conn)
note_swap = repair_swapped_closed_at_signal_note(conn)
if backfill_exchange_pnl and ex is not None: if backfill_exchange_pnl and ex is not None:
try: try:
from lib.exchange.okx_options_lib import fetch_all_option_positions_history from lib.exchange.okx_options_lib import fetch_all_option_positions_history
@@ -735,6 +742,8 @@ def ensure_local_review_synced(
out["repaired_usdt"] = repair_coin_review_rows_to_usdt(conn, ex=ex) out["repaired_usdt"] = repair_coin_review_rows_to_usdt(conn, ex=ex)
except Exception: except Exception:
out["repaired_usdt"] = 0 out["repaired_usdt"] = 0
out["ts_migrate"] = ts_migrate
out["note_swap"] = note_swap
return out return out
+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()
+181
View File
@@ -0,0 +1,181 @@
"""期权业务时区墙钟:库内统一按 APP_TIMEZONE(默认 Asia/Shanghai)存储,不可当 UTC."""
from __future__ import annotations
import os
import sqlite3
from datetime import datetime, timezone
from typing import Any, Optional
from zoneinfo import ZoneInfo
_APP_TZ = ZoneInfo(
(os.getenv("APP_TIMEZONE") or os.getenv("TZ") or "Asia/Shanghai").strip()
or "Asia/Shanghai"
)
_MIGRATE_FLAG = "options_ts_utc_wall_to_app_done"
_SWAP_REPAIR_FLAG = "options_closed_at_note_swap_done"
def options_app_tz() -> ZoneInfo:
return _APP_TZ
def options_app_now_str() -> str:
return datetime.now(_APP_TZ).strftime("%Y-%m-%d %H:%M:%S")
def ms_to_options_app_str(ms: Any) -> Optional[str]:
"""交易所 ms(UTC 绝对时间) → 业务时区墙钟字符串."""
try:
val = float(ms)
except (TypeError, ValueError):
return None
if val <= 0:
return None
try:
dt = datetime.fromtimestamp(int(val) / 1000.0, tz=timezone.utc).astimezone(_APP_TZ)
return dt.strftime("%Y-%m-%d %H:%M:%S")
except (TypeError, ValueError, OSError, OverflowError):
return None
def options_wall_clock_to_ms(raw: Any) -> Optional[int]:
"""业务时区墙钟 → UTC ms(与合约复盘 _local_input_datetime_to_ms 同口径)."""
if raw is None or raw == "":
return None
s = str(raw).strip().replace("T", " ")
for fmt, ln in (("%Y-%m-%d %H:%M:%S", 19), ("%Y-%m-%d %H:%M", 16)):
try:
dt = datetime.strptime(s[:ln], fmt).replace(tzinfo=_APP_TZ)
return int(dt.timestamp() * 1000)
except ValueError:
continue
return None
def utc_wall_clock_to_app_str(raw: Any) -> Optional[str]:
"""把误存成 UTC 墙钟的字符串改写为业务时区墙钟(+8 等)."""
if raw is None or raw == "":
return None
s = str(raw).strip().replace("T", " ")
for fmt, ln in (("%Y-%m-%d %H:%M:%S", 19), ("%Y-%m-%d %H:%M", 16)):
try:
dt = datetime.strptime(s[:ln], fmt).replace(tzinfo=timezone.utc)
return dt.astimezone(_APP_TZ).strftime("%Y-%m-%d %H:%M:%S")
except ValueError:
continue
return None
def migrate_options_utc_wall_clock_to_app(conn: sqlite3.Connection) -> dict[str, int]:
"""一次性:历史 UTC 墙钟(SQLite CURRENT_TIMESTAMP / 旧 monitor)改为业务时区.
幂等:同步状态 options_ts_utc_wall_to_app_done=1 后跳过.
"""
from lib.options.options_review_db import init_options_review_tables
from lib.options.options_review_lib import get_sync_state, set_sync_state
init_options_review_tables(conn)
if get_sync_state(conn, _MIGRATE_FLAG) == "1":
return {"skipped": 1, "trades": 0, "review": 0, "hidden": 0}
def _shift_table(table: str, cols: tuple[str, ...]) -> int:
try:
rows = conn.execute(
f"SELECT rowid AS _rid, {', '.join(cols)} FROM {table}"
).fetchall()
except sqlite3.Error:
return 0
n = 0
for row in rows:
sets: list[str] = []
vals: list[Any] = []
for col in cols:
old = row[col]
new = utc_wall_clock_to_app_str(old)
if new and str(old or "").strip() != new:
sets.append(f"{col}=?")
vals.append(new)
if not sets:
continue
vals.append(int(row["_rid"]))
conn.execute(
f"UPDATE {table} SET {', '.join(sets)} WHERE rowid=?",
vals,
)
n += 1
return n
trades = 0
try:
from lib.options.options_db import init_options_tables
init_options_tables(conn)
trades = _shift_table("options_trades", ("created_at", "closed_at"))
except Exception:
trades = 0
review = _shift_table("options_review_trades", ("opened_at", "closed_at"))
hidden = _shift_table("options_review_hidden", ("closed_at",))
set_sync_state(conn, _MIGRATE_FLAG, "1")
return {"skipped": 0, "trades": trades, "review": review, "hidden": hidden}
def repair_swapped_closed_at_signal_note(conn: sqlite3.Connection) -> dict[str, int]:
"""修复 close_option_by_bid1 曾把 signal_note 写入 closed_at 的坏行.
典型表现: closed_at='翻倍出场'/'目标位平仓', signal_note 反而是时间戳.
幂等:同步状态 options_closed_at_note_swap_done=1 后仍扫描可修行(新坏行可再修).
"""
from lib.options.options_review_db import init_options_review_tables
from lib.options.options_review_lib import set_sync_state
init_options_review_tables(conn)
trades_fixed = 0
review_fixed = 0
try:
from lib.options.options_db import init_options_tables
init_options_tables(conn)
rows = conn.execute(
"""
SELECT id, closed_at, signal_note
FROM options_trades
WHERE status = 'closed'
AND closed_at IS NOT NULL
AND TRIM(closed_at) != ''
"""
).fetchall()
for row in rows:
closed = str(row["closed_at"] or "").strip()
note = str(row["signal_note"] or "").strip()
if options_wall_clock_to_ms(closed) is not None:
continue
if options_wall_clock_to_ms(note) is None:
continue
conn.execute(
"""
UPDATE options_trades
SET closed_at = ?, signal_note = ?
WHERE id = ?
""",
(note, closed, int(row["id"])),
)
trades_fixed += 1
hk = f"local_opt:{int(row['id'])}"
cur = conn.execute(
"""
UPDATE options_review_trades
SET closed_at = ?
WHERE history_key = ?
AND (closed_at IS NULL OR TRIM(closed_at) = '' OR closed_at = ?)
""",
(note, hk, closed),
)
review_fixed += int(cur.rowcount or 0)
except Exception:
pass
set_sync_state(conn, _SWAP_REPAIR_FLAG, "1")
return {"trades": trades_fixed, "review": review_fixed}
@@ -327,6 +327,37 @@
</div> </div>
<p class="sub journal-upload-hint">可只传部分周期;选文件后即时上传</p> <p class="sub journal-upload-hint">可只传部分周期;选文件后即时上传</p>
<div class="form-row or-chart-options journal-chart-options hidden" id="or-chart-options" style="flex-wrap:wrap;align-items:center">
<label style="display:flex;align-items:center;gap:6px;font-size:.82rem">
<input type="checkbox" id="or-exchange-chart" value="true">
保存后后台生成 K 线图并作为截图(不阻塞保存)
</label>
<label style="font-size:.82rem;color:#9aa">周期1</label>
<select id="or-chart-tf1" style="min-width:72px">
{% for tf in journal_chart_tf_choices %}
<option value="{{ tf }}" {% if tf == journal_chart_default_tf1 %}selected{% endif %}>{{ tf }}</option>
{% endfor %}
</select>
<label style="font-size:.82rem;color:#9aa">周期2</label>
<select id="or-chart-tf2" style="min-width:72px">
{% for tf in journal_chart_tf_choices %}
<option value="{{ tf }}" {% if tf == journal_chart_default_tf2 %}selected{% endif %}>{{ tf }}</option>
{% endfor %}
</select>
<label style="font-size:.82rem;color:#9aa">K线数</label>
<select id="or-chart-limit" style="min-width:72px">
{% for n in [100, 150, 200, 250, 300, 400, 500] %}
<option value="{{ n }}" {% if n == journal_chart_default_limit %}selected{% endif %}>{{ n }}</option>
{% endfor %}
</select>
<label style="font-size:.82rem;color:#9aa">K线截止</label>
<select id="or-chart-anchor" style="min-width:96px" title="K线窗口右端对齐的时间">
<option value="close" {% if journal_chart_default_anchor == 'close' %}selected{% endif %}>平仓时间</option>
<option value="now" {% if journal_chart_default_anchor == 'now' %}selected{% endif %}>当前时间</option>
</select>
</div>
<div class="sub or-chart-hint hidden" id="or-chart-hint" style="font-size:.72rem;margin-top:2px;margin-bottom:0">纯期权:按标的永续画双周期上下图;只按时间标记进场/出场,不标止损;有手动截图时不自动生成</div>
<div class="or-mood-grid mood-grid"> <div class="or-mood-grid mood-grid">
<label><input type="checkbox" class="or-mood" value="怕踏空">怕踏空</label> <label><input type="checkbox" class="or-mood" value="怕踏空">怕踏空</label>
<label><input type="checkbox" class="or-mood" value="报复开仓">报复开仓</label> <label><input type="checkbox" class="or-mood" value="报复开仓">报复开仓</label>
+29 -11
View File
@@ -817,7 +817,19 @@
return `${sign}${fmt(n, 2)}U`; return `${sign}${fmt(n, 2)}U`;
} }
/** 中控期权浮盈汇总框:只显示换算后的 U(持仓表净盈亏仍用 fmtOptPnlText 双显). */ /** 已是 USDT 的期权浮盈合计(upl_total_usdc),禁止再按币×指数. */
function fmtOptFloatUsdt(v) {
if (v === null || v === undefined || Number.isNaN(Number(v))) return "—";
const n = Number(v);
const uAbs = Math.abs(n).toFixed(2);
const uSign = n < 0 ? "-" : n > 0 ? "+" : "";
return `${uSign}${uAbs}U`;
}
/**
* 币数量 → U(仅逐仓净盈亏双显/兜底合计用).
* 汇总框勿用:后端 upl_total 已折 U,再乘会变成上万假亏.
*/
function fmtOptPnlUsdtOnly(v, ccy, spotPx) { function fmtOptPnlUsdtOnly(v, ccy, spotPx) {
if (v === null || v === undefined || Number.isNaN(Number(v))) return "—"; if (v === null || v === undefined || Number.isNaN(Number(v))) return "—";
const n = Number(v); const n = Number(v);
@@ -3939,6 +3951,7 @@
const bal = const bal =
opt.balances && typeof opt.balances === "object" ? opt.balances : {}; opt.balances && typeof opt.balances === "object" ? opt.balances : {};
const pick = (a, b) => (a != null && a !== "" ? a : b); const pick = (a, b) => (a != null && a !== "" ? a : b);
// upl_total_usdc 实际为已折 USDT 的合计(币本位 × 指数后)
let upl = let upl =
opt.upl_total_usdc != null && Number.isFinite(Number(opt.upl_total_usdc)) opt.upl_total_usdc != null && Number.isFinite(Number(opt.upl_total_usdc))
? Number(opt.upl_total_usdc) ? Number(opt.upl_total_usdc)
@@ -3949,10 +3962,16 @@
let found = false; let found = false;
pos.forEach((p) => { pos.forEach((p) => {
const n = optionsRowDisplayPnl(p); const n = optionsRowDisplayPnl(p);
if (n != null) { if (n == null || !Number.isFinite(Number(n))) return;
sum += n; const ccy = String(optPremiumCcyOf(p) || "").toUpperCase();
found = true; let uu = Number(n);
if (ccy === "ETH" || ccy === "BTC") {
const px = spotPxFromOptMeta(opt, p);
if (!Number.isFinite(px) || !(px > 0)) return;
uu = Number(n) * px;
} }
sum += uu;
found = true;
}); });
if (found) upl = sum; if (found) upl = sum;
} }
@@ -3987,11 +4006,10 @@
? `${fmt(fundUsdt, 2)} <small style="font-size:12px;color:var(--muted)">U</small>` ? `${fmt(fundUsdt, 2)} <small style="font-size:12px;color:var(--muted)">U</small>`
: "—"; : "—";
const tradeTxt = formatCoinTradingLabel(usdt, eth, btc); const tradeTxt = formatCoinTradingLabel(usdt, eth, btc);
const pnlCcy = optionsPanelCcy(optMeta); // 汇总 upnl 已是 USDT,勿再按 ETH/BTC×指数(否则 -6U 会变成 -1.7万U)
const spotPx = spotPxFromOptMeta(optMeta);
const pnlTxt = upnl == null || Number.isNaN(Number(upnl)) const pnlTxt = upnl == null || Number.isNaN(Number(upnl))
? "—" ? "—"
: `<span class="${pnlCls(upnl)}">${fmtOptPnlUsdtOnly(upnl, pnlCcy, spotPx)}</span>`; : `<span class="${pnlCls(upnl)}">${fmtOptFloatUsdt(upnl)}</span>`;
return `<div class="${rowCls}"> return `<div class="${rowCls}">
<div class="stat-box"><div class="stat-label">资金账户</div><div class="stat-value">${fundTxt}</div></div> <div class="stat-box"><div class="stat-label">资金账户</div><div class="stat-value">${fundTxt}</div></div>
<div class="stat-box"><div class="stat-label">交易账户</div><div class="stat-value">${tradeTxt}</div></div> <div class="stat-box"><div class="stat-label">交易账户</div><div class="stat-value">${tradeTxt}</div></div>
@@ -4671,14 +4689,14 @@
parts.push(`交易 ${fmt(bal.trading, 2)}U`); parts.push(`交易 ${fmt(bal.trading, 2)}U`);
} }
if (optUpl != null && Number.isFinite(Number(optUpl))) { if (optUpl != null && Number.isFinite(Number(optUpl))) {
parts.push(`浮盈 ${fmtOptPnlUsdtOnly(optUpl, optCcy, spotPxFromOptMeta(opt))}`); parts.push(`浮盈 ${fmtOptFloatUsdt(optUpl)}`);
} }
if (optUpl != null && Number.isFinite(Number(optUpl)) && openCount === 0) { if (optUpl != null && Number.isFinite(Number(optUpl)) && openCount === 0) {
// 永续空仓时主数字优先展示期权浮盈(只显示 U) // 永续空仓时主数字优先展示期权浮盈(已是 U,勿再按币折算)
pnlShow = optUpl; pnlShow = optUpl;
pnlSuffix = "期权"; pnlSuffix = "期权";
pnlUnit = optCcy; pnlUnit = "USDC";
pnlSpotPx = spotPxFromOptMeta(opt); pnlSpotPx = null;
} }
} }
optLine = parts.join(" · "); optLine = parts.join(" · ");
+40
View File
@@ -79,12 +79,52 @@ class HubOptionsFundsLibTests(TestCase):
"premium_ccy": "ETH", "premium_ccy": "ETH",
"upl": 0.0016, "upl": 0.0016,
"idx_px": 2280.0, "idx_px": 2280.0,
"close_preview": {"bid_invalid": True},
} }
], ],
} }
out = options_float_pnl_usdt(snap) out = options_float_pnl_usdt(snap)
self.assertAlmostEqual(out, round(0.0016 * 2280.0, 4), places=4) self.assertAlmostEqual(out, round(0.0016 * 2280.0, 4), places=4)
def test_options_float_pnl_usdt_btc_coin_times_btc_index(self):
from lib.hub.hub_options_funds_lib import options_float_pnl_usdt
snap = {
"ok": True,
"enabled": True,
"options_margin_mode": "coin",
# 默认指数若误用 ETH 也不应覆盖逐仓 BTC idx
"options_index_px": 3000.0,
"upl_total_usdc": -100.0,
"positions": [
{
"inst_id": "BTC-USD-260327-100000-P",
"margin_mode": "coin",
"premium_ccy": "BTC",
"upl": -0.001,
"idx_px": 100000.0,
"close_preview": {"bid_invalid": True},
}
],
}
out = options_float_pnl_usdt(snap)
self.assertAlmostEqual(out, -100.0, places=4)
def test_options_float_pnl_usdt_fallback_already_usdt_no_double_fx(self):
"""实例 hub 合计已折 U 时,回退不可再 × 指数."""
from lib.hub.hub_options_funds_lib import options_float_pnl_usdt
snap = {
"ok": True,
"enabled": True,
"options_margin_mode": "coin",
"options_index_px": 100000.0,
"upl_total_usdc": -100.0,
"positions": [],
}
out = options_float_pnl_usdt(snap)
self.assertAlmostEqual(out, -100.0, places=4)
def test_repair_double_counted_fund_entry(self): def test_repair_double_counted_fund_entry(self):
raw = { raw = {
"funding_usdt": 586.82, "funding_usdt": 586.82,
+44
View File
@@ -0,0 +1,44 @@
"""币本位期权浮盈折 U(BTC/ETH 不得把币数量直接当 U)."""
from __future__ import annotations
import unittest
from lib.options.options_positions_lib import display_pnl_usdt_from_option_row
class DisplayPnlUsdtTests(unittest.TestCase):
def test_btc_coin_pnl_times_index(self):
row = {
"inst_id": "BTC-USD-260327-100000-P",
"premium_ccy": "BTC",
"margin_mode": "coin",
"idx_px": 100000.0,
"upl": -0.001,
"close_preview": {"bid_invalid": True},
}
self.assertAlmostEqual(display_pnl_usdt_from_option_row(row), -100.0, places=4)
def test_btc_infers_ccy_when_premium_ccy_missing(self):
row = {
"inst_id": "BTC-USD-260327-100000-C",
"margin_mode": "coin",
"underlying": "BTC",
"idx_px": 95000.0,
"upl": 0.002,
"close_preview": {"bid_invalid": True},
}
self.assertAlmostEqual(display_pnl_usdt_from_option_row(row), 190.0, places=4)
def test_usdc_unchanged(self):
row = {
"inst_id": "BTC-USD_UM-260327-100000-C",
"premium_ccy": "USDC",
"margin_mode": "usdc",
"upl": -12.5,
"close_preview": {"bid_invalid": True},
}
self.assertAlmostEqual(display_pnl_usdt_from_option_row(row), -12.5, places=4)
if __name__ == "__main__":
unittest.main()
+39
View File
@@ -0,0 +1,39 @@
"""翻倍出场:买一价×全仓张数判定达标(不受买一深度截断)."""
from __future__ import annotations
import unittest
from unittest.mock import patch
from lib.options.options_profit_exit_lib import _estimate_recycle, profit_exit_hit
class ProfitExitHitTests(unittest.TestCase):
def test_hit_at_0_3_mult(self):
self.assertTrue(profit_exit_hit(premium_paid=1.0, recycle_usdc=1.3, mult=0.3))
self.assertFalse(profit_exit_hit(premium_paid=1.0, recycle_usdc=1.29, mult=0.3))
def test_estimate_uses_full_sheets_not_depth(self):
pos = {"inst_id": "ETH-USD-260927-2700-P", "pos": 100, "ct_mult": 0.01}
preview = {
"bid_invalid": False,
"levels": [{"px": 0.2, "sheets": 10}],
"total_received": 0.02,
}
def fake_attach(cfg, ex, row, premium_paid=None):
row["close_preview"] = preview
row["ct_mult"] = 0.01
row["pos"] = 100
return row
with patch(
"lib.options.options_positions_lib.attach_close_preview",
side_effect=fake_attach,
):
recv = _estimate_recycle({}, object(), pos, 1.0)
self.assertAlmostEqual(float(recv), 0.2, places=6)
self.assertTrue(profit_exit_hit(premium_paid=0.15, recycle_usdc=float(recv), mult=0.3))
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,157 @@
"""期权复盘自动 K 线:单元测试."""
from __future__ import annotations
import sqlite3
import unittest
from unittest.mock import MagicMock, patch
from lib.options.options_review_chart_async_lib import (
OPTIONS_REVIEW_AUTO_CHART_TF,
build_options_review_chart_filename,
merge_auto_chart_into_images,
schedule_options_review_exchange_chart,
underlying_to_okx_perp_symbol,
)
from lib.options.options_review_db import init_options_review_tables
from lib.options.options_review_lib import save_review_entry, upsert_option_history_row
def _conn() -> sqlite3.Connection:
c = sqlite3.connect(":memory:")
c.row_factory = sqlite3.Row
init_options_review_tables(c)
return c
class OptionsReviewChartAsyncTests(unittest.TestCase):
def test_underlying_to_okx_perp_symbol(self):
self.assertEqual(
underlying_to_okx_perp_symbol(
"ETH-USD",
normalize_symbol_input_fn=lambda s: f"{s}/USDT",
normalize_okx_symbol_fn=lambda s: f"{s.split('/')[0]}/USDT:USDT"
if "/" in s
else s,
),
"ETH/USDT:USDT",
)
def test_merge_auto_chart_keeps_manual_slots(self):
merged = merge_auto_chart_into_images(
[
{"tf": "auto", "file": "old.png"},
{"tf": "15m", "file": "m15.png"},
],
"new_auto.png",
)
self.assertEqual(merged[0]["tf"], OPTIONS_REVIEW_AUTO_CHART_TF)
self.assertEqual(merged[0]["file"], "new_auto.png")
self.assertEqual(merged[1], {"tf": "15m", "file": "m15.png"})
def test_chart_filename(self):
self.assertEqual(
build_options_review_chart_filename(12), "options_journal_chart_12.png"
)
@patch("lib.options.options_review_chart_async_lib.threading.Thread")
def test_schedule_starts_daemon_thread(self, mock_thread_cls):
thread = MagicMock()
mock_thread_cls.return_value = thread
schedule_options_review_exchange_chart(
trade_id=7,
exchange_symbol="ETH/USDT:USDT",
title_prefix="t",
journal_tfs=["15m", "1h"],
journal_limit=100,
marker_payload={},
upload_folder="/tmp",
generate_chart_fn=MagicMock(),
get_db_fn=MagicMock(),
now_str_fn=lambda: "2026-09-15 12:00:00",
)
mock_thread_cls.assert_called_once()
self.assertTrue(mock_thread_cls.call_args.kwargs.get("daemon"))
thread.start.assert_called_once()
def test_writeback_merges_auto_image(self):
import tempfile
from pathlib import Path
from lib.options.options_review_images_lib import parse_options_review_images_json
with tempfile.TemporaryDirectory() as td:
db_path = Path(td) / "t.db"
def get_db():
c = sqlite3.connect(str(db_path))
c.row_factory = sqlite3.Row
return c
conn = get_db()
init_options_review_tables(conn)
upsert_option_history_row(
conn,
{
"history_key": "ex:chart1",
"inst_id": "ETH-USD-260328-2000-C",
"underlying": "ETH",
"opt_type": "C",
"realized_pnl": 1.0,
"created_at": "2026-09-01 10:00:00",
"closed_at": "2026-09-01 12:00:00",
},
)
trade_id = conn.execute(
"SELECT id FROM options_review_trades WHERE history_key='ex:chart1'"
).fetchone()["id"]
save_review_entry(
conn,
trade_id,
{
"strategy_tag": "测试",
"images": [{"tf": "15m", "file": "options_journal_x_15m.png"}],
},
)
conn.commit()
conn.close()
chart_name = build_options_review_chart_filename(trade_id)
generate = MagicMock(return_value=chart_name)
with patch(
"lib.options.options_review_chart_async_lib.threading.Thread"
) as mock_thread_cls:
thread = MagicMock()
mock_thread_cls.return_value = thread
schedule_options_review_exchange_chart(
trade_id=trade_id,
exchange_symbol="ETH/USDT:USDT",
title_prefix="ETH review",
journal_tfs=["15m", "1h"],
journal_limit=200,
marker_payload={"entry_ts_ms": 1, "exit_ts_ms": 2},
upload_folder=td,
generate_chart_fn=generate,
get_db_fn=get_db,
now_str_fn=lambda: "2026-09-15 12:00:00",
)
target = mock_thread_cls.call_args.kwargs["target"]
target()
conn = get_db()
try:
row = conn.execute(
"SELECT images_json, image FROM options_review_entries WHERE trade_id=?",
(trade_id,),
).fetchone()
self.assertEqual(row["image"], chart_name)
images = parse_options_review_images_json(row["images_json"])
self.assertEqual(images[0]["tf"], "auto")
self.assertEqual(images[0]["file"], chart_name)
self.assertTrue(any(x.get("tf") == "15m" for x in images))
finally:
conn.close()
if __name__ == "__main__":
unittest.main()
+92
View File
@@ -0,0 +1,92 @@
"""期权业务时区与 UTC 墙钟迁移单测."""
from __future__ import annotations
import sqlite3
import unittest
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
from lib.options.options_db import init_options_tables
from lib.options.options_review_db import init_options_review_tables
from lib.options.options_review_lib import get_sync_state, upsert_option_history_row
from lib.options.options_time_lib import (
migrate_options_utc_wall_clock_to_app,
ms_to_options_app_str,
options_wall_clock_to_ms,
utc_wall_clock_to_app_str,
)
class OptionsTimeLibTests(unittest.TestCase):
def test_ms_to_shanghai_wall_clock(self):
# 2026-09-13 21:41:57 UTC → 2026-09-14 05:41:57 Asia/Shanghai
ms = int(
datetime(2026, 9, 13, 21, 41, 57, tzinfo=timezone.utc).timestamp() * 1000
)
self.assertEqual(ms_to_options_app_str(ms), "2026-09-14 05:41:57")
def test_utc_wall_to_app(self):
self.assertEqual(
utc_wall_clock_to_app_str("2026-09-13 21:41:57"),
"2026-09-14 05:41:57",
)
def test_wall_clock_to_ms_roundtrip(self):
raw = "2026-09-14 05:41:57"
ms = options_wall_clock_to_ms(raw)
self.assertIsNotNone(ms)
dt = datetime.fromtimestamp(ms / 1000.0, tz=ZoneInfo("Asia/Shanghai"))
self.assertEqual(dt.strftime("%Y-%m-%d %H:%M:%S"), raw)
def test_migrate_once(self):
conn = sqlite3.connect(":memory:")
conn.row_factory = sqlite3.Row
init_options_tables(conn)
init_options_review_tables(conn)
conn.execute(
"""
INSERT INTO options_trades(
inst_id, underlying, opt_type, sheets, eth_amount, premium_paid, status,
created_at, closed_at
) VALUES ('ETH-USD-260917-2525-P', 'ETH', 'P', 1, 0.01, 1, 'closed',
'2026-09-13 21:41:57', '2026-09-14 02:31:00')
"""
)
upsert_option_history_row(
conn,
{
"history_key": "local_opt:1",
"inst_id": "ETH-USD-260917-2525-P",
"underlying": "ETH",
"opt_type": "P",
"realized_pnl": 0.5,
"created_at": "2026-09-13 21:41:57",
"closed_at": "2026-09-14 02:31:00",
},
)
conn.commit()
first = migrate_options_utc_wall_clock_to_app(conn)
self.assertEqual(first["skipped"], 0)
self.assertGreaterEqual(first["trades"], 1)
self.assertGreaterEqual(first["review"], 1)
row = conn.execute(
"SELECT created_at, closed_at FROM options_trades WHERE id=1"
).fetchone()
self.assertEqual(row["created_at"], "2026-09-14 05:41:57")
self.assertEqual(row["closed_at"], "2026-09-14 10:31:00")
rev = conn.execute(
"SELECT opened_at, closed_at FROM options_review_trades WHERE history_key='local_opt:1'"
).fetchone()
self.assertEqual(rev["opened_at"], "2026-09-14 05:41:57")
self.assertEqual(rev["closed_at"], "2026-09-14 10:31:00")
self.assertEqual(get_sync_state(conn, "options_ts_utc_wall_to_app_done"), "1")
second = migrate_options_utc_wall_clock_to_app(conn)
self.assertEqual(second["skipped"], 1)
if __name__ == "__main__":
unittest.main()