From 030b237981727678dc9f837fa78fa4c004ddfddb Mon Sep 17 00:00:00 2001 From: dekun Date: Wed, 7 Oct 2026 11:16:13 +0800 Subject: [PATCH] =?UTF-8?q?=E7=B2=89=E5=B0=98=E4=BD=8E=E4=BA=8E=E7=8E=B0?= =?UTF-8?q?=E8=B4=A7=E6=9C=80=E5=B0=8F=E5=8F=AF=E5=8D=96=E9=87=8F=E6=97=B6?= =?UTF-8?q?=E5=BF=BD=E7=95=A5=E4=B8=94=E4=B8=8D=E5=B1=95=E7=A4=BA=E3=80=81?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E9=87=8D=E8=AF=95=E5=8D=96=E5=9B=9E=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- lib/common/static/options_panel.js | 38 ++++-- lib/instance/instance_embed_context_lib.py | 12 +- .../templates/embed_boot_scripts.html | 4 +- lib/instance/templates/index.html | 4 +- lib/options/options_coin_open_lib.py | 3 +- lib/options/options_register.py | 14 +- lib/options/options_spot_bridge_lib.py | 120 +++++++++++++++++- lib/sim/hooks.py | 7 +- 8 files changed, 183 insertions(+), 19 deletions(-) diff --git a/lib/common/static/options_panel.js b/lib/common/static/options_panel.js index 944fab3..c3008cc 100644 --- a/lib/common/static/options_panel.js +++ b/lib/common/static/options_panel.js @@ -2317,15 +2317,33 @@ return n.toFixed(8).replace(/\.?0+$/, ""); } + function spotSellMinSz(ss, uly) { + const u = String(uly || "ETH").toUpperCase(); + const fromApi = u === "BTC" ? Number(ss && ss.min_sz_btc) : Number(ss && ss.min_sz_eth); + if (Number.isFinite(fromApi) && fromApi > 0) return fromApi; + return u === "BTC" ? 0.00001 : 0.0001; + } + function shouldShowSpotSellBanner(d, positions) { - // 币本位下始终露出「重试卖回」(含有仓):便于清交易户残留,文案随选中/残留标的变化 + // 仅当交易户有「可卖」(>= 现货 minSz)残留时展示;粉尘不展示、不提示重试 + const ss = (d && d.spot_sell) || {}; + const eth = Number(ss.trading_eth || 0); + const btc = Number(ss.trading_btc || 0); + const ethOk = eth >= spotSellMinSz(ss, "ETH"); + const btcOk = btc >= spotSellMinSz(ss, "BTC"); + if (ethOk || btcOk) return true; const hasCoinPos = (positions || []).some(function (p) { return isCoinPos(p); }); const coinMode = isCoinMarginMode() || hasCoinPos || - (d && d.options_margin_mode === "coin") || - !!(d && d.spot_sell); - return !!coinMode; + (d && d.options_margin_mode === "coin"); + // 待卖回桥且仍有可卖数量时才露按钮(粉尘桥后端会关掉) + if (coinMode && ss.bridge_status === "pending_sell_spot") { + const bu = String(ss.bridge_underlying || "").toUpperCase(); + const amt = bu === "BTC" ? btc : eth; + return amt >= spotSellMinSz(ss, bu || "ETH"); + } + return false; } function spotSellUnderlying(ss) { @@ -2334,14 +2352,16 @@ const bridgeUly = String(ss.bridge_underlying || "").trim().toUpperCase(); const eth = Number(ss.trading_eth || 0); const btc = Number(ss.trading_btc || 0); - // 待卖回桥优先;否则优先当前选中标的上的残留,再比大小 + const ethMin = spotSellMinSz(ss, "ETH"); + const btcMin = spotSellMinSz(ss, "BTC"); + // 待卖回桥优先;否则优先当前选中标的上的可卖残留 if (bridgeUly === "BTC" || bridgeUly === "ETH") { if (ss.bridge_status === "pending_sell_spot") return bridgeUly; } - if (selected === "BTC" && btc > 1e-12) return "BTC"; - if (selected === "ETH" && eth > 1e-12) return "ETH"; - if (btc > eth && btc > 1e-12) return "BTC"; - if (eth > 1e-12) return "ETH"; + if (selected === "BTC" && btc >= btcMin) return "BTC"; + if (selected === "ETH" && eth >= ethMin) return "ETH"; + if (btc >= btcMin && btc >= eth) return "BTC"; + if (eth >= ethMin) return "ETH"; if (bridgeUly === "BTC" || bridgeUly === "ETH") return bridgeUly; return (ss.default_underly || selected || "ETH").toUpperCase(); } diff --git a/lib/instance/instance_embed_context_lib.py b/lib/instance/instance_embed_context_lib.py index 773d546..c88a0a1 100644 --- a/lib/instance/instance_embed_context_lib.py +++ b/lib/instance/instance_embed_context_lib.py @@ -161,10 +161,18 @@ def trading_account_label( parts.append(f"{float(usdt):.2f} USDT") except (TypeError, ValueError): pass - eth_txt = _fmt_coin_amount(eth, min_amt=1e-6) + # 低于现货最小可卖的粉尘不展示(与卖回逻辑一致) + try: + from lib.options.options_spot_bridge_lib import spot_min_sell_sz + + eth_min = spot_min_sell_sz(None, "ETH") + btc_min = spot_min_sell_sz(None, "BTC") + except Exception: + eth_min, btc_min = 0.0001, 0.00001 + eth_txt = _fmt_coin_amount(eth, min_amt=eth_min) if eth_txt is not None: parts.append(f"{eth_txt} ETH") - btc_txt = _fmt_coin_amount(btc, min_amt=1e-7) + btc_txt = _fmt_coin_amount(btc, min_amt=btc_min) if btc_txt is not None: parts.append(f"{btc_txt} BTC") return "\n".join(parts) if parts else "—" diff --git a/lib/instance/templates/embed_boot_scripts.html b/lib/instance/templates/embed_boot_scripts.html index a72b47a..a3def78 100644 --- a/lib/instance/templates/embed_boot_scripts.html +++ b/lib/instance/templates/embed_boot_scripts.html @@ -1158,7 +1158,9 @@ function formatTradingAccountLabel(usdt, eth, btc, marginMode) { const pushCoin = (v, ccy) => { if (v === null || v === undefined || v === "") return; const n = Number(v); - if (Number.isNaN(n) || !(n >= (ccy === "BTC" ? 1e-7 : 1e-6))) return; + // 低于 OKX 现货常见 minSz 的粉尘不展示 + const minSz = ccy === "BTC" ? 0.00001 : 0.0001; + if (Number.isNaN(n) || !(n >= minSz)) return; const txt = String(n.toFixed(6)).replace(/\.?0+$/, ""); parts.push(`${txt || "0"} ${ccy}`); }; diff --git a/lib/instance/templates/index.html b/lib/instance/templates/index.html index 63605e1..ad27325 100644 --- a/lib/instance/templates/index.html +++ b/lib/instance/templates/index.html @@ -1644,7 +1644,9 @@ function formatTradingAccountLabel(usdt, eth, btc, marginMode) { const pushCoin = (v, ccy) => { if (v == null || v === "") return; const n = Number(v); - if (Number.isNaN(n) || !(n >= (ccy === "BTC" ? 1e-7 : 1e-6))) return; + // 低于 OKX 现货常见 minSz 的粉尘不展示 + const minSz = ccy === "BTC" ? 0.00001 : 0.0001; + if (Number.isNaN(n) || !(n >= minSz)) return; const txt = String(n.toFixed(6)).replace(/\.?0+$/, ""); parts.push(`${txt || "0"} ${ccy}`); }; diff --git a/lib/options/options_coin_open_lib.py b/lib/options/options_coin_open_lib.py index 19b111a..d540c9a 100644 --- a/lib/options/options_coin_open_lib.py +++ b/lib/options/options_coin_open_lib.py @@ -507,7 +507,8 @@ def sweep_spot_after_options_flat( continue try: avail = _spot_bridge.fetch_trading_coin_available(exchange, uly) - if avail is None or float(avail) <= 1e-8: + # 低于现货 minSz 的粉尘不扫、不重试 + if avail is None or not _spot_bridge.is_sellable_spot_coin(exchange, uly, avail): continue except Exception: continue diff --git a/lib/options/options_register.py b/lib/options/options_register.py index 5491f64..bc759d2 100644 --- a/lib/options/options_register.py +++ b/lib/options/options_register.py @@ -1546,11 +1546,21 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: open_bridges = list_open_bridges(conn_b) bal = cfg["fetch_options_balances"](ex, scope="main") bridge = open_bridges[0] if open_bridges else None + from lib.options.options_spot_bridge_lib import ( + display_sellable_coin, + spot_min_sell_sz, + ) + + raw_eth = bal.get("trading_eth_avail") or bal.get("trading_eth") + raw_btc = bal.get("trading_btc_avail") or bal.get("trading_btc") spot_sell_hint = { "bridge_status": str(bridge.get("status") or "") if bridge else None, "bridge_underlying": str(bridge.get("underlying") or "").upper() if bridge else None, - "trading_eth": bal.get("trading_eth_avail") or bal.get("trading_eth"), - "trading_btc": bal.get("trading_btc_avail") or bal.get("trading_btc"), + # 粉尘归零:不够现货最小可卖则前端不展示、不提示重试 + "trading_eth": display_sellable_coin(ex, "ETH", raw_eth), + "trading_btc": display_sellable_coin(ex, "BTC", raw_btc), + "min_sz_eth": spot_min_sell_sz(ex, "ETH"), + "min_sz_btc": spot_min_sell_sz(ex, "BTC"), "default_underly": (cfg.get("default_underly") or "ETH").strip().upper(), } finally: diff --git a/lib/options/options_spot_bridge_lib.py b/lib/options/options_spot_bridge_lib.py index 2bdb606..721c02b 100644 --- a/lib/options/options_spot_bridge_lib.py +++ b/lib/options/options_spot_bridge_lib.py @@ -13,6 +13,61 @@ BRIDGE_HOLDING = "holding" BRIDGE_PENDING_SELL = "pending_sell_spot" BRIDGE_CLOSED = "closed" +# 拉取 instruments 失败时的兜底(与 OKX 现货常见 minSz 对齐) +_SPOT_MIN_SZ_FALLBACK: dict[str, float] = {"ETH": 0.0001, "BTC": 0.00001} +_SPOT_MIN_SZ_CACHE: dict[str, tuple[float, float]] = {} # ccy -> (min_sz, mono_ts) +_SPOT_MIN_SZ_TTL_SEC = 3600.0 + + +def spot_min_sell_sz(ex: Any | None, underlying: str) -> float: + """ETH/BTC-USDT 现货最小下单量(base ccy);粉尘低于此值不卖、不展示.""" + ccy = (underlying or "ETH").strip().upper() or "ETH" + fb = float(_SPOT_MIN_SZ_FALLBACK.get(ccy) or _SPOT_MIN_SZ_FALLBACK["ETH"]) + now = time.monotonic() + cached = _SPOT_MIN_SZ_CACHE.get(ccy) + if cached and (now - cached[1]) < _SPOT_MIN_SZ_TTL_SEC: + return float(cached[0]) + min_sz = fb + if ex is not None: + try: + inst_id = spot_quote_inst_id(ccy) + rows = ex.public_get_public_instruments({"instType": "SPOT", "instId": inst_id}) or {} + data = rows.get("data") if isinstance(rows, dict) else None + if not data and hasattr(ex, "publicGetPublicInstruments"): + rows = ex.publicGetPublicInstruments({"instType": "SPOT", "instId": inst_id}) or {} + data = rows.get("data") if isinstance(rows, dict) else None + row = (data or [None])[0] if data else None + if isinstance(row, dict): + raw = _safe_float(row.get("minSz")) + if raw is not None and raw > 0: + min_sz = float(raw) + except Exception: + pass + _SPOT_MIN_SZ_CACHE[ccy] = (min_sz, now) + return min_sz + + +def is_sellable_spot_coin(ex: Any | None, underlying: str, amount: Any) -> bool: + """数量是否达到现货最小可卖(达到才重试/展示).""" + try: + amt = float(amount or 0) + except (TypeError, ValueError): + return False + if amt <= 0: + return False + return amt + 1e-15 >= spot_min_sell_sz(ex, underlying) + + +def display_sellable_coin(ex: Any | None, underlying: str, amount: Any) -> float: + """展示用:不够 minSz 的粉尘归零.""" + try: + amt = float(amount or 0) + except (TypeError, ValueError): + return 0.0 + if not is_sellable_spot_coin(ex, underlying, amt): + return 0.0 + return amt + def ensure_bridge_table(conn: sqlite3.Connection) -> None: conn.execute( @@ -229,8 +284,13 @@ def spot_market_sell_coin_to_usdt( amt = float(avail) if amt <= 0: return {"ok": False, "msg": f"{ccy} 数量须大于 0"} + min_sz = spot_min_sell_sz(ex, ccy) + if amt + 1e-15 < min_sz: + return {"ok": False, "msg": f"{ccy} 可卖数量过小(低于现货最小 {min_sz:g})"} # 留一点粉尘避免精度拒单 sell_sz = max(0.0, amt * 0.999) + if sell_sz + 1e-15 < min_sz: + return {"ok": False, "msg": f"{ccy} 可卖数量过小(低于现货最小 {min_sz:g})"} inst_id = spot_quote_inst_id(ccy) try: # 现货卖出数量精度:截到 8 位 @@ -349,6 +409,43 @@ def sell_residual_after_option_flat( except Exception: pass + # 粉尘:不够现货最小下单量则忽略,不再重试;有桥则直接关闭 + try: + avail_chk = fetch_trading_coin_available(ex, underlying) + except Exception: + avail_chk = None + if avail_chk is not None and not is_sellable_spot_coin(ex, underlying, avail_chk): + dust_msg = "粉尘低于现货最小可卖,已忽略" + if bid is None: + return {"ok": True, "msg": dust_msg, "skipped": True, "dust": True} + def _close_dust(c: sqlite3.Connection) -> dict[str, Any]: + update_bridge( + c, + bid, + status=BRIDGE_CLOSED, + inst_id=inst_id, + message=dust_msg, + close=True, + ) + return { + "ok": True, + "msg": dust_msg, + "skipped": True, + "dust": True, + "bridge_id": bid, + "bridge_status": BRIDGE_CLOSED, + } + if get_db is not None: + conn_d = get_db() + try: + return _close_dust(conn_d) + finally: + try: + conn_d.close() + except Exception: + pass + return _close_dust(conn_or_get_db) + # 此处无业务连接:模拟盘 convert 可安全另开写钱包 sell = spot_market_sell_coin_to_usdt(ex, underlying=underlying) if bid is None: @@ -361,16 +458,35 @@ def sell_residual_after_option_flat( def _apply_bridge(c: sqlite3.Connection) -> dict[str, Any]: if not sell.get("ok"): + msg = str(sell.get("msg") or "卖回 USDT 失败") + # 交易所拒单「过小」也按粉尘关闭桥,避免一直 pending 重试 + if "过小" in msg or "minimum" in msg.lower() or "min sz" in msg.lower(): + update_bridge( + c, + bid, + status=BRIDGE_CLOSED, + inst_id=inst_id, + message="粉尘低于现货最小可卖,已忽略", + close=True, + ) + return { + "ok": True, + "msg": "粉尘低于现货最小可卖,已忽略", + "skipped": True, + "dust": True, + "bridge_id": bid, + "bridge_status": BRIDGE_CLOSED, + } update_bridge( c, bid, status=BRIDGE_PENDING_SELL, inst_id=inst_id, - message=str(sell.get("msg") or "卖回 USDT 失败"), + message=msg, ) return { "ok": False, - "msg": sell.get("msg") or "卖回 USDT 失败", + "msg": msg, "bridge_id": bid, "bridge_status": BRIDGE_PENDING_SELL, } diff --git a/lib/sim/hooks.py b/lib/sim/hooks.py index 686bd41..1b3deb0 100644 --- a/lib/sim/hooks.py +++ b/lib/sim/hooks.py @@ -472,8 +472,13 @@ def _patch_spot_bridge_lib() -> None: avail = fetch_trading_coin_available(ex, coin) if avail is None or float(avail) <= 0: return {"ok": False, "msg": f"交易账户无可用 {coin}"} + from lib.options.options_spot_bridge_lib import is_sellable_spot_coin, spot_min_sell_sz + + if not is_sellable_spot_coin(ex, coin, avail): + min_sz = spot_min_sell_sz(ex, coin) + return {"ok": False, "msg": f"{coin} 可卖数量过小(低于现货最小 {min_sz:g})"} sell_sz = max(0.0, float(avail) * 0.999) - if sell_sz <= 0: + if sell_sz <= 0 or not is_sellable_spot_coin(ex, coin, sell_sz): return {"ok": False, "msg": f"{coin} 可卖数量过小"} pub = _sim_public_exchange(ex) if pub is None: