Compare commits

...

6 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
17 changed files with 384 additions and 153 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>"
+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));
+19 -9
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
@@ -361,9 +374,6 @@ 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"""
+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
+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
+10 -14
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
@@ -1846,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,
} }
) )
+6 -1
View File
@@ -715,9 +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 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) 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
@@ -739,6 +743,7 @@ def ensure_local_review_synced(
except Exception: except Exception:
out["repaired_usdt"] = 0 out["repaired_usdt"] = 0
out["ts_migrate"] = ts_migrate out["ts_migrate"] = ts_migrate
out["note_swap"] = note_swap
return out return out
+60
View File
@@ -13,6 +13,7 @@ _APP_TZ = ZoneInfo(
or "Asia/Shanghai" or "Asia/Shanghai"
) )
_MIGRATE_FLAG = "options_ts_utc_wall_to_app_done" _MIGRATE_FLAG = "options_ts_utc_wall_to_app_done"
_SWAP_REPAIR_FLAG = "options_closed_at_note_swap_done"
def options_app_tz() -> ZoneInfo: def options_app_tz() -> ZoneInfo:
@@ -119,3 +120,62 @@ def migrate_options_utc_wall_clock_to_app(conn: sqlite3.Connection) -> dict[str,
set_sync_state(conn, _MIGRATE_FLAG, "1") set_sync_state(conn, _MIGRATE_FLAG, "1")
return {"skipped": 0, "trades": trades, "review": review, "hidden": hidden} 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}
+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()