351 lines
16 KiB
TypeScript
351 lines
16 KiB
TypeScript
"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 flex-1" style={{ maxHeight: 420, scrollBehavior: "auto" }}>
|
||
{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 shadow-sm">
|
||
<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 (showLoading = false) => {
|
||
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);
|
||
if (showLoading) 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(true);
|
||
const iv = setInterval(() => fetchData(false), 30000); // 定时刷新不触发loading
|
||
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="space-y-2">
|
||
<div className="flex items-center justify-between">
|
||
<p className="text-xs text-slate-500 font-medium">
|
||
统计时间窗口:
|
||
<span className="font-semibold text-slate-700 ml-1">
|
||
{interval === "1m" ? "过去1小时" : interval === "5m" ? "过去4小时" : interval === "15m" ? "过去12小时" : "过去48小时"}
|
||
</span>
|
||
(每{interval}聚合一根,每30秒刷新)
|
||
</p>
|
||
<p className="text-xs text-slate-400">单位:{symbol === "BTC" ? "BTC" : "ETH"}</p>
|
||
</div>
|
||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
|
||
<div className="bg-slate-50 rounded-lg p-3">
|
||
<p className="text-xs text-slate-400">主动买量</p>
|
||
<p className="font-mono font-semibold text-emerald-600 text-base mt-0.5">{totalBuy.toFixed(1)}</p>
|
||
<p className="text-xs text-slate-400 mt-1">买方主动吃卖单</p>
|
||
</div>
|
||
<div className="bg-slate-50 rounded-lg p-3">
|
||
<p className="text-xs text-slate-400">主动卖量</p>
|
||
<p className="font-mono font-semibold text-red-500 text-base mt-0.5">{totalSell.toFixed(1)}</p>
|
||
<p className="text-xs text-slate-400 mt-1">卖方主动砸买单</p>
|
||
</div>
|
||
<div className="bg-slate-50 rounded-lg p-3">
|
||
<p className="text-xs text-slate-400">Delta(买-卖)</p>
|
||
<p className={`font-mono font-semibold text-base mt-0.5 ${totalDelta >= 0 ? "text-emerald-600" : "text-red-500"}`}>
|
||
{totalDelta >= 0 ? "+" : ""}{totalDelta.toFixed(1)}
|
||
</p>
|
||
<p className="text-xs text-slate-400 mt-1">{totalDelta >= 0 ? "多头占优 ↑" : "空头占优 ↓"}</p>
|
||
</div>
|
||
<div className="bg-slate-50 rounded-lg p-3">
|
||
<p className="text-xs text-slate-400">买方占比</p>
|
||
<p className={`font-mono font-semibold text-base mt-0.5 ${buyPct >= 50 ? "text-emerald-600" : "text-red-500"}`}>
|
||
{buyPct.toFixed(1)}%
|
||
</p>
|
||
<p className="text-xs text-slate-400 mt-1">{buyPct >= 55 ? "强势买盘" : buyPct >= 45 ? "买卖均衡" : "强势卖盘"}</p>
|
||
</div>
|
||
</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
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
formatter={(v: any) => [`${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
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
formatter={(v: any, name: any) => [`${Number(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>
|
||
);
|
||
}
|