(function () { const toast = document.getElementById("toast"); let settingsCache = null; let tradeMeta = {}; let trendPreviewId = null; let monitorTimer = null; function showToast(msg, isErr) { toast.textContent = msg; toast.style.borderColor = isErr ? "var(--red)" : "var(--border)"; toast.classList.add("show"); clearTimeout(showToast._t); showToast._t = setTimeout(() => toast.classList.remove("show"), 7000); } function esc(s) { return String(s) .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); } function fmt(n, d) { if (n === null || n === undefined || Number.isNaN(Number(n))) return "—"; return Number(n).toLocaleString(undefined, { maximumFractionDigits: d }); } function pnlCls(v) { const n = Number(v); if (!Number.isFinite(n) || n === 0) return ""; return n > 0 ? "pnl-pos" : "pnl-neg"; } function currentPage() { const p = window.location.pathname.replace(/\/$/, "") || "/monitor"; if (p.includes("trade")) return "trade"; if (p.includes("settings")) return "settings"; return "monitor"; } function setActiveNav() { const page = currentPage(); document.querySelectorAll(".top-nav a").forEach((a) => { a.classList.toggle("active", a.getAttribute("href").includes(page)); }); document.querySelectorAll(".page").forEach((el) => { el.classList.toggle("hidden", !el.id.includes(page)); }); if (page === "monitor") startMonitorPoll(); else stopMonitorPoll(); if (page === "trade") initTradePage(); if (page === "settings") loadSettingsUI(); } function stopMonitorPoll() { clearInterval(monitorTimer); monitorTimer = null; } function startMonitorPoll() { stopMonitorPoll(); loadMonitorBoard(); if (document.getElementById("auto-monitor").checked) { monitorTimer = setInterval(loadMonitorBoard, 5000); } } async function loadSettings() { const r = await fetch("/api/settings"); settingsCache = await r.json(); return settingsCache; } function enabledAccounts() { return (settingsCache?.exchanges || []).filter((x) => x.enabled); } async function loadMonitorBoard() { const box = document.getElementById("monitor-grid"); try { const r = await fetch("/api/monitor/board"); const data = await r.json(); document.getElementById("monitor-updated").textContent = "更新于 " + (data.updated_at || "").replace("T", " "); const parts = (data.rows || []).map(renderMonitorCard); box.innerHTML = parts.join("") || '
无已启用账户
'; box.querySelectorAll(".btn-close-ex").forEach((btn) => { btn.onclick = () => closeOne(btn.dataset.id); }); } catch (e) { box.innerHTML = `
${esc(e)}
`; } } function renderMonitorCard(row) { const ag = row.agent || {}; const pos = Array.isArray(ag.positions) ? ag.positions : []; const hm = row.hub_monitor || {}; const keys = hm.keys || []; const orders = hm.orders || []; const trends = hm.trends || []; const kmap = {}; (row.key_prices || []).forEach((k) => { kmap[k.id] = k; }); let inner = ""; if (!row.http_ok) { inner = `
${esc(row.error || "子代理不可用")}
`; } else { const posRows = pos .map( (x) => `${esc(x.symbol)}${esc(x.side)}${fmt(x.contracts, 4)}${fmt(x.unrealized_pnl, 4)}` ) .join(""); inner = `
余额 ${fmt(ag.balance_usdt, 2)} U · 浮盈合计 ${fmt(ag.total_unrealized_pnl, 4)}
`; inner += pos.length ? `${posRows}
合约方向张数浮盈
` : `
交易所无持仓
`; if (orders.length) { inner += `
机器人持仓 ${orders.length} 笔
`; orders.forEach((o) => { inner += `
${esc(o.symbol)} ${o.direction} 成交${o.trigger_price}
`; }); } if ((row.capabilities || []).includes("key") && keys.length) { inner += `
关键位 ${keys.length} 条
`; keys.slice(0, 6).forEach((k) => { const kp = kmap[k.id] || {}; inner += `
${esc(k.symbol)} ${esc(k.monitor_type)} 上${k.upper}/下${k.lower} 门控:${esc(kp.gate_summary || "-")}
`; }); } if (trends.length) { inner += `
趋势计划 ${trends.length} 个运行中
`; trends.forEach((t) => { inner += `
#${t.id} ${esc(t.symbol)} ${t.direction} SL${t.stop_loss} TP${t.take_profit}
`; }); } } const review = row.review_url ? `复盘` : ""; return `
${esc(row.name)}
${esc(row.flask_url || "")}
${review}
${inner}
`; } async function closeOne(id) { if (!confirm("确认对该账户市价全平?")) return; try { const r = await fetch("/api/close/" + encodeURIComponent(id), { method: "POST" }); const j = await r.json(); showToast(JSON.stringify(j, null, 2), !r.ok); loadMonitorBoard(); } catch (e) { showToast(String(e), true); } } async function closeAll() { const n = enabledAccounts().length; if (!confirm(`对 ${n} 个已启用账户执行紧急全平?`)) return; try { const r = await fetch("/api/close-all", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ exclude_ids: [] }), }); const j = await r.json(); showToast(JSON.stringify(j, null, 2), !r.ok); loadMonitorBoard(); } catch (e) { showToast(String(e), true); } } function initTradePage() { loadSettings().then(() => { const sel = document.getElementById("trade-account"); const prev = sel.value; sel.innerHTML = enabledAccounts() .map( (x) => `` ) .join(""); if (prev) sel.value = prev; syncTradeTabs(); loadTradeMeta(); }); } function accountCaps() { const id = document.getElementById("trade-account").value; const ex = (settingsCache?.exchanges || []).find((x) => String(x.id) === String(id)); return ex?.capabilities || []; } function syncTradeTabs() { const caps = accountCaps(); document.querySelectorAll(".tabs button").forEach((btn) => { const tab = btn.dataset.tab; let ok = false; if (tab === "order") ok = caps.includes("order"); if (tab === "key") ok = caps.includes("key"); if (tab === "trend") ok = caps.includes("trend"); btn.disabled = !ok; btn.style.opacity = ok ? "1" : "0.4"; }); let active = document.querySelector(".tabs button.active"); if (active && active.disabled) { const first = [...document.querySelectorAll(".tabs button")].find((b) => !b.disabled); if (first) switchTradeTab(first.dataset.tab); } ["order", "key", "trend"].forEach((t) => { document.getElementById("panel-" + t).classList.toggle( "hidden", !document.querySelector(`.tabs button[data-tab="${t}"]`).classList.contains("active") ); }); } function switchTradeTab(tab) { document.querySelectorAll(".tabs button").forEach((b) => { b.classList.toggle("active", b.dataset.tab === tab); }); ["order", "key", "trend"].forEach((t) => { document.getElementById("panel-" + t).classList.toggle("hidden", t !== tab); }); trendPreviewId = null; document.getElementById("trend-preview-box").style.display = "none"; } async function loadTradeMeta() { const id = document.getElementById("trade-account").value; if (!id) return; try { const r = await fetch("/api/trade/meta/" + encodeURIComponent(id)); const data = await r.json(); tradeMeta = data.meta?.meta || data.meta || {}; const el = document.getElementById("trade-meta"); if (tradeMeta.key_gate_rule_text) { el.textContent = tradeMeta.key_gate_rule_text; } else if (tradeMeta.trend_pullback_preview_ttl) { el.textContent = `预览有效期 ${tradeMeta.trend_pullback_preview_ttl}s · 补仓档 ${tradeMeta.trend_pullback_dca_legs} · 余额偏差≤${tradeMeta.trend_preview_max_drift_pct}%`; } else { el.textContent = ""; } } catch (e) { document.getElementById("trade-meta").textContent = ""; } } async function submitForm(path, formEl) { const id = document.getElementById("trade-account").value; const fd = new FormData(formEl); try { const r = await fetch(path + encodeURIComponent(id), { method: "POST", body: fd }); const j = await r.json(); const res = j.result || {}; const msgs = (res.messages || []).join("\n") || JSON.stringify(res, null, 2); showToast(msgs, !res.ok); if (res.ok && res.preview) { showTrendPreview(res); } loadTradeMeta(); } catch (e) { showToast(String(e), true); } } function showTrendPreview(res) { trendPreviewId = res.preview_id; const p = res.preview || {}; const box = document.getElementById("trend-preview-box"); const levels = (p.grid_levels || []) .map((r) => `${r.i}${r.price}${r.contracts}`) .join(""); box.innerHTML = `
预览 #${esc(p.id || trendPreviewId)} 剩余 ${p.expires_in_sec ?? "?"}s
${esc(p.symbol)} ${esc(p.direction)} ${p.leverage}x · 快照 ${fmt(p.snapshot_available_usdt, 2)} U
${levels}
#补仓价张数
`; box.style.display = "block"; document.getElementById("btn-trend-exec").onclick = executeTrend; } async function executeTrend() { if (!trendPreviewId) { showToast("请先生成预览", true); return; } if (!confirm("确认按预览参数实盘下单?")) return; const id = document.getElementById("trade-account").value; const fd = new FormData(); fd.set("preview_id", trendPreviewId); try { const r = await fetch("/api/trade/trend/execute/" + encodeURIComponent(id), { method: "POST", body: fd, }); const j = await r.json(); const res = j.result || {}; showToast((res.messages || []).join("\n") || JSON.stringify(res), !res.ok); document.getElementById("trend-preview-box").style.display = "none"; trendPreviewId = null; } catch (e) { showToast(String(e), true); } } async function loadSettingsMetaLine() { try { const r = await fetch("/api/settings/meta"); const m = await r.json(); const el = document.getElementById("settings-meta-line"); if (!el) return; const parts = []; if (m.hub_bridge_token_set) parts.push("中控已配置 HUB_BRIDGE_TOKEN"); else parts.push("中控未设 HUB_BRIDGE_TOKEN(实例需 APP_AUTH_DISABLED 或同令牌)"); if ((m.env_disabled_ids || []).length) parts.push("环境强制关闭 id: " + m.env_disabled_ids.join(", ")); el.textContent = parts.join(" · "); } catch (_) {} } function loadSettingsUI() { loadSettingsMetaLine(); loadSettings().then((data) => { const tbody = document.getElementById("settings-tbody"); tbody.innerHTML = (data.exchanges || []) .map((ex, idx) => renderSettingsRow(ex, idx)) .join(""); tbody.querySelectorAll(".btn-del-ex").forEach((btn) => { btn.onclick = () => { const i = Number(btn.dataset.idx); data.exchanges.splice(i, 1); settingsCache = data; loadSettingsUI(); }; }); }); } function renderSettingsRow(ex, idx) { const caps = ex.capabilities || []; const envOff = ex.env_disabled ? ' 环境变量强制关' : ""; return ` ${envOff} `; } function collectSettingsFromUI() { const rows = [...document.querySelectorAll("#settings-tbody tr")]; return { version: 1, exchanges: rows.map((tr) => { const caps = []; if (tr.querySelector(".cap-order").checked) caps.push("order"); if (tr.querySelector(".cap-key").checked) caps.push("key"); if (tr.querySelector(".cap-trend").checked) caps.push("trend"); const id = tr.querySelector(".ex-id").value.trim(); const stableKey = (tr.dataset.key || id).trim(); return { id: id, key: stableKey, name: tr.querySelector(".ex-name").value.trim(), flask_url: tr.querySelector(".ex-flask").value.trim(), agent_url: tr.querySelector(".ex-agent").value.trim(), review_url: tr.querySelector(".ex-review").value.trim(), enabled: tr.querySelector(".ex-enabled").checked, capabilities: caps, }; }), }; } async function saveSettings() { const body = collectSettingsFromUI(); try { const r = await fetch("/api/settings", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); const j = await r.json(); if (j.ok) { showToast("设置已保存(已写入 hub_settings.json)"); await loadSettingsUI(); } else showToast("保存失败", true); } catch (e) { showToast(String(e), true); } } document.getElementById("btn-monitor-refresh").onclick = loadMonitorBoard; document.getElementById("auto-monitor").onchange = startMonitorPoll; document.getElementById("btn-close-all").onclick = closeAll; document.getElementById("trade-account").onchange = () => { syncTradeTabs(); loadTradeMeta(); }; document.querySelectorAll(".tabs button").forEach((btn) => { btn.onclick = () => { if (!btn.disabled) switchTradeTab(btn.dataset.tab); }; }); document.getElementById("form-order").onsubmit = (e) => { e.preventDefault(); submitForm("/api/trade/order/", e.target); }; document.getElementById("form-key").onsubmit = (e) => { e.preventDefault(); submitForm("/api/trade/key/", e.target); }; document.getElementById("form-trend").onsubmit = (e) => { e.preventDefault(); submitForm("/api/trade/trend/preview/", e.target); }; document.getElementById("order-sltp-mode").onchange = function () { const pct = this.value === "pct"; document.getElementById("order-sl").style.display = pct ? "none" : ""; document.getElementById("order-tp").style.display = pct ? "none" : ""; document.getElementById("order-sl-pct").style.display = pct ? "" : "none"; document.getElementById("order-tp-pct").style.display = pct ? "" : "none"; }; document.getElementById("key-sl-tp-mode").onchange = function () { const manual = this.value === "trend_manual"; document.getElementById("key-manual-tp").style.display = manual ? "" : "none"; }; document.getElementById("trend-direction").onchange = function () { const inp = document.getElementById("trend-add-upper"); inp.placeholder = this.value === "short" ? "补仓下沿价" : "补仓上沿价"; }; document.getElementById("btn-settings-save").onclick = saveSettings; document.getElementById("btn-settings-reload").onclick = loadSettingsUI; document.getElementById("btn-settings-add").onclick = () => { const data = settingsCache || { exchanges: [] }; const nid = String(Date.now() % 100000); data.exchanges.push({ id: nid, key: "custom_" + nid, name: "新交易所", flask_url: "http://127.0.0.1:5000", agent_url: "http://127.0.0.1:15200", review_url: "", enabled: false, capabilities: ["order"], }); settingsCache = data; loadSettingsUI(); }; setActiveNav(); window.addEventListener("popstate", setActiveNav); })();