中控增加下单,关键位,系统设置

This commit is contained in:
dekun
2026-05-22 10:04:28 +08:00
parent ed6b56ff87
commit 46f73fce43
19 changed files with 1844 additions and 568 deletions
+480
View File
@@ -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, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
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);
})();