增加中控
This commit is contained in:
@@ -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:15200~15203,与 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
|
||||||
|
```
|
||||||
|
|
||||||
|
**OKX(15201)**
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd ..\crypto_monitor_okx
|
||||||
|
$env:EXCHANGE="okx"
|
||||||
|
$env:PORT="15201"
|
||||||
|
python ..\manual_trading_hub\agent.py
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gate / Gate-Bot(15202 / 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》**。
|
||||||
@@ -0,0 +1,568 @@
|
|||||||
|
"""
|
||||||
|
子账户极轻代理:仅 GET /status + POST /emergency/close-all,仅监听 127.0.0.1。
|
||||||
|
|
||||||
|
与仓库内四个策略/监控目录一一对应时,典型用法(各目录自己的 .env 里已有密钥;子代理用环境变量 PORT,勿与 Flask 的 APP_PORT 相同):
|
||||||
|
EXCHANGE=binance → crypto_monitor_binance(BINANCE_*)
|
||||||
|
EXCHANGE=okx → crypto_monitor_okx(OKX_*)
|
||||||
|
EXCHANGE=gate → crypto_monitor_gate / crypto_monitor_gate_bot(GATE_*)
|
||||||
|
|
||||||
|
环境变量:
|
||||||
|
EXCHANGE binance(默认)| okx | gate
|
||||||
|
PORT 默认 15200(与 crypto_monitor_* 的 Flask APP_PORT 错开;中控默认聚合 15200–15203)
|
||||||
|
HOST 默认 127.0.0.1
|
||||||
|
CONTROL_TOKEN 可选;请求头 X-Control-Token
|
||||||
|
|
||||||
|
Binance:BINANCE_API_KEY / BINANCE_API_SECRET;余额为 **U 本位永续合约账户** USDT(与 `crypto_monitor_binance` 的合约口径一致,非现货钱包);BINANCE_POSITION_MODE;BINANCE_MARGIN_MODE
|
||||||
|
OKX:OKX_API_KEY / OKX_API_SECRET / OKX_API_PASSPHRASE;OKX_TD_MODE;OKX_POS_MODE
|
||||||
|
Gate:GATE_API_KEY / GATE_API_SECRET;GATE_TD_MODE;GATE_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()
|
||||||
@@ -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=0(false/off)。
|
||||||
|
|
||||||
|
与仓库根目录下四个策略/监控项目对应时,中控默认聚合的子代理地址为 127.0.0.1:15200–15203
|
||||||
|
(与各 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,留空则默认 15200–15203(避免与 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()
|
||||||
@@ -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_*_的目录
|
||||||
|
# 依赖:各策略目录下存在 .env;manual_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 个 agent(start_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 "已启动 screen:mt-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` 可复制后改路径使用。
|
||||||
@@ -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, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
@@ -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` 相同 | 子代理用 **15200~15203**(或自改),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 中的表格**为准。
|
||||||
Reference in New Issue
Block a user