diff --git a/manual_trading_hub/agent.py b/manual_trading_hub/agent.py
index af75afc..32e21b6 100644
--- a/manual_trading_hub/agent.py
+++ b/manual_trading_hub/agent.py
@@ -35,6 +35,8 @@ from exchange_orders import (
cancel_order as hub_cancel_order,
cancel_orders_for_symbol,
list_open_orders,
+ replace_position_tpsl,
+ symbols_match,
)
HOST = os.getenv("HOST", "127.0.0.1")
@@ -299,6 +301,14 @@ class CancelSymbolOrdersBody(BaseModel):
scope: str = "all" # all | conditional | limit
+class PlaceTpslBody(BaseModel):
+ symbol: str
+ side: str # long | short
+ stop_loss: float
+ take_profit: float
+ contracts: float | None = None
+
+
def _close_position_market(
ex: Any, sym: str, side: str, contracts: float
) -> tuple[dict[str, Any] | None, str | None]:
@@ -629,6 +639,58 @@ def cancel_symbol_orders(
)
+@app.post("/orders/place-tpsl")
+def place_tpsl_orders(
+ body: PlaceTpslBody,
+ x_control_token: str | None = Header(default=None, alias="X-Control-Token"),
+):
+ """先撤该合约全部条件单,再挂止盈+止损(与四实例策略逻辑一致)。"""
+ _check_token(x_control_token)
+ sym = (body.symbol or "").strip()
+ side = (body.side or "").strip().lower()
+ if not sym or side not in ("long", "short"):
+ raise HTTPException(status_code=400, detail="symbol 与 side(long/short) 必填")
+ try:
+ sl = float(body.stop_loss)
+ tp = float(body.take_profit)
+ except (TypeError, ValueError) as e:
+ raise HTTPException(status_code=400, detail="stop_loss / take_profit 须为数字") from e
+ try:
+ ex = get_exchange()
+ _ensure_markets()
+ amt = body.contracts
+ if amt is None or float(amt) <= 0:
+ raw = ex.fetch_positions() or []
+ found = None
+ for p in raw:
+ psym = p.get("symbol") or ""
+ if not symbols_match(sym, psym):
+ continue
+ c = abs(float(p.get("contracts") or 0))
+ if c <= 0:
+ continue
+ ps = (p.get("side") or "").lower()
+ if ps and ps != side:
+ continue
+ found = c
+ break
+ if found is None:
+ return JSONResponse(
+ {"ok": False, "error": f"未找到持仓 {sym} {side}", "exchange": EXCHANGE_KIND},
+ status_code=200,
+ )
+ amt = found
+ info = replace_position_tpsl(ex, EXCHANGE_KIND, sym, side, float(amt), sl, tp)
+ return {"ok": True, "exchange": EXCHANGE_KIND, "placed": info}
+ except HTTPException:
+ raise
+ except Exception as e:
+ return JSONResponse(
+ {"ok": False, "error": str(e), "exchange": EXCHANGE_KIND},
+ status_code=200,
+ )
+
+
@app.post("/emergency/close-all")
def emergency_close_all(x_control_token: str | None = Header(default=None, alias="X-Control-Token")):
_check_token(x_control_token)
diff --git a/manual_trading_hub/exchange_orders.py b/manual_trading_hub/exchange_orders.py
index 03642f1..090e81e 100644
--- a/manual_trading_hub/exchange_orders.py
+++ b/manual_trading_hub/exchange_orders.py
@@ -1,8 +1,10 @@
"""
-中控子代理:拉取交易所挂单/条件单并规范化展示;撤销单笔或按合约批量撤销。
+中控子代理:拉取交易所挂单/条件单并规范化展示;撤销单笔或按合约批量撤销;挂止盈止损(先撤条件单再挂)。
"""
from __future__ import annotations
+import os
+import time
from typing import Any
@@ -353,3 +355,281 @@ def cancel_orders_for_symbol(
except Exception:
pass
return n
+
+
+def _binance_cancel_algo_open(ex: Any, symbol: str) -> None:
+ try:
+ market = ex.market(symbol)
+ cid = market.get("id")
+ if cid and hasattr(ex, "fapiPrivateDeleteAlgoOpenOrders"):
+ ex.fapiPrivateDeleteAlgoOpenOrders({"symbol": cid})
+ except Exception:
+ pass
+
+
+def _binance_trigger_params() -> dict[str, Any]:
+ wt = (os.getenv("BINANCE_TRIGGER_WORKING_TYPE") or "CONTRACT_PRICE").strip().upper()
+ if wt not in ("CONTRACT_PRICE", "MARK_PRICE"):
+ wt = "CONTRACT_PRICE"
+ return {"workingType": wt}
+
+
+def _binance_place_tp_sl(
+ ex: Any,
+ symbol: str,
+ direction: str,
+ amount: float,
+ stop_loss: float,
+ take_profit: float,
+ *,
+ position_mode: str = "hedge",
+) -> None:
+ ex.load_markets()
+ market = ex.market(symbol)
+ if not market.get("swap"):
+ raise RuntimeError("仅支持永续合约")
+ close_side = "sell" if direction == "long" else "buy"
+ amt = float(ex.amount_to_precision(symbol, float(amount)))
+ if amt <= 0:
+ raise RuntimeError("止盈止损:可平数量经精度舍入后为 0")
+ sl_px = ex.price_to_precision(symbol, float(stop_loss))
+ tp_px = ex.price_to_precision(symbol, float(take_profit))
+ common = dict(_binance_trigger_params())
+ if (position_mode or "hedge").lower() in ("hedge", "dual", "double", "hedged"):
+ common["positionSide"] = "LONG" if direction == "long" else "SHORT"
+ last_err: Exception | None = None
+ for attempt in range(6):
+ try:
+ ex.create_order(
+ symbol, "STOP_MARKET", close_side, amt, None, dict(common, stopPrice=sl_px)
+ )
+ time.sleep(0.05)
+ ex.create_order(
+ symbol,
+ "TAKE_PROFIT_MARKET",
+ close_side,
+ amt,
+ None,
+ dict(common, stopPrice=tp_px),
+ )
+ return
+ except Exception as e:
+ last_err = e
+ cancel_orders_for_symbol(ex, "binance", symbol, scope="conditional")
+ _binance_cancel_algo_open(ex, symbol)
+ time.sleep(0.2 * (attempt + 1))
+ raise RuntimeError(f"Binance 未接受止盈/止损:{last_err}")
+
+
+def _okx_order_params(direction: str, *, reduce_only: bool, pos_mode: str, td_mode: str) -> dict:
+ params: dict[str, Any] = {"tdMode": td_mode or "cross"}
+ if (pos_mode or "hedge").lower() in ("hedge", "long_short_mode", "dual"):
+ params["posSide"] = "long" if direction == "long" else "short"
+ if reduce_only:
+ params["reduceOnly"] = True
+ return params
+
+
+def _okx_place_tp_sl(
+ ex: Any,
+ symbol: str,
+ direction: str,
+ amount: float,
+ stop_loss: float,
+ take_profit: float,
+ *,
+ pos_mode: str = "hedge",
+ td_mode: str = "cross",
+) -> None:
+ ex.load_markets()
+ close_side = "sell" if direction == "long" else "buy"
+ amt = float(ex.amount_to_precision(symbol, float(amount)))
+ if amt <= 0:
+ raise RuntimeError("止盈止损:可平数量经精度舍入后为 0")
+ params = _okx_order_params(direction, reduce_only=True, pos_mode=pos_mode, td_mode=td_mode)
+ sl_s = ex.price_to_precision(symbol, float(stop_loss))
+ tp_s = ex.price_to_precision(symbol, float(take_profit))
+ params["stopLoss"] = {"triggerPrice": sl_s, "type": "market"}
+ params["takeProfit"] = {"triggerPrice": tp_s, "type": "market"}
+ ex.create_order(symbol, "market", close_side, amt, None, params)
+
+
+def _gate_tpsl_env() -> tuple[bool, int, int, str]:
+ use_pos = (os.getenv("GATE_TPSL_USE_POSITION_ORDER") or "true").lower() in ("1", "true", "yes")
+ exp = int(os.getenv("GATE_TPSL_TRIGGER_EXPIRATION", str(7 * 86400)))
+ pt = int(os.getenv("GATE_TPSL_PRICE_TYPE", "0"))
+ if pt < 0 or pt > 2:
+ pt = 0
+ pos_mode = (os.getenv("GATE_POS_MODE") or "hedge").strip().lower()
+ return use_pos, exp, pt, pos_mode
+
+
+def _gate_place_tp_sl_position(
+ ex: Any,
+ symbol: str,
+ direction: str,
+ stop_loss: float,
+ take_profit: float,
+ *,
+ pos_mode: str,
+ price_type: int,
+ expiration: int,
+) -> None:
+ ex.load_markets()
+ market = ex.market(symbol)
+ if not market.get("swap"):
+ raise RuntimeError("仅支持永续合约")
+ settle = market["settleId"]
+ contract = market["id"]
+ order_type = "close-long-position" if direction == "long" else "close-short-position"
+ close_side = "sell" if direction == "long" else "buy"
+ sl_rule, tp_rule = (2, 1) if close_side == "sell" else (1, 2)
+ initial: dict[str, Any] = {
+ "contract": contract,
+ "size": 0,
+ "price": "0",
+ "close": True,
+ "reduce_only": True,
+ "tif": "ioc",
+ "text": "api",
+ }
+ if pos_mode in ("hedge", "dual", "double"):
+ initial["auto_size"] = "close_long" if direction == "long" else "close_short"
+ sl_s = ex.price_to_precision(symbol, float(stop_loss))
+ tp_s = ex.price_to_precision(symbol, float(take_profit))
+
+ def _payload(trigger_price: str, rule: int) -> dict:
+ trig: dict[str, Any] = {
+ "strategy_type": 0,
+ "price_type": price_type,
+ "price": trigger_price,
+ "rule": rule,
+ }
+ if expiration > 0:
+ trig["expiration"] = expiration
+ return {
+ "settle": settle,
+ "initial": dict(initial),
+ "trigger": trig,
+ "order_type": order_type,
+ }
+
+ last_err: Exception | None = None
+ for attempt in range(6):
+ try:
+ ex.privateFuturesPostSettlePriceOrders(_payload(sl_s, sl_rule))
+ try:
+ ex.privateFuturesPostSettlePriceOrders(_payload(tp_s, tp_rule))
+ except Exception:
+ cancel_orders_for_symbol(ex, "gate", symbol, scope="conditional")
+ raise
+ return
+ except Exception as e:
+ last_err = e
+ time.sleep(0.2 * (attempt + 1))
+ raise RuntimeError(f"Gate 仓位类止盈/止损未接受:{last_err}")
+
+
+def _gate_place_tp_sl_legacy(
+ ex: Any,
+ symbol: str,
+ direction: str,
+ amount: float,
+ stop_loss: float,
+ take_profit: float,
+) -> None:
+ ex.load_markets()
+ close_side = "sell" if direction == "long" else "buy"
+ base = {"reduceOnly": True}
+ last_err: Exception | None = None
+ for attempt in range(6):
+ try:
+ ex.create_order(
+ symbol,
+ "market",
+ close_side,
+ amount,
+ None,
+ dict(base, stopLossPrice=float(stop_loss)),
+ )
+ ex.create_order(
+ symbol,
+ "market",
+ close_side,
+ amount,
+ None,
+ dict(base, takeProfitPrice=float(take_profit)),
+ )
+ return
+ except Exception as e:
+ last_err = e
+ time.sleep(0.2 * (attempt + 1))
+ raise RuntimeError(f"Gate 条件止盈/止损未接受:{last_err}")
+
+
+def _gate_place_tp_sl(
+ ex: Any,
+ symbol: str,
+ direction: str,
+ amount: float,
+ stop_loss: float,
+ take_profit: float,
+) -> None:
+ use_pos, exp, pt, pos_mode = _gate_tpsl_env()
+ if use_pos:
+ try:
+ _gate_place_tp_sl_position(
+ ex, symbol, direction, stop_loss, take_profit,
+ 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)
+
+
+def replace_position_tpsl(
+ ex: Any,
+ exchange_kind: str,
+ symbol: str,
+ direction: str,
+ amount: float,
+ stop_loss: float,
+ take_profit: float,
+) -> dict[str, Any]:
+ """
+ 先撤销该合约全部条件单,再挂止盈+止损。与四实例策略页逻辑对齐(读各目录 .env 中 GATE_/BINANCE_/OKX_ 参数)。
+ """
+ kind = (exchange_kind or "binance").lower()
+ direction = (direction or "long").strip().lower()
+ if direction not in ("long", "short"):
+ raise ValueError("direction 须为 long 或 short")
+ sl = float(stop_loss)
+ tp = float(take_profit)
+ if sl <= 0 or tp <= 0:
+ raise ValueError("止损、止盈价格须大于 0")
+ ex.load_markets()
+ cancelled = cancel_orders_for_symbol(ex, kind, symbol, scope="conditional")
+ if kind == "binance":
+ _binance_cancel_algo_open(ex, symbol)
+ time.sleep(0.08)
+ amt = float(amount)
+ if amt <= 0:
+ raise ValueError("持仓数量无效")
+ if kind == "binance":
+ pm = (os.getenv("BINANCE_POSITION_MODE") or "hedge").strip().lower()
+ _binance_place_tp_sl(ex, symbol, direction, amt, sl, tp, position_mode=pm)
+ elif kind == "okx":
+ pm = (os.getenv("OKX_POS_MODE") or "hedge").strip().lower()
+ td = (os.getenv("OKX_TD_MODE") or "cross").strip()
+ _okx_place_tp_sl(ex, symbol, direction, amt, sl, tp, pos_mode=pm, td_mode=td)
+ else:
+ _gate_place_tp_sl(ex, symbol, direction, amt, sl, tp)
+ return {
+ "symbol": symbol,
+ "direction": direction,
+ "amount": amt,
+ "stop_loss": sl,
+ "take_profit": tp,
+ "cancelled_conditional": cancelled,
+ }
diff --git a/manual_trading_hub/hub.py b/manual_trading_hub/hub.py
index 227daee..71329c8 100644
--- a/manual_trading_hub/hub.py
+++ b/manual_trading_hub/hub.py
@@ -43,7 +43,7 @@ HUB_BRIDGE_TOKEN = (os.getenv("HUB_BRIDGE_TOKEN") or os.getenv("CONTROL_TOKEN")
_trust_raw = (os.getenv("HUB_TRUST_LAN", "true") or "").strip().lower()
HUB_TRUST_LAN = _trust_raw not in ("0", "false", "no", "off")
DIR = Path(__file__).resolve().parent
-HUB_BUILD = "20260524-open-orders"
+HUB_BUILD = "20260525-tpsl-ui"
def _is_local(host: str | None) -> bool:
@@ -401,6 +401,14 @@ class CancelSymbolOrdersBody(BaseModel):
scope: str = "all"
+class PlaceTpslBody(BaseModel):
+ symbol: str
+ side: str
+ stop_loss: float
+ take_profit: float
+ contracts: float | None = None
+
+
@app.post("/api/orders/{exchange_id}/cancel")
async def api_cancel_order(exchange_id: str, body: CancelOrderBody):
ex = _find_exchange(exchange_id)
@@ -486,6 +494,37 @@ async def api_close_position(exchange_id: str, body: ClosePositionBody):
}
+@app.post("/api/orders/{exchange_id}/place-tpsl")
+async def api_place_tpsl(exchange_id: str, body: PlaceTpslBody):
+ ex = _find_exchange(exchange_id)
+ if not ex or not ex.get("enabled"):
+ raise HTTPException(status_code=404, detail="账户未启用")
+ url = f"{ex['agent_url'].rstrip('/')}/orders/place-tpsl"
+ async with httpx.AsyncClient() as client:
+ r = await client.post(
+ url,
+ headers=_agent_headers(),
+ json={
+ "symbol": body.symbol,
+ "side": body.side,
+ "stop_loss": body.stop_loss,
+ "take_profit": body.take_profit,
+ "contracts": body.contracts,
+ },
+ timeout=120.0,
+ )
+ try:
+ payload = r.json()
+ except Exception:
+ payload = {"raw": (r.text or "")[:2000]}
+ return {
+ "exchange": ex,
+ "status_code": r.status_code,
+ "payload": payload,
+ "ok": bool(isinstance(payload, dict) and payload.get("ok")),
+ }
+
+
@app.post("/api/close/{exchange_id}")
async def api_close_exchange(exchange_id: str):
ex = _find_exchange(exchange_id)
diff --git a/manual_trading_hub/static/app.css b/manual_trading_hub/static/app.css
index a0fac71..10bc095 100644
--- a/manual_trading_hub/static/app.css
+++ b/manual_trading_hub/static/app.css
@@ -594,6 +594,130 @@ button:disabled {
padding: 6px 4px 8px;
}
+.td-actions-row {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ justify-content: flex-end;
+}
+
+.orders-collapse {
+ margin-bottom: 8px;
+}
+
+.orders-collapse-summary {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ cursor: pointer;
+ list-style: none;
+ padding: 4px 0;
+ user-select: none;
+}
+
+.orders-collapse-summary::-webkit-details-marker {
+ display: none;
+}
+
+.orders-collapse-summary::before {
+ content: "▸";
+ color: var(--muted);
+ margin-right: 6px;
+ font-size: 10px;
+ transition: transform 0.15s ease;
+}
+
+.orders-collapse[open] > .orders-collapse-summary::before {
+ transform: rotate(90deg);
+}
+
+.orders-collapse-body {
+ margin-top: 6px;
+}
+
+.modal {
+ position: fixed;
+ inset: 0;
+ z-index: 200;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 16px;
+}
+
+.modal.hidden {
+ display: none;
+}
+
+.modal-backdrop {
+ position: absolute;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.65);
+}
+
+.modal-panel {
+ position: relative;
+ z-index: 1;
+ width: 100%;
+ max-width: 380px;
+ padding: 20px 22px;
+ background: var(--bg-elevated);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ box-shadow: var(--shadow);
+}
+
+.modal-panel h3 {
+ margin: 0 0 8px;
+ font-family: var(--display);
+ font-size: 14px;
+ letter-spacing: 0.06em;
+}
+
+.modal-meta {
+ margin: 0 0 14px;
+ font-size: 12px;
+ color: var(--muted);
+}
+
+.modal-field {
+ margin-bottom: 12px;
+}
+
+.modal-field label {
+ display: block;
+ font-size: 10px;
+ color: var(--muted);
+ margin-bottom: 4px;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+}
+
+.modal-field input {
+ width: 100%;
+ padding: 8px 10px;
+ background: rgba(0, 0, 0, 0.35);
+ border: 1px solid var(--border-soft);
+ border-radius: 6px;
+ color: var(--text);
+ font-family: var(--font);
+ font-size: 13px;
+}
+
+.modal-hint {
+ font-size: 11px;
+ color: var(--muted);
+ margin: 0 0 14px;
+ line-height: 1.5;
+}
+
+.modal-actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 8px;
+}
+
.data-table {
width: 100%;
border-collapse: collapse;
diff --git a/manual_trading_hub/static/app.js b/manual_trading_hub/static/app.js
index bd8d16a..fd0b628 100644
--- a/manual_trading_hub/static/app.js
+++ b/manual_trading_hub/static/app.js
@@ -3,6 +3,7 @@
let settingsCache = null;
let monitorTimer = null;
let authState = { required: false, logged_in: true };
+ let tpslPending = null;
async function apiFetch(url, opts) {
const r = await fetch(url, opts);
@@ -146,8 +147,22 @@
);
});
box.querySelectorAll(".btn-cancel-cond-all").forEach((btn) => {
- btn.onclick = () =>
+ btn.onclick = (ev) => {
+ ev.preventDefault();
+ ev.stopPropagation();
cancelSymbolOrders(btn.dataset.exId, btn.dataset.symbol, "conditional");
+ };
+ });
+ box.querySelectorAll(".btn-place-tpsl").forEach((btn) => {
+ btn.onclick = () =>
+ openTpslModal(
+ btn.dataset.exId,
+ btn.dataset.symbol,
+ btn.dataset.side,
+ btn.dataset.contracts,
+ btn.dataset.sl || "",
+ btn.dataset.tp || ""
+ );
});
} catch (e) {
box.innerHTML = `
${esc(e)}
`;
@@ -180,15 +195,34 @@
return ``;
}
+ function guessTpslFromCondOrders(side, cond) {
+ const triggers = (cond || [])
+ .map((o) => o.trigger_price)
+ .filter((v) => v != null && !Number.isNaN(Number(v)))
+ .map(Number);
+ if (!triggers.length) return { sl: "", tp: "" };
+ triggers.sort((a, b) => a - b);
+ const s = (side || "long").toLowerCase();
+ if (s === "short") {
+ return { sl: triggers[triggers.length - 1], tp: triggers[0] };
+ }
+ return { sl: triggers[0], tp: triggers[triggers.length - 1] };
+ }
+
function renderPositionBlock(exchangeId, x) {
const symAttr = esc(x.symbol || "").replace(/"/g, """);
const sideAttr = esc((x.side || "").toLowerCase()).replace(/"/g, """);
+ const contractsAttr = esc(String(x.contracts != null ? x.contracts : "")).replace(/"/g, """);
const cond = Array.isArray(x.conditional_orders) ? x.conditional_orders : [];
const reg = Array.isArray(x.regular_orders) ? x.regular_orders : [];
+ const guess = guessTpslFromCondOrders(x.side, cond);
+ const slAttr = esc(String(guess.sl)).replace(/"/g, """);
+ const tpAttr = esc(String(guess.tp)).replace(/"/g, """);
const condAllBtn =
cond.length > 0
- ? ``
+ ? ``
: "";
+ const condBody = renderOrderRows(exchangeId, x.symbol, cond, "conditional");
return `
| 合约 | 方向 | 张数 | 浮盈 | 操作 |
@@ -196,15 +230,20 @@
| ${esc(x.side)} |
${fmt(x.contracts, 4)} |
${fmt(x.unrealized_pnl, 4)} |
- |
+
+
+
+ |
-
- 条件单 · ${cond.length}
- ${condAllBtn}
-
- ${renderOrderRows(exchangeId, x.symbol, cond, "conditional")}
+
+
+ 条件单 · ${cond.length}
+ ${condAllBtn}
+
+ ${condBody}
+
普通委托 · ${reg.length}
@@ -213,6 +252,103 @@
`;
}
+ function openTpslModal(exchangeId, symbol, side, contracts, slHint, tpHint) {
+ tpslPending = {
+ exchangeId,
+ symbol,
+ side: (side || "long").toLowerCase(),
+ contracts: parseFloat(contracts),
+ };
+ const modal = document.getElementById("tpsl-modal");
+ const meta = document.getElementById("tpsl-modal-meta");
+ const slIn = document.getElementById("tpsl-sl");
+ const tpIn = document.getElementById("tpsl-tp");
+ if (!modal || !meta || !slIn || !tpIn) return;
+ meta.textContent = `${symbol} · ${side} · ${contracts} 张`;
+ slIn.value = slHint !== "" && slHint != null ? String(slHint) : "";
+ tpIn.value = tpHint !== "" && tpHint != null ? String(tpHint) : "";
+ modal.classList.remove("hidden");
+ modal.setAttribute("aria-hidden", "false");
+ slIn.focus();
+ }
+
+ function closeTpslModal() {
+ tpslPending = null;
+ const modal = document.getElementById("tpsl-modal");
+ if (modal) {
+ modal.classList.add("hidden");
+ modal.setAttribute("aria-hidden", "true");
+ }
+ }
+
+ async function submitTpslModal() {
+ if (!tpslPending) return;
+ const slIn = document.getElementById("tpsl-sl");
+ const tpIn = document.getElementById("tpsl-tp");
+ const sl = parseFloat(slIn && slIn.value);
+ const tp = parseFloat(tpIn && tpIn.value);
+ if (!sl || sl <= 0 || !tp || tp <= 0) {
+ showToast("请填写有效的止损价与止盈价", true);
+ return;
+ }
+ const { exchangeId, symbol, side, contracts } = tpslPending;
+ if (
+ !confirm(
+ `确认 ${symbol} ${side}\n先撤销全部条件单,再挂止损 ${sl}、止盈 ${tp}?`
+ )
+ ) {
+ return;
+ }
+ const btn = document.getElementById("tpsl-submit");
+ if (btn) btn.disabled = true;
+ try {
+ const r = await apiFetch(
+ "/api/orders/" + encodeURIComponent(exchangeId) + "/place-tpsl",
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ symbol,
+ side,
+ stop_loss: sl,
+ take_profit: tp,
+ contracts: contracts > 0 ? contracts : null,
+ }),
+ }
+ );
+ const j = await r.json();
+ const pl = j.payload || {};
+ const ok = j.ok && pl.ok !== false;
+ const n = pl.placed && pl.placed.cancelled_conditional;
+ showToast(
+ ok
+ ? `已挂单(已撤 ${n != null ? n : "?"} 笔旧条件单)`
+ : pl.error || JSON.stringify(j),
+ !ok
+ );
+ if (ok) {
+ closeTpslModal();
+ loadMonitorBoard();
+ }
+ } catch (e) {
+ showToast(String(e), true);
+ } finally {
+ if (btn) btn.disabled = false;
+ }
+ }
+
+ function initTpslModal() {
+ const backdrop = document.getElementById("tpsl-modal-backdrop");
+ const cancel = document.getElementById("tpsl-cancel");
+ const submit = document.getElementById("tpsl-submit");
+ if (backdrop) backdrop.onclick = closeTpslModal;
+ if (cancel) cancel.onclick = closeTpslModal;
+ if (submit) submit.onclick = () => submitTpslModal();
+ document.addEventListener("keydown", (ev) => {
+ if (ev.key === "Escape") closeTpslModal();
+ });
+ }
+
async function cancelOneOrder(exchangeId, symbol, orderId, channel) {
if (!confirm(`撤销委托 ${symbol} #${orderId}?`)) return;
try {
@@ -557,6 +693,8 @@
showToast("已添加一行,请填写 URL 后点「保存设置」");
};
+ initTpslModal();
+
initAuth().then((ok) => {
if (!ok) return;
loadSettings().catch(() => {});
diff --git a/manual_trading_hub/static/index.html b/manual_trading_hub/static/index.html
index 556fb3a..ac6bab1 100644
--- a/manual_trading_hub/static/index.html
+++ b/manual_trading_hub/static/index.html
@@ -7,7 +7,7 @@
-
+
@@ -79,7 +79,28 @@
+
+
+
+
挂止盈 / 止损
+
+
+
+
+
+
+
+
+
+
先撤销该合约全部条件单,再挂新止盈与止损(四所统一)。
+
+
+
+
+
+
+
-
+