增加中控

This commit is contained in:
dekun
2026-05-13 01:52:26 +08:00
parent 5900588718
commit c14c74cf14
13 changed files with 2063 additions and 0 deletions
+201
View File
@@ -0,0 +1,201 @@
# 手工交易多账户中控(manual_trading_hub
本目录提供**极简中控**:只负责多账户**监控**(持仓、盈亏、余额等)与**紧急全平**,不参与开仓、策略或任何自动化下单。策略账户侧的 `crypto_monitor_*` 项目**无需改代码**,与中控并行运行即可。
---
## 功能概览
| 能力 | 说明 |
|------|------|
| 监控 | 汇总各子代理的永续持仓、未实现盈亏、余额(USDT)、持仓模式等 |
| 单账户全平 | 对某一子代理发起市价减仓,尽量平掉该所 USDT 永续仓位 |
| 全局全平 | 对当前「已开启监控」的子代理依次发起全平 |
| 关闭某账户 | 网页上取消「参与监控」,或环境变量 `HUB_DISABLED_IDS`;被关闭的不轮询、不参与全局全平 |
---
## 架构说明
```
浏览器 → 中控 hub.py(默认监听 0.0.0.0:5100,私网可访问;本机仍可用 127.0.0.1)
↓ HTTP
子代理 agent.py × N(默认 127.0.0.1:1520015203,与 Flask 的 APP_PORT 错开)
↓ ccxt
各交易所 API
```
- **中控(hub)**:读配置、并行请求各子代理 `/status`;全平时转发 `POST /emergency/close-all`
- **子代理(agent)**:每个进程绑定一个交易所 API Key,只做只读状态 + 紧急平仓;与 `crypto_monitor_*` 里的 Flask **独立进程**,互不影响日常手工/策略操作。
### 与四个 `crypto_monitor_*` 目录的关系(已对照 `app.py`)
各策略项目在**本目录**加载 `.env``load_env_file`),并用其中的 **`APP_HOST` / `APP_PORT`** 启动 **Flask 网页**`app.py``HOST``PORT`)。这是你在浏览器里打开策略/监控后台用的端口,例如你截图里的 `APP_PORT=5001`
子代理 **不用 `APP_PORT`**,而是用环境变量 **`PORT`**FastAPI/uvicorn 监听)。若子代理与 Flask 抢同一端口,后启动的会起不来,因此中控默认把子代理配在 **`15200``15203`**,与常见 `5000` 段 Flask 配置**错开**。
| 子代理 `PORT`(建议) | 对应策略目录 | `EXCHANGE` |
|----------------------|--------------|------------|
| 15200 | `crypto_monitor_binance` | `binance` |
| 15201 | `crypto_monitor_okx` | `okx` |
| 15202 | `crypto_monitor_gate` | `gate` |
| 15203 | `crypto_monitor_gate_bot` | `gate` |
Flask 仍可继续用 `APP_HOST=0.0.0.0` 方便云服务器外网访问;**子代理**建议 **`HOST=127.0.0.1`**。中控默认 `HUB_HOST=0.0.0.0` 便于局域网打开页面;若只给自己用,设 `HUB_HOST=127.0.0.1``HUB_TRUST_LAN=0`
---
## 依赖
- Python 3.10+(与仓库其他项目相近即可)
-`requirements.txt``fastapi``uvicorn``httpx``ccxt`
安装:
```bash
cd manual_trading_hub
pip install -r requirements.txt
```
建议使用独立虚拟环境,避免与全局包冲突。
---
## 快速启动(本机)
### 1. 启动子代理(每个账户一个终端)
在**对应策略目录**下加载该目录已有 `.env`(含 API 密钥与可选代理),再启动 agent,避免密钥分散维护两套。
**Binance(子代理端口 15200,与 Flask APP_PORT 无关)**
```powershell
cd ..\crypto_monitor_binance
$env:EXCHANGE="binance"
$env:PORT="15200"
$env:HOST="127.0.0.1"
python ..\manual_trading_hub\agent.py
```
**OKX15201**
```powershell
cd ..\crypto_monitor_okx
$env:EXCHANGE="okx"
$env:PORT="15201"
python ..\manual_trading_hub\agent.py
```
**Gate / Gate-Bot15202 / 15203**
```powershell
$env:EXCHANGE="gate"
$env:PORT="15202" # Gate-Bot 用 15203
python ..\manual_trading_hub\agent.py
```
### 2. 启动中控
```powershell
cd manual_trading_hub
$env:HUB_HOST="0.0.0.0"
$env:HUB_PORT="5100"
# 可选:自定义各账户卡片标题(顺序与默认 HUB_AGENTS 一致:15200→15201→15202→15203
# $env:HUB_AGENT_NAMES="币安主号,OKX,Gate主号,Gate机器人"
python hub.py
```
浏览器打开:**http://127.0.0.1:5100/** 或 **http://本机局域网IP:5100/**
---
## 子代理(agent)环境变量
| 变量 | 含义 | 默认 |
|------|------|------|
| `EXCHANGE` | `binance` / `okx` / `gate` | `binance` |
| `HOST` | 监听地址 | `127.0.0.1` |
| `PORT` | 子代理监听端口(勿与 Flask 的 `APP_PORT` 相同) | `15200` |
| `CONTROL_TOKEN` | 若设置,请求须带头 `X-Control-Token` | 空 |
**Binance**`BINANCE_API_KEY``BINANCE_API_SECRET`;可选 `BINANCE_POSITION_MODE``hedge`/`oneway`)、`BINANCE_MARGIN_MODE`;代理 `BINANCE_SOCKS_PROXY``BINANCE_HTTP_PROXY` / `HTTPS_PROXY``/status``balance_usdt` 为 **U 本位永续合约**账户 USDT(与 `crypto_monitor_binance` 合约口径一致,非现货钱包)。
**OKX**`OKX_API_KEY``OKX_API_SECRET``OKX_API_PASSPHRASE`;可选 `OKX_TD_MODE``OKX_POS_MODE`;代理 `OKX_SOCKS_PROXY` 等。
**Gate**`GATE_API_KEY``GATE_API_SECRET`;可选 `GATE_TD_MODE``GATE_POS_MODE`;代理 `GATE_SOCKS_PROXY` 等。
---
## 中控(hub)环境变量
| 变量 | 含义 | 默认 |
|------|------|------|
| `HUB_HOST` | 监听地址 | `0.0.0.0`(局域网可连);`127.0.0.1` 仅本机 |
| `HUB_PORT` | 监听端口 | `5100` |
| `HUB_AGENTS` | 子代理 base URL,逗号分隔 | `http://127.0.0.1:15200``15203` |
| `HUB_AGENT_NAMES` | 与 `HUB_AGENTS` **顺序一一对应**的卡片显示名(逗号分隔)。不设则用内置默认名。改后需重启 hub,**所有访问该中控的浏览器**显示一致 | 内置与四目录对应的英文标签 |
| `HUB_DISABLED_IDS` | 不参与监控与全局全平的账户 `id`,逗号分隔(如暂不用 OKX 写 `1` | 空 |
| `HUB_TRUST_LAN` | 默认 `true`;设为 `0`/`false`/`off` 则仅允许本机 IP 访问中控 | 开 |
| `CONTROL_TOKEN` | 与子代理一致时,中控代发 `X-Control-Token` | 空 |
---
## 网页操作说明
- **立即刷新 / 自动刷新**:拉取最新汇总;自动刷新默认约 3 秒一轮(仅请求已开启监控的账户)。
- **账户显示名**:在运行 `hub.py` 的机器上设置环境变量 **`HUB_AGENT_NAMES`**(逗号分隔,顺序与 **`HUB_AGENTS`** 里每个子代理 URL 一致),重启中控后,任意电脑打开同一中控地址都会看到相同名称。名称里若含逗号,需整体用引号包裹或避免使用逗号。
- **参与监控**:勾选则参与轮询与「全局一键全平」;取消则本浏览器记住(`localStorage``manual_trading_hub_excluded`),不再请求该子代理。
- **该账户全平**:仅针对该子代理;与是否关闭「参与监控」无关(仍可直接调交易所紧急平仓)。
- **全局一键全平**:只对当前「参与监控」且未被 `HUB_DISABLED_IDS` 关闭的账户发起全平;请求体中的 `exclude_ids` 与网页关闭状态一致。
服务端 `HUB_DISABLED_IDS` 与浏览器关闭**取并集**:任一方关闭即不轮询、不进全局全平。
---
## HTTP API(访问控制与浏览器一致)
允许来源:**本机**或 **RFC1918 私网 IPv4**(与 `HUB_TRUST_LAN` 开启时一致);`HUB_TRUST_LAN=0` 时仅本机。
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/` | 中控页面 |
| GET | `/api/agents` | 当前配置的账户列表(id、name、url) |
| GET | `/api/snapshot` | 聚合状态;可选查询参数 `exclude_ids`(逗号分隔 id |
| POST | `/api/close/{agent_id}` | 单账户紧急全平 |
| POST | `/api/close-all` | JSON 体可选 `{"exclude_ids":["1"]}`,与 `HUB_DISABLED_IDS` 合并 |
子代理:
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/health` | 健康检查 |
| GET | `/status` | 余额、持仓、盈亏汇总 |
| POST | `/emergency/close-all` | 市价尽量平掉 USDT 永续仓位,并尝试撤该合约挂单 |
---
## 安全与边界
- 中控默认 **监听 0.0.0.0** 并放行私网访问,便于局域网使用;**公网非私网 IP** 仍会被中间件拒绝。
- 若机器有公网 IP,请用 **防火墙** 限制 `HUB_PORT` 仅内网可进,或改为 `HUB_HOST=127.0.0.1` + `HUB_TRUST_LAN=0` 仅本机。
- 子代理建议仍用 **`HOST=127.0.0.1`**,不要对局域网暴露交易所密钥通道。
- 中控**不具备**开仓、改策略、划转账能力;全平为**市价减仓**,请谨慎操作。
- 子代理与主策略进程共用密钥时,注意权限与 IP 白名单仍按交易所要求配置。
---
## 常见问题
**1. 某一行一直连不上**
检查该端口上的 `agent.py` 是否已启动、防火墙是否放行本机回环、`.env` 密钥是否与交易所一致。
**2. 暂时不用 OKX**
网页取消该行的「参与监控」,或启动中控前设置 `HUB_DISABLED_IDS=1`(默认 OKX 的 id 为 `1`)。
**3. 策略项目要不要改?**
不需要改 `crypto_monitor_*` 代码;只需额外运行 `agent.py` 进程。
**5. 局域网里别的电脑打不开中控**
默认应已可访问:中控监听 `0.0.0.0``HUB_TRUST_LAN` 默认开启。请检查:防火墙是否放行 `HUB_PORT`;浏览器是否使用 **中控机器的局域网 IP**(不要用另一台电脑上的 `127.0.0.1`)。若你曾设置 `HUB_TRUST_LAN=0``HUB_HOST=127.0.0.1`,改回默认或删掉环境变量后重启 hub。
更细的安装顺序、验收清单与可选常驻方式见同目录 **《部署文档.md》**。
+568
View File
@@ -0,0 +1,568 @@
"""
子账户极轻代理:仅 GET /status + POST /emergency/close-all,仅监听 127.0.0.1。
与仓库内四个策略/监控目录一一对应时,典型用法(各目录自己的 .env 里已有密钥;子代理用环境变量 PORT,勿与 Flask 的 APP_PORT 相同):
EXCHANGE=binance → crypto_monitor_binanceBINANCE_*
EXCHANGE=okx → crypto_monitor_okxOKX_*
EXCHANGE=gate → crypto_monitor_gate / crypto_monitor_gate_botGATE_*
环境变量:
EXCHANGE binance(默认)| okx | gate
PORT 默认 15200(与 crypto_monitor_* 的 Flask APP_PORT 错开;中控默认聚合 1520015203)
HOST 默认 127.0.0.1
CONTROL_TOKEN 可选;请求头 X-Control-Token
BinanceBINANCE_API_KEY / BINANCE_API_SECRET;余额为 **U 本位永续合约账户** USDT(与 `crypto_monitor_binance` 的合约口径一致,非现货钱包);BINANCE_POSITION_MODEBINANCE_MARGIN_MODE
OKXOKX_API_KEY / OKX_API_SECRET / OKX_API_PASSPHRASEOKX_TD_MODEOKX_POS_MODE
GateGATE_API_KEY / GATE_API_SECRETGATE_TD_MODEGATE_POS_MODE
代理与主项目一致时可设:BINANCE_SOCKS_PROXY / OKX_SOCKS_PROXY / GATE_SOCKS_PROXY(或 HTTP(S)_PROXY)。
"""
from __future__ import annotations
import math
import os
import time
from typing import Any
import ccxt
from fastapi import FastAPI, Header, HTTPException, Request
from fastapi.responses import JSONResponse
HOST = os.getenv("HOST", "127.0.0.1")
PORT = int(os.getenv("PORT", "15200"))
CONTROL_TOKEN = (os.getenv("CONTROL_TOKEN") or "").strip()
_raw_ex = (os.getenv("EXCHANGE") or "binance").strip().lower()
if _raw_ex in ("binance", "bnb", "ba"):
EXCHANGE_KIND = "binance"
elif _raw_ex in ("okx", "okex"):
EXCHANGE_KIND = "okx"
elif _raw_ex in ("gate", "gateio"):
EXCHANGE_KIND = "gate"
else:
EXCHANGE_KIND = "binance"
# —— Binance ——
_bin_pos = (os.getenv("BINANCE_POSITION_MODE") or "hedge").strip().lower()
BINANCE_POSITION_MODE = "hedge" if _bin_pos in ("hedge", "dual", "double", "hedged") else "oneway"
_bin_margin = (os.getenv("BINANCE_MARGIN_MODE") or "cross").strip().lower()
BINANCE_DEFAULT_MARGIN_MODE = "cross" if _bin_margin in ("cross", "cross_margin") else "isolated"
# —— OKX ——
OKX_TD_MODE = (os.getenv("OKX_TD_MODE") or "cross").strip()
_okx_pos = (os.getenv("OKX_POS_MODE") or "hedge").strip().lower()
OKX_POS_MODE = "hedge" if _okx_pos in ("hedge", "long_short_mode", "dual") else "net"
# —— Gate ——
_gate_td = (os.getenv("GATE_TD_MODE") or "cross").strip().lower()
GATE_DEFAULT_MARGIN_MODE = "cross" if _gate_td in ("cross", "cross_margin") else "isolated"
_gate_pos = (os.getenv("GATE_POS_MODE") or "hedge").strip().lower()
GATE_POS_MODE = "hedge" if _gate_pos in ("hedge", "dual", "double") else "single"
app = FastAPI(title="sub-agent", docs_url=None, redoc_url=None)
_ccxt_ex: Any = None
_markets_loaded = False
def _socks_proxy_url(prefix: str) -> str:
return (os.getenv(f"{prefix}_SOCKS_PROXY") or "").strip()
def _http_https_proxy(prefix: str) -> dict[str, str] | None:
http = (os.getenv(f"{prefix}_HTTP_PROXY") or "").strip()
https = (os.getenv(f"{prefix}_HTTPS_PROXY") or "").strip()
socks = _socks_proxy_url(prefix)
if socks:
return {"http": socks, "https": socks}
if http or https:
return {"http": http, "https": https}
return None
def _attach_proxies(ex: Any, prefix: str) -> None:
p = _http_https_proxy(prefix)
if p:
ex.proxies = p
def _make_exchange() -> Any:
if EXCHANGE_KIND == "binance":
key = (os.getenv("BINANCE_API_KEY") or "").strip()
secret = (os.getenv("BINANCE_API_SECRET") or "").strip()
if not key or not secret:
raise RuntimeError("缺少 BINANCE_API_KEY / BINANCE_API_SECRET")
ex = ccxt.binance(
{
"apiKey": key,
"secret": secret,
"enableRateLimit": True,
"options": {
"defaultType": "swap",
# ccxt 默认 fetch_balance 走现货;与监控项目一致,固定为 U 本位合约钱包
"fetchBalance": {"defaultType": "swap"},
"defaultMarginMode": BINANCE_DEFAULT_MARGIN_MODE,
"adjustForTimeDifference": True,
},
}
)
_attach_proxies(ex, "BINANCE")
return ex
if EXCHANGE_KIND == "okx":
key = (os.getenv("OKX_API_KEY") or "").strip()
secret = (os.getenv("OKX_API_SECRET") or "").strip()
password = (os.getenv("OKX_API_PASSPHRASE") or "").strip()
if not key or not secret or not password:
raise RuntimeError("缺少 OKX_API_KEY / OKX_API_SECRET / OKX_API_PASSPHRASE")
ex = ccxt.okx(
{
"apiKey": key,
"secret": secret,
"password": password,
"enableRateLimit": True,
"options": {"defaultType": "swap"},
}
)
_attach_proxies(ex, "OKX")
return ex
# gate
key = (os.getenv("GATE_API_KEY") or "").strip()
secret = (os.getenv("GATE_API_SECRET") or "").strip()
if not key or not secret:
raise RuntimeError("缺少 GATE_API_KEY / GATE_API_SECRET")
ex = ccxt.gateio(
{
"apiKey": key,
"secret": secret,
"enableRateLimit": True,
"options": {
"defaultType": "swap",
"defaultMarginMode": GATE_DEFAULT_MARGIN_MODE,
},
}
)
_attach_proxies(ex, "GATE")
return ex
def get_exchange() -> Any:
global _ccxt_ex
if _ccxt_ex is None:
_ccxt_ex = _make_exchange()
return _ccxt_ex
def _ensure_markets() -> None:
global _markets_loaded
if not _markets_loaded:
get_exchange().load_markets()
_markets_loaded = True
def _check_token(x_control_token: str | None) -> None:
if not CONTROL_TOKEN:
return
if (x_control_token or "").strip() != CONTROL_TOKEN:
raise HTTPException(status_code=401, detail="invalid token")
def _position_mode_label() -> str:
if EXCHANGE_KIND == "binance":
return BINANCE_POSITION_MODE
if EXCHANGE_KIND == "okx":
return OKX_POS_MODE
return GATE_POS_MODE
def _close_param_candidates_binance(direction: str) -> list[dict[str, Any]]:
ps = "LONG" if direction == "long" else "SHORT"
hedge_ro = {"positionSide": ps, "reduceOnly": True}
hedge_plain = {"positionSide": ps}
oneway_ro = {"reduceOnly": True}
oneway_plain: dict[str, Any] = {}
if BINANCE_POSITION_MODE == "hedge":
return [hedge_ro, hedge_plain, oneway_ro, oneway_plain]
return [oneway_ro, oneway_plain, hedge_ro, hedge_plain]
def _close_param_candidates_okx(direction: str) -> list[dict[str, Any]]:
base: dict[str, Any] = {"tdMode": OKX_TD_MODE}
out: list[dict[str, Any]] = []
if OKX_POS_MODE == "hedge":
ps = "long" if direction == "long" else "short"
out.extend(
[
{**base, "posSide": ps, "reduceOnly": True},
{**base, "posSide": ps},
]
)
out.extend([{**base, "reduceOnly": True}, dict(base)])
return out
def _close_param_candidates_gate(_direction: str) -> list[dict[str, Any]]:
return [{"reduceOnly": True}, {}]
def _close_param_candidates(direction: str) -> list[dict[str, Any]]:
if EXCHANGE_KIND == "binance":
return _close_param_candidates_binance(direction)
if EXCHANGE_KIND == "okx":
return _close_param_candidates_okx(direction)
return _close_param_candidates_gate(direction)
def _retryable_close_err(msg: str) -> bool:
s = (msg or "").lower()
if "-4061" in s:
return True
if "-1106" in s and "reduceonly" in s:
return True
if "reduceonly" in s or "reduce only" in s:
return True
if "position side" in s or "positionside" in s or "pos side" in s:
return True
if "dual side" in s or "position mode" in s:
return True
return False
def _position_contracts(p: dict[str, Any]) -> float:
raw = p.get("contracts")
if raw is not None:
try:
return float(raw)
except (TypeError, ValueError):
pass
info = p.get("info") or {}
for k in ("positionAmt", "positionamt", "pos", "size"):
if k in info:
try:
v = float(info[k])
if v != 0:
return v
except (TypeError, ValueError):
pass
return 0.0
def _position_side(p: dict[str, Any], contracts: float) -> str:
s = (p.get("side") or "").lower()
if s in ("long", "short"):
return s
if contracts > 0:
return "long"
if contracts < 0:
return "short"
return "long"
def _cancel_symbol_orders(ex: Any, sym: str) -> None:
try:
ex.cancel_all_orders(sym, params={})
except Exception:
pass
if EXCHANGE_KIND != "binance":
return
try:
m = ex.market(sym)
cid = m.get("id")
if cid and hasattr(ex, "fapiPrivateDeleteAlgoOpenOrders"):
ex.fapiPrivateDeleteAlgoOpenOrders({"symbol": cid})
except Exception:
pass
def _is_local(host: str | None) -> bool:
if not host:
return False
h = host.lower()
return h in ("127.0.0.1", "::1", "localhost") or h.startswith("::ffff:127.0.0.1")
def _finite_or_none(x: Any) -> float | None:
try:
f = float(x)
return f if math.isfinite(f) else None
except (TypeError, ValueError):
return None
def _extract_usdt_total(balance: dict[str, Any]) -> float | None:
"""从 ccxt balance 结构中尽量取出 USDT 总额(与 crypto_monitor_binance 一致)。"""
usdt_info = balance.get("USDT") or {}
if not isinstance(usdt_info, dict):
usdt_info = {}
total_map = balance.get("total") or {}
if not isinstance(total_map, dict):
total_map = {}
free_map = balance.get("free") or {}
if not isinstance(free_map, dict):
free_map = {}
total = usdt_info.get("total")
if total is None:
total = usdt_info.get("equity")
if total is None:
total = total_map.get("USDT")
if total is None:
total = usdt_info.get("free")
if total is None:
total = free_map.get("USDT")
try:
return float(total) if total is not None else None
except (TypeError, ValueError):
return None
def _binance_futures_usdt_asset_row(balance: Any) -> dict[str, Any] | None:
"""U 本位合约 fetch_balance(type=swap) 的 info.assets 中 USDT 一行(与币安合约后台口径一致)。"""
if not isinstance(balance, dict):
return None
info = balance.get("info")
if not isinstance(info, dict):
return None
assets = info.get("assets")
if not isinstance(assets, list):
return None
for a in assets:
if isinstance(a, dict) and str(a.get("asset") or "").upper() == "USDT":
return a
return None
def _binance_swap_usdt_total(ex: Any) -> float | None:
"""仅 U 本位永续合约账户 USDT(显式 type=swap,不用现货余额)。"""
try:
bal = ex.fetch_balance({"type": "swap"})
except Exception:
return None
row = _binance_futures_usdt_asset_row(bal)
if row:
for k in ("marginBalance", "walletBalance", "crossWalletBalance", "balance"):
x = row.get(k)
if x is not None and str(x).strip() != "":
try:
fv = float(x)
if fv >= 0:
return fv
except (TypeError, ValueError):
pass
v = _extract_usdt_total(bal)
return float(v) if v is not None else None
@app.middleware("http")
async def local_only(request: Request, call_next):
if request.client and not _is_local(request.client.host):
return JSONResponse({"detail": "forbidden"}, status_code=403)
return await call_next(request)
@app.get("/health")
def health():
return {"ok": True, "exchange": EXCHANGE_KIND}
@app.get("/status")
def status(x_control_token: str | None = Header(default=None, alias="X-Control-Token")):
try:
return _status_inner(x_control_token)
except HTTPException:
raise
except Exception as e:
return JSONResponse(
{
"ok": False,
"error": f"status: {e}",
"exchange": EXCHANGE_KIND,
"balance_usdt": None,
"positions": [],
"total_unrealized_pnl": None,
},
status_code=200,
)
def _status_inner(x_control_token: str | None) -> Any:
_check_token(x_control_token)
try:
ex = get_exchange()
except RuntimeError as e:
return JSONResponse(
{
"ok": False,
"error": str(e),
"exchange": EXCHANGE_KIND,
"balance_usdt": None,
"positions": [],
"total_unrealized_pnl": None,
},
status_code=200,
)
try:
_ensure_markets()
except Exception as e:
return JSONResponse(
{
"ok": False,
"error": f"load_markets: {e}",
"exchange": EXCHANGE_KIND,
"balance_usdt": None,
"positions": [],
"total_unrealized_pnl": None,
},
status_code=200,
)
balance_usdt: float | None = None
try:
if EXCHANGE_KIND == "binance":
balance_usdt = _binance_swap_usdt_total(ex)
else:
bal = ex.fetch_balance()
u = bal.get("USDT") or {}
if isinstance(u, dict) and u.get("total") is not None:
balance_usdt = _finite_or_none(u["total"])
except Exception:
pass
positions_out: list[dict[str, Any]] = []
total_upnl = 0.0
try:
raw = ex.fetch_positions() or []
except Exception as e:
return JSONResponse(
{
"ok": False,
"error": str(e),
"exchange": EXCHANGE_KIND,
"balance_usdt": balance_usdt,
"positions": [],
"total_unrealized_pnl": None,
},
status_code=200,
)
for p in raw:
if not isinstance(p, dict):
continue
c = _position_contracts(p)
if abs(c) < 1e-12:
continue
sym = p.get("symbol") or ""
side = _position_side(p, c)
upnl = p.get("unrealizedPnl")
try:
upnl_f = float(upnl) if upnl is not None else 0.0
except (TypeError, ValueError):
upnl_f = 0.0
total_upnl += upnl_f
notional = p.get("notional")
try:
notional_f = float(notional) if notional is not None else None
except (TypeError, ValueError):
notional_f = None
entry = p.get("entryPrice")
try:
entry_f = float(entry) if entry is not None else None
except (TypeError, ValueError):
entry_f = None
positions_out.append(
{
"symbol": sym,
"side": side,
"contracts": abs(c),
"contracts_signed": c,
"notional_usdt": _finite_or_none(notional_f) if notional_f is not None else None,
"unrealized_pnl": _finite_or_none(upnl_f),
"entry_price": _finite_or_none(entry_f) if entry_f is not None else None,
}
)
try:
pm = _position_mode_label()
except Exception:
pm = EXCHANGE_KIND
return {
"ok": True,
"exchange": EXCHANGE_KIND,
"balance_usdt": balance_usdt,
"positions": positions_out,
"total_unrealized_pnl": _finite_or_none(total_upnl),
"position_mode": pm,
}
@app.post("/emergency/close-all")
def emergency_close_all(x_control_token: str | None = Header(default=None, alias="X-Control-Token")):
_check_token(x_control_token)
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": [], "errors": [str(e)], "exchange": EXCHANGE_KIND},
status_code=200,
)
errors: list[str] = []
closed: list[dict[str, Any]] = []
try:
raw = ex.fetch_positions() or []
except Exception as e:
raise HTTPException(status_code=502, detail=f"fetch_positions: {e}") from e
for p in raw:
if not isinstance(p, dict):
continue
c = _position_contracts(p)
if abs(c) < 1e-12:
continue
sym = p.get("symbol")
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)
time.sleep(0.05)
return {"ok": len(errors) == 0, "closed": closed, "errors": errors, "exchange": EXCHANGE_KIND}
def main():
import uvicorn
uvicorn.run(app, host=HOST, port=PORT, log_level="warning", access_log=False)
if __name__ == "__main__":
main()
+272
View File
@@ -0,0 +1,272 @@
"""
中控:聚合各子账户 /status,转发紧急全平。
默认 **HUB_HOST=0.0.0.0** 且 **HUB_TRUST_LAN=开启**,便于局域网内浏览器访问;中间件仍拒绝非公网、非 RFC1918 私网的来源(本机 127.0.0.1 始终允许)。
若仅需本机访问,请设置:HUB_HOST=127.0.0.1 或 HUB_TRUST_LAN=0false/off)。
与仓库根目录下四个策略/监控项目对应时,中控默认聚合的子代理地址为 127.0.0.1:1520015203
(与各 crypto_monitor_* 里 Flask 的 APP_PORT 错开;Flask 仍用各自 .env 的 APP_HOST/APP_PORT)。
crypto_monitor_binance → 子代理建议 15200
crypto_monitor_okx → 子代理建议 15201
crypto_monitor_gate → 子代理建议 15202
crypto_monitor_gate_bot→ 子代理建议 15203
各目录单独启动 agent.py 时设置 PORT=上述端口(环境变量名是 PORT,不是 APP_PORT),与 Flask 并存。
环境变量:
HUB_PORT 默认 5100
HUB_HOST 默认 0.0.0.0(局域网可连);改为 127.0.0.1 则仅本机
HUB_AGENTS 逗号分隔子代理 URL,留空则默认 1520015203(避免与 Flask APP_PORT 冲突)
HUB_AGENT_NAMES 可选,逗号分隔显示名,与 URL 顺序对应
HUB_DISABLED_IDS 可选,逗号分隔不参与监控/全平的账户 id(与 /api/agents 中 id 一致),例:暂不用 OKX 时写 1
CONTROL_TOKEN 若子代理启用校验,在此填同一令牌(由中控代发请求头)
HUB_TRUST_LAN 默认开启;设为 0/false/off 则仅允许本机 IP 访问(与 HUB_HOST=0.0.0.0 搭配时仍只放行 127.0.0.1
"""
from __future__ import annotations
import asyncio
import os
from pathlib import Path
import httpx
from fastapi import Body, FastAPI, HTTPException, Query, Request
from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field
HUB_HOST = os.getenv("HUB_HOST", "0.0.0.0")
HUB_PORT = int(os.getenv("HUB_PORT", "5100"))
CONTROL_TOKEN = (os.getenv("CONTROL_TOKEN") or "").strip()
_trust_raw = (os.getenv("HUB_TRUST_LAN", "true") or "").strip().lower()
HUB_TRUST_LAN = _trust_raw not in ("0", "false", "no", "off")
DIR = Path(__file__).resolve().parent
def _is_local(host: str | None) -> bool:
if not host:
return False
h = host.lower()
return h in ("127.0.0.1", "::1", "localhost") or h.startswith("::ffff:127.0.0.1")
def _ipv4_rfc1918_private(host: str) -> bool:
h = host.lower()
if h.startswith("::ffff:"):
h = h[7:]
parts = h.split(".")
if len(parts) != 4:
return False
try:
a, b, c, d = (int(x) for x in parts)
except ValueError:
return False
if any(x < 0 or x > 255 for x in (a, b, c, d)):
return False
if a == 10:
return True
if a == 172 and 16 <= b <= 31:
return True
if a == 192 and b == 168:
return True
return False
def _client_allowed(host: str | None) -> bool:
if _is_local(host):
return True
if HUB_TRUST_LAN and host and _ipv4_rfc1918_private(host):
return True
return False
def _agent_headers() -> dict[str, str]:
if not CONTROL_TOKEN:
return {}
return {"X-Control-Token": CONTROL_TOKEN}
_DEFAULT_FOLDER_LABELS = (
"币安山寨账户 · crypto_monitor_binance",
"OKX · crypto_monitor_okx",
"Gate训练账户 · crypto_monitor_gate",
"Gate趋势回调 · crypto_monitor_gate_bot",
)
def _ids_from_csv(raw: str | None) -> set[str]:
if not raw or not str(raw).strip():
return set()
return {x.strip() for x in str(raw).split(",") if x.strip()}
def hub_env_excluded_ids() -> set[str]:
"""服务端固定关闭的账户(不参与拉取 /status、不参与全局全平)。"""
return _ids_from_csv(os.getenv("HUB_DISABLED_IDS"))
def merged_excluded_ids(query_exclude: str | None, body_ids: list[str] | None) -> set[str]:
s = hub_env_excluded_ids()
s |= _ids_from_csv(query_exclude)
if body_ids:
s |= {str(x).strip() for x in body_ids if str(x).strip()}
return s
def parse_agents() -> list[dict[str, str]]:
urls_s = (os.getenv("HUB_AGENTS") or "").strip()
if urls_s:
urls = [u.strip() for u in urls_s.split(",") if u.strip()]
else:
urls = [f"http://127.0.0.1:{p}" for p in range(15200, 15204)]
# 注意:若环境变量 HUB_AGENT_NAMES 非空,会完全优先于 _DEFAULT_FOLDER_LABELS(改代码不生效时请检查是否设了该变量)
names_s = (os.getenv("HUB_AGENT_NAMES") or "").strip()
names = [n.strip() for n in names_s.split(",") if n.strip()] if names_s else []
out = []
for i, url in enumerate(urls):
if i < len(names):
name = names[i]
elif i < len(_DEFAULT_FOLDER_LABELS):
name = _DEFAULT_FOLDER_LABELS[i]
else:
name = f"账户{i + 1}"
out.append({"id": str(i), "name": name, "url": url.rstrip("/")})
return out
app = FastAPI(title="hub", docs_url=None, redoc_url=None)
STATIC_DIR = DIR / "static"
if STATIC_DIR.is_dir():
app.mount("/assets", StaticFiles(directory=str(STATIC_DIR)), name="assets")
@app.middleware("http")
async def local_only(request: Request, call_next):
if request.client and not _client_allowed(request.client.host):
return JSONResponse({"detail": "forbidden"}, status_code=403)
return await call_next(request)
@app.get("/")
def index_page():
index = STATIC_DIR / "index.html"
if not index.is_file():
return JSONResponse({"detail": "missing static/index.html"}, status_code=500)
return FileResponse(index)
@app.get("/api/agents")
def api_agents():
return {"agents": parse_agents()}
class CloseAllBody(BaseModel):
exclude_ids: list[str] = Field(default_factory=list)
@app.get("/api/snapshot")
async def api_snapshot(
exclude_ids: str | None = Query(
default=None,
description="逗号分隔,浏览器侧再关闭的账户 id,与服务端 HUB_DISABLED_IDS 合并",
),
):
excl = merged_excluded_ids(exclude_ids, None)
agents = [a for a in parse_agents() if a["id"] not in excl]
headers = _agent_headers()
async def one(client: httpx.AsyncClient, a: dict[str, str]) -> dict:
url = f"{a['url']}/status"
try:
r = await client.get(url, headers=headers, timeout=10.0)
body = None
if r.content:
try:
body = r.json()
except Exception as je:
preview = (r.text or "")[:400].replace("\n", " ")
return {
"id": a["id"],
"name": a["name"],
"url": a["url"],
"http_ok": False,
"status_code": r.status_code,
"error": f"子代理返回非 JSON{je})。响应片段: {preview!r}",
"payload": None,
}
return {
"id": a["id"],
"name": a["name"],
"url": a["url"],
"http_ok": r.status_code == 200,
"status_code": r.status_code,
"payload": body,
}
except Exception as e:
return {
"id": a["id"],
"name": a["name"],
"url": a["url"],
"http_ok": False,
"status_code": None,
"error": str(e),
"payload": None,
}
async with httpx.AsyncClient() as client:
rows = await asyncio.gather(*[one(client, a) for a in agents])
env_ex = sorted(hub_env_excluded_ids())
return {"rows": list(rows), "env_excluded_ids": env_ex}
@app.post("/api/close/{agent_id}")
async def api_close_one(agent_id: str):
agents = parse_agents()
target = next((a for a in agents if a["id"] == agent_id), None)
if not target:
raise HTTPException(status_code=404, detail="unknown agent")
headers = _agent_headers()
url = f"{target['url']}/emergency/close-all"
try:
async with httpx.AsyncClient() as client:
r = await client.post(url, headers=headers, timeout=120.0)
try:
body = r.json()
except Exception:
body = {"raw": r.text[:2000]}
return {"agent": target, "status_code": r.status_code, "payload": body}
except Exception as e:
raise HTTPException(status_code=502, detail=str(e)) from e
@app.post("/api/close-all")
async def api_close_all(body: CloseAllBody | None = Body(default=None)):
excl = merged_excluded_ids(None, body.exclude_ids if body else None)
agents = [a for a in parse_agents() if a["id"] not in excl]
headers = _agent_headers()
async def post_close(client: httpx.AsyncClient, a: dict[str, str]) -> dict:
url = f"{a['url']}/emergency/close-all"
try:
r = await client.post(url, headers=headers, timeout=120.0)
try:
body = r.json()
except Exception:
body = {"raw": r.text[:2000]}
return {"id": a["id"], "name": a["name"], "status_code": r.status_code, "payload": body}
except Exception as e:
return {"id": a["id"], "name": a["name"], "status_code": None, "error": str(e)}
async with httpx.AsyncClient() as client:
results = await asyncio.gather(*[post_close(client, a) for a in agents])
return {"results": list(results)}
def main():
import uvicorn
uvicorn.run(app, host=HUB_HOST, port=HUB_PORT, log_level="warning", access_log=False)
if __name__ == "__main__":
main()
+5
View File
@@ -0,0 +1,5 @@
fastapi>=0.110,<1
uvicorn[standard]>=0.27,<1
httpx>=0.27,<1
ccxt>=4.2,<5
PySocks>=1.7,<2
@@ -0,0 +1,20 @@
[Unit]
Description=手工交易子代理 Binance(复制为 manual-agent-binance.service 并修改路径)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=YOUR_USER
WorkingDirectory=YOUR_REPO/crypto_monitor_binance
Environment=PATH=YOUR_REPO/manual_trading_hub/.venv/bin:/usr/bin:/bin
Environment=EXCHANGE=binance
Environment=PORT=15200
Environment=HOST=127.0.0.1
EnvironmentFile=-YOUR_REPO/crypto_monitor_binance/.env
ExecStart=YOUR_REPO/manual_trading_hub/.venv/bin/python YOUR_REPO/manual_trading_hub/agent.py
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,18 @@
[Unit]
Description=手工交易中控 hub(复制到 /etc/systemd/system/manual-hub.service 并修改路径)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=YOUR_USER
WorkingDirectory=YOUR_REPO/manual_trading_hub
Environment=HUB_HOST=0.0.0.0
Environment=HUB_TRUST_LAN=1
Environment=HUB_PORT=5100
ExecStart=YOUR_REPO/manual_trading_hub/.venv/bin/python YOUR_REPO/manual_trading_hub/hub.py
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,81 @@
#!/usr/bin/env bash
# 一键用 screen 后台启动 3 个子代理(不含 OKX):Binance:15200、Gate:15202、Gate-Bot:15203
# 用法:在任意目录执行 bash /path/to/manual_trading_hub/scripts/start_agents_3screen.sh
# 若 hub 单独在 /opt/manual_trading_hub,四个策略目录在别的路径,请先:
# export MANUAL_TRADING_REPO_ROOT=/path/to/含_crypto_monitor_*_的目录
# 依赖:各策略目录下存在 .envmanual_trading_hub/.venv 已 pip install -r requirements.txt
# 日志:manual_trading_hub/logs/<会话名>.log(若 screen 里进程秒退,tail 该文件排查)
# 查看:screen -ls 接入:screen -r mt-agent-bn 停:./stop_agents_3screen.sh
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
HUB_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
if [[ -n "${MANUAL_TRADING_REPO_ROOT:-}" ]]; then
REPO_ROOT="$(cd "${MANUAL_TRADING_REPO_ROOT}" && pwd)"
else
REPO_ROOT="$(cd "${HUB_DIR}/.." && pwd)"
fi
AGENT_PY="${HUB_DIR}/agent.py"
VENV_PY="${HUB_DIR}/.venv/bin/python"
LOG_DIR="${HUB_DIR}/logs"
mkdir -p "${LOG_DIR}"
echo "REPO_ROOT=${REPO_ROOT} (其下应有 crypto_monitor_binance、crypto_monitor_gate 等目录)"
if ! command -v screen >/dev/null 2>&1; then
echo "未找到 screen,请先安装:sudo apt install screen" >&2
exit 1
fi
if [[ ! -f "${VENV_PY}" ]]; then
echo "未找到 ${VENV_PY},请先在 manual_trading_hub 下创建 venv 并 pip install -r requirements.txt" >&2
exit 1
fi
if [[ ! -f "${AGENT_PY}" ]]; then
echo "未找到 agent.py${AGENT_PY}" >&2
exit 1
fi
start_one() {
local name="$1" subdir="$2" exchange="$3" port="$4"
local work="${REPO_ROOT}/${subdir}"
local logf="${LOG_DIR}/${name}.log"
if [[ ! -d "${work}" ]]; then
echo "目录不存在,跳过:${work}" >&2
echo " 若项目在别处,请设置 export MANUAL_TRADING_REPO_ROOT=/正确上级目录 后重跑" >&2
return 1
fi
if screen -ls 2>/dev/null | grep -qF ".${name}"; then
echo "已存在会话 ${name},跳过。要重建请先: screen -S ${name} -X quit" >&2
return 0
fi
screen -dmS "${name}" bash -c "
cd '${work}' || { echo 'cd failed' >>'${logf}'; exit 1; }
set -a
if [[ -f .env ]]; then
. ./.env
fi
set +a
export EXCHANGE='${exchange}' PORT='${port}' HOST=127.0.0.1
exec '${VENV_PY}' '${AGENT_PY}' >>'${logf}' 2>&1
"
sleep 0.5
if screen -ls 2>/dev/null | grep -qF ".${name}"; then
echo "已启动 screen${name} (${subdir} EXCHANGE=${exchange} PORT=${port}) 日志:${logf}"
else
echo "错误:${name} 启动后立刻退出。请执行: tail -80 '${logf}'" >&2
fi
}
start_one "mt-agent-bn" "crypto_monitor_binance" "binance" "15200"
start_one "mt-agent-gate" "crypto_monitor_gate" "gate" "15202"
start_one "mt-agent-gatebot" "crypto_monitor_gate_bot" "gate" "15203"
echo ""
echo "下一步(一键中控 screen):"
echo " chmod +x ${SCRIPT_DIR}/start_hub_screen.sh && ${SCRIPT_DIR}/start_hub_screen.sh"
echo "或手动:cd ${HUB_DIR} && source .venv/bin/activate && export HUB_AGENTS=... && python hub.py"
echo ""
echo "查看会话: screen -ls"
@@ -0,0 +1,75 @@
#!/usr/bin/env bash
# 一键用 screen 后台启动中控 hub.py(默认对接 3 个 agent:不含 OKX
# 用法:先启动 3 个 agentstart_agents_3screen.sh),再执行本脚本
# bash /path/to/manual_trading_hub/scripts/start_hub_screen.sh
# 可在运行前 export 覆盖:HUB_AGENTS、HUB_AGENT_NAMES、HUB_HOST、HUB_PORT、CONTROL_TOKEN、HUB_TRUST_LAN
# 默认 HUB_HOST=0.0.0.0、HUB_TRUST_LAN=1(局域网可访问私网 IP)。仅本机: export HUB_HOST=127.0.0.1 HUB_TRUST_LAN=0
# 查看:screen -ls 接入:screen -r mt-hub 停:./stop_hub_screen.sh
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
HUB_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
VENV_PY="${HUB_DIR}/.venv/bin/python"
HUB_PY="${HUB_DIR}/hub.py"
HUB_AGENTS="${HUB_AGENTS:-http://127.0.0.1:15200,http://127.0.0.1:15202,http://127.0.0.1:15203}"
# 不设 HUB_AGENT_NAMES 时不在此写死,由 hub.py 里 _DEFAULT_FOLDER_LABELS 生效;要覆盖可: export HUB_AGENT_NAMES="名1,名2,名3" 或写在 manual_trading_hub/.env
HUB_AGENT_NAMES="${HUB_AGENT_NAMES:-}"
HUB_HOST="${HUB_HOST:-0.0.0.0}"
HUB_PORT="${HUB_PORT:-5100}"
HUB_TRUST_LAN="${HUB_TRUST_LAN:-1}"
if ! command -v screen >/dev/null 2>&1; then
echo "未找到 screen,请先安装:sudo apt install screen" >&2
exit 1
fi
if [[ ! -f "${VENV_PY}" ]]; then
echo "未找到 ${VENV_PY},请先在 manual_trading_hub 下创建 venv 并 pip install -r requirements.txt" >&2
exit 1
fi
if [[ ! -f "${HUB_PY}" ]]; then
echo "未找到 hub.py${HUB_PY}" >&2
exit 1
fi
if screen -ls 2>/dev/null | grep -qF ".mt-hub"; then
echo "已存在会话 mt-hub,跳过。要重建请先: screen -S mt-hub -X quit" >&2
exit 0
fi
# 仅当外层显式设置了 HUB_AGENT_NAMES 时才 export,避免 export 空串覆盖 .env 里已有配置
EXTRA_NAMES=""
if [[ -n "${HUB_AGENT_NAMES:-}" ]]; then
EXTRA_NAMES="export HUB_AGENT_NAMES=$(printf '%q' "${HUB_AGENT_NAMES}")"
fi
screen -dmS mt-hub bash -c "
set -e
cd '${HUB_DIR}'
set -a
if [[ -f .env ]]; then
. ./.env
fi
set +a
export HUB_AGENTS='${HUB_AGENTS}'
${EXTRA_NAMES}
export HUB_HOST='${HUB_HOST}'
export HUB_PORT='${HUB_PORT}'
export HUB_TRUST_LAN='${HUB_TRUST_LAN}'
exec '${VENV_PY}' '${HUB_PY}'
"
echo "已启动 screenmt-hub"
echo " HUB_AGENTS=${HUB_AGENTS}"
if [[ -n "${HUB_AGENT_NAMES}" ]]; then
echo " HUB_AGENT_NAMES=${HUB_AGENT_NAMES}"
else
echo " HUB_AGENT_NAMES=(未设,使用 hub.py 内 _DEFAULT_FOLDER_LABELS)"
fi
echo " 监听:${HUB_HOST}:${HUB_PORT} HUB_TRUST_LAN=${HUB_TRUST_LAN}(默认允许私网访问中控)"
echo " 本机:http://127.0.0.1:${HUB_PORT}/ 局域网:http://<本机局域网IP>:${HUB_PORT}/"
echo " 仅本机请: export HUB_HOST=127.0.0.1 HUB_TRUST_LAN=0 后重启本脚本"
echo "接入: screen -r mt-hub"
@@ -0,0 +1,10 @@
#!/usr/bin/env bash
# 关闭 start_agents_3screen.sh 启动的 3 个 screen 会话
set -euo pipefail
for s in mt-agent-bn mt-agent-gate mt-agent-gatebot; do
if screen -ls 2>/dev/null | grep -qF ".${s}"; then
screen -S "${s}" -X quit && echo "已关闭:${s}" || true
else
echo "未运行:${s}"
fi
done
@@ -0,0 +1,8 @@
#!/usr/bin/env bash
# 关闭 start_hub_screen.sh 启动的中控 screen 会话
set -euo pipefail
if screen -ls 2>/dev/null | grep -qF ".mt-hub"; then
screen -S mt-hub -X quit && echo "已关闭:mt-hub" || true
else
echo "未运行:mt-hub"
fi
@@ -0,0 +1,189 @@
# Ubuntu 后台运行(中控 + 子代理)
前台跑 `python agent.py` / `python hub.py` 时,关掉终端进程会结束。要**常驻后台**,可用下面三种之一(推荐 **systemd**)。
---
## 一、tmux / screen(最简单,适合先试用)
仓库已提供 **一键起 3 个 agent(不含 OKX** 的 screen 脚本(需可执行权限):
```bash
chmod +x manual_trading_hub/scripts/start_agents_3screen.sh
chmod +x manual_trading_hub/scripts/start_hub_screen.sh
chmod +x manual_trading_hub/scripts/stop_agents_3screen.sh
chmod +x manual_trading_hub/scripts/stop_hub_screen.sh
./manual_trading_hub/scripts/start_agents_3screen.sh
./manual_trading_hub/scripts/start_hub_screen.sh
# 关闭:
./manual_trading_hub/scripts/stop_hub_screen.sh
./manual_trading_hub/scripts/stop_agents_3screen.sh
```
脚本默认认为:`manual_trading_hub` 的**上一级目录**里并列放着三个 `crypto_monitor_*`。若你把 hub 单独放在 `/opt/manual_trading_hub`,而策略项目在例如 `/opt/交易复盘系统/`,请先执行
`export MANUAL_TRADING_REPO_ROOT=/opt/交易复盘系统` 再运行 `start_agents_3screen.sh`
启动后若 `screen -ls` 里没有 `mt-agent-*`,看日志:`tail -80 /opt/manual_trading_hub/logs/mt-agent-bn.log`
### 局域网内其他电脑访问中控
中控 **默认** `HUB_HOST=0.0.0.0``HUB_TRUST_LAN=开启`,同一局域网内可用 `http://<中控机局域网IP>:5100/` 打开页面(本机仍可用 `http://127.0.0.1:5100/`)。请确保防火墙放行端口,例如:`sudo ufw allow 5100/tcp`
若改为 **仅本机** 访问:`HUB_HOST=127.0.0.1``HUB_TRUST_LAN=0`,重启 hub。
也可把上述变量写进 `manual_trading_hub/.env`,再用 `start_hub_screen.sh` 启动。
以下为手工 tmux 示例:
```bash
# 新建会话,在里面照常启动 agent 或 hub,然后按键 Ctrl+B 再按 D 脱离
tmux new -s hub
cd /你的路径/交易复盘系统/manual_trading_hub && source .venv/bin/activate && python hub.py
# Ctrl+B, D
tmux new -s agent-bn
cd /你的路径/交易复盘系统/crypto_monitor_binance && set -a && source .env && set +a
export EXCHANGE=binance PORT=15200 HOST=127.0.0.1
source /你的路径/交易复盘系统/manual_trading_hub/.venv/bin/activate
python /你的路径/交易复盘系统/manual_trading_hub/agent.py
# Ctrl+B, D
```
重新连上:`tmux attach -t hub`
---
## 二、nohup(快速、无守护重启)
```bash
cd /你的路径/交易复盘系统/manual_trading_hub
source .venv/bin/activate
nohup python hub.py > /tmp/manual-hub.log 2>&1 &
```
子代理同理(每个账户一条):**先 `source` 该策略目录的 `.env`**`agent.py` 不会自己读文件),再 `nohup python …/agent.py`
停进程:`ps aux | grep hub.py``grep agent.py`,再 `kill <pid>`
---
## 三、systemd(推荐:开机自启、崩溃自动拉起)
1. 把下面两个示例里的 **`YOUR_REPO`** 改成你本机「交易复盘系统」的绝对路径,`YOUR_USER` 改成 Linux 用户名。
2. 复制到 `/etc/systemd/system/`(需 sudo),文件名例如 `manual-hub.service``manual-agent-binance.service`
3. 执行:
```bash
sudo systemctl daemon-reload
sudo systemctl enable --now manual-hub.service
sudo systemctl enable --now manual-agent-binance.service
# 其余 OKX / Gate 同理再建 3 个 unit 或合并为多条
```
查看状态:`sudo systemctl status manual-hub`
日志:`journalctl -u manual-hub -f`
### 示例:`/etc/systemd/system/manual-hub.service`
```ini
[Unit]
Description=手工交易中控 hub
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=YOUR_USER
WorkingDirectory=YOUR_REPO/manual_trading_hub
Environment=HUB_HOST=0.0.0.0
Environment=HUB_TRUST_LAN=1
Environment=HUB_PORT=5100
ExecStart=YOUR_REPO/manual_trading_hub/.venv/bin/python YOUR_REPO/manual_trading_hub/hub.py
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
```
**注意:** `agent.py` **不会**像 Flask 那样自动加载目录里的 `.env`,密钥必须由 **systemd 的 `EnvironmentFile=`** 注入,或用下面 `bash -c` 方式 `source .env` 后再启动。
### 示例:`/etc/systemd/system/manual-agent-binance.service`
```ini
[Unit]
Description=手工交易子代理 Binance
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=YOUR_USER
WorkingDirectory=YOUR_REPO/crypto_monitor_binance
Environment=PATH=YOUR_REPO/manual_trading_hub/.venv/bin:/usr/bin:/bin
Environment=EXCHANGE=binance
Environment=PORT=15200
Environment=HOST=127.0.0.1
# 把该账户的 .env 注入进程(与 Flask 同一份即可;仅支持 KEY=VALUE 行,勿写 shell 语法)
EnvironmentFile=-YOUR_REPO/crypto_monitor_binance/.env
ExecStart=YOUR_REPO/manual_trading_hub/.venv/bin/python YOUR_REPO/manual_trading_hub/agent.py
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
```
`.env`**systemd 无法解析** 的内容(复杂引号、`export` 等),改用:
```ini
ExecStart=/bin/bash -lc 'set -a; source YOUR_REPO/crypto_monitor_binance/.env; set +a; exec YOUR_REPO/manual_trading_hub/.venv/bin/python YOUR_REPO/manual_trading_hub/agent.py'
```
并删掉或注释掉 `EnvironmentFile=` 行,避免重复注入。
**OKX / Gate / Gate-Bot**:各复制一份 `.service`,改 `Description``WorkingDirectory`、以及 `EXCHANGE` / `PORT``15201``15202``15203`)。
---
## 四、常见问题(子代理 / screen / 依赖)
1. **`curl http://127.0.0.1:15202/status`(或其它端口)返回 `ok:false`,错误里提到 pysocks / SOCKS**
策略目录 `.env` 里配置了 `GATE_SOCKS_PROXY`(或 `BINANCE_SOCKS_PROXY``OKX_SOCKS_PROXY`)时,ccxt 需要 **PySocks**。在 **`/opt/manual_trading_hub/.venv`**(或你本机的 `manual_trading_hub/.venv`)中执行:
`pip install PySocks``pip install -r requirements.txt`
2. **已经 `pip install PySocks`,错误文案完全不变**
子代理是**常驻进程**,首次请求已创建 ccxt;在运行中的进程里**仅安装包不会自动生效**。须**重启**该 agent:例如
`screen -S mt-agent-gate -X quit`
再执行 `start_agents_3screen.sh`(或你的等价启动方式)。**不要**依赖「会话还在、以为已经更新」的旧进程。
3. **`start_agents_3screen.sh` 打印「已存在会话、跳过」**
脚本检测到 `mt-agent-*` 已在跑会跳过创建。需要先停再启:
`./stop_agents_3screen.sh`
或对单个会话:`screen -S mt-agent-gate -X quit`,再跑启动脚本。
4. **确认 15200/15202/15203 上的进程用的是 hub 的 venv**
```bash
ps aux | grep agent.py
tr '\0' ' ' < /proc/<PID>/cmdline; echo
```
应看到 `…/manual_trading_hub/.venv/bin/python` 与 `…/manual_trading_hub/agent.py`。若用的是系统 `python3`,要么在**同一解释器环境**里装依赖,要么改为用 `start_agents_3screen.sh` 启动(脚本内写死 `VENV_PY`)。
5. **中控某账户一直红 / 非 JSON**
对应该端口的 agent 未启动,或 `HUB_AGENTS` 与 agent 的 `PORT` 不一致。本机先测:
`curl -sS http://127.0.0.1:1520x/status | head -c 400`
再看 `logs/mt-agent-*.log`。
6. **子代理端口与 Flask 冲突**
agent 使用环境变量 **`PORT`**(脚本里 15200、15202、15203);各策略 `.env` 里的 **`APP_PORT`** 给 Flask。二者**不能**相同。
7. **systemd 下改依赖或 `.env` 后**
与 screen 相同:`pip install` 或改 `EnvironmentFile` 后需 **`systemctl restart <unit>`**,否则仍是旧进程。
---
## 五、注意
- 子代理与中控仍建议只监听 **127.0.0.1**Flask 的 `APP_HOST=0.0.0.0` 与中控无关。
- 若策略项目用**自己的** `.venv`,把 `ExecStart` 里的 Python 改成该 venv 的 `python`,但 `agent.py` 路径仍指向 `manual_trading_hub/agent.py`。
同目录下另有 `example-systemd/*.service.example` 可复制后改路径使用。
+398
View File
@@ -0,0 +1,398 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>手工交易中控</title>
<style>
:root {
--bg: #0f1216;
--panel: #171b22;
--text: #e8eaed;
--muted: #8b929a;
--border: #2a313c;
--green: #3fb950;
--red: #f85149;
--accent: #58a6ff;
}
* { box-sizing: border-box; }
body {
font-family: ui-sans-serif, system-ui, "Segoe UI", sans-serif;
background: var(--bg);
color: var(--text);
margin: 0;
padding: 16px clamp(16px, 4vw, 56px);
font-size: 14px;
line-height: 1.45;
}
.page {
max-width: 1040px;
margin: 0 auto;
width: 100%;
}
h1 { font-size: 1.1rem; font-weight: 600; margin: 0 0 12px; }
.toolbar {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
margin-bottom: 16px;
}
.toolbar span { color: var(--muted); font-size: 12px; }
button {
background: var(--panel);
color: var(--text);
border: 1px solid var(--border);
border-radius: 6px;
padding: 8px 14px;
cursor: pointer;
font-size: 13px;
}
button:hover { border-color: var(--accent); }
button.danger { border-color: var(--red); color: var(--red); }
button.danger:hover { background: #2d1514; }
button:disabled { opacity: 0.45; cursor: not-allowed; }
.card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 8px;
margin-bottom: 12px;
overflow: hidden;
}
.card-head {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 12px;
border-bottom: 1px solid var(--border);
gap: 8px;
flex-wrap: wrap;
}
.card-head strong { font-size: 14px; }
.card-head .meta { color: var(--muted); font-size: 12px; word-break: break-all; }
.metrics {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 8px;
padding: 10px 12px;
font-size: 13px;
}
.metrics div span { color: var(--muted); display: block; font-size: 11px; }
.metrics-row-balance-upnl {
grid-column: 1 / -1;
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 8px 28px;
padding-bottom: 8px;
margin-bottom: 4px;
border-bottom: 1px solid var(--border);
font-size: 13px;
}
.metric-inline {
display: inline-flex;
align-items: baseline;
gap: 6px;
}
.metric-inline .metric-lbl { color: var(--muted); font-size: 12px; }
.metric-inline .metric-num {
font-weight: 600;
font-variant-numeric: tabular-nums;
font-size: 13px;
color: var(--text);
}
.metric-inline .metric-num.pnl-pos { color: var(--green); }
.metric-inline .metric-num.pnl-neg { color: var(--red); }
.pnl-pos { color: var(--green); }
.pnl-neg { color: var(--red); }
th.hl-pnl,
td.hl-pnl {
background: rgba(88, 166, 255, 0.08);
border-left: 2px solid rgba(88, 166, 255, 0.55);
}
th.hl-pnl { color: var(--accent); font-weight: 600; }
table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
th, td { padding: 8px 10px; text-align: left; border-top: 1px solid var(--border); }
th { color: var(--muted); font-weight: 500; }
.err { color: var(--red); padding: 12px; font-size: 13px; }
.card-disabled { opacity: 0.72; border-style: dashed; }
.card-disabled .card-head { border-bottom-style: dashed; }
.off-note { padding: 12px 14px; color: var(--muted); font-size: 13px; }
.monitor-toggle { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--muted); cursor: pointer; user-select: none; }
.monitor-toggle input { cursor: pointer; }
.monitor-toggle input:disabled { cursor: not-allowed; }
#toast {
position: fixed;
bottom: 16px;
right: 16px;
max-width: min(420px, 90vw);
background: var(--panel);
border: 1px solid var(--border);
padding: 10px 14px;
border-radius: 8px;
font-size: 13px;
display: none;
z-index: 20;
white-space: pre-wrap;
}
#toast.show { display: block; }
</style>
</head>
<body>
<div class="page">
<h1>手工交易 · 多账户中控</h1>
<div class="toolbar">
<button type="button" id="btn-refresh">立即刷新</button>
<label style="color:var(--muted);font-size:12px;display:flex;align-items:center;gap:6px;">
<input type="checkbox" id="auto-refresh" checked /> 每 3 秒自动刷新
</label>
<button type="button" id="btn-close-all" class="danger">全局一键全平</button>
<span style="color:var(--muted);font-size:12px;">关闭的账户不轮询、不参与全平(本机记住);账户显示名由中控环境变量 <code style="font-size:11px;">HUB_AGENT_NAMES</code> 配置,所有访问同一中控的电脑一致。</span>
<span id="last-updated"></span>
</div>
<div id="root"></div>
</div>
<div id="toast"></div>
<script>
const LS_EXCLUDED = "manual_trading_hub_excluded";
const root = document.getElementById("root");
const toast = document.getElementById("toast");
const lastUpdated = document.getElementById("last-updated");
let timer = null;
let agentsList = [];
let envExcludedSet = new Set();
let rowById = new Map();
function loadExcludedLS() {
try {
const raw = localStorage.getItem(LS_EXCLUDED);
const arr = raw ? JSON.parse(raw) : [];
return new Set((Array.isArray(arr) ? arr : []).map(String));
} catch {
return new Set();
}
}
function saveExcludedLS(set) {
localStorage.setItem(LS_EXCLUDED, JSON.stringify([...set]));
}
function showToast(msg, isErr) {
toast.textContent = msg;
toast.style.borderColor = isErr ? "var(--red)" : "var(--border)";
toast.classList.add("show");
clearTimeout(showToast._t);
showToast._t = setTimeout(() => toast.classList.remove("show"), 6000);
}
function fmtNum(x, d) {
if (x === null || x === undefined || Number.isNaN(Number(x))) return "—";
const n = Number(x);
return n.toLocaleString(undefined, { maximumFractionDigits: d });
}
function pnlClass(v) {
const n = Number(v);
if (!Number.isFinite(n) || n === 0) return "";
return n > 0 ? "pnl-pos" : "pnl-neg";
}
function renderActiveCard(r) {
const err = r.error || (r.payload && r.payload.error);
const p = r.payload || {};
let inner;
if (!r.http_ok || err) {
inner = `<div class="err">${escapeHtml(String(err || ("HTTP " + (r.status_code ?? "?"))))}</div>`;
} else {
const pos = Array.isArray(p.positions) ? p.positions : [];
const rows = pos.map(
(x) =>
`<tr>
<td>${escapeHtml(x.symbol || "")}</td>
<td>${escapeHtml(x.side || "")}</td>
<td>${fmtNum(x.contracts, 6)}</td>
<td>${fmtNum(x.notional_usdt, 2)}</td>
<td class="hl-pnl ${pnlClass(x.unrealized_pnl)}">${fmtNum(x.unrealized_pnl, 4)}</td>
<td>${fmtNum(x.entry_price, 6)}</td>
</tr>`
);
const topBalUpnl = `<div class="metrics-row-balance-upnl">
<span class="metric-inline"><span class="metric-lbl">余额 USDT</span><span class="metric-num">${fmtNum(p.balance_usdt, 2)}</span></span>
<span class="metric-inline"><span class="metric-lbl">未实现盈亏合计</span><span class="metric-num ${pnlClass(p.total_unrealized_pnl)}">${fmtNum(p.total_unrealized_pnl, 4)}</span></span>
</div>`;
inner = `
<div class="metrics">
${topBalUpnl}
<div><span>交易所</span>${escapeHtml(p.exchange || "—")}</div>
<div><span>持仓模式</span>${escapeHtml(p.position_mode || "—")}</div>
</div>
${
pos.length
? `<table><thead><tr><th>合约</th><th>方向</th><th>张数</th><th>名义(约)</th><th class="hl-pnl">未实现盈亏</th><th>均价</th></tr></thead><tbody>${rows}</tbody></table>`
: `<div style="padding:12px;color:var(--muted)">无持仓</div>`
}`;
}
return `
<div class="card" data-agent-id="${escapeHtml(r.id)}">
<div class="card-head">
<div>
<strong>${escapeHtml(r.name)}</strong>
<div class="meta">${escapeHtml(r.url)}</div>
</div>
<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;">
<label class="monitor-toggle">
<input type="checkbox" class="toggle-monitor" data-agent-id="${escapeHtml(r.id)}" checked />
参与监控
</label>
<button type="button" class="danger btn-close-one" data-agent-id="${escapeHtml(r.id)}">该账户全平</button>
</div>
</div>
${inner}
</div>`;
}
function renderDisabledCard(agent, reason) {
const server = reason === "server";
const inputAttrs = server
? `class="toggle-monitor" data-agent-id="${escapeHtml(agent.id)}" disabled`
: `class="toggle-monitor" data-agent-id="${escapeHtml(agent.id)}"`;
const note = server
? "已在服务端关闭(环境变量 HUB_DISABLED_IDS),不轮询、不参与全局全平。"
: "已在本浏览器关闭。勾选「参与监控」可重新纳入轮询与全局全平。";
return `
<div class="card card-disabled" data-agent-id="${escapeHtml(agent.id)}">
<div class="card-head">
<div>
<strong>${escapeHtml(agent.name)}</strong>
<div class="meta">${escapeHtml(agent.url)}</div>
</div>
<label class="monitor-toggle">
<input type="checkbox" ${inputAttrs} />
参与监控
</label>
</div>
<div class="off-note">${note}</div>
</div>`;
}
function escapeHtml(s) {
return String(s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
async function loadAll() {
const lsEx = loadExcludedLS();
const csv = [...lsEx].join(",");
const qs = csv ? "?exclude_ids=" + encodeURIComponent(csv) : "";
const [ar, sr] = await Promise.all([
fetch("/api/agents").then((r) => r.json()),
fetch("/api/snapshot" + qs).then((r) => r.json()),
]);
agentsList = ar.agents || [];
envExcludedSet = new Set((sr.env_excluded_ids || []).map(String));
rowById = new Map((sr.rows || []).map((row) => [String(row.id), row]));
const parts = [];
for (const agent of agentsList) {
const id = String(agent.id);
const serverOff = envExcludedSet.has(id);
const clientOff = lsEx.has(id);
if (serverOff) {
parts.push(renderDisabledCard(agent, "server"));
} else if (clientOff) {
parts.push(renderDisabledCard(agent, "client"));
} else {
const row = rowById.get(id);
if (row) {
parts.push(renderActiveCard(row));
} else {
parts.push(
renderActiveCard({
id,
name: agent.name,
url: agent.url,
http_ok: false,
error: "无快照",
payload: null,
})
);
}
}
}
root.innerHTML = parts.join("") || '<div class="err">无账户配置</div>';
lastUpdated.textContent = "更新于 " + new Date().toLocaleTimeString();
root.querySelectorAll(".btn-close-one").forEach((btn) => {
btn.onclick = () => closeOne(btn.getAttribute("data-agent-id"));
});
}
async function closeOne(id) {
if (!confirm("确认对该账户市价全平所有永续持仓?")) return;
try {
const res = await fetch("/api/close/" + encodeURIComponent(id), { method: "POST" });
const j = await res.json();
showToast(JSON.stringify(j, null, 2), !res.ok);
await loadAll();
} catch (e) {
showToast(String(e), true);
}
}
async function closeAll() {
const lsEx = loadExcludedLS();
const activeCount = agentsList.filter(
(a) => !envExcludedSet.has(String(a.id)) && !lsEx.has(String(a.id))
).length;
if (!confirm(`对当前 ${activeCount} 个已开启监控的账户执行市价全平?此操作不可撤销。`)) return;
try {
const res = await fetch("/api/close-all", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ exclude_ids: [...lsEx] }),
});
const j = await res.json();
showToast(JSON.stringify(j, null, 2), !res.ok);
await loadAll();
} catch (e) {
showToast(String(e), true);
}
}
root.addEventListener("change", (ev) => {
const t = ev.target;
if (!t.classList || !t.classList.contains("toggle-monitor")) return;
if (t.disabled) return;
const id = t.getAttribute("data-agent-id");
if (!id) return;
const set = loadExcludedLS();
if (t.checked) set.delete(id);
else set.add(id);
saveExcludedLS(set);
loadAll().catch((e) => showToast(String(e), true));
});
document.getElementById("btn-refresh").onclick = () => loadAll().catch((e) => showToast(String(e), true));
document.getElementById("btn-close-all").onclick = closeAll;
function schedule() {
clearInterval(timer);
if (document.getElementById("auto-refresh").checked)
timer = setInterval(() => loadAll().catch(() => {}), 3000);
}
document.getElementById("auto-refresh").onchange = schedule;
loadAll().catch((e) => {
root.innerHTML = `<div class="err">${escapeHtml(String(e))}</div>`;
});
schedule();
</script>
</body>
</html>
+218
View File
@@ -0,0 +1,218 @@
# 手工交易中控 — 部署文档
本文档描述在本机(以 Windows 为主)将 **manual_trading_hub** 部署为「多账户监控 + 紧急全平」的推荐步骤、验收方法与注意事项。功能说明与配置项详见同目录 **《README.md》**。
---
## 一、部署目标
- 本机或局域网可访问 **中控页面**(默认监听 `0.0.0.0:5100`,私网 IP 可打开;本机可用 `127.0.0.1`)。
- 每个需要纳入监控的交易所账户,有独立的 **子代理** 进程(默认端口 **`15200``15203`**,与各 `crypto_monitor_*` 里 Flask 的 **`APP_PORT`** 错开)。
- 策略项目 `crypto_monitor_binance` / `crypto_monitor_okx` / `crypto_monitor_gate` / `crypto_monitor_gate_bot` **无需修改代码**,与中控并行运行。
---
## 二、前置条件
1. 已安装 **Python 3.10+**,且 `pip` 可用。
2. 各策略目录下已配置好交易所 API(与平时运行 Flask 时相同的 `.env` 或环境变量)。各项目 `app.py` 会读取同目录 `.env` 中的 **`APP_HOST` / `APP_PORT`** 用于 **Flask**;子代理使用环境变量 **`PORT`**,二者不能占用同一端口。
3. 本机端口 **`15200``15203`、5100**(及你各策略 `.env` 里的 `APP_PORT`)无冲突;若被占用,须改子代理 `PORT` 并同步修改 `HUB_AGENTS`
---
## 三、目录与文件
```
manual_trading_hub/
agent.py # 子代理(单账户)
hub.py # 中控
requirements.txt # Python 依赖
static/
index.html # 中控前端页面
README.md # 说明文档
部署文档.md # 本文档
```
---
## 四、安装依赖
`manual_trading_hub` 目录执行:
```powershell
cd manual_trading_hub
python -m venv .venv
.\.venv\Scripts\Activate.ps1
pip install -r requirements.txt
```
Linux / macOS
```bash
cd manual_trading_hub
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
```
后续启动 `hub.py` / `agent.py` 时,请使用**同一虚拟环境**中的 `python`,保证已安装 `fastapi``uvicorn``httpx``ccxt`;若各策略 `.env` 中配置了 **SOCKS 代理**`GATE_SOCKS_PROXY` / `BINANCE_SOCKS_PROXY` / `OKX_SOCKS_PROXY` 等),还需 **`PySocks`**(已写入 `requirements.txt`,新环境 `pip install -r requirements.txt` 即可)。
---
## 五、部署步骤(推荐顺序)
### 步骤 1:确认策略进程(可选)
若你平时已运行各目录下的 Flask`app.py`),可保持运行;中控与子代理**不依赖** Flask 是否启动。
### 步骤 2:为每个账户启动子代理
**每个账户一个终端窗口**,在对应策略项目目录进入后加载环境变量,再启动 agent(路径按你仓库实际位置调整)。
示例:**Binance 账户 → 子代理端口 15200**Flask 的 `APP_PORT` 可为 5000、5001 等,由该目录 `.env` 决定,二者不同即可)
```powershell
cd D:\你的路径\交易复盘系统\crypto_monitor_binance
.\.venv\Scripts\Activate.ps1 # 若策略项目使用独立 venv,可在此激活
# 确保已加载 .env(若你用 dotenv-cli 等工具,在此执行)
$env:EXCHANGE="binance"
$env:PORT="15200"
$env:HOST="127.0.0.1"
python ..\manual_trading_hub\agent.py
```
**OKX → 子代理 `PORT=15201`**、`EXCHANGE=okx`**Gate → 15202**、**Gate-Bot → 15203**`EXCHANGE=gate`
**可选安全**:各 agent 与中控制台设置相同随机串:
```powershell
$env:CONTROL_TOKEN="你的长随机串"
```
子代理与中控均需设置同一 `CONTROL_TOKEN`
### 步骤 3:启动中控
新开终端:
```powershell
cd D:\你的路径\交易复盘系统\manual_trading_hub
.\.venv\Scripts\Activate.ps1
$env:HUB_HOST="0.0.0.0"
$env:HUB_PORT="5100"
# 若暂不使用 OKX,可在服务端固定关闭 id=1:
# $env:HUB_DISABLED_IDS="1"
python hub.py
```
控制台无报错即表示监听成功(日志级别为 `warning`,默认不刷屏)。
### 步骤 4:浏览器验收
1. 打开 **http://127.0.0.1:5100/****http://本机局域网IP:5100/**
2. 应看到与 `HUB_AGENTS` 配置一致的账户卡片;已启动子代理的行应能显示余额或持仓(无持仓则显示「无持仓」)。
3. 点击 **立即刷新**,数据应更新。
4. 取消某一行的 **「参与监控」**,该行应变灰且不再刷新数据;再勾选应恢复。
**不建议**在生产环境对实盘轻易点击「全平」做测试;若必须测试,请使用测试网或小资金账户。
---
## 六、接口验收(可选)
在已启动 hub 的本机 PowerShell
```powershell
Invoke-RestMethod "http://127.0.0.1:5100/api/agents"
Invoke-RestMethod "http://127.0.0.1:5100/api/snapshot"
```
子代理单测(需 agent 已启动):
```powershell
Invoke-RestMethod "http://127.0.0.1:15200/health"
Invoke-RestMethod "http://127.0.0.1:15200/status"
```
若启用了 `CONTROL_TOKEN`,需加请求头(PowerShell 示例):
```powershell
$h = @{ "X-Control-Token" = "你的长随机串" }
Invoke-RestMethod "http://127.0.0.1:15200/status" -Headers $h
```
---
## 七、自定义端口与账户数量
仅部署 3 个账户时,可只启动 3 个 agent,并设置:
```powershell
$env:HUB_AGENTS="http://127.0.0.1:15200,http://127.0.0.1:15202,http://127.0.0.1:15203"
$env:HUB_AGENT_NAMES="Binance,Gate,Gate-Bot"
```
`HUB_AGENT_NAMES` 与 URL **数量、顺序**一一对应。
---
## 八、常驻运行(可选)
**不必一直开着终端。** Ubuntu 下可用 **tmux 脱离**、**nohup &**、或 **systemd**(推荐:崩溃自启、可开机启动)。
详细命令、**常见问题(screen / PySocks / 重启)**与 **systemd 单元示例**见:
- `manual_trading_hub/scripts/后台运行-Ubuntu.md`
- `manual_trading_hub/scripts/example-systemd/*.service.example`(改路径后复制到 `/etc/systemd/system/`
Windows 可将 `hub.py` 与各 `agent.py` 写入「启动」文件夹,或使用 **NSSM** / **任务计划程序** 在登录后启动;注意工作目录与环境变量要在任务里写全。
---
## 九、升级与回滚
- **升级**:在 `manual_trading_hub``git pull`(若使用 Git)后,重新 `pip install -r requirements.txt` 即可。
- **回滚**:恢复上一版本代码与依赖;配置仅环境变量与浏览器 localStorage,无数据库。
---
## 十、故障排查
### 10.1 现象速查表
| 现象 | 可能原因 | 处理 |
|------|----------|------|
| 页面打不开 | 中控未启动或端口错 | 检查 `HUB_PORT`、防火墙、是否用 127.0.0.1 访问 |
| 某账户一直报错 | 子代理未启动或端口不一致 | 核对 `HUB_AGENTS` 与该 agent 的 `PORT` |
| 中控 JSON 报错 / `Expecting value` | 子代理未启动、返回非 JSON、或端口错 | 本机 `curl http://127.0.0.1:1520x/status`;确认 agent 已起且端口与 `HUB_AGENTS` 一致 |
| 401 / 连不上子代理 | `CONTROL_TOKEN` 不一致 | 中控与子代理设为同一令牌,或全部去掉令牌 |
| 有密钥仍报缺密钥 | 启动 agent 时未加载策略目录的 `.env` | 在对应目录启动,或手动 export 全部密钥变量 |
| `/status``ok:false`,文案含 **pysocks** / **SOCKS** | 使用 SOCKS 代理但未装 **PySocks** | 在 **`manual_trading_hub/.venv`** 执行 `pip install PySocks``pip install -r requirements.txt` |
| **已安装 PySocks**`/status` 仍报同样 pysocks 文案 | 子代理进程未重启 | 子代理是常驻进程,**仅 pip 不会替换已运行进程**;退出对应 screen / systemd 单元后重新拉起(Ubuntu 见 `scripts/后台运行-Ubuntu.md` §四) |
| 跑 `start_agents_3screen.sh` 无新会话 | screen 会话已存在被脚本跳过 | 先 `stop_agents_3screen.sh`,或 `screen -S mt-agent-xxx -X quit` 后再启动 |
| 子代理行为异常、依赖已装仍报错 | 实际用的不是 hub 的 venv | `ps aux` 查看命令行,应为 `…/manual_trading_hub/.venv/bin/python …/manual_trading_hub/agent.py`;否则在**当前使用的解释器**对应环境中装依赖,或改用官方脚本启动 |
| 子代理端口与 Flask 抢端口 | `PORT` 与策略目录 `.env``APP_PORT` 相同 | 子代理用 **1520015203**(或自改),Flask 继续用 `APP_PORT`,二者勿重复 |
| 全平失败 | 持仓模式、精度、交易所维护 | 看返回 JSON 中 `errors` 字段;对照交易所 App |
### 10.2 依赖或代码更新后
- **`pip install -r requirements.txt` 或单独 `pip install` 之后**:须**重启**受影响的 **hub**、**各子代理** 进程,变更才会生效。
- **拉取新版本代码后**:同样重启进程;若曾遇 `'function' object has no attribute load_markets'` 等旧版 agent 异常,升级后重启子代理即可。
---
## 十一、安全清单(部署前自检)
- [ ] 若机器暴露在公网,已用防火墙限制 `HUB_PORT` 或已改为 `HUB_HOST=127.0.0.1` / `HUB_TRUST_LAN=0` 仅本机。
- [ ] API Key 权限最小化;生产环境建议启用 IP 白名单。
- [ ] `CONTROL_TOKEN` 已设为足够长的随机串(若启用)。
- [ ] 已告知实际操作人员:**全局全平**不可撤销。
---
## 十二、与说明文档的关系
- **《README.md》**:产品能力、架构、环境变量表、API 简表、常见问题。
- **《部署文档.md》**:按步骤安装、启动、验收与运维注意(本文)。
两处如有端口/变量不一致,以**当前代码**与 **README 中的表格**为准。