中控增加条件单
This commit is contained in:
@@ -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_binance(BINANCE_*)
|
EXCHANGE=binance → crypto_monitor_binance(BINANCE_*)
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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, """);
|
||||||
|
const rows = orders
|
||||||
|
.map((o) => {
|
||||||
|
const oidAttr = esc(o.id || "").replace(/"/g, """);
|
||||||
|
const chAttr = esc(o.channel || "regular").replace(/"/g, """);
|
||||||
|
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, """);
|
||||||
|
const sideAttr = esc((x.side || "").toLowerCase()).replace(/"/g, """);
|
||||||
|
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, """);
|
|
||||||
const sideAttr = esc((x.side || "").toLowerCase()).replace(/"/g, """);
|
|
||||||
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>`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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` |
|
||||||
|
|||||||
Reference in New Issue
Block a user