增加排名
This commit is contained in:
+35
-41
@@ -6,16 +6,18 @@ from fastapi import FastAPI, HTTPException
|
|||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
from .aggregator import aggregate_period, enrich_snapshot_meta
|
|
||||||
from .config import ROOT_DIR, settings
|
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 .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 .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 .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 .wecom import build_markdown, send_wecom_markdown
|
||||||
|
from .state import get_today_cache
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=logging.INFO,
|
level=logging.INFO,
|
||||||
@@ -59,48 +61,34 @@ async def index():
|
|||||||
return {"message": "Web UI not found. Place files in /web"}
|
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")
|
@app.get("/api/today/top30")
|
||||||
async def api_today_top30():
|
async def api_today_top30():
|
||||||
|
from .state import get_today_cache
|
||||||
|
|
||||||
cached = get_today_cache()
|
cached = get_today_cache()
|
||||||
if cached:
|
if cached:
|
||||||
|
from .funding_store import enrich_items_with_funding
|
||||||
|
|
||||||
cached["items"] = await enrich_items_with_funding(cached.get("items", []))
|
cached["items"] = await enrich_items_with_funding(cached.get("items", []))
|
||||||
return cached
|
return cached
|
||||||
start, end = get_today_period()
|
return await get_period_top30(
|
||||||
try:
|
"today", get_today_period, use_live_prices=True, data_mode=settings.today_data_mode
|
||||||
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:
|
@app.get("/api/yesterday/top30")
|
||||||
logger.error("api today failed: %s", e)
|
async def api_yesterday_top30():
|
||||||
meta = enrich_snapshot_meta([], start, end)
|
return await get_period_top30("yesterday", get_yesterday_period)
|
||||||
meta["error"] = "数据暂不可用,请检查网络或稍后重试"
|
|
||||||
return meta
|
|
||||||
|
@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")
|
@app.post("/api/push/test")
|
||||||
@@ -135,6 +123,12 @@ async def api_refresh_today():
|
|||||||
return get_today_cache() or {"message": "done"}
|
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")
|
@app.get("/api/chart/{symbol}/daily")
|
||||||
async def api_chart_daily(symbol: str, limit: int | None = None, refresh: bool = False):
|
async def api_chart_daily(symbol: str, limit: int | None = None, refresh: bool = False):
|
||||||
"""合约日 K 线:优先读本地 SQLite,过期再拉币安入库。"""
|
"""合约日 K 线:优先读本地 SQLite,过期再拉币安入库。"""
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -24,6 +24,14 @@ def get_yesterday_period(now: datetime | None = None) -> tuple[datetime, datetim
|
|||||||
return start, end
|
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]:
|
def get_today_period(now: datetime | None = None) -> tuple[datetime, datetime]:
|
||||||
"""[D 08:00, now) in Shanghai time."""
|
"""[D 08:00, now) in Shanghai time."""
|
||||||
now = now or now_shanghai()
|
now = now or now_shanghai()
|
||||||
|
|||||||
+40
-16
@@ -9,7 +9,7 @@ from .binance import binance_client
|
|||||||
from .config import settings
|
from .config import settings
|
||||||
from .db import get_latest_snapshot, init_db, log_push, save_snapshot, was_pushed_today
|
from .db import get_latest_snapshot, init_db, log_push, save_snapshot, was_pushed_today
|
||||||
from .exceptions import BinanceRateLimitedError
|
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 .state import get_today_cache, set_today_cache
|
||||||
from .funding_store import prefetch_funding
|
from .funding_store import prefetch_funding
|
||||||
from .kline_store import prefetch_symbols
|
from .kline_store import prefetch_symbols
|
||||||
@@ -39,30 +39,46 @@ def _restore_today_from_db() -> bool:
|
|||||||
return False
|
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:
|
async def job_finalize_yesterday() -> None:
|
||||||
logger.info("Job: finalize yesterday period")
|
logger.info("Job: finalize yesterday & daybefore")
|
||||||
if binance_client.is_rate_limited():
|
if binance_client.is_rate_limited():
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Skip yesterday job — rate limited %ss",
|
"Skip finalize — rate limited %ss",
|
||||||
binance_client.rate_limit_remaining_sec(),
|
binance_client.rate_limit_remaining_sec(),
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
binance_client.clear_symbol_cache()
|
binance_client.clear_symbol_cache()
|
||||||
start, end = get_yesterday_period()
|
start_db, end_db = get_daybefore_period()
|
||||||
items = await aggregate_period(
|
snap_db = get_latest_snapshot("daybefore")
|
||||||
start, end, use_live_prices=False, mode=settings.yesterday_data_mode
|
if not snap_db or snap_db.get("period_end") != end_db.isoformat():
|
||||||
)
|
items_db = await _finalize_closed_period("daybefore", start_db, end_db)
|
||||||
save_snapshot("yesterday", start, end, items)
|
if items_db:
|
||||||
logger.info("Yesterday snapshot saved: %d items", len(items))
|
syms = [x["symbol"] for x in items_db if x.get("symbol")]
|
||||||
syms = [x["symbol"] for x in items 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:
|
if syms:
|
||||||
await prefetch_symbols(syms)
|
await prefetch_symbols(syms)
|
||||||
await prefetch_funding(syms)
|
await prefetch_funding(syms)
|
||||||
except BinanceRateLimitedError as e:
|
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:
|
except Exception as e:
|
||||||
logger.error("Finalize yesterday failed: %s", e)
|
logger.error("Finalize failed: %s", e)
|
||||||
|
|
||||||
|
|
||||||
async def job_push_wecom() -> None:
|
async def job_push_wecom() -> None:
|
||||||
@@ -144,14 +160,22 @@ async def startup_tasks() -> None:
|
|||||||
_restore_today_from_db()
|
_restore_today_from_db()
|
||||||
return
|
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")
|
snap = get_latest_snapshot("yesterday")
|
||||||
if not snap or snap.get("period_end") != end_y.isoformat():
|
if not snap or snap.get("period_end") != end_y.isoformat():
|
||||||
try:
|
try:
|
||||||
logger.info("Startup: computing yesterday snapshot")
|
logger.info("Startup: computing yesterday snapshot")
|
||||||
items = await aggregate_period(
|
await _finalize_closed_period("yesterday", start_y, end_y)
|
||||||
start_y, end_y, use_live_prices=False, mode=settings.yesterday_data_mode
|
|
||||||
)
|
|
||||||
save_snapshot("yesterday", start_y, end_y, items)
|
|
||||||
except BinanceRateLimitedError as e:
|
except BinanceRateLimitedError as e:
|
||||||
logger.error("Startup yesterday rate limited %ss", e.retry_after_sec)
|
logger.error("Startup yesterday rate limited %ss", e.retry_after_sec)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -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, "前日"),
|
||||||
|
}
|
||||||
+261
-155
@@ -1,55 +1,64 @@
|
|||||||
const REFRESH_MS = 60_000;
|
const REFRESH_MS = 60_000;
|
||||||
|
|
||||||
const tableState = {
|
const PERIOD_API = {
|
||||||
yesterday: {
|
today: "/api/today/top30",
|
||||||
items: [],
|
yesterday: "/api/yesterday/top30",
|
||||||
meta: {},
|
daybefore: "/api/daybefore/top30",
|
||||||
sortKey: "rank",
|
|
||||||
sortDir: "asc",
|
|
||||||
},
|
|
||||||
today: {
|
|
||||||
items: [],
|
|
||||||
meta: {},
|
|
||||||
sortKey: "rank",
|
|
||||||
sortDir: "asc",
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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 = {
|
const SORT_KEYS = {
|
||||||
rank: (r) => Number(r.rank) || 0,
|
rank: (r) => Number(r.rank) || 0,
|
||||||
symbol: (r) => String(r.symbol || ""),
|
symbol: (r) => String(r.symbol || ""),
|
||||||
quote_volume: (r) => Number(r.quote_volume) || 0,
|
quote_volume: (r) => Number(r.quote_volume) || 0,
|
||||||
price_change_pct: (r) => Number(r.price_change_pct) || 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,
|
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 = `
|
||||||
|
<thead><tr>
|
||||||
|
<th class="sortable" data-sort="rank">排名</th>
|
||||||
|
<th class="sortable" data-sort="symbol">合约</th>
|
||||||
|
<th class="chart-col">日线图</th>
|
||||||
|
<th class="sortable" data-sort="quote_volume">成交额 (USDT)</th>
|
||||||
|
<th class="sortable" data-sort="price_change_pct">涨跌幅</th>
|
||||||
|
<th class="funding-col">资金费率</th>
|
||||||
|
<th class="sortable" data-sort="tags">标记</th>
|
||||||
|
</tr></thead>`;
|
||||||
|
|
||||||
function formatPeriod(start, end) {
|
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)}`;
|
return `${fmt(start)} ~ ${fmt(end)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function tagText(row) {
|
function tagText(row) {
|
||||||
const tags = [];
|
const t = [];
|
||||||
if (row.is_high_volume) tags.push("千万+");
|
if (row.is_high_volume) t.push("千万+");
|
||||||
if (row.is_high_change) tags.push("涨跌5%+");
|
if (row.is_high_change) t.push("涨跌5%+");
|
||||||
return tags.join(" ") || "";
|
return t.join(" ") || "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderTags(row) {
|
function renderTags(row) {
|
||||||
const parts = [];
|
const p = [];
|
||||||
if (row.is_high_volume) {
|
if (row.is_high_volume) p.push('<span class="tag tag-vol">千万+</span>');
|
||||||
parts.push('<span class="tag tag-vol">千万+</span>');
|
if (row.is_high_change) p.push('<span class="tag tag-chg">涨跌5%+</span>');
|
||||||
}
|
return p.length ? p.join("") : "—";
|
||||||
if (row.is_high_change) {
|
|
||||||
parts.push('<span class="tag tag-chg">涨跌5%+</span>');
|
|
||||||
}
|
|
||||||
return parts.length ? parts.join("") : "—";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function pctClass(pct) {
|
function pctClass(pct) {
|
||||||
@@ -58,9 +67,9 @@ function pctClass(pct) {
|
|||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function sortItems(items, key, dir) {
|
function sortItems(items, key, dir, customKeys) {
|
||||||
const getter = SORT_KEYS[key] || SORT_KEYS.rank;
|
const getter = (customKeys || SORT_KEYS)[key] || SORT_KEYS.rank;
|
||||||
const sorted = [...items].sort((a, b) => {
|
return [...items].sort((a, b) => {
|
||||||
const va = getter(a);
|
const va = getter(a);
|
||||||
const vb = getter(b);
|
const vb = getter(b);
|
||||||
if (typeof va === "string") {
|
if (typeof va === "string") {
|
||||||
@@ -68,58 +77,72 @@ function sortItems(items, key, dir) {
|
|||||||
}
|
}
|
||||||
return dir === "asc" ? va - vb : vb - va;
|
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 data-table="${periodId}">${TABLE_HEADER}<tbody id="${periodId}-body"></tbody></table>`;
|
||||||
|
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) {
|
function updateSortHeaders(tableId) {
|
||||||
const table = document.querySelector(`table[data-table="${tableId}"]`);
|
const table = document.querySelector(`table[data-table="${tableId}"]`);
|
||||||
if (!table) return;
|
if (!table) return;
|
||||||
const { sortKey, sortDir } = tableState[tableId];
|
const { sortKey, sortDir } = tableState[tableId] || { sortKey: "rank", sortDir: "asc" };
|
||||||
table.querySelectorAll("th.sortable").forEach((th) => {
|
table.querySelectorAll("th.sortable").forEach((th) => {
|
||||||
th.classList.remove("sorted-asc", "sorted-desc");
|
th.classList.remove("sorted-asc", "sorted-desc");
|
||||||
const key = th.dataset.sort;
|
if (th.dataset.sort === sortKey) {
|
||||||
if (key === sortKey) {
|
|
||||||
th.classList.add(sortDir === "asc" ? "sorted-asc" : "sorted-desc");
|
th.classList.add(sortDir === "asc" ? "sorted-asc" : "sorted-desc");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderTable(tableId, tbody) {
|
function renderPeriodTable(periodId) {
|
||||||
const state = tableState[tableId];
|
const tbody = ensurePeriodTable(periodId);
|
||||||
|
if (!tbody) return;
|
||||||
|
const state = tableState[periodId];
|
||||||
const items = sortItems(state.items, state.sortKey, state.sortDir);
|
const items = sortItems(state.items, state.sortKey, state.sortDir);
|
||||||
|
|
||||||
if (!items.length) {
|
if (!items.length) {
|
||||||
tbody.innerHTML = '<tr><td colspan="7" class="loading">暂无数据</td></tr>';
|
tbody.innerHTML = '<tr><td colspan="7" class="loading">暂无数据</td></tr>';
|
||||||
updateSortHeaders(tableId);
|
updateSortHeaders(periodId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
tbody.innerHTML = items
|
tbody.innerHTML = items
|
||||||
.map((row, idx) => {
|
.map((row, idx) => {
|
||||||
const highlight =
|
const hl = row.is_high_volume || row.is_high_change ? " row-highlight" : "";
|
||||||
row.is_high_volume || row.is_high_change ? " row-highlight" : "";
|
|
||||||
const pct = row.price_change_pct ?? 0;
|
const pct = row.price_change_pct ?? 0;
|
||||||
const displayRank =
|
const rank =
|
||||||
state.sortKey === "rank" && state.sortDir === "asc"
|
state.sortKey === "rank" && state.sortDir === "asc" ? row.rank : idx + 1;
|
||||||
? row.rank
|
return `<tr class="${hl}">
|
||||||
: idx + 1;
|
<td class="rank">${rank}</td>
|
||||||
return `<tr class="${highlight}">
|
|
||||||
<td class="rank">${displayRank}</td>
|
|
||||||
<td class="symbol-cell"><strong>${row.symbol}</strong></td>
|
<td class="symbol-cell"><strong>${row.symbol}</strong></td>
|
||||||
<td class="chart-cell">
|
<td class="chart-cell">
|
||||||
<div class="mini-chart" data-symbol="${row.symbol}">
|
<div class="mini-chart" data-symbol="${row.symbol}"><canvas></canvas><span class="chart-status"></span></div>
|
||||||
<canvas></canvas>
|
|
||||||
<span class="chart-status"></span>
|
|
||||||
</div>
|
|
||||||
</td>
|
</td>
|
||||||
<td data-value="${row.quote_volume ?? 0}">${row.quote_volume_fmt || row.quote_volume}</td>
|
<td data-value="${row.quote_volume ?? 0}">${row.quote_volume_fmt || row.quote_volume}</td>
|
||||||
<td class="${pctClass(pct)}" data-value="${pct}">${row.price_change_pct_fmt || pct.toFixed(2) + "%"}</td>
|
<td class="${pctClass(pct)}" data-value="${pct}">${row.price_change_pct_fmt || pct.toFixed(2) + "%"}</td>
|
||||||
<td class="funding-cell" data-value="${row.funding_rate_pct ?? 0}">
|
<td class="funding-cell" data-value="${row.funding_rate_pct ?? 0}">
|
||||||
<div class="funding-cell-inner">
|
<div class="funding-cell-inner">
|
||||||
<span class="funding-rate-label ${pctClass(row.funding_rate_pct ?? 0)}">${row.funding_rate_fmt || "—"}</span>
|
<span class="funding-rate-label ${pctClass(row.funding_rate_pct ?? 0)}">${row.funding_rate_fmt || "—"}</span>
|
||||||
<div class="mini-funding-chart" data-symbol="${row.symbol}">
|
<div class="mini-funding-chart" data-symbol="${row.symbol}"><canvas></canvas></div>
|
||||||
<canvas></canvas>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td data-value="${tagText(row)}">${renderTags(row)}</td>
|
<td data-value="${tagText(row)}">${renderTags(row)}</td>
|
||||||
@@ -127,145 +150,228 @@ function renderTable(tableId, tbody) {
|
|||||||
})
|
})
|
||||||
.join("");
|
.join("");
|
||||||
|
|
||||||
updateSortHeaders(tableId);
|
updateSortHeaders(periodId);
|
||||||
|
if (currentView === periodId) {
|
||||||
enqueueCharts(tbody);
|
enqueueCharts(tbody);
|
||||||
if (typeof enqueueFundingCharts === "function") enqueueFundingCharts(tbody);
|
if (typeof enqueueFundingCharts === "function") enqueueFundingCharts(tbody);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function setTableData(tableId, data) {
|
function setPeriodData(periodId, data) {
|
||||||
tableState[tableId].items = data.items || [];
|
tableState[periodId].items = data.items || [];
|
||||||
tableState[tableId].meta = {
|
tableState[periodId].meta = {
|
||||||
period_start: data.period_start,
|
period_start: data.period_start,
|
||||||
period_end: data.period_end,
|
period_end: data.period_end,
|
||||||
updated_at: data.updated_at,
|
updated_at: data.updated_at,
|
||||||
};
|
};
|
||||||
const tbody = document.getElementById(`${tableId}-body`);
|
const pe = document.getElementById(`${periodId}-period`);
|
||||||
renderTable(tableId, tbody);
|
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 = '<tr><td colspan="7" class="loading">加载中…</td></tr>';
|
||||||
|
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 = `<tr><td colspan="7" class="error">${e.message}</td></tr>`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleSort(tableId, key) {
|
function toggleSort(tableId, key) {
|
||||||
const state = tableState[tableId];
|
const s = tableState[tableId];
|
||||||
if (state.sortKey === key) {
|
if (s.sortKey === key) s.sortDir = s.sortDir === "asc" ? "desc" : "asc";
|
||||||
state.sortDir = state.sortDir === "asc" ? "desc" : "asc";
|
else {
|
||||||
} else {
|
s.sortKey = key;
|
||||||
state.sortKey = key;
|
s.sortDir = key === "symbol" ? "asc" : "desc";
|
||||||
state.sortDir = key === "symbol" ? "asc" : "desc";
|
|
||||||
}
|
}
|
||||||
const tbody = document.getElementById(`${tableId}-body`);
|
renderPeriodTable(tableId);
|
||||||
renderTable(tableId, tbody);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetSort(tableId) {
|
function resetSort(tableId) {
|
||||||
tableState[tableId].sortKey = "rank";
|
tableState[tableId].sortKey = "rank";
|
||||||
tableState[tableId].sortDir = "asc";
|
tableState[tableId].sortDir = "asc";
|
||||||
const tbody = document.getElementById(`${tableId}-body`);
|
renderPeriodTable(tableId);
|
||||||
renderTable(tableId, tbody);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function exportCsv(tableId) {
|
function exportPeriodCsv(periodId) {
|
||||||
const state = tableState[tableId];
|
const state = tableState[periodId];
|
||||||
if (!state.items.length) {
|
if (!state.items.length) return alert("暂无数据");
|
||||||
alert("暂无数据可导出");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const items = sortItems(state.items, state.sortKey, state.sortDir);
|
const items = sortItems(state.items, state.sortKey, state.sortDir);
|
||||||
const header = [
|
const header = ["排名", "合约", "成交额", "涨跌幅%", "资金费率%", "标记"];
|
||||||
"排名",
|
|
||||||
"合约",
|
|
||||||
"成交额显示",
|
|
||||||
"成交额USDT",
|
|
||||||
"涨跌幅%",
|
|
||||||
"资金费率%",
|
|
||||||
"千万+",
|
|
||||||
"涨跌5%+",
|
|
||||||
"标记",
|
|
||||||
];
|
|
||||||
const rows = items.map((r, i) => [
|
const rows = items.map((r, i) => [
|
||||||
state.sortKey === "rank" && state.sortDir === "asc" ? r.rank : i + 1,
|
state.sortKey === "rank" && state.sortDir === "asc" ? r.rank : i + 1,
|
||||||
r.symbol,
|
r.symbol,
|
||||||
r.quote_volume_fmt || "",
|
|
||||||
r.quote_volume ?? "",
|
r.quote_volume ?? "",
|
||||||
r.price_change_pct ?? "",
|
r.price_change_pct ?? "",
|
||||||
r.funding_rate_pct ?? "",
|
r.funding_rate_pct ?? "",
|
||||||
r.is_high_volume ? "是" : "否",
|
|
||||||
r.is_high_change ? "是" : "否",
|
|
||||||
tagText(r),
|
tagText(r),
|
||||||
]);
|
]);
|
||||||
|
downloadCsv(`binance-${periodId}`, header, rows, state.meta.period_start);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
document.querySelectorAll("th.sortable").forEach((th) => {
|
function downloadCsv(name, header, rows, periodStart) {
|
||||||
th.addEventListener("click", () => {
|
const esc = (v) => `"${String(v).replace(/"/g, '""')}"`;
|
||||||
const table = th.closest("table");
|
const csv = [header, ...rows].map((r) => r.map(esc).join(",")).join("\n");
|
||||||
const tableId = table?.dataset.table;
|
const blob = new Blob(["\ufeff" + csv], { type: "text/csv;charset=utf-8" });
|
||||||
const key = th.dataset.sort;
|
const a = document.createElement("a");
|
||||||
if (tableId && key) toggleSort(tableId, key);
|
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 = `<p class="loading">${statsData.message || "请等待三个周期数据就绪"}</p>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!items.length) {
|
||||||
|
wrap.innerHTML = '<p class="loading">暂无符合条件的合约</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
wrap.innerHTML = `
|
||||||
|
<table data-table="stats">
|
||||||
|
<thead><tr>
|
||||||
|
<th>合约</th>
|
||||||
|
<th>今日排名</th><th>今日涨跌</th><th>今日成交额</th>
|
||||||
|
<th>昨日排名</th><th>昨日涨跌</th><th>昨日成交额</th>
|
||||||
|
<th>前日排名</th><th>前日涨跌</th><th>前日成交额</th>
|
||||||
|
<th>三日总成交额</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody id="stats-body"></tbody>
|
||||||
|
</table>`;
|
||||||
|
|
||||||
|
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 `<td class="${f === "pct" ? pctClass(pct) : ""}">${f === "pct" ? x.price_change_pct_fmt || "—" : f === "rank" ? x.rank ?? "—" : x.quote_volume_fmt || "—"}</td>`;
|
||||||
|
};
|
||||||
|
return `<tr class="row-highlight">
|
||||||
|
<td><strong>${row.symbol}</strong></td>
|
||||||
|
${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")}
|
||||||
|
<td>${formatVol(row.total_quote_volume)}</td>
|
||||||
|
</tr>`;
|
||||||
|
})
|
||||||
|
.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 =
|
||||||
|
'<p class="loading">统计中…</p>';
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/stats/three-day");
|
||||||
|
statsData = await res.json();
|
||||||
|
renderStatsTable();
|
||||||
|
} catch (e) {
|
||||||
|
document.getElementById("stats-table-wrap").innerHTML = `<p class="error">${e.message}</p>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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) => {
|
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) => {
|
document.querySelectorAll("[data-reset]").forEach((btn) => {
|
||||||
btn.addEventListener("click", () => resetSort(btn.dataset.reset));
|
btn.addEventListener("click", () => resetSort(btn.dataset.reset));
|
||||||
});
|
});
|
||||||
|
|
||||||
async function loadYesterday() {
|
|
||||||
const body = document.getElementById("yesterday-body");
|
|
||||||
body.innerHTML = '<tr><td colspan="7" class="loading">加载中…</td></tr>';
|
|
||||||
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 = `<tr><td colspan="7" class="error">加载失败: ${e.message}</td></tr>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 = `<tr><td colspan="7" class="error">加载失败: ${e.message}</td></tr>`;
|
|
||||||
document.getElementById("status").textContent = e.message;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById("btn-refresh").addEventListener("click", async () => {
|
document.getElementById("btn-refresh").addEventListener("click", async () => {
|
||||||
document.getElementById("status").textContent = "刷新中…";
|
document.getElementById("status").textContent = "刷新中…";
|
||||||
await fetch("/api/refresh/today", { method: "POST" });
|
await fetch("/api/refresh/today", { method: "POST" });
|
||||||
await loadToday();
|
await loadPeriod("today");
|
||||||
|
if (currentView === "stats") await loadStats();
|
||||||
});
|
});
|
||||||
|
|
||||||
loadYesterday();
|
document.getElementById("btn-reload-stats")?.addEventListener("click", () => {
|
||||||
loadToday();
|
statsData = null;
|
||||||
setInterval(loadToday, REFRESH_MS);
|
loadStats();
|
||||||
|
});
|
||||||
|
document.getElementById("btn-export-stats")?.addEventListener("click", exportStatsCsv);
|
||||||
|
|
||||||
|
loadPeriod("today");
|
||||||
|
loadPeriod("yesterday");
|
||||||
|
loadPeriod("daybefore");
|
||||||
|
|
||||||
|
setInterval(() => {
|
||||||
|
if (currentView === "today") loadPeriod("today");
|
||||||
|
}, REFRESH_MS);
|
||||||
|
|||||||
+58
-46
@@ -7,40 +7,20 @@
|
|||||||
<link rel="stylesheet" href="/static/style.css" />
|
<link rel="stylesheet" href="/static/style.css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header>
|
<header class="site-header">
|
||||||
<h1>币安 U本位合约 · 成交额排名</h1>
|
<h1>币安 U本位合约 · 成交额排名</h1>
|
||||||
<p class="subtitle">Top30 · 日K+成交量 · 资金费率当前+历史曲线 · 点击图表放大</p>
|
<p class="subtitle">北京时间 08:00 切日 · Top30 · 日K+成交量+资金费率</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="panel" id="panel-yesterday">
|
<nav class="main-nav" id="main-nav">
|
||||||
<div class="panel-head">
|
<button type="button" class="nav-item active" data-view="today">今日周期</button>
|
||||||
<h2>昨日周期</h2>
|
<button type="button" class="nav-item" data-view="yesterday">昨日周期</button>
|
||||||
<span class="period" id="yesterday-period">—</span>
|
<button type="button" class="nav-item" data-view="daybefore">前日周期</button>
|
||||||
<span class="updated" id="yesterday-updated"></span>
|
<button type="button" class="nav-item" data-view="stats">数据统计</button>
|
||||||
<div class="panel-actions">
|
</nav>
|
||||||
<button type="button" class="btn-secondary" data-export="yesterday">导出 CSV</button>
|
|
||||||
<button type="button" class="btn-secondary" data-reset="yesterday">默认排序</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="table-wrap">
|
|
||||||
<table data-table="yesterday">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th class="sortable" data-sort="rank">排名</th>
|
|
||||||
<th class="sortable" data-sort="symbol">合约</th>
|
|
||||||
<th class="chart-col">日线图</th>
|
|
||||||
<th class="sortable" data-sort="quote_volume">成交额 (USDT)</th>
|
|
||||||
<th class="sortable" data-sort="price_change_pct">涨跌幅</th>
|
|
||||||
<th class="funding-col">资金费率</th>
|
|
||||||
<th class="sortable" data-sort="tags">标记</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody id="yesterday-body"></tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="panel" id="panel-today">
|
<main id="view-today" class="view-panel active">
|
||||||
|
<section class="panel">
|
||||||
<div class="panel-head">
|
<div class="panel-head">
|
||||||
<h2>今日周期 <span class="live">实时</span></h2>
|
<h2>今日周期 <span class="live">实时</span></h2>
|
||||||
<span class="period" id="today-period">—</span>
|
<span class="period" id="today-period">—</span>
|
||||||
@@ -50,23 +30,55 @@
|
|||||||
<button type="button" class="btn-secondary" data-reset="today">默认排序</button>
|
<button type="button" class="btn-secondary" data-reset="today">默认排序</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="table-wrap">
|
<div class="table-wrap" id="today-table-wrap"></div>
|
||||||
<table data-table="today">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th class="sortable" data-sort="rank">排名</th>
|
|
||||||
<th class="sortable" data-sort="symbol">合约</th>
|
|
||||||
<th class="chart-col">日线图</th>
|
|
||||||
<th class="sortable" data-sort="quote_volume">成交额 (USDT)</th>
|
|
||||||
<th class="sortable" data-sort="price_change_pct">涨跌幅</th>
|
|
||||||
<th class="funding-col">资金费率</th>
|
|
||||||
<th class="sortable" data-sort="tags">标记</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody id="today-body"></tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<main id="view-yesterday" class="view-panel">
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<h2>昨日周期</h2>
|
||||||
|
<span class="period" id="yesterday-period">—</span>
|
||||||
|
<span class="updated" id="yesterday-updated"></span>
|
||||||
|
<div class="panel-actions">
|
||||||
|
<button type="button" class="btn-secondary" data-export="yesterday">导出 CSV</button>
|
||||||
|
<button type="button" class="btn-secondary" data-reset="yesterday">默认排序</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap" id="yesterday-table-wrap"></div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<main id="view-daybefore" class="view-panel">
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<h2>前日周期</h2>
|
||||||
|
<span class="period" id="daybefore-period">—</span>
|
||||||
|
<span class="updated" id="daybefore-updated"></span>
|
||||||
|
<div class="panel-actions">
|
||||||
|
<button type="button" class="btn-secondary" data-export="daybefore">导出 CSV</button>
|
||||||
|
<button type="button" class="btn-secondary" data-reset="daybefore">默认排序</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap" id="daybefore-table-wrap"></div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<main id="view-stats" class="view-panel">
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<h2>数据统计</h2>
|
||||||
|
<span class="period" id="stats-criteria">—</span>
|
||||||
|
<span class="updated" id="stats-summary"></span>
|
||||||
|
<div class="panel-actions">
|
||||||
|
<button type="button" class="btn-secondary" id="btn-reload-stats">重新统计</button>
|
||||||
|
<button type="button" class="btn-secondary" id="btn-export-stats">导出 CSV</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="stats-desc" id="stats-desc"></p>
|
||||||
|
<div class="table-wrap" id="stats-table-wrap"></div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
<footer>
|
<footer>
|
||||||
<button type="button" id="btn-refresh">立即刷新今日</button>
|
<button type="button" id="btn-refresh">立即刷新今日</button>
|
||||||
|
|||||||
+57
-1
@@ -33,10 +33,66 @@ header h1 {
|
|||||||
|
|
||||||
.subtitle {
|
.subtitle {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
margin: 0 0 1.5rem;
|
margin: 0 0 0.75rem;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.main-nav {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
padding-bottom: 0.75rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--muted);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 0.45rem 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item:hover {
|
||||||
|
color: var(--text);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item.active {
|
||||||
|
background: rgba(240, 185, 11, 0.15);
|
||||||
|
color: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-panel {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-panel.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-desc {
|
||||||
|
padding: 0 1.25rem 0.75rem;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#stats-table-wrap table {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#stats-table-wrap th,
|
||||||
|
#stats-table-wrap td {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.panel {
|
.panel {
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
|
|||||||
Reference in New Issue
Block a user