"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 (
);
}
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 (
🔒 Gate-Control
{passed ? "✅ Gate通过" : "❌ 否决"}
波动率
{((factors.atr_pct_price ?? 0) * 100).toFixed(3)}%
需 ≥{(gates.vol_atr_pct_min * 100).toFixed(2)}%
OBI
= 0 ? "text-emerald-600" : "text-red-500"}`}>
{((factors.obi_raw ?? 0) * 100).toFixed(2)}%
否决±{gates.obi_threshold}
期现背离
= 0 ? "text-emerald-600" : "text-red-500"}`}>
{((factors.spot_perp_div ?? 0) * 10000).toFixed(2)}bps
否决±{(gates.spot_perp_threshold * 100).toFixed(1)}%
鲸鱼
${(gates.whale_usd_threshold / 1000).toFixed(0)}k
{">"}{(gates.whale_flow_pct * 100).toFixed(0)}%占比
{blockReason && (
否决原因: {blockReason}
)}
);
}
function IndicatorCards({ sym, strategyName, cvdFastWindow, cvdSlowWindow, weights, gates }: {
sym: string; strategyName: string; cvdFastWindow: string; cvdSlowWindow: string; weights: Weights; gates: Gates;
}) {
const [data, setData] = useState(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 等待指标数据...
;
const priceVsVwap = data.price > data.vwap_30m ? "上方" : "下方";
const totalWeight = weights.direction + weights.env + weights.aux + weights.momentum;
return (
{/* CVD双轨 */}
CVD_fast ({cvdFastWindow})
= 0 ? "text-emerald-600" : "text-red-500"}`}>
{fmt(data.cvd_fast)}
斜率: = 0 ? "text-emerald-600" : "text-red-500"}>
{data.cvd_fast_slope >= 0 ? "↑" : "↓"}{fmt(Math.abs(data.cvd_fast_slope))}
CVD_slow ({cvdSlowWindow})
= 0 ? "text-emerald-600" : "text-red-500"}`}>
{fmt(data.cvd_mid)}
{data.cvd_mid > 0 ? "多" : "空"}头占优
CVD共振
= 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 ? "✅ 空头共振" : "⚠️ 分歧"}
双周期共振
{/* ATR + VWAP + P95/P99 */}
ATR
${fmt(data.atr_5m, 2)}
60 ? "text-amber-600 font-semibold" : "text-slate-400"}>
{data.atr_percentile.toFixed(0)}%{data.atr_percentile > 60 ? "🔥" : ""}
VWAP
${data.vwap_30m.toLocaleString("en-US", { maximumFractionDigits: 1 })}
价格在 data.vwap_30m ? "text-emerald-600" : "text-red-500"}>{priceVsVwap}
P95
{data.p95_qty?.toFixed(4) ?? "-"}
大单阈值
P99
{data.p99_qty?.toFixed(4) ?? "-"}
超大单
{/* 信号状态 + 四层分 */}
四层评分 · {coin}
{data.signal === "LONG" ? "🟢 做多" : data.signal === "SHORT" ? "🔴 做空" : "⚪ 无信号"}
{data.score}/{totalWeight}
{data.tier === "heavy" ? "加仓" : data.tier === "standard" ? "标准" : "不开仓"}
{/* Gate 卡片 */}
);
}
function SignalHistory({ coin, strategyName }: { coin: string; strategyName: string }) {
const [data, setData] = useState([]);
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 (
最近信号
{data.map((s, i) => (
{s.signal === "LONG" ? "🟢 LONG" : "🔴 SHORT"}
{bjtFull(s.ts)}
{s.score}
))}
);
}
function AllSignalsModal({
open,
onClose,
symbol,
strategyName,
}: {
open: boolean;
onClose: () => void;
symbol: string;
strategyName: string;
}) {
const [rows, setRows] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(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 (
所有历史信号(含未开仓)
最近 200 条 · {symbol} · {strategyName}
{loading ? (
加载中...
) : error ? (
{error}
) : rows.length === 0 ? (
暂无历史信号
) : (
| 时间 |
信号 |
总分 |
四层评分
|
门控 |
{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 (
|
{bjtFull(r.ts)}
|
{r.signal === "LONG"
? "多"
: r.signal === "SHORT"
? "空"
: "无"}
|
{r.score}
|
方:{dirScore}
环:{envScore}
辅:{auxScore}
动:{momScore}
|
{gatePassed ? (
通过
) : (
拒绝
{gateBlock ? ` · ${gateBlock}` : ""}
)}
|
);
})}
)}
);
}
function CVDChart({ sym, minutes, strategyName, cvdFastWindow, cvdSlowWindow }: {
sym: string; minutes: number; strategyName: string; cvdFastWindow: string; cvdSlowWindow: string;
}) {
const [data, setData] = useState([]);
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 加载指标数据...
;
if (data.length === 0) return 暂无指标数据,等待积累...
;
return (
v >= 1000 ? `$${(v / 1000).toFixed(1)}k` : `$${v.toFixed(0)}`}
/>
{
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 }}
/>
);
}
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 (
⚡ 信号引擎
CVD {cvdFastWindow}/{cvdSlowWindow} · 权重 {weights.direction}/{weights.env}/{weights.aux}/{weights.momentum} · {coin}
{coin}
CVD双轨 + 币价
蓝=fast({cvdFastWindow}) · 紫=slow({cvdSlowWindow}) · 橙=价格
{WINDOWS.map(w => (
))}
setShowAllSignals(false)}
symbol={symbol}
strategyName={strategyName}
/>
);
}