feat: add LiveTradesCard - real-time agg trades display + /api/trades/latest endpoint
This commit is contained in:
parent
7e38b24fa8
commit
bb187167bb
@ -440,7 +440,32 @@ async def get_trades_summary(
|
||||
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)):
|
||||
"""采集器健康状态"""
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
|
||||
@ -6,6 +6,7 @@ import { api, RatesResponse, StatsResponse, HistoryResponse, HistoryPoint, Signa
|
||||
import { useAuth } from "@/lib/auth";
|
||||
import RateCard from "@/components/RateCard";
|
||||
import StatsCard from "@/components/StatsCard";
|
||||
import LiveTradesCard from "@/components/LiveTradesCard";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
LineChart, Line, XAxis, YAxis, Tooltip, Legend,
|
||||
@ -185,6 +186,12 @@ export default function Dashboard() {
|
||||
|
||||
{/* 统计卡片 */}
|
||||
<AuthGate>
|
||||
{/* 实时成交流 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<LiveTradesCard symbol="BTC" />
|
||||
<LiveTradesCard symbol="ETH" />
|
||||
</div>
|
||||
|
||||
{stats && (
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<StatsCard title="BTC 套利" mean7d={stats.BTC.mean7d} annualized={stats.BTC.annualized} accent="blue" />
|
||||
|
||||
118
frontend/components/LiveTradesCard.tsx
Normal file
118
frontend/components/LiveTradesCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user