const PERIOD_API = { today: "/api/today/top30", yesterday: "/api/yesterday/top30", daybefore: "/api/daybefore/top30", }; const tableState = { today: { items: [], meta: {}, sortKey: "rank", sortDir: "asc" }, yesterday: { items: [], meta: {}, sortKey: "rank", sortDir: "asc" }, daybefore: { items: [], meta: {}, sortKey: "rank", sortDir: "asc" }, }; const PERIOD_LS_PREFIX = "ba_period_"; const PERIOD_TTL_MS = 4 * 60 * 60 * 1000; let statsData = null; let currentView = "today"; let llmPollTimer = null; let llmInterpretMap = {}; const SORT_KEYS = { rank: (r) => Number(r.rank) || 0, symbol: (r) => String(r.symbol || ""), quote_volume: (r) => Number(r.quote_volume) || 0, price_change_pct: (r) => Number(r.price_change_pct) || 0, funding_rate: (r) => Number(r.funding_rate_pct) || 0, tags: (r) => { let s = 0; if (r.is_high_volume) s += 2; if (r.is_high_change) s += 1; return s; }, total_quote_volume: (r) => Number(r.total_quote_volume) || 0, avg_change_pct: (r) => Number(r.avg_change_pct) || 0, }; const TABLE_HEADER = ` 排名 合约 日线图 成交额 (USDT) 涨跌幅 资金费率 标记 `; function formatPeriod(start, end) { const fmt = (s) => (s || "").replace("T", " ").slice(0, 16); return `${fmt(start)} ~ ${fmt(end)}`; } function tagText(row) { const t = []; if (row.is_high_volume) t.push("千万+"); if (row.is_high_change) t.push("涨跌5%+"); return t.join(" ") || ""; } function renderTags(row) { const p = []; if (row.is_high_volume) p.push('千万+'); if (row.is_high_change) p.push('涨跌5%+'); return p.length ? p.join("") : "—"; } function pctClass(pct) { if (pct > 0) return "pct-up"; if (pct < 0) return "pct-down"; return ""; } function sortItems(items, key, dir, customKeys) { const getter = (customKeys || SORT_KEYS)[key] || SORT_KEYS.rank; return [...items].sort((a, b) => { const va = getter(a); const vb = getter(b); if (typeof va === "string") { return dir === "asc" ? va.localeCompare(vb) : vb.localeCompare(va); } return dir === "asc" ? va - vb : vb - va; }); } function ensurePeriodTable(periodId) { const wrap = document.getElementById(`${periodId}-table-wrap`); if (!wrap) return null; let table = wrap.querySelector(`table[data-table="${periodId}"]`); if (!table) { wrap.innerHTML = `${TABLE_HEADER}
`; table = wrap.querySelector("table"); bindSortHandlers(table); } return document.getElementById(`${periodId}-body`); } function bindSortHandlers(table) { table.querySelectorAll("th.sortable").forEach((th) => { th.onclick = () => { const tableId = table.dataset.table; const key = th.dataset.sort; if (tableId && key) toggleSort(tableId, key); }; }); } function updateSortHeaders(tableId) { const table = document.querySelector(`table[data-table="${tableId}"]`); if (!table) return; const { sortKey, sortDir } = tableState[tableId] || { sortKey: "rank", sortDir: "asc" }; table.querySelectorAll("th.sortable").forEach((th) => { th.classList.remove("sorted-asc", "sorted-desc"); if (th.dataset.sort === sortKey) { th.classList.add(sortDir === "asc" ? "sorted-asc" : "sorted-desc"); } }); } function renderPeriodTable(periodId) { const tbody = ensurePeriodTable(periodId); if (!tbody) return; const state = tableState[periodId]; const items = sortItems(state.items, state.sortKey, state.sortDir); if (!items.length) { tbody.innerHTML = '暂无数据'; updateSortHeaders(periodId); return; } tbody.innerHTML = items .map((row, idx) => { const hl = row.is_high_volume || row.is_high_change ? " row-highlight" : ""; const pct = row.price_change_pct ?? 0; const rank = state.sortKey === "rank" && state.sortDir === "asc" ? row.rank : idx + 1; return ` ${rank} ${row.symbol}
${row.quote_volume_fmt || row.quote_volume} ${row.price_change_pct_fmt || pct.toFixed(2) + "%"}
${row.funding_rate_fmt || "—"}
${renderTags(row)} `; }) .join(""); updateSortHeaders(periodId); if (currentView === periodId) { enqueueCharts(tbody); if (typeof enqueueFundingCharts === "function") enqueueFundingCharts(tbody); } } function setPeriodData(periodId, data) { tableState[periodId].items = data.items || []; tableState[periodId].meta = { period_start: data.period_start, period_end: data.period_end, updated_at: data.updated_at, }; const pe = document.getElementById(`${periodId}-period`); const ue = document.getElementById(`${periodId}-updated`); if (pe) pe.textContent = formatPeriod(data.period_start, data.period_end); if (ue) ue.textContent = "更新: " + (data.updated_at || "").replace("T", " ").slice(0, 19); renderPeriodTable(periodId); } function loadPeriodFromLS(periodId) { try { const raw = localStorage.getItem(PERIOD_LS_PREFIX + periodId); if (!raw) return null; const obj = JSON.parse(raw); if (!obj?.data || Date.now() - (obj.ts || 0) > PERIOD_TTL_MS) return null; return obj.data; } catch { return null; } } function savePeriodToLS(periodId, data) { try { localStorage.setItem( PERIOD_LS_PREFIX + periodId, JSON.stringify({ ts: Date.now(), data }) ); } catch { /* quota */ } } async function loadPeriod(periodId, force = false) { const tbody = ensurePeriodTable(periodId); if (!force) { const cached = loadPeriodFromLS(periodId); if (cached?.items?.length) { setPeriodData(periodId, cached); if (periodId === "today") { document.getElementById("status").textContent = "今日数据(浏览器缓存)"; } return; } } if (tbody) tbody.innerHTML = '加载中…'; try { const res = await fetch(PERIOD_API[periodId]); const data = await res.json(); savePeriodToLS(periodId, data); setPeriodData(periodId, data); if (periodId === "today") { document.getElementById("status").textContent = force ? "今日数据已手动刷新" : "今日数据已加载"; } } catch (e) { if (tbody) tbody.innerHTML = `${e.message}`; } } function toggleSort(tableId, key) { const s = tableState[tableId]; if (s.sortKey === key) s.sortDir = s.sortDir === "asc" ? "desc" : "asc"; else { s.sortKey = key; s.sortDir = key === "symbol" ? "asc" : "desc"; } renderPeriodTable(tableId); } function resetSort(tableId) { tableState[tableId].sortKey = "rank"; tableState[tableId].sortDir = "asc"; renderPeriodTable(tableId); } function exportPeriodCsv(periodId) { const state = tableState[periodId]; if (!state.items.length) return alert("暂无数据"); const items = sortItems(state.items, state.sortKey, state.sortDir); const header = ["排名", "合约", "成交额", "涨跌幅%", "资金费率%", "标记"]; const rows = items.map((r, i) => [ state.sortKey === "rank" && state.sortDir === "asc" ? r.rank : i + 1, r.symbol, r.quote_volume ?? "", r.price_change_pct ?? "", r.funding_rate_pct ?? "", tagText(r), ]); downloadCsv(`binance-${periodId}`, header, rows, state.meta.period_start); } function downloadCsv(name, header, rows, periodStart) { const esc = (v) => `"${String(v).replace(/"/g, '""')}"`; const csv = [header, ...rows].map((r) => r.map(esc).join(",")).join("\n"); const blob = new Blob(["\ufeff" + csv], { type: "text/csv;charset=utf-8" }); const a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = `${name}-${(periodStart || "").slice(0, 10)}.csv`; a.click(); } function renderStatsTable() { const wrap = document.getElementById("stats-table-wrap"); if (!wrap || !statsData) return; const items = statsData.items || []; document.getElementById("stats-criteria").textContent = statsData.criteria || ""; document.getElementById("stats-desc").textContent = statsData.message || ""; const sum = statsData.summary; document.getElementById("stats-summary").textContent = statsData.ok ? `符合条件 ${statsData.count} 个 · 三日交集 ${sum?.intersection ?? 0} 个` : "数据未就绪"; if (!statsData.ok) { wrap.innerHTML = `

${statsData.message || "请等待三个周期数据就绪"}

`; return; } if (!items.length) { wrap.innerHTML = '

暂无符合条件的合约

'; return; } wrap.innerHTML = `
合约 今日排名今日涨跌今日成交额 昨日排名昨日涨跌昨日成交额 前日排名前日涨跌前日成交额 三日总成交额 AI解读
`; const body = document.getElementById("stats-body"); body.innerHTML = items .map((row) => { const d = (p) => row[p] || {}; const cell = (p, f) => { const x = d(p); const pct = x.price_change_pct ?? 0; return `${f === "pct" ? x.price_change_pct_fmt || "—" : f === "rank" ? x.rank ?? "—" : x.quote_volume_fmt || "—"}`; }; const llm = llmInterpretMap[row.symbol]; const llmCell = llm ? `
AI解读
${escapeHtml(llm.content)}
${llm.created_at?.slice(0, 19) || ""}
` : ''; return ` ${row.symbol} ${cell("today", "rank")}${cell("today", "pct")}${cell("today", "vol")} ${cell("yesterday", "rank")}${cell("yesterday", "pct")}${cell("yesterday", "vol")} ${cell("daybefore", "rank")}${cell("daybefore", "pct")}${cell("daybefore", "vol")} ${formatVol(row.total_quote_volume)} ${llmCell} `; }) .join(""); } function escapeHtml(s) { return String(s || "") .replace(/&/g, "&") .replace(//g, ">") .replace(/\n/g, "
"); } function formatVol(v) { if (v >= 1e8) return (v / 1e8).toFixed(2) + "亿"; if (v >= 1e4) return (v / 1e4).toFixed(2) + "万"; return String(Math.round(v)); } async function loadLlmInterpretations() { try { const res = await fetch("/api/llm/interpretations"); const data = await res.json(); llmInterpretMap = {}; for (const item of data.items || []) { llmInterpretMap[item.symbol] = item; } renderLlmList(data.items || []); if (statsData?.ok) renderStatsTable(); } catch { /* ignore */ } } function renderLlmList(items) { const el = document.getElementById("llm-interpret-list"); if (!el) return; if (!items.length) { el.innerHTML = '

暂无解读记录

'; return; } el.innerHTML = items .map( (it) => `

${it.symbol} ${it.batch_id}

${escapeHtml(it.content)}
` ) .join(""); } async function refreshLlmStatus() { try { const res = await fetch("/api/llm/status"); const st = await res.json(); const label = document.getElementById("llm-model-label"); const text = document.getElementById("llm-status-text"); if (label) label.textContent = st.enabled ? st.model : "未配置"; if (!text) return; if (st.running) { text.textContent = `解读中 ${st.done}/${st.total} · 当前 ${st.current_symbol || "—"} · 批次 ${st.batch_id}`; if (!llmPollTimer) { llmPollTimer = setInterval(async () => { await refreshLlmStatus(); await loadLlmInterpretations(); if (!(await fetch("/api/llm/status").then((r) => r.json())).running) { clearInterval(llmPollTimer); llmPollTimer = null; } }, 5000); } } else { text.textContent = st.enabled ? `就绪 · 每币 ${st.interval_sec}s · 最近批次 ${st.batch_id || "—"}` : "请在 .env 配置 LLM_API_KEY"; if (llmPollTimer) { clearInterval(llmPollTimer); llmPollTimer = null; } } } catch { /* ignore */ } } async function runLlmInterpret() { const btn = document.getElementById("btn-llm-run"); if (btn) btn.disabled = true; try { const res = await fetch("/api/llm/interpret/run", { method: "POST" }); const data = await res.json(); if (!data.ok) alert(data.message || "启动失败"); await refreshLlmStatus(); } catch (e) { alert(e.message); } finally { if (btn) btn.disabled = false; } } async function loadStats() { document.getElementById("stats-table-wrap").innerHTML = '

统计中…

'; try { const res = await fetch("/api/stats/three-day"); statsData = await res.json(); await loadLlmInterpretations(); renderStatsTable(); await refreshLlmStatus(); } catch (e) { document.getElementById("stats-table-wrap").innerHTML = `

${e.message}

`; } } function exportStatsCsv() { if (!statsData?.items?.length) return alert("暂无数据"); const header = [ "合约", "今日排名", "今日涨跌%", "今日成交额", "昨日排名", "昨日涨跌%", "昨日成交额", "前日排名", "前日涨跌%", "前日成交额", "三日总成交额", ]; const rows = statsData.items.map((r) => [ r.symbol, r.today?.rank, r.today?.price_change_pct, r.today?.quote_volume, r.yesterday?.rank, r.yesterday?.price_change_pct, r.yesterday?.quote_volume, r.daybefore?.rank, r.daybefore?.price_change_pct, r.daybefore?.quote_volume, r.total_quote_volume, ]); downloadCsv("binance-three-day-stats", header, rows, "stats"); } function switchView(view) { currentView = view; document.querySelectorAll(".nav-item").forEach((b) => { b.classList.toggle("active", b.dataset.view === view); }); document.querySelectorAll(".view-panel").forEach((p) => { p.classList.toggle("active", p.id === `view-${view}`); }); if (view === "stats") { if (!statsData) loadStats(); else { refreshLlmStatus(); loadLlmInterpretations(); } return; } const tbody = document.getElementById(`${view}-body`); if (tbody && tableState[view].items.length) { renderPeriodTable(view); enqueueCharts(tbody); if (typeof enqueueFundingCharts === "function") enqueueFundingCharts(tbody); } else if (!tableState[view].items.length) { loadPeriod(view); } } document.getElementById("main-nav").addEventListener("click", (e) => { const btn = e.target.closest(".nav-item"); if (btn?.dataset.view) switchView(btn.dataset.view); }); document.querySelectorAll("[data-export]").forEach((btn) => { btn.addEventListener("click", () => exportPeriodCsv(btn.dataset.export)); }); document.querySelectorAll("[data-reset]").forEach((btn) => { btn.addEventListener("click", () => resetSort(btn.dataset.reset)); }); document.getElementById("btn-refresh").addEventListener("click", async () => { document.getElementById("status").textContent = "刷新中…"; await fetch("/api/refresh/today", { method: "POST" }); await loadPeriod("today", true); if (currentView === "stats") await loadStats(); }); document.getElementById("btn-llm-run")?.addEventListener("click", runLlmInterpret); document.getElementById("btn-llm-refresh")?.addEventListener("click", async () => { await loadLlmInterpretations(); await refreshLlmStatus(); }); document.getElementById("btn-reload-stats")?.addEventListener("click", () => { statsData = null; loadStats(); }); document.getElementById("btn-export-stats")?.addEventListener("click", exportStatsCsv); loadPeriod("today"); loadPeriod("yesterday"); loadPeriod("daybefore");