389 lines
16 KiB
TypeScript
389 lines
16 KiB
TypeScript
"use client";
|
||
|
||
import { useParams, useSearchParams, useRouter } from "next/navigation";
|
||
import { useEffect, useState, useCallback } from "react";
|
||
import { authFetch } from "@/lib/auth";
|
||
import Link from "next/link";
|
||
import dynamic from "next/dynamic";
|
||
import {
|
||
ArrowLeft,
|
||
CheckCircle,
|
||
PauseCircle,
|
||
AlertCircle,
|
||
Clock,
|
||
Settings,
|
||
} from "lucide-react";
|
||
|
||
// ─── Dynamic imports for each strategy's pages ───────────────────
|
||
const SignalsV53 = dynamic(() => import("@/app/signals-v53/page"), { ssr: false });
|
||
const SignalsV53Fast = dynamic(() => import("@/app/signals-v53fast/page"), { ssr: false });
|
||
const SignalsV53Middle = dynamic(() => import("@/app/signals-v53middle/page"), { ssr: false });
|
||
const PaperV53 = dynamic(() => import("@/app/paper-v53/page"), { ssr: false });
|
||
const PaperV53Fast = dynamic(() => import("@/app/paper-v53fast/page"), { ssr: false });
|
||
const PaperV53Middle = dynamic(() => import("@/app/paper-v53middle/page"), { ssr: false });
|
||
const SignalsGeneric = dynamic(() => import("./SignalsGeneric"), { ssr: false });
|
||
const PaperGeneric = dynamic(() => import("./PaperGeneric"), { ssr: false });
|
||
|
||
// ─── UUID → legacy strategy name map ─────────────────────────────
|
||
const UUID_TO_LEGACY: Record<string, string> = {
|
||
"00000000-0000-0000-0000-000000000053": "v53",
|
||
"00000000-0000-0000-0000-000000000054": "v53_middle",
|
||
"00000000-0000-0000-0000-000000000055": "v53_fast",
|
||
};
|
||
|
||
// ─── Types ────────────────────────────────────────────────────────
|
||
interface StrategySummary {
|
||
strategy_id?: string;
|
||
id?: string;
|
||
display_name: string;
|
||
status: string;
|
||
started_at: number;
|
||
initial_balance: number;
|
||
current_balance: number;
|
||
net_usdt: number;
|
||
net_r: number;
|
||
trade_count: number;
|
||
win_rate: number;
|
||
avg_win_r: number;
|
||
avg_loss_r: number;
|
||
open_positions: number;
|
||
pnl_usdt_24h: number;
|
||
pnl_r_24h: number;
|
||
cvd_windows?: string;
|
||
cvd_fast_window?: string;
|
||
cvd_slow_window?: string;
|
||
description?: string;
|
||
symbol?: string;
|
||
}
|
||
|
||
interface StrategyDetail {
|
||
weight_direction: number;
|
||
weight_env: number;
|
||
weight_aux: number;
|
||
weight_momentum: number;
|
||
entry_score: number;
|
||
// 门1 波动率
|
||
gate_vol_enabled: boolean;
|
||
vol_atr_pct_min: number;
|
||
// 门2 CVD共振
|
||
gate_cvd_enabled: boolean;
|
||
// 门3 鲸鱼否决
|
||
gate_whale_enabled: boolean;
|
||
whale_usd_threshold: number;
|
||
whale_flow_pct: number;
|
||
// 门4 OBI否决
|
||
gate_obi_enabled: boolean;
|
||
obi_threshold: number;
|
||
// 门5 期现背离
|
||
gate_spot_perp_enabled: boolean;
|
||
spot_perp_threshold: number;
|
||
sl_atr_multiplier: number;
|
||
tp1_ratio: number;
|
||
tp2_ratio: number;
|
||
timeout_minutes: number;
|
||
flip_threshold: number;
|
||
symbol: string;
|
||
direction: string;
|
||
cvd_fast_window: string;
|
||
cvd_slow_window: string;
|
||
}
|
||
|
||
// ─── Helpers ──────────────────────────────────────────────────────
|
||
function fmtDur(ms: number) {
|
||
const s = Math.floor((Date.now() - ms) / 1000);
|
||
const d = Math.floor(s / 86400);
|
||
const h = Math.floor((s % 86400) / 3600);
|
||
const m = Math.floor((s % 3600) / 60);
|
||
if (d > 0) return `${d}天${h}h`;
|
||
if (h > 0) return `${h}h${m}m`;
|
||
return `${m}m`;
|
||
}
|
||
|
||
function StatusBadge({ status }: { status: string }) {
|
||
if (status === "running") return <span className="flex items-center gap-1 text-xs text-emerald-600 font-medium"><CheckCircle size={12} />运行中</span>;
|
||
if (status === "paused") return <span className="flex items-center gap-1 text-xs text-yellow-600 font-medium"><PauseCircle size={12} />已暂停</span>;
|
||
return <span className="flex items-center gap-1 text-xs text-red-500 font-medium"><AlertCircle size={12} />异常</span>;
|
||
}
|
||
|
||
// ─── Config Tab ───────────────────────────────────────────────────
|
||
function ConfigTab({ detail, strategyId }: { detail: StrategyDetail; strategyId: string }) {
|
||
const router = useRouter();
|
||
|
||
const row = (label: string, value: string | number | boolean) => (
|
||
<div className="flex items-center justify-between py-2 border-b border-slate-100 last:border-0">
|
||
<span className="text-xs text-slate-500">{label}</span>
|
||
<span className="text-xs font-medium text-slate-800">{String(value)}</span>
|
||
</div>
|
||
);
|
||
|
||
const gateRow = (label: string, enabled: boolean, threshold: string) => (
|
||
<div className="flex items-center justify-between py-2 border-b border-slate-100 last:border-0">
|
||
<div className="flex items-center gap-2">
|
||
<span className={`inline-block w-2 h-2 rounded-full ${enabled ? "bg-emerald-400" : "bg-slate-300"}`} />
|
||
<span className="text-xs text-slate-500">{label}</span>
|
||
</div>
|
||
<span className={`text-xs font-medium ${enabled ? "text-slate-800" : "text-slate-400"}`}>
|
||
{enabled ? threshold : "已关闭"}
|
||
</span>
|
||
</div>
|
||
);
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
<div className="flex justify-end">
|
||
<button
|
||
onClick={() => router.push(`/strategy-plaza/${strategyId}/edit`)}
|
||
className="flex items-center gap-1.5 px-4 py-2 rounded-xl bg-blue-600 text-white text-xs font-medium hover:bg-blue-700 transition-colors"
|
||
>
|
||
<Settings size={13} />
|
||
编辑参数
|
||
</button>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||
{/* 基础配置 */}
|
||
<div className="bg-white border border-slate-200 rounded-xl p-4">
|
||
<h4 className="text-xs font-semibold text-slate-600 mb-2">基础配置</h4>
|
||
{row("交易对", detail.symbol)}
|
||
{row("交易方向", detail.direction === "both" ? "多空双向" : detail.direction === "long_only" ? "只做多" : "只做空")}
|
||
{row("CVD 快线", detail.cvd_fast_window)}
|
||
{row("CVD 慢线", detail.cvd_slow_window)}
|
||
</div>
|
||
|
||
{/* 四层权重 */}
|
||
<div className="bg-white border border-slate-200 rounded-xl p-4">
|
||
<h4 className="text-xs font-semibold text-slate-600 mb-2">四层权重</h4>
|
||
{row("方向权重", `${detail.weight_direction}%`)}
|
||
{row("环境权重", `${detail.weight_env}%`)}
|
||
{row("辅助权重", `${detail.weight_aux}%`)}
|
||
{row("动量权重", `${detail.weight_momentum}%`)}
|
||
{row("入场阈值", `${detail.entry_score} 分`)}
|
||
</div>
|
||
|
||
{/* 五道 Gate */}
|
||
<div className="bg-white border border-slate-200 rounded-xl p-4">
|
||
<h4 className="text-xs font-semibold text-slate-600 mb-2">过滤门控 (Gate)</h4>
|
||
{gateRow("门1 波动率", detail.gate_vol_enabled, `ATR% ≥ ${((detail.vol_atr_pct_min ?? 0) * 100).toFixed(2)}%`)}
|
||
{gateRow("门2 CVD共振", detail.gate_cvd_enabled ?? true, "快慢CVD同向")}
|
||
{gateRow("门3 鲸鱼否决", detail.gate_whale_enabled, `USD ≥ $${((detail.whale_usd_threshold ?? 50000) / 1000).toFixed(0)}k`)}
|
||
{gateRow("门4 OBI否决", detail.gate_obi_enabled, `阈值 ${detail.obi_threshold}`)}
|
||
{gateRow("门5 期现背离", detail.gate_spot_perp_enabled, `溢价 ≤ ${((detail.spot_perp_threshold ?? 0.005) * 100).toFixed(2)}%`)}
|
||
</div>
|
||
|
||
{/* 风控参数 */}
|
||
<div className="bg-white border border-slate-200 rounded-xl p-4">
|
||
<h4 className="text-xs font-semibold text-slate-600 mb-2">风控参数</h4>
|
||
{row("SL 宽度", `${detail.sl_atr_multiplier} × ATR`)}
|
||
{row("TP1 目标", `${detail.tp1_ratio} × RD`)}
|
||
{row("TP2 目标", `${detail.tp2_ratio} × RD`)}
|
||
{row("超时", `${detail.timeout_minutes} 分钟`)}
|
||
{row("反转阈值", `${detail.flip_threshold} 分`)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ─── Content router ───────────────────────────────────────────────
|
||
function SignalsContent({ strategyId, symbol, detail }: { strategyId: string; symbol?: string; detail?: StrategyDetail | null }) {
|
||
const legacy = UUID_TO_LEGACY[strategyId] || strategyId;
|
||
if (legacy === "v53") return <SignalsV53 />;
|
||
if (legacy === "v53_fast") return <SignalsV53Fast />;
|
||
if (legacy === "v53_middle") return <SignalsV53Middle />;
|
||
const weights = detail ? {
|
||
direction: detail.weight_direction,
|
||
env: detail.weight_env,
|
||
aux: detail.weight_aux,
|
||
momentum: detail.weight_momentum,
|
||
} : { direction: 38, env: 32, aux: 28, momentum: 2 };
|
||
const gates = detail ? {
|
||
obi_threshold: detail.obi_threshold,
|
||
whale_usd_threshold: detail.whale_usd_threshold,
|
||
whale_flow_pct: detail.whale_flow_pct,
|
||
vol_atr_pct_min: detail.vol_atr_pct_min,
|
||
spot_perp_threshold: detail.spot_perp_threshold,
|
||
} : { obi_threshold: 0.3, whale_usd_threshold: 100000, whale_flow_pct: 0.5, vol_atr_pct_min: 0.002, spot_perp_threshold: 0.003 };
|
||
return (
|
||
<SignalsGeneric
|
||
strategyId={strategyId}
|
||
symbol={symbol || "BTCUSDT"}
|
||
cvdFastWindow={detail?.cvd_fast_window || "15m"}
|
||
cvdSlowWindow={detail?.cvd_slow_window || "1h"}
|
||
weights={weights}
|
||
gates={gates}
|
||
/>
|
||
);
|
||
}
|
||
|
||
function PaperContent({ strategyId, symbol }: { strategyId: string; symbol?: string }) {
|
||
const legacy = UUID_TO_LEGACY[strategyId] || strategyId;
|
||
if (legacy === "v53") return <PaperV53 />;
|
||
if (legacy === "v53_fast") return <PaperV53Fast />;
|
||
if (legacy === "v53_middle") return <PaperV53Middle />;
|
||
return <PaperGeneric strategyId={strategyId} symbol={symbol || "BTCUSDT"} />;
|
||
}
|
||
|
||
// ─── Main Page ────────────────────────────────────────────────────
|
||
export default function StrategyDetailPage() {
|
||
const params = useParams();
|
||
const searchParams = useSearchParams();
|
||
const router = useRouter();
|
||
const strategyId = params?.id as string;
|
||
const tab = searchParams?.get("tab") || "signals";
|
||
|
||
const [summary, setSummary] = useState<StrategySummary | null>(null);
|
||
const [detail, setDetail] = useState<StrategyDetail | null>(null);
|
||
const [loading, setLoading] = useState(true);
|
||
|
||
const fetchData = useCallback(async () => {
|
||
try {
|
||
// Try new /api/strategies/{id} first for full detail
|
||
const r = await authFetch(`/api/strategies/${strategyId}`);
|
||
if (r.ok) {
|
||
const d = await r.json();
|
||
const s = d.strategy;
|
||
setSummary({
|
||
strategy_id: s.strategy_id,
|
||
display_name: s.display_name,
|
||
status: s.status,
|
||
started_at: s.started_at || s.created_at || Date.now(),
|
||
initial_balance: s.initial_balance,
|
||
current_balance: s.current_balance,
|
||
net_usdt: s.net_usdt || 0,
|
||
net_r: s.net_r || 0,
|
||
trade_count: s.trade_count || 0,
|
||
win_rate: s.win_rate || 0,
|
||
avg_win_r: s.avg_win_r || 0,
|
||
avg_loss_r: s.avg_loss_r || 0,
|
||
open_positions: s.open_positions || 0,
|
||
pnl_usdt_24h: s.pnl_usdt_24h || 0,
|
||
pnl_r_24h: s.pnl_r_24h || 0,
|
||
cvd_fast_window: s.cvd_fast_window,
|
||
cvd_slow_window: s.cvd_slow_window,
|
||
description: s.description,
|
||
symbol: s.symbol,
|
||
});
|
||
setDetail({
|
||
weight_direction: s.weight_direction,
|
||
weight_env: s.weight_env,
|
||
weight_aux: s.weight_aux,
|
||
weight_momentum: s.weight_momentum,
|
||
entry_score: s.entry_score,
|
||
gate_vol_enabled: s.gate_vol_enabled,
|
||
vol_atr_pct_min: s.vol_atr_pct_min,
|
||
gate_cvd_enabled: s.gate_cvd_enabled,
|
||
gate_whale_enabled: s.gate_whale_enabled,
|
||
whale_usd_threshold: s.whale_usd_threshold,
|
||
whale_flow_pct: s.whale_flow_pct,
|
||
gate_obi_enabled: s.gate_obi_enabled,
|
||
obi_threshold: s.obi_threshold,
|
||
gate_spot_perp_enabled: s.gate_spot_perp_enabled,
|
||
spot_perp_threshold: s.spot_perp_threshold,
|
||
sl_atr_multiplier: s.sl_atr_multiplier,
|
||
tp1_ratio: s.tp1_ratio,
|
||
tp2_ratio: s.tp2_ratio,
|
||
timeout_minutes: s.timeout_minutes,
|
||
flip_threshold: s.flip_threshold,
|
||
symbol: s.symbol,
|
||
direction: s.direction,
|
||
cvd_fast_window: s.cvd_fast_window,
|
||
cvd_slow_window: s.cvd_slow_window,
|
||
});
|
||
return;
|
||
}
|
||
} catch {}
|
||
// Fallback to legacy /api/strategy-plaza/{id}/summary
|
||
try {
|
||
const r = await authFetch(`/api/strategy-plaza/${strategyId}/summary`);
|
||
if (r.ok) {
|
||
const d = await r.json();
|
||
setSummary(d);
|
||
}
|
||
} catch {}
|
||
setLoading(false);
|
||
}, [strategyId]);
|
||
|
||
useEffect(() => {
|
||
fetchData().finally(() => setLoading(false));
|
||
const iv = setInterval(fetchData, 30000);
|
||
return () => clearInterval(iv);
|
||
}, [fetchData]);
|
||
|
||
if (loading) {
|
||
return (
|
||
<div className="flex items-center justify-center min-h-screen">
|
||
<div className="text-gray-400 animate-pulse">加载中...</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const isProfit = (summary?.net_usdt ?? 0) >= 0;
|
||
const cvdLabel = summary?.cvd_fast_window
|
||
? `${summary.cvd_fast_window}/${summary.cvd_slow_window}`
|
||
: summary?.cvd_windows || "";
|
||
|
||
return (
|
||
<div className="p-4 max-w-full">
|
||
{/* Back + Strategy Header */}
|
||
<div className="flex items-center gap-3 mb-4">
|
||
<Link href="/strategy-plaza" className="flex items-center gap-1 text-slate-500 hover:text-slate-800 text-sm transition-colors">
|
||
<ArrowLeft size={16} />
|
||
策略广场
|
||
</Link>
|
||
<span className="text-slate-300">/</span>
|
||
<span className="text-slate-800 font-medium">{summary?.display_name ?? strategyId}</span>
|
||
</div>
|
||
|
||
{/* Summary Bar */}
|
||
{summary && (
|
||
<div className="flex flex-wrap items-center gap-3 rounded-xl border border-slate-200 bg-white px-4 py-2.5 mb-4">
|
||
<StatusBadge status={summary.status} />
|
||
<span className="text-xs text-slate-400 flex items-center gap-1">
|
||
<Clock size={10} />运行 {fmtDur(summary.started_at)}
|
||
</span>
|
||
{cvdLabel && (
|
||
<span className="text-xs text-blue-600 bg-blue-50 border border-blue-100 px-2 py-0.5 rounded">CVD {cvdLabel}</span>
|
||
)}
|
||
<span className="ml-auto flex items-center gap-4 text-xs">
|
||
<span className="text-slate-500">胜率 <span className={summary.win_rate >= 50 ? "text-emerald-600 font-bold" : "text-amber-600 font-bold"}>{summary.win_rate}%</span></span>
|
||
<span className="text-slate-500">净R <span className={`font-bold ${isProfit ? "text-emerald-600" : "text-red-500"}`}>{summary.net_r >= 0 ? "+" : ""}{summary.net_r}R</span></span>
|
||
<span className="text-slate-500">余额 <span className={`font-bold ${isProfit ? "text-emerald-600" : "text-red-500"}`}>{summary.current_balance.toLocaleString()} U</span></span>
|
||
<span className="text-slate-500">24h <span className={`font-bold ${summary.pnl_usdt_24h >= 0 ? "text-emerald-600" : "text-red-500"}`}>{summary.pnl_usdt_24h >= 0 ? "+" : ""}{summary.pnl_usdt_24h} U</span></span>
|
||
</span>
|
||
</div>
|
||
)}
|
||
|
||
{/* Tabs */}
|
||
<div className="flex gap-2 mb-4">
|
||
{[
|
||
{ key: "signals", label: "📊 信号引擎" },
|
||
{ key: "paper", label: "📈 模拟盘" },
|
||
{ key: "config", label: "⚙️ 参数配置" },
|
||
].map(({ key, label }) => (
|
||
<button
|
||
key={key}
|
||
onClick={() => router.push(`/strategy-plaza/${strategyId}?tab=${key}`)}
|
||
className={`px-4 py-1.5 rounded-lg text-xs font-medium border transition-colors ${
|
||
tab === key
|
||
? "bg-blue-600 text-white border-blue-600"
|
||
: "bg-white text-slate-600 border-slate-200 hover:border-blue-300 hover:text-blue-600"
|
||
}`}
|
||
>
|
||
{label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{/* Content */}
|
||
<div>
|
||
{tab === "signals" && <SignalsContent strategyId={strategyId} symbol={summary?.symbol} detail={detail} />}
|
||
{tab === "paper" && <PaperContent strategyId={strategyId} symbol={summary?.symbol} />}
|
||
{tab === "config" && detail && <ConfigTab detail={detail} strategyId={strategyId} />}
|
||
{tab === "config" && !detail && (
|
||
<div className="text-center text-slate-400 text-sm py-16">暂无配置信息</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|