增加单仓平仓

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 相同): 与仓库内四个策略/监控目录一一对应时,典型用法(各目录自己的 .env 里已有密钥;子代理用环境变量 PORT,勿与 Flask 的 APP_PORT 相同):
EXCHANGE=binance → crypto_monitor_binanceBINANCE_* EXCHANGE=binance → crypto_monitor_binanceBINANCE_*
@@ -28,6 +28,7 @@ from typing import Any
import ccxt import ccxt
from fastapi import FastAPI, Header, HTTPException, Request from fastapi import FastAPI, Header, HTTPException, Request
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from pydantic import BaseModel
HOST = os.getenv("HOST", "127.0.0.1") HOST = os.getenv("HOST", "127.0.0.1")
PORT = int(os.getenv("PORT", "15200")) PORT = int(os.getenv("PORT", "15200"))
@@ -275,6 +276,47 @@ def _cancel_symbol_orders(ex: Any, sym: str) -> None:
pass 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: def _is_local(host: str | None) -> bool:
if not host: if not host:
return False return False
@@ -526,38 +568,85 @@ def emergency_close_all(x_control_token: str | None = Header(default=None, alias
if not sym: if not sym:
continue continue
side = _position_side(p, c) side = _position_side(p, c)
close_side = "sell" if side == "long" else "buy" info, err = _close_position_market(ex, sym, side, abs(c))
direction = "long" if side == "long" else "short" if err:
try: errors.append(err)
amt = float(ex.amount_to_precision(sym, abs(c))) elif info:
except Exception: closed.append(info)
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)
time.sleep(0.05) time.sleep(0.05)
return {"ok": len(errors) == 0, "closed": closed, "errors": errors, "exchange": EXCHANGE_KIND} 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(): def main():
import uvicorn import uvicorn
+36
View File
@@ -385,6 +385,42 @@ class CloseAllBody(BaseModel):
exclude_ids: list[str] = Field(default_factory=list) 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}") @app.post("/api/close/{exchange_id}")
async def api_close_exchange(exchange_id: str): async def api_close_exchange(exchange_id: str):
ex = _find_exchange(exchange_id) ex = _find_exchange(exchange_id)
+12
View File
@@ -370,6 +370,18 @@ button:disabled {
box-shadow: var(--glow); 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 { .chk-label {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
+42 -5
View File
@@ -132,6 +132,10 @@
box.querySelectorAll(".btn-close-ex").forEach((btn) => { box.querySelectorAll(".btn-close-ex").forEach((btn) => {
btn.onclick = () => closeOne(btn.dataset.id); 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) { } catch (e) {
box.innerHTML = `<div class="err">${esc(e)}</div>`; box.innerHTML = `<div class="err">${esc(e)}</div>`;
} }
@@ -165,12 +169,19 @@
inner += `<div class="section-title">交易所持仓</div>`; inner += `<div class="section-title">交易所持仓</div>`;
if (pos.length) { if (pos.length) {
const posRows = pos const posRows = pos
.map( .map((x) => {
(x) => const symAttr = esc(x.symbol || "").replace(/"/g, "&quot;");
`<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>` 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(""); .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 { } else {
inner += `<div class="empty-hint">无持仓</div>`; inner += `<div class="empty-hint">无持仓</div>`;
} }
@@ -241,6 +252,32 @@
</div>`; </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) { async function closeOne(id) {
if (!confirm("确认对该账户市价全平?")) return; if (!confirm("确认对该账户市价全平?")) return;
try { try {
+3 -3
View File
@@ -7,7 +7,7 @@
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <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 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> </head>
<body> <body>
<div class="app-bg" aria-hidden="true"></div> <div class="app-bg" aria-hidden="true"></div>
@@ -39,7 +39,7 @@
<summary>数据来源与复盘链接</summary> <summary>数据来源与复盘链接</summary>
<div class="hint-body"> <div class="hint-body">
持仓与余额来自子代理;关键位、机器人单、趋势计划来自各实例 Flask(须 PM2 运行 crypto_*)。<br /> 持仓与余额来自子代理;关键位、机器人单、趋势计划来自各实例 Flask(须 PM2 运行 crypto_*)。<br />
人工下单、添加关键位、趋势回调请在各实例网页操作;中控监控与紧急全平。<br /> 人工下单、添加关键位、趋势回调请在各实例网页操作;中控监控、单仓平仓与账户全平。<br />
「交易复盘」在新标签打开该实例 /records。其它电脑访问中控时,请在 hub 的 <code>.env</code> 设置 「交易复盘」在新标签打开该实例 /records。其它电脑访问中控时,请在 hub 的 <code>.env</code> 设置
<code>HUB_PUBLIC_ORIGIN=http://服务器内网IP</code> <code>HUB_PUBLIC_ORIGIN=http://服务器内网IP</code>
</div> </div>
@@ -80,6 +80,6 @@
</div> </div>
<div id="toast"></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> </body>
</html> </html>