增加单仓平仓

This commit is contained in:
dekun
2026-05-23 16:52:28 +08:00
parent 80150227e3
commit a4c13fd8cd
5 changed files with 210 additions and 36 deletions
+117 -28
View File
@@ -1,5 +1,5 @@
"""
子账户极轻代理:GET /status + POST /emergency/close-all,仅监听 127.0.0.1。
子账户极轻代理:GET /statusPOST /emergency/close-all、POST /emergency/close-position,仅监听 127.0.0.1。
与仓库内四个策略/监控目录一一对应时,典型用法(各目录自己的 .env 里已有密钥;子代理用环境变量 PORT,勿与 Flask 的 APP_PORT 相同):
EXCHANGE=binance → crypto_monitor_binanceBINANCE_*
@@ -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
+36
View File
@@ -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)
+12
View File
@@ -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;
+42 -5
View File
@@ -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 = `<div class="err">${esc(e)}</div>`;
}
@@ -165,12 +169,19 @@
inner += `<div class="section-title">交易所持仓</div>`;
if (pos.length) {
const posRows = pos
.map(
(x) =>
`<tr><td>${esc(x.symbol)}</td><td>${esc(x.side)}</td><td>${fmt(x.contracts, 4)}</td><td class="${pnlCls(x.unrealized_pnl)}">${fmt(x.unrealized_pnl, 4)}</td></tr>`
)
.map((x) => {
const symAttr = esc(x.symbol || "").replace(/"/g, "&quot;");
const sideAttr = esc((x.side || "").toLowerCase()).replace(/"/g, "&quot;");
return `<tr>
<td>${esc(x.symbol)}</td>
<td>${esc(x.side)}</td>
<td>${fmt(x.contracts, 4)}</td>
<td class="${pnlCls(x.unrealized_pnl)}">${fmt(x.unrealized_pnl, 4)}</td>
<td class="td-actions"><button type="button" class="btn-close-pos danger" data-ex-id="${esc(row.id)}" data-symbol="${symAttr}" data-side="${sideAttr}">平仓</button></td>
</tr>`;
})
.join("");
inner += `<table class="data-table"><thead><tr><th>合约</th><th>方向</th><th>张数</th><th>浮盈</th></tr></thead><tbody>${posRows}</tbody></table>`;
inner += `<table class="data-table"><thead><tr><th>合约</th><th>方向</th><th>张数</th><th>浮盈</th><th>操作</th></tr></thead><tbody>${posRows}</tbody></table>`;
} else {
inner += `<div class="empty-hint">无持仓</div>`;
}
@@ -241,6 +252,32 @@
</div>`;
}
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 {
+3 -3
View File
@@ -7,7 +7,7 @@
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Orbitron:wght@500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/assets/app.css?v=20260522-grid" />
<link rel="stylesheet" href="/assets/app.css?v=20260522-close-pos" />
</head>
<body>
<div class="app-bg" aria-hidden="true"></div>
@@ -39,7 +39,7 @@
<summary>数据来源与复盘链接</summary>
<div class="hint-body">
持仓与余额来自子代理;关键位、机器人单、趋势计划来自各实例 Flask(须 PM2 运行 crypto_*)。<br />
人工下单、添加关键位、趋势回调请在各实例网页操作;中控监控与紧急全平。<br />
人工下单、添加关键位、趋势回调请在各实例网页操作;中控监控、单仓平仓与账户全平。<br />
「交易复盘」在新标签打开该实例 /records。其它电脑访问中控时,请在 hub 的 <code>.env</code> 设置
<code>HUB_PUBLIC_ORIGIN=http://服务器内网IP</code>
</div>
@@ -80,6 +80,6 @@
</div>
<div id="toast"></div>
<script src="/assets/app.js?v=20260522-grid"></script>
<script src="/assets/app.js?v=20260522-close-pos"></script>
</body>
</html>