arbitrage-engine/frontend/app/trades/page.tsx

379 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useEffect, useState, useCallback } from "react";
import { authFetch } from "@/lib/auth";
import { useAuth } from "@/lib/auth";
import Link from "next/link";
import {
ComposedChart, Area, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, ReferenceLine, CartesianGrid, Legend
} 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;
setTrades([]);
setLoading(true);
const fetch = async () => {
try {
const res = await authFetch(`/api/trades/latest?symbol=${symbol}&limit=40`);
if (!res.ok) return;
const data = await res.json();
if (!running) return;
const incoming: TradeRow[] = data.data || [];
setTrades(prev => {
const existingIds = new Set(prev.map((t: TradeRow) => t.agg_id));
const newOnes = incoming.filter((t: TradeRow) => !existingIds.has(t.agg_id));
if (newOnes.length === 0) return prev;
// 最新在顶部最多保留50条
return [...newOnes, ...prev].slice(0, 50);
});
setLoading(false);
} catch {}
};
fetch();
const iv = setInterval(fetch, 2000); // 移动端友好2秒刷新降低重排
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="lg:overflow-y-auto lg:h-[420px] flex-1 bg-white">
{loading ? (
<div className="flex items-center justify-center h-24 text-slate-400 text-sm">...</div>
) : (
<table className="w-full text-xs">
{/* 手机端关闭sticky避免触摸焦点被内层抢走 */}
<thead className="lg:sticky lg: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;
// 价格轴范围只显示实际波动区间±0.3%缓冲)
const prices = chartData.map(d => d.vwap).filter(v => v > 0);
const priceMin = prices.length > 0 ? Math.min(...prices) : 0;
const priceMax = prices.length > 0 ? Math.max(...prices) : 0;
const pricePad = (priceMax - priceMin) * 0.3 || priceMax * 0.001;
const priceYMin = Math.floor(priceMin - pricePad);
const priceYMax = Math.ceil(priceMax + pricePad);
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 + 价格双Y轴图 */}
<div>
<p className="text-xs text-slate-500 mb-2 font-medium"> Delta==+ </p>
<ResponsiveContainer width="100%" height={200}>
<ComposedChart data={chartData} margin={{ top: 4, right: 60, bottom: 0, left: 8 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#f1f5f9" />
<XAxis dataKey="time" tick={{ fill: "#94a3b8", fontSize: 10 }} tickLine={false} interval="preserveStartEnd" />
{/* 左轴Delta */}
<YAxis yAxisId="delta" tick={{ fill: "#94a3b8", fontSize: 10 }} tickLine={false} axisLine={false} width={55} />
{/* 右轴:价格,自适应波动范围 */}
<YAxis yAxisId="price" orientation="right" tick={{ fill: "#f59e0b", fontSize: 10 }} tickLine={false} axisLine={false} width={65}
domain={[priceYMin, priceYMax]}
tickFormatter={(v: number) => v >= 1000 ? `$${(v/1000).toFixed(1)}k` : `$${v.toFixed(0)}`}
/>
<Tooltip
// eslint-disable-next-line @typescript-eslint/no-explicit-any
formatter={(v: any, name: any) => {
if (name === "price") return [`$${Number(v).toLocaleString("en-US", { minimumFractionDigits: 1, maximumFractionDigits: 1 })}`, "币价(VWAP)"];
return [`${Number(v).toFixed(2)}`, "Delta"];
}}
contentStyle={{ background: "#fff", border: "1px solid #e2e8f0", borderRadius: 8, fontSize: 11 }}
/>
<Legend wrapperStyle={{ fontSize: 11 }} />
<ReferenceLine yAxisId="delta" y={0} stroke="#94a3b8" strokeDasharray="4 2" />
<Area yAxisId="delta" type="monotone" dataKey="delta" name="Delta"
stroke="#2563eb" fill="#eff6ff" strokeWidth={1.5} dot={false} connectNulls
/>
<Line yAxisId="price" type="monotone" dataKey="vwap" name="price"
stroke="#f59e0b" strokeWidth={1.5} dot={false} connectNulls strokeDasharray="4 2"
/>
</ComposedChart>
</ResponsiveContainer>
</div>
{/* 买卖量图 */}
<div>
<p className="text-xs text-slate-500 mb-2 font-medium"> vs </p>
<ResponsiveContainer width="100%" height={160}>
<ComposedChart 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 yAxisId="vol" 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 yAxisId="vol" type="monotone" dataKey="buy" name="buy" stroke="#16a34a" fill="#f0fdf4" strokeWidth={1.5} dot={false} connectNulls />
<Area yAxisId="vol" type="monotone" dataKey="sell" name="sell" stroke="#dc2626" fill="#fef2f2" strokeWidth={1.5} dot={false} connectNulls />
</ComposedChart>
</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>
);
}