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 = `
+
+ `;
+
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 = `排名
+ 合约
+ 日线图
+ 成交额 (USDT)
+ 涨跌幅
+ 资金费率
+ 标记
+
${statsData.message || "请等待三个周期数据就绪"}
`; + return; + } + + if (!items.length) { + wrap.innerHTML = '暂无符合条件的合约
'; + return; + } + + 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 = 'Top30 · 日K+成交量 · 资金费率当前+历史曲线 · 点击图表放大
+北京时间 08:00 切日 · Top30 · 日K+成交量+资金费率
| 排名 | -合约 | -日线图 | -成交额 (USDT) | -涨跌幅 | -资金费率 | -标记 | -
|---|
| 排名 | -合约 | -日线图 | -成交额 (USDT) | -涨跌幅 | -资金费率 | -标记 | -
|---|