feat: V4 trades page - live trades + delta analysis + sidebar entry, remove from dashboard
This commit is contained in:
parent
3ff83845fb
commit
155dc87df3
@ -6,7 +6,6 @@ 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,
|
||||
@ -186,12 +185,6 @@ 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" />
|
||||
|
||||
332
frontend/app/trades/page.tsx
Normal file
332
frontend/app/trades/page.tsx
Normal 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>=主动买(多方发动)
|
||||
<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-30分钟的Delta走势,寻找规律性前兆。</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -6,11 +6,12 @@ import { usePathname } from "next/navigation";
|
||||
import {
|
||||
LayoutDashboard, Info,
|
||||
Menu, X, Zap, LogIn, UserPlus,
|
||||
ChevronLeft, ChevronRight
|
||||
ChevronLeft, ChevronRight, Activity
|
||||
} from "lucide-react";
|
||||
|
||||
const navItems = [
|
||||
{ href: "/", label: "仪表盘", icon: LayoutDashboard },
|
||||
{ href: "/trades", label: "成交流", icon: Activity },
|
||||
{ href: "/about", label: "说明", icon: Info },
|
||||
];
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user