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}
|
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)
|
||||||
|
|||||||
@ -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" />
|
||||||
|
|||||||
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