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:
@@ -2958,6 +2958,8 @@ def _gate_place_tp_sl_orders_position_price_orders(exchange_symbol, direction, s
|
|||||||
}
|
}
|
||||||
if GATE_POS_MODE == "hedge":
|
if GATE_POS_MODE == "hedge":
|
||||||
initial["auto_size"] = "close_long" if direction == "long" else "close_short"
|
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))
|
sl_s = exchange.price_to_precision(exchange_symbol, float(stop_loss))
|
||||||
tp_s = exchange.price_to_precision(exchange_symbol, float(take_profit))
|
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}")
|
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):
|
def _gate_place_tp_sl_orders(exchange_symbol, direction, contracts_amount, stop_loss, take_profit):
|
||||||
|
pos_err = None
|
||||||
if GATE_TPSL_USE_POSITION_ORDER:
|
if GATE_TPSL_USE_POSITION_ORDER:
|
||||||
try:
|
try:
|
||||||
_gate_place_tp_sl_orders_position_price_orders(exchange_symbol, direction, stop_loss, take_profit)
|
_gate_place_tp_sl_orders_position_price_orders(exchange_symbol, direction, stop_loss, take_profit)
|
||||||
return
|
return
|
||||||
except Exception:
|
except Exception as e:
|
||||||
pass
|
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(
|
_gate_place_tp_sl_orders_legacy_conditional(
|
||||||
exchange_symbol, direction, contracts_amount, stop_loss, take_profit,
|
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):
|
def _gate_place_stop_loss_only_position(exchange_symbol, direction, stop_loss):
|
||||||
|
|||||||
@@ -3120,16 +3120,32 @@ def _gate_place_stop_loss_only_position(exchange_symbol, direction, stop_loss):
|
|||||||
raise RuntimeError(f"交易所未接受仅止损仓位触发单:{last_err}")
|
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):
|
def _gate_place_tp_sl_orders(exchange_symbol, direction, contracts_amount, stop_loss, take_profit):
|
||||||
|
pos_err = None
|
||||||
if GATE_TPSL_USE_POSITION_ORDER:
|
if GATE_TPSL_USE_POSITION_ORDER:
|
||||||
try:
|
try:
|
||||||
_gate_place_tp_sl_orders_position_price_orders(exchange_symbol, direction, stop_loss, take_profit)
|
_gate_place_tp_sl_orders_position_price_orders(exchange_symbol, direction, stop_loss, take_profit)
|
||||||
return
|
return
|
||||||
except Exception:
|
except Exception as e:
|
||||||
pass
|
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(
|
_gate_place_tp_sl_orders_legacy_conditional(
|
||||||
exchange_symbol, direction, contracts_amount, stop_loss, take_profit,
|
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):
|
def ensure_markets_loaded(force=False):
|
||||||
|
|||||||
@@ -293,6 +293,30 @@ def _okx_list(ex: Any, symbol: str | None) -> list[dict]:
|
|||||||
return out
|
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:
|
def _gate_trigger_params(ex: Any) -> dict:
|
||||||
p = {"type": "swap", "trigger": True}
|
p = {"type": "swap", "trigger": True}
|
||||||
try:
|
try:
|
||||||
@@ -342,6 +366,10 @@ def _gate_list(ex: Any, symbol: str | None) -> list[dict]:
|
|||||||
item["type"] = item.get("type") or "trigger"
|
item["type"] = item.get("type") or "trigger"
|
||||||
n = _normalize_raw_order(item, channel="algo")
|
n = _normalize_raw_order(item, channel="algo")
|
||||||
if n:
|
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)
|
out.append(n)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
@@ -370,11 +398,33 @@ def list_open_orders(ex: Any, exchange_kind: str, symbol: str | None = None) ->
|
|||||||
return uniq
|
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:
|
def attach_orders_to_positions(positions: list[dict], orders: list[dict]) -> None:
|
||||||
for p in positions:
|
for p in positions:
|
||||||
sym = p.get("symbol") or ""
|
sym = p.get("symbol") or ""
|
||||||
matched = [o for o in orders if symbols_match(sym, o.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"]
|
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"):
|
if pos_mode in ("hedge", "dual", "double"):
|
||||||
initial["auto_size"] = "close_long" if direction == "long" else "close_short"
|
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))
|
sl_s = ex.price_to_precision(symbol, float(stop_loss))
|
||||||
tp_s = ex.price_to_precision(symbol, float(take_profit))
|
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}")
|
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(
|
def _gate_place_tp_sl(
|
||||||
ex: Any,
|
ex: Any,
|
||||||
symbol: str,
|
symbol: str,
|
||||||
@@ -677,6 +734,7 @@ def _gate_place_tp_sl(
|
|||||||
take_profit: float,
|
take_profit: float,
|
||||||
) -> None:
|
) -> None:
|
||||||
use_pos, exp, pt, pos_mode = _gate_tpsl_env()
|
use_pos, exp, pt, pos_mode = _gate_tpsl_env()
|
||||||
|
pos_err: Exception | None = None
|
||||||
if use_pos:
|
if use_pos:
|
||||||
try:
|
try:
|
||||||
_gate_place_tp_sl_position(
|
_gate_place_tp_sl_position(
|
||||||
@@ -684,9 +742,20 @@ def _gate_place_tp_sl(
|
|||||||
pos_mode=pos_mode, price_type=pt, expiration=exp,
|
pos_mode=pos_mode, price_type=pt, expiration=exp,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
except Exception:
|
except Exception as e:
|
||||||
pass
|
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)
|
_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(
|
def replace_position_tpsl(
|
||||||
|
|||||||
@@ -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]:
|
def _tpsl_slots_to_conditional_orders(exchange_tpsl: dict, symbol: str) -> list[dict]:
|
||||||
"""将实例 price_snapshot 的 exchange_tpsl 转为中控条件单结构。"""
|
"""将实例 price_snapshot 的 exchange_tpsl 转为中控条件单结构。"""
|
||||||
out: list[dict] = []
|
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
|
p["exchange_tpsl"] = et
|
||||||
cond = p.get("conditional_orders") or []
|
cond = p.get("conditional_orders") or []
|
||||||
merged = _tpsl_slots_to_conditional_orders(et, sym)
|
merged = _tpsl_slots_to_conditional_orders(et, sym)
|
||||||
if not cond:
|
p["conditional_orders"] = _merge_conditional_orders_no_dup(cond, merged)
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
async def _fetch_exchange_flask_bundle(
|
async def _fetch_exchange_flask_bundle(
|
||||||
|
|||||||
@@ -896,8 +896,29 @@
|
|||||||
return localStorage.getItem(ordersCollapseKey(exchangeId, symbol)) === "1";
|
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) {
|
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;
|
if (cond.length) return cond;
|
||||||
const et = pos.exchange_tpsl;
|
const et = pos.exchange_tpsl;
|
||||||
if (!et) return [];
|
if (!et) return [];
|
||||||
|
|||||||
@@ -250,6 +250,6 @@
|
|||||||
<div id="toast"></div>
|
<div id="toast"></div>
|
||||||
<script src="https://unpkg.com/lightweight-charts@4.2.0/dist/lightweight-charts.standalone.production.js"></script>
|
<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/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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user