diff --git a/lib/common/static/hedge_plan.js b/lib/common/static/hedge_plan.js index 15906ac..2142438 100644 --- a/lib/common/static/hedge_plan.js +++ b/lib/common/static/hedge_plan.js @@ -2940,7 +2940,11 @@ headers: { "Content-Type": "application/json" }, body: JSON.stringify({}), }); - alert(d.msg || "已平仓 #" + (d.plan_id || planId)); + var tip = d.msg || "已平仓 #" + (d.plan_id || planId); + if (Array.isArray(d.notes) && d.notes.length) { + tip += "\n" + d.notes.join("\n"); + } + alert(tip); void loadLivePositions(); void loadActivePlans(); void loadHistory(); diff --git a/lib/hedge_plan/hedge_plan_monitor_lib.py b/lib/hedge_plan/hedge_plan_monitor_lib.py index f0f0eae..3b2da7b 100644 --- a/lib/hedge_plan/hedge_plan_monitor_lib.py +++ b/lib/hedge_plan/hedge_plan_monitor_lib.py @@ -178,6 +178,8 @@ def _sell_all_coin_to_usdt(cfg: dict[str, Any], underlying: str) -> dict[str, An from lib.options.options_spot_bridge_lib import spot_market_sell_coin_to_usdt ex = cfg.get("exchange_options") or cfg.get("exchange") + if callable(ex): + ex = ex() if ex is None: return {"ok": False, "msg": "无交易所实例,跳过卖币"} return spot_market_sell_coin_to_usdt(ex, underlying=str(underlying or "ETH").upper()) diff --git a/lib/hedge_plan/hedge_plan_orders_lib.py b/lib/hedge_plan/hedge_plan_orders_lib.py index 4c42025..1de51e9 100644 --- a/lib/hedge_plan/hedge_plan_orders_lib.py +++ b/lib/hedge_plan/hedge_plan_orders_lib.py @@ -1681,18 +1681,36 @@ def execute_manual_close_plan(cfg: dict[str, Any], conn: Any, plan_id: int) -> d notes.append(f"永续已平 {symbol}") _release_db_write(conn) - # 3) 卖币回 USDT - sell_r: dict[str, Any] = {"ok": True, "skipped": True} + # 3) 卖币回 USDT(期权平仓后交易账户应有标的币) + sell_r: dict[str, Any] = {"ok": False, "msg": "未执行卖币"} try: _release_db_write(conn) sell_ex = cfg.get("exchange_options") or cfg.get("exchange") - if sell_ex is not None: + if callable(sell_ex): + sell_ex = sell_ex() + if sell_ex is None: + sell_r = {"ok": False, "msg": "无交易所实例,无法卖币"} + else: sell_r = spot_market_sell_coin_to_usdt(sell_ex, underlying=uly) + # 无可用币视为已无需卖(非失败),其它失败要暴露 + if not sell_r.get("ok"): + msg_l = str(sell_r.get("msg") or "") + if "无可用" in msg_l or "可卖数量过小" in msg_l: + sell_r = {**sell_r, "ok": True, "skipped": True, "soft": True} except Exception as e: sell_r = {"ok": False, "msg": str(e)} close_detail["sell_coin"] = sell_r - if sell_r.get("ok"): - notes.append("已卖币回 USDT" if not sell_r.get("skipped") else "卖币跳过") + if sell_r.get("ok") and not sell_r.get("skipped"): + notes.append( + "已卖币回 USDT" + + ( + f" {sell_r.get('coin_sold')}→{sell_r.get('usdt_recovered')}U" + if sell_r.get("coin_sold") is not None + else "" + ) + ) + elif sell_r.get("skipped"): + notes.append("卖币跳过: " + str(sell_r.get("msg") or "无可用币")) else: notes.append("卖币失败: " + str(sell_r.get("msg") or "")) @@ -1730,11 +1748,18 @@ def execute_manual_close_plan(cfg: dict[str, Any], conn: Any, plan_id: int) -> d notify_plan_end(cfg, conn, plan2) except Exception: pass + sell_ok = bool(sell_r.get("ok")) + msg = "已平仓并结束计划" + if sell_ok and not sell_r.get("skipped"): + msg = "已平仓并卖币回 USDT" + elif not sell_ok: + msg = "已平仓,但卖币失败: " + str(sell_r.get("msg") or "") return { "ok": True, "plan_id": int(plan_id), - "msg": "已平仓并结束计划", + "msg": msg, "notes": notes, "close": close_detail, + "sell_coin": sell_r, "realized_pnl_total": plan2.get("realized_pnl_total") if plan2 else total, } diff --git a/lib/hedge_plan/templates/hedge_plan_panel.html b/lib/hedge_plan/templates/hedge_plan_panel.html index fb95535..dca64af 100644 --- a/lib/hedge_plan/templates/hedge_plan_panel.html +++ b/lib/hedge_plan/templates/hedge_plan_panel.html @@ -431,4 +431,4 @@ - + diff --git a/lib/options/options_spot_bridge_lib.py b/lib/options/options_spot_bridge_lib.py index 9f1e675..3b5861b 100644 --- a/lib/options/options_spot_bridge_lib.py +++ b/lib/options/options_spot_bridge_lib.py @@ -177,6 +177,50 @@ def fetch_trading_coin_available(ex: Any, ccy: str) -> float | None: return None +def spot_min_sell_sz(ex: Any, ccy: str) -> float: + """OKX 现货最小卖出量(base ccy);读不到则用保守默认.""" + ccy_u = (ccy or "").strip().upper() + defaults = {"BTC": 1e-5, "ETH": 1e-4} + default = float(defaults.get(ccy_u, 1e-4)) + inst_id = spot_quote_inst_id(ccy_u) + if not inst_id: + return default + try: + if ex is not None and hasattr(ex, "public_get_public_instruments"): + resp = ex.public_get_public_instruments({"instType": "SPOT", "instId": inst_id}) + data = (resp or {}).get("data") or [] + if data: + raw = data[0].get("minSz") + if raw is not None and str(raw).strip() != "": + return float(raw) + except Exception: + pass + try: + if ex is not None and hasattr(ex, "market"): + m = ex.market(inst_id.replace("-", "/")) + lim = (m or {}).get("limits") or {} + amt = (lim.get("amount") or {}).get("min") + if amt is not None: + return float(amt) + except Exception: + pass + return default + + +def is_sellable_spot_coin(ex: Any, ccy: str, amount: float) -> bool: + """数量是否达到现货最小卖出门槛.""" + try: + amt = float(amount or 0) + except (TypeError, ValueError): + return False + if amt <= 0: + return False + try: + return amt + 1e-15 >= float(spot_min_sell_sz(ex, ccy)) + except Exception: + return amt > 0 + + def spot_market_buy_coin_with_usdt( ex: Any, *,