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 += ``;
+ inner += ``;
} 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 @@
-
+