From c14c74cf145a1a431cd939999a1ee393f9226a4d Mon Sep 17 00:00:00 2001 From: dekun Date: Wed, 13 May 2026 01:52:26 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E4=B8=AD=E6=8E=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- manual_trading_hub/README.md | 201 +++++++ manual_trading_hub/agent.py | 568 ++++++++++++++++++ manual_trading_hub/hub.py | 272 +++++++++ manual_trading_hub/requirements.txt | 5 + .../manual-agent-binance.service.example | 20 + .../manual-hub.service.example | 18 + .../scripts/start_agents_3screen.sh | 81 +++ .../scripts/start_hub_screen.sh | 75 +++ .../scripts/stop_agents_3screen.sh | 10 + manual_trading_hub/scripts/stop_hub_screen.sh | 8 + manual_trading_hub/scripts/后台运行-Ubuntu.md | 189 ++++++ manual_trading_hub/static/index.html | 398 ++++++++++++ manual_trading_hub/部署文档.md | 218 +++++++ 13 files changed, 2063 insertions(+) create mode 100644 manual_trading_hub/README.md create mode 100644 manual_trading_hub/agent.py create mode 100644 manual_trading_hub/hub.py create mode 100644 manual_trading_hub/requirements.txt create mode 100644 manual_trading_hub/scripts/example-systemd/manual-agent-binance.service.example create mode 100644 manual_trading_hub/scripts/example-systemd/manual-hub.service.example create mode 100644 manual_trading_hub/scripts/start_agents_3screen.sh create mode 100644 manual_trading_hub/scripts/start_hub_screen.sh create mode 100644 manual_trading_hub/scripts/stop_agents_3screen.sh create mode 100644 manual_trading_hub/scripts/stop_hub_screen.sh create mode 100644 manual_trading_hub/scripts/后台运行-Ubuntu.md create mode 100644 manual_trading_hub/static/index.html create mode 100644 manual_trading_hub/部署文档.md diff --git a/manual_trading_hub/README.md b/manual_trading_hub/README.md new file mode 100644 index 0000000..aa77bc1 --- /dev/null +++ b/manual_trading_hub/README.md @@ -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》**。 diff --git a/manual_trading_hub/agent.py b/manual_trading_hub/agent.py new file mode 100644 index 0000000..a3b2e1d --- /dev/null +++ b/manual_trading_hub/agent.py @@ -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() diff --git a/manual_trading_hub/hub.py b/manual_trading_hub/hub.py new file mode 100644 index 0000000..302b672 --- /dev/null +++ b/manual_trading_hub/hub.py @@ -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() diff --git a/manual_trading_hub/requirements.txt b/manual_trading_hub/requirements.txt new file mode 100644 index 0000000..a29f6d3 --- /dev/null +++ b/manual_trading_hub/requirements.txt @@ -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 diff --git a/manual_trading_hub/scripts/example-systemd/manual-agent-binance.service.example b/manual_trading_hub/scripts/example-systemd/manual-agent-binance.service.example new file mode 100644 index 0000000..366174f --- /dev/null +++ b/manual_trading_hub/scripts/example-systemd/manual-agent-binance.service.example @@ -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 diff --git a/manual_trading_hub/scripts/example-systemd/manual-hub.service.example b/manual_trading_hub/scripts/example-systemd/manual-hub.service.example new file mode 100644 index 0000000..702f82f --- /dev/null +++ b/manual_trading_hub/scripts/example-systemd/manual-hub.service.example @@ -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 diff --git a/manual_trading_hub/scripts/start_agents_3screen.sh b/manual_trading_hub/scripts/start_agents_3screen.sh new file mode 100644 index 0000000..6ed4c47 --- /dev/null +++ b/manual_trading_hub/scripts/start_agents_3screen.sh @@ -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" diff --git a/manual_trading_hub/scripts/start_hub_screen.sh b/manual_trading_hub/scripts/start_hub_screen.sh new file mode 100644 index 0000000..d505d1f --- /dev/null +++ b/manual_trading_hub/scripts/start_hub_screen.sh @@ -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" diff --git a/manual_trading_hub/scripts/stop_agents_3screen.sh b/manual_trading_hub/scripts/stop_agents_3screen.sh new file mode 100644 index 0000000..81d89f5 --- /dev/null +++ b/manual_trading_hub/scripts/stop_agents_3screen.sh @@ -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 diff --git a/manual_trading_hub/scripts/stop_hub_screen.sh b/manual_trading_hub/scripts/stop_hub_screen.sh new file mode 100644 index 0000000..49f25c5 --- /dev/null +++ b/manual_trading_hub/scripts/stop_hub_screen.sh @@ -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 diff --git a/manual_trading_hub/scripts/后台运行-Ubuntu.md b/manual_trading_hub/scripts/后台运行-Ubuntu.md new file mode 100644 index 0000000..444f7d8 --- /dev/null +++ b/manual_trading_hub/scripts/后台运行-Ubuntu.md @@ -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 `。 + +--- + +## 三、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//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 `**,否则仍是旧进程。 + +--- + +## 五、注意 + +- 子代理与中控仍建议只监听 **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` 可复制后改路径使用。 diff --git a/manual_trading_hub/static/index.html b/manual_trading_hub/static/index.html new file mode 100644 index 0000000..d2ba263 --- /dev/null +++ b/manual_trading_hub/static/index.html @@ -0,0 +1,398 @@ + + + + + + 手工交易中控 + + + +
+

手工交易 · 多账户中控

+
+ + + + 关闭的账户不轮询、不参与全平(本机记住);账户显示名由中控环境变量 HUB_AGENT_NAMES 配置,所有访问同一中控的电脑一致。 + +
+
+
+
+ + + diff --git a/manual_trading_hub/部署文档.md b/manual_trading_hub/部署文档.md new file mode 100644 index 0000000..c78d5c6 --- /dev/null +++ b/manual_trading_hub/部署文档.md @@ -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 中的表格**为准。