Files
crypto_monitor/manual_trading_hub/static/funds.js
T
dekun ec8607932b feat: circular fund account cards with fullscreen detail view
Show per-exchange balances as clickable circles with mini curves; open a fullscreen panel for equity history and drawdown.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 17:04:21 +08:00

428 lines
14 KiB
JavaScript

/**
* 中控资金概况:总资金曲线、分户资金与回撤(资金户+交易户,不含浮盈)。
*/
(function () {
const page = document.getElementById("page-funds");
if (!page) return;
const elStatus = document.getElementById("funds-status");
const elTotal = document.getElementById("funds-total-usdt");
const elDdU = document.getElementById("funds-total-dd-u");
const elDdPct = document.getElementById("funds-total-dd-pct");
const elDelta = document.getElementById("funds-total-delta");
const elMeta = document.getElementById("funds-meta");
const elChartHost = document.getElementById("funds-chart-total");
const elAccounts = document.getElementById("funds-accounts");
const elBtnRefresh = document.getElementById("funds-btn-refresh");
const elFs = document.getElementById("funds-fullscreen");
const elFsBackdrop = document.getElementById("funds-fs-backdrop");
const elFsClose = document.getElementById("funds-fs-close");
const elFsTitle = document.getElementById("funds-fs-title");
const elFsSub = document.getElementById("funds-fs-sub");
const elFsTotal = document.getElementById("funds-fs-total");
const elFsFunding = document.getElementById("funds-fs-funding");
const elFsTrading = document.getElementById("funds-fs-trading");
const elFsDelta = document.getElementById("funds-fs-delta");
const elFsDd = document.getElementById("funds-fs-dd");
const elFsChartHost = document.getElementById("funds-fs-chart");
let chart = null;
let lineSeries = null;
let fsChart = null;
let fsLineSeries = null;
let inited = false;
let loading = false;
let lastOverview = null;
let fsAccountKey = "";
function fmt(n, d) {
if (n == null || n === "" || !Number.isFinite(Number(n))) return "—";
return Number(n).toFixed(d == null ? 2 : d);
}
function fmtDelta(n) {
if (n == null || !Number.isFinite(Number(n))) return "—";
const v = Number(n);
const sign = v > 0 ? "+" : "";
return sign + v.toFixed(2) + " U";
}
function deltaClass(n) {
if (!Number.isFinite(Number(n))) return "";
if (Number(n) > 0) return "pos";
if (Number(n) < 0) return "neg";
return "";
}
function setStatus(msg, isErr) {
if (!elStatus) return;
elStatus.textContent = msg || "";
elStatus.className = "funds-status" + (isErr ? " err" : "");
}
function seriesToChartData(series) {
return (series || [])
.filter(function (p) {
return p && p.day && Number.isFinite(Number(p.total_usdt));
})
.map(function (p) {
return { time: String(p.day), value: Number(p.total_usdt) };
});
}
function destroyChart() {
if (chart) {
chart.remove();
chart = null;
lineSeries = null;
}
if (elChartHost) elChartHost.innerHTML = "";
}
function destroyFsChart() {
if (fsChart) {
fsChart.remove();
fsChart = null;
fsLineSeries = null;
}
if (elFsChartHost) elFsChartHost.innerHTML = "";
}
function chartPalette() {
const light = document.documentElement.getAttribute("data-theme") === "light";
return light
? { bg: "#f0f4f9", text: "#4a6078", border: "#b8c8d8", line: "#006e9a" }
: { bg: "#0b0e18", text: "#9aa4b8", border: "#2a3348", line: "#3b82f6" };
}
function createAreaChart(host) {
const p = chartPalette();
const c = LightweightCharts.createChart(host, {
layout: { background: { color: p.bg }, textColor: p.text },
grid: {
vertLines: { color: p.border, visible: true },
horzLines: { color: p.border, visible: true },
},
rightPriceScale: { borderColor: p.border },
timeScale: { borderColor: p.border, timeVisible: true },
crosshair: { mode: LightweightCharts.CrosshairMode.Normal },
handleScroll: { mouseWheel: true, pressedMouseMove: true },
handleScale: { axisPressedMouseMove: true, mouseWheel: true, pinch: true },
});
const s = c.addAreaSeries({
lineColor: p.line,
topColor: p.line + "44",
bottomColor: p.line + "08",
lineWidth: 2,
priceFormat: { type: "price", precision: 2, minMove: 0.01 },
});
new ResizeObserver(function () {
if (c && host) {
c.applyOptions({ width: host.clientWidth, height: host.clientHeight });
}
}).observe(host);
c.applyOptions({ width: host.clientWidth, height: host.clientHeight });
return { chart: c, series: s };
}
function ensureChart() {
if (!elChartHost || !window.LightweightCharts) return;
if (chart) return;
const built = createAreaChart(elChartHost);
chart = built.chart;
lineSeries = built.series;
}
function ensureFsChart() {
if (!elFsChartHost || !window.LightweightCharts) return;
if (fsChart) return;
const built = createAreaChart(elFsChartHost);
fsChart = built.chart;
fsLineSeries = built.series;
}
function renderMiniChart(host, series) {
if (!host || !window.LightweightCharts) return;
host.innerHTML = "";
const data = seriesToChartData(series);
if (data.length < 2) {
host.textContent = "历史不足";
return;
}
const p = chartPalette();
const mini = LightweightCharts.createChart(host, {
layout: { background: { color: "transparent" }, textColor: p.text },
grid: { vertLines: { visible: false }, horzLines: { visible: false } },
rightPriceScale: { visible: false },
timeScale: { visible: false },
crosshair: { mode: LightweightCharts.CrosshairMode.Hidden },
handleScroll: false,
handleScale: false,
});
const s = mini.addAreaSeries({
lineColor: p.line,
topColor: p.line + "55",
bottomColor: p.line + "05",
lineWidth: 1.5,
});
s.setData(data);
mini.timeScale().fitContent();
const w = host.clientWidth;
const h = host.clientHeight;
if (w > 0 && h > 0) mini.applyOptions({ width: w, height: h });
}
function accountStatus(ac) {
if (!ac || !ac.monitored) return { text: "未监控", cls: "" };
if (ac.data_ok) return { text: "已监控", cls: "is-ok" };
return { text: "余额未齐", cls: "" };
}
function monitoredLabel(ac) {
return ac && ac.monitored ? "暂无曲线" : "未参与";
}
function shortAmt(ac) {
if (!ac || !ac.monitored || !ac.data_ok) return "—";
return fmt(ac.total_usdt, 0) + " U";
}
function renderAccounts(accounts) {
if (!elAccounts) return;
if (!accounts || !accounts.length) {
elAccounts.innerHTML = '<p class="funds-empty">暂无账户配置</p>';
return;
}
elAccounts.innerHTML = accounts
.map(function (ac) {
const monitored = !!ac.monitored;
const offCls = monitored ? "" : " is-off";
const st = accountStatus(ac);
const clickable = monitored ? "" : ' disabled aria-disabled="true"';
return (
'<div class="funds-ac-item">' +
'<button type="button" class="funds-ac-circle' +
offCls +
'" data-key="' +
(ac.key || "") +
'"' +
clickable +
' title="' +
(monitored ? "点击查看资金曲线" : "未监控,不参与合计") +
'">' +
'<div class="funds-ac-circle-chart" aria-hidden="true"></div>' +
'<div class="funds-ac-circle-overlay">' +
'<span class="funds-ac-circle-name">' +
(ac.name || ac.key || "—") +
"</span>" +
'<span class="funds-ac-circle-amt">' +
shortAmt(ac) +
"</span>" +
"</div>" +
"</button>" +
'<span class="funds-ac-circle-badge ' +
st.cls +
'">' +
st.text +
"</span>" +
"</div>"
);
})
.join("");
elAccounts.querySelectorAll(".funds-ac-circle").forEach(function (btn, idx) {
const ac = accounts[idx];
const host = btn.querySelector(".funds-ac-circle-chart");
if (ac && ac.monitored && host) {
renderMiniChart(host, ac.series || []);
} else if (host) {
host.textContent = monitoredLabel(ac);
}
if (ac && ac.monitored) {
btn.addEventListener("click", function () {
openAccountFullscreen(ac.key);
});
}
});
}
function findAccount(key) {
const accounts = (lastOverview && lastOverview.accounts) || [];
return accounts.find(function (ac) {
return String(ac.key || "") === String(key || "");
});
}
function closeAccountFullscreen() {
fsAccountKey = "";
destroyFsChart();
if (elFs) {
elFs.classList.add("hidden");
elFs.setAttribute("aria-hidden", "true");
}
document.body.classList.remove("funds-fullscreen-open");
}
function openAccountFullscreen(key) {
const ac = findAccount(key);
if (!ac || !ac.monitored) return;
fsAccountKey = String(key || "");
const dd = ac.drawdown || {};
const meta = lastOverview || {};
if (elFsTitle) elFsTitle.textContent = ac.name || ac.key || "—";
if (elFsSub) {
const parts = [
"资金户 + 交易户(不含浮盈)",
"交易日 " + (meta.trading_day || "—"),
"自 " + (meta.history_start_day || "2026-06-09") + " 起",
];
elFsSub.textContent = parts.join(" · ");
}
if (elFsTotal) {
elFsTotal.textContent =
ac.data_ok && ac.total_usdt != null ? fmt(ac.total_usdt, 2) + " U" : "—";
}
if (elFsFunding) {
elFsFunding.textContent =
ac.funding_usdt != null ? fmt(ac.funding_usdt, 2) + " U" : "—";
}
if (elFsTrading) {
elFsTrading.textContent =
ac.trading_usdt != null ? fmt(ac.trading_usdt, 2) + " U" : "—";
}
if (elFsDelta) {
elFsDelta.textContent = fmtDelta(ac.day_delta_usdt);
elFsDelta.className = "v " + deltaClass(ac.day_delta_usdt);
}
if (elFsDd) {
const ddU = dd.max_drawdown_u != null ? fmt(dd.max_drawdown_u, 2) + " U" : "—";
const ddPct = dd.max_drawdown_pct != null ? fmt(dd.max_drawdown_pct, 2) + "%" : "—";
elFsDd.textContent = ddU + " / " + ddPct;
}
if (elFs) {
elFs.classList.remove("hidden");
elFs.setAttribute("aria-hidden", "false");
document.body.classList.add("funds-fullscreen-open");
}
destroyFsChart();
const pts = seriesToChartData(ac.series || []);
if (pts.length) {
ensureFsChart();
if (fsLineSeries) {
fsLineSeries.setData(pts);
fsChart.timeScale().fitContent();
}
requestAnimationFrame(function () {
if (fsChart && elFsChartHost) {
fsChart.applyOptions({
width: elFsChartHost.clientWidth,
height: elFsChartHost.clientHeight,
});
fsChart.timeScale().fitContent();
}
});
} else if (elFsChartHost) {
elFsChartHost.innerHTML =
'<p class="funds-empty">暂无历史曲线,请保持监控板运行以积累快照</p>';
}
}
function renderOverview(data) {
lastOverview = data;
const totals = data.totals || {};
const dd = totals.drawdown || {};
if (elTotal) {
elTotal.textContent =
totals.total_usdt != null ? fmt(totals.total_usdt, 2) + " U" : "—";
}
if (elDdU) elDdU.textContent = dd.max_drawdown_u != null ? fmt(dd.max_drawdown_u, 2) + " U" : "—";
if (elDdPct) {
elDdPct.textContent = dd.max_drawdown_pct != null ? fmt(dd.max_drawdown_pct, 2) + "%" : "—";
}
if (elDelta) {
elDelta.textContent = fmtDelta(totals.day_delta_usdt);
elDelta.className = "funds-stat-val " + deltaClass(totals.day_delta_usdt);
}
if (elMeta) {
const parts = [
"交易日 " + (data.trading_day || "—"),
"切日 " + (data.reset_hour != null ? data.reset_hour : 8) + ":00 北京",
"自 " + (data.history_start_day || "2026-06-09") + " 起",
"最多 " + (data.keep_days || 180) + " 交易日",
];
if (data.updated_at) parts.push("刷新 " + data.updated_at);
if (totals.live_known_count != null) {
parts.push("合计含 " + totals.live_known_count + " 户");
}
elMeta.textContent = parts.join(" · ");
}
ensureChart();
if (lineSeries) {
const pts = seriesToChartData(totals.series || []);
if (pts.length) {
lineSeries.setData(pts);
chart.timeScale().fitContent();
} else {
lineSeries.setData([]);
}
}
renderAccounts(data.accounts || []);
if (fsAccountKey) {
const ac = findAccount(fsAccountKey);
if (ac && ac.monitored) openAccountFullscreen(fsAccountKey);
else closeAccountFullscreen();
}
}
async function load() {
if (loading) return;
loading = true;
setStatus("加载中…");
try {
const r = await fetch("/api/hub/fund-overview", { credentials: "same-origin" });
const j = await r.json();
if (!r.ok) {
setStatus(j.detail || j.msg || "加载失败", true);
return;
}
renderOverview(j);
setStatus("");
} catch (e) {
setStatus(String(e.message || e), true);
} finally {
loading = false;
}
}
function bind() {
if (elBtnRefresh) elBtnRefresh.addEventListener("click", load);
if (elFsBackdrop) elFsBackdrop.addEventListener("click", closeAccountFullscreen);
if (elFsClose) elFsClose.addEventListener("click", closeAccountFullscreen);
document.addEventListener("keydown", function (ev) {
if (ev.key === "Escape" && fsAccountKey) closeAccountFullscreen();
});
document.addEventListener("hub-theme-change", function () {
destroyChart();
destroyFsChart();
load();
});
}
function init() {
if (!page || page.classList.contains("hidden")) return;
if (!inited) {
bind();
inited = true;
}
load();
}
function destroy() {
closeAccountFullscreen();
destroyChart();
}
window.hubFundsPage = { init: init, destroy: destroy, reload: load };
})();