diff --git a/lib/common/static/instance_theme.css b/lib/common/static/instance_theme.css index 2278563..6156d06 100644 --- a/lib/common/static/instance_theme.css +++ b/lib/common/static/instance_theme.css @@ -5233,6 +5233,24 @@ html[data-theme="light"] .opt-source-badge--oo { font-variant-numeric: tabular-nums; font-weight: 600; } +.opt-spot-sell-banner { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px 12px; + margin: 0 0 10px; + padding: 10px 12px; + border: 1px solid rgba(255, 170, 80, 0.45); + border-radius: 10px; + background: rgba(48, 32, 12, 0.55); +} +.opt-spot-sell-banner-text { + flex: 1; + min-width: 180px; + font-size: 0.82rem; + color: #f5d9b8; + line-height: 1.45; +} .opt-target-monitors { margin: 0 0 10px; padding: 10px 12px; @@ -5836,6 +5854,13 @@ html[data-theme="light"] .options-page-wrap .pos-empty { color: #334155 !important; } +html[data-theme="light"] .options-page-wrap .opt-spot-sell-banner { + border-color: rgba(180, 110, 40, 0.45); + background: rgba(255, 244, 228, 0.95); +} +html[data-theme="light"] .options-page-wrap .opt-spot-sell-banner-text { + color: #6a4a18; +} html[data-theme="light"] .options-page-wrap .opt-target-monitors { background: #eef4fa !important; border: 1px solid #94a3b8 !important; diff --git a/lib/common/static/options_panel.js b/lib/common/static/options_panel.js index e0047ad..b2dbfae 100644 --- a/lib/common/static/options_panel.js +++ b/lib/common/static/options_panel.js @@ -2166,7 +2166,7 @@ if (ss && ss.ok && !ss.skipped) { okMsg += "\n已自动卖回 USDT"; } else if (ss && ss.bridge_status === "pending_sell_spot") { - okMsg += "\n卖回 USDT 失败,请点「重试卖回」"; + okMsg += "\n卖回 USDT 失败,请点持仓区「重试卖回」"; } alert(okMsg); } else { @@ -2210,6 +2210,82 @@ setOptionsPosTab(state.posTab); } + function shouldShowSpotSellBanner(d, positions) { + if (!isCoinMarginMode()) return false; + const ss = d && d.spot_sell; + if (!ss) return false; + const hasCoinPos = (positions || []).some(function (p) { return isCoinPos(p); }); + if (hasCoinPos) return false; + if (ss.bridge_status === "pending_sell_spot") return true; + const eth = Number(ss.trading_eth || 0); + const btc = Number(ss.trading_btc || 0); + return eth > 0.00001 || btc > 0.000001; + } + + function spotSellUnderlying(ss) { + if (!ss) return (state.underlying || "ETH").toUpperCase(); + if (ss.bridge_underlying) return String(ss.bridge_underlying).toUpperCase(); + const eth = Number(ss.trading_eth || 0); + const btc = Number(ss.trading_btc || 0); + if (btc > eth && btc > 0.000001) return "BTC"; + if (eth > 0.00001) return "ETH"; + return (ss.default_underly || state.underlying || "ETH").toUpperCase(); + } + + function paintSpotSellBanner(d, positions) { + const banner = document.getElementById("opt-spot-sell-banner"); + const textEl = document.getElementById("opt-spot-sell-banner-text"); + if (!banner || !textEl) return; + const show = shouldShowSpotSellBanner(d, positions); + if (!show) { + banner.hidden = true; + state.spotSellUnderlying = null; + return; + } + const ss = d.spot_sell || {}; + const uly = spotSellUnderlying(ss); + state.spotSellUnderlying = uly; + const eth = Number(ss.trading_eth || 0); + const btc = Number(ss.trading_btc || 0); + const amt = uly === "BTC" ? btc : eth; + let msg = "交易账户残留 " + uly; + if (amt > 0) msg += " " + fmt(amt, 6); + if (ss.bridge_status === "pending_sell_spot") { + msg += ",自动卖回 USDT 失败"; + } else { + msg += ",可卖回 USDT"; + } + textEl.textContent = msg; + banner.hidden = false; + } + + async function retrySpotSell() { + const btn = document.getElementById("opt-spot-sell-retry-btn"); + const uly = (state.spotSellUnderlying || state.underlying || "ETH").toUpperCase(); + if (!confirm("将交易账户全部 " + uly + " 市价卖回 USDT,确认?")) return; + if (btn) btn.disabled = true; + try { + const r = await apiJson("/api/options/spot-bridge/retry-sell", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ underlying: uly }), + }); + if (r.ok) { + if (r.skipped) { + alert(r.msg || "无残留币需卖回"); + } else { + alert("已卖回 USDT" + (r.sell && r.sell.coin_sold != null ? (" (" + r.sell.coin_sold + " " + uly + ")") : "")); + } + refreshAllPositions(); + if (typeof refreshAccountSnapshot === "function") refreshAccountSnapshot(); + } else { + alert(r.msg || "卖回失败"); + } + } finally { + if (btn) btn.disabled = false; + } + } + function resolvePositionsList(d) { const now = Date.now(); const list = (d && d.ok && d.positions) ? d.positions : []; @@ -2307,6 +2383,7 @@ if (seq !== positionsRefreshSeq) return; const list = resolvePositionsList(d); paintPositions(list); + paintSpotSellBanner(d, list); const fromPos = list.reduce(function (targets, p) { if (!p) return targets; if (p.target_index != null) { @@ -2706,6 +2783,8 @@ document.getElementById("opt-load-chain").addEventListener("click", loadChain); document.getElementById("opt-refresh-positions").addEventListener("click", refreshAllPositions); document.getElementById("opt-open-btn").addEventListener("click", openPosition); + const spotSellRetryBtn = document.getElementById("opt-spot-sell-retry-btn"); + if (spotSellRetryBtn) spotSellRetryBtn.addEventListener("click", retrySpotSell); const pendingRefreshBtn = document.getElementById("opt-pending-refresh"); if (pendingRefreshBtn) { pendingRefreshBtn.addEventListener("click", function () { diff --git a/lib/options/options_register.py b/lib/options/options_register.py index 131628f..a5b929c 100644 --- a/lib/options/options_register.py +++ b/lib/options/options_register.py @@ -1423,7 +1423,33 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None: rows.append(row) finally: conn.close() - return jsonify({"ok": True, "positions": rows}) + spot_sell_hint = None + try: + from lib.options.options_margin_mode_lib import is_coin_margin_mode + + if is_coin_margin_mode(): + from lib.options.options_spot_bridge_lib import list_open_bridges + + conn_b = cfg["get_db"]() + try: + from lib.options.options_db import init_options_tables + + init_options_tables(conn_b) + open_bridges = list_open_bridges(conn_b) + bal = cfg["fetch_options_balances"](ex, scope="main") + bridge = open_bridges[0] if open_bridges else None + 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"), + "default_underly": (cfg.get("default_underly") or "ETH").strip().upper(), + } + finally: + conn_b.close() + except Exception: + spot_sell_hint = None + return jsonify({"ok": True, "positions": rows, "spot_sell": spot_sell_hint}) @app.route("/api/options/targets") @lr diff --git a/lib/options/options_spot_bridge_lib.py b/lib/options/options_spot_bridge_lib.py index 886947b..645a354 100644 --- a/lib/options/options_spot_bridge_lib.py +++ b/lib/options/options_spot_bridge_lib.py @@ -221,20 +221,16 @@ def spot_market_sell_coin_to_usdt( underlying: str, coin_amount: float | None = None, ) -> dict[str, Any]: - """交易账户:市价卖出标的币换 USDT.coin_amount 空则尽量卖光可用.""" + """交易账户:市价卖出标的币换 USDT.始终卖光交易户可用余额(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}"} + amt = float(avail) + if amt <= 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) + sell_sz = max(0.0, amt * 0.999) inst_id = spot_quote_inst_id(ccy) try: # 现货卖出数量精度:截到 8 位 @@ -278,20 +274,9 @@ def rollback_bought_coin_to_usdt( reason: str = "", coin_amount: float | None = None, ) -> dict[str, Any]: - """买币后开期权失败:卖回 USDT 并关闭桥.优先卖 bridge 记录的买入量.""" - amt = coin_amount - if amt is None or float(amt) <= 0: - ensure_bridge_table(conn) - row = conn.execute( - "SELECT coin_bought FROM options_spot_bridge WHERE id=?", - (int(bridge_id),), - ).fetchone() - if row: - try: - amt = float(row[0] if not isinstance(row, dict) else row.get("coin_bought") or 0) - except (TypeError, ValueError, KeyError, IndexError): - amt = None - sell = spot_market_sell_coin_to_usdt(ex, underlying=underlying, coin_amount=amt) + """买币后开期权失败:卖回 USDT 并关闭桥.卖光交易户可用标的币.""" + _ = coin_amount # 兼容旧签名;定量以交易户可用为准 + sell = spot_market_sell_coin_to_usdt(ex, underlying=underlying) if not sell.get("ok"): update_bridge( conn, @@ -328,13 +313,7 @@ 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) + sell = spot_market_sell_coin_to_usdt(ex, underlying=underlying) if target is None: if not sell.get("ok"): msg = str(sell.get("msg") or "") diff --git a/lib/options/templates/options_panel.html b/lib/options/templates/options_panel.html index e4f9e80..c5de807 100644 --- a/lib/options/templates/options_panel.html +++ b/lib/options/templates/options_panel.html @@ -204,6 +204,10 @@