Add spot sell retry UI; sell all trading-account coin on sell-back.

Fix coin-margin sell-back to use full available balance instead of bridge record; show retry banner and button on the options positions panel.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-08-23 08:01:06 +08:00
parent 6215d0975d
commit a18f1f6713
6 changed files with 152 additions and 42 deletions
+25
View File
@@ -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;
+80 -1
View File
@@ -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 () {
+27 -1
View File
@@ -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
+11 -32
View File
@@ -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 "")
+4
View File
@@ -204,6 +204,10 @@
</div>
<div class="options-pos-tab-body">
<div class="options-pos-pane is-active" data-opt-pos-pane="live" role="tabpanel" aria-labelledby="opt-pos-tab-live">
<div id="opt-spot-sell-banner" class="opt-spot-sell-banner" hidden>
<div class="opt-spot-sell-banner-text" id="opt-spot-sell-banner-text"></div>
<button type="button" class="btn-primary" id="opt-spot-sell-retry-btn">重试卖回</button>
</div>
<div id="opt-target-monitors" class="opt-target-monitors" hidden>
<div class="opt-target-monitors-head">目标监控</div>
<div id="opt-target-monitors-list"></div>
+5 -8
View File
@@ -468,14 +468,11 @@ def _patch_spot_bridge_lib() -> None:
try:
if _GET_DB is not None and is_sim_mode(_GET_DB):
coin = (underlying or "ETH").strip().upper() or "ETH"
amt = coin_amount
if amt is None or float(amt) <= 0:
amt = fetch_trading_coin_available(ex, coin)
if amt is None or float(amt) <= 0:
return {"ok": False, "msg": f"交易账户无可用 {coin}"}
sell_sz = float(amt)
if sell_sz > 1e-8:
sell_sz = max(0.0, sell_sz * 0.999)
_ = coin_amount
avail = fetch_trading_coin_available(ex, coin)
if avail is None or float(avail) <= 0:
return {"ok": False, "msg": f"交易账户无可用 {coin}"}
sell_sz = max(0.0, float(avail) * 0.999)
if sell_sz <= 0:
return {"ok": False, "msg": f"{coin} 可卖数量过小"}
pub = _sim_public_exchange(ex)