中控增加条件单

This commit is contained in:
dekun
2026-05-24 07:35:48 +08:00
parent 19ac702e5a
commit a3218f4fbd
7 changed files with 685 additions and 21 deletions
+94 -1
View File
@@ -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 相同): 与仓库内四个策略/监控目录一一对应时,典型用法(各目录自己的 .env 里已有密钥;子代理用环境变量 PORT,勿与 Flask 的 APP_PORT 相同):
EXCHANGE=binance → crypto_monitor_binanceBINANCE_* EXCHANGE=binance → crypto_monitor_binanceBINANCE_*
@@ -30,6 +30,13 @@ from fastapi import FastAPI, Header, HTTPException, Request
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from pydantic import BaseModel 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") HOST = os.getenv("HOST", "127.0.0.1")
PORT = int(os.getenv("PORT", "15200")) PORT = int(os.getenv("PORT", "15200"))
CONTROL_TOKEN = (os.getenv("CONTROL_TOKEN") or "").strip() CONTROL_TOKEN = (os.getenv("CONTROL_TOKEN") or "").strip()
@@ -281,6 +288,17 @@ class EmergencyClosePositionBody(BaseModel):
side: str 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( def _close_position_market(
ex: Any, sym: str, side: str, contracts: float ex: Any, sym: str, side: str, contracts: float
) -> tuple[dict[str, Any] | None, str | None]: ) -> 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: try:
pm = _position_mode_label() pm = _position_mode_label()
except Exception: 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") @app.post("/emergency/close-all")
def emergency_close_all(x_control_token: str | None = Header(default=None, alias="X-Control-Token")): def emergency_close_all(x_control_token: str | None = Header(default=None, alias="X-Control-Token")):
_check_token(x_control_token) _check_token(x_control_token)
+355
View File
@@ -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
+66 -1
View File
@@ -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() _trust_raw = (os.getenv("HUB_TRUST_LAN", "true") or "").strip().lower()
HUB_TRUST_LAN = _trust_raw not in ("0", "false", "no", "off") HUB_TRUST_LAN = _trust_raw not in ("0", "false", "no", "off")
DIR = Path(__file__).resolve().parent DIR = Path(__file__).resolve().parent
HUB_BUILD = "20260522-settings-fix" HUB_BUILD = "20260524-open-orders"
def _is_local(host: str | None) -> bool: def _is_local(host: str | None) -> bool:
@@ -390,6 +390,71 @@ class ClosePositionBody(BaseModel):
side: str 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") @app.post("/api/close/{exchange_id}/position")
async def api_close_position(exchange_id: str, body: ClosePositionBody): async def api_close_position(exchange_id: str, body: ClosePositionBody):
ex = _find_exchange(exchange_id) ex = _find_exchange(exchange_id)
+49
View File
@@ -545,6 +545,55 @@ button:disabled {
margin-top: 0; 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 { .data-table {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
+114 -14
View File
@@ -136,11 +136,124 @@
btn.onclick = () => btn.onclick = () =>
closeOnePosition(btn.dataset.exId, btn.dataset.symbol, btn.dataset.side); 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) { } catch (e) {
box.innerHTML = `<div class="err">${esc(e)}</div>`; box.innerHTML = `<div class="err">${esc(e)}</div>`;
} }
} }
function renderOrderRows(exchangeId, symbol, orders, kind) {
if (!orders || !orders.length) {
const hint =
kind === "conditional"
? "暂无条件单(止盈/止损等)"
: "暂无普通委托";
return `<div class="order-empty">${hint}</div>`;
}
const symAttr = esc(symbol || "").replace(/"/g, "&quot;");
const rows = orders
.map((o) => {
const oidAttr = esc(o.id || "").replace(/"/g, "&quot;");
const chAttr = esc(o.channel || "regular").replace(/"/g, "&quot;");
const trig =
o.trigger_price != null ? fmt(o.trigger_price, 4) : o.price != null ? fmt(o.price, 4) : "—";
return `<tr>
<td>${esc(o.label || o.type || "委托")}</td>
<td>${fmt(o.amount, 4)}</td>
<td>${trig}</td>
<td class="td-actions"><button type="button" class="btn-cancel-order ghost" data-ex-id="${esc(exchangeId)}" data-symbol="${symAttr}" data-order-id="${oidAttr}" data-channel="${chAttr}">撤单</button></td>
</tr>`;
})
.join("");
return `<table class="data-table data-table-sub"><thead><tr><th>类型</th><th>数量</th><th>触发/价格</th><th>操作</th></tr></thead><tbody>${rows}</tbody></table>`;
}
function renderPositionBlock(exchangeId, x) {
const symAttr = esc(x.symbol || "").replace(/"/g, "&quot;");
const sideAttr = esc((x.side || "").toLowerCase()).replace(/"/g, "&quot;");
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
? `<button type="button" class="btn-cancel-cond-all ghost" data-ex-id="${esc(exchangeId)}" data-symbol="${symAttr}">撤销全部条件单</button>`
: "";
return `<div class="pos-block">
<table class="data-table"><thead><tr><th>合约</th><th>方向</th><th>张数</th><th>浮盈</th><th>操作</th></tr></thead><tbody>
<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(exchangeId)}" data-symbol="${symAttr}" data-side="${sideAttr}">平仓</button></td>
</tr>
</tbody></table>
<div class="pos-orders">
<div class="pos-orders-head">
<span class="pos-orders-title">条件单 · ${cond.length}</span>
${condAllBtn}
</div>
${renderOrderRows(exchangeId, x.symbol, cond, "conditional")}
<div class="pos-orders-head" style="margin-top:10px">
<span class="pos-orders-title">普通委托 · ${reg.length}</span>
</div>
${renderOrderRows(exchangeId, x.symbol, reg, "limit")}
</div>
</div>`;
}
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) { function renderMonitorCard(row) {
const ag = row.agent || {}; const ag = row.agent || {};
const pos = Array.isArray(ag.positions) ? ag.positions : []; const pos = Array.isArray(ag.positions) ? ag.positions : [];
@@ -168,20 +281,7 @@
</div>`; </div>`;
inner += `<div class="section-title">交易所持仓</div>`; inner += `<div class="section-title">交易所持仓</div>`;
if (pos.length) { if (pos.length) {
const posRows = pos inner += pos.map((x) => renderPositionBlock(row.id, x)).join("");
.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><th>操作</th></tr></thead><tbody>${posRows}</tbody></table>`;
} else { } else {
inner += `<div class="empty-hint">无持仓</div>`; inner += `<div class="empty-hint">无持仓</div>`;
} }
+2 -2
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-close-pos" /> <link rel="stylesheet" href="/assets/app.css?v=20260524-open-orders" />
</head> </head>
<body> <body>
<div class="app-bg" aria-hidden="true"></div> <div class="app-bg" aria-hidden="true"></div>
@@ -80,6 +80,6 @@
</div> </div>
<div id="toast"></div> <div id="toast"></div>
<script src="/assets/app.js?v=20260522-close-pos"></script> <script src="/assets/app.js?v=20260524-open-orders"></script>
</body> </body>
</html> </html>
+5 -3
View File
@@ -1,6 +1,6 @@
# 多账户交易中控 — 使用说明 # 多账户交易中控 — 使用说明
本文档说明 **manual_trading_hub** 的架构、启动方式、界面操作与故障排查。中控聚合四所 **持仓/余额/关键位/趋势计划监控 + 紧急全平**;**人工下单、关键位、策略交易(趋势回调 / 顺势加仓)、交易复盘** 均在各实例网页操作(点监控卡片 **「实例」**)。 本文档说明 **manual_trading_hub** 的架构、启动方式、界面操作与故障排查。中控聚合四所 **持仓/条件单/余额/关键位/趋势计划监控 + 撤单/紧急全平**;**人工下单、关键位、策略交易(趋势回调 / 顺势加仓)、交易复盘** 均在各实例网页操作(点监控卡片 **「实例」**)。
--- ---
@@ -19,7 +19,7 @@
| 组件 | 职责 | 默认端口(可在设置页改) | | 组件 | 职责 | 默认端口(可在设置页改) |
|------|------|-------------------------| |------|------|-------------------------|
| **hub.py** | 聚合 UI、监控 API、全平 | `5100` | | **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` | | **crypto_monitor_*.app** | 策略库、关键位、人工单、趋势预览/执行 | 币安 `5001`、Gate `5000`、Gate趋势 `5002`、OKX `5004` |
### 1.1 四账户默认配置 ### 1.1 四账户默认配置
@@ -177,7 +177,9 @@ curl -s http://127.0.0.1:5100/api/ping
| 功能 | 说明 | | 功能 | 说明 |
|------|------| |------|------|
| **2×2 卡片** | 仅显示「已启用」账户;每卡含子代理持仓、浮盈、余额 | | **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` | | **关键位** | 仅 `capabilities``key` 的户;展示门控摘要(`/api/price_snapshot` |
| **趋势计划** | 仅当该户勾选 **监控趋势计划** 时展示 `trend_pullback_plans`active | | **趋势计划** | 仅当该户勾选 **监控趋势计划** 时展示 `trend_pullback_plans`active |
| **实例 / 复盘** | 「实例」→ 该户 Flask(**实盘下单、关键位、策略交易 `/strategy`、复盘**);「复盘」→ `/records`。若配置 **`HUB_PUBLIC_ORIGIN`**,外链替换 `127.0.0.1` | | **实例 / 复盘** | 「实例」→ 该户 Flask(**实盘下单、关键位、策略交易 `/strategy`、复盘**);「复盘」→ `/records`。若配置 **`HUB_PUBLIC_ORIGIN`**,外链替换 `127.0.0.1` |