diff --git a/backend/app/main.py b/backend/app/main.py index 5a09636..1792e1d 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -6,16 +6,18 @@ from fastapi import FastAPI, HTTPException from fastapi.responses import FileResponse from fastapi.staticfiles import StaticFiles -from .aggregator import aggregate_period, enrich_snapshot_meta from .config import ROOT_DIR, settings -from .funding_store import enrich_items_with_funding, get_funding_bundle +from .funding_store import get_funding_bundle from .kline_store import get_daily_candles, sync_daily_klines -from .db import get_latest_snapshot, init_db, log_push +from .db import get_latest_snapshot, init_db, log_push, save_snapshot from .exceptions import BinanceRateLimitedError -from .periods import get_today_period, get_yesterday_period +from .period_api import get_period_top30 +from .periods import get_daybefore_period, get_today_period, get_yesterday_period from .scheduler import job_finalize_yesterday, job_push_wecom, job_refresh_today, start_scheduler, startup_tasks, stop_scheduler -from .state import get_today_cache +from .stats import compute_three_day_stats +from .aggregator import aggregate_period from .wecom import build_markdown, send_wecom_markdown +from .state import get_today_cache logging.basicConfig( level=logging.INFO, @@ -59,48 +61,34 @@ async def index(): return {"message": "Web UI not found. Place files in /web"} -@app.get("/api/yesterday/top30") -async def api_yesterday_top30(): - snap = get_latest_snapshot("yesterday") - if snap: - items = await enrich_items_with_funding(snap["items"]) - return { - "period_start": snap["period_start"], - "period_end": snap["period_end"], - "updated_at": snap["created_at"], - "top_n": settings.top_n, - "volume_threshold": settings.volume_threshold, - "change_threshold": settings.change_threshold, - "items": items, - } - start, end = get_yesterday_period() - try: - mode = settings.yesterday_data_mode - items = await aggregate_period(start, end, mode=mode) - return enrich_snapshot_meta(items, start, end, data_mode=mode) - except Exception as e: - logger.error("api yesterday failed: %s", e) - meta = enrich_snapshot_meta([], start, end) - meta["error"] = "数据暂不可用,请检查网络或稍后重试" - return meta - - @app.get("/api/today/top30") async def api_today_top30(): + from .state import get_today_cache + cached = get_today_cache() if cached: + from .funding_store import enrich_items_with_funding + cached["items"] = await enrich_items_with_funding(cached.get("items", [])) return cached - start, end = get_today_period() - try: - mode = settings.today_data_mode - items = await aggregate_period(start, end, use_live_prices=True, mode=mode) - return enrich_snapshot_meta(items, start, end, data_mode=mode) - except Exception as e: - logger.error("api today failed: %s", e) - meta = enrich_snapshot_meta([], start, end) - meta["error"] = "数据暂不可用,请检查网络或稍后重试" - return meta + return await get_period_top30( + "today", get_today_period, use_live_prices=True, data_mode=settings.today_data_mode + ) + + +@app.get("/api/yesterday/top30") +async def api_yesterday_top30(): + return await get_period_top30("yesterday", get_yesterday_period) + + +@app.get("/api/daybefore/top30") +async def api_daybefore_top30(): + return await get_period_top30("daybefore", get_daybefore_period) + + +@app.get("/api/stats/three-day") +async def api_stats_three_day(): + return compute_three_day_stats() @app.post("/api/push/test") @@ -135,6 +123,12 @@ async def api_refresh_today(): return get_today_cache() or {"message": "done"} +@app.post("/api/refresh/daybefore") +async def api_refresh_daybefore(): + await job_finalize_yesterday() + return get_latest_snapshot("daybefore") or {"message": "done"} + + @app.get("/api/chart/{symbol}/daily") async def api_chart_daily(symbol: str, limit: int | None = None, refresh: bool = False): """合约日 K 线:优先读本地 SQLite,过期再拉币安入库。""" diff --git a/backend/app/period_api.py b/backend/app/period_api.py new file mode 100644 index 0000000..a893281 --- /dev/null +++ b/backend/app/period_api.py @@ -0,0 +1,59 @@ +"""各周期 Top30 API 共用逻辑。""" + +import logging +from collections.abc import Callable +from datetime import datetime + +from .aggregator import aggregate_period, enrich_snapshot_meta +from .config import settings +from .db import get_latest_snapshot, save_snapshot +from .funding_store import enrich_items_with_funding + +logger = logging.getLogger(__name__) + + +async def get_period_top30( + period_type: str, + period_getter: Callable[[], tuple[datetime, datetime]], + *, + use_live_prices: bool = False, + data_mode: str | None = None, + auto_save: bool = True, +) -> dict: + start, end = period_getter() + expected_end = end.isoformat() + + snap = get_latest_snapshot(period_type) + if snap and snap.get("period_end") == expected_end: + items = await enrich_items_with_funding(snap["items"]) + return { + "period_type": period_type, + "period_start": snap["period_start"], + "period_end": snap["period_end"], + "updated_at": snap["created_at"], + "top_n": settings.top_n, + "volume_threshold": settings.volume_threshold, + "change_threshold": settings.change_threshold, + "data_mode": data_mode or settings.yesterday_data_mode, + "items": items, + } + + mode = data_mode or ( + settings.today_data_mode if use_live_prices else settings.yesterday_data_mode + ) + try: + items = await aggregate_period( + start, end, use_live_prices=use_live_prices, mode=mode + ) + if auto_save and items: + save_snapshot(period_type, start, end, items) + items = await enrich_items_with_funding(items) + meta = enrich_snapshot_meta(items, start, end, data_mode=mode) + meta["period_type"] = period_type + return meta + except Exception as e: + logger.error("period %s failed: %s", period_type, e) + meta = enrich_snapshot_meta([], start, end) + meta["period_type"] = period_type + meta["error"] = "数据暂不可用,请检查网络或稍后重试" + return meta diff --git a/backend/app/periods.py b/backend/app/periods.py index 646586d..6e95b28 100644 --- a/backend/app/periods.py +++ b/backend/app/periods.py @@ -24,6 +24,14 @@ def get_yesterday_period(now: datetime | None = None) -> tuple[datetime, datetim return start, end +def get_daybefore_period(now: datetime | None = None) -> tuple[datetime, datetime]: + """前日周期 [D-2 08:00, D-1 08:00) in Shanghai time.""" + now = now or now_shanghai() + end = _align_cutoff(now) - timedelta(days=1) + start = end - timedelta(days=1) + return start, end + + def get_today_period(now: datetime | None = None) -> tuple[datetime, datetime]: """[D 08:00, now) in Shanghai time.""" now = now or now_shanghai() diff --git a/backend/app/scheduler.py b/backend/app/scheduler.py index 2b82385..21224b4 100644 --- a/backend/app/scheduler.py +++ b/backend/app/scheduler.py @@ -9,7 +9,7 @@ from .binance import binance_client from .config import settings from .db import get_latest_snapshot, init_db, log_push, save_snapshot, was_pushed_today from .exceptions import BinanceRateLimitedError -from .periods import get_today_period, get_yesterday_period, now_shanghai +from .periods import get_daybefore_period, get_today_period, get_yesterday_period, now_shanghai from .state import get_today_cache, set_today_cache from .funding_store import prefetch_funding from .kline_store import prefetch_symbols @@ -39,30 +39,46 @@ def _restore_today_from_db() -> bool: return False +async def _finalize_closed_period(period_type: str, start, end) -> list[dict] | None: + items = await aggregate_period( + start, end, use_live_prices=False, mode=settings.yesterday_data_mode + ) + save_snapshot(period_type, start, end, items) + logger.info("%s snapshot saved: %s ~ %s, %d items", period_type, start, end, len(items)) + return items + + async def job_finalize_yesterday() -> None: - logger.info("Job: finalize yesterday period") + logger.info("Job: finalize yesterday & daybefore") if binance_client.is_rate_limited(): logger.warning( - "Skip yesterday job — rate limited %ss", + "Skip finalize — rate limited %ss", binance_client.rate_limit_remaining_sec(), ) return try: binance_client.clear_symbol_cache() - start, end = get_yesterday_period() - items = await aggregate_period( - start, end, use_live_prices=False, mode=settings.yesterday_data_mode - ) - save_snapshot("yesterday", start, end, items) - logger.info("Yesterday snapshot saved: %d items", len(items)) - syms = [x["symbol"] for x in items if x.get("symbol")] - if syms: - await prefetch_symbols(syms) - await prefetch_funding(syms) + start_db, end_db = get_daybefore_period() + snap_db = get_latest_snapshot("daybefore") + if not snap_db or snap_db.get("period_end") != end_db.isoformat(): + items_db = await _finalize_closed_period("daybefore", start_db, end_db) + if items_db: + syms = [x["symbol"] for x in items_db if x.get("symbol")] + if syms: + await prefetch_symbols(syms) + await prefetch_funding(syms) + + start_y, end_y = get_yesterday_period() + items_y = await _finalize_closed_period("yesterday", start_y, end_y) + if items_y: + syms = [x["symbol"] for x in items_y if x.get("symbol")] + if syms: + await prefetch_symbols(syms) + await prefetch_funding(syms) except BinanceRateLimitedError as e: - logger.error("Finalize yesterday rate limited %ss", e.retry_after_sec) + logger.error("Finalize rate limited %ss", e.retry_after_sec) except Exception as e: - logger.error("Finalize yesterday failed: %s", e) + logger.error("Finalize failed: %s", e) async def job_push_wecom() -> None: @@ -144,14 +160,22 @@ async def startup_tasks() -> None: _restore_today_from_db() return + start_db, end_db = get_daybefore_period(now) + snap_db = get_latest_snapshot("daybefore") + if not snap_db or snap_db.get("period_end") != end_db.isoformat(): + try: + logger.info("Startup: computing daybefore snapshot") + await _finalize_closed_period("daybefore", start_db, end_db) + except BinanceRateLimitedError as e: + logger.error("Startup daybefore rate limited %ss", e.retry_after_sec) + except Exception as e: + logger.error("Startup daybefore failed: %s", e) + snap = get_latest_snapshot("yesterday") if not snap or snap.get("period_end") != end_y.isoformat(): try: logger.info("Startup: computing yesterday snapshot") - items = await aggregate_period( - start_y, end_y, use_live_prices=False, mode=settings.yesterday_data_mode - ) - save_snapshot("yesterday", start_y, end_y, items) + await _finalize_closed_period("yesterday", start_y, end_y) except BinanceRateLimitedError as e: logger.error("Startup yesterday rate limited %ss", e.retry_after_sec) except Exception as e: diff --git a/backend/app/stats.py b/backend/app/stats.py new file mode 100644 index 0000000..8176460 --- /dev/null +++ b/backend/app/stats.py @@ -0,0 +1,131 @@ +"""三日数据统计:连续三日 Top30 且 |涨跌|>=5%。""" + +from typing import Any + +from .config import settings +from .db import get_latest_snapshot + + +def _items_by_symbol(items: list[dict]) -> dict[str, dict]: + return {x["symbol"]: x for x in items if x.get("symbol")} + + +def compute_three_day_stats() -> dict[str, Any]: + today_snap = get_latest_snapshot("today") + yesterday_snap = get_latest_snapshot("yesterday") + daybefore_snap = get_latest_snapshot("daybefore") + + threshold = settings.change_threshold + top_n = settings.top_n + + missing = [] + if not today_snap: + missing.append("今日") + if not yesterday_snap: + missing.append("昨日") + if not daybefore_snap: + missing.append("前日") + + if missing: + return { + "ok": False, + "missing_periods": missing, + "message": f"缺少快照:{', '.join(missing)},请等待刷新或手动触发", + "criteria": _criteria_text(threshold, top_n), + "count": 0, + "items": [], + "periods": _period_meta(today_snap, yesterday_snap, daybefore_snap), + } + + today_map = _items_by_symbol(today_snap["items"]) + yesterday_map = _items_by_symbol(yesterday_snap["items"]) + daybefore_map = _items_by_symbol(daybefore_snap["items"]) + + symbols = set(today_map) & set(yesterday_map) & set(daybefore_map) + qualified: list[dict] = [] + + for sym in sorted(symbols): + t, y, b = today_map[sym], yesterday_map[sym], daybefore_map[sym] + if not ( + abs(t.get("price_change_pct", 0)) >= threshold + and abs(y.get("price_change_pct", 0)) >= threshold + and abs(b.get("price_change_pct", 0)) >= threshold + ): + continue + qualified.append( + { + "symbol": sym, + "today": _pick_fields(t), + "yesterday": _pick_fields(y), + "daybefore": _pick_fields(b), + "avg_change_pct": round( + ( + t.get("price_change_pct", 0) + + y.get("price_change_pct", 0) + + b.get("price_change_pct", 0) + ) + / 3, + 4, + ), + "total_quote_volume": ( + (t.get("quote_volume") or 0) + + (y.get("quote_volume") or 0) + + (b.get("quote_volume") or 0) + ), + } + ) + + qualified.sort(key=lambda x: x["total_quote_volume"], reverse=True) + + return { + "ok": True, + "criteria": _criteria_text(threshold, top_n), + "count": len(qualified), + "items": qualified, + "periods": _period_meta(today_snap, yesterday_snap, daybefore_snap), + "summary": { + "today_top30": len(today_map), + "yesterday_top30": len(yesterday_map), + "daybefore_top30": len(daybefore_map), + "intersection": len(symbols), + }, + } + + +def _criteria_text(threshold: float, top_n: int) -> str: + return ( + f"连续三日成交额 Top{top_n} 且每日 |涨跌幅| ≥ {threshold:g}%" + f"(今日/昨日/前日三个完整切日周期)" + ) + + +def _pick_fields(row: dict) -> dict: + return { + "rank": row.get("rank"), + "quote_volume": row.get("quote_volume"), + "quote_volume_fmt": row.get("quote_volume_fmt"), + "price_change_pct": row.get("price_change_pct"), + "price_change_pct_fmt": row.get("price_change_pct_fmt"), + "funding_rate_fmt": row.get("funding_rate_fmt"), + "is_high_volume": row.get("is_high_volume"), + "is_high_change": row.get("is_high_change"), + } + + +def _period_meta(today, yesterday, daybefore) -> dict: + def one(snap, label): + if not snap: + return {"label": label, "ready": False} + return { + "label": label, + "ready": True, + "period_start": snap["period_start"], + "period_end": snap["period_end"], + "updated_at": snap.get("created_at"), + } + + return { + "today": one(today, "今日"), + "yesterday": one(yesterday, "昨日"), + "daybefore": one(daybefore, "前日"), + } diff --git a/web/app.js b/web/app.js index 223a656..e85ca03 100644 --- a/web/app.js +++ b/web/app.js @@ -1,55 +1,64 @@ const REFRESH_MS = 60_000; -const tableState = { - yesterday: { - items: [], - meta: {}, - sortKey: "rank", - sortDir: "asc", - }, - today: { - items: [], - meta: {}, - sortKey: "rank", - sortDir: "asc", - }, +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" }, +}; + +let statsData = null; +let currentView = "today"; + 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, - tags: (r) => { - let score = 0; - if (r.is_high_volume) score += 2; - if (r.is_high_change) score += 1; - return score; - }, 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); + const fmt = (s) => (s || "").replace("T", " ").slice(0, 16); return `${fmt(start)} ~ ${fmt(end)}`; } function tagText(row) { - const tags = []; - if (row.is_high_volume) tags.push("千万+"); - if (row.is_high_change) tags.push("涨跌5%+"); - return tags.join(" ") || ""; + const t = []; + if (row.is_high_volume) t.push("千万+"); + if (row.is_high_change) t.push("涨跌5%+"); + return t.join(" ") || ""; } function renderTags(row) { - const parts = []; - if (row.is_high_volume) { - parts.push('千万+'); - } - if (row.is_high_change) { - parts.push('涨跌5%+'); - } - return parts.length ? parts.join("") : "—"; + 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) { @@ -58,9 +67,9 @@ function pctClass(pct) { return ""; } -function sortItems(items, key, dir) { - const getter = SORT_KEYS[key] || SORT_KEYS.rank; - const sorted = [...items].sort((a, b) => { +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") { @@ -68,58 +77,72 @@ function sortItems(items, key, dir) { } return dir === "asc" ? va - vb : vb - va; }); - return sorted; +} + +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]; + const { sortKey, sortDir } = tableState[tableId] || { sortKey: "rank", sortDir: "asc" }; table.querySelectorAll("th.sortable").forEach((th) => { th.classList.remove("sorted-asc", "sorted-desc"); - const key = th.dataset.sort; - if (key === sortKey) { + if (th.dataset.sort === sortKey) { th.classList.add(sortDir === "asc" ? "sorted-asc" : "sorted-desc"); } }); } -function renderTable(tableId, tbody) { - const state = tableState[tableId]; +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(tableId); + updateSortHeaders(periodId); return; } tbody.innerHTML = items .map((row, idx) => { - const highlight = - row.is_high_volume || row.is_high_change ? " row-highlight" : ""; + const hl = row.is_high_volume || row.is_high_change ? " row-highlight" : ""; const pct = row.price_change_pct ?? 0; - const displayRank = - state.sortKey === "rank" && state.sortDir === "asc" - ? row.rank - : idx + 1; - return ` - ${displayRank} + 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)} @@ -127,145 +150,228 @@ function renderTable(tableId, tbody) { }) .join(""); - updateSortHeaders(tableId); - enqueueCharts(tbody); - if (typeof enqueueFundingCharts === "function") enqueueFundingCharts(tbody); + updateSortHeaders(periodId); + if (currentView === periodId) { + enqueueCharts(tbody); + if (typeof enqueueFundingCharts === "function") enqueueFundingCharts(tbody); + } } -function setTableData(tableId, data) { - tableState[tableId].items = data.items || []; - tableState[tableId].meta = { +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 tbody = document.getElementById(`${tableId}-body`); - renderTable(tableId, tbody); + 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); +} + +async function loadPeriod(periodId) { + const tbody = ensurePeriodTable(periodId); + if (tbody) tbody.innerHTML = '加载中…'; + try { + const res = await fetch(PERIOD_API[periodId]); + const data = await res.json(); + setPeriodData(periodId, data); + if (periodId === "today") { + document.getElementById("status").textContent = "今日数据已刷新"; + } + } catch (e) { + if (tbody) tbody.innerHTML = `${e.message}`; + } } function toggleSort(tableId, key) { - const state = tableState[tableId]; - if (state.sortKey === key) { - state.sortDir = state.sortDir === "asc" ? "desc" : "asc"; - } else { - state.sortKey = key; - state.sortDir = key === "symbol" ? "asc" : "desc"; + 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"; } - const tbody = document.getElementById(`${tableId}-body`); - renderTable(tableId, tbody); + renderPeriodTable(tableId); } function resetSort(tableId) { tableState[tableId].sortKey = "rank"; tableState[tableId].sortDir = "asc"; - const tbody = document.getElementById(`${tableId}-body`); - renderTable(tableId, tbody); + renderPeriodTable(tableId); } -function exportCsv(tableId) { - const state = tableState[tableId]; - if (!state.items.length) { - alert("暂无数据可导出"); - return; - } +function exportPeriodCsv(periodId) { + const state = tableState[periodId]; + if (!state.items.length) return alert("暂无数据"); const items = sortItems(state.items, state.sortKey, state.sortDir); - const header = [ - "排名", - "合约", - "成交额显示", - "成交额USDT", - "涨跌幅%", - "资金费率%", - "千万+", - "涨跌5%+", - "标记", - ]; + const header = ["排名", "合约", "成交额", "涨跌幅%", "资金费率%", "标记"]; const rows = items.map((r, i) => [ state.sortKey === "rank" && state.sortDir === "asc" ? r.rank : i + 1, r.symbol, - r.quote_volume_fmt || "", r.quote_volume ?? "", r.price_change_pct ?? "", r.funding_rate_pct ?? "", - r.is_high_volume ? "是" : "否", - r.is_high_change ? "是" : "否", tagText(r), ]); - - const escape = (v) => `"${String(v).replace(/"/g, '""')}"`; - const csv = [header, ...rows].map((row) => row.map(escape).join(",")).join("\n"); - const blob = new Blob(["\ufeff" + csv], { type: "text/csv;charset=utf-8" }); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - const period = state.meta.period_start - ? state.meta.period_start.slice(0, 10) - : tableId; - a.href = url; - a.download = `binance-top30-${tableId}-${period}.csv`; - a.click(); - URL.revokeObjectURL(url); + downloadCsv(`binance-${periodId}`, header, rows, state.meta.period_start); } -document.querySelectorAll("th.sortable").forEach((th) => { - th.addEventListener("click", () => { - const table = th.closest("table"); - const tableId = table?.dataset.table; - const key = th.dataset.sort; - if (tableId && key) toggleSort(tableId, key); +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 = ` + + + + + + + + + +
合约今日排名今日涨跌今日成交额昨日排名昨日涨跌昨日成交额前日排名前日涨跌前日成交额三日总成交额
`; + + 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 || "—"}`; + }; + 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)} + `; + }) + .join(""); +} + +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 loadStats() { + document.getElementById("stats-table-wrap").innerHTML = + '

统计中…

'; + try { + const res = await fetch("/api/stats/three-day"); + statsData = await res.json(); + renderStatsTable(); + } 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(); + 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", () => exportCsv(btn.dataset.export)); + btn.addEventListener("click", () => exportPeriodCsv(btn.dataset.export)); }); - document.querySelectorAll("[data-reset]").forEach((btn) => { btn.addEventListener("click", () => resetSort(btn.dataset.reset)); }); -async function loadYesterday() { - const body = document.getElementById("yesterday-body"); - body.innerHTML = '加载中…'; - try { - const res = await fetch("/api/yesterday/top30"); - const data = await res.json(); - document.getElementById("yesterday-period").textContent = formatPeriod( - data.period_start, - data.period_end - ); - document.getElementById("yesterday-updated").textContent = - "更新: " + (data.updated_at || "").replace("T", " ").slice(0, 19); - setTableData("yesterday", data); - } catch (e) { - body.innerHTML = `加载失败: ${e.message}`; - } -} - -async function loadToday() { - const body = document.getElementById("today-body"); - try { - const res = await fetch("/api/today/top30"); - const data = await res.json(); - document.getElementById("today-period").textContent = formatPeriod( - data.period_start, - data.period_end - ); - document.getElementById("today-updated").textContent = - "更新: " + (data.updated_at || "").replace("T", " ").slice(0, 19); - setTableData("today", data); - document.getElementById("status").textContent = "今日数据已刷新"; - } catch (e) { - body.innerHTML = `加载失败: ${e.message}`; - document.getElementById("status").textContent = e.message; - } -} - document.getElementById("btn-refresh").addEventListener("click", async () => { document.getElementById("status").textContent = "刷新中…"; await fetch("/api/refresh/today", { method: "POST" }); - await loadToday(); + await loadPeriod("today"); + if (currentView === "stats") await loadStats(); }); -loadYesterday(); -loadToday(); -setInterval(loadToday, REFRESH_MS); +document.getElementById("btn-reload-stats")?.addEventListener("click", () => { + statsData = null; + loadStats(); +}); +document.getElementById("btn-export-stats")?.addEventListener("click", exportStatsCsv); + +loadPeriod("today"); +loadPeriod("yesterday"); +loadPeriod("daybefore"); + +setInterval(() => { + if (currentView === "today") loadPeriod("today"); +}, REFRESH_MS); diff --git a/web/index.html b/web/index.html index db985fd..550f8ba 100644 --- a/web/index.html +++ b/web/index.html @@ -7,66 +7,78 @@ -
+ -
-
-

昨日周期

- - -
- - -
-
-
- - - - - - - - - - - - - -
排名合约日线图成交额 (USDT)涨跌幅资金费率标记
-
-
+ -
-
-

今日周期 实时

- - -
- - +
+
+
+

今日周期 实时

+ + +
+ + +
-
-
- - - - - - - - - - - - - -
排名合约日线图成交额 (USDT)涨跌幅资金费率标记
-
-
+
+ + + +
+
+
+

昨日周期

+ + +
+ + +
+
+
+
+
+ +
+
+
+

前日周期

+ + +
+ + +
+
+
+
+
+ +
+
+
+

数据统计

+ + +
+ + +
+
+

+
+
+