Files
crypto_monitor/manual_trading_hub/static/app.js
T
2026-05-22 10:56:42 +08:00

509 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(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 flaskOk = row.flask_ok !== false && hm.ok !== false;
const keys = flaskOk ? hm.keys || [] : [];
const orders = flaskOk ? hm.orders || [] : [];
const trends = flaskOk ? hm.trends || [] : [];
const kmap = {};
(row.key_prices || []).forEach((k) => {
kmap[k.id] = k;
});
let inner = "";
const agOk = ag.ok !== false;
const agErr = ag.error || row.error || "";
if (!row.http_ok) {
inner = `<div class="err">${esc(row.error || "子代理不可用")}</div>`;
} else if (!agOk) {
inner = `<div class="err">${esc(agErr || "子代理返回失败")}</div>`;
inner += `<div class="rule-tip">请检查:PM2 子代理是否在对应 crypto_monitor_* 目录加载了 .env<code>curl ${esc(row.agent_url || "")}/status</code></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")) {
if (!flaskOk) {
inner += `<div style="margin-top:8px;font-size:12px;color:#f85149">关键位/机器人:策略 Flask 未连通</div>`;
const fe = row.flask_error || hm.msg || hm.error || "";
const short =
fe ||
(hm.status === 404
? "HTTP 404:请 git pull 并重启各 crypto_* Flaskhub_bridge 路由未注册)"
: "请确认实例已启动,且 HUB_BRIDGE_TOKEN 与实例一致或 APP_AUTH_DISABLED=true");
inner += `<div class="rule-tip">${esc(short)}</div>`;
} else if (!keys.length) {
inner += `<div style="margin-top:8px;color:var(--muted);font-size:12px">关键位:当前无记录(在下单区或实例首页添加)</div>`;
} else {
inner += `<div style="margin-top:8px;font-size:12px;color:#b8c4ff">关键位 ${keys.length} 条</div>`;
keys.slice(0, 8).forEach((k) => {
const kp = kmap[k.id] || kmap[String(k.id)] || {};
const mt = k.monitor_type || k.type || "";
inner += `<div class="rule-tip">${esc(k.symbol)} ${esc(mt)}${k.upper}/下${k.lower}`;
if (kp.price_display != null || kp.price != null) {
inner += ` · 现价 ${esc(kp.price_display != null ? kp.price_display : kp.price)}`;
}
inner += ` · 门控 ${esc(kp.gate_summary || "-")}</div>`;
});
}
} else if ((row.capabilities || []).includes("trend")) {
inner += `<div style="margin-top:6px;color:var(--muted);font-size:12px">该账户为趋势户,无关键位(见趋势计划或下单区)</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" title="打开该实例的交易记录与复盘页(不在中控内操作)">交易复盘</a>`
: "";
return `<div class="card">
<div class="card-head">
<div><strong>${esc(row.name)}</strong><div class="rule-tip">${esc(row.flask_url_browser || 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.public_origin) parts.push("复盘外链: " + m.public_origin);
else parts.push("未设 HUB_PUBLIC_ORIGIN(复盘 127.0.0.1 仅服务器本机可开)");
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);
})();