arbitrage-engine/frontend/app/strategy-plaza/[id]/SignalsGeneric.tsx

654 lines
26 KiB
TypeScript
Raw Permalink 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 {
ComposedChart, Area, Line, XAxis, YAxis, Tooltip, ResponsiveContainer,
ReferenceLine, CartesianGrid, Legend
} from "recharts";
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;
gate_passed?: boolean;
factors?: {
gate_passed?: boolean;
gate_block?: string;
block_reason?: string;
obi_raw?: number;
spot_perp_div?: number;
whale_cvd_ratio?: number;
atr_pct_price?: number;
direction?: { score?: number; max?: number };
crowding?: { score?: number; max?: number };
environment?: { score?: number; max?: number };
auxiliary?: { score?: number; max?: number };
} | null;
}
interface SignalRecord {
ts: number;
score: number;
signal: string;
}
interface AllSignalRow {
ts: number;
score: number;
signal: string | null;
price?: number;
// factors 结构与 LatestIndicator.factors 基本一致,兼容 string/json
factors?: LatestIndicator["factors"] | string | null;
}
interface Gates {
obi_threshold: number;
whale_usd_threshold: number;
whale_flow_pct: number;
vol_atr_pct_min: number;
spot_perp_threshold: number;
}
interface Weights {
direction: number;
env: number;
aux: number;
momentum: number;
}
interface Props {
strategyId: string;
symbol: string;
cvdFastWindow: string;
cvdSlowWindow: string;
weights: Weights;
gates: Gates;
}
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 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 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 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-12 text-right">{score}/{max}</span>
</div>
);
}
function GateCard({ factors, gates }: { factors: LatestIndicator["factors"]; gates: Gates }) {
if (!factors) return null;
const passed = factors.gate_passed ?? true;
const blockReason = factors.gate_block || factors.block_reason;
return (
<div className={`rounded-xl border px-3 py-2 mt-2 ${passed ? "border-purple-200 bg-purple-50" : "border-red-200 bg-red-50"}`}>
<div className="flex items-center justify-between mb-1.5">
<p className="text-[10px] font-semibold text-purple-800">🔒 Gate-Control</p>
<span className={`text-[10px] font-bold px-2 py-0.5 rounded ${passed ? "bg-emerald-100 text-emerald-700" : "bg-red-100 text-red-600"}`}>
{passed ? "✅ Gate通过" : "❌ 否决"}
</span>
</div>
<div className="grid grid-cols-4 gap-1.5">
<div className="bg-white rounded px-2 py-1">
<p className="text-[10px] text-slate-400"></p>
<p className="text-xs font-mono text-slate-800">{((factors.atr_pct_price ?? 0) * 100).toFixed(3)}%</p>
<p className="text-[9px] text-slate-400"> {(gates.vol_atr_pct_min * 100).toFixed(2)}%</p>
</div>
<div className="bg-white rounded px-2 py-1">
<p className="text-[10px] text-slate-400">OBI</p>
<p className={`text-xs font-mono ${(factors.obi_raw ?? 0) >= 0 ? "text-emerald-600" : "text-red-500"}`}>
{((factors.obi_raw ?? 0) * 100).toFixed(2)}%
</p>
<p className="text-[9px] text-slate-400">±{gates.obi_threshold}</p>
</div>
<div className="bg-white rounded px-2 py-1">
<p className="text-[10px] text-slate-400"></p>
<p className={`text-xs font-mono ${(factors.spot_perp_div ?? 0) >= 0 ? "text-emerald-600" : "text-red-500"}`}>
{((factors.spot_perp_div ?? 0) * 10000).toFixed(2)}bps
</p>
<p className="text-[9px] text-slate-400">±{(gates.spot_perp_threshold * 100).toFixed(1)}%</p>
</div>
<div className="bg-white rounded px-2 py-1">
<p className="text-[10px] text-slate-400"></p>
<p className="text-xs font-mono text-slate-800">${(gates.whale_usd_threshold / 1000).toFixed(0)}k</p>
<p className="text-[9px] text-slate-400">{">"}{(gates.whale_flow_pct * 100).toFixed(0)}%</p>
</div>
</div>
{blockReason && (
<p className="text-[10px] text-red-600 mt-1.5 bg-red-50 rounded px-2 py-1">
: <span className="font-mono">{blockReason}</span>
</p>
)}
</div>
);
}
function IndicatorCards({ sym, strategyName, cvdFastWindow, cvdSlowWindow, weights, gates }: {
sym: string; strategyName: string; cvdFastWindow: string; cvdSlowWindow: string; weights: Weights; gates: Gates;
}) {
const [data, setData] = useState<LatestIndicator | null>(null);
const coin = sym.replace("USDT", "") as "BTC" | "ETH" | "XRP" | "SOL";
useEffect(() => {
const fetch_ = async () => {
try {
const res = await authFetch(`/api/signals/latest?strategy=${strategyName}`);
if (!res.ok) return;
const json = await res.json();
setData(json[coin] || null);
} catch {}
};
fetch_();
const iv = setInterval(fetch_, 5000);
return () => clearInterval(iv);
}, [coin, strategyName]);
if (!data) return <div className="text-center text-slate-400 text-sm py-4">...</div>;
const priceVsVwap = data.price > data.vwap_30m ? "上方" : "下方";
const totalWeight = weights.direction + weights.env + weights.aux + weights.momentum;
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 ({cvdFastWindow})</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_slow ({cvdSlowWindow})</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">{data.cvd_mid > 0 ? "多" : "空"}</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共振</p>
<p className={`font-mono font-bold text-sm ${data.cvd_fast >= 0 && data.cvd_mid >= 0 ? "text-emerald-600" : data.cvd_fast < 0 && data.cvd_mid < 0 ? "text-red-500" : "text-slate-400"}`}>
{data.cvd_fast >= 0 && data.cvd_mid >= 0 ? "✅ 多头共振" : data.cvd_fast < 0 && data.cvd_mid < 0 ? "✅ 空头共振" : "⚠️ 分歧"}
</p>
<p className="text-[10px] text-slate-400"></p>
</div>
</div>
{/* ATR + VWAP + P95/P99 */}
<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>
{/* 信号状态 + 四层分 */}
<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"> · {coin}</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}/{totalWeight}</p>
<p className="text-[10px] text-slate-500">
{data.tier === "heavy" ? "加仓" : data.tier === "standard" ? "标准" : "不开仓"}
</p>
</div>
</div>
<div className="mt-2 space-y-1">
<LayerScore label="方向" score={data.factors?.direction?.score ?? 0} max={weights.direction} colorClass="bg-blue-600" />
<LayerScore label="环境" score={data.factors?.environment?.score ?? 0} max={weights.env} colorClass="bg-emerald-600" />
<LayerScore label="辅助" score={data.factors?.auxiliary?.score ?? 0} max={weights.aux} colorClass="bg-violet-600" />
<LayerScore label="动量" score={data.factors?.crowding?.score ?? 0} max={weights.momentum} colorClass="bg-slate-500" />
</div>
</div>
{/* Gate 卡片 */}
<GateCard factors={data.factors} gates={gates} />
</div>
);
}
function SignalHistory({ coin, strategyName }: { coin: string; strategyName: string }) {
const [data, setData] = useState<SignalRecord[]>([]);
useEffect(() => {
const fetchData = async () => {
try {
const res = await authFetch(`/api/signals/signal-history?symbol=${coin}&limit=20&strategy=${strategyName}`);
if (!res.ok) return;
const json = await res.json();
setData(json.data || []);
} catch {}
};
fetchData();
const iv = setInterval(fetchData, 15000);
return () => clearInterval(iv);
}, [coin, strategyName]);
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>
<span className="font-mono text-xs text-slate-700">{s.score}</span>
</div>
))}
</div>
</div>
);
}
function AllSignalsModal({
open,
onClose,
symbol,
strategyName,
}: {
open: boolean;
onClose: () => void;
symbol: string;
strategyName: string;
}) {
const [rows, setRows] = useState<AllSignalRow[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!open) return;
const fetchAll = async () => {
setLoading(true);
setError(null);
try {
const res = await authFetch(
`/api/signals/history?symbol=${symbol}&limit=200&strategy=${strategyName}`
);
if (!res.ok) {
setError(`加载失败 (${res.status})`);
setRows([]);
return;
}
const json = await res.json();
setRows(json.items || []);
} catch (e) {
console.error(e);
setError("加载失败,请稍后重试");
setRows([]);
} finally {
setLoading(false);
}
};
fetchAll();
}, [open, symbol, strategyName]);
const parseFactors = (r: AllSignalRow): LatestIndicator["factors"] | null => {
const f = r.factors;
if (!f) return null;
if (typeof f === "string") {
try {
return JSON.parse(f);
} catch {
return null;
}
}
return f;
};
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="bg-white rounded-xl shadow-2xl w-full max-w-3xl max-h-[80vh] flex flex-col border border-slate-200">
<div className="px-4 py-3 border-b border-slate-100 flex items-center justify-between gap-2">
<div>
<h3 className="text-sm font-semibold text-slate-900">
</h3>
<p className="text-[11px] text-slate-500">
200 · {symbol} · {strategyName}
</p>
</div>
<button
onClick={onClose}
className="px-2 py-1 rounded-lg border border-slate-200 text-[11px] text-slate-600 hover:bg-slate-50"
>
</button>
</div>
<div className="flex-1 overflow-y-auto">
{loading ? (
<div className="py-8 text-center text-slate-400 text-sm">
...
</div>
) : error ? (
<div className="py-8 text-center text-red-500 text-sm">
{error}
</div>
) : rows.length === 0 ? (
<div className="py-8 text-center text-slate-400 text-sm">
</div>
) : (
<table className="w-full text-[11px] text-left">
<thead className="bg-slate-50 border-b border-slate-200 sticky top-0">
<tr>
<th className="px-3 py-2 text-slate-500 font-medium"></th>
<th className="px-3 py-2 text-slate-500 font-medium"></th>
<th className="px-3 py-2 text-slate-500 font-medium"></th>
<th className="px-3 py-2 text-slate-500 font-medium">
</th>
<th className="px-3 py-2 text-slate-500 font-medium"></th>
</tr>
</thead>
<tbody>
{rows.map((r, idx) => {
const f = parseFactors(r);
const dirScore = f?.direction?.score ?? 0;
const envScore = f?.environment?.score ?? 0;
const auxScore = f?.auxiliary?.score ?? 0;
const momScore = f?.crowding?.score ?? 0;
const gateBlock =
(f?.gate_block as string | undefined) ||
(f?.block_reason as string | undefined) ||
"";
const gatePassed =
typeof f?.gate_passed === "boolean"
? f?.gate_passed
: !gateBlock;
return (
<tr
key={`${r.ts}-${idx}`}
className="border-b border-slate-100 last:border-b-0 hover:bg-slate-50/60"
>
<td className="px-3 py-1.5 text-slate-500">
{bjtFull(r.ts)}
</td>
<td className="px-3 py-1.5">
<span
className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full border ${
r.signal === "LONG"
? "border-emerald-300 bg-emerald-50 text-emerald-600"
: r.signal === "SHORT"
? "border-red-300 bg-red-50 text-red-500"
: "border-slate-200 bg-slate-50 text-slate-400"
}`}
>
<span className="text-[10px]">
{r.signal === "LONG"
? "多"
: r.signal === "SHORT"
? "空"
: "无"}
</span>
</span>
</td>
<td className="px-3 py-1.5 font-mono text-slate-800">
{r.score}
</td>
<td className="px-3 py-1.5">
<div className="flex flex-wrap gap-1 text-[10px] text-slate-500">
<span>:{dirScore}</span>
<span>:{envScore}</span>
<span>:{auxScore}</span>
<span>:{momScore}</span>
</div>
</td>
<td className="px-3 py-1.5">
{gatePassed ? (
<span className="inline-flex items-center px-1.5 py-0.5 rounded-full bg-emerald-50 text-emerald-600 border border-emerald-200 text-[10px]">
</span>
) : (
<span className="inline-flex items-center px-1.5 py-0.5 rounded-full bg-red-50 text-red-500 border border-red-200 text-[10px]">
{gateBlock ? ` · ${gateBlock}` : ""}
</span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
)}
</div>
</div>
</div>
);
}
function CVDChart({ sym, minutes, strategyName, cvdFastWindow, cvdSlowWindow }: {
sym: string; minutes: number; strategyName: string; cvdFastWindow: string; cvdSlowWindow: string;
}) {
const [data, setData] = useState<IndicatorRow[]>([]);
const [loading, setLoading] = useState(true);
const coin = sym.replace("USDT", "");
const fetchData = useCallback(async (silent = false) => {
try {
const res = await authFetch(`/api/signals/indicators?symbol=${coin}&minutes=${minutes}&strategy=${strategyName}`);
if (!res.ok) return;
const json = await res.json();
setData(json.data || []);
if (!silent) setLoading(false);
} catch {}
}, [coin, minutes, strategyName]);
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">...</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(${cvdFastWindow})`];
return [fmt(Number(v)), `CVD_slow(${cvdSlowWindow})`];
}}
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 SignalsGeneric({ strategyId, symbol, cvdFastWindow, cvdSlowWindow, weights, gates }: Props) {
const [minutes, setMinutes] = useState(240);
const coin = symbol.replace("USDT", "");
const strategyName = `custom_${strategyId.slice(0, 8)}`;
const [showAllSignals, setShowAllSignals] = useState(false);
return (
<div className="space-y-3 p-1">
<div className="flex items-center justify-between flex-wrap gap-2">
<div>
<h2 className="text-sm font-bold text-slate-900"> </h2>
<p className="text-slate-500 text-[10px]">
CVD {cvdFastWindow}/{cvdSlowWindow} · {weights.direction}/{weights.env}/{weights.aux}/{weights.momentum} · {coin}
</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setShowAllSignals(true)}
className="px-2 py-0.5 rounded-lg border border-slate-200 text-[10px] text-slate-600 hover:bg-slate-50"
>
</button>
<span className="px-2 py-0.5 rounded text-[10px] font-semibold bg-blue-100 text-blue-700 border border-blue-200">
{coin}
</span>
</div>
</div>
<IndicatorCards
sym={symbol}
strategyName={strategyName}
cvdFastWindow={cvdFastWindow}
cvdSlowWindow={cvdSlowWindow}
weights={weights}
gates={gates}
/>
<SignalHistory coin={coin} strategyName={strategyName} />
<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({cvdFastWindow}) · =slow({cvdSlowWindow}) · =</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 sym={symbol} minutes={minutes} strategyName={strategyName} cvdFastWindow={cvdFastWindow} cvdSlowWindow={cvdSlowWindow} />
</div>
</div>
<AllSignalsModal
open={showAllSignals}
onClose={() => setShowAllSignals(false)}
symbol={symbol}
strategyName={strategyName}
/>
</div>
);
}