feat: V4 trades page - live trades + delta analysis + sidebar entry, remove from dashboard

This commit is contained in:
root 2026-02-27 11:47:32 +00:00
parent 3ff83845fb
commit 155dc87df3
3 changed files with 334 additions and 8 deletions

View File

@ -6,7 +6,6 @@ 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,
@ -186,12 +185,6 @@ 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,332 @@
"use client";
import { useEffect, useState, useRef, useCallback } from "react";
import { authFetch } from "@/lib/auth";
import { useAuth } from "@/lib/auth";
import Link from "next/link";
import {
AreaChart, Area, XAxis, YAxis, Tooltip, ResponsiveContainer, ReferenceLine, CartesianGrid
} from "recharts";
// ─── Types ───────────────────────────────────────────────────────
interface TradeRow {
agg_id: number;
price: number;
qty: number;
time_ms: number;
is_buyer_maker: number;
}
interface SummaryBar {
time_ms: number;
buy_vol: number;
sell_vol: number;
delta: number;
total_vol: number;
trade_count: number;
vwap: number;
max_qty: number;
}
type Symbol = "BTC" | "ETH";
type Interval = "1m" | "5m" | "15m" | "1h";
const INTERVALS: { label: string; value: Interval }[] = [
{ label: "1m", value: "1m" },
{ label: "5m", value: "5m" },
{ label: "15m", value: "15m" },
{ label: "1h", value: "1h" },
];
function bjtStr(ms: number, short = false) {
const d = new Date(ms + 8 * 3600 * 1000);
const hhmm = `${String(d.getUTCHours()).padStart(2,"0")}:${String(d.getUTCMinutes()).padStart(2,"0")}`;
if (short) return hhmm;
return `${d.getUTCFullYear()}-${String(d.getUTCMonth()+1).padStart(2,"0")}-${String(d.getUTCDate()).padStart(2,"0")} ${hhmm}`;
}
function timeStr(ms: number) {
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")}`;
}
// ─── 实时成交流 ──────────────────────────────────────────────────
function LiveTrades({ symbol }: { symbol: Symbol }) {
const [trades, setTrades] = useState<TradeRow[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
let running = true;
const fetch = async () => {
try {
const res = await authFetch(`/api/trades/latest?symbol=${symbol}&limit=30`);
if (!res.ok) return;
const data = await res.json();
if (!running) return;
setTrades(prev => {
const existingIds = new Set(prev.map((t: TradeRow) => t.agg_id));
const newOnes = (data.data || []).filter((t: TradeRow) => !existingIds.has(t.agg_id));
return [...newOnes, ...prev].slice(0, 60);
});
setLoading(false);
} catch {}
};
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 flex flex-col">
<div className="px-4 py-3 border-b border-slate-100">
<div className="flex items-center justify-between">
<h3 className="font-semibold text-slate-800 text-sm"> · {symbol}/USDT</h3>
<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>
<p className="text-xs text-slate-400 mt-0.5">
<span className="text-emerald-600 font-medium"> 绿</span>=&nbsp;
<span className="text-red-500 font-medium"> </span>=
</p>
</div>
<div className="overflow-y-auto" style={{ maxHeight: 400 }}>
{loading ? (
<div className="flex items-center justify-center h-24 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"></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">
<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>
);
}
// ─── 成交流分析Delta图+买卖量柱状图)────────────────────────────
function FlowAnalysis({ symbol }: { symbol: Symbol }) {
const [interval, setInterval_] = useState<Interval>("5m");
const [data, setData] = useState<SummaryBar[]>([]);
const [loading, setLoading] = useState(true);
const [dataAge, setDataAge] = useState(0); // 数据积累分钟数
const fetchData = useCallback(async () => {
const end_ms = Date.now();
const windowMs = { "1m": 3600000, "5m": 14400000, "15m": 43200000, "1h": 172800000 }[interval];
const start_ms = end_ms - windowMs;
try {
const res = await authFetch(
`/api/trades/summary?symbol=${symbol}&start_ms=${start_ms}&end_ms=${end_ms}&interval=${interval}`
);
if (!res.ok) return;
const json = await res.json();
const bars: SummaryBar[] = json.data || [];
setData(bars);
setLoading(false);
if (bars.length > 0) {
const ageMs = end_ms - bars[0].time_ms;
setDataAge(Math.round(ageMs / 60000));
}
} catch {}
}, [symbol, interval]);
useEffect(() => {
setLoading(true);
fetchData();
const iv = setInterval(fetchData, 30000);
return () => clearInterval(iv);
}, [fetchData]);
// Recharts数据格式
const chartData = data.map(b => ({
time: bjtStr(b.time_ms, true),
buy: parseFloat(b.buy_vol.toFixed(2)),
sell: parseFloat(b.sell_vol.toFixed(2)),
delta: parseFloat(b.delta.toFixed(2)),
count: b.trade_count,
vwap: b.vwap,
}));
// 统计摘要
const totalBuy = data.reduce((s, b) => s + b.buy_vol, 0);
const totalSell = data.reduce((s, b) => s + b.sell_vol, 0);
const totalDelta = totalBuy - totalSell;
const buyPct = totalBuy + totalSell > 0 ? (totalBuy / (totalBuy + totalSell) * 100) : 50;
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">
<div className="flex items-center justify-between flex-wrap gap-2">
<div>
<h3 className="font-semibold text-slate-800 text-sm">{symbol} </h3>
<p className="text-xs text-slate-400 mt-0.5"> Delta · </p>
</div>
<div className="flex gap-1">
{INTERVALS.map(iv => (
<button key={iv.value} onClick={() => setInterval_(iv.value)}
className={`px-2 py-1 rounded border text-xs transition-colors ${interval === iv.value ? "bg-slate-800 text-white border-slate-800" : "border-slate-200 text-slate-500 hover:border-slate-400"}`}>
{iv.label}
</button>
))}
</div>
</div>
</div>
{loading ? (
<div className="flex items-center justify-center h-48 text-slate-400 text-sm">...</div>
) : data.length === 0 ? (
<div className="flex flex-col items-center justify-center h-48 text-slate-400 text-sm gap-2">
<span>📊</span>
<span>...</span>
<span className="text-xs"></span>
</div>
) : (
<div className="p-4 space-y-4">
{/* 摘要卡片 */}
<div className="grid grid-cols-4 gap-2">
<div className="bg-slate-50 rounded-lg p-2 text-center">
<p className="text-xs text-slate-400"></p>
<p className="font-mono font-semibold text-emerald-600 text-sm">{totalBuy.toFixed(1)}</p>
</div>
<div className="bg-slate-50 rounded-lg p-2 text-center">
<p className="text-xs text-slate-400"></p>
<p className="font-mono font-semibold text-red-500 text-sm">{totalSell.toFixed(1)}</p>
</div>
<div className="bg-slate-50 rounded-lg p-2 text-center">
<p className="text-xs text-slate-400">Delta</p>
<p className={`font-mono font-semibold text-sm ${totalDelta >= 0 ? "text-emerald-600" : "text-red-500"}`}>
{totalDelta >= 0 ? "+" : ""}{totalDelta.toFixed(1)}
</p>
</div>
<div className="bg-slate-50 rounded-lg p-2 text-center">
<p className="text-xs text-slate-400"></p>
<p className={`font-mono font-semibold text-sm ${buyPct >= 50 ? "text-emerald-600" : "text-red-500"}`}>
{buyPct.toFixed(1)}%
</p>
</div>
</div>
{/* Delta折线图 */}
<div>
<p className="text-xs text-slate-500 mb-2 font-medium"> Delta==</p>
<ResponsiveContainer width="100%" height={160}>
<AreaChart data={chartData} margin={{ top: 4, right: 8, bottom: 0, left: 8 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#f1f5f9" />
<XAxis dataKey="time" tick={{ fill: "#94a3b8", fontSize: 10 }} tickLine={false} interval="preserveStartEnd" />
<YAxis tick={{ fill: "#94a3b8", fontSize: 10 }} tickLine={false} axisLine={false} width={50} />
<Tooltip
formatter={(v: number) => [v.toFixed(2), "Delta"]}
contentStyle={{ background: "#fff", border: "1px solid #e2e8f0", borderRadius: 8, fontSize: 11 }}
/>
<ReferenceLine y={0} stroke="#94a3b8" strokeDasharray="4 2" />
<Area type="monotone" dataKey="delta"
stroke="#2563eb" fill="#eff6ff" strokeWidth={1.5}
dot={false} connectNulls
/>
</AreaChart>
</ResponsiveContainer>
</div>
{/* 买卖量柱状图 */}
<div>
<p className="text-xs text-slate-500 mb-2 font-medium"> vs </p>
<ResponsiveContainer width="100%" height={160}>
<AreaChart data={chartData} margin={{ top: 4, right: 8, bottom: 0, left: 8 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#f1f5f9" />
<XAxis dataKey="time" tick={{ fill: "#94a3b8", fontSize: 10 }} tickLine={false} interval="preserveStartEnd" />
<YAxis tick={{ fill: "#94a3b8", fontSize: 10 }} tickLine={false} axisLine={false} width={50} />
<Tooltip
formatter={(v: number, name: string) => [v.toFixed(2), name === "buy" ? "主动买" : "主动卖"]}
contentStyle={{ background: "#fff", border: "1px solid #e2e8f0", borderRadius: 8, fontSize: 11 }}
/>
<Area type="monotone" dataKey="buy" stroke="#16a34a" fill="#f0fdf4" strokeWidth={1.5} dot={false} connectNulls />
<Area type="monotone" dataKey="sell" stroke="#dc2626" fill="#fef2f2" strokeWidth={1.5} dot={false} connectNulls />
</AreaChart>
</ResponsiveContainer>
</div>
<p className="text-xs text-slate-400 text-right">
{dataAge} · {data.length} K · 30
</p>
</div>
)}
</div>
);
}
// ─── 主页面 ──────────────────────────────────────────────────────
export default function TradesPage() {
const { isLoggedIn, loading } = useAuth();
const [symbol, setSymbol] = useState<Symbol>("BTC");
if (loading) return (
<div className="flex items-center justify-center h-64 text-slate-400">...</div>
);
if (!isLoggedIn) return (
<div className="flex flex-col items-center justify-center h-64 gap-4">
<div className="text-5xl">🔒</div>
<p className="text-slate-600 font-medium"></p>
<div className="flex gap-2">
<Link href="/login" className="bg-blue-600 text-white px-4 py-2 rounded-lg text-sm"></Link>
<Link href="/register" className="border border-slate-300 text-slate-600 px-4 py-2 rounded-lg text-sm"></Link>
</div>
</div>
);
return (
<div className="space-y-5">
{/* 标题+币种切换 */}
<div className="flex items-center justify-between flex-wrap gap-2">
<div>
<h1 className="text-xl font-bold text-slate-900"></h1>
<p className="text-slate-500 text-xs mt-0.5"> + Delta </p>
</div>
<div className="flex gap-1.5">
{(["BTC", "ETH"] as Symbol[]).map(s => (
<button key={s} onClick={() => setSymbol(s)}
className={`px-4 py-1.5 rounded-lg border text-sm font-medium transition-colors ${symbol === s ? "bg-blue-600 text-white border-blue-600" : "border-slate-200 text-slate-600 hover:border-blue-400"}`}>
{s}
</button>
))}
</div>
</div>
{/* 实时成交 + 分析图 并排 */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<LiveTrades symbol={symbol} />
<FlowAnalysis symbol={symbol} />
</div>
{/* 说明 */}
<div className="rounded-lg border border-blue-100 bg-blue-50 px-4 py-3 text-xs text-slate-600 space-y-1">
<p><span className="text-blue-600 font-medium"></span>Delta持续为正Delta突然大幅转负</p>
<p><span className="text-blue-600 font-medium"></span>K线大涨大跌的时间点15-30Delta走势</p>
</div>
</div>
);
}

View File

@ -6,11 +6,12 @@ import { usePathname } from "next/navigation";
import { import {
LayoutDashboard, Info, LayoutDashboard, Info,
Menu, X, Zap, LogIn, UserPlus, Menu, X, Zap, LogIn, UserPlus,
ChevronLeft, ChevronRight ChevronLeft, ChevronRight, Activity
} from "lucide-react"; } from "lucide-react";
const navItems = [ const navItems = [
{ href: "/", label: "仪表盘", icon: LayoutDashboard }, { href: "/", label: "仪表盘", icon: LayoutDashboard },
{ href: "/trades", label: "成交流", icon: Activity },
{ href: "/about", label: "说明", icon: Info }, { href: "/about", label: "说明", icon: Info },
]; ];