币本位盈亏双显ETH/U(按指数换算);平仓卖币改为卖光交易户可用余额

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-08-20 17:45:32 +08:00
parent d4d2110412
commit f5c553844f
9 changed files with 235 additions and 48 deletions
+36 -2
View File
@@ -897,6 +897,31 @@
return fmtUsdc(n);
}
/** 币本位盈亏双显:0.0018 ETH / 4.09U(按指数/现货价换算). */
function spotPxOf(p) {
const n = Number(p && (p.idx_px != null ? p.idx_px : p.idxPx != null ? p.idxPx : p.index_px));
return Number.isFinite(n) && n > 0 ? n : null;
}
function fmtCoinUsdtDual(coinAmt, spotPx, ccy, signed) {
if (coinAmt === null || coinAmt === undefined || Number.isNaN(Number(coinAmt))) return "—";
const n = Number(coinAmt);
const unit = String(ccy || "ETH").toUpperCase();
if (unit !== "ETH" && unit !== "BTC") {
const sign = signed && n > 0 ? "+" : "";
return sign + fmtUsdc(n) + "U";
}
const absCoin = Math.abs(n).toFixed(8).replace(/\.?0+$/, "") || "0";
const coinSign = n < 0 ? "-" : signed && n > 0 ? "+" : "";
const coinTxt = coinSign + absCoin + " " + unit;
const px = Number(spotPx);
if (!Number.isFinite(px) || !(px > 0)) return coinTxt;
const u = n * px;
const absU = Math.abs(u).toFixed(2);
const uSign = u < 0 ? "-" : signed && u > 0 ? "+" : "";
return coinTxt + " / " + uSign + absU + "U";
}
function fmtPremiumAmtSigned(v, ccy) {
if (v === null || v === undefined || Number.isNaN(Number(v))) return "—";
const n = Number(v);
@@ -904,6 +929,15 @@
return sign + fmtPremiumAmt(n, ccy) + " " + (String(ccy || "USDC").toUpperCase());
}
function fmtNetPnlDual(net, p) {
const ccy = posPremiumCcy(p);
if (ccy === "USDC") {
if (net == null || Number.isNaN(Number(net))) return "—";
return fmtUsdc(Number(net)) + "U";
}
return fmtCoinUsdtDual(net, spotPxOf(p), ccy, true);
}
function fmtClosePreview(preview, premiumPaid, p) {
if (!preview || preview.total_received == null) return "—";
const ccy = posPremiumCcy(p);
@@ -1614,7 +1648,7 @@
const markTxt = p.mark_px != null ? fmtOptionPx(p.mark_px, tickSz) : fmtDisplay(p.mark_px_fmt);
const netTxt = closePreview.bid_invalid || net == null
? "—"
: (fmtPremiumAmt(net, premCcy) + (coinPos ? (" " + premCcy) : ""));
: fmtNetPnlDual(net, p);
return (
'<div class="pos-card-head">' +
'<div class="pos-card-symbol"><strong>' + (p.inst_id || "") + '</strong>' +
@@ -1873,7 +1907,7 @@
? '<span class="opt-pos-bar-cd">到期 <span class="opt-expiry-cd" data-opt-exp-ms="' + expAttr + '">—</span></span>'
: "") +
'<span class="opt-pos-bar-pnl ' + uplCls + '">' +
(net == null ? "—" : (fmtPremiumAmt(net, posPremiumCcy(p)) + " " + posPremiumCcy(p))) + "</span>" +
(net == null ? "—" : fmtNetPnlDual(net, p)) + "</span>" +
'<span class="opt-pos-bar-roi ' + uplCls + '">' +
(roi == null ? "—" : fmt(roi, 2) + "%") + "</span>" +
"</span>" +
+34 -1
View File
@@ -56,6 +56,39 @@
return fmtUsdc(n);
}
function spotPxOf(p) {
const n = Number(p && (p.idx_px != null ? p.idx_px : p.idxPx != null ? p.idxPx : p.index_px));
return Number.isFinite(n) && n > 0 ? n : null;
}
function fmtCoinUsdtDual(coinAmt, spotPx, ccy, signed) {
if (coinAmt === null || coinAmt === undefined || Number.isNaN(Number(coinAmt))) return "—";
const n = Number(coinAmt);
const unit = String(ccy || "ETH").toUpperCase();
if (unit !== "ETH" && unit !== "BTC") {
const sign = signed && n > 0 ? "+" : "";
return sign + fmtUsdc(n) + "U";
}
const absCoin = Math.abs(n).toFixed(8).replace(/\.?0+$/, "") || "0";
const coinSign = n < 0 ? "-" : signed && n > 0 ? "+" : "";
const coinTxt = coinSign + absCoin + " " + unit;
const px = Number(spotPx);
if (!Number.isFinite(px) || !(px > 0)) return coinTxt;
const u = n * px;
const absU = Math.abs(u).toFixed(2);
const uSign = u < 0 ? "-" : signed && u > 0 ? "+" : "";
return coinTxt + " / " + uSign + absU + "U";
}
function fmtNetPnlDual(net, p) {
const ccy = posPremiumCcy(p);
if (ccy === "USDC") {
if (net == null || Number.isNaN(Number(net))) return "—";
return fmtUsdc(Number(net)) + "U";
}
return fmtCoinUsdtDual(net, spotPxOf(p), ccy, true);
}
function optTypeLabel(t) {
return (t || "").toUpperCase() === "P" ? "看跌 Put" : "看涨 Call";
}
@@ -209,7 +242,7 @@
const pnlCells = hidePnl
? ""
: '<div class="pos-cell"><span class="pos-label">净盈亏</span><span class="pos-value ' + uplCls + '">' +
(net == null ? "—" : (fmtPremiumAmt(net, premCcy) + (premCcy !== "USDC" ? (" " + premCcy) : ""))) + "</span></div>" +
(net == null ? "—" : fmtNetPnlDual(net, p)) + "</span></div>" +
'<div class="pos-cell"><span class="pos-label">收益率</span><span class="pos-value ' + uplCls + '">' +
(roi == null ? "—" : fmt(roi, 2) + "%") + "</span></div>";
return (
+29 -7
View File
@@ -1075,7 +1075,7 @@ function refreshOrderDefaults(){
}).catch(()=>{});
}
function paintRealtimePnl(v, unit){
function paintRealtimePnl(v, unit, spotPx){
const nodes = document.querySelectorAll('[data-funds-field="realtime-pnl"]');
if(!nodes.length) return;
if(v === null || v === undefined || Number.isNaN(Number(v))){
@@ -1088,11 +1088,23 @@ function paintRealtimePnl(v, unit){
const n = Number(v);
const u = String(unit || lastRealtimePnlUnit || "U").toUpperCase() || "U";
lastRealtimePnlUnit = u;
if (spotPx != null && Number.isFinite(Number(spotPx)) && Number(spotPx) > 0) {
lastRealtimePnlSpotPx = Number(spotPx);
}
const sign = n > 0 ? "+" : "";
let text;
if (u === "ETH" || u === "BTC") {
const abs = Math.abs(n).toFixed(8).replace(/\.?0+$/, "") || "0";
text = `${n < 0 ? "-" : sign}${abs}${u}`;
const coinTxt = `${n < 0 ? "-" : sign}${abs} ${u}`;
const px = Number(spotPx != null ? spotPx : lastRealtimePnlSpotPx);
if (Number.isFinite(px) && px > 0) {
const uu = n * px;
const uAbs = Math.abs(uu).toFixed(2);
const uSign = uu < 0 ? "-" : uu > 0 ? "+" : "";
text = `${coinTxt} / ${uSign}${uAbs}U`;
} else {
text = coinTxt;
}
} else {
text = `${sign}${n.toFixed(2)}U`;
}
@@ -1104,15 +1116,16 @@ function paintRealtimePnl(v, unit){
}
let lastRealtimePnl = null;
let lastRealtimePnlUnit = "U";
function updateRealtimePnl(v, unit){
let lastRealtimePnlSpotPx = null;
function updateRealtimePnl(v, unit, spotPx){
if(v != null && !Number.isNaN(Number(v))){
lastRealtimePnl = Number(v);
if (unit) lastRealtimePnlUnit = String(unit).toUpperCase();
paintRealtimePnl(v, lastRealtimePnlUnit);
paintRealtimePnl(v, lastRealtimePnlUnit, spotPx);
return;
}
if(lastRealtimePnl != null) return;
paintRealtimePnl(v, lastRealtimePnlUnit);
paintRealtimePnl(v, lastRealtimePnlUnit, spotPx);
}
function sumOrdersFloatPnl(orders){
if(!orders || !orders.length) return null;
@@ -1143,14 +1156,23 @@ function paintRealtimePnlFromSnapshot(data){
const opt = data.options_unrealized_pnl;
const coinMode = String(data.options_margin_mode || "").toLowerCase() === "coin";
const underly = String(data.options_underly || "ETH").toUpperCase() || "ETH";
const spotPx = data.options_index_px != null ? Number(data.options_index_px) : null;
if (coinMode && opt != null && !Number.isNaN(Number(opt))) {
if (perp != null && Math.abs(Number(perp)) >= 0.005) {
const optAbs = Math.abs(Number(opt)).toFixed(8).replace(/\.?0+$/, "") || "0";
const optSign = Number(opt) > 0 ? "+" : (Number(opt) < 0 ? "-" : "");
const perpSign = Number(perp) > 0 ? "+" : "";
const text = `${perpSign}${Number(perp).toFixed(2)}U / ${optSign}${optAbs}${underly}`;
let optPart = `${optSign}${optAbs} ${underly}`;
if (Number.isFinite(spotPx) && spotPx > 0) {
const uu = Number(opt) * spotPx;
const uAbs = Math.abs(uu).toFixed(2);
const uSign = uu < 0 ? "-" : uu > 0 ? "+" : "";
optPart += ` / ${uSign}${uAbs}U`;
}
const text = `${perpSign}${Number(perp).toFixed(2)}U / ${optPart}`;
lastRealtimePnl = Number(opt);
lastRealtimePnlUnit = underly;
lastRealtimePnlSpotPx = spotPx;
document.querySelectorAll('[data-funds-field="realtime-pnl"]').forEach((pnlEl) => {
pnlEl.innerText = text;
const n = Number(opt) + Number(perp);
@@ -1159,7 +1181,7 @@ function paintRealtimePnlFromSnapshot(data){
});
return;
}
paintRealtimePnl(opt, underly);
paintRealtimePnl(opt, underly, spotPx);
return;
}
const combined = combineRealtimeFloatPnl(perp, opt);
+29 -7
View File
@@ -1556,7 +1556,7 @@ function refreshOrderDefaults(){
}).catch(()=>{});
}
function paintRealtimePnl(v, unit){
function paintRealtimePnl(v, unit, spotPx){
const nodes = document.querySelectorAll('[data-funds-field="realtime-pnl"]');
if(!nodes.length) return;
if(v === null || v === undefined || Number.isNaN(Number(v))){
@@ -1569,11 +1569,23 @@ function paintRealtimePnl(v, unit){
const n = Number(v);
const u = String(unit || lastRealtimePnlUnit || "U").toUpperCase() || "U";
lastRealtimePnlUnit = u;
if (spotPx != null && Number.isFinite(Number(spotPx)) && Number(spotPx) > 0) {
lastRealtimePnlSpotPx = Number(spotPx);
}
const sign = n > 0 ? "+" : "";
let text;
if (u === "ETH" || u === "BTC") {
const abs = Math.abs(n).toFixed(8).replace(/\.?0+$/, "") || "0";
text = `${n < 0 ? "-" : sign}${abs}${u}`;
const coinTxt = `${n < 0 ? "-" : sign}${abs} ${u}`;
const px = Number(spotPx != null ? spotPx : lastRealtimePnlSpotPx);
if (Number.isFinite(px) && px > 0) {
const uu = n * px;
const uAbs = Math.abs(uu).toFixed(2);
const uSign = uu < 0 ? "-" : uu > 0 ? "+" : "";
text = `${coinTxt} / ${uSign}${uAbs}U`;
} else {
text = coinTxt;
}
} else {
text = `${sign}${n.toFixed(2)}U`;
}
@@ -1585,15 +1597,16 @@ function paintRealtimePnl(v, unit){
}
let lastRealtimePnl = null;
let lastRealtimePnlUnit = "U";
function updateRealtimePnl(v, unit){
let lastRealtimePnlSpotPx = null;
function updateRealtimePnl(v, unit, spotPx){
if(v != null && !Number.isNaN(Number(v))){
lastRealtimePnl = Number(v);
if (unit) lastRealtimePnlUnit = String(unit).toUpperCase();
paintRealtimePnl(v, lastRealtimePnlUnit);
paintRealtimePnl(v, lastRealtimePnlUnit, spotPx);
return;
}
if(lastRealtimePnl != null) return;
paintRealtimePnl(v, lastRealtimePnlUnit);
paintRealtimePnl(v, lastRealtimePnlUnit, spotPx);
}
function sumOrdersFloatPnl(orders){
if(!orders || !orders.length) return null;
@@ -1624,15 +1637,24 @@ function paintRealtimePnlFromSnapshot(data){
const opt = data.options_unrealized_pnl;
const coinMode = String(data.options_margin_mode || "").toLowerCase() === "coin";
const underly = String(data.options_underly || "ETH").toUpperCase() || "ETH";
const spotPx = data.options_index_px != null ? Number(data.options_index_px) : null;
if (coinMode && opt != null && !Number.isNaN(Number(opt))) {
// 币本位期权盈亏单位为币,勿与永续 U 混加成「xxU」
if (perp != null && Math.abs(Number(perp)) >= 0.005) {
const optAbs = Math.abs(Number(opt)).toFixed(8).replace(/\.?0+$/, "") || "0";
const optSign = Number(opt) > 0 ? "+" : (Number(opt) < 0 ? "-" : "");
const perpSign = Number(perp) > 0 ? "+" : "";
const text = `${perpSign}${Number(perp).toFixed(2)}U / ${optSign}${optAbs}${underly}`;
let optPart = `${optSign}${optAbs} ${underly}`;
if (Number.isFinite(spotPx) && spotPx > 0) {
const uu = Number(opt) * spotPx;
const uAbs = Math.abs(uu).toFixed(2);
const uSign = uu < 0 ? "-" : uu > 0 ? "+" : "";
optPart += ` / ${uSign}${uAbs}U`;
}
const text = `${perpSign}${Number(perp).toFixed(2)}U / ${optPart}`;
lastRealtimePnl = Number(opt);
lastRealtimePnlUnit = underly;
lastRealtimePnlSpotPx = spotPx;
document.querySelectorAll('[data-funds-field="realtime-pnl"]').forEach((pnlEl) => {
pnlEl.innerText = text;
const n = Number(opt) + Number(perp);
@@ -1641,7 +1663,7 @@ function paintRealtimePnlFromSnapshot(data){
});
return;
}
paintRealtimePnl(opt, underly);
paintRealtimePnl(opt, underly, spotPx);
return;
}
const combined = combineRealtimeFloatPnl(perp, opt);
+20 -1
View File
@@ -115,6 +115,24 @@ def build_options_hub_snapshot(cfg: dict[str, Any]) -> dict[str, Any]:
)
p["margin_mode_label"] = "币本位" if row_mode == "coin" else "USDC"
underly = (os.getenv("OKX_OPTIONS_DEFAULT_UNDERLY") or "ETH").strip().upper() or "ETH"
options_index_px = None
try:
from lib.exchange.okx_options_lib import fetch_index_price
options_index_px = fetch_index_price(ex, underly)
except Exception:
options_index_px = None
if options_index_px is None:
for p in positions:
try:
px = float(p.get("idx_px") or p.get("idxPx") or 0)
except (TypeError, ValueError):
px = 0
if px > 0:
options_index_px = px
break
coin_budget = None
bridge_status = None
open_bridges = []
@@ -157,7 +175,8 @@ def build_options_hub_snapshot(cfg: dict[str, Any]) -> dict[str, Any]:
"max_active_positions": options_max_active_positions(),
"options_margin_mode": margin_mode,
"options_margin_mode_label": "币本位" if margin_mode == "coin" else "USDC",
"options_underly": (os.getenv("OKX_OPTIONS_DEFAULT_UNDERLY") or "ETH").strip().upper() or "ETH",
"options_underly": underly,
"options_index_px": options_index_px,
"coin_budget": coin_budget,
"bridge_status": bridge_status,
"open_bridges": open_bridges,
+21 -20
View File
@@ -221,26 +221,32 @@ def spot_market_sell_coin_to_usdt(
underlying: str,
coin_amount: float | None = None,
) -> dict[str, Any]:
"""交易账户:市价卖出标的币换 USDT.coin_amount 空则尽量卖光可用."""
"""交易账户:市价卖出标的币换 USDT.
默认/推荐:coin_amount 为空 → 卖光交易账户全部可用币(全部卖出).
传入 coin_amount 时仍不超过可用余额,且尽量按可用全额卖出(不故意留粉尘).
"""
ccy = (underlying or "ETH").upper()
amt = coin_amount
if amt is None or float(amt) <= 0:
avail = fetch_trading_coin_available(ex, ccy)
if avail is None or float(avail) <= 0:
return {"ok": False, "msg": f"交易账户无可用 {ccy}"}
amt = float(avail)
if float(amt) <= 0:
avail = fetch_trading_coin_available(ex, ccy)
if avail is None or float(avail) <= 0:
return {"ok": False, "msg": f"交易账户无可用 {ccy}"}
avail_f = float(avail)
# 全部卖出:以可用余额为准;若传入数量则不超过可用(开仓失败回滚用)
if coin_amount is None or float(coin_amount) <= 0:
sell_sz = avail_f
else:
sell_sz = min(float(coin_amount), avail_f)
if sell_sz <= 0:
return {"ok": False, "msg": f"{ccy} 数量须大于 0"}
# 留一点粉尘避免精度拒单
sell_sz = float(amt)
if sell_sz > 1e-8:
sell_sz = max(0.0, sell_sz * 0.999)
inst_id = spot_quote_inst_id(ccy)
try:
# 现货卖出数量精度:截到 8 位
# 现货卖出:向下截到 8 位,避免超过可用被拒;不再 *0.999 故意留残
sz = f"{sell_sz:.8f}".rstrip("0").rstrip(".")
if not sz or float(sz) <= 0:
return {"ok": False, "msg": f"{ccy} 可卖数量过小"}
# 二次钳制:格式化后仍不得超过可用
if float(sz) > avail_f:
sz = f"{avail_f:.8f}".rstrip("0").rstrip(".")
body = {
"instId": inst_id,
"tdMode": "cash",
@@ -328,13 +334,8 @@ def sell_residual_after_option_flat(
if not underlying or str(b.get("underlying") or "").upper() == underlying.upper():
target = b
break
coin_amt = None
if target is not None:
try:
coin_amt = float(target.get("coin_bought") or 0) or None
except (TypeError, ValueError):
coin_amt = None
sell = spot_market_sell_coin_to_usdt(ex, underlying=underlying, coin_amount=coin_amt)
# 平仓后全部卖出交易账户可用标的币(含权利金盈亏留下的币),不按 bridge 记账量限卖
sell = spot_market_sell_coin_to_usdt(ex, underlying=underlying, coin_amount=None)
if target is None:
if not sell.get("ok"):
msg = str(sell.get("msg") or "")