删除中控下单区

This commit is contained in:
dekun
2026-05-22 11:38:20 +08:00
parent 427f94e0e8
commit 661305c26a
7 changed files with 50 additions and 627 deletions
+2 -2
View File
@@ -1,8 +1,8 @@
# 手工交易多账户中控(manual_trading_hub
> **完整操作说明见 [使用说明.md](./使用说明.md)**(监控区 / 下单区 / 系统设置、鉴权、四所能力与故障排查)。
> **完整操作说明见 [使用说明.md](./使用说明.md)**(监控区 / 系统设置、鉴权、四所能力与故障排查)。
本目录提供多账户 **监控 + 下单转发 + 紧急全平**。各 `crypto_monitor_*` 仅需注册 `hub_bridge`(见使用说明);策略与复盘仍在各实例网页
本目录提供多账户 **监控 + 紧急全平**人工下单、关键位、趋势回调请在`crypto_monitor_*` 实例网页操作;策略与复盘仍在各实例。
---
+11 -108
View File
@@ -1,6 +1,6 @@
"""
多账户交易中控:监控区 / 下单区 / 系统设置。
转发至各 crypto_monitor_* 的 /api/hub/* 与子代理 /status
多账户交易中控:监控区 / 系统设置。
聚合各实例监控数据与子代理 /status;下单请在各实例网页操作
"""
from __future__ import annotations
@@ -9,7 +9,7 @@ import os
from pathlib import Path
import httpx
from fastapi import Body, FastAPI, HTTPException, Query, Request
from fastapi import Body, FastAPI, HTTPException, Request
from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field
@@ -114,12 +114,18 @@ def root_redirect():
@app.get("/monitor")
@app.get("/trade")
@app.get("/settings")
def shell_pages():
return _shell_page()
@app.get("/trade")
def trade_removed_redirect():
from fastapi.responses import RedirectResponse
return RedirectResponse("/monitor", status_code=302)
@app.get("/api/settings")
def api_get_settings():
return load_settings()
@@ -142,7 +148,7 @@ 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"],
"capability_options": ["key", "trend"],
"public_origin": f"{po[0]}://{po[1]}" if po else None,
"public_origin_hint": (
"未设置 HUB_PUBLIC_ORIGIN 时,复盘链接若为 127.0.0.1,仅服务器本机浏览器可打开"
@@ -182,25 +188,6 @@ async def _fetch_agent_status(client: httpx.AsyncClient, ex: dict) -> dict:
}
def _exchange_brief(ex: dict | None) -> dict | None:
if not ex:
return None
return {
"id": ex.get("id"),
"key": ex.get("key"),
"name": ex.get("name"),
}
def _form_plain_dict(form) -> dict[str, str]:
out: dict[str, str] = {}
for k, v in form.multi_items() if hasattr(form, "multi_items") else form.items():
if hasattr(v, "read"):
continue
out[str(k)] = "" if v is None else str(v)
return out
def _parse_http_json_body(r: httpx.Response) -> dict:
text = (r.text or "").strip()
if not text:
@@ -336,90 +323,6 @@ async def api_close_all(body: CloseAllBody | None = Body(default=None)):
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="账户未启用")
try:
form = _form_plain_dict(await request.form())
async with httpx.AsyncClient() as client:
result = await _fetch_flask_json(client, ex, "/api/hub/add_order", "POST", form)
return {"exchange": _exchange_brief(ex), "result": result}
except HTTPException:
raise
except Exception as e:
return JSONResponse(
{"exchange": _exchange_brief(ex), "result": {"ok": False, "messages": [str(e)]}},
status_code=200,
)
@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="该账户不支持关键位")
try:
form = _form_plain_dict(await request.form())
async with httpx.AsyncClient() as client:
result = await _fetch_flask_json(client, ex, "/api/hub/add_key", "POST", form)
return {"exchange": _exchange_brief(ex), "result": result}
except HTTPException:
raise
except Exception as e:
return JSONResponse(
{"exchange": _exchange_brief(ex), "result": {"ok": False, "messages": [str(e)]}},
status_code=200,
)
@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
+1 -67
View File
@@ -428,57 +428,7 @@ button:disabled {
white-space: nowrap;
}
/* —— 下单区 —— */
.trade-bar {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
margin-bottom: 16px;
padding: 14px 16px;
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--radius);
}
.trade-bar label {
font-size: 12px;
color: var(--muted);
margin-right: 6px;
}
.trade-bar select {
min-width: 220px;
}
.tabs {
display: flex;
gap: 4px;
margin-bottom: 16px;
padding: 4px;
background: var(--bg-elevated);
border-radius: var(--radius);
border: 1px solid var(--border-soft);
width: fit-content;
max-width: 100%;
flex-wrap: wrap;
}
.tabs button {
border: none;
background: transparent;
padding: 8px 16px;
border-radius: 7px;
}
.tabs button.active {
background: var(--panel);
color: var(--accent);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3);
border: 1px solid var(--border);
}
.trade-meta {
.settings-meta-line {
font-size: 12px;
color: var(--muted);
padding: 10px 14px;
@@ -489,21 +439,6 @@ button:disabled {
line-height: 1.55;
}
.form-panel.hidden {
display: none;
}
.form-panel .card-head strong {
font-size: 14px;
}
.form-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 12px;
align-items: end;
}
.field {
display: flex;
flex-direction: column;
@@ -522,7 +457,6 @@ button:disabled {
.field input,
.field select,
.trade-bar select,
.form-row input,
.form-row select {
background: var(--bg);
+9 -206
View File
@@ -1,8 +1,6 @@
(function () {
const toast = document.getElementById("toast");
let settingsCache = null;
let tradeMeta = {};
let trendPreviewId = null;
let monitorTimer = null;
function showToast(msg, isErr) {
@@ -34,7 +32,6 @@
function currentPage() {
const p = window.location.pathname.replace(/\/$/, "") || "/monitor";
if (p.includes("trade")) return "trade";
if (p.includes("settings")) return "settings";
return "monitor";
}
@@ -49,7 +46,6 @@
});
if (page === "monitor") startMonitorPoll();
else stopMonitorPoll();
if (page === "trade") initTradePage();
if (page === "settings") loadSettingsUI();
}
@@ -171,13 +167,18 @@
const review = row.review_url
? `<a class="btn-link" href="${esc(row.review_url)}" target="_blank" rel="noopener">复盘</a>`
: "";
const flaskOpen = row.flask_url_browser || row.flask_url;
const openFlask = flaskOpen
? `<a class="btn-link" href="${esc(flaskOpen)}" target="_blank" rel="noopener">实例</a>`
: "";
return `<div class="card">
<div class="card-head">
<div>
<div class="card-title">${esc(row.name)}</div>
<div class="card-sub">${esc(row.flask_url_browser || row.flask_url || "")}</div>
<div class="card-sub">${esc(flaskOpen || "")}</div>
</div>
<div class="card-actions">
${openFlask}
${review}
<button type="button" class="danger btn-close-ex" data-id="${esc(row.id)}">全平</button>
</div>
@@ -215,164 +216,6 @@
}
}
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");
let txt = "";
if (tradeMeta.key_gate_rule_text) txt = tradeMeta.key_gate_rule_text;
else if (tradeMeta.trend_pullback_preview_ttl) {
txt = `预览 ${tradeMeta.trend_pullback_preview_ttl}s · 补仓 ${tradeMeta.trend_pullback_dca_legs} 档 · 余额偏差 ≤${tradeMeta.trend_preview_max_drift_pct}%`;
}
el.textContent = txt;
el.style.display = txt ? "block" : "none";
} catch (e) {
const el = document.getElementById("trade-meta");
el.textContent = "";
el.style.display = "none";
}
}
async function parseJsonResponse(r) {
const text = await r.text();
if (!text) return {};
try {
return JSON.parse(text);
} catch (e) {
const snippet = text.slice(0, 200);
throw new Error(
`HTTP ${r.status} 响应不是 JSON${snippet}${text.length > 200 ? "…" : ""}`
);
}
}
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 parseJsonResponse(r);
const res = j.result || {};
const msgs =
(res.messages || []).join("\n") ||
res.error ||
res.msg ||
res.text ||
JSON.stringify(res, null, 2);
const failed = res.ok === false || r.status >= 400 || !!res.error || !!res.text;
showToast(msgs || (failed ? "操作失败" : "已提交"), failed);
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="section-title">预览 #${esc(p.id || trendPreviewId)} · ${p.expires_in_sec ?? "?"}s</div>
<div class="list-line">${esc(p.symbol)} ${esc(p.direction)} · ${p.leverage}x · 快照 ${fmt(p.snapshot_available_usdt, 2)} U</div>
<table class="data-table"><thead><tr><th>#</th><th></th><th></th></tr></thead><tbody>${levels}</tbody></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");
@@ -425,9 +268,8 @@
<div class="field field-wide"><label>复盘链接可空</label><input class="ex-review" value="${esc(ex.review_url || "")}" placeholder=" /records" /></div>
</div>
<div class="cap-chips">
<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>
<label><input type="checkbox" class="cap-key" ${caps.includes("key") ? "checked" : ""}/> 监控关键位</label>
<label><input type="checkbox" class="cap-trend" ${caps.includes("trend") ? "checked" : ""}/> 监控趋势计划</label>
</div>
<div class="settings-card-foot">
<div class="field"><label>id</label><input class="ex-id" value="${esc(ex.id || "")}" /></div>
@@ -442,7 +284,6 @@
version: 1,
exchanges: rows.map((card) => {
const caps = [];
if (card.querySelector(".cap-order").checked) caps.push("order");
if (card.querySelector(".cap-key").checked) caps.push("key");
if (card.querySelector(".cap-trend").checked) caps.push("trend");
const id = card.querySelector(".ex-id").value.trim();
@@ -482,44 +323,6 @@
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";
const slField = document.getElementById("order-sl").closest(".field");
const tpField = document.getElementById("order-tp").closest(".field");
if (slField) slField.style.display = pct ? "none" : "";
if (tpField) tpField.style.display = pct ? "none" : "";
document.getElementById("wrap-sl-pct").style.display = pct ? "" : "none";
document.getElementById("wrap-tp-pct").style.display = pct ? "" : "none";
};
document.getElementById("key-sl-tp-mode").onchange = function () {
const manual = this.value === "trend_manual";
document.getElementById("wrap-key-manual-tp").style.display = manual ? "" : "none";
};
document.getElementById("trend-direction").onchange = function () {
const lbl = document.getElementById("trend-add-label");
if (lbl) lbl.textContent = this.value === "short" ? "补仓下沿价" : "补仓上沿价";
};
document.getElementById("btn-settings-save").onclick = saveSettings;
document.getElementById("btn-settings-reload").onclick = loadSettingsUI;
document.getElementById("btn-settings-add").onclick = () => {
@@ -533,7 +336,7 @@
agent_url: "http://127.0.0.1:15200",
review_url: "",
enabled: false,
capabilities: ["order"],
capabilities: ["key"],
});
settingsCache = data;
loadSettingsUI();
+2 -181
View File
@@ -12,7 +12,6 @@
<div class="brand">复盘系统中控</div>
<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>
</header>
@@ -25,6 +24,7 @@
<summary>说明:数据来源与复盘链接</summary>
<div class="hint-body">
持仓与余额来自子代理;关键位、机器人单、趋势计划来自各实例 Flask(须 PM2 运行 crypto_*)。<br />
人工下单、添加关键位、趋势回调请在各实例网页操作;中控仅监控与紧急全平。<br />
「交易复盘」在新标签打开该实例 /records。其它电脑访问中控时,请在 hub 的 <code>.env</code> 设置
<code>HUB_PUBLIC_ORIGIN=http://服务器内网IP</code>
</div>
@@ -41,185 +41,6 @@
<div id="monitor-grid" class="grid-monitor"></div>
</div>
<div id="page-trade" class="page hidden">
<div class="page-head">
<h1>下单区</h1>
</div>
<div class="trade-bar">
<label for="trade-account">交易账户</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="trade-meta" style="display:none"></div>
<div id="panel-order" class="form-panel card">
<div class="card-head"><strong>人工下单</strong></div>
<div class="card-body">
<form id="form-order" class="form-grid">
<div class="field">
<label>合约</label>
<input name="symbol" placeholder="BTC / BTCUSDT" required />
</div>
<div class="field">
<label>方向</label>
<select name="direction" required>
<option value="">请选择</option>
<option value="long">做多</option>
<option value="short">做空</option>
</select>
</div>
<div class="field">
<label>止盈止损</label>
<select name="sltp_mode" id="order-sltp-mode">
<option value="price">价格</option>
<option value="pct">百分比</option>
</select>
</div>
<div class="field">
<label>单型</label>
<select name="trade_style" required>
<option value="trend">趋势单</option>
<option value="swing">波段单</option>
</select>
</div>
<div class="field">
<label>杠杆</label>
<input name="leverage" type="number" min="1" step="1" placeholder="可选" />
</div>
<div class="field field-check">
<input type="checkbox" name="breakeven_enabled" value="1" id="order-be" checked />
<label for="order-be">移动保本</label>
</div>
<div class="field">
<label>止损</label>
<input name="sl" id="order-sl" step="any" required />
</div>
<div class="field">
<label>止盈</label>
<input name="tgt" id="order-tp" step="any" required />
</div>
<div class="field" id="wrap-sl-pct" style="display:none">
<label>止损 %</label>
<input name="sl_pct" id="order-sl-pct" type="number" step="0.01" />
</div>
<div class="field" id="wrap-tp-pct" style="display:none">
<label>止盈 %</label>
<input name="tp_pct" id="order-tp-pct" type="number" step="0.01" />
</div>
<div class="form-actions">
<button type="submit" class="primary">开仓(以损定仓)</button>
</div>
</form>
</div>
</div>
<div id="panel-key" class="form-panel card hidden">
<div class="card-head"><strong>添加关键位</strong></div>
<div class="card-body">
<form id="form-key" class="form-grid">
<div class="field">
<label>合约</label>
<input name="symbol" placeholder="BTC / BTCUSDT" required />
</div>
<div class="field">
<label>类型</label>
<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>
</div>
<div class="field">
<label>方向</label>
<select name="direction" required>
<option value="">请选择</option>
<option value="long">做多</option>
<option value="short">做空</option>
</select>
</div>
<div class="field">
<label>上沿 / 阻力</label>
<input name="upper" step="any" required />
</div>
<div class="field">
<label>下沿 / 支撑</label>
<input name="lower" step="any" required />
</div>
<div class="field">
<label>模式</label>
<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>
</div>
<div class="field" id="wrap-key-manual-tp" style="display:none">
<label>趋势止盈价</label>
<input name="manual_take_profit" id="key-manual-tp" step="any" />
</div>
<div class="field field-check">
<input type="checkbox" name="breakeven_enabled" value="1" id="key-be-cb" />
<label for="key-be-cb">移动保本</label>
</div>
<div class="form-actions">
<button type="submit" class="primary">添加关键位</button>
</div>
</form>
</div>
</div>
<div id="panel-trend" class="form-panel card hidden">
<div class="card-head"><strong>趋势回调</strong></div>
<div class="card-body">
<form id="form-trend" class="form-grid">
<div class="field">
<label>合约</label>
<input name="symbol" placeholder="BTC / ETHUSDT" required />
</div>
<div class="field">
<label>方向</label>
<select name="direction" id="trend-direction" required>
<option value="">请选择</option>
<option value="long">做多</option>
<option value="short">做空</option>
</select>
</div>
<div class="field">
<label>杠杆</label>
<input name="leverage" type="number" min="1" step="1" required />
</div>
<div class="field">
<label>风险 %</label>
<input name="risk_percent" type="number" min="0.1" step="0.1" value="5" />
</div>
<div class="field">
<label>止损价</label>
<input name="sl" step="any" required />
</div>
<div class="field">
<label id="trend-add-label">补仓上沿价</label>
<input name="add_upper" id="trend-add-upper" step="any" required />
</div>
<div class="field">
<label>止盈价</label>
<input name="take_profit" step="any" required />
</div>
<div class="form-actions">
<button type="submit" class="primary">生成预览</button>
</div>
</form>
<div id="trend-preview-box" style="margin-top:16px;display:none"></div>
</div>
</div>
</div>
<div id="page-settings" class="page hidden">
<div class="page-head">
<h1>系统设置</h1>
@@ -231,7 +52,7 @@
<code>HUB_DISABLED_IDS</code> 可强制关闭账户;<code>HUB_BRIDGE_TOKEN</code> 与实例一致,或实例 <code>APP_AUTH_DISABLED=true</code>
</div>
</details>
<p id="settings-meta-line" class="trade-meta"></p>
<p id="settings-meta-line" class="settings-meta-line"></p>
<div class="toolbar">
<button type="button" id="btn-settings-save" class="primary">保存设置</button>
<button type="button" id="btn-settings-add">添加交易所</button>
+23 -61
View File
@@ -1,6 +1,6 @@
# 多账户交易中控 — 使用说明
本文档说明 **manual_trading_hub**(方案 A的架构、启动方式、三页界面操作与故障排查。中控聚合四所 **监控 + 下单 + 关键位 + 趋势回调(仅 Gate 趋势户)****交易复盘**仍进入各实例网页,中控只提供跳转链接
本文档说明 **manual_trading_hub** 的架构、启动方式、界面操作与故障排查。中控聚合四所 **持仓/余额/关键位/趋势计划监控 + 紧急全平****人工下单、添加关键位、趋势回调、交易复盘** 均在各实例网页操作
---
@@ -9,17 +9,16 @@
```
浏览器
├─ /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
└─ HTTP → 各实例 Flask/api/hub/monitor、/api/price_snapshot 等只读聚合
```
| 组件 | 职责 | 默认端口(可在设置页改) |
|------|------|-------------------------|
| **hub.py** | 聚合 UI、转发下单/监控 API、全平 | `5100` |
| **hub.py** | 聚合 UI、监控 API、全平 | `5100` |
| **agent.py** | 交易所只读状态 + 紧急市价全平 | 币安 `15200`、OKX `15201`、Gate `15202`、Gate趋势 `15203` |
| **crypto_monitor_*.app** | 策略库、关键位、人工单、趋势预览/执行 | 币安 `5001`、Gate `5000`、Gate趋势 `5002`、OKX `5004` |
@@ -139,8 +138,8 @@ python hub.py
浏览器打开:
- 监控区:http://127.0.0.1:5100/monitor
- 下单区:http://127.0.0.1:5100/trade
- 系统设置:http://127.0.0.1:5100/settings
- (旧链接 `/trade` 会自动跳转到监控区)
---
@@ -154,7 +153,7 @@ python hub.py
| **机器人持仓** | 来自实例 `/api/hub/monitor``order_monitors`active |
| **关键位** | 仅 `capabilities``key` 的户;展示门控摘要(`/api/price_snapshot` |
| **趋势计划** | 仅 Gate 趋势户;`trend_pullback_plans` active |
| **交易复盘** | 新标签打开该户 `/records`**中控不做复盘**。链接默认由 `flask_url` 生成;若配置 **`HUB_PUBLIC_ORIGIN`**(如 `http://192.168.x.x`),会把 `127.0.0.1` 换成 Ubuntu 内网 IP,方便局域网其它设备打开 |
| **实例 / 复盘** | 「实例」打开该户 Flask 首页;「复盘」打开 `/records`**中控不做下单与复盘编辑**。若配置 **`HUB_PUBLIC_ORIGIN`**,外链会把 `127.0.0.1` 换成内网 IP |
| **关键位** | 来自实例 `/api/hub/monitor` + `/api/price_snapshot`(须 Flask 已启动);无记录或 Flask 未连通时卡片会提示原因;**Gate 趋势户**无关键位 |
| **该户全平** | `POST` 子代理 `/emergency/close-all`,仅平该 API Key 仓位 |
| **全局紧急全平** | 对所有已启用户依次全平(不含 `HUB_DISABLED_IDS` 强制关闭的 id |
@@ -162,36 +161,11 @@ python hub.py
持仓数据以 **子代理 ccxt** 为准;关键位/趋势/机器人单以 **Flask 数据库** 为准。若 Flask 未启动,卡片仍会显示 agent 持仓,但下方策略信息可能为空或报错。
### 4.2 下单区 `/trade`
### 4.2 系统设置 `/settings`
顶部 **账户下拉** 切换目标所;下方 Tab 根据该户 **能力** 自动启用/禁用:
**可用**:打开 http://127.0.0.1:5100/settings ,修改表格后点 **保存设置** 即写入 `hub_settings.json`;**重新加载** 从磁盘/默认再读(会重新套用 `HUB_DISABLED_IDS`)。保存后监控区立即使用新 URL/启用状态,**无需重启 hub**。
#### 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**。
**下单与关键位**:请在监控卡片点击「实例」,进入各 `crypto_monitor_*` 网页操作(与中控上线前相同)。
| 列 | 含义 |
|----|------|
@@ -200,7 +174,7 @@ python hub.py
| 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 后仍生效。
@@ -209,14 +183,14 @@ python hub.py
---
## 5. 能力矩阵(下单区 Tab
## 5. 能力矩阵(监控展示
| 账户 | 人工下单 | 关键位 | 趋势回调 |
|------|:--------:|:------:|:--------:|
| 币安 | ✓ | ✓ | — |
| OKX | ✓ | ✓ | — |
| Gate 训练 | ✓ | ✓ | — |
| Gate 趋势 | ✓ | — | ✓ |
| 账户 | 监控关键位 | 监控趋势计划 |
|------|:----------:|:--------------:|
| 币安 | ✓ | — |
| OKX | ✓ | — |
| Gate 训练 | ✓ | — |
| Gate 趋势 | — | ✓ |
---
@@ -231,23 +205,13 @@ python hub.py
| 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/monitor` 等;下单请在实例网页):
| 路径 | 说明 |
|------|------|
| `/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` | 趋势执行 |
---
@@ -282,7 +246,7 @@ python hub.py
## 8. 安全与边界
1. **中控下单**下单区操作会真实调用交易所逻辑,与在实例网页操作等价,请确认账户与参数
1. **中控下单**开仓、关键位、趋势回调仅在各实例网页操作
2. **全平为市价减仓**:监控区全平不可撤销,操作前二次确认。
3. **子代理建议只监听 127.0.0.1**,不要对局域网暴露 API Key 通道。
4. **公网暴露 hub**:请防火墙限制 `5100`,或 `HUB_HOST=127.0.0.1` + `HUB_TRUST_LAN=0`
@@ -297,11 +261,9 @@ python hub.py
|------|----------|------|
| 监控卡片「子代理不可用」 | 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 趋势户无关键位 |
| 无关键位块 | 该户 capabilities 无 `key` | 正常;Gate 趋势户无关键位 |
| 局域网无法打开中控 | 防火墙 / `HUB_TRUST_LAN=0` | 放行端口或恢复默认信任私网 |
手动探测实例桥接:
@@ -318,8 +280,8 @@ Invoke-WebRequest -Uri "http://127.0.0.1:5001/api/hub/ping" -Headers @{"X-Hub-To
早期中控 **仅监控 + 全平**,使用环境变量 `HUB_AGENTS` 列表。当前版本改为:
- **hub_settings.json**(或内置默认)管理四所 URL 与能力;
- **页 UI**:监控 / 下单 / 设置;
- 通过 **hub_bridge** 调用实例下单 API
- **页 UI**:监控 / 设置;
- 通过 **hub_bridge** 只读聚合监控数据
子代理 `agent.py` 仍负责持仓与全平;`HUB_AGENTS` 环境变量在新版 hub 中 **不再使用**(以设置文件为准)。
@@ -345,8 +307,8 @@ pm2 save && pm2 startup
1. 启动四所 **agent** + **Flask**OKX 按需)。
2. 启动 **hub.py**,打开监控区确认持仓与关键位门控正常。
3. 在下单区选账户 → 人工单 / 关键位 / 趋势(按能力)
4. 复盘、导出记录 → 点击监控卡片「复盘」进入对应实例
3. 开仓、关键位、趋势 → 点击监控卡片「实例」进入对应 Flask
4. 复盘、导出记录 → 点击「复盘」进入 `/records`
5. 异常行情 → 单户全平或全局紧急全平。
如有新交易所,在 **系统设置** 添加一行并勾选能力,无需修改 hub.py 源码(需该所有 Flask 注册 `hub_bridge` 且 agent 已部署)。
+2 -2
View File
@@ -1,6 +1,6 @@
# 多账户交易中控 — 部署文档(含 PM2)
本文档说明在 **Ubuntu / Linux** 上部署 **manual_trading_hub**(监控区、下单区、系统设置)的推荐步骤。功能与界面操作见 **《使用说明.md》**;环境变量说明见 **`.env.example`** 与各 `crypto_monitor_*``.env.example`
本文档说明在 **Ubuntu / Linux** 上部署 **manual_trading_hub**(监控区、系统设置)的推荐步骤。功能与界面操作见 **《使用说明.md》**;环境变量说明见 **`.env.example`** 与各 `crypto_monitor_*``.env.example`
---
@@ -160,7 +160,7 @@ bash scripts/run_hub.sh
1. 打开 **http://127.0.0.1:5100/monitor**(局域网用本机私网 IP)。
2. 已启用账户应显示持仓;Flask 已起时有关键位/趋势信息。
3. **http://127.0.0.1:5100/settings** 保存后生成 `hub_settings.json`
4. **http://127.0.0.1:5100/trade** 选账户测试下单(实盘慎用)。
4. 在各实例 Flask 网页测试下单/关键位(中控仅监控,不下单)。
接口探测: