粉尘低于现货最小可卖量时忽略且不展示、不再重试卖回。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-10-07 11:16:13 +08:00
parent 80ef5ceccb
commit 030b237981
8 changed files with 183 additions and 19 deletions
+29 -9
View File
@@ -2317,15 +2317,33 @@
return n.toFixed(8).replace(/\.?0+$/, ""); 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) { 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 hasCoinPos = (positions || []).some(function (p) { return isCoinPos(p); });
const coinMode = const coinMode =
isCoinMarginMode() || isCoinMarginMode() ||
hasCoinPos || hasCoinPos ||
(d && d.options_margin_mode === "coin") || (d && d.options_margin_mode === "coin");
!!(d && d.spot_sell); // 待卖回桥且仍有可卖数量时才露按钮(粉尘桥后端会关掉)
return !!coinMode; 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) { function spotSellUnderlying(ss) {
@@ -2334,14 +2352,16 @@
const bridgeUly = String(ss.bridge_underlying || "").trim().toUpperCase(); const bridgeUly = String(ss.bridge_underlying || "").trim().toUpperCase();
const eth = Number(ss.trading_eth || 0); const eth = Number(ss.trading_eth || 0);
const btc = Number(ss.trading_btc || 0); const btc = Number(ss.trading_btc || 0);
// 待卖回桥优先;否则优先当前选中标的上的残留,再比大小 const ethMin = spotSellMinSz(ss, "ETH");
const btcMin = spotSellMinSz(ss, "BTC");
// 待卖回桥优先;否则优先当前选中标的上的可卖残留
if (bridgeUly === "BTC" || bridgeUly === "ETH") { if (bridgeUly === "BTC" || bridgeUly === "ETH") {
if (ss.bridge_status === "pending_sell_spot") return bridgeUly; if (ss.bridge_status === "pending_sell_spot") return bridgeUly;
} }
if (selected === "BTC" && btc > 1e-12) return "BTC"; if (selected === "BTC" && btc >= btcMin) return "BTC";
if (selected === "ETH" && eth > 1e-12) return "ETH"; if (selected === "ETH" && eth >= ethMin) return "ETH";
if (btc > eth && btc > 1e-12) return "BTC"; if (btc >= btcMin && btc >= eth) return "BTC";
if (eth > 1e-12) return "ETH"; if (eth >= ethMin) return "ETH";
if (bridgeUly === "BTC" || bridgeUly === "ETH") return bridgeUly; if (bridgeUly === "BTC" || bridgeUly === "ETH") return bridgeUly;
return (ss.default_underly || selected || "ETH").toUpperCase(); return (ss.default_underly || selected || "ETH").toUpperCase();
} }
+10 -2
View File
@@ -161,10 +161,18 @@ def trading_account_label(
parts.append(f"{float(usdt):.2f} USDT") parts.append(f"{float(usdt):.2f} USDT")
except (TypeError, ValueError): except (TypeError, ValueError):
pass 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: if eth_txt is not None:
parts.append(f"{eth_txt} ETH") 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: if btc_txt is not None:
parts.append(f"{btc_txt} BTC") parts.append(f"{btc_txt} BTC")
return "\n".join(parts) if parts else "—" return "\n".join(parts) if parts else "—"
@@ -1158,7 +1158,9 @@ function formatTradingAccountLabel(usdt, eth, btc, marginMode) {
const pushCoin = (v, ccy) => { const pushCoin = (v, ccy) => {
if (v === null || v === undefined || v === "") return; if (v === null || v === undefined || v === "") return;
const n = Number(v); 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+$/, ""); const txt = String(n.toFixed(6)).replace(/\.?0+$/, "");
parts.push(`${txt || "0"} ${ccy}`); parts.push(`${txt || "0"} ${ccy}`);
}; };
+3 -1
View File
@@ -1644,7 +1644,9 @@ function formatTradingAccountLabel(usdt, eth, btc, marginMode) {
const pushCoin = (v, ccy) => { const pushCoin = (v, ccy) => {
if (v == null || v === "") return; if (v == null || v === "") return;
const n = Number(v); 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+$/, ""); const txt = String(n.toFixed(6)).replace(/\.?0+$/, "");
parts.push(`${txt || "0"} ${ccy}`); parts.push(`${txt || "0"} ${ccy}`);
}; };
+2 -1
View File
@@ -507,7 +507,8 @@ def sweep_spot_after_options_flat(
continue continue
try: try:
avail = _spot_bridge.fetch_trading_coin_available(exchange, uly) 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 continue
except Exception: except Exception:
continue continue
+12 -2
View File
@@ -1546,11 +1546,21 @@ def register_options_routes(app: Flask, cfg: dict[str, Any]) -> None:
open_bridges = list_open_bridges(conn_b) open_bridges = list_open_bridges(conn_b)
bal = cfg["fetch_options_balances"](ex, scope="main") bal = cfg["fetch_options_balances"](ex, scope="main")
bridge = open_bridges[0] if open_bridges else None 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 = { spot_sell_hint = {
"bridge_status": str(bridge.get("status") or "") if bridge else None, "bridge_status": str(bridge.get("status") or "") if bridge else None,
"bridge_underlying": str(bridge.get("underlying") or "").upper() 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(), "default_underly": (cfg.get("default_underly") or "ETH").strip().upper(),
} }
finally: finally:
+118 -2
View File
@@ -13,6 +13,61 @@ BRIDGE_HOLDING = "holding"
BRIDGE_PENDING_SELL = "pending_sell_spot" BRIDGE_PENDING_SELL = "pending_sell_spot"
BRIDGE_CLOSED = "closed" 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: def ensure_bridge_table(conn: sqlite3.Connection) -> None:
conn.execute( conn.execute(
@@ -229,8 +284,13 @@ def spot_market_sell_coin_to_usdt(
amt = float(avail) amt = float(avail)
if amt <= 0: if amt <= 0:
return {"ok": False, "msg": f"{ccy} 数量须大于 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) 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) inst_id = spot_quote_inst_id(ccy)
try: try:
# 现货卖出数量精度:截到 8 位 # 现货卖出数量精度:截到 8 位
@@ -349,6 +409,43 @@ def sell_residual_after_option_flat(
except Exception: except Exception:
pass 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 可安全另开写钱包 # 此处无业务连接:模拟盘 convert 可安全另开写钱包
sell = spot_market_sell_coin_to_usdt(ex, underlying=underlying) sell = spot_market_sell_coin_to_usdt(ex, underlying=underlying)
if bid is None: if bid is None:
@@ -361,16 +458,35 @@ def sell_residual_after_option_flat(
def _apply_bridge(c: sqlite3.Connection) -> dict[str, Any]: def _apply_bridge(c: sqlite3.Connection) -> dict[str, Any]:
if not sell.get("ok"): 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( update_bridge(
c, c,
bid, bid,
status=BRIDGE_PENDING_SELL, status=BRIDGE_PENDING_SELL,
inst_id=inst_id, inst_id=inst_id,
message=str(sell.get("msg") or "卖回 USDT 失败"), message=msg,
) )
return { return {
"ok": False, "ok": False,
"msg": sell.get("msg") or "卖回 USDT 失败", "msg": msg,
"bridge_id": bid, "bridge_id": bid,
"bridge_status": BRIDGE_PENDING_SELL, "bridge_status": BRIDGE_PENDING_SELL,
} }
+6 -1
View File
@@ -472,8 +472,13 @@ def _patch_spot_bridge_lib() -> None:
avail = fetch_trading_coin_available(ex, coin) avail = fetch_trading_coin_available(ex, coin)
if avail is None or float(avail) <= 0: if avail is None or float(avail) <= 0:
return {"ok": False, "msg": f"交易账户无可用 {coin}"} 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) 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} 可卖数量过小"} return {"ok": False, "msg": f"{coin} 可卖数量过小"}
pub = _sim_public_exchange(ex) pub = _sim_public_exchange(ex)
if pub is None: if pub is None: