Files
crypto_monitor/manual_trading_hub/static/app.js
T

481 lines
18 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 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);
})();