(function () {
const toast = document.getElementById("toast");
let settingsCache = null;
let authState = { required: false, logged_in: true };
let tpslPending = null;
let lastMonitorRows = [];
let expandedExchangeId = sessionStorage.getItem("hub_expanded_ex") || "";
const HUB_MONITOR_BOARD_CACHE_KEY = "hub_monitor_board_v1";
const HUB_MONITOR_CACHE_MAX_AGE_MS = 6 * 60 * 60 * 1000;
const MONITOR_BOARD_SNAPSHOT_URL = "/api/monitor/board/snapshot";
const HUB_MONITOR_SNAPSHOT_TIMEOUT_MS = 15000;
/** 关注:浮亏超过交易账户余额的比例(10%) */
const HUB_ALERT_FLOAT_LOSS_RATIO = 0.1;
let lastMonitorBoardUpdatedAt = "";
let localBoardVersion = 0;
let monitorBoardInFlight = false;
let monitorBoardFetchPending = false;
let monitorBoardSlowHintTimer = null;
let boardEventSource = null;
let sseReconnectTimer = null;
async function apiFetch(url, opts) {
const r = await fetch(url, opts);
if (r.status === 401) {
const next = encodeURIComponent(location.pathname + location.search);
location.href = "/login?next=" + next;
throw new Error("未登录");
}
return r;
}
let instanceFrameUrl = "";
/** @type {{ exchangeId: string, nextPath: string, title: string } | null} */
let instanceFrameCtx = null;
function isHubEmbedded() {
try {
return window.self !== window.top;
} catch (_) {
return true;
}
}
/** 在 LocalNav 等父页 iframe 内:直接替换本 iframe 地址,避免 postMessage / 三层嵌套 */
function openInstanceInParentFrame(url) {
try {
window.location.assign(url);
return true;
} catch (_) {
return false;
}
}
async function fetchInstanceOpenUrl(exchangeId, nextPath, opts) {
const options = opts || {};
const next = nextPath || "/";
const q = new URLSearchParams({ exchange_id: String(exchangeId), next });
if (options.embed) q.set("embed", "1");
if (globalThis.HubTheme && typeof HubTheme.get === "function") {
q.set("hub_theme", HubTheme.get());
}
const r = await apiFetch("/api/instance/open-url?" + q.toString());
const j = await r.json();
if (!j.ok || !j.url) {
throw new Error(j.detail || "无法生成打开链接");
}
return j.url;
}
async function openInstance(exchangeId, nextPath, opts) {
const options = opts || {};
const newTab = !!options.newTab;
const next = nextPath || "/";
try {
const embedded = isHubEmbedded();
const url = await fetchInstanceOpenUrl(exchangeId, next, { embed: embedded });
if (newTab) {
window.open(url, "_blank", "noopener");
return;
}
const row = lastMonitorRows.find((x) => String(x.id) === String(exchangeId));
const title = row ? row.name : exchangeId;
instanceFrameCtx = { exchangeId: String(exchangeId), nextPath: next, title };
if (embedded) {
try {
window.parent.postMessage(
{
type: "hub:open-instance-nav",
exchangeId: String(exchangeId),
nextPath: next,
title,
},
"*"
);
} catch (_) {}
if (openInstanceInParentFrame(url)) return;
}
openInstanceFrame(url, title);
} catch (e) {
showToast(String(e), true);
}
}
async function refreshInstanceFrame() {
if (!instanceFrameCtx) {
if (instanceFrameUrl) {
const frame = document.getElementById("instance-frame");
if (frame) frame.src = instanceFrameUrl;
}
return;
}
try {
const url = await fetchInstanceOpenUrl(
instanceFrameCtx.exchangeId,
instanceFrameCtx.nextPath,
{ embed: isHubEmbedded() }
);
instanceFrameUrl = url;
const frame = document.getElementById("instance-frame");
if (frame) frame.src = url;
} catch (e) {
showToast(String(e), true);
}
}
function openInstanceFrame(url, title) {
const shell = document.getElementById("instance-frame-shell");
const frame = document.getElementById("instance-frame");
const titleEl = document.getElementById("instance-frame-title");
if (!shell || !frame) {
window.open(url, "_blank", "noopener");
return;
}
closeExchangeFullscreen();
instanceFrameUrl = url;
if (titleEl) titleEl.textContent = title || "实例";
frame.src = url;
shell.classList.remove("hidden");
shell.setAttribute("aria-hidden", "false");
document.body.classList.add("hub-instance-frame-open");
if (frame.dataset.themeSyncBound !== "1") {
frame.dataset.themeSyncBound = "1";
frame.addEventListener("load", function syncInstanceFrameTheme() {
try {
if (globalThis.HubTheme && typeof HubTheme.get === "function" && frame.contentWindow) {
frame.contentWindow.postMessage(
{ type: "hub-theme-sync", theme: HubTheme.get() },
"*"
);
}
} catch (_) {}
});
}
}
function closeInstanceFrame() {
const shell = document.getElementById("instance-frame-shell");
const frame = document.getElementById("instance-frame");
instanceFrameUrl = "";
instanceFrameCtx = null;
if (frame) frame.src = "about:blank";
if (shell) {
shell.classList.add("hidden");
shell.setAttribute("aria-hidden", "true");
}
document.body.classList.remove("hub-instance-frame-open");
}
/** @deprecated use openInstance */
async function openInstanceInBrowser(exchangeId, nextPath) {
return openInstance(exchangeId, nextPath, { newTab: false });
}
async function initAuth() {
try {
const r = await fetch("/api/auth/status");
authState = await r.json();
const btn = document.getElementById("btn-logout");
if (btn) btn.style.display = authState.required ? "" : "none";
if (authState.required && !authState.logged_in) {
location.href =
"/login?next=" + encodeURIComponent(location.pathname + location.search);
return false;
}
return true;
} catch (_) {
return true;
}
}
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 });
}
/** 交易所持仓开仓价(四所子代理 entry_price) */
function positionEntryPrice(pos) {
if (!pos) return null;
const n = Number(pos.entry_price);
if (!Number.isFinite(n) || n <= 0) return null;
return n;
}
function symbolPriceKey(sym) {
return (sym || "").trim().toUpperCase();
}
function buildPriceTickMap(row) {
const map = Object.create(null);
const put = (sym, tick) => {
const k = symbolPriceKey(sym);
if (!k || tick == null || !Number.isFinite(Number(tick))) return;
if (map[k] == null) map[k] = Number(tick);
};
((row && row.agent && row.agent.positions) || []).forEach((p) => put(p.symbol, p.price_tick));
const hm = (row && row.hub_monitor) || {};
(hm.trends || []).forEach((t) => put(t.exchange_symbol || t.symbol, t.price_tick));
(hm.orders || []).forEach((o) => put(o.exchange_symbol || o.symbol, o.price_tick));
return map;
}
function lookupPriceTick(symbol, tickMap) {
if (!tickMap || !symbol) return null;
const k = symbolPriceKey(symbol);
if (tickMap[k] != null) return tickMap[k];
const base = normSym(symbol);
if (base && tickMap[base] != null) return tickMap[base];
return null;
}
function decimalsFromTick(tick) {
if (tick == null || !Number.isFinite(Number(tick)) || Number(tick) <= 0) return null;
const t = Number(tick);
if (t >= 1) return 0;
const s = t.toFixed(12).replace(/0+$/, "");
const frac = s.split(".")[1];
return frac ? Math.min(12, frac.length) : 0;
}
function defaultPriceDecimals(value) {
const n = Number(value);
if (!Number.isFinite(n)) return 4;
const av = Math.abs(n);
if (av >= 10000) return 2;
if (av >= 100) return 3;
if (av >= 1) return 4;
if (av >= 0.01) return 6;
return 8;
}
/** 按交易所 tick(子代理/Flask 下发)格式化价格 */
function fmtSymbolPrice(value, symbol, tickMap, displayFallback) {
if (displayFallback != null && displayFallback !== "") return String(displayFallback);
if (value == null || value === "") return "—";
const n = Number(value);
if (!Number.isFinite(n)) return "—";
const tick = lookupPriceTick(symbol, tickMap);
const d = decimalsFromTick(tick);
return fmt(n, d != null ? d : defaultPriceDecimals(n));
}
function fmtEntryPrice(pos, tickMap) {
if (pos && pos.entry_price_fmt) return String(pos.entry_price_fmt);
return fmtSymbolPrice(positionEntryPrice(pos), pos && pos.symbol, tickMap);
}
function positionMarkPrice(pos) {
if (!pos) return null;
const n = Number(pos.mark_price);
if (!Number.isFinite(n) || n <= 0) return null;
return n;
}
function fmtMarkPrice(pos, tickMap) {
if (pos && pos.mark_price_fmt) return String(pos.mark_price_fmt);
return fmtSymbolPrice(positionMarkPrice(pos), pos && pos.symbol, tickMap);
}
function resolveTrendPositionRatioPct(trendPlan) {
const t = trendPlan || {};
if (t.position_ratio_pct != null && t.position_ratio_pct !== "") {
const n = Number(t.position_ratio_pct);
if (Number.isFinite(n)) return n;
}
const snap = Number(t.snapshot_available_usdt);
const margin = Number(t.plan_margin_capital);
if (Number.isFinite(snap) && snap > 0 && Number.isFinite(margin) && margin > 0) {
return Math.round((margin / snap) * 10000) / 100;
}
return null;
}
function resolveTrendSizingFooter(mo, trendPlan, isTrend) {
if (!isTrend || !trendPlan || !trendPlan.id) {
return {
leverage: mo.leverage,
planBase: mo.margin_capital,
positionRatio: mo.position_ratio,
};
}
const base =
trendPlan.snapshot_available_usdt != null && trendPlan.snapshot_available_usdt !== ""
? trendPlan.snapshot_available_usdt
: trendPlan.plan_margin_capital;
return {
leverage: trendPlan.leverage,
planBase: base,
positionRatio: resolveTrendPositionRatioPct(trendPlan),
};
}
function formatMonitorRiskMeta(mo, trendPlan) {
const m = mo || {};
const t = trendPlan || {};
const amt =
m.risk_amount != null && m.risk_amount !== ""
? Number(m.risk_amount)
: t.risk_amount != null && t.risk_amount !== ""
? Number(t.risk_amount)
: null;
const pctRaw =
m.risk_percent != null && m.risk_percent !== ""
? m.risk_percent
: t.risk_percent != null && t.risk_percent !== ""
? t.risk_percent
: null;
if (pctRaw == null || pctRaw === "") {
if (amt != null && Number.isFinite(amt)) {
return `风险: ${fmt(amt, 2)}U`;
}
return null;
}
const pct = esc(pctRaw);
if (amt != null && Number.isFinite(amt)) {
return `风险: ${pct}%≈${fmt(amt, 2)}U`;
}
return `风险: ${pct}%`;
}
function resolveTrendMarkPrice(pos, trendPlan, symbol, tickMap) {
const fromPos = fmtMarkPrice(pos, tickMap);
if (fromPos && fromPos !== "—") return fromPos;
const t = trendPlan || {};
const sym = symbol || (pos && pos.symbol) || t.exchange_symbol || t.symbol || "";
if (t.floating_mark != null && t.floating_mark !== "") {
return fmtSymbolPrice(t.floating_mark, sym, tickMap);
}
if (t.last_mark_price != null && t.last_mark_price !== "") {
return fmtSymbolPrice(t.last_mark_price, sym, tickMap);
}
return "—";
}
function estimateLinearSwapUpnl(side, entry, mark, contracts, contractSize) {
const e = Number(entry);
const m = Number(mark);
const c = Math.abs(Number(contracts));
let mult = Number(contractSize);
if (!Number.isFinite(mult) || mult <= 0) mult = 1;
if (!Number.isFinite(e) || !Number.isFinite(m) || !Number.isFinite(c) || c <= 0) {
return null;
}
const diff =
(side || "long").toLowerCase() === "long" ? m - e : e - m;
return Math.round(diff * c * mult * 100) / 100;
}
/** 展示浮盈:子代理 unrealized_pnl;与 entry/mark/张数 推算偏差 >20% 时用推算值 */
function resolvePositionUpnlUsdt(pos, trendPlan, markOverride) {
const p = pos || {};
const t = trendPlan || {};
let exchange =
p.unrealized_pnl != null && p.unrealized_pnl !== ""
? Number(p.unrealized_pnl)
: null;
if (exchange != null && !Number.isFinite(exchange)) exchange = null;
const entry =
t.avg_entry_price != null && t.avg_entry_price !== ""
? Number(t.avg_entry_price)
: p.entry_price != null && p.entry_price !== ""
? Number(p.entry_price)
: t.trigger_price != null
? Number(t.trigger_price)
: null;
let mark =
markOverride != null && Number.isFinite(Number(markOverride))
? Number(markOverride)
: p.mark_price != null && p.mark_price !== ""
? Number(p.mark_price)
: t.floating_mark != null
? Number(t.floating_mark)
: t.last_mark_price != null
? Number(t.last_mark_price)
: null;
const contracts = p.contracts;
const cs =
p.contract_size != null && p.contract_size !== ""
? Number(p.contract_size)
: 1;
const computed = estimateLinearSwapUpnl(
p.side || t.direction,
entry,
mark,
contracts,
cs
);
if (computed == null) {
if (exchange != null) return exchange;
if (t.floating_pnl != null && t.floating_pnl !== "") {
const n = Number(t.floating_pnl);
if (Number.isFinite(n)) return n;
}
return null;
}
if (exchange == null) return computed;
const ref = Math.max(Math.abs(computed), 1);
if (Math.abs(exchange - computed) / ref > 0.2) return computed;
return exchange;
}
function resolveTrendFloatingPnl(pos, trendPlan, markOverride) {
return resolvePositionUpnlUsdt(pos, trendPlan, markOverride);
}
function formatFloatingPnlText(upnl, notionalUsdt) {
if (upnl == null || !Number.isFinite(Number(upnl))) return { text: "—", cls: "" };
let pnlText = fmt(upnl, 2) + "U";
const notional = Number(notionalUsdt);
if (Number.isFinite(notional) && Math.abs(notional) > 1e-8) {
const pct = (Number(upnl) / Math.abs(notional)) * 100;
pnlText += ` (${pct >= 0 ? "+" : ""}${pct.toFixed(2)}%)`;
}
return { text: pnlText, cls: pnlCls(upnl) };
}
/** 与实例策略页一致:浮盈亏 % = 浮盈亏 / 计划保证金 */
function formatTrendPlanFloatingPnl(upnl, planMargin) {
if (upnl == null || !Number.isFinite(Number(upnl))) {
return { text: "—", cls: "" };
}
let pnlText = fmt(upnl, 2) + "U";
const margin = Number(planMargin);
if (Number.isFinite(margin) && margin > 0) {
const pct = (Number(upnl) / margin) * 100;
pnlText += ` (${pct >= 0 ? "+" : ""}${pct.toFixed(2)}%)`;
}
const n = Number(upnl);
let cls = "pnl-neutral";
if (n > 0) cls = "pnl-profit";
else if (n < 0) cls = "pnl-loss";
return { text: pnlText, cls };
}
function renderDirectionBadge(side) {
const s = normSide(side);
const label = sideDirLabel(side);
const cls = s === "long" ? "direction-long" : s === "short" ? "direction-short" : "";
if (!cls) return esc(String(label));
return `${esc(label)}`;
}
function resolveTrendDcaLevels(t) {
if (Array.isArray(t.dca_levels) && t.dca_levels.length) return t.dca_levels;
const plan = t || {};
let grid = [];
let legAmounts = [];
try {
grid = JSON.parse(plan.grid_prices_json || "[]");
if (!Array.isArray(grid)) grid = [];
} catch (_e) {
grid = [];
}
try {
legAmounts = JSON.parse(plan.leg_amounts_json || "[]");
if (!Array.isArray(legAmounts)) legAmounts = [];
} catch (_e2) {
legAmounts = [];
}
const legsDone = Number(plan.legs_done) || 0;
const dcaLegs = Number(plan.dca_legs) || 0;
const firstDone = Number(plan.first_order_done) !== 0;
const out = [
{
label: "首仓",
price: null,
contracts: plan.first_order_amount,
status: firstDone ? "done" : "pending",
status_label: firstDone ? "已开仓" : "待开仓",
},
];
const n = Math.max(grid.length, legAmounts.length, dcaLegs);
for (let idx = 0; idx < n; idx += 1) {
const legI = idx + 1;
const done = legI <= legsDone;
out.push({
label: `补仓${legI}`,
price: idx < grid.length ? grid[idx] : null,
contracts: idx < legAmounts.length ? legAmounts[idx] : null,
status: done ? "done" : "pending",
status_label: done ? "已补仓" : "待补仓",
});
}
return out;
}
function pnlCls(v) {
const n = Number(v);
if (!Number.isFinite(n) || n === 0) return "";
return n > 0 ? "pnl-pos" : "pnl-neg";
}
function normSide(side) {
const s = (side || "").toLowerCase();
if (s === "buy") return "long";
if (s === "sell") return "short";
return s;
}
function sideDirCls(side) {
const s = normSide(side);
if (s === "long") return "side-long";
if (s === "short") return "side-short";
return "";
}
function sideDirLabel(side) {
const s = normSide(side);
if (s === "long") return "做多";
if (s === "short") return "做空";
return side || "—";
}
function isTrendHandoffOrder(monitorOrder) {
const mo = monitorOrder || {};
return String(mo.trade_style || "").toLowerCase() === "trend_pullback_handoff";
}
function isTrendContext(monitorOrder, trendPlan) {
const mo = monitorOrder || {};
const tp = trendPlan || {};
if (tp.id != null && Number(tp.id) > 0) return true;
const tid = Number(mo.trend_plan_id);
if (Number.isFinite(tid) && tid > 0) return true;
const mt = String(mo.monitor_type || "").trim();
if (mt === "趋势回调") return true;
const kst = String(mo.key_signal_type || "").trim();
return kst === "趋势回调" || kst === "趋势回调计划";
}
function trendAddZoneLabel(direction) {
return (direction || "long").toLowerCase() === "short" ? "补仓下沿" : "补仓上沿";
}
function monitorOrderSourceLabel(mo, trendPlan) {
if (isTrendContext(mo, trendPlan)) return "趋势回调计划";
const o = mo || {};
const mt = String(o.monitor_type || "").trim();
return mt || "下单监控";
}
function monitorOrderSourceHtml(mo, trendPlan) {
if (isTrendContext(mo, trendPlan)) {
return `来源: ${esc(monitorOrderSourceLabel(mo, trendPlan))}`;
}
const src = monitorOrderSourceLabel(mo, trendPlan);
const kst = String((mo && mo.key_signal_type) || "").trim();
let text = src;
if (kst && kst !== src && !text.includes(kst)) {
text += " · " + kst;
}
return `来源: ${esc(text)}`;
}
function renderDirectionHtml(side) {
const cls = sideDirCls(side);
const label = sideDirLabel(side);
if (!cls) return esc(String(label));
return `${esc(label)}`;
}
function keyHasPendingOrder(keyRow, keyPrice) {
const kp = keyPrice || {};
const oid = keyRow.fib_limit_order_id;
if (oid != null && String(oid).trim() !== "") return true;
const gm = String(kp.gate_metrics || "");
if (gm.includes("限价单") || gm.includes("挂单")) return true;
const gs = String(kp.gate_summary || "");
if (/挂|限价|等待成交/.test(gs)) return true;
return false;
}
function fmtKeyOrderAmount(keyRow) {
const raw = keyRow.fib_order_amount;
if (raw == null || raw === "") return "";
const n = Number(raw);
if (!Number.isFinite(n) || n <= 0) return "";
return `${fmt(n, 4)} 张`;
}
/** 全屏持仓区:按仓位数量附加布局 class(1~6 固定列数,7+ 自动填充) */
function hubPosListCountClass(n) {
const c = Math.max(0, parseInt(n, 10) || 0);
if (c <= 0) return "count-0";
if (c <= 6) return `count-${c}`;
return "count-many";
}
function currentPage() {
const p = window.location.pathname.replace(/\/$/, "") || "/monitor";
if (p.includes("settings")) return "settings";
if (p.includes("archive")) return "archive";
if (p.includes("funds")) return "funds";
if (p.includes("market")) return "market";
if (p.includes("/ai")) return "ai";
return "monitor";
}
function pageElementId(page) {
if (page === "settings") return "page-settings";
if (page === "archive") return "page-archive";
if (page === "funds") return "page-funds";
if (page === "market") return "page-market";
if (page === "ai") return "page-ai";
return "page-monitor";
}
function setActiveNav() {
const page = currentPage();
const pageId = pageElementId(page);
document.querySelectorAll(".top-nav a").forEach((a) => {
const href = (a.getAttribute("href") || "").split("?")[0];
a.classList.toggle(
"active",
href === "/" + page || (page === "monitor" && (href === "/" || href === "/monitor"))
);
});
document.querySelectorAll(".page").forEach((el) => {
el.classList.toggle("hidden", el.id !== pageId);
});
document.body.classList.toggle("hub-page-ai", page === "ai");
syncHubAiMobileViewport();
if (page === "monitor") startMonitorPoll();
else stopMonitorPoll();
if (page === "settings") loadSettingsUI();
if (page === "ai") loadAiPage();
if (page === "archive" && window.hubArchivePage) {
window.hubArchivePage.init();
} else if (window.hubArchivePage && window.hubArchivePage.destroy) {
window.hubArchivePage.destroy();
}
if (page === "funds" && window.hubFundsPage) {
window.hubFundsPage.init();
} else if (window.hubFundsPage && window.hubFundsPage.destroy) {
window.hubFundsPage.destroy();
}
if (page === "market" && window.hubMarketChart) {
window.hubMarketChart.init();
} else if (window.hubMarketChart) {
if (window.hubMarketChart.stopChartLive) window.hubMarketChart.stopChartLive();
else {
if (window.hubMarketChart.stopAutoRefresh) window.hubMarketChart.stopAutoRefresh();
}
if (window.hubMarketChart.stopPriceTagTimer) window.hubMarketChart.stopPriceTagTimer();
}
}
function stopMonitorPoll() {
closeMonitorBoardStream();
if (sseReconnectTimer) {
clearTimeout(sseReconnectTimer);
sseReconnectTimer = null;
}
}
function closeMonitorBoardStream() {
if (boardEventSource) {
boardEventSource.close();
boardEventSource = null;
}
}
function connectMonitorBoardStream() {
closeMonitorBoardStream();
if (!document.getElementById("auto-monitor")?.checked) return;
if (currentPage() !== "monitor") return;
boardEventSource = new EventSource("/api/monitor/board/stream");
boardEventSource.addEventListener("board", (ev) => {
try {
const st = JSON.parse(ev.data || "{}");
const ver = Number(st.board_version) || 0;
if (ver !== localBoardVersion) {
void fetchMonitorBoardSnapshot({ background: true });
} else if (st.aggregating && lastMonitorRows.length) {
applyMonitorBoardUi(lastMonitorRows, st.updated_at || lastMonitorBoardUpdatedAt, {
stale: true,
});
}
} catch (_) {}
});
boardEventSource.onerror = () => {
closeMonitorBoardStream();
if (sseReconnectTimer) clearTimeout(sseReconnectTimer);
sseReconnectTimer = setTimeout(() => {
if (currentPage() === "monitor" && document.getElementById("auto-monitor")?.checked) {
connectMonitorBoardStream();
void fetchMonitorBoardSnapshot({ background: true });
}
}, 8000);
};
}
async function requestMonitorBoardRefresh() {
await apiFetch("/api/monitor/board/refresh", { method: "POST" });
}
function clearMonitorBoardSlowHint() {
if (monitorBoardSlowHintTimer) {
clearTimeout(monitorBoardSlowHintTimer);
monitorBoardSlowHintTimer = null;
}
}
function scheduleMonitorBoardSlowHint(box) {
clearMonitorBoardSlowHint();
if (!box) return;
monitorBoardSlowHintTimer = setTimeout(() => {
if (lastMonitorRows.length) return;
const el = box.querySelector(".board-loading");
if (!el) return;
const sub = el.querySelector(".board-loading-sub");
if (sub) {
sub.textContent =
"后台首次聚合较慢(四所子代理 + Flask)。可检查 PM2、或设 HUB_BOARD_KEY_PRICES=false 加速。";
}
}, 12000);
}
function saveMonitorBoardCache(rows, updatedAt, boardVersion) {
try {
sessionStorage.setItem(
HUB_MONITOR_BOARD_CACHE_KEY,
JSON.stringify({
version: 1,
board_version: boardVersion != null ? boardVersion : localBoardVersion,
updated_at: updatedAt || "",
rows: rows || [],
saved_at: Date.now(),
})
);
} catch (_) {}
}
function loadMonitorBoardFromCache() {
try {
const raw = sessionStorage.getItem(HUB_MONITOR_BOARD_CACHE_KEY);
if (!raw) return null;
const data = JSON.parse(raw);
if (!data || !Array.isArray(data.rows) || !data.rows.length) return null;
const age = Date.now() - Number(data.saved_at || 0);
if (!Number.isFinite(age) || age > HUB_MONITOR_CACHE_MAX_AGE_MS) {
sessionStorage.removeItem(HUB_MONITOR_BOARD_CACHE_KEY);
return null;
}
return data;
} catch (_) {
return null;
}
}
function restoreMonitorBoardFromCache() {
const cached = loadMonitorBoardFromCache();
if (!cached) return false;
lastMonitorRows = cached.rows;
lastMonitorBoardUpdatedAt = cached.updated_at || "";
localBoardVersion = 0;
applyMonitorBoardUi(cached.rows, lastMonitorBoardUpdatedAt, { stale: true });
return true;
}
function applyMonitorBoardUi(rows, updatedAt, opts) {
const options = opts || {};
const tsRaw = updatedAt || lastMonitorBoardUpdatedAt || "";
if (updatedAt) lastMonitorBoardUpdatedAt = updatedAt;
const online = (rows || []).filter((x) => x.http_ok && (x.agent || {}).ok !== false).length;
const pill = document.getElementById("sys-status");
if (pill) {
pill.textContent = rows.length ? `LINK ${online}/${rows.length}` : "NO DATA";
pill.classList.toggle("warn", rows.length && online < rows.length);
if (options.stale) pill.classList.add("syncing");
else pill.classList.remove("syncing");
}
const upd = document.getElementById("monitor-updated");
if (upd) {
const ts = tsRaw.replace("T", " ");
upd.textContent = options.stale
? ts
? `缓存 ${ts} · 后台聚合中…`
: "后台聚合中…"
: ts
? `UPD ${ts}`
: "";
}
updateMonitorAlertSummary(rows || []);
renderMonitorGrid(rows || []);
}
function startMonitorPoll() {
const hadCache = restoreMonitorBoardFromCache();
void fetchMonitorBoardSnapshot({ showLoading: !hadCache });
connectMonitorBoardStream();
}
async function loadSettings() {
const r = await apiFetch("/api/settings");
settingsCache = await r.json();
return settingsCache;
}
function enabledAccounts() {
return (settingsCache?.exchanges || []).filter((x) => x.enabled);
}
function isMobileLayout() {
return window.matchMedia("(max-width: 720px)").matches;
}
function positionHasContracts(p) {
const c = Number(p && p.contracts);
return Number.isFinite(c) && Math.abs(c) >= 1e-12;
}
function exchangeNeedsFlask(row) {
const caps = row.capabilities || [];
return caps.includes("key") || caps.includes("trend");
}
function positionMissingStopLoss(pos, orders, trends) {
if (!positionHasContracts(pos)) return false;
const mo = findMonitorOrder(orders, pos.symbol, pos.side);
const tp = findTrendPlan(trends, pos.symbol, pos.side);
const tpsl = resolvePositionTpsl(pos, mo, tp);
const sl = tpsl.sl;
if (sl !== "" && sl != null && Number.isFinite(Number(sl))) return false;
const cond = condOrdersFromPosition(pos);
const picked = pickExTpslOrders(cond);
if (picked.sl && picked.sl.trigger_price != null) return false;
const et = pos.exchange_tpsl;
if (et && et.sl) return false;
return true;
}
function analyzeExchangeAlert(row) {
const ag = row.agent || {};
const hm = row.hub_monitor || {};
const pos = Array.isArray(ag.positions) ? ag.positions : [];
const flaskOk = row.flask_ok !== false && hm.ok !== false;
const upnl = Number(ag.total_unrealized_pnl);
const tradingBal = Number(row.trading_usdt);
const balance =
Number.isFinite(tradingBal) && tradingBal > 0
? tradingBal
: Number(ag.balance_usdt);
const sortUpnl = Number.isFinite(upnl) ? upnl : 0;
if (!row.http_ok) {
return { level: "error", summary: "子代理离线", sortUpnl: 0 };
}
if (ag.ok === false) {
return {
level: "error",
summary: (ag.error || row.error || "子代理异常").slice(0, 24),
sortUpnl: 0,
};
}
if (exchangeNeedsFlask(row) && !flaskOk) {
const fe = row.flask_error || hm.error || hm.msg || "Flask未连通";
return { level: "error", summary: String(fe).slice(0, 24), sortUpnl };
}
const orders = flaskOk ? hm.orders || [] : [];
const trends = flaskOk ? hm.trends || [] : [];
let missingSl = false;
for (const p of pos) {
if (positionMissingStopLoss(p, orders, trends)) {
missingSl = true;
break;
}
}
if (Number.isFinite(upnl) && upnl < 0 && Number.isFinite(balance) && balance > 0) {
const lossPct = (Math.abs(upnl) / balance) * 100;
if (lossPct >= HUB_ALERT_FLOAT_LOSS_RATIO * 100) {
return {
level: "warn",
summary: `浮亏超10% · ${fmt(upnl, 2)}U`,
sortUpnl,
};
}
}
if (missingSl) {
return { level: "warn", summary: "缺止损", sortUpnl };
}
const openCount = pos.filter(positionHasContracts).length;
return {
level: "ok",
summary: openCount ? "正常" : "空仓",
sortUpnl,
};
}
function sortRowsForMobileDashboard(rows) {
const levelOrder = { error: 0, warn: 1, ok: 2 };
return rows
.map((r) => ({ r, a: analyzeExchangeAlert(r) }))
.sort((x, y) => {
const ld = levelOrder[x.a.level] - levelOrder[y.a.level];
if (ld !== 0) return ld;
return (x.a.sortUpnl || 0) - (y.a.sortUpnl || 0);
})
.map((x) => x.r);
}
function updateMonitorAlertSummary(rows) {
const el = document.getElementById("monitor-alert-summary");
if (!el) return;
if (!isMobileLayout() || !rows.length) {
el.classList.add("hidden");
el.innerHTML = "";
return;
}
let err = 0;
let warn = 0;
let ok = 0;
rows.forEach((r) => {
const lv = analyzeExchangeAlert(r).level;
if (lv === "error") err += 1;
else if (lv === "warn") warn += 1;
else ok += 1;
});
el.classList.remove("hidden");
el.innerHTML = `正常 ${ok}·关注 ${warn}·异常 ${err}`;
}
/** 监控卡片列数:桌面 3/2 列;手机端 2 列瓦片 */
function syncMonitorGridColumns(gridEl, count) {
if (!gridEl) return;
if (isMobileLayout()) {
gridEl.style.gridTemplateColumns = "repeat(2, minmax(0, 1fr))";
return;
}
let cols = 3;
if (count <= 1) cols = 1;
else if (count === 2) cols = 2;
else if (count === 3) cols = 3;
else if (count === 4) cols = 2;
else cols = 3;
gridEl.style.gridTemplateColumns = `repeat(${cols}, minmax(0, 1fr))`;
}
const AI_MOBILE_TAB_KEY = "hub_ai_mobile_tab";
function applyAiMobileTab(tab) {
const layout = document.querySelector(".ai-layout");
const tabs = document.querySelectorAll(".ai-mobile-tab");
if (!layout) return;
const mobile = isMobileLayout();
const active = mobile ? tab || localStorage.getItem(AI_MOBILE_TAB_KEY) || "chat" : "both";
if (mobile) layout.dataset.aiMobileTab = active;
else delete layout.dataset.aiMobileTab;
tabs.forEach((btn) => {
const on = mobile && btn.dataset.aiTab === active;
btn.classList.toggle("is-active", on);
btn.setAttribute("aria-selected", on ? "true" : "false");
});
if (mobile && active === "chat") {
const box = document.getElementById("ai-chat-messages");
if (box) requestAnimationFrame(() => { box.scrollTop = box.scrollHeight; });
}
}
function initAiMobileTabs() {
const tabs = document.querySelectorAll(".ai-mobile-tab");
if (!tabs.length) return;
tabs.forEach((btn) => {
btn.addEventListener("click", () => {
const tab = btn.dataset.aiTab || "chat";
localStorage.setItem(AI_MOBILE_TAB_KEY, tab);
applyAiMobileTab(tab);
if (tab === "chat") {
const input = document.getElementById("ai-chat-input");
if (input && isMobileLayout()) input.focus();
}
});
});
window.addEventListener("resize", () => applyAiMobileTab());
applyAiMobileTab();
}
let syncHubAiMobileViewport = () => {};
function initHubAiMobileViewport() {
const mq = window.matchMedia("(max-width: 720px)");
const shell = document.querySelector(".app-shell");
const chatInput = document.getElementById("ai-chat-input");
if (!shell || !window.visualViewport) {
syncHubAiMobileViewport = () => {};
return;
}
const scrollChatToEnd = () => {
const box = document.getElementById("ai-chat-messages");
if (box) requestAnimationFrame(() => { box.scrollTop = box.scrollHeight; });
};
syncHubAiMobileViewport = () => {
const onAi = document.body.classList.contains("hub-page-ai");
if (!onAi || !mq.matches) {
shell.style.removeProperty("height");
shell.style.removeProperty("max-height");
shell.style.removeProperty("width");
shell.style.removeProperty("transform");
document.documentElement.style.removeProperty("--hub-vvh");
document.body.classList.remove("hub-ai-keyboard-open");
return;
}
const vv = window.visualViewport;
const h = Math.max(240, Math.round(vv.height));
const top = Math.round(vv.offsetTop || 0);
const left = Math.round(vv.offsetLeft || 0);
document.documentElement.style.setProperty("--hub-vvh", `${h}px`);
shell.style.height = `${h}px`;
shell.style.maxHeight = `${h}px`;
shell.style.width = `${Math.round(vv.width)}px`;
shell.style.transform =
top > 0 || left > 0 ? `translate(${left}px, ${top}px)` : "";
const keyboardLikely =
top > 0 || h < window.innerHeight * 0.82 || document.activeElement === chatInput;
document.body.classList.toggle("hub-ai-keyboard-open", keyboardLikely);
};
window.visualViewport.addEventListener("resize", syncHubAiMobileViewport);
window.visualViewport.addEventListener("scroll", syncHubAiMobileViewport);
window.addEventListener("resize", syncHubAiMobileViewport);
window.addEventListener("orientationchange", () => {
setTimeout(syncHubAiMobileViewport, 80);
});
if (chatInput) {
chatInput.addEventListener("focus", () => {
syncHubAiMobileViewport();
scrollChatToEnd();
setTimeout(syncHubAiMobileViewport, 50);
setTimeout(syncHubAiMobileViewport, 280);
});
chatInput.addEventListener("blur", () => {
setTimeout(syncHubAiMobileViewport, 80);
setTimeout(syncHubAiMobileViewport, 320);
});
}
syncHubAiMobileViewport();
}
function initMobileLayout() {
initAiMobileTabs();
initHubAiMobileViewport();
let resizeTimer = null;
let wasMobile = isMobileLayout();
window.addEventListener("resize", () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
const nowMobile = isMobileLayout();
if (lastMonitorRows.length && nowMobile !== wasMobile) {
wasMobile = nowMobile;
renderMonitorGrid(lastMonitorRows);
updateMonitorAlertSummary(lastMonitorRows);
return;
}
wasMobile = nowMobile;
const box = document.getElementById("monitor-grid");
if (box && lastMonitorRows.length) {
syncMonitorGridColumns(box, lastMonitorRows.length);
updateMonitorAlertSummary(lastMonitorRows);
}
}, 120);
});
}
function normSym(s) {
return String(s || "")
.toUpperCase()
.replace(/:USDT$/i, "")
.replace(/\/USDT:USDT$/i, "")
.replace(/\/USDT$/i, "");
}
function symbolsMatchHub(a, b) {
const x = normSym(a);
const y = normSym(b);
if (!x || !y) return false;
return x === y;
}
function ordersCollapseKey(exchangeId, symbol) {
const sym = normSym(symbol) || "unknown";
return `hub_orders_${exchangeId}_${sym}`;
}
function isOrdersCollapseOpen(exchangeId, symbol) {
return localStorage.getItem(ordersCollapseKey(exchangeId, symbol)) === "1";
}
function dedupeCondOrdersByTrigger(orders) {
const list = Array.isArray(orders) ? orders : [];
const seen = new Set();
const out = [];
for (const o of list) {
const px = orderTriggerOrPrice(o);
const key =
px != null
? "t:" + String(px)
: o && o.id
? "id:" + String(o.id)
: null;
if (key && seen.has(key)) continue;
if (key) seen.add(key);
out.push(o);
}
return out;
}
function upsertExTpslCondOrder(cond, role, slot) {
if (!slot || slot.trigger_price == null || slot.trigger_price === "") return;
const label = role === "sl" ? "止损" : "止盈";
const item = {
label: label,
trigger_price: Number(slot.trigger_price),
amount: slot.amount != null ? slot.amount : null,
id: slot.order_id || "",
channel: "algo",
};
const idx = cond.findIndex(function (o) {
const lb = o.label || "";
return role === "sl" ? /^止损\b/.test(lb) || lb.includes("止损") : /^止盈\b/.test(lb) || lb.includes("止盈");
});
if (idx >= 0) cond[idx] = Object.assign({}, cond[idx], item);
else cond.push(item);
}
function condOrdersFromPosition(pos) {
const cond = dedupeCondOrdersByTrigger(
Array.isArray(pos.conditional_orders) ? pos.conditional_orders : []
);
const et = pos.exchange_tpsl;
if (!et) return cond;
upsertExTpslCondOrder(cond, "sl", et.sl);
upsertExTpslCondOrder(cond, "tp", et.tp);
return cond;
}
function findMonitorOrder(orders, symbol, side) {
const want = (side || "").toLowerCase();
for (const o of orders || []) {
const sym = o.exchange_symbol || o.symbol || "";
if (!symbolsMatchHub(sym, symbol)) continue;
const d = (o.direction || "").toLowerCase();
if (!d || d === want) return o;
}
return null;
}
function calcRrRatio(side, entry, sl, tp) {
const e = Number(entry);
const s = Number(sl);
const t = Number(tp);
if (![e, s, t].every((n) => Number.isFinite(n) && n > 0)) return null;
if ((side || "long").toLowerCase() === "short") {
const risk = s - e;
const reward = e - t;
if (risk <= 0 || reward <= 0) return null;
return reward / risk;
}
const risk = e - s;
const reward = t - e;
if (risk <= 0 || reward <= 0) return null;
return reward / risk;
}
function resolveTrendPlanRr(trendPlan, side, entry, sl, tp) {
const t = trendPlan || {};
if (t.money_rr != null && t.money_rr !== "") {
const n = Number(t.money_rr);
if (Number.isFinite(n) && n > 0) return n;
}
if (t.planned_rr != null && t.planned_rr !== "") {
const n = Number(t.planned_rr);
if (Number.isFinite(n) && n > 0) return n;
}
const e = t.avg_entry_price != null && t.avg_entry_price !== "" ? t.avg_entry_price : entry;
const s = t.stop_loss != null && t.stop_loss !== "" ? t.stop_loss : sl;
const p = t.take_profit != null && t.take_profit !== "" ? t.take_profit : tp;
return calcRrRatio(side, e, s, p);
}
function resolveSnapshotRr(mo, side, entry, sl, tp, tpMonitored, trendPlan) {
if (tpMonitored && isTrendContext(mo, trendPlan)) {
const rr = resolveTrendPlanRr(trendPlan, side, entry, sl, tp);
if (rr != null) return rr;
}
if (tpMonitored) return null;
const snap = mo && mo.rr_ratio;
if (snap != null && snap !== "") {
const n = Number(snap);
if (Number.isFinite(n)) return n;
}
const initSl = mo && (mo.initial_stop_loss != null ? mo.initial_stop_loss : mo.stop_loss);
return calcRrRatio(side, entry, initSl || sl, tp);
}
function formatTpCellValue(tp, tpMonitored, symbol, tickMap) {
if (tpMonitored) {
if (tp != null && tp !== "") {
return `程序监控 · ${fmtSymbolPrice(tp, symbol, tickMap)}`;
}
return "程序监控";
}
if (tp != null && tp !== "") return fmtSymbolPrice(tp, symbol, tickMap);
return "—";
}
function isBreakevenSecured(side, entry, monitorOrder, cond, pos) {
const mo = monitorOrder || {};
const p = pos || {};
if (mo.sl_breakeven_secured === true || mo.sl_breakeven_secured === 1) return true;
if (p.sl_breakeven_secured === true || p.sl_breakeven_secured === 1) return true;
const { sl } = pickExTpslOrders(cond);
const trig = sl && sl.trigger_price != null ? Number(sl.trigger_price) : NaN;
const e = Number(entry);
if (!Number.isFinite(trig) || !Number.isFinite(e)) return false;
if ((side || "long").toLowerCase() === "short") return trig <= e;
return trig >= e;
}
function breakevenBadgeHtml() {
return `已保本`;
}
async function fetchMonitorBoardSnapshot(opts) {
const options = opts || {};
const background = !!options.background;
const showLoading = !!options.showLoading && !lastMonitorRows.length;
const box = document.getElementById("monitor-grid");
if (monitorBoardInFlight) {
if (background) monitorBoardFetchPending = true;
else return;
}
if (showLoading && box) {
box.innerHTML =
'
';
scheduleMonitorBoardSlowHint(box);
} else if (background && lastMonitorRows.length) {
applyMonitorBoardUi(lastMonitorRows, null, { stale: true });
}
monitorBoardInFlight = true;
const ctrl = new AbortController();
const fetchTimer = setTimeout(() => ctrl.abort(), HUB_MONITOR_SNAPSHOT_TIMEOUT_MS);
try {
const r = await apiFetch(MONITOR_BOARD_SNAPSHOT_URL, { signal: ctrl.signal });
const data = await r.json();
if (!r.ok) {
throw new Error(data.msg || data.detail || `HTTP ${r.status}`);
}
const ver = Number(data.board_version) || 0;
const rows = data.rows || [];
const waitingFirst = data.aggregating && !rows.length && ver <= localBoardVersion;
if (waitingFirst && showLoading) {
if (box) {
const sub = box.querySelector(".board-loading-sub");
if (sub) sub.textContent = "后台正在首次聚合四所数据(约 5~15 秒)…";
}
return;
}
const ts = data.updated_at || "";
const versionChanged = ver !== localBoardVersion;
const timeChanged = ts && ts !== lastMonitorBoardUpdatedAt;
if (versionChanged || timeChanged || !lastMonitorRows.length) {
localBoardVersion = ver;
lastMonitorRows = rows;
saveMonitorBoardCache(lastMonitorRows, ts, ver);
applyMonitorBoardUi(lastMonitorRows, ts, {
stale: !!data.aggregating,
});
} else if (data.aggregating && lastMonitorRows.length) {
applyMonitorBoardUi(lastMonitorRows, data.updated_at || lastMonitorBoardUpdatedAt, {
stale: true,
});
}
if (data.ok === false && data.msg && !background) {
showToast(String(data.msg), true);
}
} catch (e) {
const msg =
e && e.name === "AbortError" ? "读取监控快照超时,请检查中控是否运行" : String(e);
if (background && lastMonitorRows.length) {
showToast("快照读取失败,仍显示上次数据", true);
applyMonitorBoardUi(lastMonitorRows, null, { stale: false });
return;
}
if (box) box.innerHTML = `${esc(msg)}
`;
} finally {
clearTimeout(fetchTimer);
clearMonitorBoardSlowHint();
monitorBoardInFlight = false;
if (monitorBoardFetchPending) {
monitorBoardFetchPending = false;
void fetchMonitorBoardSnapshot({ background: true });
}
}
}
async function refreshMonitorBoardNow() {
if (lastMonitorRows.length) {
applyMonitorBoardUi(lastMonitorRows, lastMonitorBoardUpdatedAt, { stale: true });
}
try {
await requestMonitorBoardRefresh();
await fetchMonitorBoardSnapshot({ background: false });
} catch (e) {
showToast(String(e), true);
}
}
function closeExchangeFullscreen() {
expandedExchangeId = "";
sessionStorage.removeItem("hub_expanded_ex");
const fs = document.getElementById("exchange-fullscreen");
if (fs) {
fs.classList.add("hidden");
fs.setAttribute("aria-hidden", "true");
}
document.body.classList.remove("hub-fullscreen-open");
}
function openExchangeFullscreen(exId) {
expandedExchangeId = String(exId);
sessionStorage.setItem("hub_expanded_ex", expandedExchangeId);
renderMonitorGrid(lastMonitorRows);
}
function renderMonitorGrid(rows) {
const box = document.getElementById("monitor-grid");
const fs = document.getElementById("exchange-fullscreen");
const fsInner = document.getElementById("exchange-fullscreen-inner");
if (!box) return;
if (expandedExchangeId && !rows.some((r) => String(r.id) === String(expandedExchangeId))) {
closeExchangeFullscreen();
}
const mobileTiles = isMobileLayout() && !expandedExchangeId;
const displayRows = mobileTiles ? sortRowsForMobileDashboard(rows) : rows;
box.classList.toggle("grid-monitor-tiles", mobileTiles);
try {
box.innerHTML =
displayRows
.map((r) => (mobileTiles ? renderMonitorTile(r) : renderMonitorCard(r)))
.join("") || '无已启用账户
';
} catch (err) {
console.error("renderMonitorGrid", err);
box.innerHTML = `监控区渲染失败:${esc(String(err && err.message ? err.message : err))}
`;
}
syncMonitorGridColumns(box, displayRows.length);
bindMonitorInteractions(box);
if (expandedExchangeId && fs && fsInner) {
const row = rows.find((r) => String(r.id) === String(expandedExchangeId));
if (row) {
try {
fsInner.innerHTML = renderFullscreenExchange(row);
fs.classList.remove("hidden");
fs.setAttribute("aria-hidden", "false");
document.body.classList.add("hub-fullscreen-open");
bindMonitorInteractions(fsInner);
fsInner.querySelectorAll(".btn-expand-back").forEach((btn) => {
btn.onclick = (ev) => {
ev.stopPropagation();
closeExchangeFullscreen();
renderMonitorGrid(lastMonitorRows);
};
});
} catch (err) {
console.error("renderFullscreenExchange", err);
closeExchangeFullscreen();
showToast("全屏渲染失败: " + err, true);
}
} else {
closeExchangeFullscreen();
}
} else {
closeExchangeFullscreen();
}
}
function normalizeMarketSymbol(raw) {
let s = (raw || "").trim().toUpperCase();
if (!s) return "";
if (s.includes(":")) {
const base = s.split(":")[0];
if (base.includes("/")) return base;
}
return s;
}
function resolveExchangeKey(exchangeId) {
const row = (lastMonitorRows || []).find((r) => String(r.id) === String(exchangeId));
return (row && (row.key || row.id)) || exchangeId;
}
function findTrendPlan(trends, symbol, side) {
const want = (side || "").toLowerCase();
for (const t of trends || []) {
const sym = t.symbol || t.exchange_symbol || "";
if (!symbolsMatchHub(sym, symbol)) continue;
const d = (t.direction || "").toLowerCase();
if (!d || d === want) return t;
}
return null;
}
function orderTriggerOrPrice(o) {
if (!o) return null;
if (o.trigger_price != null && o.trigger_price !== "") {
const t = Number(o.trigger_price);
if (Number.isFinite(t) && t > 0) return t;
}
if (o.price != null && o.price !== "") {
const p = Number(o.price);
if (Number.isFinite(p) && p > 0) return p;
}
return null;
}
function inferTpslFromCondOrders(side, cond, entry) {
const picked = pickExTpslOrders(cond);
let sl = picked.sl ? orderTriggerOrPrice(picked.sl) : "";
let tp = picked.tp ? orderTriggerOrPrice(picked.tp) : "";
if (sl !== "" && sl != null) sl = Number(sl);
if (tp !== "" && tp != null) tp = Number(tp);
if (sl !== "" && tp !== "" && Number(sl) !== Number(tp)) {
return { sl, tp };
}
const triggers = (cond || [])
.map(function (o) {
const px = orderTriggerOrPrice(o);
return px == null ? null : { price: px, label: o.label || "" };
})
.filter(function (o) {
return o != null;
});
if (!triggers.length) return { sl: sl || "", tp: tp || "" };
const s = (side || "long").toLowerCase();
const e = entry != null && Number.isFinite(Number(entry)) ? Number(entry) : null;
if (e != null) {
const below = triggers.filter(function (t) {
return t.price < e;
});
const above = triggers.filter(function (t) {
return t.price > e;
});
if (s === "long") {
if (sl === "" && below.length) {
sl = Math.max.apply(
null,
below.map(function (t) {
return t.price;
})
);
}
if (tp === "" && above.length) {
tp = Math.min.apply(
null,
above.map(function (t) {
return t.price;
})
);
}
} else {
if (sl === "" && above.length) {
sl = Math.min.apply(
null,
above.map(function (t) {
return t.price;
})
);
}
if (tp === "" && below.length) {
tp = Math.max.apply(
null,
below.map(function (t) {
return t.price;
})
);
}
}
}
if (triggers.length === 1 && sl === "" && tp === "") {
const one = triggers[0];
const p = one.price;
const lbl = one.label;
if (e != null) {
if (s === "long") {
if (p < e) sl = p;
else if (p > e) tp = p;
} else if (p > e) sl = p;
else if (p < e) tp = p;
} else if (/止损/.test(lbl)) sl = p;
else if (/止盈/.test(lbl) && !/止盈止损/.test(lbl)) tp = p;
}
if (sl !== "" && tp !== "" && Number(sl) === Number(tp)) tp = "";
return { sl: sl || "", tp: tp || "" };
}
function resolvePositionTpsl(pos, monitorOrder, trendPlan) {
const mo = monitorOrder || {};
const tp = trendPlan || {};
const cond = condOrdersFromPosition(pos);
const entryRaw =
pos.entry_price != null
? pos.entry_price
: mo.trigger_price != null
? mo.trigger_price
: tp.avg_entry_price;
const entryN = entryRaw != null && entryRaw !== "" ? Number(entryRaw) : null;
const isTrend = isTrendContext(mo, trendPlan);
const handoff = isTrendHandoffOrder(mo);
let sl = mo.stop_loss != null && mo.stop_loss !== "" ? mo.stop_loss : "";
let takeProfit = mo.take_profit != null && mo.take_profit !== "" ? mo.take_profit : "";
let tpMonitored = false;
if (handoff) {
tpMonitored = false;
} else if (isTrend) {
tpMonitored = true;
if (trendPlan && trendPlan.stop_loss != null && trendPlan.stop_loss !== "") {
sl = trendPlan.stop_loss;
}
if (trendPlan && trendPlan.take_profit != null && trendPlan.take_profit !== "") {
takeProfit = trendPlan.take_profit;
} else {
takeProfit = "";
}
}
const inferred = inferTpslFromCondOrders(pos.side, cond, entryN);
if (inferred.sl !== "" && inferred.sl != null) {
sl = inferred.sl;
} else if (sl === "" || sl == null) {
sl = inferred.sl;
}
if (!tpMonitored) {
if (inferred.tp !== "" && inferred.tp != null) {
takeProfit = inferred.tp;
} else if (takeProfit === "" || takeProfit == null) {
takeProfit = inferred.tp;
}
}
if (sl !== "" && takeProfit !== "" && Number(sl) === Number(takeProfit)) {
takeProfit = "";
}
return {
entry: entryRaw,
sl,
tp: takeProfit,
tp_monitored: tpMonitored,
is_trend: isTrend,
is_handoff: handoff,
};
}
function buildPositionMarketContext(pos, monitorOrder, trendPlan, exchangeId) {
const mo = monitorOrder || {};
const tpsl = resolvePositionTpsl(pos, monitorOrder, trendPlan);
const cond = condOrdersFromPosition(pos);
const reg = Array.isArray(pos.regular_orders) ? pos.regular_orders : [];
const num = function (v) {
if (v == null || v === "") return null;
const n = Number(v);
return Number.isFinite(n) ? n : null;
};
const orders = [];
cond.forEach(function (o) {
orders.push({
kind: "条件",
label: o.label || "条件单",
price: num(o.trigger_price),
amount: num(o.amount),
});
});
reg.forEach(function (o) {
orders.push({
kind: "普通",
label: o.label || o.type || "委托",
price: num(o.price != null ? o.price : o.trigger_price),
amount: num(o.amount),
});
});
const entryPx = num(pos.entry_price != null ? pos.entry_price : tpsl.entry);
const markPx = num(pos.mark_price);
const contractSize = num(pos.contract_size);
const upnl = resolvePositionUpnlUsdt(pos, trendPlan, markPx);
const planMargin =
trendPlan && trendPlan.plan_margin_capital != null
? num(trendPlan.plan_margin_capital)
: mo.margin_capital != null
? num(mo.margin_capital)
: null;
const leverage =
trendPlan && trendPlan.leverage != null
? num(trendPlan.leverage)
: mo.leverage != null
? num(mo.leverage)
: null;
return {
exchange_id: exchangeId || null,
symbol: (pos.symbol || "").trim(),
side: (pos.side || "long").toLowerCase(),
entry: entryPx,
mark_price: markPx,
stop_loss: num(tpsl.sl),
take_profit: num(tpsl.tp),
tp_monitored: !!tpsl.tp_monitored,
is_trend: !!tpsl.is_trend,
contracts: num(pos.contracts),
contract_size: contractSize != null ? contractSize : 1,
unrealized_pnl: upnl != null ? Number(upnl) : null,
notional_usdt: num(pos.notional_usdt),
plan_margin: planMargin,
leverage: leverage,
orders: orders,
};
}
const HUB_MARKET_POS_CTX_KEY = "hubMarketPosContext";
function encodePosCtx(ctx) {
try {
return btoa(unescape(encodeURIComponent(JSON.stringify(ctx))));
} catch (e) {
return "";
}
}
function decodePosCtx(raw) {
if (!raw) return null;
try {
return JSON.parse(decodeURIComponent(escape(atob(raw))));
} catch (e) {
return null;
}
}
function marketOpenBtnAttrs(exchangeId, exchangeKey, symbol, pos, monitorOrder, trendPlan) {
const symAttr = esc(symbol || "").replace(/"/g, """);
const exKeyAttr = esc(exchangeKey || exchangeId || "").replace(/"/g, """);
const ctxEnc = esc(
encodePosCtx(buildPositionMarketContext(pos, monitorOrder, trendPlan, exchangeId))
).replace(
/"/g,
"""
);
return (
'data-ex-id="' +
esc(exchangeId) +
'" data-ex-key="' +
exKeyAttr +
'" data-symbol="' +
symAttr +
'" data-pos-ctx="' +
ctxEnc +
'"'
);
}
function openMarketForPosition(exchangeId, symbol, exchangeKey, posCtxRaw) {
const exKey = exchangeKey || resolveExchangeKey(exchangeId);
const sym = normalizeMarketSymbol(symbol);
if (!exKey || !sym) {
showToast("无法打开行情:缺少交易所或合约", true);
return;
}
const ctx = decodePosCtx(posCtxRaw);
if (ctx) {
ctx.symbol = sym;
ctx.exchange_key = exKey;
sessionStorage.setItem(HUB_MARKET_POS_CTX_KEY, JSON.stringify(ctx));
} else {
sessionStorage.removeItem(HUB_MARKET_POS_CTX_KEY);
}
if (expandedExchangeId) {
closeExchangeFullscreen();
}
const qs = new URLSearchParams({ exchange_key: exKey, symbol: sym });
history.pushState({}, "", "/market?" + qs.toString());
setActiveNav();
if (window.hubMarketChart && window.hubMarketChart.openWith) {
window.hubMarketChart.openWith(exKey, sym);
}
}
function bindMonitorInteractions(box) {
box.querySelectorAll(".btn-open-market").forEach((btn) => {
btn.onclick = (ev) => {
ev.preventDefault();
ev.stopPropagation();
openMarketForPosition(btn.dataset.exId, btn.dataset.symbol, btn.dataset.exKey, btn.dataset.posCtx);
};
});
box.querySelectorAll(".btn-open-instance").forEach((btn) => {
btn.onclick = (ev) => {
ev.preventDefault();
ev.stopPropagation();
const msg = (btn.dataset.confirm || "").trim();
if (msg && !confirm(msg)) return;
openInstance(btn.dataset.exId, btn.dataset.next || "/", {
newTab: ev.ctrlKey || ev.metaKey,
});
};
});
box.querySelectorAll(".btn-hub-trend-stop").forEach((btn) => {
btn.onclick = (ev) => {
ev.preventDefault();
ev.stopPropagation();
hubTrendPlanStop(btn.dataset.exId, btn.dataset.planId);
};
});
box.querySelectorAll(".btn-hub-trend-be").forEach((btn) => {
btn.onclick = (ev) => {
ev.preventDefault();
ev.stopPropagation();
const card = btn.closest(".hub-trend-plan-card");
const inp = card ? card.querySelector(".hub-plan-be-input") : null;
hubTrendPlanBreakeven(btn.dataset.exId, btn.dataset.planId, inp);
};
});
box.querySelectorAll(".btn-close-ex").forEach((btn) => {
btn.onclick = () => closeOne(btn.dataset.id);
});
box.querySelectorAll(".btn-close-pos").forEach((btn) => {
btn.onclick = (ev) => {
ev.stopPropagation();
closeOnePosition(btn.dataset.exId, btn.dataset.symbol, btn.dataset.side);
};
});
box.querySelectorAll(".btn-cancel-order").forEach((btn) => {
btn.onclick = (ev) => {
ev.stopPropagation();
cancelOneOrder(
btn.dataset.exId,
btn.dataset.symbol,
btn.dataset.orderId,
btn.dataset.channel
);
};
});
box.querySelectorAll(".btn-cancel-cond-all").forEach((btn) => {
btn.onclick = (ev) => {
ev.preventDefault();
ev.stopPropagation();
cancelSymbolOrders(btn.dataset.exId, btn.dataset.symbol, "conditional");
};
});
box.querySelectorAll(".btn-place-tpsl").forEach((btn) => {
btn.onclick = (ev) => {
ev.stopPropagation();
openTpslModal(
btn.dataset.exId,
btn.dataset.symbol,
btn.dataset.side,
btn.dataset.contracts,
btn.dataset.sl || "",
btn.dataset.tp || ""
);
};
});
box.querySelectorAll(".card-expand-zone").forEach((zone) => {
zone.onclick = (ev) => {
if (ev.target.closest("a, button, input, summary, details, .card-actions")) return;
const id = zone.closest(".card")?.dataset.exId;
if (id) openExchangeFullscreen(id);
};
});
box.querySelectorAll("details.pos-orders-collapse[data-collapse-key]").forEach((el) => {
el.addEventListener("toggle", () => {
const k = el.dataset.collapseKey;
if (k) localStorage.setItem(k, el.open ? "1" : "0");
});
});
}
function renderOrderRows(exchangeId, symbol, orders, kind, tickMap) {
if (!orders || !orders.length) {
const hint =
kind === "conditional"
? "暂无条件单(止盈/止损等)"
: "暂无普通委托";
return `${hint}
`;
}
const symAttr = esc(symbol || "").replace(/"/g, """);
const rows = orders
.map((o) => {
const oidAttr = esc(o.id || "").replace(/"/g, """);
const chAttr = esc(o.channel || "regular").replace(/"/g, """);
const trig =
o.trigger_price != null
? fmtSymbolPrice(o.trigger_price, symbol, tickMap)
: o.price != null
? fmtSymbolPrice(o.price, symbol, tickMap)
: "—";
return `
| ${esc(o.label || o.type || "委托")} |
${fmt(o.amount, 4)} |
${trig} |
|
`;
})
.join("");
return ``;
}
function guessTpslFromCondOrders(side, cond, entry) {
return inferTpslFromCondOrders(side, cond, entry);
}
function renderOrdersCollapse(exchangeId, symbol, cond, reg, tickMap) {
const symAttr = esc(symbol || "").replace(/"/g, """);
const orderTotal = cond.length + reg.length;
const collapseKey = ordersCollapseKey(exchangeId, symbol);
const openAttr = isOrdersCollapseOpen(exchangeId, symbol) ? " open" : "";
const condAllBtn =
cond.length > 0
? ``
: "";
const condBody = renderOrderRows(exchangeId, symbol, cond, "conditional", tickMap);
const regBody = renderOrderRows(exchangeId, symbol, reg, "limit", tickMap);
return `
委托单 ${orderTotal}
条件 ${cond.length} · 普通 ${reg.length}
${condAllBtn}
`;
}
function syntheticExTpslOrder(role, price, amount) {
if (price == null || price === "" || !Number.isFinite(Number(price))) return null;
return {
label: role === "sl" ? "止损" : "止盈",
trigger_price: Number(price),
price: Number(price),
amount: amount != null ? amount : null,
id: "",
channel: "plan",
};
}
function pickExTpslOrders(cond) {
let sl = cond.find((o) => /^止损\b/.test(o.label || ""));
let tp = cond.find((o) => /^止盈\b/.test(o.label || "") && !(o.label || "").includes("止盈止损"));
if (!sl || !tp) {
const combo = cond.find((o) => (o.label || "").includes("止盈止损"));
if (combo) {
const m = (combo.label || "").match(/SL=([\d.eE+-]+).*TP=([\d.eE+-]+)/i);
if (m) {
if (!sl) sl = { ...combo, label: "止损", trigger_price: Number(m[1]) };
if (!tp) tp = { ...combo, label: "止盈", trigger_price: Number(m[2]) };
}
}
}
if (!sl) sl = cond.find((o) => (o.label || "").includes("止损"));
if (!tp) tp = cond.find((o) => (o.label || "").includes("止盈") && o !== sl);
return { sl, tp };
}
function renderExTpslRows(exchangeId, symbol, cond, tickMap, resolvedTpsl, contracts) {
const symAttr = esc(symbol || "").replace(/"/g, """);
let { sl, tp } = pickExTpslOrders(cond);
const plan = resolvedTpsl || {};
if (!sl && plan.sl != null && plan.sl !== "") {
sl = syntheticExTpslOrder("sl", plan.sl, contracts);
}
if (!tp && plan.tp != null && plan.tp !== "") {
tp = syntheticExTpslOrder("tp", plan.tp, contracts);
}
function row(label, o) {
if (!o) {
return `${label}:—
`;
}
const oid = esc(o.id || "").replace(/"/g, """);
const ch = esc(o.channel || "regular").replace(/"/g, """);
const px = orderTriggerOrPrice(o);
const trig = px != null ? fmtSymbolPrice(px, symbol, tickMap) : "—";
const cancelBtn =
oid && o.channel !== "plan"
? ``
: "";
const planHint = o.channel === "plan" ? '(下单监控)' : "";
return `
${label}:触发 ${trig} · 数量 ${fmt(o.amount, 4)}${planHint}
${cancelBtn}
`;
}
return row("止损", sl) + row("止盈", tp);
}
function trendAddSummaryHtml(t, tickMap) {
const done = t.add_count != null ? t.add_count : t.legs_done;
const total = t.add_count_total != null ? t.add_count_total : t.dca_legs;
const sym = t.exchange_symbol || t.symbol || "";
let html = "";
if (done != null && Number(done) >= 0) {
html += total != null ? ` · 补仓 ${esc(done)}/${esc(total)}` : ` · 补仓 ${esc(done)} 次`;
const pxs = t.add_prices_display;
if (Array.isArray(pxs) && pxs.length) {
html += ` · 加仓价 ${pxs.map((p) => esc(p)).join(" / ")}`;
} else if (Array.isArray(t.add_prices) && t.add_prices.length) {
html += ` · 加仓价 ${t.add_prices.map((p) => esc(fmtSymbolPrice(p, sym, tickMap))).join(" / ")}`;
} else if (Number(done) === 0) {
html += " · 加仓价 —";
}
}
return html;
}
function renderTrendDcaTable(t, tickMap) {
const levels = resolveTrendDcaLevels(t);
if (!levels.length) return "";
const sym = t.exchange_symbol || t.symbol || "";
const rows = levels
.map((lv) => {
const price =
lv.price != null && lv.price !== ""
? fmtSymbolPrice(lv.price, sym, tickMap)
: "—";
const amt =
lv.contracts != null && lv.contracts !== "" ? esc(String(lv.contracts)) : "—";
const avg =
lv.avg_entry != null && lv.avg_entry !== ""
? fmtSymbolPrice(lv.avg_entry, sym, tickMap)
: "—";
const profitU =
lv.profit_u != null && lv.profit_u !== "" ? fmt(lv.profit_u, 2) : "—";
const riskU = lv.risk_u != null && lv.risk_u !== "" ? fmt(lv.risk_u, 2) : "—";
const rr = lv.rr != null && lv.rr !== "" ? `${fmt(lv.rr, 2)}:1` : "—";
const stCls = lv.status === "done" ? "st-done" : "st-pending";
const label = lv.status_label || (lv.status === "done" ? "已补仓" : "待补仓");
return `
| ${esc(lv.label || lv.leg_key || "—")} |
${esc(price)} |
${amt} |
${esc(avg)} |
${esc(profitU)} |
${esc(riskU)} |
${esc(rr)} |
${esc(label)} |
`;
})
.join("");
return `
补仓计划明细
| 档位 | 触发价 | 张数 | 加仓后均价 | 止盈盈利(U) | 止损(U) | 盈亏比 | 状态 |
${rows}
`;
}
function renderTrendPlanCard(t, tickMap, pos, exchangeRow) {
const sym = t.exchange_symbol || t.symbol || "";
const side = (t.direction || "long").toLowerCase();
const sl = t.stop_loss_display || fmtSymbolPrice(t.stop_loss, sym, tickMap);
const tp = t.take_profit_display || fmtSymbolPrice(t.take_profit, sym, tickMap);
const avg = t.avg_entry_price_display || fmtSymbolPrice(t.avg_entry_price, sym, tickMap);
const addZone =
t.add_upper_display || fmtSymbolPrice(t.add_upper, sym, tickMap) || "—";
const rr = resolveTrendPlanRr(t, side, t.avg_entry_price, t.stop_loss, t.take_profit);
const rrTxt = rr != null ? `${fmt(rr, 2)}:1` : "—";
const mark = resolveTrendMarkPrice(pos, t, sym, tickMap);
const legsDone = t.add_count != null ? t.add_count : t.legs_done;
const legsTotal = t.add_count_total != null ? t.add_count_total : t.dca_legs;
const legsTxt =
legsDone != null && legsTotal != null
? `${esc(legsDone)}/${esc(legsTotal)}`
: legsDone != null
? esc(legsDone)
: "—";
const upnlTrend = resolveTrendFloatingPnl(pos, t);
const pnlFmt = formatTrendPlanFloatingPnl(upnlTrend, t.plan_margin_capital);
const pnlVal =
pnlFmt.text === "—"
? "—"
: `${esc(pnlFmt.text)}`;
const riskTxt =
t.risk_percent != null && t.risk_percent !== "" ? `${esc(t.risk_percent)}%` : "—";
const snapTxt =
t.snapshot_available_usdt != null && t.snapshot_available_usdt !== ""
? `${fmt(t.snapshot_available_usdt, 2)}U`
: "—";
const marginTxt =
t.plan_margin_capital != null && t.plan_margin_capital !== ""
? `≈${fmt(t.plan_margin_capital, 2)}U`
: "—";
const levTxt = t.leverage != null && t.leverage !== "" ? `${esc(t.leverage)}x` : "—";
const bePctDefault =
t.breakeven_default_offset_pct != null && t.breakeven_default_offset_pct !== ""
? t.breakeven_default_offset_pct
: t.breakeven_offset_pct != null && t.breakeven_offset_pct !== ""
? t.breakeven_offset_pct
: "0.3";
const exId = exchangeRow && exchangeRow.id != null ? esc(exchangeRow.id) : "";
const planId = esc(t.id);
const caps = (exchangeRow && exchangeRow.capabilities) || [];
const flaskOk =
exchangeRow && exchangeRow.flask_ok !== false && (exchangeRow.hub_monitor || {}).ok !== false;
const canHubTrend = !!(flaskOk && caps.includes("trend") && exId && planId);
const beAppliedFlag = !!t.breakeven_applied;
const endBtn = canHubTrend
? ``
: "";
const beBtn = canHubTrend && !beAppliedFlag
? ``
: beAppliedFlag
? ""
: `保本移交下单监控`;
const beApplied =
t.breakeven_applied
? `已保本 ${esc(String(t.breakeven_applied_at || "").slice(0, 16))}`
: "";
const dcaHtml = renderTrendDcaTable(t, tickMap);
const dcaCol = dcaHtml
? `${dcaHtml}
`
: ``;
return `
#${esc(t.id)} ${esc(sym)}
${renderDirectionBadge(t.direction)}
${endBtn}
来源: 趋势回调计划 | 风险: ${riskTxt}
| ${esc(trendAddZoneLabel(t.direction))} ${esc(addZone)}
| 已补仓 ${legsTxt}
均价${esc(avg)}
止损${esc(sl)}
止盈${esc(tp)}
盈亏比${esc(rrTxt)}
标记价${esc(mark)}
浮盈亏${pnlVal}
${dcaCol}
`;
}
function renderTrendSection(trends, tickMap, positions, exchangeRow) {
if (!trends || !trends.length) return "";
const posList = Array.isArray(positions) ? positions : [];
const cards = trends
.map((t) => {
const sym = t.exchange_symbol || t.symbol || "";
const side = (t.direction || "long").toLowerCase();
let matched = null;
for (const p of posList) {
if (!symbolsMatchHub(p.symbol, sym)) continue;
const ps = (p.side || "").toLowerCase();
if (!ps || ps === side) {
matched = p;
break;
}
}
return renderTrendPlanCard(t, tickMap, matched, exchangeRow);
})
.join("");
return ``;
}
function renderLivePositionCard(exchangeId, exchangeKey, pos, monitorOrder, trendPlan, tickMap) {
const symbol = pos.symbol || "";
const exKeyAttr = esc(exchangeKey || exchangeId || "").replace(/"/g, """);
const side = (pos.side || "long").toLowerCase();
const sideCn = sideDirLabel(side);
const sideCls = sideDirCls(side) || "side-long";
const mo = monitorOrder || {};
const cond = condOrdersFromPosition(pos);
const reg = Array.isArray(pos.regular_orders) ? pos.regular_orders : [];
const tpsl = resolvePositionTpsl(pos, mo, trendPlan);
const symAttr = esc(symbol).replace(/"/g, """);
const sideAttr = esc(side).replace(/"/g, """);
const contractsAttr = esc(String(pos.contracts != null ? pos.contracts : "")).replace(/"/g, """);
const slAttr = esc(String(tpsl.sl)).replace(/"/g, """);
const tpAttr = esc(String(tpsl.tp)).replace(/"/g, """);
const entry = tpsl.entry;
const sl = tpsl.sl;
const tp = tpsl.tp;
const tpMonitored = tpsl.tp_monitored;
const isTrend = isTrendContext(mo, trendPlan);
const rr = resolveSnapshotRr(mo, side, entry, sl, tp, tpMonitored, trendPlan);
const beSecured = isBreakevenSecured(side, entry, mo, cond, pos);
const upnl = resolveTrendFloatingPnl(pos, trendPlan);
const pnlFmt = formatFloatingPnlText(upnl, pos.notional_usdt);
const pnlText = pnlFmt.text;
const sizingFoot = resolveTrendSizingFooter(mo, trendPlan, isTrend);
const markDisplay = isTrend
? resolveTrendMarkPrice(pos, trendPlan, symbol, tickMap)
: fmtMarkPrice(pos, tickMap);
const meta = [];
if (isTrend) {
meta.push(monitorOrderSourceHtml(mo, trendPlan));
const riskLine = formatMonitorRiskMeta(mo, trendPlan);
if (riskLine) meta.push(riskLine);
if (trendPlan && trendPlan.id) {
const zone =
trendPlan.add_upper_display ||
fmtSymbolPrice(trendPlan.add_upper, symbol, tickMap) ||
"—";
meta.push(
`${esc(trendAddZoneLabel(trendPlan.direction))} ${esc(zone)}`
);
const addSum = trendAddSummaryHtml(trendPlan, tickMap);
if (addSum) meta.push(addSum.replace(/^ · /, ""));
}
meta.push(`移动保本:关`);
} else if (mo.monitor_type || mo.key_signal_type || mo.trend_plan_id) {
meta.push(monitorOrderSourceHtml(mo, trendPlan));
if (mo.trade_style) meta.push(`风格: ${esc(mo.trade_style)}`);
else meta.push("风格: —");
const riskLine = formatMonitorRiskMeta(mo, trendPlan);
if (riskLine) meta.push(riskLine);
const beOn = mo.breakeven_enabled === 1 || mo.breakeven_enabled === true;
meta.push(
`移动保本:${beOn ? "开" : "关"}`
);
} else {
meta.push("来源: 交易所持仓");
meta.push("风格: —");
meta.push(`移动保本:关`);
}
const symBeBadge = beSecured ? ` ${breakevenBadgeHtml()}` : "";
const mktAttrs = marketOpenBtnAttrs(exchangeId, exchangeKey, symbol, pos, monitorOrder, trendPlan);
return `
${symBeBadge}
${sideCn}
${meta.map((m) => `${m}`).join("")}
开仓价${fmtEntryPrice(pos, tickMap)}
标记价${markDisplay}
止损${sl != null && sl !== "" ? fmtSymbolPrice(sl, symbol, tickMap) : "—"}
止盈${formatTpCellValue(tp, tpMonitored, symbol, tickMap)}
盈亏比${rr != null ? fmt(rr, 2) + ":1" : "—"}
张数${fmt(pos.contracts, 4)}
浮盈亏${pnlText}
交易所止盈止损
${renderExTpslRows(exchangeId, symbol, cond, tickMap, tpsl, pos.contracts)}
${renderOrdersCollapse(exchangeId, symbol, cond, reg, tickMap)}
`;
}
function renderHubSectionCard(title, bodyHtml, emptyHint) {
const inner = bodyHtml || `${esc(emptyHint || "暂无")}
`;
return ``;
}
function renderKeySection(keys, kmap) {
if (!keys.length) return "";
const cards = keys
.map((k) => {
const kp = kmap[k.id] || kmap[String(k.id)] || {};
const mt = k.monitor_type || k.type || "";
const pending = keyHasPendingOrder(k, kp);
const cardCls = pending ? "hub-mini-card hub-key-pending" : "hub-mini-card";
const dir = k.direction ? ` · ${renderDirectionHtml(k.direction)}` : "";
const pendingTag = pending
? `挂单中`
: "";
const amtTxt = fmtKeyOrderAmount(k);
const amtLine = amtTxt
? `挂单数量 ${esc(amtTxt)}
`
: "";
return `
${esc(k.symbol)} · ${esc(mt)}${dir} ${pendingTag}
上沿 ${esc(k.upper)} / 下沿 ${esc(k.lower)}
${amtLine}
${esc(kp.gate_summary || kp.price_display || kp.price || "—")}${kp.gate_metrics ? ` · ${esc(kp.gate_metrics)}` : ""}
`;
})
.join("");
return `${cards}
`;
}
function renderOrderMonitorSection(orders, tickMap) {
if (!orders || !orders.length) return "";
return orders
.map((o) => {
const sym = o.exchange_symbol || o.symbol || "";
return `
#${esc(o.id)} · ${esc(o.symbol || o.exchange_symbol)} · ${renderDirectionHtml(o.direction)}
触发 ${fmtSymbolPrice(o.trigger_price, sym, tickMap)} · SL ${fmtSymbolPrice(o.stop_loss, sym, tickMap)} · TP ${fmtSymbolPrice(o.take_profit, sym, tickMap)} · ${esc(o.trade_style || o.monitor_type || "下单监控")}
`;
})
.join("");
}
function renderRollSection(rolls, tickMap) {
if (!rolls || !rolls.length) return "";
return rolls
.map(
(g) => `
组 #${esc(g.id)} · 监控单 #${esc(g.order_monitor_id || "—")}
腿数 ${esc(g.leg_count != null ? g.leg_count : "—")} · 止损 ${fmtSymbolPrice(g.current_stop_loss, g.symbol, tickMap)} · ${esc(g.status || "active")}
`
)
.join("");
}
function renderPositionTableRow(
exchangeId,
exchangeKey,
x,
monitorOrder,
trendPlan,
tickMap,
opts
) {
const options = opts || {};
const compact = !!options.compact;
const symAttr = esc(x.symbol || "").replace(/"/g, """);
const sideAttr = esc((x.side || "").toLowerCase()).replace(/"/g, """);
const side = sideAttr || "long";
const contractsAttr = esc(String(x.contracts != null ? x.contracts : "")).replace(
/"/g,
"""
);
const cond = condOrdersFromPosition(x);
const tpsl = resolvePositionTpsl(x, monitorOrder, trendPlan);
const beSecured = isBreakevenSecured(side, tpsl.entry, monitorOrder, cond, x);
const slAttr = esc(String(tpsl.sl)).replace(/"/g, """);
const tpAttr = esc(String(tpsl.tp)).replace(/"/g, """);
const mktAttrs = marketOpenBtnAttrs(exchangeId, exchangeKey, x.symbol, x, monitorOrder, trendPlan);
const symBeBadge = beSecured ? ` ${breakevenBadgeHtml()}` : "";
const actionCell = compact
? ``
: `
`;
return `
| ${symBeBadge} |
${renderDirectionHtml(x.side)} |
${fmtEntryPrice(x, tickMap)} |
${fmtMarkPrice(x, tickMap)} |
${fmt(x.contracts, 4)} |
${fmt(x.unrealized_pnl, 2)} |
${actionCell} |
`;
}
function renderPositionBlock(exchangeId, exchangeKey, x, monitorOrder, trendPlan, tickMap, opts) {
const options = opts || {};
const compact = !!options.compact;
const reg = Array.isArray(x.regular_orders) ? x.regular_orders : [];
const cond = condOrdersFromPosition(x);
const ordersBlock = compact
? ""
: renderOrdersCollapse(exchangeId, x.symbol, cond, reg, tickMap);
const rowHtml = renderPositionTableRow(
exchangeId,
exchangeKey,
x,
monitorOrder,
trendPlan,
tickMap,
opts
);
return ``;
}
const KEY_BUCKET_FIB_TYPES = new Set([
"斐波回调0.618",
"斐波回调0.786",
"关键位斐波0.618",
"关键位斐波0.786",
]);
const KEY_BUCKET_BREAKOUT_TYPES = new Set([
"箱体突破",
"收敛突破",
"关键位箱体突破",
"关键位收敛突破",
"关键位收敛结构",
]);
const KEY_BUCKET_WATCH_TYPES = new Set([
"关键阻力位",
"关键支撑位",
"关键位监控",
]);
function classifyKeyMonitorBucket(monitorType) {
const t = String(monitorType || "").trim();
if (!t) return "watch";
if (KEY_BUCKET_FIB_TYPES.has(t) || /斐波/.test(t)) return "fib";
if (KEY_BUCKET_BREAKOUT_TYPES.has(t) || /突破/.test(t)) return "breakout";
if (KEY_BUCKET_WATCH_TYPES.has(t) || /阻力|支撑/.test(t)) return "watch";
return "watch";
}
function countKeyMonitorsByBucket(keys) {
const counts = { breakout: 0, fib: 0, watch: 0 };
(keys || []).forEach((k) => {
if (!k || typeof k !== "object") return;
const bucket = classifyKeyMonitorBucket(k.monitor_type || k.type);
if (bucket === "breakout") counts.breakout += 1;
else if (bucket === "fib") counts.fib += 1;
else counts.watch += 1;
});
return counts;
}
function renderCardStrategyStats(row, hm, flaskOk) {
if (!flaskOk || !hm || typeof hm !== "object") return "";
const caps = row.capabilities || [];
const chips = [];
if (caps.includes("key")) {
const kc = countKeyMonitorsByBucket(hm.keys || []);
if (kc.breakout > 0) chips.push({ kind: "key-breakout", label: `突破 ${kc.breakout}` });
if (kc.fib > 0) chips.push({ kind: "key-breakout", label: `斐波 ${kc.fib}` });
if (kc.watch > 0) chips.push({ kind: "key-watch", label: `监控 ${kc.watch}` });
}
if (caps.includes("trend")) {
const trendN = Array.isArray(hm.trends) ? hm.trends.length : 0;
if (trendN > 0) chips.push({ kind: "trend", label: `趋势回调 ${trendN}` });
}
const rollN = Array.isArray(hm.rolls) ? hm.rolls.length : 0;
if (rollN > 0) chips.push({ kind: "roll", label: `顺势加仓 ${rollN}` });
if (!chips.length) return "";
return `${chips
.map(
(c) =>
`${esc(c.label)}`
)
.join("")}
`;
}
function renderGridPositionsTable(exchangeId, exchangeKey, positions, orders, trends, tickMap) {
const rows = positions
.map((p) =>
renderPositionTableRow(
exchangeId,
exchangeKey,
p,
findMonitorOrder(orders, p.symbol, p.side),
findTrendPlan(trends, p.symbol, p.side),
tickMap,
{ compact: true }
)
)
.join("");
return ``;
}
function renderAccountStatRow(row, ag) {
const upnl = ag.total_unrealized_pnl;
return `
资金账户
${fmt(row.funding_usdt, 2)} U
交易账户
${fmt(row.trading_usdt, 2)} U
`;
}
function renderGridBody(row, ag, pos, hm, flaskOk, keys, orders, trends, rolls, kmap) {
const tickMap = buildPriceTickMap(row);
let inner = renderAccountStatRow(row, ag);
inner += `交易所持仓 · ${pos.length} 仓
`;
if (pos.length) {
inner += renderGridPositionsTable(
row.id,
row.key || row.id,
pos,
orders,
trends,
tickMap
);
} else {
inner += '无持仓
';
}
inner += renderCardStrategyStats(row, hm, flaskOk);
inner += `点击标题栏进入全屏 · 委托 / 关键位 / 下单监控 / 趋势回调 / 顺势加仓
`;
return inner;
}
function renderFullscreenExchange(row) {
const tickMap = buildPriceTickMap(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 rolls = flaskOk ? hm.rolls || [] : [];
const kmap = {};
(row.key_prices || []).forEach((k) => {
kmap[k.id] = k;
});
const flaskOpen = row.flask_url_browser || row.flask_url;
let html = `
${esc(row.name)}
${esc(flaskOpen || "")}
${flaskOpen ? `
打开实例` : ""}
${flaskOpen ? `
策略交易` : ""}
`;
if (!row.http_ok || ag.ok === false) {
html += `${esc(row.error || ag.error || "子代理不可用")}
`;
return html;
}
html += renderAccountStatRow(row, ag);
const posCount = pos.length;
const posListCls = hubPosListCountClass(posCount);
html += `持仓(${posCount} 仓 · 每币种一卡)
`;
html += ``;
if (posCount) {
pos.forEach((p) => {
html += renderLivePositionCard(
row.id,
row.key || row.id,
p,
findMonitorOrder(orders, p.symbol, p.side),
findTrendPlan(trends, p.symbol, p.side),
tickMap
);
});
} else {
html += '
暂无持仓
';
}
html += "
";
if ((row.capabilities || []).includes("key")) {
if (!flaskOk) {
html += renderHubSectionCard("关键位", `${esc(row.flask_error || hm.error || "Flask 未连通")}
`, "");
} else {
html += renderHubSectionCard(
`关键位 · ${keys.length}`,
renderKeySection(keys, kmap),
"当前无关键位记录"
);
}
}
html += renderHubSectionCard("下单监控", renderOrderMonitorSection(orders, tickMap), "暂无运行中的下单监控");
if ((row.capabilities || []).includes("trend")) {
html += renderHubSectionCard(
"趋势回调",
renderTrendSection(trends, tickMap, pos, row),
"暂无运行中的趋势回调计划"
);
}
html += renderHubSectionCard("顺势加仓", renderRollSection(rolls, tickMap), "暂无运行中的顺势加仓组");
return html;
}
function openTpslModal(exchangeId, symbol, side, contracts, slHint, tpHint) {
tpslPending = {
exchangeId,
symbol,
side: (side || "long").toLowerCase(),
contracts: parseFloat(contracts),
};
const modal = document.getElementById("tpsl-modal");
const meta = document.getElementById("tpsl-modal-meta");
const slIn = document.getElementById("tpsl-sl");
const tpIn = document.getElementById("tpsl-tp");
if (!modal || !meta || !slIn || !tpIn) return;
meta.textContent = `${symbol} · ${side} · ${contracts} 张`;
slIn.value = slHint !== "" && slHint != null ? String(slHint) : "";
tpIn.value = tpHint !== "" && tpHint != null ? String(tpHint) : "";
modal.classList.remove("hidden");
modal.setAttribute("aria-hidden", "false");
slIn.focus();
}
function closeTpslModal() {
tpslPending = null;
const modal = document.getElementById("tpsl-modal");
if (modal) {
modal.classList.add("hidden");
modal.setAttribute("aria-hidden", "true");
}
}
async function submitTpslModal() {
if (!tpslPending) return;
const slIn = document.getElementById("tpsl-sl");
const tpIn = document.getElementById("tpsl-tp");
const sl = parseFloat(slIn && slIn.value);
const tp = parseFloat(tpIn && tpIn.value);
if (!sl || sl <= 0 || !tp || tp <= 0) {
showToast("请填写有效的止损价与止盈价", true);
return;
}
const { exchangeId, symbol, side, contracts } = tpslPending;
if (
!confirm(
`确认 ${symbol} ${side}\n先撤销全部条件单,再挂止损 ${sl}、止盈 ${tp}?`
)
) {
return;
}
const btn = document.getElementById("tpsl-submit");
if (btn) btn.disabled = true;
try {
const r = await apiFetch(
"/api/orders/" + encodeURIComponent(exchangeId) + "/place-tpsl",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
symbol,
side,
stop_loss: sl,
take_profit: tp,
contracts: contracts > 0 ? contracts : null,
}),
}
);
const j = await r.json();
const pl = j.payload || {};
const ok = j.ok && pl.ok !== false;
const n = pl.placed && pl.placed.cancelled_conditional;
showToast(
ok
? `已挂单(已撤 ${n != null ? n : "?"} 笔旧条件单)`
: pl.error || JSON.stringify(j),
!ok
);
if (ok) {
closeTpslModal();
refreshMonitorBoardNow();
}
} catch (e) {
showToast(String(e), true);
} finally {
if (btn) btn.disabled = false;
}
}
function initInstanceFrame() {
const back = document.getElementById("instance-frame-back");
const refresh = document.getElementById("instance-frame-refresh");
const newTab = document.getElementById("instance-frame-newtab");
const frame = document.getElementById("instance-frame");
if (back) back.onclick = () => closeInstanceFrame();
if (refresh) refresh.onclick = () => refreshInstanceFrame();
if (newTab) {
newTab.onclick = () => {
if (instanceFrameCtx) {
openInstance(instanceFrameCtx.exchangeId, instanceFrameCtx.nextPath, {
newTab: true,
});
return;
}
if (instanceFrameUrl) window.open(instanceFrameUrl, "_blank", "noopener");
};
}
}
function initFullscreen() {
const backdrop = document.getElementById("exchange-fullscreen-backdrop");
if (backdrop) {
backdrop.onclick = () => {
closeExchangeFullscreen();
renderMonitorGrid(lastMonitorRows);
};
}
const fs = document.getElementById("exchange-fullscreen");
if (fs && !expandedExchangeId) {
fs.classList.add("hidden");
fs.setAttribute("aria-hidden", "true");
}
}
function initTpslModal() {
const backdrop = document.getElementById("tpsl-modal-backdrop");
const cancel = document.getElementById("tpsl-cancel");
const submit = document.getElementById("tpsl-submit");
if (backdrop) backdrop.onclick = closeTpslModal;
if (cancel) cancel.onclick = closeTpslModal;
if (submit) submit.onclick = () => submitTpslModal();
document.addEventListener("keydown", (ev) => {
if (ev.key === "Escape") {
closeTpslModal();
const shell = document.getElementById("instance-frame-shell");
if (shell && !shell.classList.contains("hidden")) {
closeInstanceFrame();
return;
}
if (expandedExchangeId) {
closeExchangeFullscreen();
renderMonitorGrid(lastMonitorRows);
}
}
});
}
async function cancelOneOrder(exchangeId, symbol, orderId, channel) {
if (!confirm(`撤销委托 ${symbol} #${orderId}?`)) return;
try {
const r = await apiFetch("/api/orders/" + encodeURIComponent(exchangeId) + "/cancel", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ symbol, order_id: orderId, channel: channel || "regular" }),
});
const j = await r.json();
const pl = j.payload || {};
const ok = j.ok && pl.ok !== false;
showToast(ok ? "已撤单" : pl.error || JSON.stringify(j), !ok);
refreshMonitorBoardNow();
} catch (e) {
showToast(String(e), true);
}
}
async function cancelSymbolOrders(exchangeId, symbol, scope) {
const label = scope === "conditional" ? "全部条件单" : "全部委托";
if (!confirm(`确认撤销 ${symbol} 的${label}?`)) return;
try {
const r = await apiFetch(
"/api/orders/" + encodeURIComponent(exchangeId) + "/cancel-symbol",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ symbol, scope }),
}
);
const j = await r.json();
const pl = j.payload || {};
const ok = j.ok && pl.ok !== false;
const n = pl.cancelled_count != null ? pl.cancelled_count : "?";
showToast(ok ? `已撤销 ${n} 笔` : pl.error || JSON.stringify(j), !ok);
refreshMonitorBoardNow();
} catch (e) {
showToast(String(e), true);
}
}
function renderMonitorTile(row) {
const ag = row.agent || {};
const pos = Array.isArray(ag.positions) ? ag.positions : [];
const alert = analyzeExchangeAlert(row);
const upnl = ag.total_unrealized_pnl;
const openCount = pos.filter(positionHasContracts).length;
const dotCls =
alert.level === "error" ? "bad" : alert.level === "warn" ? "warn" : "ok";
const tileCls =
alert.level === "error"
? "hub-tile-error"
: alert.level === "warn"
? "hub-tile-warn"
: "hub-tile-ok";
const ts = (lastMonitorBoardUpdatedAt || "").replace("T", " ");
const tsShort = ts ? ts.slice(-8) : "—";
const posLine =
openCount > 0 ? `${openCount}仓 · ${alert.summary}` : alert.summary;
const hm = row.hub_monitor || {};
const flaskOk = row.flask_ok !== false && hm.ok !== false;
const strategyStats = renderCardStrategyStats(row, hm, flaskOk);
return `
${esc(row.name)}
${fmt(upnl, 2)} U
${esc(posLine)}
${strategyStats}
`;
}
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 rolls = flaskOk ? hm.rolls || [] : [];
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 = `${esc(row.error || "子代理不可用")}
`;
} else if (!agOk) {
inner = `${esc(agErr || "子代理返回失败")}
`;
inner += `请检查 PM2 子代理与 ${esc(row.agent_url || "")}/status
`;
} else {
inner = renderGridBody(row, ag, pos, hm, flaskOk, keys, orders, trends, rolls, kmap);
}
const online = row.http_ok && agOk;
const cardCls = online ? "card-online" : "card-offline";
const dotCls = online ? "ok" : "bad";
const flaskOpen = row.flask_url_browser || row.flask_url;
const openFlask = flaskOpen
? `实例`
: "";
const openReview = flaskOpen
? `复盘`
: "";
return `
${openFlask}
${openReview}
${inner}
`;
}
async function hubTrendPlanStop(exchangeId, planId) {
if (!exchangeId || !planId) {
showToast("缺少交易所或计划 ID", true);
return;
}
if (!confirm("结束计划:市价平仓并撤掉该合约全部挂单,确定?")) return;
try {
const r = await apiFetch("/api/trend/" + encodeURIComponent(exchangeId) + "/stop", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ plan_id: Number(planId) }),
});
const j = await r.json();
showToast(j.message || (j.ok ? "已结束趋势回调计划" : "结束失败"), !j.ok);
if (j.ok) refreshMonitorBoardNow();
} catch (e) {
showToast(String(e), true);
}
}
async function hubTrendPlanBreakeven(exchangeId, planId, inputEl) {
if (!exchangeId || !planId) {
showToast("缺少交易所或计划 ID", true);
return;
}
const raw = inputEl ? String(inputEl.value || "").trim() : "";
let pct = null;
if (raw !== "") {
pct = Number(raw);
if (!Number.isFinite(pct) || pct < 0) {
showToast("保本偏移% 须为非负数", true);
return;
}
}
if (
!confirm(
"确认保本?将结束本趋势计划,持仓移交「下单监控」,并在交易所挂保本止损与计划止盈;后续平仓写入交易记录。"
)
) {
return;
}
try {
const body = { plan_id: Number(planId) };
if (pct != null) body.breakeven_offset_pct = pct;
const r = await apiFetch("/api/trend/" + encodeURIComponent(exchangeId) + "/breakeven", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const j = await r.json();
showToast(j.message || (j.ok ? "保本移交成功" : "保本移交失败"), !j.ok);
if (j.ok) refreshMonitorBoardNow();
} catch (e) {
showToast(String(e), true);
}
}
async function closeOnePosition(exchangeId, symbol, side) {
const label = `${symbol} · ${side}`;
if (!confirm(`确认对该账户市价平仓:${label}?`)) return;
try {
const r = await apiFetch(
"/api/close/" + encodeURIComponent(exchangeId) + "/position",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ symbol, side }),
}
);
const j = await r.json();
const pl = j.payload || {};
const ok = j.ok && pl.ok !== false;
const msg =
(ok && pl.closed
? `已平仓 ${pl.closed.symbol} ${pl.closed.side} · 张数 ${pl.closed.amount}`
: pl.error) || JSON.stringify(j, null, 2);
showToast(msg, !ok);
refreshMonitorBoardNow();
} catch (e) {
showToast(String(e), true);
}
}
async function closeOne(id) {
if (!confirm("确认对该账户市价全平?")) return;
try {
const r = await apiFetch("/api/close/" + encodeURIComponent(id), { method: "POST" });
const j = await r.json();
showToast(JSON.stringify(j, null, 2), !r.ok);
refreshMonitorBoardNow();
} catch (e) {
showToast(String(e), true);
}
}
async function closeAll() {
const n = enabledAccounts().length;
if (!confirm(`对 ${n} 个已启用账户执行紧急全平?`)) return;
try {
const r = await apiFetch("/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);
refreshMonitorBoardNow();
} catch (e) {
showToast(String(e), true);
}
}
async function loadSettingsMetaLine() {
try {
const r = await apiFetch("/api/settings/meta");
const m = await r.json();
const el = document.getElementById("settings-meta-line");
if (!el) return;
const parts = [];
if (m.password_required) parts.push("已启用用户名+密码登录");
else parts.push("未设 HUB_PASSWORD(反代公网暴露时建议设置 HUB_USERNAME + HUB_PASSWORD)");
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(复盘链接仅本机可开)");
if ((m.env_disabled_ids || []).length) {
parts.push("环境强制关闭 id: " + m.env_disabled_ids.join(", ") + "(改 .env 后须重启 hub)");
} else {
parts.push("HUB_DISABLED_IDS 未强制关闭任何账户");
}
el.textContent = parts.join(" · ");
} catch (_) {}
}
function renderSettingsList(data) {
const list = document.getElementById("settings-list");
if (!list) return;
list.innerHTML = (data.exchanges || [])
.map((ex, idx) => renderSettingsCard(ex, idx))
.join("");
list.querySelectorAll(".btn-del-ex").forEach((btn) => {
btn.onclick = () => {
const i = Number(btn.dataset.idx);
data.exchanges.splice(i, 1);
settingsCache = data;
renderSettingsList(data);
};
});
}
function loadSettingsUI() {
loadSettingsMetaLine();
loadSettings().then((data) => {
renderSettingsList(data);
});
}
function renderSettingsCard(ex, idx) {
const caps = ex.capabilities || [];
const envOff = ex.env_disabled
? '环境变量强制关'
: "";
return ``;
}
function collectSettingsFromUI() {
const rows = [...document.querySelectorAll("#settings-list .settings-card")];
return {
version: 1,
exchanges: rows.map((card) => {
const caps = [];
if (card.querySelector(".cap-key").checked) caps.push("key");
if (card.querySelector(".cap-trend").checked) caps.push("trend");
const id = card.querySelector(".ex-id").value.trim();
const stableKey = (card.dataset.key || id).trim();
return {
id: id,
key: stableKey,
name: card.querySelector(".ex-name").value.trim(),
flask_url: card.querySelector(".ex-flask").value.trim(),
agent_url: card.querySelector(".ex-agent").value.trim(),
review_url: card.querySelector(".ex-review").value.trim(),
enabled: card.querySelector(".ex-enabled").checked,
capabilities: caps,
};
}),
};
}
async function saveSettings() {
const body = collectSettingsFromUI();
try {
const r = await apiFetch("/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)");
if (j.settings) {
settingsCache = j.settings;
renderSettingsList(j.settings);
loadSettingsMetaLine();
} else {
await loadSettingsUI();
}
} else showToast("保存失败", true);
} catch (e) {
showToast(String(e), true);
}
}
document.getElementById("btn-logout").onclick = async () => {
try {
await fetch("/api/auth/logout", { method: "POST" });
} catch (_) {}
location.href = "/login";
};
document.getElementById("btn-monitor-refresh").onclick = () => refreshMonitorBoardNow();
document.getElementById("auto-monitor").onchange = () => {
if (document.getElementById("auto-monitor").checked) {
connectMonitorBoardStream();
} else {
closeMonitorBoardStream();
}
};
document.getElementById("btn-close-all").onclick = closeAll;
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: ["key"],
});
settingsCache = data;
renderSettingsList(data);
showToast("已添加一行,请填写 URL 后点「保存设置」");
};
let aiSummaryLoading = false;
let aiChatLoading = false;
let aiChatSessionCache = null;
function aiPnlClass(v) {
const n = Number(v);
if (!Number.isFinite(n) || Math.abs(n) < 1e-9) return "";
return n > 0 ? "pos" : "neg";
}
function aiPnlSigned(v, digits) {
const n = Number(v);
if (!Number.isFinite(n)) return "—";
const abs = fmt(Math.abs(n), digits);
if (Math.abs(n) < 1e-9) return `${abs}U`;
return `${n > 0 ? "+" : "-"}${abs}U`;
}
function renderHubMarkdown(text) {
const raw = String(text || "");
if (typeof window !== "undefined" && window.AiReviewRender && window.AiReviewRender.renderMarkdown) {
return window.AiReviewRender.renderMarkdown(raw);
}
return esc(raw)
.replace(/\*\*(.+?)\*\*/g, "$1")
.replace(/\n/g, "
");
}
function renderAiMarkdown(text) {
return renderHubMarkdown(text);
}
function enhanceHubSummaryMarkdown(md) {
let out = String(md || "");
out = out.replace(/\*\*今日交易总结(([^)]+))\*\*/g, "# 📋 今日交易总结($1)");
out = out.replace(/\*\*1\.\s*(?:📊\s*)?总览\*\*/g, "## 1. 📊 总览");
out = out.replace(/\*\*2\.\s*(?:👥\s*)?分户明细\*\*/g, "## 2. 👥 分户明细");
out = out.replace(/\*\*3\.\s*(?:⚠️\s*)?需关注\*\*/g, "## 3. ⚠️ 需关注");
out = out.replace(/\*\*4\.\s*(?:ℹ️\s*)?数据说明\*\*/g, "## 4. ℹ️ 数据说明");
out = out.replace(/\*\*5\.\s*(?:💡\s*)?操作建议\*\*/g, "## 5. 💡 操作建议");
return out;
}
function aiFmtFund(v) {
const n = Number(v);
if (!Number.isFinite(n)) return "—";
return `${fmt(n, 2)}U`;
}
function aiPnlCellHtml(v, digits) {
const cls = aiPnlClass(v);
const valCls = cls ? ` ai-stat-val ${cls}` : " ai-stat-val";
return `${aiPnlSigned(v, digits)}`;
}
function aiAccountStatusClass(status) {
const s = String(status || "");
if (s === "未监控") return "ai-ac-unmon";
if (s.includes("异常")) return "ai-ac-err";
if (s.includes("需关注")) return "ai-ac-warn";
return "";
}
function renderAiAccountTable(snapshot) {
const accounts = snapshot && snapshot.by_account;
if (!accounts || typeof accounts !== "object") return "";
const rows = Object.values(accounts);
if (!rows.length) return "";
const head =
"" +
"| 账户 | 状态 | 资金账户 | 交易账户 | 今日盈亏 | 笔数 | 浮盈亏 | 备注 | " +
"
";
const body = rows
.map((ac) => {
const closedPnl = Number(ac.pnl_u);
const floatPnl = Number(ac.float_pnl_u);
const remark =
ac.remark ||
(Array.isArray(ac.issues) && ac.issues.length ? ac.issues.join(";") : "无");
const statusCls = aiAccountStatusClass(ac.status);
const countLabel = `${Number(ac.closed_count) || 0}${Number(ac.closed_count_yesterday) ? ` / 昨${Number(ac.closed_count_yesterday)}` : ""}`;
return (
"" +
`| ${esc(ac.name || "—")} | ` +
`${esc(ac.status || "—")} | ` +
`${aiFmtFund(ac.funding_usdt)} | ` +
`${aiFmtFund(ac.trading_usdt)} | ` +
`${aiPnlCellHtml(closedPnl, 2)} | ` +
`${countLabel} | ` +
`${aiPnlCellHtml(floatPnl, 2)} | ` +
`` +
"
"
);
})
.join("");
return ``;
}
function renderAiClosedTradesBlock(snapshot) {
const rows = (snapshot && snapshot.closed_trades) || [];
if (!rows.length) return "";
const head =
"| 交易日 | 账户 | 合约 | 方向 | 结果 | 盈亏 | 时间 |
";
const body = rows
.map((t) => {
const pnl = Number(t.pnl_amount);
return (
"" +
`| ${esc(t.trading_day || "—")} | ` +
`${esc(t.account_name || "—")} | ` +
`${esc(t.symbol || "—")} | ` +
`${esc(t.direction || "—")} | ` +
`${esc(t.result || "—")} | ` +
`${aiPnlCellHtml(pnl, 2)} | ` +
`` +
"
"
);
})
.join("");
return (
``
);
}
function renderAiSummaryBody(contentMd, snapshot) {
const md = enhanceHubSummaryMarkdown(contentMd);
const sec2 = /##\s*2\.\s*👥\s*分户明细/;
const sec3 = /##\s*3\.\s*⚠️\s*需关注/;
const i2 = md.search(sec2);
const i3 = md.search(sec3);
const tableHtml = renderAiAccountTable(snapshot);
const closedHtml = renderAiClosedTradesBlock(snapshot);
if (i2 >= 0 && i3 > i2 && tableHtml) {
const headEnd = i2 + md.slice(i2).match(sec2)[0].length;
const part1 = md.slice(0, headEnd);
const part2 = md.slice(i3);
return renderHubMarkdown(part1) + tableHtml + closedHtml + renderHubMarkdown(part2);
}
return renderHubMarkdown(md) + (tableHtml ? tableHtml + closedHtml : "");
}
function setAiSummaryMarkdown(body, contentMd, snapshot) {
if (!body) return;
body.classList.add("ai-result-md");
body.innerHTML = renderAiSummaryBody(contentMd, snapshot);
}
function setAiSummaryPlaceholder(body, html) {
if (!body) return;
body.classList.remove("ai-result-md");
body.innerHTML = html;
}
function renderAiSummaryStats(snapshot) {
const el = document.getElementById("ai-summary-stats");
if (!el) return;
if (!snapshot || !snapshot.totals) {
el.innerHTML = "";
return;
}
const t = snapshot.totals;
const closedPnl = Number(t.total_pnl_u);
const floatPnl = Number(t.float_pnl_u);
const closedCls = aiPnlClass(closedPnl);
const floatCls = aiPnlClass(floatPnl);
el.innerHTML = [
`交易日${esc(t.trading_day || "—")}`,
`平仓盈亏${aiPnlSigned(closedPnl, 2)}`,
`笔数${t.closed_count || 0}(胜${t.win_count || 0}/负${t.loss_count || 0})`,
`浮盈亏${aiPnlSigned(floatPnl, 2)}`,
].join("");
}
function renderAiChatRow(role, content, extraClass, attachments) {
const isUser = role === "user";
const label = isUser ? "主人" : "AI教练";
const rowCls = isUser ? "ai-msg-row-user" : "ai-msg-row-coach";
const bubbleCls = isUser ? "ai-bubble-user" : "ai-bubble-assistant";
const isThinking = extraClass && String(extraClass).includes("ai-bubble-thinking");
const bubbleInner = isUser || isThinking ? esc(content || "") : renderHubMarkdown(content || "");
const mdCls = !isUser && !isThinking ? " ai-result-md" : "";
const attList = Array.isArray(attachments) ? attachments : [];
const attHtml = attList.length
? `${attList
.map((a) => `${esc(a.name || "附件")}`)
.join("")}
`
: "";
return (
`` +
`${label}` +
`${attHtml}` +
`` +
`
`
);
}
function renderAiChatMessages(session, opts) {
const options = opts || {};
const box = document.getElementById("ai-chat-messages");
const title = document.getElementById("ai-chat-title");
if (!box) return;
const msgs = (session && session.messages) || [];
if (title) {
title.textContent = session && session.title ? `聊天 · ${session.title}` : "聊天";
}
const showPlaceholder =
!msgs.length && !options.pendingUser && !options.thinking;
if (showPlaceholder) {
box.innerHTML =
'主人发消息会立刻出现在右侧;AI教练 会先显示「正在思考…」再回复。可点「附件」上传图片或文档。
';
return;
}
let html = msgs
.map((m) =>
renderAiChatRow(
m.role === "user" ? "user" : "assistant",
m.content || "",
null,
m.attachments
)
)
.join("");
if (options.pendingUser) {
html += renderAiChatRow("user", options.pendingUser, null, options.pendingAttachments);
}
if (options.thinking) {
html += renderAiChatRow("assistant", "正在思考…", "ai-bubble-thinking");
}
box.innerHTML = html;
box.scrollTop = box.scrollHeight;
}
function setAiChatBusy(busy) {
aiChatLoading = !!busy;
const btn = document.getElementById("btn-ai-chat-send");
const input = document.getElementById("ai-chat-input");
if (btn) btn.disabled = busy;
if (input) input.disabled = busy;
}
async function loadAiSummary() {
const body = document.getElementById("ai-summary-body");
try {
const r = await apiFetch("/api/ai/summary");
const j = await r.json();
const latest = j.latest;
if (latest && latest.content_md) {
if (body) setAiSummaryMarkdown(body, latest.content_md, latest.stats_snapshot);
renderAiSummaryStats(latest.stats_snapshot);
}
} catch (e) {
if (body) setAiSummaryPlaceholder(body, `${esc(String(e))}
`);
}
}
async function loadAiChatSession() {
const r = await apiFetch("/api/ai/chat/session");
const j = await r.json();
aiChatSessionCache = j.session || null;
renderAiChatMessages(aiChatSessionCache);
}
async function loadAiPage() {
applyAiMobileTab();
await Promise.all([loadAiSummary(), loadAiChatSession()]);
if (isMobileLayout() && (localStorage.getItem(AI_MOBILE_TAB_KEY) || "chat") === "chat") {
const input = document.getElementById("ai-chat-input");
if (input && !aiChatLoading) {
setTimeout(() => input.focus(), 80);
}
}
}
async function generateAiSummary() {
if (aiSummaryLoading) return;
aiSummaryLoading = true;
const btn = document.getElementById("btn-ai-summary");
const body = document.getElementById("ai-summary-body");
if (btn) btn.disabled = true;
if (body) setAiSummaryPlaceholder(body, '正在聚合四户数据并生成总结…
');
try {
const r = await apiFetch("/api/ai/summary/generate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ force: true }),
});
const j = await r.json();
if (!r.ok) throw new Error(j.detail || j.msg || "生成失败");
if (!j.ok && j.detail) throw new Error(j.detail);
const sum = j.summary;
if (sum && sum.content_md && body) {
setAiSummaryMarkdown(body, sum.content_md, sum.stats_snapshot);
renderAiSummaryStats(sum.stats_snapshot);
}
showToast(j.cached ? "已是最新上下文,返回缓存总结" : "今日总结已生成");
await loadAiSummary();
} catch (e) {
showToast(String(e), true);
if (body) setAiSummaryPlaceholder(body, `${esc(String(e))}
`);
} finally {
aiSummaryLoading = false;
if (btn) btn.disabled = false;
}
}
async function newAiChat() {
try {
const r = await apiFetch("/api/ai/chat/new", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
const j = await r.json();
aiChatSessionCache = j.session || null;
renderAiChatMessages(aiChatSessionCache);
showToast("已开始新对话");
} catch (e) {
showToast(String(e), true);
}
}
async function sendAiChat(ev) {
if (ev) ev.preventDefault();
if (aiChatLoading) return;
const input = document.getElementById("ai-chat-input");
const fileInput = document.getElementById("ai-chat-files");
const fileLabel = document.getElementById("ai-chat-files-label");
const text = (input && input.value || "").trim();
const files = fileInput && fileInput.files ? Array.from(fileInput.files) : [];
if (!text && !files.length) return;
const pendingAttachments = files.map((f) => ({ name: f.name, kind: f.type.startsWith("image/") ? "image" : "text" }));
if (input) input.value = "";
setAiChatBusy(true);
renderAiChatMessages(aiChatSessionCache, {
pendingUser: text || (files.length ? `(上传 ${files.length} 个附件)` : ""),
pendingAttachments,
thinking: true,
});
try {
const fd = new FormData();
fd.append("message", text);
files.forEach((f) => fd.append("files", f, f.name));
const r = await apiFetch("/api/ai/chat/send", { method: "POST", body: fd });
const j = await r.json();
if (!r.ok) throw new Error(j.detail || j.msg || "发送失败");
aiChatSessionCache = j.session || null;
renderAiChatMessages(aiChatSessionCache);
if (fileInput) fileInput.value = "";
if (fileLabel) fileLabel.textContent = "";
if (j.attachment_warnings && j.attachment_warnings.length) {
showToast(j.attachment_warnings.join(";"), true);
}
} catch (e) {
showToast(String(e), true);
renderAiChatMessages(aiChatSessionCache);
} finally {
setAiChatBusy(false);
}
}
const aiChatFiles = document.getElementById("ai-chat-files");
const aiChatFilesLabel = document.getElementById("ai-chat-files-label");
if (aiChatFiles && aiChatFilesLabel) {
aiChatFiles.addEventListener("change", () => {
const names = aiChatFiles.files ? Array.from(aiChatFiles.files).map((f) => f.name) : [];
aiChatFilesLabel.textContent = names.length ? names.join("、") : "";
});
}
const aiSummaryBtn = document.getElementById("btn-ai-summary");
if (aiSummaryBtn) aiSummaryBtn.onclick = () => generateAiSummary();
const aiChatNewBtn = document.getElementById("btn-ai-chat-new");
if (aiChatNewBtn) aiChatNewBtn.onclick = () => newAiChat();
const aiChatForm = document.getElementById("ai-chat-form");
if (aiChatForm) aiChatForm.addEventListener("submit", sendAiChat);
initTpslModal();
initInstanceFrame();
initFullscreen();
initMobileLayout();
if (globalThis.HubTheme && typeof HubTheme.initToggleUI === "function") {
HubTheme.initToggleUI();
}
function initShellNav() {
document.querySelectorAll(".top-nav a[href^='/']").forEach((a) => {
a.addEventListener("click", (ev) => {
const href = a.getAttribute("href");
if (!href || ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey) return;
ev.preventDefault();
const path = href.split("?")[0];
if (path === window.location.pathname) {
setActiveNav();
return;
}
history.pushState({}, "", href);
setActiveNav();
});
});
window.addEventListener("popstate", setActiveNav);
}
initAuth().then((ok) => {
if (!ok) return;
initShellNav();
setActiveNav();
if (currentPage() === "settings") {
loadSettings().catch(() => {});
}
});
})();