diff --git a/manual_trading_hub/agent.py b/manual_trading_hub/agent.py index a3b2e1d..6b406a8 100644 --- a/manual_trading_hub/agent.py +++ b/manual_trading_hub/agent.py @@ -1,5 +1,5 @@ """ -子账户极轻代理:仅 GET /status + POST /emergency/close-all,仅监听 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_*) @@ -28,6 +28,7 @@ from typing import Any import ccxt from fastapi import FastAPI, Header, HTTPException, Request from fastapi.responses import JSONResponse +from pydantic import BaseModel HOST = os.getenv("HOST", "127.0.0.1") PORT = int(os.getenv("PORT", "15200")) @@ -275,6 +276,47 @@ def _cancel_symbol_orders(ex: Any, sym: str) -> None: pass +class EmergencyClosePositionBody(BaseModel): + symbol: str + side: str + + +def _close_position_market( + ex: Any, sym: str, side: str, contracts: float +) -> tuple[dict[str, Any] | None, str | None]: + """市价平掉指定合约、方向;返回 (closed_info, error_message)。""" + side_n = (side or "").strip().lower() + if side_n not in ("long", "short"): + return None, f"无效方向: {side}" + close_side = "sell" if side_n == "long" else "buy" + direction = side_n + try: + amt = float(ex.amount_to_precision(sym, abs(float(contracts)))) + except Exception: + amt = abs(float(contracts)) + if amt <= 0: + return None, f"{sym}: 可平张数为 0" + order_resp = None + last_err: Exception | None = None + for params in _close_param_candidates(direction): + try: + order_resp = ex.create_order(sym, "market", close_side, amt, None, params) + last_err = None + break + except Exception as e: + last_err = e + if _retryable_close_err(str(e)): + continue + return None, f"{sym}: {e}" + if order_resp is None: + return None, f"{sym}: {last_err or '下单失败'}" + _cancel_symbol_orders(ex, sym) + return ( + {"symbol": sym, "side": side_n, "amount": amt, "order_id": order_resp.get("id")}, + None, + ) + + def _is_local(host: str | None) -> bool: if not host: return False @@ -526,38 +568,85 @@ def emergency_close_all(x_control_token: str | None = Header(default=None, alias if not sym: continue side = _position_side(p, c) - close_side = "sell" if side == "long" else "buy" - direction = "long" if side == "long" else "short" - try: - amt = float(ex.amount_to_precision(sym, abs(c))) - except Exception: - amt = abs(c) - if amt <= 0: - continue - order_resp = None - last_err: Exception | None = None - for params in _close_param_candidates(direction): - try: - order_resp = ex.create_order(sym, "market", close_side, amt, None, params) - last_err = None - break - except Exception as e: - last_err = e - if _retryable_close_err(str(e)): - continue - errors.append(f"{sym}: {e}") - order_resp = None - break - if order_resp is None and last_err and sym not in "".join(errors): - errors.append(f"{sym}: {last_err}") - if order_resp is not None: - closed.append({"symbol": sym, "side": side, "amount": amt, "order_id": order_resp.get("id")}) - _cancel_symbol_orders(ex, sym) + info, err = _close_position_market(ex, sym, side, abs(c)) + if err: + errors.append(err) + elif info: + closed.append(info) time.sleep(0.05) return {"ok": len(errors) == 0, "closed": closed, "errors": errors, "exchange": EXCHANGE_KIND} +@app.post("/emergency/close-position") +def emergency_close_position( + body: EmergencyClosePositionBody, + x_control_token: str | None = Header(default=None, alias="X-Control-Token"), +): + _check_token(x_control_token) + sym = (body.symbol or "").strip() + want_side = (body.side or "").strip().lower() + if not sym: + raise HTTPException(status_code=400, detail="symbol 不能为空") + if want_side not in ("long", "short"): + raise HTTPException(status_code=400, detail="side 须为 long 或 short") + try: + ex = get_exchange() + except RuntimeError as e: + raise HTTPException(status_code=503, detail=str(e)) from e + try: + _ensure_markets() + except Exception as e: + return JSONResponse( + { + "ok": False, + "error": f"load_markets: {e}", + "closed": None, + "exchange": EXCHANGE_KIND, + }, + status_code=200, + ) + try: + raw = ex.fetch_positions() or [] + except Exception as e: + raise HTTPException(status_code=502, detail=f"fetch_positions: {e}") from e + + matched = None + for p in raw: + if not isinstance(p, dict): + continue + if (p.get("symbol") or "").strip() != sym: + continue + c = _position_contracts(p) + if abs(c) < 1e-12: + continue + side = _position_side(p, c) + if side != want_side: + continue + matched = (sym, side, abs(c)) + break + + if not matched: + return JSONResponse( + { + "ok": False, + "error": f"未找到持仓: {sym} {want_side}", + "closed": None, + "exchange": EXCHANGE_KIND, + }, + status_code=200, + ) + + sym, side, c = matched + info, err = _close_position_market(ex, sym, side, c) + if err: + return JSONResponse( + {"ok": False, "error": err, "closed": None, "exchange": EXCHANGE_KIND}, + status_code=200, + ) + return {"ok": True, "closed": info, "errors": [], "exchange": EXCHANGE_KIND} + + def main(): import uvicorn diff --git a/manual_trading_hub/hub.py b/manual_trading_hub/hub.py index a47cfea..64289d2 100644 --- a/manual_trading_hub/hub.py +++ b/manual_trading_hub/hub.py @@ -385,6 +385,42 @@ class CloseAllBody(BaseModel): exclude_ids: list[str] = Field(default_factory=list) +class ClosePositionBody(BaseModel): + symbol: str + side: str + + +@app.post("/api/close/{exchange_id}/position") +async def api_close_position(exchange_id: str, body: ClosePositionBody): + ex = _find_exchange(exchange_id) + if not ex or not ex.get("enabled"): + raise HTTPException(status_code=404, detail="账户未启用") + sym = (body.symbol or "").strip() + side = (body.side or "").strip().lower() + if not sym: + raise HTTPException(status_code=400, detail="symbol 不能为空") + if side not in ("long", "short"): + raise HTTPException(status_code=400, detail="side 须为 long 或 short") + url = f"{ex['agent_url'].rstrip('/')}/emergency/close-position" + async with httpx.AsyncClient() as client: + r = await client.post( + url, + headers=_agent_headers(), + json={"symbol": sym, "side": side}, + 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 bc45b93..459390c 100644 --- a/manual_trading_hub/static/app.css +++ b/manual_trading_hub/static/app.css @@ -370,6 +370,18 @@ button:disabled { box-shadow: var(--glow); } +.btn-close-pos { + font-size: 11px; + padding: 4px 10px; + white-space: nowrap; +} + +.data-table .td-actions { + text-align: right; + width: 1%; + white-space: nowrap; +} + .chk-label { display: inline-flex; align-items: center; diff --git a/manual_trading_hub/static/app.js b/manual_trading_hub/static/app.js index 026c87c..ed51207 100644 --- a/manual_trading_hub/static/app.js +++ b/manual_trading_hub/static/app.js @@ -132,6 +132,10 @@ box.querySelectorAll(".btn-close-ex").forEach((btn) => { btn.onclick = () => closeOne(btn.dataset.id); }); + box.querySelectorAll(".btn-close-pos").forEach((btn) => { + btn.onclick = () => + closeOnePosition(btn.dataset.exId, btn.dataset.symbol, btn.dataset.side); + }); } catch (e) { box.innerHTML = `
${esc(e)}
`; } @@ -165,12 +169,19 @@ inner += `
交易所持仓
`; if (pos.length) { const posRows = pos - .map( - (x) => - `${esc(x.symbol)}${esc(x.side)}${fmt(x.contracts, 4)}${fmt(x.unrealized_pnl, 4)}` - ) + .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 += `${posRows}
合约方向张数浮盈操作
`; } else { inner += `
无持仓
`; } @@ -241,6 +252,32 @@ `; } + async function closeOnePosition(exchangeId, symbol, side) { + const label = `${symbol} · ${side}`; + if (!confirm(`确认对该账户市价平仓:${label}?`)) return; + try { + const r = await apiFetch( + "/api/close/" + encodeURIComponent(exchangeId) + "/position", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ symbol, side }), + } + ); + const j = await r.json(); + const pl = j.payload || {}; + const ok = j.ok && pl.ok !== false; + const msg = + (ok && pl.closed + ? `已平仓 ${pl.closed.symbol} ${pl.closed.side} · 张数 ${pl.closed.amount}` + : pl.error) || JSON.stringify(j, null, 2); + showToast(msg, !ok); + loadMonitorBoard(); + } catch (e) { + showToast(String(e), true); + } + } + async function closeOne(id) { if (!confirm("确认对该账户市价全平?")) return; try { diff --git a/manual_trading_hub/static/index.html b/manual_trading_hub/static/index.html index e0f210f..31e6e1c 100644 --- a/manual_trading_hub/static/index.html +++ b/manual_trading_hub/static/index.html @@ -7,7 +7,7 @@ - + @@ -39,7 +39,7 @@ 数据来源与复盘链接
持仓与余额来自子代理;关键位、机器人单、趋势计划来自各实例 Flask(须 PM2 运行 crypto_*)。
- 人工下单、添加关键位、趋势回调请在各实例网页操作;中控仅监控与紧急全平。
+ 人工下单、添加关键位、趋势回调请在各实例网页操作;中控可监控、单仓平仓与账户全平。
「交易复盘」在新标签打开该实例 /records。其它电脑访问中控时,请在 hub 的 .env 设置 HUB_PUBLIC_ORIGIN=http://服务器内网IP
@@ -80,6 +80,6 @@
- +