diff --git a/manual_trading_hub/agent.py b/manual_trading_hub/agent.py index 6b406a8..af75afc 100644 --- a/manual_trading_hub/agent.py +++ b/manual_trading_hub/agent.py @@ -1,5 +1,5 @@ """ -子账户极轻代理:GET /status、POST /emergency/close-all、POST /emergency/close-position,仅监听 127.0.0.1。 +子账户极轻代理:GET /status、挂单/条件单查询与撤销、POST /emergency/close-all、POST /emergency/close-position,仅监听 127.0.0.1。 与仓库内四个策略/监控目录一一对应时,典型用法(各目录自己的 .env 里已有密钥;子代理用环境变量 PORT,勿与 Flask 的 APP_PORT 相同): EXCHANGE=binance → crypto_monitor_binance(BINANCE_*) @@ -30,6 +30,13 @@ from fastapi import FastAPI, Header, HTTPException, Request from fastapi.responses import JSONResponse from pydantic import BaseModel +from exchange_orders import ( + attach_orders_to_positions, + cancel_order as hub_cancel_order, + cancel_orders_for_symbol, + list_open_orders, +) + HOST = os.getenv("HOST", "127.0.0.1") PORT = int(os.getenv("PORT", "15200")) CONTROL_TOKEN = (os.getenv("CONTROL_TOKEN") or "").strip() @@ -281,6 +288,17 @@ class EmergencyClosePositionBody(BaseModel): side: str +class CancelOrderBody(BaseModel): + symbol: str + order_id: str + channel: str = "regular" + + +class CancelSymbolOrdersBody(BaseModel): + symbol: str + scope: str = "all" # all | conditional | limit + + def _close_position_market( ex: Any, sym: str, side: str, contracts: float ) -> tuple[dict[str, Any] | None, str | None]: @@ -522,6 +540,16 @@ def _status_inner(x_control_token: str | None) -> Any: } ) + try: + attach_orders_to_positions( + positions_out, + list_open_orders(ex, EXCHANGE_KIND, None), + ) + except Exception: + for p in positions_out: + p.setdefault("conditional_orders", []) + p.setdefault("regular_orders", []) + try: pm = _position_mode_label() except Exception: @@ -536,6 +564,71 @@ def _status_inner(x_control_token: str | None) -> Any: } +@app.get("/open-orders") +def open_orders( + symbol: str = "", + x_control_token: str | None = Header(default=None, alias="X-Control-Token"), +): + _check_token(x_control_token) + try: + ex = get_exchange() + _ensure_markets() + sym = (symbol or "").strip() or None + orders = list_open_orders(ex, EXCHANGE_KIND, sym) + return {"ok": True, "exchange": EXCHANGE_KIND, "symbol": sym, "orders": orders} + except Exception as e: + return JSONResponse( + {"ok": False, "error": str(e), "exchange": EXCHANGE_KIND, "orders": []}, + status_code=200, + ) + + +@app.post("/orders/cancel") +def cancel_one_order( + body: CancelOrderBody, + x_control_token: str | None = Header(default=None, alias="X-Control-Token"), +): + _check_token(x_control_token) + sym = (body.symbol or "").strip() + oid = (body.order_id or "").strip() + if not sym or not oid: + raise HTTPException(status_code=400, detail="symbol 与 order_id 必填") + try: + ex = get_exchange() + _ensure_markets() + hub_cancel_order(ex, EXCHANGE_KIND, sym, oid, body.channel or "regular") + return {"ok": True, "exchange": EXCHANGE_KIND, "cancelled": {"symbol": sym, "order_id": oid}} + except Exception as e: + return JSONResponse( + {"ok": False, "error": str(e), "exchange": EXCHANGE_KIND}, + status_code=200, + ) + + +@app.post("/orders/cancel-symbol") +def cancel_symbol_orders( + body: CancelSymbolOrdersBody, + x_control_token: str | None = Header(default=None, alias="X-Control-Token"), +): + _check_token(x_control_token) + sym = (body.symbol or "").strip() + if not sym: + raise HTTPException(status_code=400, detail="symbol 必填") + scope = (body.scope or "all").strip().lower() + if scope not in ("all", "conditional", "limit"): + raise HTTPException(status_code=400, detail="scope 须为 all / conditional / limit") + try: + ex = get_exchange() + _ensure_markets() + n = cancel_orders_for_symbol(ex, EXCHANGE_KIND, sym, scope=scope) + return {"ok": True, "exchange": EXCHANGE_KIND, "cancelled_count": n, "scope": scope} + except Exception as e: + return JSONResponse( + {"ok": False, "error": str(e), "exchange": EXCHANGE_KIND, "cancelled_count": 0}, + 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 new file mode 100644 index 0000000..03642f1 --- /dev/null +++ b/manual_trading_hub/exchange_orders.py @@ -0,0 +1,355 @@ +""" +中控子代理:拉取交易所挂单/条件单并规范化展示;撤销单笔或按合约批量撤销。 +""" +from __future__ import annotations + +from typing import Any + + +def _coerce_float(*values) -> float | None: + for v in values: + if v is None or v == "": + continue + try: + return float(v) + except (TypeError, ValueError): + continue + return None + + +def symbols_match(position_symbol: str, order_symbol: str) -> bool: + a = (position_symbol or "").strip().upper() + b = (order_symbol or "").strip().upper() + if not a or not b: + return False + if a == b: + return True + for suf in (":USDT", "/USDT:USDT", "/USDT"): + a2 = a.replace(suf, "") + b2 = b.replace(suf, "") + if f"{a2}/USDT" == b or f"{a2}/USDT:USDT" == b: + return True + if f"{b2}/USDT" == a or f"{b2}/USDT:USDT" == a: + return True + if a2 == b2: + return True + return False + + +def _order_type_str(order: dict) -> str: + info = order.get("info") or {} + if isinstance(info, dict): + for key in ("orderType", "type", "origType", "algoType", "ordType"): + val = info.get(key) + if val: + return str(val).upper() + return str(order.get("type") or "").upper() + + +def _is_conditional_type(typ: str) -> bool: + t = (typ or "").upper() + if not t: + return False + keys = ("STOP", "TAKE_PROFIT", "TRAIL", "TRIGGER", "CONDITIONAL") + return any(k in t for k in keys) + + +def _order_label(typ: str, side: str, reduce_only: bool | None) -> str: + t = (typ or "").upper() + side_l = (side or "").lower() + parts = [] + if "TAKE_PROFIT" in t: + parts.append("止盈") + elif "STOP" in t: + parts.append("止损") + elif "LIMIT" in t: + parts.append("限价") + elif "MARKET" in t: + parts.append("市价") + else: + parts.append(typ or "委托") + if side_l == "buy": + parts.append("买入") + elif side_l == "sell": + parts.append("卖出") + if reduce_only: + parts.append("·只减仓") + return " ".join(parts) + + +def _normalize_raw_order(order: dict, *, channel: str) -> dict[str, Any] | None: + if not isinstance(order, dict): + return None + info = order.get("info") or {} + if not isinstance(info, dict): + info = {} + oid = order.get("id") or info.get("algoId") or info.get("orderId") or info.get("ordId") + if oid is None: + return None + sym = str(order.get("symbol") or info.get("symbol") or "") + typ = _order_type_str(order) + side = str(order.get("side") or info.get("side") or "").lower() + reduce_only = order.get("reduceOnly") + if reduce_only is None: + reduce_only = info.get("reduceOnly") + try: + reduce_only = bool(reduce_only) if reduce_only is not None else None + except (TypeError, ValueError): + reduce_only = None + trig = _coerce_float( + order.get("stopPrice"), + order.get("triggerPrice"), + info.get("triggerPrice"), + info.get("stopPrice"), + info.get("slTriggerPx"), + info.get("tpTriggerPx"), + ) + price = _coerce_float(order.get("price"), info.get("price")) + amt = _coerce_float(order.get("amount"), order.get("remaining"), info.get("quantity"), info.get("origQty")) + category = "conditional" if _is_conditional_type(typ) or channel == "algo" else "limit" + return { + "id": str(oid), + "symbol": sym, + "channel": channel, + "category": category, + "label": _order_label(typ, side, reduce_only), + "type": typ, + "side": side, + "amount": amt, + "trigger_price": trig, + "price": price, + "reduce_only": reduce_only, + "status": str(order.get("status") or info.get("status") or "open"), + } + + +def _binance_list(ex: Any, symbol: str | None) -> list[dict]: + ex.load_markets() + out: list[dict] = [] + symbols: list[str] = [] + if symbol: + try: + symbols = [ex.market(symbol)["symbol"]] + except Exception: + symbols = [symbol] + else: + symbols = [] + try: + for p in ex.fetch_positions() or []: + sym = p.get("symbol") + if sym: + symbols.append(sym) + except Exception: + pass + if symbol and not symbols: + symbols = [symbol] + + def collect(ex_sym: str) -> None: + market = ex.market(ex_sym) + contract_id = market.get("id") + try: + for o in ex.fetch_open_orders(ex_sym) or []: + item = dict(o) + item["_channel"] = "regular" + n = _normalize_raw_order(item, channel="regular") + if n: + out.append(n) + except Exception: + pass + try: + if contract_id and hasattr(ex, "fapiPrivateGetOpenAlgoOrders"): + raw = ex.fapiPrivateGetOpenAlgoOrders({"symbol": contract_id}) + items = raw if isinstance(raw, list) else (raw.get("orders") or raw.get("data") or []) + for info in items or []: + if not isinstance(info, dict): + continue + wrapped = { + "id": info.get("algoId") or info.get("orderId"), + "symbol": ex_sym, + "info": info, + "type": info.get("orderType") or info.get("type"), + "side": (info.get("side") or "").lower(), + "amount": info.get("quantity") or info.get("origQty"), + "stopPrice": info.get("triggerPrice") or info.get("stopPrice"), + "reduceOnly": info.get("reduceOnly"), + } + n = _normalize_raw_order(wrapped, channel="algo") + if n: + out.append(n) + except Exception: + pass + + if symbols: + seen = set() + for s in symbols: + if s in seen: + continue + seen.add(s) + collect(s) + return out + + +def _okx_list(ex: Any, symbol: str | None) -> list[dict]: + ex.load_markets() + out: list[dict] = [] + symbols: list[str] = [] + if symbol: + try: + symbols = [ex.market(symbol)["symbol"]] + except Exception: + symbols = [symbol] + else: + try: + for p in ex.fetch_positions() or []: + sym = p.get("symbol") + if sym: + symbols.append(sym) + except Exception: + pass + if symbol and not symbols: + symbols = [symbol] + seen = set() + for sym in symbols: + if sym in seen: + continue + seen.add(sym) + try: + for o in ex.fetch_open_orders(sym) or []: + ch = "algo" if _is_conditional_type(_order_type_str(o)) else "regular" + n = _normalize_raw_order(dict(o), channel=ch) + if n: + out.append(n) + except Exception: + pass + return out + + +def _gate_trigger_params(ex: Any) -> dict: + p = {"type": "swap", "trigger": True} + try: + ex.load_unified_status() + if ex.options.get("unifiedAccount"): + p["unifiedAccount"] = True + except Exception: + pass + return p + + +def _gate_list(ex: Any, symbol: str | None) -> list[dict]: + ex.load_markets() + out: list[dict] = [] + symbols: list[str] = [] + if symbol: + try: + symbols = [ex.market(symbol)["symbol"]] + except Exception: + symbols = [symbol] + else: + try: + for p in ex.fetch_positions() or []: + sym = p.get("symbol") + if sym: + symbols.append(sym) + except Exception: + pass + if symbol and not symbols: + symbols = [symbol] + trig_params = _gate_trigger_params(ex) + seen = set() + for sym in symbols: + if sym in seen: + continue + seen.add(sym) + try: + for o in ex.fetch_open_orders(sym) or []: + n = _normalize_raw_order(dict(o), channel="regular") + if n: + out.append(n) + except Exception: + pass + try: + for o in ex.fetch_open_orders(sym, params=trig_params) or []: + item = dict(o) + item["type"] = item.get("type") or "trigger" + n = _normalize_raw_order(item, channel="algo") + if n: + out.append(n) + except Exception: + pass + return out + + +def list_open_orders(ex: Any, exchange_kind: str, symbol: str | None = None) -> list[dict]: + kind = (exchange_kind or "binance").lower() + if kind == "binance": + orders = _binance_list(ex, symbol) + elif kind == "okx": + orders = _okx_list(ex, symbol) + else: + orders = _gate_list(ex, symbol) + if symbol: + orders = [o for o in orders if symbols_match(symbol, o.get("symbol") or "")] + # 去重 id+channel + seen: set[tuple[str, str]] = set() + uniq: list[dict] = [] + for o in orders: + key = (o["id"], o["channel"]) + if key in seen: + continue + seen.add(key) + uniq.append(o) + return uniq + + +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"] + p["regular_orders"] = [o for o in matched if o.get("category") != "conditional"] + + +def cancel_order( + ex: Any, + exchange_kind: str, + symbol: str, + order_id: str, + channel: str = "regular", +) -> None: + kind = (exchange_kind or "binance").lower() + ex.load_markets() + market = ex.market(symbol) + unified = market["symbol"] + ch = (channel or "regular").lower() + if kind == "binance" and ch == "algo": + contract_id = market.get("id") + if contract_id and hasattr(ex, "fapiPrivateDeleteAlgoOrder"): + ex.fapiPrivateDeleteAlgoOrder({"symbol": contract_id, "algoId": str(order_id)}) + return + params = None + if kind == "gate" and ch == "algo": + params = _gate_trigger_params(ex) + ex.cancel_order(str(order_id), unified, params) + + +def cancel_orders_for_symbol( + ex: Any, + exchange_kind: str, + symbol: str, + *, + scope: str = "all", +) -> int: + """scope: all | conditional | limit""" + orders = list_open_orders(ex, exchange_kind, symbol) + if scope == "conditional": + orders = [o for o in orders if o.get("category") == "conditional"] + elif scope == "limit": + orders = [o for o in orders if o.get("category") != "conditional"] + n = 0 + for o in orders: + try: + cancel_order(ex, exchange_kind, symbol, o["id"], o.get("channel") or "regular") + n += 1 + except Exception: + pass + return n diff --git a/manual_trading_hub/hub.py b/manual_trading_hub/hub.py index 64289d2..227daee 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 = "20260522-settings-fix" +HUB_BUILD = "20260524-open-orders" def _is_local(host: str | None) -> bool: @@ -390,6 +390,71 @@ class ClosePositionBody(BaseModel): side: str +class CancelOrderBody(BaseModel): + symbol: str + order_id: str + channel: str = "regular" + + +class CancelSymbolOrdersBody(BaseModel): + symbol: str + scope: str = "all" + + +@app.post("/api/orders/{exchange_id}/cancel") +async def api_cancel_order(exchange_id: str, body: CancelOrderBody): + 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/cancel" + async with httpx.AsyncClient() as client: + r = await client.post( + url, + headers=_agent_headers(), + json={ + "symbol": body.symbol, + "order_id": body.order_id, + "channel": body.channel or "regular", + }, + timeout=60.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/orders/{exchange_id}/cancel-symbol") +async def api_cancel_symbol_orders(exchange_id: str, body: CancelSymbolOrdersBody): + 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/cancel-symbol" + async with httpx.AsyncClient() as client: + r = await client.post( + url, + headers=_agent_headers(), + json={"symbol": body.symbol, "scope": body.scope or "all"}, + 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}/position") async def api_close_position(exchange_id: str, body: ClosePositionBody): ex = _find_exchange(exchange_id) diff --git a/manual_trading_hub/static/app.css b/manual_trading_hub/static/app.css index 459390c..a0fac71 100644 --- a/manual_trading_hub/static/app.css +++ b/manual_trading_hub/static/app.css @@ -545,6 +545,55 @@ button:disabled { margin-top: 0; } +.pos-block { + margin-bottom: 14px; + padding-bottom: 10px; + border-bottom: 1px dashed var(--border-soft); +} + +.pos-block:last-child { + border-bottom: none; + margin-bottom: 0; +} + +.pos-orders { + margin: 8px 0 0; + padding: 8px 10px; + background: rgba(0, 0, 0, 0.2); + border-radius: 6px; + border: 1px solid var(--border-soft); +} + +.pos-orders-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 6px; +} + +.pos-orders-title { + font-size: 10px; + color: var(--muted); + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.data-table-sub { + font-size: 10px; +} + +.data-table-sub th, +.data-table-sub td { + padding: 5px 6px; +} + +.order-empty { + font-size: 11px; + color: var(--muted); + padding: 6px 4px 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 ed51207..bd8d16a 100644 --- a/manual_trading_hub/static/app.js +++ b/manual_trading_hub/static/app.js @@ -136,11 +136,124 @@ btn.onclick = () => closeOnePosition(btn.dataset.exId, btn.dataset.symbol, btn.dataset.side); }); + box.querySelectorAll(".btn-cancel-order").forEach((btn) => { + btn.onclick = () => + cancelOneOrder( + btn.dataset.exId, + btn.dataset.symbol, + btn.dataset.orderId, + btn.dataset.channel + ); + }); + box.querySelectorAll(".btn-cancel-cond-all").forEach((btn) => { + btn.onclick = () => + cancelSymbolOrders(btn.dataset.exId, btn.dataset.symbol, "conditional"); + }); } catch (e) { box.innerHTML = `
${esc(e)}
`; } } + function renderOrderRows(exchangeId, symbol, orders, kind) { + if (!orders || !orders.length) { + const hint = + kind === "conditional" + ? "暂无条件单(止盈/止损等)" + : "暂无普通委托"; + return `
${hint}
`; + } + const symAttr = esc(symbol || "").replace(/"/g, """); + const rows = orders + .map((o) => { + const oidAttr = esc(o.id || "").replace(/"/g, """); + const chAttr = esc(o.channel || "regular").replace(/"/g, """); + const trig = + o.trigger_price != null ? fmt(o.trigger_price, 4) : o.price != null ? fmt(o.price, 4) : "—"; + return ` + ${esc(o.label || o.type || "委托")} + ${fmt(o.amount, 4)} + ${trig} + + `; + }) + .join(""); + return `${rows}
类型数量触发/价格操作
`; + } + + function renderPositionBlock(exchangeId, x) { + const symAttr = esc(x.symbol || "").replace(/"/g, """); + const sideAttr = esc((x.side || "").toLowerCase()).replace(/"/g, """); + const cond = Array.isArray(x.conditional_orders) ? x.conditional_orders : []; + const reg = Array.isArray(x.regular_orders) ? x.regular_orders : []; + const condAllBtn = + cond.length > 0 + ? `` + : ""; + return `
+ + + + + + + + +
合约方向张数浮盈操作
${esc(x.symbol)}${esc(x.side)}${fmt(x.contracts, 4)}${fmt(x.unrealized_pnl, 4)}
+
+
+ 条件单 · ${cond.length} + ${condAllBtn} +
+ ${renderOrderRows(exchangeId, x.symbol, cond, "conditional")} +
+ 普通委托 · ${reg.length} +
+ ${renderOrderRows(exchangeId, x.symbol, reg, "limit")} +
+
`; + } + + async function cancelOneOrder(exchangeId, symbol, orderId, channel) { + if (!confirm(`撤销委托 ${symbol} #${orderId}?`)) return; + try { + const r = await apiFetch("/api/orders/" + encodeURIComponent(exchangeId) + "/cancel", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ symbol, order_id: orderId, channel: channel || "regular" }), + }); + const j = await r.json(); + const pl = j.payload || {}; + const ok = j.ok && pl.ok !== false; + showToast(ok ? "已撤单" : pl.error || JSON.stringify(j), !ok); + loadMonitorBoard(); + } catch (e) { + showToast(String(e), true); + } + } + + async function cancelSymbolOrders(exchangeId, symbol, scope) { + const label = scope === "conditional" ? "全部条件单" : "全部委托"; + if (!confirm(`确认撤销 ${symbol} 的${label}?`)) return; + try { + const r = await apiFetch( + "/api/orders/" + encodeURIComponent(exchangeId) + "/cancel-symbol", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ symbol, scope }), + } + ); + const j = await r.json(); + const pl = j.payload || {}; + const ok = j.ok && pl.ok !== false; + const n = pl.cancelled_count != null ? pl.cancelled_count : "?"; + showToast(ok ? `已撤销 ${n} 笔` : pl.error || JSON.stringify(j), !ok); + loadMonitorBoard(); + } catch (e) { + showToast(String(e), true); + } + } + function renderMonitorCard(row) { const ag = row.agent || {}; const pos = Array.isArray(ag.positions) ? ag.positions : []; @@ -168,20 +281,7 @@ `; inner += `
交易所持仓
`; if (pos.length) { - const posRows = pos - .map((x) => { - const symAttr = esc(x.symbol || "").replace(/"/g, """); - const sideAttr = esc((x.side || "").toLowerCase()).replace(/"/g, """); - return ` - ${esc(x.symbol)} - ${esc(x.side)} - ${fmt(x.contracts, 4)} - ${fmt(x.unrealized_pnl, 4)} - - `; - }) - .join(""); - inner += `${posRows}
合约方向张数浮盈操作
`; + inner += pos.map((x) => renderPositionBlock(row.id, x)).join(""); } else { inner += `
无持仓
`; } diff --git a/manual_trading_hub/static/index.html b/manual_trading_hub/static/index.html index 31e6e1c..556fb3a 100644 --- a/manual_trading_hub/static/index.html +++ b/manual_trading_hub/static/index.html @@ -7,7 +7,7 @@ - + @@ -80,6 +80,6 @@
- + diff --git a/manual_trading_hub/使用说明.md b/manual_trading_hub/使用说明.md index 60b4537..97ca489 100644 --- a/manual_trading_hub/使用说明.md +++ b/manual_trading_hub/使用说明.md @@ -1,6 +1,6 @@ # 多账户交易中控 — 使用说明 -本文档说明 **manual_trading_hub** 的架构、启动方式、界面操作与故障排查。中控聚合四所 **持仓/余额/关键位/趋势计划监控 + 紧急全平**;**人工下单、关键位、策略交易(趋势回调 / 顺势加仓)、交易复盘** 均在各实例网页操作(点监控卡片 **「实例」**)。 +本文档说明 **manual_trading_hub** 的架构、启动方式、界面操作与故障排查。中控聚合四所 **持仓/条件单/余额/关键位/趋势计划监控 + 撤单/紧急全平**;**人工下单、关键位、策略交易(趋势回调 / 顺势加仓)、交易复盘** 均在各实例网页操作(点监控卡片 **「实例」**)。 --- @@ -19,7 +19,7 @@ | 组件 | 职责 | 默认端口(可在设置页改) | |------|------|-------------------------| | **hub.py** | 聚合 UI、监控 API、全平 | `5100` | -| **agent.py** | 交易所只读状态 + 紧急市价全平 | 币安 `15200`、OKX `15201`、Gate `15202`、Gate趋势 `15203` | +| **agent.py** | 交易所只读状态、挂单/条件单查询与撤销 + 紧急市价全平 | 币安 `15200`、OKX `15201`、Gate `15202`、Gate趋势 `15203` | | **crypto_monitor_*.app** | 策略库、关键位、人工单、趋势预览/执行 | 币安 `5001`、Gate `5000`、Gate趋势 `5002`、OKX `5004` | ### 1.1 四账户默认配置 @@ -177,7 +177,9 @@ curl -s http://127.0.0.1:5100/api/ping | 功能 | 说明 | |------|------| | **2×2 卡片** | 仅显示「已启用」账户;每卡含子代理持仓、浮盈、余额 | -| **机器人持仓** | 来自实例 `/api/hub/monitor` 的 `order_monitors`(active) | +| **条件单 / 委托** | 每个持仓下方展示交易所 **条件单**(止盈/止损等)与 **普通委托**;数据来自子代理实时拉取(币安含 Algo 通道) | +| **撤单** | 单笔「撤单」或「撤销全部条件单」;经中控转发子代理 `POST /orders/cancel`、`/orders/cancel-symbol` | +| **机器人单** | 来自实例 `/api/hub/monitor` 的 `order_monitors`(active),为本地监控计划,**不等于**交易所条件单 | | **关键位** | 仅 `capabilities` 含 `key` 的户;展示门控摘要(`/api/price_snapshot`) | | **趋势计划** | 仅当该户勾选 **监控趋势计划** 时展示 `trend_pullback_plans`(active) | | **实例 / 复盘** | 「实例」→ 该户 Flask(**实盘下单、关键位、策略交易 `/strategy`、复盘**);「复盘」→ `/records`。若配置 **`HUB_PUBLIC_ORIGIN`**,外链替换 `127.0.0.1` |