arbitrage-engine/frontend/app/signals/page.tsx
root 01b1992643 feat: save factors to signal_indicators + show FR/Liq on signals page
- DB: added factors JSONB column to signal_indicators
- Backend: save_indicator now includes factors JSON
- API: /api/signals/latest returns factors field
- Frontend: signals page shows FR and 清算 score bars
2026-03-01 23:57:55 +00:00

622 lines
27 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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" | "XRP" | "SOL";
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;
tier?: "light" | "standard" | "heavy" | null;
factors?: {
direction?: { score?: number };
crowding?: { score?: number };
environment?: { score?: number };
confirmation?: { score?: number };
auxiliary?: { score?: number };
funding_rate?: { score?: number; value?: number };
liquidation?: { score?: number; long_usd?: number; short_usd?: number };
} | null;
}
interface StrategyScoreSnapshot {
score: number | null;
signal: string | null;
ts: number | null;
source?: string;
funding_rate_score?: number | null;
liquidation_score?: number | null;
}
interface StrategyLatestRow {
primary_strategy?: "v51_baseline" | "v52_8signals";
latest_signal?: string | null;
latest_ts?: number | null;
v51?: StrategyScoreSnapshot;
v52?: StrategyScoreSnapshot;
}
interface MarketIndicatorValue {
value: Record<string, unknown>;
ts: number;
}
interface MarketIndicatorSet {
long_short_ratio?: MarketIndicatorValue;
top_trader_position?: MarketIndicatorValue;
open_interest_hist?: MarketIndicatorValue;
coinbase_premium?: MarketIndicatorValue;
}
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 pct(v: number, digits = 1): string {
return `${(v * 100).toFixed(digits)}%`;
}
function agoLabel(ts: number | null | undefined): string {
if (!ts) return "--";
const minutes = Math.round((Date.now() - ts) / 60000);
if (minutes < 1) return "刚刚";
if (minutes < 60) return `${minutes}m前`;
return `${Math.round(minutes / 60)}h前`;
}
function LayerScore({ label, score, max, colorClass }: { label: string; score: number; max: number; colorClass: string }) {
const ratio = Math.max(0, Math.min((score / max) * 100, 100));
return (
<div className="flex items-center gap-2">
<span className="text-[10px] text-slate-500 w-6 shrink-0">{label}</span>
<div className="flex-1 h-1.5 rounded-full bg-slate-100 overflow-hidden">
<div className={`h-full ${colorClass}`} style={{ width: `${ratio}%` }} />
</div>
<span className="text-[10px] font-mono text-slate-600 w-8 text-right">{score}/{max}</span>
</div>
);
}
function LatestStrategyComparison() {
const [rows, setRows] = useState<Record<Symbol, StrategyLatestRow | undefined>>({
BTC: undefined,
ETH: undefined,
XRP: undefined,
SOL: undefined,
});
useEffect(() => {
const fetch = async () => {
try {
const res = await authFetch("/api/signals/latest-v52");
if (!res.ok) return;
const json = await res.json();
setRows({
BTC: json.BTC,
ETH: json.ETH,
XRP: json.XRP,
SOL: json.SOL,
});
} catch {}
};
fetch();
const iv = setInterval(fetch, 10000);
return () => clearInterval(iv);
}, []);
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm overflow-hidden">
<div className="px-3 py-2 border-b border-slate-100">
<h3 className="font-semibold text-slate-800 text-xs">V5.1 vs V5.2</h3>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-2 p-2">
{(["BTC", "ETH", "XRP", "SOL"] as Symbol[]).map((sym) => {
const row = rows[sym];
const latestSignal = row?.latest_signal;
const v51 = row?.v51;
const v52 = row?.v52;
const v52Fr = v52?.funding_rate_score;
const v52Liq = v52?.liquidation_score;
return (
<div key={sym} className="rounded-lg border border-slate-200 bg-slate-50 px-2.5 py-2">
<div className="flex items-center justify-between">
<p className="font-mono text-sm font-bold text-slate-800">{sym}</p>
<span className="text-[10px] text-slate-400">{agoLabel(row?.latest_ts ?? null)}</span>
</div>
<p className={`text-[11px] mt-0.5 font-semibold ${latestSignal === "LONG" ? "text-emerald-600" : latestSignal === "SHORT" ? "text-red-500" : "text-slate-400"}`}>
{latestSignal === "LONG" ? "🟢 LONG" : latestSignal === "SHORT" ? "🔴 SHORT" : "⚪ 无信号"}
</p>
<div className="mt-1 text-[11px] text-slate-700 flex items-center gap-1.5 flex-wrap">
<span className="rounded bg-slate-200 text-slate-700 px-1.5 py-0.5 font-mono">V5.1: {v51?.score ?? "--"}</span>
<span className="rounded bg-emerald-100 text-emerald-700 px-1.5 py-0.5 font-mono"> V5.2: {v52?.score ?? "--"}</span>
</div>
<div className="mt-1 text-[10px] text-slate-500">
{v52Fr === null || v52Fr === undefined ? "FR --" : `FR ${v52Fr >= 0 ? "+" : ""}${v52Fr}`} · {v52Liq === null || v52Liq === undefined ? "Liq --" : `Liq ${v52Liq >= 0 ? "+" : ""}${v52Liq}`}
</div>
<div className="mt-1 text-[9px] text-slate-400">
来源: V5.1 {v51?.source || "--"} | V5.2 {v52?.source || "--"}
</div>
</div>
);
})}
</div>
</div>
);
}
function MarketIndicatorsCards({ symbol }: { symbol: Symbol }) {
const [data, setData] = useState<MarketIndicatorSet | null>(null);
useEffect(() => {
const fetch = async () => {
try {
const res = await authFetch("/api/signals/market-indicators");
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-3">...</div>;
// value可能是JSON字符串或对象统一解析
const parseVal = (v: unknown): Record<string, unknown> => {
if (!v) return {};
if (typeof v === "string") { try { return JSON.parse(v); } catch { return {}; } }
if (typeof v === "object") return v as Record<string, unknown>;
return {};
};
const lsVal = parseVal(data.long_short_ratio?.value);
const topVal = parseVal(data.top_trader_position?.value);
const oiVal = parseVal(data.open_interest_hist?.value);
const premVal = parseVal(data.coinbase_premium?.value);
const longPct = Number(lsVal?.longAccount ?? 0.5) * 100;
const shortPct = Number(lsVal?.shortAccount ?? 0.5) * 100;
const topLong = Number(topVal?.longAccount ?? 0.5) * 100;
const topShort = Number(topVal?.shortAccount ?? 0.5) * 100;
const oiValue = Number(oiVal?.sumOpenInterestValue ?? 0);
const oiDisplay = oiValue >= 1e9 ? `$${(oiValue / 1e9).toFixed(2)}B` : oiValue >= 1e6 ? `$${(oiValue / 1e6).toFixed(0)}M` : `$${oiValue.toFixed(0)}`;
const premium = Number(premVal?.premium_pct ?? 0);
return (
<div className="grid grid-cols-4 gap-1.5">
<div className="bg-slate-50 rounded-lg px-2 py-1.5">
<p className="text-[10px] text-slate-400"></p>
<p className="text-xs font-mono text-slate-800">L:{longPct.toFixed(1)}% S:{shortPct.toFixed(1)}%</p>
</div>
<div className="bg-slate-50 rounded-lg px-2 py-1.5">
<p className="text-[10px] text-slate-400"></p>
<p className="text-xs font-mono text-slate-800">{topLong.toFixed(1)}% {topLong >= 55 ? "📈" : topLong <= 45 ? "📉" : ""}</p>
</div>
<div className="bg-slate-50 rounded-lg px-2 py-1.5">
<p className="text-[10px] text-slate-400">OI</p>
<p className="text-xs font-mono text-slate-800">{oiDisplay}</p>
</div>
<div className="bg-slate-50 rounded-lg px-2 py-1.5">
<p className="text-[10px] text-slate-400">CB Premium</p>
<p className={`text-xs font-mono ${premium >= 0 ? "text-emerald-600" : "text-red-500"}`}>{premium >= 0 ? "+" : ""}{premium.toFixed(4)}%</p>
</div>
</div>
);
}
// ─── 信号历史 ────────────────────────────────────────────────────
interface SignalRecord {
ts: number;
score: number;
signal: string;
}
function bjtFull(ms: number) {
const d = new Date(ms + 8 * 3600 * 1000);
return `${String(d.getUTCMonth() + 1).padStart(2, "0")}-${String(d.getUTCDate()).padStart(2, "0")} ${String(d.getUTCHours()).padStart(2, "0")}:${String(d.getUTCMinutes()).padStart(2, "0")}:${String(d.getUTCSeconds()).padStart(2, "0")}`;
}
function SignalHistory({ symbol }: { symbol: Symbol }) {
const [data, setData] = useState<SignalRecord[]>([]);
useEffect(() => {
const fetchData = async () => {
try {
const res = await authFetch(`/api/signals/signal-history?symbol=${symbol}&limit=20`);
if (!res.ok) return;
const json = await res.json();
setData(json.data || []);
} catch {}
};
fetchData();
const iv = setInterval(fetchData, 15000);
return () => clearInterval(iv);
}, [symbol]);
if (data.length === 0) return null;
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm overflow-hidden">
<div className="px-3 py-2 border-b border-slate-100">
<h3 className="font-semibold text-slate-800 text-xs"></h3>
</div>
<div className="divide-y divide-slate-100 max-h-48 overflow-y-auto">
{data.map((s, i) => (
<div key={i} className="px-3 py-1.5 flex items-center justify-between">
<div className="flex items-center gap-2">
<span className={`text-xs font-bold ${s.signal === "LONG" ? "text-emerald-600" : "text-red-500"}`}>
{s.signal === "LONG" ? "🟢 LONG" : "🔴 SHORT"}
</span>
<span className="text-[10px] text-slate-400">{bjtFull(s.ts)}</span>
</div>
<div className="flex items-center gap-1.5">
<span className="font-mono text-xs text-slate-700">{s.score}</span>
<span className={`text-[10px] px-1 py-0.5 rounded ${
s.score >= 85 ? "bg-red-100 text-red-700" :
s.score >= 75 ? "bg-blue-100 text-blue-700" :
"bg-slate-100 text-slate-600"
}`}>
{s.score >= 85 ? "加仓" : s.score >= 75 ? "标准" : "轻仓"}
</span>
</div>
</div>
))}
</div>
</div>
);
}
// ─── 实时指标卡片 ────────────────────────────────────────────────
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-1.5">
<div className="bg-white rounded-lg border border-slate-200 px-2.5 py-2">
<p className="text-[10px] text-slate-400">CVD_fast (30m)</p>
<p className={`font-mono font-bold text-sm ${data.cvd_fast >= 0 ? "text-emerald-600" : "text-red-500"}`}>
{fmt(data.cvd_fast)}
</p>
<p className="text-[10px] text-slate-400">
: <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 px-2.5 py-2">
<p className="text-[10px] text-slate-400">CVD_mid (4h)</p>
<p className={`font-mono font-bold text-sm ${data.cvd_mid >= 0 ? "text-emerald-600" : "text-red-500"}`}>
{fmt(data.cvd_mid)}
</p>
<p className="text-[10px] text-slate-400">{cvdMidDir}</p>
</div>
<div className="bg-white rounded-lg border border-slate-200 px-2.5 py-2">
<p className="text-[10px] text-slate-400">CVD_day</p>
<p className={`font-mono font-bold text-sm ${data.cvd_day >= 0 ? "text-emerald-600" : "text-red-500"}`}>
{fmt(data.cvd_day)}
</p>
<p className="text-[10px] text-slate-400">线</p>
</div>
</div>
{/* ATR + VWAP + 大单 - 4列紧凑 */}
<div className="grid grid-cols-4 gap-1.5">
<div className="bg-white rounded-lg border border-slate-200 px-2.5 py-2">
<p className="text-[10px] text-slate-400">ATR</p>
<p className="font-mono font-semibold text-sm text-slate-800">${fmt(data.atr_5m, 2)}</p>
<p className="text-[10px]">
<span className={data.atr_percentile > 60 ? "text-amber-600 font-semibold" : "text-slate-400"}>
{data.atr_percentile.toFixed(0)}%{data.atr_percentile > 60 ? "🔥" : ""}
</span>
</p>
</div>
<div className="bg-white rounded-lg border border-slate-200 px-2.5 py-2">
<p className="text-[10px] text-slate-400">VWAP</p>
<p className="font-mono font-semibold text-sm text-slate-800">${data.vwap_30m.toLocaleString("en-US", { maximumFractionDigits: 1 })}</p>
<p className="text-[10px]">
<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 px-2.5 py-2">
<p className="text-[10px] text-slate-400">P95</p>
<p className="font-mono font-semibold text-sm text-slate-800">{data.p95_qty.toFixed(4)}</p>
<p className="text-[10px] text-slate-400"></p>
</div>
<div className="bg-white rounded-lg border border-slate-200 px-2.5 py-2">
<p className="text-[10px] text-slate-400">P99</p>
<p className="font-mono font-semibold text-sm text-amber-600">{data.p99_qty.toFixed(4)}</p>
<p className="text-[10px] text-slate-400"></p>
</div>
</div>
{/* 信号状态V5.1- 紧凑版 */}
<div className={`rounded-xl border px-3 py-2.5 ${
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-[10px] text-slate-500"></p>
<p className={`font-bold text-base ${
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="font-mono font-bold text-lg text-slate-800">{data.score}/100</p>
<p className="text-[10px] text-slate-500">{data.tier === "heavy" ? "加仓" : data.tier === "standard" ? "标准" : data.tier === "light" ? "轻仓" : "不开仓"}</p>
</div>
</div>
<div className="mt-2 space-y-1">
<LayerScore label="方向" score={data.factors?.direction?.score ?? Math.min(Math.round(data.score * 0.45), 45)} max={45} colorClass="bg-blue-600" />
<LayerScore label="拥挤" score={data.factors?.crowding?.score ?? Math.min(Math.round(data.score * 0.20), 20)} max={20} colorClass="bg-violet-600" />
<LayerScore label="环境" score={data.factors?.environment?.score ?? Math.min(Math.round(data.score * 0.15), 15)} max={15} colorClass="bg-emerald-600" />
<LayerScore label="确认" score={data.factors?.confirmation?.score ?? Math.min(Math.round(data.score * 0.15), 15)} max={15} colorClass="bg-amber-500" />
<LayerScore label="辅助" score={data.factors?.auxiliary?.score ?? Math.min(Math.round(data.score * 0.05), 5)} max={5} colorClass="bg-slate-500" />
<LayerScore label="FR" score={data.factors?.funding_rate?.score ?? 0} max={5} colorClass="bg-cyan-600" />
<LayerScore label="清算" score={data.factors?.liquidation?.score ?? 0} max={5} colorClass="bg-orange-500" />
</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-3">
{/* 标题 */}
<div className="flex items-center justify-between flex-wrap gap-2">
<div>
<h1 className="text-lg font-bold text-slate-900"> V5.1 vs V5.2</h1>
<p className="text-slate-500 text-[10px]"> · V5.2 Funding Rate / Liquidation </p>
</div>
<div className="flex gap-1">
{(["BTC", "ETH", "XRP", "SOL"] as Symbol[]).map(s => (
<button key={s} onClick={() => setSymbol(s)}
className={`px-3 py-1 rounded-lg border text-xs 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>
<LatestStrategyComparison />
{/* 实时指标卡片 */}
<IndicatorCards symbol={symbol} />
{/* Market Indicators */}
<div className="rounded-xl border border-slate-200 bg-white px-3 py-2">
<h3 className="font-semibold text-slate-800 text-xs mb-1.5">Market Indicators</h3>
<MarketIndicatorsCards symbol={symbol} />
</div>
{/* 信号历史 */}
<SignalHistory symbol={symbol} />
{/* CVD三轨图 */}
<div className="rounded-xl border border-slate-200 bg-white shadow-sm overflow-hidden">
<div className="px-3 py-2 border-b border-slate-100 flex items-center justify-between flex-wrap gap-1">
<div>
<h3 className="font-semibold text-slate-800 text-xs">CVD三轨 + </h3>
<p className="text-[10px] text-slate-400">=fast(30m) · =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="px-3 py-2">
<CVDChart symbol={symbol} minutes={minutes} />
</div>
</div>
{/* 说明 */}
<div className="rounded-xl border border-slate-200 bg-white shadow-sm overflow-hidden">
<div className="px-3 py-2 border-b border-slate-100">
<h3 className="font-semibold text-slate-800 text-xs">📖 </h3>
</div>
<div className="px-3 py-2 space-y-2 text-[11px] text-slate-600">
<div>
<span className="font-bold text-slate-800">1 45</span>
<span className="text-slate-500"> </span>
<p className="mt-0.5">CVD三轨30m/4h资金流向+ P99大单流+ CVD同向+ = </p>
</div>
<div>
<span className="font-bold text-slate-800">2 20</span>
<span className="text-slate-500"> </span>
<p className="mt-0.5">+ </p>
</div>
<div>
<span className="font-bold text-slate-800">3 15</span>
<span className="text-slate-500"> </span>
<p className="mt-0.5">OI变化率OI上涨==OI下降=</p>
</div>
<div>
<span className="font-bold text-slate-800">4 15</span>
<span className="text-slate-500"> </span>
<p className="mt-0.5">CVD_fast(30m)CVD_mid(4h)=15=0</p>
</div>
<div>
<span className="font-bold text-slate-800">5 5</span>
<span className="text-slate-500"> </span>
<p className="mt-0.5">Coinbase PremiumCB vs ===</p>
</div>
<div className="pt-1 border-t border-slate-100">
<span className="text-blue-600 font-medium"></span>&lt;60 · 60-74 · 75-84 · 85 · 10
</div>
</div>
</div>
</div>
);
}