From 3b97a595626b8f53d4e917eb6ff1ac19c36f48e8 Mon Sep 17 00:00:00 2001 From: dekun Date: Sun, 24 May 2026 08:09:08 +0800 Subject: [PATCH] =?UTF-8?q?=E4=B8=AD=E6=8E=A7=E5=A2=9E=E5=8A=A0=E6=9D=A1?= =?UTF-8?q?=E4=BB=B6=E5=8D=95=E5=A7=94=E6=89=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- manual_trading_hub/agent.py | 62 ++++++ manual_trading_hub/exchange_orders.py | 282 +++++++++++++++++++++++++- manual_trading_hub/hub.py | 41 +++- manual_trading_hub/static/app.css | 124 +++++++++++ manual_trading_hub/static/app.js | 154 +++++++++++++- manual_trading_hub/static/index.html | 25 ++- manual_trading_hub/使用说明.md | 6 +- manual_trading_hub/常见问题.md | 22 ++ manual_trading_hub/部署文档.md | 29 +++ 9 files changed, 731 insertions(+), 14 deletions(-) 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 `${rows}
类型数量触发/价格操作
`; } + 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 @@
+ +
- + diff --git a/manual_trading_hub/使用说明.md b/manual_trading_hub/使用说明.md index 97ca489..590e435 100644 --- a/manual_trading_hub/使用说明.md +++ b/manual_trading_hub/使用说明.md @@ -177,8 +177,10 @@ curl -s http://127.0.0.1:5100/api/ping | 功能 | 说明 | |------|------| | **2×2 卡片** | 仅显示「已启用」账户;每卡含子代理持仓、浮盈、余额 | -| **条件单 / 委托** | 每个持仓下方展示交易所 **条件单**(止盈/止损等)与 **普通委托**;数据来自子代理实时拉取(币安含 Algo 通道) | -| **撤单** | 单笔「撤单」或「撤销全部条件单」;经中控转发子代理 `POST /orders/cancel`、`/orders/cancel-symbol` | +| **条件单 / 委托** | 每个持仓下方展示交易所 **条件单**(默认折叠)与 **普通委托**;数据来自子代理实时拉取(币安含 Algo 通道) | +| **撤单** | 条件单区内单笔「撤单」或「撤销全部」;经中控 `POST /api/orders/{id}/cancel`、`cancel-symbol` | +| **挂止盈止损** | 持仓行 **「委托」**:弹窗填止损/止盈价 → **先撤该合约全部条件单,再挂新 TP/SL**(币安 / OKX / Gate / Gate趋势 四所统一,逻辑与各实例 `.env` 参数一致) | +| **平仓** | 持仓行「平仓」:仅平该方向仓位(子代理市价减仓) | | **机器人单** | 来自实例 `/api/hub/monitor` 的 `order_monitors`(active),为本地监控计划,**不等于**交易所条件单 | | **关键位** | 仅 `capabilities` 含 `key` 的户;展示门控摘要(`/api/price_snapshot`) | | **趋势计划** | 仅当该户勾选 **监控趋势计划** 时展示 `trend_pullback_plans`(active) | diff --git a/manual_trading_hub/常见问题.md b/manual_trading_hub/常见问题.md index 676a11d..4f6ac41 100644 --- a/manual_trading_hub/常见问题.md +++ b/manual_trading_hub/常见问题.md @@ -116,6 +116,28 @@ curl -s http://127.0.0.1:15202/status | head -c 300 应 `ok: true` 且有 `balance_usdt`。 +### 3.3 Gate 子代理「一会正常、一会连不上」(仅 Gate 两户) + +| 现象 | 说明 | +|------|------| +| 中控 LINK 2/4,仅 Gate 红 | 本机 `15202`/`15203` 在 PM2 重启间隙连不上 | +| 日志 `$'\r': command not found` | `crypto_monitor_gate*` 的 `.env` 为 Windows CRLF | +| `curl` 有时通有时不通 | 与 Gate 外网无关,先修 CRLF 并重建 agent | + +**修复**(服务器): + +```bash +cd /opt/crypto_monitor +sed -i 's/\r$//' crypto_monitor_gate/.env crypto_monitor_gate_bot/.env +bash manual_trading_hub/scripts/fix_env_crlf.sh +cd manual_trading_hub && pm2 restart manual-agent-gate manual-agent-gate-bot +# 仍反复重启时:pm2 delete 后按 ecosystem.config.cjs 重新 start(见部署文档 §5.6) +``` + +修好后 `pm2 describe manual-agent-gate` 的 **restarts** 应不再疯涨;`pm2 flush manual-agent-gate` 可清掉旧 CRLF 日志。 + +**若子代理已绿但挂委托失败**:再查 `GATE_SOCKS_PROXY`、API 权限、止损止盈价格是否合理(与各实例策略页相同 `.env` 参数)。 + ### 3.2 有持仓但无关键位 / 趋势,或提示 Flask 404 | 原因 | 处理 | diff --git a/manual_trading_hub/部署文档.md b/manual_trading_hub/部署文档.md index 9f5e851..3f8105f 100644 --- a/manual_trading_hub/部署文档.md +++ b/manual_trading_hub/部署文档.md @@ -147,6 +147,35 @@ pm2 status # crypto_binance / crypto_gate …(各策略目录自有 ecosystem.config.cjs) ``` +### 5.6 Gate 子代理「一会能连、一会子代理不可用」(Windows `.env` 换行) + +**现象**:仅 Gate 训练 / Gate 趋势卡片红字「子代理不可用」;`pm2 logs manual-agent-gate` 反复出现: + +```text +./.env: line 22: $'\r': command not found +agent start: exchange=gate port=15202 ... +``` + +**原因**:在 Windows 编辑的 `crypto_monitor_gate/.env`、`crypto_monitor_gate_bot/.env` 为 **CRLF**,Linux 上 `source` 失败;PM2 反复重启,中控轮询时偶发连不上(**不是外网问题**)。 + +**处理**(在服务器仓库根执行): + +```bash +cd /opt/crypto_monitor +sed -i 's/\r$//' crypto_monitor_gate/.env crypto_monitor_gate_bot/.env +bash manual_trading_hub/scripts/fix_env_crlf.sh +cd manual_trading_hub +pm2 delete manual-agent-gate manual-agent-gate-bot 2>/dev/null || true +pm2 start ecosystem.config.cjs --only manual-agent-gate +pm2 start ecosystem.config.cjs --only manual-agent-gate-bot +pm2 save +curl -s http://127.0.0.1:15202/status | head -c 200 # 应 ok:true +``` + +**预防**:`.env` 保存为 **LF**;Windows 用 `deploy/setup_env.ps1` 复制 `.env.example` 时已写 LF。子代理须经 **`scripts/run_agent.sh`** 启动(内置去 CRLF 的 `load_dotenv_file`),勿裸跑 `python agent.py`。 + +详见 [常见问题.md](./常见问题.md) **§3.1**、**§3.3**。 + --- ## 六、手动启动(不用 PM2 时)