diff --git a/.gitignore b/.gitignore index a36f642..95486e1 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ **/.env.backup* **/.env.bak **/.env.local +manual_trading_hub/hub_settings.json # 数据库与上传(运行时生成) **/*.sqlite diff --git a/crypto_monitor_binance/.env.example b/crypto_monitor_binance/.env.example index 3d097d1..f15daeb 100644 --- a/crypto_monitor_binance/.env.example +++ b/crypto_monitor_binance/.env.example @@ -26,6 +26,10 @@ APP_USERNAME=dekun APP_PASSWORD=ChangeMe123! # 是否关闭登录校验(局域网可设 true;公网务必 false) APP_AUTH_DISABLED=true +# --- 多账户交易中控 manual_trading_hub --- +# 中控请求本实例 /api/hub/* 时携带请求头 X-Hub-Token,须与中控启动环境变量 HUB_BRIDGE_TOKEN 一致 +# 未设置且 APP_AUTH_DISABLED=false 时,仅网页登录后可访问;本机联调可保持 APP_AUTH_DISABLED=true +# HUB_BRIDGE_TOKEN=your-long-random-token # Flask 会话密钥(必须替换为长随机字符串) FLASK_SECRET_KEY=CHANGE_TO_LONG_RANDOM_SECRET diff --git a/crypto_monitor_binance/app.py b/crypto_monitor_binance/app.py index 9848d12..36d043c 100644 --- a/crypto_monitor_binance/app.py +++ b/crypto_monitor_binance/app.py @@ -53,6 +53,7 @@ from key_sl_tp_lib import ( sl_tp_mode_label, sl_tp_plan_summary_text, ) +from hub_auth import request_allowed as hub_request_allowed from history_window_lib import ( PRESET_CUSTOM, PRESET_UTC_LAST24H, @@ -5479,11 +5480,9 @@ def logout(): def login_required(f): @wraps(f) def decorated(*args, **kwargs): - if AUTH_DISABLED: + if hub_request_allowed(bool(session.get("logged_in")), AUTH_DISABLED): return f(*args, **kwargs) - if not session.get("logged_in"): - return redirect("/login") - return f(*args, **kwargs) + return redirect("/login") return decorated @@ -7634,6 +7633,38 @@ def ai_weekly_review(): conn.close() return jsonify({"result": full}) +def _hub_meta_bundle(): + return { + "exchange_display": EXCHANGE_DISPLAY_NAME, + "key_gate_rule_text": ( + f"周期 {KLINE_TIMEFRAME}|确认K:突破棒偏移 {KEY_CONFIRM_BREAKOUT_BAR}、确认棒偏移 {KEY_CONFIRM_BAR}|" + f"量能:突破量 > 前{KEY_VOLUME_MA_BARS}均量×{KEY_VOLUME_RATIO_MIN}|" + f"自动开仓盈亏比 > {KEY_AUTO_MIN_PLANNED_RR}:1|日成交量排名前 {KEY_DAILY_VOLUME_RANK_MAX}" + ), + "manual_min_planned_rr": MANUAL_MIN_PLANNED_RR, + "max_active_positions": MAX_ACTIVE_POSITIONS, + "btc_leverage": BTC_LEVERAGE, + "alt_leverage": ALT_LEVERAGE, + } + + +try: + from hub_bridge import install_on_app + + install_on_app( + app, + exchange="binance", + capabilities=["order", "key"], + has_trend=False, + get_db=get_db, + row_to_dict=row_to_dict, + meta_fn=_hub_meta_bundle, + views={"add_order": add_order, "add_key": add_key}, + ) +except Exception as _hub_err: + print(f"[hub_bridge] binance: {_hub_err}") + + # 启动 if __name__ == "__main__": threading.Thread(target=background_task, daemon=True).start() diff --git a/crypto_monitor_gate/.env.example b/crypto_monitor_gate/.env.example index 167cad2..fdead10 100644 --- a/crypto_monitor_gate/.env.example +++ b/crypto_monitor_gate/.env.example @@ -26,6 +26,10 @@ APP_USERNAME=dekun APP_PASSWORD=ChangeMe123! # 是否关闭登录校验(局域网可设 true;公网务必 false) APP_AUTH_DISABLED=true +# --- 多账户交易中控 manual_trading_hub --- +# 中控请求本实例 /api/hub/* 时携带请求头 X-Hub-Token,须与中控启动环境变量 HUB_BRIDGE_TOKEN 一致 +# 未设置且 APP_AUTH_DISABLED=false 时,仅网页登录后可访问;本机联调可保持 APP_AUTH_DISABLED=true +# HUB_BRIDGE_TOKEN=your-long-random-token # Flask 会话密钥(必须替换为长随机字符串) FLASK_SECRET_KEY=CHANGE_TO_LONG_RANDOM_SECRET diff --git a/crypto_monitor_gate/app.py b/crypto_monitor_gate/app.py index e0552d8..84a6157 100644 --- a/crypto_monitor_gate/app.py +++ b/crypto_monitor_gate/app.py @@ -54,6 +54,7 @@ from key_sl_tp_lib import ( sl_tp_mode_label, sl_tp_plan_summary_text, ) +from hub_auth import request_allowed as hub_request_allowed from history_window_lib import ( PRESET_CUSTOM, PRESET_UTC_LAST24H, @@ -5316,11 +5317,9 @@ def logout(): def login_required(f): @wraps(f) def decorated(*args, **kwargs): - if AUTH_DISABLED: + if hub_request_allowed(bool(session.get("logged_in")), AUTH_DISABLED): return f(*args, **kwargs) - if not session.get("logged_in"): - return redirect("/login") - return f(*args, **kwargs) + return redirect("/login") return decorated @@ -7669,6 +7668,38 @@ def ai_weekly_review(): conn.close() return jsonify({"result": full}) +def _hub_meta_bundle(): + return { + "exchange_display": EXCHANGE_DISPLAY_NAME, + "key_gate_rule_text": ( + f"周期 {KLINE_TIMEFRAME}|确认K:突破棒偏移 {KEY_CONFIRM_BREAKOUT_BAR}、确认棒偏移 {KEY_CONFIRM_BAR}|" + f"量能:突破量 > 前{KEY_VOLUME_MA_BARS}均量×{KEY_VOLUME_RATIO_MIN}|" + f"自动开仓盈亏比 > {KEY_AUTO_MIN_PLANNED_RR}:1|日成交量排名前 {KEY_DAILY_VOLUME_RANK_MAX}" + ), + "manual_min_planned_rr": MANUAL_MIN_PLANNED_RR, + "max_active_positions": MAX_ACTIVE_POSITIONS, + "btc_leverage": BTC_LEVERAGE, + "alt_leverage": ALT_LEVERAGE, + } + + +try: + from hub_bridge import install_on_app + + install_on_app( + app, + exchange="gate", + capabilities=["order", "key"], + has_trend=False, + get_db=get_db, + row_to_dict=row_to_dict, + meta_fn=_hub_meta_bundle, + views={"add_order": add_order, "add_key": add_key}, + ) +except Exception as _hub_err: + print(f"[hub_bridge] gate: {_hub_err}") + + # 启动 if __name__ == "__main__": threading.Thread(target=background_task, daemon=True).start() diff --git a/crypto_monitor_gate_bot/.env.example b/crypto_monitor_gate_bot/.env.example index 6a42244..236b373 100644 --- a/crypto_monitor_gate_bot/.env.example +++ b/crypto_monitor_gate_bot/.env.example @@ -26,6 +26,10 @@ APP_USERNAME=dekun APP_PASSWORD=ChangeMe123! # 是否关闭登录校验(局域网可设 true;公网务必 false) APP_AUTH_DISABLED=true +# --- 多账户交易中控 manual_trading_hub --- +# 中控请求本实例 /api/hub/* 时携带请求头 X-Hub-Token,须与中控启动环境变量 HUB_BRIDGE_TOKEN 一致 +# 未设置且 APP_AUTH_DISABLED=false 时,仅网页登录后可访问;本机联调可保持 APP_AUTH_DISABLED=true +# HUB_BRIDGE_TOKEN=your-long-random-token # Flask 会话密钥(必须替换为长随机字符串) FLASK_SECRET_KEY=CHANGE_TO_LONG_RANDOM_SECRET diff --git a/crypto_monitor_gate_bot/app.py b/crypto_monitor_gate_bot/app.py index 1805bf0..d333d24 100644 --- a/crypto_monitor_gate_bot/app.py +++ b/crypto_monitor_gate_bot/app.py @@ -34,6 +34,7 @@ import sys if _REPO_ROOT not in sys.path: sys.path.insert(0, _REPO_ROOT) +from hub_auth import request_allowed as hub_request_allowed from history_window_lib import ( PRESET_CUSTOM, PRESET_UTC_LAST24H, @@ -5138,11 +5139,9 @@ def logout(): def login_required(f): @wraps(f) def decorated(*args, **kwargs): - if AUTH_DISABLED: + if hub_request_allowed(bool(session.get("logged_in")), AUTH_DISABLED): return f(*args, **kwargs) - if not session.get("logged_in"): - return redirect("/login") - return f(*args, **kwargs) + return redirect("/login") return decorated @@ -7301,6 +7300,40 @@ def ai_weekly_review(): conn.close() return jsonify({"result": full}) +def _hub_meta_bundle(): + return { + "exchange_display": EXCHANGE_DISPLAY_NAME, + "trend_pullback_preview_ttl": TREND_PULLBACK_PREVIEW_TTL_SECONDS, + "trend_manual_breakeven_offset_pct": TREND_PULLBACK_MANUAL_BREAKEVEN_OFFSET_PCT, + "trend_pullback_dca_legs": TREND_PULLBACK_DCA_LEGS, + "trend_preview_max_drift_pct": TREND_PREVIEW_MAX_BALANCE_DRIFT_PCT, + "manual_min_planned_rr": float(os.getenv("MANUAL_MIN_PLANNED_RR", "1.4")), + "max_active_positions": max(1, int(os.getenv("MAX_ACTIVE_POSITIONS", "1"))), + } + + +try: + from hub_bridge import install_on_app + + install_on_app( + app, + exchange="gate_bot", + capabilities=["order", "trend"], + has_trend=True, + get_db=get_db, + row_to_dict=row_to_dict, + meta_fn=_hub_meta_bundle, + views={ + "add_order": add_order, + "add_key": add_key, + "preview_trend_pullback": preview_trend_pullback, + "execute_trend_pullback": execute_trend_pullback, + }, + ) +except Exception as _hub_err: + print(f"[hub_bridge] gate_bot: {_hub_err}") + + # 启动 if __name__ == "__main__": threading.Thread(target=background_task, daemon=True).start() diff --git a/crypto_monitor_okx/.env.example b/crypto_monitor_okx/.env.example index 214fec8..09bd34c 100644 --- a/crypto_monitor_okx/.env.example +++ b/crypto_monitor_okx/.env.example @@ -26,6 +26,10 @@ APP_USERNAME=dekun APP_PASSWORD=ChangeMe123! # 是否关闭登录校验(局域网可设 true;公网务必 false) APP_AUTH_DISABLED=true +# --- 多账户交易中控 manual_trading_hub --- +# 中控请求本实例 /api/hub/* 时携带请求头 X-Hub-Token,须与中控启动环境变量 HUB_BRIDGE_TOKEN 一致 +# 未设置且 APP_AUTH_DISABLED=false 时,仅网页登录后可访问;本机联调可保持 APP_AUTH_DISABLED=true +# HUB_BRIDGE_TOKEN=your-long-random-token # Flask 会话密钥(必须替换为长随机字符串) FLASK_SECRET_KEY=CHANGE_TO_LONG_RANDOM_SECRET diff --git a/crypto_monitor_okx/app.py b/crypto_monitor_okx/app.py index b45a454..0d01a77 100644 --- a/crypto_monitor_okx/app.py +++ b/crypto_monitor_okx/app.py @@ -53,6 +53,7 @@ from key_sl_tp_lib import ( sl_tp_mode_label, sl_tp_plan_summary_text, ) +from hub_auth import request_allowed as hub_request_allowed from history_window_lib import ( PRESET_CUSTOM, PRESET_UTC_LAST24H, @@ -4095,11 +4096,9 @@ def logout(): def login_required(f): @wraps(f) def decorated(*args, **kwargs): - if AUTH_DISABLED: + if hub_request_allowed(bool(session.get("logged_in")), AUTH_DISABLED): return f(*args, **kwargs) - if not session.get("logged_in"): - return redirect("/login") - return f(*args, **kwargs) + return redirect("/login") return decorated @@ -5910,6 +5909,38 @@ def ai_weekly_review(): conn.close() return jsonify({"result": full}) +def _hub_meta_bundle(): + return { + "exchange_display": EXCHANGE_DISPLAY_NAME, + "key_gate_rule_text": ( + f"周期 {KLINE_TIMEFRAME}|量能/突破/二确门控见箱体与收敛规则|" + f"自动开仓盈亏比 > {KEY_AUTO_MIN_PLANNED_RR}:1|日成交量排名前 {KEY_DAILY_VOLUME_RANK_MAX}|" + f"斐波:添加后立即挂限价 @ E,失效按标记价触达 H/L(未成交撤单)" + ), + "manual_min_planned_rr": float(os.getenv("MANUAL_MIN_PLANNED_RR", "1.4")), + "max_active_positions": max(1, int(os.getenv("MAX_ACTIVE_POSITIONS", "1"))), + "btc_leverage": BTC_LEVERAGE, + "alt_leverage": ALT_LEVERAGE, + } + + +try: + from hub_bridge import install_on_app + + install_on_app( + app, + exchange="okx", + capabilities=["order", "key"], + has_trend=False, + get_db=get_db, + row_to_dict=row_to_dict, + meta_fn=_hub_meta_bundle, + views={"add_order": add_order, "add_key": add_key}, + ) +except Exception as _hub_err: + print(f"[hub_bridge] okx: {_hub_err}") + + # 启动 if __name__ == "__main__": threading.Thread(target=background_task, daemon=True).start() diff --git a/hub_auth.py b/hub_auth.py new file mode 100644 index 0000000..2837b59 --- /dev/null +++ b/hub_auth.py @@ -0,0 +1,20 @@ +"""中控调用实例 API 时的鉴权辅助(各 crypto_monitor_* 的 login_required 共用)。""" + +from __future__ import annotations + +import os + +from flask import request + + +def hub_bridge_token() -> str: + return (os.getenv("HUB_BRIDGE_TOKEN") or "").strip() + + +def request_allowed(session_logged_in: bool, auth_disabled: bool) -> bool: + if auth_disabled or session_logged_in: + return True + tok = hub_bridge_token() + if tok and request.headers.get("X-Hub-Token") == tok: + return True + return False diff --git a/hub_bridge.py b/hub_bridge.py new file mode 100644 index 0000000..72a1ea0 --- /dev/null +++ b/hub_bridge.py @@ -0,0 +1,240 @@ +""" +各 crypto_monitor_* 注册 /api/hub/* JSON 接口,供 manual_trading_hub 调用。 +实例末尾:app.config["HUB_CTX"] = {...}; register_hub_routes(app) +""" + +from __future__ import annotations + +import json +import time +from functools import wraps + +from flask import current_app, get_flashed_messages, jsonify, request, session + +from hub_auth import request_allowed + + +def _hub_auth_required(f): + @wraps(f) + def wrapped(*args, **kwargs): + from flask import current_app as cap + + auth_disabled = bool(cap.config.get("HUB_AUTH_DISABLED")) + if not request_allowed(bool(session.get("logged_in")), auth_disabled): + return jsonify({"ok": False, "msg": "未授权(登录或 HUB_BRIDGE_TOKEN)"}), 401 + return f(*args, **kwargs) + + return wrapped + + +def _ctx(): + return current_app.config.get("HUB_CTX") or {} + + +def _row_to_dict(row): + fn = _ctx().get("row_to_dict") + if fn and row is not None: + return fn(row) + return dict(row) if row is not None else {} + + +def _invoke_view(view_name: str, path: str, form=None) -> dict: + views = _ctx().get("views") or {} + view = views.get(view_name) + if not view: + return {"ok": False, "messages": [f"未配置视图 {view_name}"]} + data = form if form is not None else request.form + with current_app.test_request_context(path, method="POST", data=data): + session["logged_in"] = True + try: + view() + except Exception as e: + return {"ok": False, "messages": [str(e)]} + msgs = [str(x) for x in get_flashed_messages()] + ok = True + for m in msgs: + if any(k in m for k in ("失败", "错误", "拒绝", "无效", "缺少", "无法", "过期")): + ok = False + break + return {"ok": ok, "messages": msgs} + + +def install_on_app( + app, + *, + exchange: str, + capabilities: list, + has_trend: bool, + get_db, + row_to_dict, + meta_fn, + views: dict, +): + app.config["HUB_CTX"] = { + "exchange": exchange, + "capabilities": list(capabilities), + "has_trend": bool(has_trend), + "get_db": get_db, + "row_to_dict": row_to_dict, + "meta_fn": meta_fn, + "views": views, + } + register_hub_routes(app) + + +def register_hub_routes(app): + auth_disabled = False + try: + import os + + auth_disabled = os.getenv("APP_AUTH_DISABLED", "false").lower() in ( + "1", + "true", + "yes", + "on", + ) + except Exception: + pass + app.config.setdefault("HUB_AUTH_DISABLED", auth_disabled) + + @app.route("/api/hub/ping") + @_hub_auth_required + def api_hub_ping(): + c = _ctx() + return jsonify( + { + "ok": True, + "exchange": c.get("exchange"), + "capabilities": c.get("capabilities") or [], + } + ) + + @app.route("/api/hub/meta") + @_hub_auth_required + def api_hub_meta(): + c = _ctx() + meta_fn = c.get("meta_fn") + meta = meta_fn() if callable(meta_fn) else {} + return jsonify({"ok": True, "meta": meta}) + + @app.route("/api/hub/monitor") + @_hub_auth_required + def api_hub_monitor(): + c = _ctx() + get_db = c.get("get_db") + if not get_db: + return jsonify({"ok": False, "msg": "HUB_CTX 缺少 get_db"}), 500 + conn = get_db() + keys = [] + for row in conn.execute("SELECT * FROM key_monitors ORDER BY id DESC").fetchall(): + keys.append(_row_to_dict(row)) + orders = [] + for row in conn.execute( + "SELECT * FROM order_monitors WHERE status='active' ORDER BY id DESC" + ).fetchall(): + orders.append(_row_to_dict(row)) + trends = [] + if c.get("has_trend"): + for row in conn.execute( + "SELECT * FROM trend_pullback_plans WHERE status='active' ORDER BY id DESC" + ).fetchall(): + trends.append(_row_to_dict(row)) + conn.close() + enrich = c.get("enrich_monitor") + if callable(enrich): + try: + payload = enrich(keys=keys, orders=orders, trends=trends) + if isinstance(payload, dict): + return jsonify({"ok": True, **payload}) + except Exception as e: + return jsonify({"ok": False, "msg": str(e)}), 500 + return jsonify( + {"ok": True, "keys": keys, "orders": orders, "trends": trends, "key_prices": []} + ) + + @app.route("/api/hub/add_order", methods=["POST"]) + @_hub_auth_required + def api_hub_add_order(): + return jsonify(_invoke_view("add_order", "/trade")) + + @app.route("/api/hub/add_key", methods=["POST"]) + @_hub_auth_required + def api_hub_add_key(): + return jsonify(_invoke_view("add_key", "/key_monitor")) + + @app.route("/api/hub/trend/preview", methods=["POST"]) + @_hub_auth_required + def api_hub_trend_preview(): + if not _ctx().get("has_trend"): + return jsonify({"ok": False, "msg": "该实例无趋势回调"}), 400 + data = _invoke_view("preview_trend_pullback", "/trade") + pid = _latest_preview_id() + preview = _fetch_preview(pid) if pid else None + return jsonify( + { + "ok": bool(data.get("ok")), + "messages": data.get("messages") or [], + "preview_id": pid, + "preview": preview, + } + ) + + @app.route("/api/hub/trend/execute", methods=["POST"]) + @_hub_auth_required + def api_hub_trend_execute(): + if not _ctx().get("has_trend"): + return jsonify({"ok": False, "msg": "该实例无趋势回调"}), 400 + pid = (request.form.get("preview_id") or "").strip() + if not pid: + body = request.get_json(silent=True) or {} + pid = str(body.get("preview_id") or "").strip() + form = {"preview_id": pid} if pid else {} + return jsonify(_invoke_view("execute_trend_pullback", "/trade", form=form)) + + @app.route("/api/hub/trend/preview/") + @_hub_auth_required + def api_hub_trend_preview_get(pid): + if not _ctx().get("has_trend"): + return jsonify({"ok": False, "msg": "该实例无趋势回调"}), 400 + preview = _fetch_preview(pid) + if not preview: + return jsonify({"ok": False, "msg": "预览不存在或已过期"}), 404 + return jsonify({"ok": True, "preview": preview}) + + +def _latest_preview_id(): + get_db = _ctx().get("get_db") + if not get_db: + return None + conn = get_db() + row = conn.execute( + "SELECT id FROM trend_pullback_previews ORDER BY created_at DESC LIMIT 1" + ).fetchone() + conn.close() + return row["id"] if row else None + + +def _fetch_preview(pid): + get_db = _ctx().get("get_db") + if not get_db or not pid: + return None + conn = get_db() + row = conn.execute( + "SELECT * FROM trend_pullback_previews WHERE id=?", (pid,) + ).fetchone() + conn.close() + if not row: + return None + d = _row_to_dict(row) + now_ms = int(time.time() * 1000) + d["expires_in_sec"] = max(0, int((int(d.get("expires_at_ms") or 0) - now_ms) / 1000)) + try: + grid = json.loads(d.get("grid_prices_json") or "[]") + legs = json.loads(d.get("leg_amounts_json") or "[]") + d["grid_levels"] = [ + {"i": i + 1, "price": grid[i], "contracts": legs[i] if i < len(legs) else None} + for i in range(len(grid)) + ] + except Exception: + d["grid_levels"] = [] + return d diff --git a/manual_trading_hub/.env.example b/manual_trading_hub/.env.example new file mode 100644 index 0000000..59f50c1 --- /dev/null +++ b/manual_trading_hub/.env.example @@ -0,0 +1,28 @@ +# ============================================================================= +# 中控 hub.py / 子代理 agent.py 环境变量模板(可提交 Git) +# 使用:cp .env.example .env 后填入真实值;启动前由 shell export 或 dotenv 加载 +# ============================================================================= + +# hub.py 监听 +HUB_HOST=0.0.0.0 +HUB_PORT=5100 +# 仅本机访问可改为 127.0.0.1,并设 HUB_TRUST_LAN=false + +# 与四实例 .env 中 HUB_BRIDGE_TOKEN 相同的长随机串 +# 中控 → 各 Flask:请求头 X-Hub-Token +# 中控 → 各子代理:请求头 X-Control-Token(可与子代理 CONTROL_TOKEN 同值,hub 会用 HUB_BRIDGE_TOKEN 转发) +# HUB_BRIDGE_TOKEN=your-long-random-token + +# 逗号分隔的账户 id,强制关闭(不参与监控/全局全平;设置页对应行勾选框灰掉) +# 默认 1 = OKX;不用 OKX 可保持;要用 OKX 请删掉本行或改为空 +HUB_DISABLED_IDS=1 + +# true=允许 RFC1918 私网访问中控页面;false=仅 127.0.0.1 +HUB_TRUST_LAN=true + +# --- 子代理 agent.py(在 crypto_monitor_* 目录启动时另设 EXCHANGE / PORT)--- +# 与 HUB_BRIDGE_TOKEN 一致时可只设其一;agent 校验请求头 X-Control-Token +# CONTROL_TOKEN=your-long-random-token +# EXCHANGE=binance +# PORT=15200 +# HOST=127.0.0.1 diff --git a/manual_trading_hub/README.md b/manual_trading_hub/README.md index 6aeb436..6c7e5e9 100644 --- a/manual_trading_hub/README.md +++ b/manual_trading_hub/README.md @@ -1,6 +1,8 @@ # 手工交易多账户中控(manual_trading_hub) -本目录提供**极简中控**:只负责多账户**监控**(持仓、盈亏、余额等)与**紧急全平**,不参与开仓、策略或任何自动化下单。策略账户侧的 `crypto_monitor_*` 项目**无需改代码**,与中控并行运行即可。 +> **完整操作说明见 [使用说明.md](./使用说明.md)**(监控区 / 下单区 / 系统设置、鉴权、四所能力与故障排查)。 + +本目录提供多账户 **监控 + 下单转发 + 紧急全平**。各 `crypto_monitor_*` 仅需注册 `hub_bridge`(见使用说明);策略与复盘仍在各实例网页。 --- diff --git a/manual_trading_hub/hub.py b/manual_trading_hub/hub.py index 302b672..165f855 100644 --- a/manual_trading_hub/hub.py +++ b/manual_trading_hub/hub.py @@ -1,27 +1,6 @@ """ -中控:聚合各子账户 /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) +多账户交易中控:监控区 / 下单区 / 系统设置。 +转发至各 crypto_monitor_* 的 /api/hub/* 与子代理 /status。 """ from __future__ import annotations @@ -35,9 +14,16 @@ from fastapi.responses import FileResponse, JSONResponse from fastapi.staticfiles import StaticFiles from pydantic import BaseModel, Field +from settings_store import ( + enabled_exchanges, + env_force_disabled_ids, + load_settings, + save_settings, +) + 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() +HUB_BRIDGE_TOKEN = (os.getenv("HUB_BRIDGE_TOKEN") or 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 @@ -80,58 +66,23 @@ def _client_allowed(host: str | None) -> bool: return False -def _agent_headers() -> dict[str, str]: - if not CONTROL_TOKEN: +def _hub_headers() -> dict[str, str]: + if not HUB_BRIDGE_TOKEN: return {} - return {"X-Control-Token": CONTROL_TOKEN} + return {"X-Hub-Token": HUB_BRIDGE_TOKEN} -_DEFAULT_FOLDER_LABELS = ( - "币安山寨账户 · crypto_monitor_binance", - "OKX · crypto_monitor_okx", - "Gate训练账户 · crypto_monitor_gate", - "Gate趋势回调 · crypto_monitor_gate_bot", -) +def _agent_headers() -> dict[str, str]: + if not HUB_BRIDGE_TOKEN: + return {} + return {"X-Control-Token": HUB_BRIDGE_TOKEN} -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 +def _find_exchange(ex_id: str) -> dict | None: + for ex in load_settings().get("exchanges") or []: + if str(ex.get("id")) == str(ex_id): + return ex + return None app = FastAPI(title="hub", docs_url=None, redoc_url=None) @@ -147,121 +98,236 @@ async def local_only(request: Request, call_next): return await call_next(request) -@app.get("/") -def index_page(): +def _shell_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()} +@app.get("/") +def root_redirect(): + from fastapi.responses import RedirectResponse + + return RedirectResponse("/monitor") + + +@app.get("/monitor") +@app.get("/trade") +@app.get("/settings") +def shell_pages(): + return _shell_page() + + +@app.get("/api/settings") +def api_get_settings(): + return load_settings() + + +class SettingsBody(BaseModel): + exchanges: list[dict] = Field(default_factory=list) + + +@app.post("/api/settings") +def api_save_settings(body: SettingsBody): + data = {"version": 1, "exchanges": body.exchanges} + save_settings(data) + return {"ok": True} + + +@app.get("/api/settings/meta") +def api_settings_meta(): + return { + "env_disabled_ids": sorted(env_force_disabled_ids()), + "hub_bridge_token_set": bool(HUB_BRIDGE_TOKEN), + "capability_options": ["order", "key", "trend"], + } + + +async def _fetch_agent_status(client: httpx.AsyncClient, ex: dict) -> dict: + url = f"{ex['agent_url'].rstrip('/')}/status" + try: + r = await client.get(url, headers=_agent_headers(), timeout=12.0) + body = r.json() if r.content else {} + return { + "id": ex["id"], + "name": ex["name"], + "key": ex.get("key"), + "agent_url": ex["agent_url"], + "flask_url": ex.get("flask_url"), + "capabilities": ex.get("capabilities") or [], + "http_ok": r.status_code == 200, + "agent": body, + "error": body.get("error") if isinstance(body, dict) else None, + } + except Exception as e: + return { + "id": ex["id"], + "name": ex["name"], + "key": ex.get("key"), + "agent_url": ex["agent_url"], + "flask_url": ex.get("flask_url"), + "capabilities": ex.get("capabilities") or [], + "http_ok": False, + "error": str(e), + "agent": None, + } + + +async def _fetch_flask_json( + client: httpx.AsyncClient, ex: dict, path: str, method: str = "GET", data=None +) -> dict | None: + base = (ex.get("flask_url") or "").rstrip("/") + if not base: + return None + try: + if method == "GET": + r = await client.get(f"{base}{path}", headers=_hub_headers(), timeout=15.0) + else: + r = await client.post(f"{base}{path}", headers=_hub_headers(), data=data, timeout=120.0) + if r.status_code >= 400: + return {"ok": False, "status": r.status_code, "text": (r.text or "")[:500]} + return r.json() + except Exception as e: + return {"ok": False, "error": str(e)} + + +@app.get("/api/monitor/board") +async def api_monitor_board(): + exchanges = enabled_exchanges() + async with httpx.AsyncClient() as client: + agent_rows = await asyncio.gather(*[_fetch_agent_status(client, ex) for ex in exchanges]) + out = [] + for ex, agent_row in zip(exchanges, agent_rows): + hub_mon = await _fetch_flask_json(client, ex, "/api/hub/monitor") + meta = await _fetch_flask_json(client, ex, "/api/hub/meta") + key_prices = None + if "key" in (ex.get("capabilities") or []): + snap = await _fetch_flask_json(client, ex, "/api/price_snapshot") + if isinstance(snap, dict): + key_prices = snap.get("key_prices") + out.append( + { + **agent_row, + "review_url": ex.get("review_url") or "", + "hub_monitor": hub_mon, + "meta": (meta or {}).get("meta") if isinstance(meta, dict) else meta, + "key_prices": key_prices, + } + ) + return {"rows": out, "updated_at": __import__("datetime").datetime.now().isoformat(timespec="seconds")} 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, - } - +@app.post("/api/close/{exchange_id}") +async def api_close_exchange(exchange_id: str): + ex = _find_exchange(exchange_id) + if not ex or not ex.get("enabled"): + raise HTTPException(status_code=404, detail="账户未启用") + url = f"{ex['agent_url'].rstrip('/')}/emergency/close-all" 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 + r = await client.post(url, headers=_agent_headers(), timeout=120.0) + try: + body = r.json() + except Exception: + body = {"raw": (r.text or "")[:2000]} + return {"exchange": ex, "status_code": r.status_code, "payload": body} @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)} - + excl = set(body.exclude_ids if body else []) + excl |= env_force_disabled_ids() + targets = [x for x in enabled_exchanges() if str(x["id"]) not in excl] async with httpx.AsyncClient() as client: - results = await asyncio.gather(*[post_close(client, a) for a in agents]) + + async def one(ex: dict): + url = f"{ex['agent_url'].rstrip('/')}/emergency/close-all" + try: + r = await client.post(url, headers=_agent_headers(), timeout=120.0) + try: + payload = r.json() + except Exception: + payload = {"raw": (r.text or "")[:2000]} + return {"id": ex["id"], "name": ex["name"], "status_code": r.status_code, "payload": payload} + except Exception as e: + return {"id": ex["id"], "name": ex["name"], "status_code": None, "error": str(e)} + + results = await asyncio.gather(*[one(ex) for ex in targets]) return {"results": list(results)} +@app.get("/api/trade/meta/{exchange_id}") +async def api_trade_meta(exchange_id: str): + ex = _find_exchange(exchange_id) + if not ex or not ex.get("enabled"): + raise HTTPException(status_code=404, detail="账户未启用") + async with httpx.AsyncClient() as client: + meta = await _fetch_flask_json(client, ex, "/api/hub/meta") + return {"exchange": ex, "meta": meta} + + +@app.post("/api/trade/order/{exchange_id}") +async def api_trade_order(exchange_id: str, request: Request): + ex = _find_exchange(exchange_id) + if not ex or not ex.get("enabled"): + raise HTTPException(status_code=404, detail="账户未启用") + form = await request.form() + async with httpx.AsyncClient() as client: + result = await _fetch_flask_json(client, ex, "/api/hub/add_order", "POST", dict(form)) + return {"exchange": ex, "result": result} + + +@app.post("/api/trade/key/{exchange_id}") +async def api_trade_key(exchange_id: str, request: Request): + ex = _find_exchange(exchange_id) + if not ex or not ex.get("enabled"): + raise HTTPException(status_code=404, detail="账户未启用") + if "key" not in (ex.get("capabilities") or []): + raise HTTPException(status_code=400, detail="该账户不支持关键位") + form = await request.form() + async with httpx.AsyncClient() as client: + result = await _fetch_flask_json(client, ex, "/api/hub/add_key", "POST", dict(form)) + return {"exchange": ex, "result": result} + + +@app.post("/api/trade/trend/preview/{exchange_id}") +async def api_trade_trend_preview(exchange_id: str, request: Request): + ex = _find_exchange(exchange_id) + if not ex or not ex.get("enabled"): + raise HTTPException(status_code=404, detail="账户未启用") + if "trend" not in (ex.get("capabilities") or []): + raise HTTPException(status_code=400, detail="该账户不支持趋势回调") + form = await request.form() + async with httpx.AsyncClient() as client: + result = await _fetch_flask_json(client, ex, "/api/hub/trend/preview", "POST", dict(form)) + return {"exchange": ex, "result": result} + + +@app.post("/api/trade/trend/execute/{exchange_id}") +async def api_trade_trend_execute(exchange_id: str, request: Request): + ex = _find_exchange(exchange_id) + if not ex or not ex.get("enabled"): + raise HTTPException(status_code=404, detail="账户未启用") + form = await request.form() + async with httpx.AsyncClient() as client: + result = await _fetch_flask_json(client, ex, "/api/hub/trend/execute", "POST", dict(form)) + return {"exchange": ex, "result": result} + + +@app.get("/api/trade/trend/preview/{exchange_id}/{preview_id}") +async def api_trade_trend_preview_get(exchange_id: str, preview_id: str): + ex = _find_exchange(exchange_id) + if not ex or not ex.get("enabled"): + raise HTTPException(status_code=404, detail="账户未启用") + async with httpx.AsyncClient() as client: + result = await _fetch_flask_json(client, ex, f"/api/hub/trend/preview/{preview_id}") + return {"exchange": ex, "result": result} + + def main(): import uvicorn diff --git a/manual_trading_hub/settings_store.py b/manual_trading_hub/settings_store.py new file mode 100644 index 0000000..4564ac3 --- /dev/null +++ b/manual_trading_hub/settings_store.py @@ -0,0 +1,95 @@ +"""中控交易所配置(hub_settings.json)。""" + +from __future__ import annotations + +import json +import os +from pathlib import Path + +DIR = Path(__file__).resolve().parent +SETTINGS_PATH = DIR / "hub_settings.json" + +DEFAULT_EXCHANGES = [ + { + "id": "0", + "key": "binance", + "name": "币安 · crypto_monitor_binance", + "agent_url": "http://127.0.0.1:15200", + "flask_url": "http://127.0.0.1:5001", + "review_url": "http://127.0.0.1:5001/records", + "enabled": True, + "capabilities": ["order", "key"], + }, + { + "id": "1", + "key": "okx", + "name": "OKX · crypto_monitor_okx", + "agent_url": "http://127.0.0.1:15201", + "flask_url": "http://127.0.0.1:5004", + "review_url": "http://127.0.0.1:5004/records", + "enabled": False, + "capabilities": ["order", "key"], + }, + { + "id": "2", + "key": "gate", + "name": "Gate训练 · crypto_monitor_gate", + "agent_url": "http://127.0.0.1:15202", + "flask_url": "http://127.0.0.1:5000", + "review_url": "http://127.0.0.1:5000/records", + "enabled": True, + "capabilities": ["order", "key"], + }, + { + "id": "3", + "key": "gate_bot", + "name": "Gate趋势 · crypto_monitor_gate_bot", + "agent_url": "http://127.0.0.1:15203", + "flask_url": "http://127.0.0.1:5002", + "review_url": "http://127.0.0.1:5002/records", + "enabled": True, + "capabilities": ["order", "trend"], + }, +] + + +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 env_force_disabled_ids() -> set[str]: + raw = os.getenv("HUB_DISABLED_IDS", "1").strip() + return _ids_from_csv(raw) + + +def load_settings() -> dict: + data = {"exchanges": [dict(x) for x in DEFAULT_EXCHANGES], "version": 1} + if SETTINGS_PATH.is_file(): + try: + loaded = json.loads(SETTINGS_PATH.read_text(encoding="utf-8")) + if isinstance(loaded, dict) and isinstance(loaded.get("exchanges"), list): + data = loaded + except Exception: + pass + force_off = env_force_disabled_ids() + for ex in data.get("exchanges") or []: + if str(ex.get("id")) in force_off: + ex["enabled"] = False + ex["env_disabled"] = True + else: + ex.setdefault("env_disabled", False) + return data + + +def save_settings(data: dict) -> None: + SETTINGS_PATH.write_text( + json.dumps(data, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + + +def enabled_exchanges(data: dict | None = None) -> list[dict]: + data = data or load_settings() + return [x for x in data.get("exchanges") or [] if x.get("enabled")] diff --git a/manual_trading_hub/static/app.css b/manual_trading_hub/static/app.css new file mode 100644 index 0000000..36e78c5 --- /dev/null +++ b/manual_trading_hub/static/app.css @@ -0,0 +1,102 @@ +: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: 0; + font-size: 14px; + line-height: 1.45; +} +a { color: var(--accent); } +.top-nav { + display: flex; + gap: 4px; + padding: 12px 20px; + border-bottom: 1px solid var(--border); + background: #12161c; + flex-wrap: wrap; +} +.top-nav a { + padding: 8px 16px; + border-radius: 6px; + text-decoration: none; + color: var(--muted); +} +.top-nav a.active { background: var(--panel); color: var(--text); border: 1px solid var(--border); } +.page { max-width: 1200px; margin: 0 auto; padding: 16px 20px 40px; } +.page.hidden { display: none; } +.toolbar { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; margin-bottom: 14px; } +button, .btn { + 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: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 { + padding: 10px 12px; + border-bottom: 1px solid var(--border); + display: flex; + justify-content: space-between; + flex-wrap: wrap; + gap: 8px; +} +.card-body { padding: 10px 12px; } +.grid-2 { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 12px; } +.form-row { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin-bottom: 8px; } +.form-row input, .form-row select, .form-row textarea { + background: #0d1117; + border: 1px solid var(--border); + color: var(--text); + border-radius: 6px; + padding: 7px 10px; + font-size: 13px; +} +.form-row input { min-width: 100px; } +.rule-tip { font-size: 12px; color: var(--muted); margin: 8px 0; line-height: 1.5; } +table { width: 100%; border-collapse: collapse; font-size: 12px; } +th, td { padding: 6px 8px; border-top: 1px solid var(--border); text-align: left; } +th { color: var(--muted); } +.pnl-pos { color: var(--green); } +.pnl-neg { color: var(--red); } +.err { color: var(--red); } +.badge { font-size: 11px; padding: 2px 6px; border-radius: 4px; background: #1f3a5a; color: #8fc8ff; } +.tabs { display: flex; gap: 6px; margin-bottom: 12px; flex-wrap: wrap; } +.tabs button.active { border-color: var(--accent); color: var(--accent); } +#toast { + position: fixed; bottom: 16px; right: 16px; + max-width: min(480px, 90vw); + background: var(--panel); + border: 1px solid var(--border); + padding: 10px 14px; + border-radius: 8px; + display: none; + z-index: 30; + white-space: pre-wrap; +} +#toast.show { display: block; } +.settings-table input { width: 100%; min-width: 80px; } +.chk-row { display: flex; gap: 12px; flex-wrap: wrap; font-size: 12px; } diff --git a/manual_trading_hub/static/app.js b/manual_trading_hub/static/app.js new file mode 100644 index 0000000..9205fd5 --- /dev/null +++ b/manual_trading_hub/static/app.js @@ -0,0 +1,480 @@ +(function () { + const toast = document.getElementById("toast"); + let settingsCache = null; + let tradeMeta = {}; + let trendPreviewId = null; + let monitorTimer = null; + + 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"), 7000); + } + + function esc(s) { + return String(s) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); + } + + function fmt(n, d) { + if (n === null || n === undefined || Number.isNaN(Number(n))) return "—"; + return Number(n).toLocaleString(undefined, { maximumFractionDigits: d }); + } + + function pnlCls(v) { + const n = Number(v); + if (!Number.isFinite(n) || n === 0) return ""; + return n > 0 ? "pnl-pos" : "pnl-neg"; + } + + function currentPage() { + const p = window.location.pathname.replace(/\/$/, "") || "/monitor"; + if (p.includes("trade")) return "trade"; + if (p.includes("settings")) return "settings"; + return "monitor"; + } + + function setActiveNav() { + const page = currentPage(); + document.querySelectorAll(".top-nav a").forEach((a) => { + a.classList.toggle("active", a.getAttribute("href").includes(page)); + }); + document.querySelectorAll(".page").forEach((el) => { + el.classList.toggle("hidden", !el.id.includes(page)); + }); + if (page === "monitor") startMonitorPoll(); + else stopMonitorPoll(); + if (page === "trade") initTradePage(); + if (page === "settings") loadSettingsUI(); + } + + function stopMonitorPoll() { + clearInterval(monitorTimer); + monitorTimer = null; + } + + function startMonitorPoll() { + stopMonitorPoll(); + loadMonitorBoard(); + if (document.getElementById("auto-monitor").checked) { + monitorTimer = setInterval(loadMonitorBoard, 5000); + } + } + + async function loadSettings() { + const r = await fetch("/api/settings"); + settingsCache = await r.json(); + return settingsCache; + } + + function enabledAccounts() { + return (settingsCache?.exchanges || []).filter((x) => x.enabled); + } + + async function loadMonitorBoard() { + const box = document.getElementById("monitor-grid"); + try { + const r = await fetch("/api/monitor/board"); + const data = await r.json(); + document.getElementById("monitor-updated").textContent = + "更新于 " + (data.updated_at || "").replace("T", " "); + const parts = (data.rows || []).map(renderMonitorCard); + box.innerHTML = parts.join("") || '
无已启用账户
'; + box.querySelectorAll(".btn-close-ex").forEach((btn) => { + btn.onclick = () => closeOne(btn.dataset.id); + }); + } catch (e) { + box.innerHTML = `
${esc(e)}
`; + } + } + + function renderMonitorCard(row) { + const ag = row.agent || {}; + const pos = Array.isArray(ag.positions) ? ag.positions : []; + const hm = row.hub_monitor || {}; + const keys = hm.keys || []; + const orders = hm.orders || []; + const trends = hm.trends || []; + const kmap = {}; + (row.key_prices || []).forEach((k) => { + kmap[k.id] = k; + }); + let inner = ""; + if (!row.http_ok) { + inner = `
${esc(row.error || "子代理不可用")}
`; + } else { + const posRows = pos + .map( + (x) => + `${esc(x.symbol)}${esc(x.side)}${fmt(x.contracts, 4)}${fmt(x.unrealized_pnl, 4)}` + ) + .join(""); + inner = `
余额 ${fmt(ag.balance_usdt, 2)} U · 浮盈合计 ${fmt(ag.total_unrealized_pnl, 4)}
`; + inner += pos.length + ? `${posRows}
合约方向张数浮盈
` + : `
交易所无持仓
`; + if (orders.length) { + inner += `
机器人持仓 ${orders.length} 笔
`; + orders.forEach((o) => { + inner += `
${esc(o.symbol)} ${o.direction} 成交${o.trigger_price}
`; + }); + } + if ((row.capabilities || []).includes("key") && keys.length) { + inner += `
关键位 ${keys.length} 条
`; + keys.slice(0, 6).forEach((k) => { + const kp = kmap[k.id] || {}; + inner += `
${esc(k.symbol)} ${esc(k.monitor_type)} 上${k.upper}/下${k.lower} 门控:${esc(kp.gate_summary || "-")}
`; + }); + } + if (trends.length) { + inner += `
趋势计划 ${trends.length} 个运行中
`; + trends.forEach((t) => { + inner += `
#${t.id} ${esc(t.symbol)} ${t.direction} SL${t.stop_loss} TP${t.take_profit}
`; + }); + } + } + const review = row.review_url + ? `复盘` + : ""; + return `
+
+
${esc(row.name)}
${esc(row.flask_url || "")}
+
+ ${review} + +
+
+
${inner}
+
`; + } + + async function closeOne(id) { + if (!confirm("确认对该账户市价全平?")) return; + try { + const r = await fetch("/api/close/" + encodeURIComponent(id), { method: "POST" }); + const j = await r.json(); + showToast(JSON.stringify(j, null, 2), !r.ok); + loadMonitorBoard(); + } catch (e) { + showToast(String(e), true); + } + } + + async function closeAll() { + const n = enabledAccounts().length; + if (!confirm(`对 ${n} 个已启用账户执行紧急全平?`)) return; + try { + const r = await fetch("/api/close-all", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ exclude_ids: [] }), + }); + const j = await r.json(); + showToast(JSON.stringify(j, null, 2), !r.ok); + loadMonitorBoard(); + } catch (e) { + showToast(String(e), true); + } + } + + function initTradePage() { + loadSettings().then(() => { + const sel = document.getElementById("trade-account"); + const prev = sel.value; + sel.innerHTML = enabledAccounts() + .map( + (x) => + `` + ) + .join(""); + if (prev) sel.value = prev; + syncTradeTabs(); + loadTradeMeta(); + }); + } + + function accountCaps() { + const id = document.getElementById("trade-account").value; + const ex = (settingsCache?.exchanges || []).find((x) => String(x.id) === String(id)); + return ex?.capabilities || []; + } + + function syncTradeTabs() { + const caps = accountCaps(); + document.querySelectorAll(".tabs button").forEach((btn) => { + const tab = btn.dataset.tab; + let ok = false; + if (tab === "order") ok = caps.includes("order"); + if (tab === "key") ok = caps.includes("key"); + if (tab === "trend") ok = caps.includes("trend"); + btn.disabled = !ok; + btn.style.opacity = ok ? "1" : "0.4"; + }); + let active = document.querySelector(".tabs button.active"); + if (active && active.disabled) { + const first = [...document.querySelectorAll(".tabs button")].find((b) => !b.disabled); + if (first) switchTradeTab(first.dataset.tab); + } + ["order", "key", "trend"].forEach((t) => { + document.getElementById("panel-" + t).classList.toggle( + "hidden", + !document.querySelector(`.tabs button[data-tab="${t}"]`).classList.contains("active") + ); + }); + } + + function switchTradeTab(tab) { + document.querySelectorAll(".tabs button").forEach((b) => { + b.classList.toggle("active", b.dataset.tab === tab); + }); + ["order", "key", "trend"].forEach((t) => { + document.getElementById("panel-" + t).classList.toggle("hidden", t !== tab); + }); + trendPreviewId = null; + document.getElementById("trend-preview-box").style.display = "none"; + } + + async function loadTradeMeta() { + const id = document.getElementById("trade-account").value; + if (!id) return; + try { + const r = await fetch("/api/trade/meta/" + encodeURIComponent(id)); + const data = await r.json(); + tradeMeta = data.meta?.meta || data.meta || {}; + const el = document.getElementById("trade-meta"); + if (tradeMeta.key_gate_rule_text) { + el.textContent = tradeMeta.key_gate_rule_text; + } else if (tradeMeta.trend_pullback_preview_ttl) { + el.textContent = + `预览有效期 ${tradeMeta.trend_pullback_preview_ttl}s · 补仓档 ${tradeMeta.trend_pullback_dca_legs} · 余额偏差≤${tradeMeta.trend_preview_max_drift_pct}%`; + } else { + el.textContent = ""; + } + } catch (e) { + document.getElementById("trade-meta").textContent = ""; + } + } + + async function submitForm(path, formEl) { + const id = document.getElementById("trade-account").value; + const fd = new FormData(formEl); + try { + const r = await fetch(path + encodeURIComponent(id), { method: "POST", body: fd }); + const j = await r.json(); + const res = j.result || {}; + const msgs = (res.messages || []).join("\n") || JSON.stringify(res, null, 2); + showToast(msgs, !res.ok); + if (res.ok && res.preview) { + showTrendPreview(res); + } + loadTradeMeta(); + } catch (e) { + showToast(String(e), true); + } + } + + function showTrendPreview(res) { + trendPreviewId = res.preview_id; + const p = res.preview || {}; + const box = document.getElementById("trend-preview-box"); + const levels = (p.grid_levels || []) + .map((r) => `${r.i}${r.price}${r.contracts}`) + .join(""); + box.innerHTML = ` +
预览 #${esc(p.id || trendPreviewId)} 剩余 ${p.expires_in_sec ?? "?"}s
+
${esc(p.symbol)} ${esc(p.direction)} ${p.leverage}x · 快照 ${fmt(p.snapshot_available_usdt, 2)} U
+ ${levels}
#补仓价张数
+
+ +
`; + box.style.display = "block"; + document.getElementById("btn-trend-exec").onclick = executeTrend; + } + + async function executeTrend() { + if (!trendPreviewId) { + showToast("请先生成预览", true); + return; + } + if (!confirm("确认按预览参数实盘下单?")) return; + const id = document.getElementById("trade-account").value; + const fd = new FormData(); + fd.set("preview_id", trendPreviewId); + try { + const r = await fetch("/api/trade/trend/execute/" + encodeURIComponent(id), { + method: "POST", + body: fd, + }); + const j = await r.json(); + const res = j.result || {}; + showToast((res.messages || []).join("\n") || JSON.stringify(res), !res.ok); + document.getElementById("trend-preview-box").style.display = "none"; + trendPreviewId = null; + } catch (e) { + showToast(String(e), true); + } + } + + async function loadSettingsMetaLine() { + try { + const r = await fetch("/api/settings/meta"); + const m = await r.json(); + const el = document.getElementById("settings-meta-line"); + if (!el) return; + const parts = []; + if (m.hub_bridge_token_set) parts.push("中控已配置 HUB_BRIDGE_TOKEN"); + else parts.push("中控未设 HUB_BRIDGE_TOKEN(实例需 APP_AUTH_DISABLED 或同令牌)"); + if ((m.env_disabled_ids || []).length) + parts.push("环境强制关闭 id: " + m.env_disabled_ids.join(", ")); + el.textContent = parts.join(" · "); + } catch (_) {} + } + + function loadSettingsUI() { + loadSettingsMetaLine(); + loadSettings().then((data) => { + const tbody = document.getElementById("settings-tbody"); + tbody.innerHTML = (data.exchanges || []) + .map((ex, idx) => renderSettingsRow(ex, idx)) + .join(""); + tbody.querySelectorAll(".btn-del-ex").forEach((btn) => { + btn.onclick = () => { + const i = Number(btn.dataset.idx); + data.exchanges.splice(i, 1); + settingsCache = data; + loadSettingsUI(); + }; + }); + }); + } + + function renderSettingsRow(ex, idx) { + const caps = ex.capabilities || []; + const envOff = ex.env_disabled + ? ' 环境变量强制关' + : ""; + return ` + ${envOff} + + + + + + + + + + + + `; + } + + function collectSettingsFromUI() { + const rows = [...document.querySelectorAll("#settings-tbody tr")]; + return { + version: 1, + exchanges: rows.map((tr) => { + const caps = []; + if (tr.querySelector(".cap-order").checked) caps.push("order"); + if (tr.querySelector(".cap-key").checked) caps.push("key"); + if (tr.querySelector(".cap-trend").checked) caps.push("trend"); + const id = tr.querySelector(".ex-id").value.trim(); + const stableKey = (tr.dataset.key || id).trim(); + return { + id: id, + key: stableKey, + name: tr.querySelector(".ex-name").value.trim(), + flask_url: tr.querySelector(".ex-flask").value.trim(), + agent_url: tr.querySelector(".ex-agent").value.trim(), + review_url: tr.querySelector(".ex-review").value.trim(), + enabled: tr.querySelector(".ex-enabled").checked, + capabilities: caps, + }; + }), + }; + } + + async function saveSettings() { + const body = collectSettingsFromUI(); + try { + const r = await fetch("/api/settings", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + const j = await r.json(); + if (j.ok) { + showToast("设置已保存(已写入 hub_settings.json)"); + await loadSettingsUI(); + } else showToast("保存失败", true); + } catch (e) { + showToast(String(e), true); + } + } + + document.getElementById("btn-monitor-refresh").onclick = loadMonitorBoard; + document.getElementById("auto-monitor").onchange = startMonitorPoll; + document.getElementById("btn-close-all").onclick = closeAll; + document.getElementById("trade-account").onchange = () => { + syncTradeTabs(); + loadTradeMeta(); + }; + document.querySelectorAll(".tabs button").forEach((btn) => { + btn.onclick = () => { + if (!btn.disabled) switchTradeTab(btn.dataset.tab); + }; + }); + document.getElementById("form-order").onsubmit = (e) => { + e.preventDefault(); + submitForm("/api/trade/order/", e.target); + }; + document.getElementById("form-key").onsubmit = (e) => { + e.preventDefault(); + submitForm("/api/trade/key/", e.target); + }; + document.getElementById("form-trend").onsubmit = (e) => { + e.preventDefault(); + submitForm("/api/trade/trend/preview/", e.target); + }; + document.getElementById("order-sltp-mode").onchange = function () { + const pct = this.value === "pct"; + document.getElementById("order-sl").style.display = pct ? "none" : ""; + document.getElementById("order-tp").style.display = pct ? "none" : ""; + document.getElementById("order-sl-pct").style.display = pct ? "" : "none"; + document.getElementById("order-tp-pct").style.display = pct ? "" : "none"; + }; + document.getElementById("key-sl-tp-mode").onchange = function () { + const manual = this.value === "trend_manual"; + document.getElementById("key-manual-tp").style.display = manual ? "" : "none"; + }; + document.getElementById("trend-direction").onchange = function () { + const inp = document.getElementById("trend-add-upper"); + inp.placeholder = this.value === "short" ? "补仓下沿价" : "补仓上沿价"; + }; + document.getElementById("btn-settings-save").onclick = saveSettings; + document.getElementById("btn-settings-reload").onclick = loadSettingsUI; + document.getElementById("btn-settings-add").onclick = () => { + const data = settingsCache || { exchanges: [] }; + const nid = String(Date.now() % 100000); + data.exchanges.push({ + id: nid, + key: "custom_" + nid, + name: "新交易所", + flask_url: "http://127.0.0.1:5000", + agent_url: "http://127.0.0.1:15200", + review_url: "", + enabled: false, + capabilities: ["order"], + }); + settingsCache = data; + loadSettingsUI(); + }; + + setActiveNav(); + window.addEventListener("popstate", setActiveNav); +})(); diff --git a/manual_trading_hub/static/index.html b/manual_trading_hub/static/index.html index d2ba263..6ab07f3 100644 --- a/manual_trading_hub/static/index.html +++ b/manual_trading_hub/static/index.html @@ -3,396 +3,159 @@ - 手工交易中控 - + 多账户交易中控 + -
-

手工交易 · 多账户中控

-
- - - - 关闭的账户不轮询、不参与全平(本机记住);账户显示名由中控环境变量 HUB_AGENT_NAMES 配置,所有访问同一中控的电脑一致。 - + + +
+

监控区

+
+ + + + +
+
-
+ + + + +
- + diff --git a/manual_trading_hub/使用说明.md b/manual_trading_hub/使用说明.md new file mode 100644 index 0000000..8230290 --- /dev/null +++ b/manual_trading_hub/使用说明.md @@ -0,0 +1,337 @@ +# 多账户交易中控 — 使用说明 + +本文档说明 **manual_trading_hub**(方案 A)的架构、启动方式、三页界面操作与故障排查。中控聚合四所 **监控 + 下单 + 关键位 + 趋势回调(仅 Gate 趋势户)**;**交易复盘**仍进入各实例网页,中控只提供跳转链接。 + +--- + +## 1. 架构总览 + +``` +浏览器 + ├─ /monitor 监控区(持仓、关键位、趋势计划、全平) + ├─ /trade 下单区(人工下单 / 关键位 / 趋势回调) + └─ /settings 系统设置(hub_settings.json) + +中控 hub.py(默认 :5100) + ├─ HTTP → 子代理 agent.py × N(/status、/emergency/close-all) + └─ HTTP → 各实例 Flask app.py(/api/hub/*、/api/price_snapshot) +``` + +| 组件 | 职责 | 默认端口(可在设置页改) | +|------|------|-------------------------| +| **hub.py** | 聚合 UI、转发下单/监控 API、全平 | `5100` | +| **agent.py** | 交易所只读状态 + 紧急市价全平 | 币安 `15200`、OKX `15201`、Gate `15202`、Gate趋势 `15203` | +| **crypto_monitor_*.app** | 策略库、关键位、人工单、趋势预览/执行 | 币安 `5001`、Gate `5000`、Gate趋势 `5002`、OKX `5004` | + +### 1.1 四账户默认配置 + +| id | 名称 | Flask | Agent | 能力 | 默认启用 | +|----|------|-------|-------|------|----------| +| 0 | 币安 | :5001 | :15200 | 下单、关键位 | 是 | +| 1 | OKX | :5004 | :15201 | 下单、关键位 | **否**(`HUB_DISABLED_IDS=1`) | +| 2 | Gate 训练 | :5000 | :15202 | 下单、关键位 | 是 | +| 3 | Gate 趋势 | :5002 | :15203 | **下单、趋势回调**(无关键位 Tab) | 是 | + +- **Gate 趋势户**:保留 **人工下单** 与 **趋势回调**;监控区可看到运行中的趋势计划,但 **不展示关键位**(capabilities 无 `key`)。 +- **OKX**:默认关闭;需要时在「系统设置」勾选启用,并去掉环境变量 `HUB_DISABLED_IDS` 中的 `1`。 + +### 1.2 实例侧改动(最小) + +各 `crypto_monitor_*` 仅增加: + +1. `login_required` 走 `hub_auth.request_allowed`(支持请求头 `X-Hub-Token`)。 +2. 文件末尾 `hub_bridge.install_on_app(...)` 注册 `/api/hub/*`。 + +业务逻辑、数据库、复盘页面 **未改**;复盘请打开各实例 `/records`(设置里的「复盘链接」)。 + +--- + +## 2. 环境准备 + +### 2.1 依赖安装 + +```powershell +cd c:\Users\dekun\Desktop\crypto_monitor\manual_trading_hub +pip install -r requirements.txt +``` + +### 2.2 鉴权令牌(推荐生产启用) + +四实例 Flask 与中控、子代理需 **同一密钥**: + +| 变量 | 作用 | +|------|------| +| `HUB_BRIDGE_TOKEN` | 中控 → Flask 使用头 `X-Hub-Token`;各实例 `hub_auth` 校验 | +| `CONTROL_TOKEN` | 可与上相同;中控 → 子代理使用头 `X-Control-Token` | + +中控 `hub.py` 会读取 `HUB_BRIDGE_TOKEN`,若无则回退 `CONTROL_TOKEN`。 + +**开发本机**可临时在各实例 `.env` 设 `APP_AUTH_DISABLED=true`,则 Flask 不校验令牌(仍建议子代理设 `CONTROL_TOKEN` 防误暴露)。 + +### 2.3 强制关闭某账户 + +```powershell +$env:HUB_DISABLED_IDS="1" # 默认即关闭 OKX(id=1) +``` + +与设置页「启用」取 **与** 关系:环境变量强制关闭时,网页勾选框会灰掉且无法启用。 + +### 2.4 配置文件 + +- 路径:`manual_trading_hub/hub_settings.json`(在网页 **系统设置 → 保存设置** 后写入)。 +- 未保存前使用 `settings_store.py` 内置默认四所地址。 +- 建议 **不要** 把含内网 IP 的 `hub_settings.json` 提交到公开仓库。 +- 环境变量模板:`manual_trading_hub/.env.example`;四实例模板中已补充 `HUB_BRIDGE_TOKEN` 说明。 + +--- + +## 3. 启动顺序(Windows 示例) + +**原则**:先子代理与四实例 Flask,再中控。 + +### 3.1 子代理(每所一个终端) + +在对应策略目录加载 `.env` 后: + +```powershell +# 币安 +cd c:\Users\dekun\Desktop\crypto_monitor\crypto_monitor_binance +$env:EXCHANGE="binance"; $env:PORT="15200"; $env:HOST="127.0.0.1" +# 可选:$env:CONTROL_TOKEN="你的随机串" +python ..\manual_trading_hub\agent.py +``` + +```powershell +# OKX(若启用) +cd ..\crypto_monitor_okx +$env:EXCHANGE="okx"; $env:PORT="15201" +python ..\manual_trading_hub\agent.py +``` + +```powershell +# Gate 训练 +cd ..\crypto_monitor_gate +$env:EXCHANGE="gate"; $env:PORT="15202" +python ..\manual_trading_hub\agent.py +``` + +```powershell +# Gate 趋势 +cd ..\crypto_monitor_gate_bot +$env:EXCHANGE="gate"; $env:PORT="15203" +python ..\manual_trading_hub\agent.py +``` + +### 3.2 四实例 Flask + +在各目录按原有方式启动 `app.py`(确保 `APP_PORT` 与设置页 Flask URL 一致)。 + +### 3.3 中控 + +```powershell +cd c:\Users\dekun\Desktop\crypto_monitor\manual_trading_hub +$env:HUB_HOST="0.0.0.0" # 仅本机可改为 127.0.0.1 +$env:HUB_PORT="5100" +$env:HUB_BRIDGE_TOKEN="你的随机串" # 与各实例、子代理一致 +python hub.py +``` + +浏览器打开: + +- 监控区:http://127.0.0.1:5100/monitor +- 下单区:http://127.0.0.1:5100/trade +- 系统设置:http://127.0.0.1:5100/settings + +--- + +## 4. 页面操作说明 + +### 4.1 监控区 `/monitor` + +| 功能 | 说明 | +|------|------| +| **2×2 卡片** | 仅显示「已启用」账户;每卡含子代理持仓、浮盈、余额 | +| **机器人持仓** | 来自实例 `/api/hub/monitor` 的 `order_monitors`(active) | +| **关键位** | 仅 `capabilities` 含 `key` 的户;展示门控摘要(`/api/price_snapshot`) | +| **趋势计划** | 仅 Gate 趋势户;`trend_pullback_plans` active | +| **复盘** | 新标签打开该户 `review_url`(各实例交易记录页) | +| **该户全平** | `POST` 子代理 `/emergency/close-all`,仅平该 API Key 仓位 | +| **全局紧急全平** | 对所有已启用户依次全平(不含 `HUB_DISABLED_IDS` 强制关闭的 id) | +| **自动刷新** | 默认每 5 秒请求 `/api/monitor/board` | + +持仓数据以 **子代理 ccxt** 为准;关键位/趋势/机器人单以 **Flask 数据库** 为准。若 Flask 未启动,卡片仍会显示 agent 持仓,但下方策略信息可能为空或报错。 + +### 4.2 下单区 `/trade` + +顶部 **账户下拉** 切换目标所;下方 Tab 根据该户 **能力** 自动启用/禁用: + +#### Tab:人工下单 + +- 字段与各实例「手工开仓」一致:合约、方向、止盈止损模式(价格/百分比)、趋势单/波段单、杠杆、移动保本、止损/止盈。 +- 提交后中控转发 `POST /api/hub/add_order`,逻辑与在实例网页下单相同(含以损定仓、门控等)。 +- **Gate 趋势户** 同样可使用本 Tab(保留人工下单)。 + +#### Tab:关键位 + +- 仅 **币安、Gate 训练、OKX**(capabilities 含 `key`)显示。 +- 类型:箱体突破、收敛突破、斐波回调、关键阻力/支撑等;模式含标准突破、箱体 1R、趋势单自填止盈。 +- 提交转发 `POST /api/hub/add_key`。 +- 页面上方会显示该实例 **关键位门控规则** 文案(来自 `/api/hub/meta`)。 + +#### Tab:趋势回调 + +- 仅 **Gate 趋势户**(capabilities 含 `trend`)。 +- 流程:**填写参数 → 生成预览 → 确认执行(实盘)**。 +- 预览转发 `POST /api/hub/trend/preview`;执行须带 `preview_id`,转发 `POST /api/hub/trend/execute`。 +- 预览有效期内展示补仓档位表;过期需重新生成。 +- 做空时「补仓上沿」在 UI 上提示为补仓下沿价(字段名仍为 `add_upper`,与实例一致)。 + +操作结果在页面底部 **Toast** 显示实例返回的 flash 汇总信息。 + +### 4.3 系统设置 `/settings` + +**可用**:打开 http://127.0.0.1:5100/settings ,修改表格后点 **保存设置** 即写入 `hub_settings.json`;**重新加载** 从磁盘/默认再读(会重新套用 `HUB_DISABLED_IDS`)。保存后监控区、下单区立即使用新 URL/启用状态,**无需重启 hub**。 + +| 列 | 含义 | +|----|------| +| 启用 | 是否参与监控与全局全平;被 `HUB_DISABLED_IDS` 锁定的无法勾选 | +| 显示名 | 监控卡片标题 | +| Flask URL | 实例根地址,如 `http://127.0.0.1:5001` | +| Agent URL | 子代理根地址,如 `http://127.0.0.1:15200` | +| 复盘链接 | 一般为 `{Flask}/records` | +| 能力 | 勾选 order / key / trend,控制下单区 Tab | +| id | 与 `HUB_DISABLED_IDS`、全平 API 路径中的 id 对应 | + +- **保存设置**:写入 `hub_settings.json`,重启 hub 后仍生效。 +- **添加交易所**:可增第五所等,需自行启动对应 Flask + agent 并填写 URL。 +- **删**:从列表移除(保存后生效)。 + +--- + +## 5. 能力矩阵(下单区 Tab) + +| 账户 | 人工下单 | 关键位 | 趋势回调 | +|------|:--------:|:------:|:--------:| +| 币安 | ✓ | ✓ | — | +| OKX | ✓ | ✓ | — | +| Gate 训练 | ✓ | ✓ | — | +| Gate 趋势 | ✓ | — | ✓ | + +--- + +## 6. HTTP API 摘要(中控) + +访问控制:默认允许 **本机** 与 **RFC1918 私网**(`HUB_TRUST_LAN=true`);公网 IP 访问返回 403。 + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/api/settings` | 读取配置 | +| POST | `/api/settings` | 保存配置 | +| GET | `/api/monitor/board` | 监控聚合 | +| POST | `/api/close/{id}` | 单户全平 | +| POST | `/api/close-all` | 全局全平,body 可选 `exclude_ids` | +| GET | `/api/trade/meta/{id}` | 实例 meta(门控文案等) | +| POST | `/api/trade/order/{id}` | 人工下单 | +| POST | `/api/trade/key/{id}` | 添加关键位 | +| POST | `/api/trade/trend/preview/{id}` | 趋势预览 | +| POST | `/api/trade/trend/execute/{id}` | 趋势执行 | + +实例侧(需 `X-Hub-Token` 或已登录): + +| 路径 | 说明 | +|------|------| +| `/api/hub/ping` | 连通与能力 | +| `/api/hub/meta` | 表单规则、预览 TTL 等 | +| `/api/hub/monitor` | 关键位、机器人单、趋势计划 | +| `/api/hub/add_order` | 人工开仓 | +| `/api/hub/add_key` | 添加关键位 | +| `/api/hub/trend/preview` | 趋势预览 | +| `/api/hub/trend/execute` | 趋势执行 | + +--- + +## 7. 环境变量速查 + +### 中控 hub.py + +| 变量 | 默认 | 说明 | +|------|------|------| +| `HUB_HOST` | `0.0.0.0` | 监听地址 | +| `HUB_PORT` | `5100` | 监听端口 | +| `HUB_BRIDGE_TOKEN` | 空 | Flask 桥接令牌;可同 `CONTROL_TOKEN` | +| `HUB_DISABLED_IDS` | `1` | 逗号分隔,强制关闭的账户 id | +| `HUB_TRUST_LAN` | `true` | `false` 时仅本机可访问中控页面 | + +### 子代理 agent.py + +| 变量 | 说明 | +|------|------| +| `EXCHANGE` | `binance` / `okx` / `gate` | +| `PORT` / `HOST` | 监听 | +| `CONTROL_TOKEN` | 与中控一致时必填头 `X-Control-Token` | + +### 各实例 Flask + +| 变量 | 说明 | +|------|------| +| `HUB_BRIDGE_TOKEN` | 与中控一致 | +| `APP_AUTH_DISABLED` | `true` 时跳过登录与令牌(仅建议本机调试) | + +--- + +## 8. 安全与边界 + +1. **中控可下单**:下单区操作会真实调用交易所逻辑,与在实例网页操作等价,请确认账户与参数。 +2. **全平为市价减仓**:监控区全平不可撤销,操作前二次确认。 +3. **子代理建议只监听 127.0.0.1**,不要对局域网暴露 API Key 通道。 +4. **公网暴露 hub**:请防火墙限制 `5100`,或 `HUB_HOST=127.0.0.1` + `HUB_TRUST_LAN=0`。 +5. **复盘不在中控**:时间筛选、导出 CSV、编辑笔记仍在各实例 `/records`。 +6. **OKX 默认关**:避免未部署 OKX 时监控卡片持续报错。 + +--- + +## 9. 故障排查 + +| 现象 | 可能原因 | 处理 | +|------|----------|------| +| 监控卡片「子代理不可用」 | agent 未启动或端口错 | 检查 Agent URL、启动 agent | +| 无关键位/趋势信息 | Flask 未启动或令牌错误 | 启动 app.py;核对 `HUB_BRIDGE_TOKEN` | +| 下单返回 401 | 令牌不一致 | 四实例与中控设相同 `HUB_BRIDGE_TOKEN` | +| 全平 401 | 子代理 `CONTROL_TOKEN` 与中控不一致 | 中控用 `X-Control-Token` 转发,需与 agent 一致 | +| OKX 始终灰色 | `HUB_DISABLED_IDS=1` | 清空该环境变量并在设置页启用 | +| 趋势预览成功但执行失败 | 预览过期 | 重新「生成预览」再执行 | +| 关键位 Tab 灰色 | 该户 capabilities 无 `key` | 正常;Gate 趋势户无关键位 | +| 局域网无法打开中控 | 防火墙 / `HUB_TRUST_LAN=0` | 放行端口或恢复默认信任私网 | + +手动探测实例桥接: + +```powershell +$tok = "你的令牌" +Invoke-WebRequest -Uri "http://127.0.0.1:5001/api/hub/ping" -Headers @{"X-Hub-Token"=$tok} +``` + +--- + +## 10. 与旧版 README 的差异 + +早期中控 **仅监控 + 全平**,使用环境变量 `HUB_AGENTS` 列表。当前版本改为: + +- **hub_settings.json**(或内置默认)管理四所 URL 与能力; +- **三页 UI**:监控 / 下单 / 设置; +- 通过 **hub_bridge** 调用实例下单 API。 + +子代理 `agent.py` 仍负责持仓与全平;`HUB_AGENTS` 环境变量在新版 hub 中 **不再使用**(以设置文件为准)。 + +更细的 Ubuntu screen / systemd 示例见同目录 **《部署文档.md》** 与 **scripts/**。 + +--- + +## 11. 日常推荐流程 + +1. 启动四所 **agent** + **Flask**(OKX 按需)。 +2. 启动 **hub.py**,打开监控区确认持仓与关键位门控正常。 +3. 在下单区选账户 → 人工单 / 关键位 / 趋势(按能力)。 +4. 复盘、导出记录 → 点击监控卡片「复盘」进入对应实例。 +5. 异常行情 → 单户全平或全局紧急全平。 + +如有新交易所,在 **系统设置** 添加一行并勾选能力,无需修改 hub.py 源码(需该所有 Flask 注册 `hub_bridge` 且 agent 已部署)。