From 24270944e7e98a05e424d9afe7ab10e42e3cb96e Mon Sep 17 00:00:00 2001 From: dekun Date: Thu, 4 Jun 2026 19:48:04 +0800 Subject: [PATCH] fix(hub,gate): cross-margin TP/SL and dedupe hub conditional orders Gate hedge position triggers use close=false; stop silent ccxt fallback on cross margin. Hub merges agent and Flask TP/SL by trigger price and labels Gate orders correctly. Co-authored-by: Cursor --- crypto_monitor_gate/app.py | 28 ++++++++-- crypto_monitor_gate_bot/app.py | 26 +++++++-- manual_trading_hub/exchange_orders.py | 77 +++++++++++++++++++++++++-- manual_trading_hub/hub.py | 57 ++++++++++++++++---- manual_trading_hub/static/app.js | 23 +++++++- manual_trading_hub/static/index.html | 2 +- tests/test_hub_cond_orders_dedupe.py | 32 +++++++++++ 7 files changed, 220 insertions(+), 25 deletions(-) create mode 100644 tests/test_hub_cond_orders_dedupe.py diff --git a/crypto_monitor_gate/app.py b/crypto_monitor_gate/app.py index ae0ab44..30cf68f 100644 --- a/crypto_monitor_gate/app.py +++ b/crypto_monitor_gate/app.py @@ -2958,6 +2958,8 @@ def _gate_place_tp_sl_orders_position_price_orders(exchange_symbol, direction, s } if GATE_POS_MODE == "hedge": initial["auto_size"] = "close_long" if direction == "long" else "close_short" + # Gate API 1018:auto_size=close_long|close_short 时 initial.close 须为 false + initial["close"] = False sl_s = exchange.price_to_precision(exchange_symbol, float(stop_loss)) tp_s = exchange.price_to_precision(exchange_symbol, float(take_profit)) @@ -2993,16 +2995,32 @@ def _gate_place_tp_sl_orders_position_price_orders(exchange_symbol, direction, s raise RuntimeError(f"交易所未接受仓位类条件止盈/止损:{last_err}") +def _gate_td_mode_is_cross(): + return _GATE_DEFAULT_MARGIN_MODE == "cross" + + def _gate_place_tp_sl_orders(exchange_symbol, direction, contracts_amount, stop_loss, take_profit): + pos_err = None if GATE_TPSL_USE_POSITION_ORDER: try: _gate_place_tp_sl_orders_position_price_orders(exchange_symbol, direction, stop_loss, take_profit) return - except Exception: - pass - _gate_place_tp_sl_orders_legacy_conditional( - exchange_symbol, direction, contracts_amount, stop_loss, take_profit, - ) + except Exception as e: + pos_err = e + if _gate_td_mode_is_cross(): + raise RuntimeError( + f"交易所未接受仓位类条件止盈/止损(全仓不支持 ccxt 条件单回退):{pos_err}" + ) from e + try: + _gate_place_tp_sl_orders_legacy_conditional( + exchange_symbol, direction, contracts_amount, stop_loss, take_profit, + ) + except Exception as legacy_err: + if pos_err is not None: + raise RuntimeError( + f"交易所未接受仓位类条件止盈/止损:{pos_err};条件单回退亦失败:{legacy_err}" + ) from legacy_err + raise def _gate_place_stop_loss_only_position(exchange_symbol, direction, stop_loss): diff --git a/crypto_monitor_gate_bot/app.py b/crypto_monitor_gate_bot/app.py index 73a5099..2f458d4 100644 --- a/crypto_monitor_gate_bot/app.py +++ b/crypto_monitor_gate_bot/app.py @@ -3120,16 +3120,32 @@ def _gate_place_stop_loss_only_position(exchange_symbol, direction, stop_loss): raise RuntimeError(f"交易所未接受仅止损仓位触发单:{last_err}") +def _gate_td_mode_is_cross(): + return _GATE_DEFAULT_MARGIN_MODE == "cross" + + def _gate_place_tp_sl_orders(exchange_symbol, direction, contracts_amount, stop_loss, take_profit): + pos_err = None if GATE_TPSL_USE_POSITION_ORDER: try: _gate_place_tp_sl_orders_position_price_orders(exchange_symbol, direction, stop_loss, take_profit) return - except Exception: - pass - _gate_place_tp_sl_orders_legacy_conditional( - exchange_symbol, direction, contracts_amount, stop_loss, take_profit, - ) + except Exception as e: + pos_err = e + if _gate_td_mode_is_cross(): + raise RuntimeError( + f"交易所未接受仓位类条件止盈/止损(全仓不支持 ccxt 条件单回退):{pos_err}" + ) from e + try: + _gate_place_tp_sl_orders_legacy_conditional( + exchange_symbol, direction, contracts_amount, stop_loss, take_profit, + ) + except Exception as legacy_err: + if pos_err is not None: + raise RuntimeError( + f"交易所未接受仓位类条件止盈/止损:{pos_err};条件单回退亦失败:{legacy_err}" + ) from legacy_err + raise def ensure_markets_loaded(force=False): diff --git a/manual_trading_hub/exchange_orders.py b/manual_trading_hub/exchange_orders.py index a33afd4..8178b5f 100644 --- a/manual_trading_hub/exchange_orders.py +++ b/manual_trading_hub/exchange_orders.py @@ -293,6 +293,30 @@ def _okx_list(ex: Any, symbol: str | None) -> list[dict]: return out +def _gate_extract_trigger_rule(info: dict) -> int | None: + if not isinstance(info, dict): + return None + trig = info.get("trigger") + if isinstance(trig, dict) and trig.get("rule") is not None: + try: + return int(trig["rule"]) + except (TypeError, ValueError): + pass + try: + return int(info.get("rule")) + except (TypeError, ValueError): + return None + + +def _gate_tpsl_role_from_rule(rule: int | None, direction: str) -> str | None: + if rule is None: + return None + d = (direction or "long").strip().lower() + if d == "long": + return "sl" if rule == 2 else ("tp" if rule == 1 else None) + return "sl" if rule == 1 else ("tp" if rule == 2 else None) + + def _gate_trigger_params(ex: Any) -> dict: p = {"type": "swap", "trigger": True} try: @@ -342,6 +366,10 @@ def _gate_list(ex: Any, symbol: str | None) -> list[dict]: item["type"] = item.get("type") or "trigger" n = _normalize_raw_order(item, channel="algo") if n: + info = o.get("info") if isinstance(o.get("info"), dict) else {} + rule = _gate_extract_trigger_rule(info) + if rule is not None: + n["gate_trigger_rule"] = rule out.append(n) except Exception: pass @@ -370,11 +398,33 @@ def list_open_orders(ex: Any, exchange_kind: str, symbol: str | None = None) -> return uniq +def _enrich_gate_conditional_labels(cond: list[dict], side: str) -> None: + """Gate 仓位类触发单在 ccxt 中常显示为「市价·只减仓」,按 trigger.rule 标为止盈/止损。""" + direction = (side or "long").strip().lower() + for o in cond: + if not isinstance(o, dict): + continue + if (o.get("label") or "").startswith(("止盈", "止损")): + continue + role = _gate_tpsl_role_from_rule(o.get("gate_trigger_rule"), direction) + trig = o.get("trigger_price") + if not role or trig is None: + continue + try: + trig_f = float(trig) + except (TypeError, ValueError): + continue + prefix = "止损" if role == "sl" else "止盈" + o["label"] = f"{prefix} {trig_f:g}" + + def attach_orders_to_positions(positions: list[dict], orders: list[dict]) -> None: for p in positions: sym = p.get("symbol") or "" matched = [o for o in orders if symbols_match(sym, o.get("symbol") or "")] - p["conditional_orders"] = [o for o in matched if o.get("category") == "conditional"] + cond = [o for o in matched if o.get("category") == "conditional"] + _enrich_gate_conditional_labels(cond, p.get("side") or "long") + p["conditional_orders"] = cond p["regular_orders"] = [o for o in matched if o.get("category") != "conditional"] @@ -596,6 +646,8 @@ def _gate_place_tp_sl_position( } if pos_mode in ("hedge", "dual", "double"): initial["auto_size"] = "close_long" if direction == "long" else "close_short" + # Gate API 1018:auto_size=close_long|close_short 时 initial.close 须为 false + initial["close"] = False sl_s = ex.price_to_precision(symbol, float(stop_loss)) tp_s = ex.price_to_precision(symbol, float(take_profit)) @@ -668,6 +720,11 @@ def _gate_place_tp_sl_legacy( raise RuntimeError(f"Gate 条件止盈/止损未接受:{last_err}") +def _gate_td_mode_cross() -> bool: + td = (os.getenv("GATE_TD_MODE") or "cross").strip().lower() + return td in ("cross", "cross_margin") + + def _gate_place_tp_sl( ex: Any, symbol: str, @@ -677,6 +734,7 @@ def _gate_place_tp_sl( take_profit: float, ) -> None: use_pos, exp, pt, pos_mode = _gate_tpsl_env() + pos_err: Exception | None = None if use_pos: try: _gate_place_tp_sl_position( @@ -684,9 +742,20 @@ def _gate_place_tp_sl( pos_mode=pos_mode, price_type=pt, expiration=exp, ) return - except Exception: - pass - _gate_place_tp_sl_legacy(ex, symbol, direction, amount, stop_loss, take_profit) + except Exception as e: + pos_err = e + if _gate_td_mode_cross(): + raise RuntimeError( + f"Gate 仓位类止盈/止损未接受(全仓不支持 ccxt 条件单回退):{pos_err}" + ) from e + try: + _gate_place_tp_sl_legacy(ex, symbol, direction, amount, stop_loss, take_profit) + except Exception as legacy_err: + if pos_err is not None: + raise RuntimeError( + f"Gate 仓位类止盈/止损未接受:{pos_err};条件单回退亦失败:{legacy_err}" + ) from legacy_err + raise def replace_position_tpsl( diff --git a/manual_trading_hub/hub.py b/manual_trading_hub/hub.py index 619a341..fb39a01 100644 --- a/manual_trading_hub/hub.py +++ b/manual_trading_hub/hub.py @@ -687,6 +687,53 @@ def _flask_error_from_hub_mon(hub_mon: dict | None) -> str | None: ) +def _cond_order_trigger_key(price: object) -> str | None: + if price is None or price == "": + return None + try: + return f"{float(price):.12g}" + except (TypeError, ValueError): + return None + + +def _merge_conditional_orders_no_dup( + existing: list, extra: list +) -> list: + """子代理已拉到的条件单与 Flask exchange_tpsl 合成行按触发价/订单号去重,避免 Gate 显示 4 笔实为 2 笔。""" + if not extra: + return list(existing) if existing else [] + if not existing: + return list(extra) + triggers: set[str] = set() + order_ids: set[str] = set() + out: list = [] + for row in existing: + if not isinstance(row, dict): + continue + out.append(row) + k = _cond_order_trigger_key(row.get("trigger_price")) + if k: + triggers.add(k) + oid = row.get("id") + if oid not in (None, ""): + order_ids.add(str(oid)) + for row in extra: + if not isinstance(row, dict): + continue + k = _cond_order_trigger_key(row.get("trigger_price")) + oid = row.get("id") + if k and k in triggers: + continue + if oid not in (None, "") and str(oid) in order_ids: + continue + out.append(row) + if k: + triggers.add(k) + if oid not in (None, ""): + order_ids.add(str(oid)) + return out + + def _tpsl_slots_to_conditional_orders(exchange_tpsl: dict, symbol: str) -> list[dict]: """将实例 price_snapshot 的 exchange_tpsl 转为中控条件单结构。""" out: list[dict] = [] @@ -984,15 +1031,7 @@ def _merge_flask_exchange_tpsl(agent_row: dict, snap: dict | None, hub_mon: dict p["exchange_tpsl"] = et cond = p.get("conditional_orders") or [] merged = _tpsl_slots_to_conditional_orders(et, sym) - if not cond: - p["conditional_orders"] = merged - elif merged: - labels = {str(c.get("label") or "") for c in cond if isinstance(c, dict)} - for row in merged: - lbl = str(row.get("label") or "") - if lbl and not any(lbl in x or x in lbl for x in labels): - cond.append(row) - p["conditional_orders"] = cond + p["conditional_orders"] = _merge_conditional_orders_no_dup(cond, merged) async def _fetch_exchange_flask_bundle( diff --git a/manual_trading_hub/static/app.js b/manual_trading_hub/static/app.js index bf261ec..ac96de9 100644 --- a/manual_trading_hub/static/app.js +++ b/manual_trading_hub/static/app.js @@ -896,8 +896,29 @@ return localStorage.getItem(ordersCollapseKey(exchangeId, symbol)) === "1"; } + function dedupeCondOrdersByTrigger(orders) { + const list = Array.isArray(orders) ? orders : []; + const seen = new Set(); + const out = []; + for (const o of list) { + const px = orderTriggerOrPrice(o); + const key = + px != null + ? "t:" + String(px) + : o && o.id + ? "id:" + String(o.id) + : null; + if (key && seen.has(key)) continue; + if (key) seen.add(key); + out.push(o); + } + return out; + } + function condOrdersFromPosition(pos) { - const cond = Array.isArray(pos.conditional_orders) ? pos.conditional_orders : []; + const cond = dedupeCondOrdersByTrigger( + Array.isArray(pos.conditional_orders) ? pos.conditional_orders : [] + ); if (cond.length) return cond; const et = pos.exchange_tpsl; if (!et) return []; diff --git a/manual_trading_hub/static/index.html b/manual_trading_hub/static/index.html index 7f2bd6b..6191d46 100644 --- a/manual_trading_hub/static/index.html +++ b/manual_trading_hub/static/index.html @@ -250,6 +250,6 @@
- + diff --git a/tests/test_hub_cond_orders_dedupe.py b/tests/test_hub_cond_orders_dedupe.py new file mode 100644 index 0000000..0313c08 --- /dev/null +++ b/tests/test_hub_cond_orders_dedupe.py @@ -0,0 +1,32 @@ +"""中控条件单列表:子代理与 Flask exchange_tpsl 合并去重。""" + +from manual_trading_hub.hub import _merge_conditional_orders_no_dup + + +def test_merge_skips_duplicate_trigger_prices(): + existing = [ + { + "id": "100", + "label": "市价 买入 ·只减仓", + "trigger_price": 57, + "amount": 11, + }, + { + "id": "101", + "label": "市价 买入 ·只减仓", + "trigger_price": 71, + "amount": 11, + }, + ] + extra = [ + {"id": "", "label": "止损 57", "trigger_price": 57, "amount": 11}, + {"id": "", "label": "止盈 71", "trigger_price": 71, "amount": 11}, + ] + merged = _merge_conditional_orders_no_dup(existing, extra) + assert len(merged) == 2 + assert {round(o["trigger_price"]) for o in merged} == {57, 71} + + +def test_merge_uses_extra_when_existing_empty(): + extra = [{"id": "1", "label": "止损 57", "trigger_price": 57}] + assert _merge_conditional_orders_no_dup([], extra) == extra