中控增加下单,关键位,系统设置
This commit is contained in:
@@ -15,6 +15,7 @@
|
||||
**/.env.backup*
|
||||
**/.env.bak
|
||||
**/.env.local
|
||||
manual_trading_hub/hub_settings.json
|
||||
|
||||
# 数据库与上传(运行时生成)
|
||||
**/*.sqlite
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
+20
@@ -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
|
||||
+240
@@ -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/<pid>")
|
||||
@_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
|
||||
@@ -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
|
||||
@@ -1,6 +1,8 @@
|
||||
# 手工交易多账户中控(manual_trading_hub)
|
||||
|
||||
本目录提供**极简中控**:只负责多账户**监控**(持仓、盈亏、余额等)与**紧急全平**,不参与开仓、策略或任何自动化下单。策略账户侧的 `crypto_monitor_*` 项目**无需改代码**,与中控并行运行即可。
|
||||
> **完整操作说明见 [使用说明.md](./使用说明.md)**(监控区 / 下单区 / 系统设置、鉴权、四所能力与故障排查)。
|
||||
|
||||
本目录提供多账户 **监控 + 下单转发 + 紧急全平**。各 `crypto_monitor_*` 仅需注册 `hub_bridge`(见使用说明);策略与复盘仍在各实例网页。
|
||||
|
||||
---
|
||||
|
||||
|
||||
+231
-165
@@ -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
|
||||
|
||||
|
||||
@@ -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")]
|
||||
@@ -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; }
|
||||
@@ -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, ">")
|
||||
.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("") || '<div class="err">无已启用账户</div>';
|
||||
box.querySelectorAll(".btn-close-ex").forEach((btn) => {
|
||||
btn.onclick = () => closeOne(btn.dataset.id);
|
||||
});
|
||||
} catch (e) {
|
||||
box.innerHTML = `<div class="err">${esc(e)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
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 = `<div class="err">${esc(row.error || "子代理不可用")}</div>`;
|
||||
} else {
|
||||
const posRows = pos
|
||||
.map(
|
||||
(x) =>
|
||||
`<tr><td>${esc(x.symbol)}</td><td>${esc(x.side)}</td><td>${fmt(x.contracts, 4)}</td><td class="${pnlCls(x.unrealized_pnl)}">${fmt(x.unrealized_pnl, 4)}</td></tr>`
|
||||
)
|
||||
.join("");
|
||||
inner = `<div class="rule-tip">余额 ${fmt(ag.balance_usdt, 2)} U · 浮盈合计 <span class="${pnlCls(ag.total_unrealized_pnl)}">${fmt(ag.total_unrealized_pnl, 4)}</span></div>`;
|
||||
inner += pos.length
|
||||
? `<table><tr><th>合约</th><th>方向</th><th>张数</th><th>浮盈</th></tr>${posRows}</table>`
|
||||
: `<div style="color:var(--muted);padding:6px 0">交易所无持仓</div>`;
|
||||
if (orders.length) {
|
||||
inner += `<div style="margin-top:8px;font-size:12px;color:#b8c4ff">机器人持仓 ${orders.length} 笔</div>`;
|
||||
orders.forEach((o) => {
|
||||
inner += `<div class="rule-tip">${esc(o.symbol)} ${o.direction} 成交${o.trigger_price}</div>`;
|
||||
});
|
||||
}
|
||||
if ((row.capabilities || []).includes("key") && keys.length) {
|
||||
inner += `<div style="margin-top:8px;font-size:12px;color:#b8c4ff">关键位 ${keys.length} 条</div>`;
|
||||
keys.slice(0, 6).forEach((k) => {
|
||||
const kp = kmap[k.id] || {};
|
||||
inner += `<div class="rule-tip">${esc(k.symbol)} ${esc(k.monitor_type)} 上${k.upper}/下${k.lower} 门控:${esc(kp.gate_summary || "-")}</div>`;
|
||||
});
|
||||
}
|
||||
if (trends.length) {
|
||||
inner += `<div style="margin-top:8px;font-size:12px;color:#b8c4ff">趋势计划 ${trends.length} 个运行中</div>`;
|
||||
trends.forEach((t) => {
|
||||
inner += `<div class="rule-tip">#${t.id} ${esc(t.symbol)} ${t.direction} SL${t.stop_loss} TP${t.take_profit}</div>`;
|
||||
});
|
||||
}
|
||||
}
|
||||
const review = row.review_url
|
||||
? `<a href="${esc(row.review_url)}" target="_blank" rel="noopener">复盘</a>`
|
||||
: "";
|
||||
return `<div class="card">
|
||||
<div class="card-head">
|
||||
<div><strong>${esc(row.name)}</strong><div class="rule-tip">${esc(row.flask_url || "")}</div></div>
|
||||
<div style="display:flex;gap:8px;align-items:center">
|
||||
${review}
|
||||
<button type="button" class="danger btn-close-ex" data-id="${esc(row.id)}">该户全平</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">${inner}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
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) =>
|
||||
`<option value="${esc(x.id)}">${esc(x.name)}</option>`
|
||||
)
|
||||
.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) => `<tr><td>${r.i}</td><td>${r.price}</td><td>${r.contracts}</td></tr>`)
|
||||
.join("");
|
||||
box.innerHTML = `
|
||||
<div class="rule-tip">预览 #${esc(p.id || trendPreviewId)} 剩余 ${p.expires_in_sec ?? "?"}s</div>
|
||||
<div class="rule-tip">${esc(p.symbol)} ${esc(p.direction)} ${p.leverage}x · 快照 ${fmt(p.snapshot_available_usdt, 2)} U</div>
|
||||
<table><tr><th>#</th><th>补仓价</th><th>张数</th></tr>${levels}</table>
|
||||
<div class="form-row" style="margin-top:8px">
|
||||
<button type="button" id="btn-trend-exec">确认执行(实盘)</button>
|
||||
</div>`;
|
||||
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
|
||||
? ' <span class="badge">环境变量强制关</span>'
|
||||
: "";
|
||||
return `<tr data-idx="${idx}" data-key="${esc(ex.key || ex.id || "")}">
|
||||
<td><input type="checkbox" class="ex-enabled" ${ex.enabled ? "checked" : ""} ${ex.env_disabled ? "disabled" : ""}/>${envOff}</td>
|
||||
<td><input class="ex-name" value="${esc(ex.name || "")}" /></td>
|
||||
<td><input class="ex-flask" value="${esc(ex.flask_url || "")}" /></td>
|
||||
<td><input class="ex-agent" value="${esc(ex.agent_url || "")}" /></td>
|
||||
<td><input class="ex-review" value="${esc(ex.review_url || "")}" /></td>
|
||||
<td class="chk-row">
|
||||
<label><input type="checkbox" class="cap-order" ${caps.includes("order") ? "checked" : ""}/>下单</label>
|
||||
<label><input type="checkbox" class="cap-key" ${caps.includes("key") ? "checked" : ""}/>关键位</label>
|
||||
<label><input type="checkbox" class="cap-trend" ${caps.includes("trend") ? "checked" : ""}/>趋势</label>
|
||||
</td>
|
||||
<td><input class="ex-id" value="${esc(ex.id || "")}" style="width:48px" /></td>
|
||||
<td><button type="button" class="btn-del-ex" data-idx="${idx}">删</button></td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
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);
|
||||
})();
|
||||
@@ -3,396 +3,159 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>手工交易中控</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0f1216;
|
||||
--panel: #171b22;
|
||||
--text: #e8eaed;
|
||||
--muted: #8b929a;
|
||||
--border: #2a313c;
|
||||
--green: #3fb950;
|
||||
--red: #f85149;
|
||||
--accent: #58a6ff;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
font-family: ui-sans-serif, system-ui, "Segoe UI", sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
margin: 0;
|
||||
padding: 16px clamp(16px, 4vw, 56px);
|
||||
font-size: 14px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.page {
|
||||
max-width: 1040px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
h1 { font-size: 1.1rem; font-weight: 600; margin: 0 0 12px; }
|
||||
.toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.toolbar span { color: var(--muted); font-size: 12px; }
|
||||
button {
|
||||
background: var(--panel);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 8px 14px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
button:hover { border-color: var(--accent); }
|
||||
button.danger { border-color: var(--red); color: var(--red); }
|
||||
button.danger:hover { background: #2d1514; }
|
||||
button:disabled { opacity: 0.45; cursor: not-allowed; }
|
||||
.card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.card-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.card-head strong { font-size: 14px; }
|
||||
.card-head .meta { color: var(--muted); font-size: 12px; word-break: break-all; }
|
||||
.metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.metrics div span { color: var(--muted); display: block; font-size: 11px; }
|
||||
.metrics-row-balance-upnl {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 8px 28px;
|
||||
padding-bottom: 8px;
|
||||
margin-bottom: 4px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 13px;
|
||||
}
|
||||
.metric-inline {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
}
|
||||
.metric-inline .metric-lbl { color: var(--muted); font-size: 12px; }
|
||||
.metric-inline .metric-num {
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
}
|
||||
.metric-inline .metric-num.pnl-pos { color: var(--green); }
|
||||
.metric-inline .metric-num.pnl-neg { color: var(--red); }
|
||||
.pnl-pos { color: var(--green); }
|
||||
.pnl-neg { color: var(--red); }
|
||||
th.hl-pnl,
|
||||
td.hl-pnl {
|
||||
background: rgba(88, 166, 255, 0.08);
|
||||
border-left: 2px solid rgba(88, 166, 255, 0.55);
|
||||
}
|
||||
th.hl-pnl { color: var(--accent); font-weight: 600; }
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
}
|
||||
th, td { padding: 8px 10px; text-align: left; border-top: 1px solid var(--border); }
|
||||
th { color: var(--muted); font-weight: 500; }
|
||||
.err { color: var(--red); padding: 12px; font-size: 13px; }
|
||||
.card-disabled { opacity: 0.72; border-style: dashed; }
|
||||
.card-disabled .card-head { border-bottom-style: dashed; }
|
||||
.off-note { padding: 12px 14px; color: var(--muted); font-size: 13px; }
|
||||
.monitor-toggle { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--muted); cursor: pointer; user-select: none; }
|
||||
.monitor-toggle input { cursor: pointer; }
|
||||
.monitor-toggle input:disabled { cursor: not-allowed; }
|
||||
#toast {
|
||||
position: fixed;
|
||||
bottom: 16px;
|
||||
right: 16px;
|
||||
max-width: min(420px, 90vw);
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
padding: 10px 14px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
display: none;
|
||||
z-index: 20;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
#toast.show { display: block; }
|
||||
</style>
|
||||
<title>多账户交易中控</title>
|
||||
<link rel="stylesheet" href="/assets/app.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
<h1>手工交易 · 多账户中控</h1>
|
||||
<div class="toolbar">
|
||||
<button type="button" id="btn-refresh">立即刷新</button>
|
||||
<label style="color:var(--muted);font-size:12px;display:flex;align-items:center;gap:6px;">
|
||||
<input type="checkbox" id="auto-refresh" checked /> 每 3 秒自动刷新
|
||||
</label>
|
||||
<button type="button" id="btn-close-all" class="danger">全局一键全平</button>
|
||||
<span style="color:var(--muted);font-size:12px;">关闭的账户不轮询、不参与全平(本机记住);账户显示名由中控环境变量 <code style="font-size:11px;">HUB_AGENT_NAMES</code> 配置,所有访问同一中控的电脑一致。</span>
|
||||
<span id="last-updated"></span>
|
||||
<nav class="top-nav">
|
||||
<a href="/monitor" id="nav-monitor">监控区</a>
|
||||
<a href="/trade" id="nav-trade">下单区</a>
|
||||
<a href="/settings" id="nav-settings">系统设置</a>
|
||||
</nav>
|
||||
|
||||
<div id="page-monitor" class="page">
|
||||
<h1>监控区</h1>
|
||||
<div class="toolbar">
|
||||
<button type="button" id="btn-monitor-refresh">立即刷新</button>
|
||||
<label style="color:var(--muted);font-size:12px;display:flex;align-items:center;gap:6px">
|
||||
<input type="checkbox" id="auto-monitor" checked /> 每 5 秒刷新
|
||||
</label>
|
||||
<button type="button" id="btn-close-all" class="danger">全局紧急全平</button>
|
||||
<span id="monitor-updated" style="color:var(--muted);font-size:12px"></span>
|
||||
</div>
|
||||
<div id="monitor-grid" class="grid-2"></div>
|
||||
</div>
|
||||
<div id="root"></div>
|
||||
|
||||
<div id="page-trade" class="page hidden">
|
||||
<h1>下单区</h1>
|
||||
<div class="form-row">
|
||||
<label>账户</label>
|
||||
<select id="trade-account"></select>
|
||||
</div>
|
||||
<div class="tabs">
|
||||
<button type="button" data-tab="order" class="active">人工下单</button>
|
||||
<button type="button" data-tab="key">关键位</button>
|
||||
<button type="button" data-tab="trend">趋势回调</button>
|
||||
</div>
|
||||
<div id="trade-meta" class="rule-tip"></div>
|
||||
|
||||
<div id="panel-order" class="card">
|
||||
<div class="card-head"><strong>人工下单</strong></div>
|
||||
<div class="card-body">
|
||||
<form id="form-order" class="form-row">
|
||||
<input name="symbol" placeholder="BTC 或 BTC/USDT" required />
|
||||
<select name="direction" required>
|
||||
<option value="">方向</option>
|
||||
<option value="long">做多</option>
|
||||
<option value="short">做空</option>
|
||||
</select>
|
||||
<select name="sltp_mode" id="order-sltp-mode">
|
||||
<option value="price">止盈止损:价格</option>
|
||||
<option value="pct">止盈止损:百分比</option>
|
||||
</select>
|
||||
<select name="trade_style" required>
|
||||
<option value="trend">趋势单</option>
|
||||
<option value="swing">波段单</option>
|
||||
</select>
|
||||
<input name="leverage" type="number" min="1" step="1" placeholder="杠杆(可选)" />
|
||||
<label style="display:flex;align-items:center;gap:4px;font-size:12px;color:var(--muted)">
|
||||
<input type="checkbox" name="breakeven_enabled" value="1" checked /> 移动保本
|
||||
</label>
|
||||
<input name="sl" id="order-sl" step="any" placeholder="止损价" required />
|
||||
<input name="tgt" id="order-tp" step="any" placeholder="止盈价" required />
|
||||
<input name="sl_pct" id="order-sl-pct" type="number" step="0.01" placeholder="止损%" style="display:none" />
|
||||
<input name="tp_pct" id="order-tp-pct" type="number" step="0.01" placeholder="止盈%" style="display:none" />
|
||||
<button type="submit">开仓(以损定仓)</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="panel-key" class="card hidden">
|
||||
<div class="card-head"><strong>添加关键位</strong></div>
|
||||
<div class="card-body">
|
||||
<form id="form-key" class="form-row">
|
||||
<input name="symbol" placeholder="BTC 或 BTC/USDT" required />
|
||||
<select name="type" required>
|
||||
<option value="箱体突破">箱体突破</option>
|
||||
<option value="收敛突破">收敛突破</option>
|
||||
<option value="斐波回调0.618">斐波回调0.618</option>
|
||||
<option value="斐波回调0.786">斐波回调0.786</option>
|
||||
<option value="关键阻力位">关键阻力位</option>
|
||||
<option value="关键支撑位">关键支撑位</option>
|
||||
</select>
|
||||
<select name="direction" required>
|
||||
<option value="">方向</option>
|
||||
<option value="long">做多</option>
|
||||
<option value="short">做空</option>
|
||||
</select>
|
||||
<input name="upper" step="any" placeholder="上沿/阻力" required />
|
||||
<input name="lower" step="any" placeholder="下沿/支撑" required />
|
||||
<select name="sl_tp_mode" id="key-sl-tp-mode">
|
||||
<option value="standard">标准突破</option>
|
||||
<option value="box_1p5">箱体1R·止盈1.5H</option>
|
||||
<option value="trend_manual">趋势单·自填止盈</option>
|
||||
</select>
|
||||
<input name="manual_take_profit" id="key-manual-tp" step="any" placeholder="趋势单止盈价" style="display:none" />
|
||||
<label style="display:flex;align-items:center;gap:4px;font-size:12px;color:var(--muted)">
|
||||
<input type="checkbox" name="breakeven_enabled" value="1" id="key-be-cb" /> 移动保本
|
||||
</label>
|
||||
<button type="submit">添加关键位</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="panel-trend" class="card hidden">
|
||||
<div class="card-head"><strong>趋势回调</strong></div>
|
||||
<div class="card-body">
|
||||
<form id="form-trend" class="form-row">
|
||||
<input name="symbol" placeholder="BTC 或 ETH/USDT" required />
|
||||
<select name="direction" id="trend-direction" required>
|
||||
<option value="">方向</option>
|
||||
<option value="long">做多</option>
|
||||
<option value="short">做空</option>
|
||||
</select>
|
||||
<input name="leverage" type="number" min="1" step="1" placeholder="杠杆" required />
|
||||
<input name="risk_percent" type="number" min="0.1" step="0.1" value="5" placeholder="风险%" />
|
||||
<input name="sl" step="any" placeholder="止损价" required />
|
||||
<input name="add_upper" id="trend-add-upper" step="any" placeholder="补仓上沿价" required />
|
||||
<input name="take_profit" step="any" placeholder="止盈价" required />
|
||||
<button type="submit">生成预览</button>
|
||||
</form>
|
||||
<div id="trend-preview-box" style="margin-top:12px;display:none"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="page-settings" class="page hidden">
|
||||
<h1>系统设置</h1>
|
||||
<p class="rule-tip">
|
||||
配置各交易所 Flask 地址与子代理地址,点击「保存设置」写入本目录
|
||||
<code>hub_settings.json</code>(重启 hub 后仍生效)。OKX 默认关闭;环境变量
|
||||
<code>HUB_DISABLED_IDS=1</code> 会强制关闭对应 id(勾选框灰掉)。实例须配置与中控一致的
|
||||
<code>HUB_BRIDGE_TOKEN</code>,或本机调试时 <code>APP_AUTH_DISABLED=true</code>。
|
||||
</p>
|
||||
<p id="settings-meta-line" class="rule-tip"></p>
|
||||
<div class="toolbar">
|
||||
<button type="button" id="btn-settings-save">保存设置</button>
|
||||
<button type="button" id="btn-settings-add">添加交易所</button>
|
||||
<button type="button" id="btn-settings-reload">重新加载</button>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-body" style="overflow:auto">
|
||||
<table class="settings-table" id="settings-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>启用</th><th>显示名</th><th>Flask URL</th><th>Agent URL</th><th>复盘链接</th>
|
||||
<th>能力</th><th>id</th><th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="settings-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="toast"></div>
|
||||
<script>
|
||||
const LS_EXCLUDED = "manual_trading_hub_excluded";
|
||||
const root = document.getElementById("root");
|
||||
const toast = document.getElementById("toast");
|
||||
const lastUpdated = document.getElementById("last-updated");
|
||||
let timer = null;
|
||||
let agentsList = [];
|
||||
let envExcludedSet = new Set();
|
||||
let rowById = new Map();
|
||||
|
||||
function loadExcludedLS() {
|
||||
try {
|
||||
const raw = localStorage.getItem(LS_EXCLUDED);
|
||||
const arr = raw ? JSON.parse(raw) : [];
|
||||
return new Set((Array.isArray(arr) ? arr : []).map(String));
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
function saveExcludedLS(set) {
|
||||
localStorage.setItem(LS_EXCLUDED, JSON.stringify([...set]));
|
||||
}
|
||||
|
||||
function showToast(msg, isErr) {
|
||||
toast.textContent = msg;
|
||||
toast.style.borderColor = isErr ? "var(--red)" : "var(--border)";
|
||||
toast.classList.add("show");
|
||||
clearTimeout(showToast._t);
|
||||
showToast._t = setTimeout(() => toast.classList.remove("show"), 6000);
|
||||
}
|
||||
|
||||
function fmtNum(x, d) {
|
||||
if (x === null || x === undefined || Number.isNaN(Number(x))) return "—";
|
||||
const n = Number(x);
|
||||
return n.toLocaleString(undefined, { maximumFractionDigits: d });
|
||||
}
|
||||
|
||||
function pnlClass(v) {
|
||||
const n = Number(v);
|
||||
if (!Number.isFinite(n) || n === 0) return "";
|
||||
return n > 0 ? "pnl-pos" : "pnl-neg";
|
||||
}
|
||||
|
||||
function renderActiveCard(r) {
|
||||
const err = r.error || (r.payload && r.payload.error);
|
||||
const p = r.payload || {};
|
||||
let inner;
|
||||
if (!r.http_ok || err) {
|
||||
inner = `<div class="err">${escapeHtml(String(err || ("HTTP " + (r.status_code ?? "?"))))}</div>`;
|
||||
} else {
|
||||
const pos = Array.isArray(p.positions) ? p.positions : [];
|
||||
const rows = pos.map(
|
||||
(x) =>
|
||||
`<tr>
|
||||
<td>${escapeHtml(x.symbol || "")}</td>
|
||||
<td>${escapeHtml(x.side || "")}</td>
|
||||
<td>${fmtNum(x.contracts, 6)}</td>
|
||||
<td>${fmtNum(x.notional_usdt, 2)}</td>
|
||||
<td class="hl-pnl ${pnlClass(x.unrealized_pnl)}">${fmtNum(x.unrealized_pnl, 4)}</td>
|
||||
<td>${fmtNum(x.entry_price, 6)}</td>
|
||||
</tr>`
|
||||
);
|
||||
const topBalUpnl = `<div class="metrics-row-balance-upnl">
|
||||
<span class="metric-inline"><span class="metric-lbl">余额 USDT</span><span class="metric-num">${fmtNum(p.balance_usdt, 2)}</span></span>
|
||||
<span class="metric-inline"><span class="metric-lbl">未实现盈亏合计</span><span class="metric-num ${pnlClass(p.total_unrealized_pnl)}">${fmtNum(p.total_unrealized_pnl, 4)}</span></span>
|
||||
</div>`;
|
||||
inner = `
|
||||
<div class="metrics">
|
||||
${topBalUpnl}
|
||||
<div><span>交易所</span>${escapeHtml(p.exchange || "—")}</div>
|
||||
<div><span>持仓模式</span>${escapeHtml(p.position_mode || "—")}</div>
|
||||
</div>
|
||||
${
|
||||
pos.length
|
||||
? `<table><thead><tr><th>合约</th><th>方向</th><th>张数</th><th>名义(约)</th><th class="hl-pnl">未实现盈亏</th><th>均价</th></tr></thead><tbody>${rows}</tbody></table>`
|
||||
: `<div style="padding:12px;color:var(--muted)">无持仓</div>`
|
||||
}`;
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="card" data-agent-id="${escapeHtml(r.id)}">
|
||||
<div class="card-head">
|
||||
<div>
|
||||
<strong>${escapeHtml(r.name)}</strong>
|
||||
<div class="meta">${escapeHtml(r.url)}</div>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;">
|
||||
<label class="monitor-toggle">
|
||||
<input type="checkbox" class="toggle-monitor" data-agent-id="${escapeHtml(r.id)}" checked />
|
||||
参与监控
|
||||
</label>
|
||||
<button type="button" class="danger btn-close-one" data-agent-id="${escapeHtml(r.id)}">该账户全平</button>
|
||||
</div>
|
||||
</div>
|
||||
${inner}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderDisabledCard(agent, reason) {
|
||||
const server = reason === "server";
|
||||
const inputAttrs = server
|
||||
? `class="toggle-monitor" data-agent-id="${escapeHtml(agent.id)}" disabled`
|
||||
: `class="toggle-monitor" data-agent-id="${escapeHtml(agent.id)}"`;
|
||||
const note = server
|
||||
? "已在服务端关闭(环境变量 HUB_DISABLED_IDS),不轮询、不参与全局全平。"
|
||||
: "已在本浏览器关闭。勾选「参与监控」可重新纳入轮询与全局全平。";
|
||||
return `
|
||||
<div class="card card-disabled" data-agent-id="${escapeHtml(agent.id)}">
|
||||
<div class="card-head">
|
||||
<div>
|
||||
<strong>${escapeHtml(agent.name)}</strong>
|
||||
<div class="meta">${escapeHtml(agent.url)}</div>
|
||||
</div>
|
||||
<label class="monitor-toggle">
|
||||
<input type="checkbox" ${inputAttrs} />
|
||||
参与监控
|
||||
</label>
|
||||
</div>
|
||||
<div class="off-note">${note}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
async function loadAll() {
|
||||
const lsEx = loadExcludedLS();
|
||||
const csv = [...lsEx].join(",");
|
||||
const qs = csv ? "?exclude_ids=" + encodeURIComponent(csv) : "";
|
||||
const [ar, sr] = await Promise.all([
|
||||
fetch("/api/agents").then((r) => r.json()),
|
||||
fetch("/api/snapshot" + qs).then((r) => r.json()),
|
||||
]);
|
||||
agentsList = ar.agents || [];
|
||||
envExcludedSet = new Set((sr.env_excluded_ids || []).map(String));
|
||||
rowById = new Map((sr.rows || []).map((row) => [String(row.id), row]));
|
||||
|
||||
const parts = [];
|
||||
for (const agent of agentsList) {
|
||||
const id = String(agent.id);
|
||||
const serverOff = envExcludedSet.has(id);
|
||||
const clientOff = lsEx.has(id);
|
||||
if (serverOff) {
|
||||
parts.push(renderDisabledCard(agent, "server"));
|
||||
} else if (clientOff) {
|
||||
parts.push(renderDisabledCard(agent, "client"));
|
||||
} else {
|
||||
const row = rowById.get(id);
|
||||
if (row) {
|
||||
parts.push(renderActiveCard(row));
|
||||
} else {
|
||||
parts.push(
|
||||
renderActiveCard({
|
||||
id,
|
||||
name: agent.name,
|
||||
url: agent.url,
|
||||
http_ok: false,
|
||||
error: "无快照",
|
||||
payload: null,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
root.innerHTML = parts.join("") || '<div class="err">无账户配置</div>';
|
||||
lastUpdated.textContent = "更新于 " + new Date().toLocaleTimeString();
|
||||
root.querySelectorAll(".btn-close-one").forEach((btn) => {
|
||||
btn.onclick = () => closeOne(btn.getAttribute("data-agent-id"));
|
||||
});
|
||||
}
|
||||
|
||||
async function closeOne(id) {
|
||||
if (!confirm("确认对该账户市价全平所有永续持仓?")) return;
|
||||
try {
|
||||
const res = await fetch("/api/close/" + encodeURIComponent(id), { method: "POST" });
|
||||
const j = await res.json();
|
||||
showToast(JSON.stringify(j, null, 2), !res.ok);
|
||||
await loadAll();
|
||||
} catch (e) {
|
||||
showToast(String(e), true);
|
||||
}
|
||||
}
|
||||
|
||||
async function closeAll() {
|
||||
const lsEx = loadExcludedLS();
|
||||
const activeCount = agentsList.filter(
|
||||
(a) => !envExcludedSet.has(String(a.id)) && !lsEx.has(String(a.id))
|
||||
).length;
|
||||
if (!confirm(`对当前 ${activeCount} 个已开启监控的账户执行市价全平?此操作不可撤销。`)) return;
|
||||
try {
|
||||
const res = await fetch("/api/close-all", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ exclude_ids: [...lsEx] }),
|
||||
});
|
||||
const j = await res.json();
|
||||
showToast(JSON.stringify(j, null, 2), !res.ok);
|
||||
await loadAll();
|
||||
} catch (e) {
|
||||
showToast(String(e), true);
|
||||
}
|
||||
}
|
||||
|
||||
root.addEventListener("change", (ev) => {
|
||||
const t = ev.target;
|
||||
if (!t.classList || !t.classList.contains("toggle-monitor")) return;
|
||||
if (t.disabled) return;
|
||||
const id = t.getAttribute("data-agent-id");
|
||||
if (!id) return;
|
||||
const set = loadExcludedLS();
|
||||
if (t.checked) set.delete(id);
|
||||
else set.add(id);
|
||||
saveExcludedLS(set);
|
||||
loadAll().catch((e) => showToast(String(e), true));
|
||||
});
|
||||
|
||||
document.getElementById("btn-refresh").onclick = () => loadAll().catch((e) => showToast(String(e), true));
|
||||
document.getElementById("btn-close-all").onclick = closeAll;
|
||||
|
||||
function schedule() {
|
||||
clearInterval(timer);
|
||||
if (document.getElementById("auto-refresh").checked)
|
||||
timer = setInterval(() => loadAll().catch(() => {}), 3000);
|
||||
}
|
||||
document.getElementById("auto-refresh").onchange = schedule;
|
||||
|
||||
loadAll().catch((e) => {
|
||||
root.innerHTML = `<div class="err">${escapeHtml(String(e))}</div>`;
|
||||
});
|
||||
schedule();
|
||||
</script>
|
||||
<script src="/assets/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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 已部署)。
|
||||
Reference in New Issue
Block a user