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 <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-04 19:48:04 +08:00
parent ed0805538f
commit 24270944e7
7 changed files with 220 additions and 25 deletions
+20 -2
View File
@@ -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 1018auto_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
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):
+18 -2
View File
@@ -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
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):
+72 -3
View File
@@ -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 1018auto_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
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(
+48 -9
View File
@@ -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(
+22 -1
View File
@@ -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 [];
+1 -1
View File
@@ -250,6 +250,6 @@
<div id="toast"></div>
<script src="https://unpkg.com/lightweight-charts@4.2.0/dist/lightweight-charts.standalone.production.js"></script>
<script src="/assets/chart.js?v=20260604-market-pnl-sl-drag"></script>
<script src="/assets/app.js?v=20260604-hub-handoff-tpsl"></script>
<script src="/assets/app.js?v=20260604-hub-cond-dedupe"></script>
</body>
</html>
+32
View File
@@ -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