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

389 lines
16 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 { 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>
);
}