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

323 lines
14 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";
type Symbol = "BTC" | "ETH";
interface IndicatorRow {
ts: number;
cvd_fast: number;
cvd_mid: number;
cvd_day: number;
atr_5m: number;
vwap_30m: number;
price: number;
score: number;
signal: string | null;
}
interface LatestIndicator {
ts: number;
cvd_fast: number;
cvd_mid: number;
cvd_day: number;
cvd_fast_slope: number;
atr_5m: number;
atr_percentile: number;
vwap_30m: number;
price: number;
p95_qty: number;
p99_qty: number;
score: number;
signal: string | null;
}
const WINDOWS = [
{ label: "1h", value: 60 },
{ label: "4h", value: 240 },
{ label: "12h", value: 720 },
{ label: "24h", value: 1440 },
];
function bjtStr(ms: number) {
const d = new Date(ms + 8 * 3600 * 1000);
return `${String(d.getUTCHours()).padStart(2, "0")}:${String(d.getUTCMinutes()).padStart(2, "0")}`;
}
function fmt(v: number, decimals = 1): string {
if (Math.abs(v) >= 1000000) return `${(v / 1000000).toFixed(1)}M`;
if (Math.abs(v) >= 1000) return `${(v / 1000).toFixed(1)}K`;
return v.toFixed(decimals);
}
// ─── 实时指标卡片 ────────────────────────────────────────────────
function IndicatorCards({ symbol }: { symbol: Symbol }) {
const [data, setData] = useState<LatestIndicator | null>(null);
useEffect(() => {
const fetch = async () => {
try {
const res = await authFetch("/api/signals/latest");
if (!res.ok) return;
const json = await res.json();
setData(json[symbol] || null);
} catch {}
};
fetch();
const iv = setInterval(fetch, 5000);
return () => clearInterval(iv);
}, [symbol]);
if (!data) return <div className="text-center text-slate-400 text-sm py-4">...</div>;
const cvdFastDir = data.cvd_fast > 0 ? "多" : "空";
const cvdMidDir = data.cvd_mid > 0 ? "多" : "空";
const priceVsVwap = data.price > data.vwap_30m ? "上方" : "下方";
// 核心条件检查
const core1 = data.cvd_fast > 0 && data.cvd_fast_slope > 0 ? "✅" : data.cvd_fast < 0 && data.cvd_fast_slope < 0 ? "✅空" : "⬜";
const core2 = data.cvd_mid !== 0 ? "✅" : "⬜";
const core3 = data.price !== data.vwap_30m ? "✅" : "⬜";
return (
<div className="space-y-3">
{/* CVD三轨 */}
<div className="grid grid-cols-3 gap-2">
<div className="bg-white rounded-lg border border-slate-200 p-3">
<p className="text-xs text-slate-400">CVD_fast (30m)</p>
<p className={`font-mono font-bold text-lg ${data.cvd_fast >= 0 ? "text-emerald-600" : "text-red-500"}`}>
{fmt(data.cvd_fast)}
</p>
<p className="text-xs text-slate-400 mt-0.5">
: <span className={data.cvd_fast_slope >= 0 ? "text-emerald-600" : "text-red-500"}>
{data.cvd_fast_slope >= 0 ? "↑" : "↓"} {fmt(Math.abs(data.cvd_fast_slope))}
</span>
</p>
</div>
<div className="bg-white rounded-lg border border-slate-200 p-3">
<p className="text-xs text-slate-400">CVD_mid (4h)</p>
<p className={`font-mono font-bold text-lg ${data.cvd_mid >= 0 ? "text-emerald-600" : "text-red-500"}`}>
{fmt(data.cvd_mid)}
</p>
<p className="text-xs text-slate-400 mt-0.5">: {cvdMidDir}</p>
</div>
<div className="bg-white rounded-lg border border-slate-200 p-3">
<p className="text-xs text-slate-400">CVD_day ()</p>
<p className={`font-mono font-bold text-lg ${data.cvd_day >= 0 ? "text-emerald-600" : "text-red-500"}`}>
{fmt(data.cvd_day)}
</p>
<p className="text-xs text-slate-400 mt-0.5">线</p>
</div>
</div>
{/* ATR + VWAP + 大单 */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
<div className="bg-white rounded-lg border border-slate-200 p-3">
<p className="text-xs text-slate-400">ATR (5m×14)</p>
<p className="font-mono font-semibold text-slate-800">${fmt(data.atr_5m, 2)}</p>
<p className="text-xs text-slate-400 mt-0.5">
: <span className={data.atr_percentile > 60 ? "text-amber-600 font-semibold" : "text-slate-500"}>
{data.atr_percentile.toFixed(0)}%
</span>
{data.atr_percentile > 60 ? " 🔥扩张" : data.atr_percentile < 40 ? " 压缩" : ""}
</p>
</div>
<div className="bg-white rounded-lg border border-slate-200 p-3">
<p className="text-xs text-slate-400">VWAP (30m)</p>
<p className="font-mono font-semibold text-slate-800">${data.vwap_30m.toLocaleString("en-US", { maximumFractionDigits: 1 })}</p>
<p className="text-xs text-slate-400 mt-0.5">
VWAP<span className={data.price > data.vwap_30m ? "text-emerald-600" : "text-red-500"}>{priceVsVwap}</span>
</p>
</div>
<div className="bg-white rounded-lg border border-slate-200 p-3">
<p className="text-xs text-slate-400"> P95</p>
<p className="font-mono font-semibold text-slate-800">{data.p95_qty.toFixed(4)}</p>
<p className="text-xs text-slate-400 mt-0.5">24h动态分位</p>
</div>
<div className="bg-white rounded-lg border border-slate-200 p-3">
<p className="text-xs text-slate-400"> P99</p>
<p className="font-mono font-semibold text-amber-600">{data.p99_qty.toFixed(4)}</p>
<p className="text-xs text-slate-400 mt-0.5"></p>
</div>
</div>
{/* 信号状态 */}
<div className={`rounded-lg border p-3 ${
data.signal === "LONG" ? "border-emerald-300 bg-emerald-50" :
data.signal === "SHORT" ? "border-red-300 bg-red-50" :
"border-slate-200 bg-slate-50"
}`}>
<div className="flex items-center justify-between">
<div>
<p className="text-xs text-slate-500"></p>
<p className={`font-bold text-lg ${
data.signal === "LONG" ? "text-emerald-700" :
data.signal === "SHORT" ? "text-red-600" :
"text-slate-400"
}`}>
{data.signal === "LONG" ? "🟢 做多" : data.signal === "SHORT" ? "🔴 做空" : "⚪ 无信号"}
</p>
</div>
<div className="text-right">
<p className="text-xs text-slate-500"></p>
<p className="font-mono font-bold text-slate-800">{data.score}/60</p>
</div>
</div>
{data.signal && (
<div className="mt-2 grid grid-cols-3 gap-2 text-xs">
<span>{core1} CVD_fast方向</span>
<span>{core2} CVD_mid方向</span>
<span>{core3} VWAP位置</span>
</div>
)}
</div>
</div>
);
}
// ─── CVD三轨图 ──────────────────────────────────────────────────
function CVDChart({ symbol, minutes }: { symbol: Symbol; minutes: number }) {
const [data, setData] = useState<IndicatorRow[]>([]);
const [loading, setLoading] = useState(true);
const fetchData = useCallback(async (silent = false) => {
try {
const res = await authFetch(`/api/signals/indicators?symbol=${symbol}&minutes=${minutes}`);
if (!res.ok) return;
const json = await res.json();
setData(json.data || []);
if (!silent) setLoading(false);
} catch {}
}, [symbol, minutes]);
useEffect(() => {
setLoading(true);
fetchData();
const iv = setInterval(() => fetchData(true), 30000);
return () => clearInterval(iv);
}, [fetchData]);
const chartData = data.map(d => ({
time: bjtStr(d.ts),
fast: parseFloat(d.cvd_fast?.toFixed(2) || "0"),
mid: parseFloat(d.cvd_mid?.toFixed(2) || "0"),
price: d.price,
}));
// 价格轴自适应
const prices = chartData.map(d => d.price).filter(v => v > 0);
const pMin = prices.length ? Math.min(...prices) : 0;
const pMax = prices.length ? Math.max(...prices) : 0;
const pPad = (pMax - pMin) * 0.3 || pMax * 0.001;
if (loading) return <div className="flex items-center justify-center h-48 text-slate-400 text-sm">...</div>;
if (data.length === 0) return <div className="flex items-center justify-center h-48 text-slate-400 text-sm">signal-engine需运行积累</div>;
return (
<ResponsiveContainer width="100%" height={220}>
<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" />
<YAxis yAxisId="cvd" 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={[Math.floor(pMin - pPad), Math.ceil(pMax + pPad)]}
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()}`, "币价"];
if (name === "fast") return [fmt(Number(v)), "CVD_fast(30m)"];
return [fmt(Number(v)), "CVD_mid(4h)"];
}}
contentStyle={{ background: "#fff", border: "1px solid #e2e8f0", borderRadius: 8, fontSize: 11 }}
/>
<Legend wrapperStyle={{ fontSize: 11 }} />
<ReferenceLine yAxisId="cvd" y={0} stroke="#94a3b8" strokeDasharray="4 2" />
<Area yAxisId="cvd" type="monotone" dataKey="fast" name="fast" stroke="#2563eb" fill="#eff6ff" strokeWidth={1.5} dot={false} connectNulls />
<Line yAxisId="cvd" type="monotone" dataKey="mid" name="mid" stroke="#7c3aed" strokeWidth={1.5} dot={false} connectNulls strokeDasharray="6 3" />
<Line yAxisId="price" type="monotone" dataKey="price" name="price" stroke="#f59e0b" strokeWidth={1.5} dot={false} connectNulls strokeDasharray="4 2" />
</ComposedChart>
</ResponsiveContainer>
);
}
// ─── 主页面 ──────────────────────────────────────────────────────
export default function SignalsPage() {
const { isLoggedIn, loading } = useAuth();
const [symbol, setSymbol] = useState<Symbol>("BTC");
const [minutes, setMinutes] = useState(240);
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">V5 </h1>
<p className="text-slate-500 text-xs mt-0.5">CVD三轨 + ATR + VWAP + /</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>
{/* 实时指标卡片 */}
<IndicatorCards symbol={symbol} />
{/* CVD三轨图 */}
<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 flex-wrap gap-2">
<div>
<h3 className="font-semibold text-slate-800 text-sm">CVD三轨 + </h3>
<p className="text-xs text-slate-400 mt-0.5">=CVD_fast(30m) · 线=CVD_mid(4h) · 线=</p>
</div>
<div className="flex gap-1">
{WINDOWS.map(w => (
<button key={w.value} onClick={() => setMinutes(w.value)}
className={`px-2 py-1 rounded border text-xs transition-colors ${minutes === w.value ? "bg-slate-800 text-white border-slate-800" : "border-slate-200 text-slate-500 hover:border-slate-400"}`}>
{w.label}
</button>
))}
</div>
</div>
<div className="p-4">
<CVDChart symbol={symbol} minutes={minutes} />
</div>
</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>CVD_fast方向 + CVD_mid方向 + VWAP位置 = 3ATR扩张(+25) + (+20) + (+15)</p>
<p><span className="text-blue-600 font-medium"></span>0-152% / 20-405% / 45-608%1030</p>
</div>
</div>
);
}