feat: add LiveTradesCard - real-time agg trades display + /api/trades/latest endpoint

This commit is contained in:
root 2026-02-27 11:35:55 +00:00
parent 7e38b24fa8
commit bb187167bb
3 changed files with 151 additions and 1 deletions

View File

@ -440,7 +440,32 @@ async def get_trades_summary(
return {"symbol": symbol, "interval": interval, "count": len(result), "data": result} return {"symbol": symbol, "interval": interval, "count": len(result), "data": result}
@app.get("/api/collector/health") @app.get("/api/trades/latest")
async def get_trades_latest(
symbol: str = "BTC",
limit: int = 30,
user: dict = Depends(get_current_user),
):
"""查最新N条原始成交记录从本地DB实时刷新用"""
sym_full = symbol.upper() + "USDT"
now_month = _dt.datetime.now(_dt.timezone.utc).strftime("%Y%m")
tname = f"agg_trades_{now_month}"
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
try:
rows = conn.execute(
f"SELECT agg_id, price, qty, time_ms, is_buyer_maker FROM {tname} "
f"WHERE symbol = ? ORDER BY agg_id DESC LIMIT ?",
(sym_full, limit)
).fetchall()
except Exception:
rows = []
conn.close()
return {
"symbol": symbol,
"count": len(rows),
"data": [dict(r) for r in rows],
}
async def collector_health(user: dict = Depends(get_current_user)): async def collector_health(user: dict = Depends(get_current_user)):
"""采集器健康状态""" """采集器健康状态"""
conn = sqlite3.connect(DB_PATH) conn = sqlite3.connect(DB_PATH)

View File

@ -6,6 +6,7 @@ import { api, RatesResponse, StatsResponse, HistoryResponse, HistoryPoint, Signa
import { useAuth } from "@/lib/auth"; import { useAuth } from "@/lib/auth";
import RateCard from "@/components/RateCard"; import RateCard from "@/components/RateCard";
import StatsCard from "@/components/StatsCard"; import StatsCard from "@/components/StatsCard";
import LiveTradesCard from "@/components/LiveTradesCard";
import Link from "next/link"; import Link from "next/link";
import { import {
LineChart, Line, XAxis, YAxis, Tooltip, Legend, LineChart, Line, XAxis, YAxis, Tooltip, Legend,
@ -185,6 +186,12 @@ export default function Dashboard() {
{/* 统计卡片 */} {/* 统计卡片 */}
<AuthGate> <AuthGate>
{/* 实时成交流 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<LiveTradesCard symbol="BTC" />
<LiveTradesCard symbol="ETH" />
</div>
{stats && ( {stats && (
<div className="grid grid-cols-3 gap-3"> <div className="grid grid-cols-3 gap-3">
<StatsCard title="BTC 套利" mean7d={stats.BTC.mean7d} annualized={stats.BTC.annualized} accent="blue" /> <StatsCard title="BTC 套利" mean7d={stats.BTC.mean7d} annualized={stats.BTC.annualized} accent="blue" />

View File

@ -0,0 +1,118 @@
"use client";
import { useEffect, useState } from "react";
import { authFetch } from "@/lib/auth";
interface TradeRow {
time_ms: number;
price: number;
qty: number;
is_buyer_maker: number; // 0=主动买, 1=主动卖
agg_id: number;
}
interface TradesResponse {
symbol: string;
count: number;
data: TradeRow[];
}
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? "";
async function fetchLatestTrades(symbol: string): Promise<TradeRow[]> {
// 查最近5秒的原始trades
const end_ms = Date.now();
const start_ms = end_ms - 5000;
const res = await authFetch(
`/api/trades/latest?symbol=${symbol}&limit=30`,
{ cache: "no-store" }
);
if (!res.ok) return [];
const data: TradesResponse = await res.json();
return data.data || [];
}
function timeStr(ms: number): string {
const d = new Date(ms + 8 * 3600 * 1000);
return `${String(d.getUTCHours()).padStart(2,"0")}:${String(d.getUTCMinutes()).padStart(2,"0")}:${String(d.getUTCSeconds()).padStart(2,"0")}`;
}
export default function LiveTradesCard({ symbol }: { symbol: "BTC" | "ETH" }) {
const [trades, setTrades] = useState<TradeRow[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
useEffect(() => {
let running = true;
const fetch = async () => {
try {
const data = await fetchLatestTrades(symbol);
if (running) {
setTrades(prev => {
// 合并新数据按时间降序最多保留50条
const existingIds = new Set(prev.map(t => t.agg_id));
const newOnes = data.filter(t => !existingIds.has(t.agg_id));
return [...newOnes, ...prev].slice(0, 50);
});
setLoading(false);
}
} catch (e) {
if (running) setError("加载失败");
}
};
fetch();
const iv = setInterval(fetch, 1000);
return () => { running = false; clearInterval(iv); };
}, [symbol]);
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm overflow-hidden">
<div className="px-4 py-3 border-b border-slate-100 flex items-center justify-between">
<h2 className="font-semibold text-slate-800 text-sm"> · {symbol}/USDT</h2>
<div className="flex items-center gap-1.5">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse" />
<span className="text-xs text-emerald-600"></span>
</div>
</div>
<div className="overflow-hidden" style={{ maxHeight: 320 }}>
{loading ? (
<div className="flex items-center justify-center h-32 text-slate-400 text-sm">...</div>
) : error ? (
<div className="flex items-center justify-center h-32 text-red-400 text-sm">{error}</div>
) : trades.length === 0 ? (
<div className="flex items-center justify-center h-32 text-slate-400 text-sm">...</div>
) : (
<table className="w-full text-xs">
<thead className="sticky top-0 bg-white z-10">
<tr className="bg-slate-50 border-b border-slate-100">
<th className="text-left px-3 py-2 text-slate-500 font-medium">(USDT)</th>
<th className="text-right px-3 py-2 text-slate-500 font-medium"></th>
<th className="text-right px-3 py-2 text-slate-500 font-medium"></th>
</tr>
</thead>
<tbody>
{trades.map(t => (
<tr key={t.agg_id} className="border-b border-slate-50 hover:bg-slate-50 transition-colors">
<td className={`px-3 py-1.5 font-mono font-semibold ${t.is_buyer_maker === 0 ? "text-emerald-600" : "text-red-500"}`}>
{t.is_buyer_maker === 0 ? "▲" : "▼"} {t.price.toLocaleString("en-US", { minimumFractionDigits: 1, maximumFractionDigits: 1 })}
</td>
<td className="px-3 py-1.5 text-right font-mono text-slate-600">
{t.qty >= 1000
? `${(t.qty / 1000).toFixed(2)}K`
: t.qty.toFixed(symbol === "BTC" ? 4 : 3)}
</td>
<td className="px-3 py-1.5 text-right text-slate-400 font-mono">
{timeStr(t.time_ms)}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
);
}