feat(v54-frontend): add create/edit/deprecated pages, config tab, sidebar entry

This commit is contained in:
root 2026-03-11 15:23:56 +00:00
parent 9d44885188
commit f8f13a48d5
7 changed files with 1239 additions and 143 deletions

View File

@ -0,0 +1,75 @@
"use client";
import { useAuth, authFetch } from "@/lib/auth";
import { useParams, useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import StrategyForm, { StrategyFormData } from "@/components/StrategyForm";
export default function EditStrategyPage() {
useAuth();
const params = useParams();
const router = useRouter();
const sid = params?.id as string;
const [formData, setFormData] = useState<StrategyFormData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
useEffect(() => {
if (!sid) return;
authFetch(`/api/strategies/${sid}`)
.then((r) => r.json())
.then((d) => {
const s = d.strategy;
if (!s) throw new Error("策略不存在");
setFormData({
display_name: s.display_name,
symbol: s.symbol,
direction: s.direction,
initial_balance: s.initial_balance,
cvd_fast_window: s.cvd_fast_window,
cvd_slow_window: s.cvd_slow_window,
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_obi_enabled: s.gate_obi_enabled,
obi_threshold: s.obi_threshold,
gate_whale_enabled: s.gate_whale_enabled,
whale_cvd_threshold: s.whale_cvd_threshold,
gate_vol_enabled: s.gate_vol_enabled,
atr_percentile_min: s.atr_percentile_min,
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,
description: s.description || "",
});
})
.catch((e) => setError(e.message))
.finally(() => setLoading(false));
}, [sid]);
if (loading) return <div className="p-8 text-slate-400 text-sm animate-pulse">...</div>;
if (error) return <div className="p-8 text-red-500 text-sm">{error}</div>;
if (!formData) return null;
return (
<div className="p-4 max-w-2xl mx-auto">
<div className="mb-5">
<h1 className="text-lg font-bold text-slate-800"></h1>
<p className="text-slate-500 text-xs mt-0.5">15</p>
</div>
<StrategyForm
mode="edit"
initialData={formData}
strategyId={sid}
isBalanceEditable={false}
onSuccess={() => router.push(`/strategy-plaza/${sid}`)}
/>
</div>
);
}

View File

@ -11,6 +11,7 @@ import {
PauseCircle, PauseCircle,
AlertCircle, AlertCircle,
Clock, Clock,
Settings,
} from "lucide-react"; } from "lucide-react";
// ─── Dynamic imports for each strategy's pages ─────────────────── // ─── Dynamic imports for each strategy's pages ───────────────────
@ -21,9 +22,17 @@ const PaperV53 = dynamic(() => import("@/app/paper-v53/page"), { ssr: false });
const PaperV53Fast = dynamic(() => import("@/app/paper-v53fast/page"), { ssr: false }); const PaperV53Fast = dynamic(() => import("@/app/paper-v53fast/page"), { ssr: false });
const PaperV53Middle = dynamic(() => import("@/app/paper-v53middle/page"), { ssr: false }); const PaperV53Middle = dynamic(() => import("@/app/paper-v53middle/page"), { 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 ──────────────────────────────────────────────────────── // ─── Types ────────────────────────────────────────────────────────
interface StrategySummary { interface StrategySummary {
id: string; strategy_id?: string;
id?: string;
display_name: string; display_name: string;
status: string; status: string;
started_at: number; started_at: number;
@ -39,9 +48,36 @@ interface StrategySummary {
pnl_usdt_24h: number; pnl_usdt_24h: number;
pnl_r_24h: number; pnl_r_24h: number;
cvd_windows?: string; cvd_windows?: string;
cvd_fast_window?: string;
cvd_slow_window?: string;
description?: string; description?: string;
} }
interface StrategyDetail {
weight_direction: number;
weight_env: number;
weight_aux: number;
weight_momentum: number;
entry_score: number;
gate_obi_enabled: boolean;
obi_threshold: number;
gate_whale_enabled: boolean;
whale_cvd_threshold: number;
gate_vol_enabled: boolean;
atr_percentile_min: number;
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 ────────────────────────────────────────────────────── // ─── Helpers ──────────────────────────────────────────────────────
function fmtDur(ms: number) { function fmtDur(ms: number) {
const s = Math.floor((Date.now() - ms) / 1000); const s = Math.floor((Date.now() - ms) / 1000);
@ -54,24 +90,104 @@ function fmtDur(ms: number) {
} }
function StatusBadge({ status }: { status: string }) { function StatusBadge({ status }: { status: string }) {
if (status === "running") return <span className="flex items-center gap-1 text-xs text-emerald-400"><CheckCircle size={12} /></span>; 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-400"><PauseCircle 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-400"><AlertCircle 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("Gate1 OBI", detail.gate_obi_enabled, `${detail.obi_threshold}`)}
{gateRow("Gate2 大单CVD", detail.gate_whale_enabled, `${detail.whale_cvd_threshold}`)}
{gateRow("Gate3 ATR%", detail.gate_vol_enabled, `${detail.atr_percentile_min}%`)}
{gateRow("Gate4 现货溢价", detail.gate_spot_perp_enabled, `${detail.spot_perp_threshold}`)}
</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 ─────────────────────────────────────────────── // ─── Content router ───────────────────────────────────────────────
function SignalsContent({ strategyId }: { strategyId: string }) { function SignalsContent({ strategyId }: { strategyId: string }) {
if (strategyId === "v53") return <SignalsV53 />; const legacy = UUID_TO_LEGACY[strategyId] || strategyId;
if (strategyId === "v53_fast") return <SignalsV53Fast />; if (legacy === "v53") return <SignalsV53 />;
if (strategyId === "v53_middle") return <SignalsV53Middle />; if (legacy === "v53_fast") return <SignalsV53Fast />;
return <div className="p-8 text-gray-400">: {strategyId}</div>; if (legacy === "v53_middle") return <SignalsV53Middle />;
return <div className="p-8 text-gray-400"></div>;
} }
function PaperContent({ strategyId }: { strategyId: string }) { function PaperContent({ strategyId }: { strategyId: string }) {
if (strategyId === "v53") return <PaperV53 />; const legacy = UUID_TO_LEGACY[strategyId] || strategyId;
if (strategyId === "v53_fast") return <PaperV53Fast />; if (legacy === "v53") return <PaperV53 />;
if (strategyId === "v53_middle") return <PaperV53Middle />; if (legacy === "v53_fast") return <PaperV53Fast />;
return <div className="p-8 text-gray-400">: {strategyId}</div>; if (legacy === "v53_middle") return <PaperV53Middle />;
return <div className="p-8 text-gray-400"></div>;
} }
// ─── Main Page ──────────────────────────────────────────────────── // ─── Main Page ────────────────────────────────────────────────────
@ -83,9 +199,64 @@ export default function StrategyDetailPage() {
const tab = searchParams?.get("tab") || "signals"; const tab = searchParams?.get("tab") || "signals";
const [summary, setSummary] = useState<StrategySummary | null>(null); const [summary, setSummary] = useState<StrategySummary | null>(null);
const [detail, setDetail] = useState<StrategyDetail | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const fetchSummary = useCallback(async () => { 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,
});
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_obi_enabled: s.gate_obi_enabled,
obi_threshold: s.obi_threshold,
gate_whale_enabled: s.gate_whale_enabled,
whale_cvd_threshold: s.whale_cvd_threshold,
gate_vol_enabled: s.gate_vol_enabled,
atr_percentile_min: s.atr_percentile_min,
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 { try {
const r = await authFetch(`/api/strategy-plaza/${strategyId}/summary`); const r = await authFetch(`/api/strategy-plaza/${strategyId}/summary`);
if (r.ok) { if (r.ok) {
@ -97,10 +268,10 @@ export default function StrategyDetailPage() {
}, [strategyId]); }, [strategyId]);
useEffect(() => { useEffect(() => {
fetchSummary(); fetchData().finally(() => setLoading(false));
const iv = setInterval(fetchSummary, 30000); const iv = setInterval(fetchData, 30000);
return () => clearInterval(iv); return () => clearInterval(iv);
}, [fetchSummary]); }, [fetchData]);
if (loading) { if (loading) {
return ( return (
@ -111,17 +282,20 @@ export default function StrategyDetailPage() {
} }
const isProfit = (summary?.net_usdt ?? 0) >= 0; 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 ( return (
<div className="p-4 max-w-full"> <div className="p-4 max-w-full">
{/* Back + Strategy Header */} {/* Back + Strategy Header */}
<div className="flex items-center gap-3 mb-4"> <div className="flex items-center gap-3 mb-4">
<Link href="/strategy-plaza" className="flex items-center gap-1 text-gray-400 hover:text-white text-sm transition-colors"> <Link href="/strategy-plaza" className="flex items-center gap-1 text-slate-500 hover:text-slate-800 text-sm transition-colors">
<ArrowLeft size={16} /> <ArrowLeft size={16} />
广 广
</Link> </Link>
<span className="text-gray-600">/</span> <span className="text-slate-300">/</span>
<span className="text-white font-medium">{summary?.display_name ?? strategyId}</span> <span className="text-slate-800 font-medium">{summary?.display_name ?? strategyId}</span>
</div> </div>
{/* Summary Bar */} {/* Summary Bar */}
@ -131,8 +305,8 @@ export default function StrategyDetailPage() {
<span className="text-xs text-slate-400 flex items-center gap-1"> <span className="text-xs text-slate-400 flex items-center gap-1">
<Clock size={10} /> {fmtDur(summary.started_at)} <Clock size={10} /> {fmtDur(summary.started_at)}
</span> </span>
{summary.cvd_windows && ( {cvdLabel && (
<span className="text-xs text-blue-600 bg-blue-50 border border-blue-100 px-2 py-0.5 rounded">CVD {summary.cvd_windows}</span> <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="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"> <span className={summary.win_rate >= 50 ? "text-emerald-600 font-bold" : "text-amber-600 font-bold"}>{summary.win_rate}%</span></span>
@ -148,6 +322,7 @@ export default function StrategyDetailPage() {
{[ {[
{ key: "signals", label: "📊 信号引擎" }, { key: "signals", label: "📊 信号引擎" },
{ key: "paper", label: "📈 模拟盘" }, { key: "paper", label: "📈 模拟盘" },
{ key: "config", label: "⚙️ 参数配置" },
].map(({ key, label }) => ( ].map(({ key, label }) => (
<button <button
key={key} key={key}
@ -163,12 +338,13 @@ export default function StrategyDetailPage() {
))} ))}
</div> </div>
{/* Content — direct render of existing pages */} {/* Content */}
<div> <div>
{tab === "signals" ? ( {tab === "signals" && <SignalsContent strategyId={strategyId} />}
<SignalsContent strategyId={strategyId} /> {tab === "paper" && <PaperContent strategyId={strategyId} />}
) : ( {tab === "config" && detail && <ConfigTab detail={detail} strategyId={strategyId} />}
<PaperContent strategyId={strategyId} /> {tab === "config" && !detail && (
<div className="text-center text-slate-400 text-sm py-16"></div>
)} )}
</div> </div>
</div> </div>

View File

@ -0,0 +1,24 @@
"use client";
import { useAuth } from "@/lib/auth";
import { useRouter } from "next/navigation";
import StrategyForm, { DEFAULT_FORM } from "@/components/StrategyForm";
export default function CreateStrategyPage() {
useAuth();
const router = useRouter();
return (
<div className="p-4 max-w-2xl mx-auto">
<div className="mb-5">
<h1 className="text-lg font-bold text-slate-800"></h1>
<p className="text-slate-500 text-xs mt-0.5"></p>
</div>
<StrategyForm
mode="create"
initialData={DEFAULT_FORM}
onSuccess={(id) => router.push(`/strategy-plaza/${id}`)}
/>
</div>
);
}

View File

@ -0,0 +1,196 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { authFetch, useAuth } from "@/lib/auth";
import Link from "next/link";
import {
TrendingUp, TrendingDown, Clock, Activity, RotateCcw
} from "lucide-react";
interface DeprecatedStrategy {
strategy_id: string;
display_name: string;
symbol: string;
status: string;
started_at: number;
deprecated_at: number | null;
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;
pnl_usdt_24h: number;
last_trade_at: number | null;
}
function formatTime(ms: number | null): string {
if (!ms) return "—";
return new Date(ms).toLocaleString("zh-CN", {
timeZone: "Asia/Shanghai",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
}
export default function DeprecatedStrategiesPage() {
useAuth();
const [strategies, setStrategies] = useState<DeprecatedStrategy[]>([]);
const [loading, setLoading] = useState(true);
const [restoring, setRestoring] = useState<string | null>(null);
const fetchData = useCallback(async () => {
try {
const res = await authFetch("/api/strategies?include_deprecated=true");
const data = await res.json();
const deprecated = (data.strategies || []).filter(
(s: DeprecatedStrategy) => s.status === "deprecated"
);
setStrategies(deprecated);
} catch (e) {
console.error(e);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchData();
}, [fetchData]);
const handleRestore = async (sid: string, name: string) => {
if (!confirm(`确认重新启用策略「${name}」?将继续使用原有余额和历史数据。`)) return;
setRestoring(sid);
try {
await authFetch(`/api/strategies/${sid}/restore`, { method: "POST" });
await fetchData();
} catch (e) {
console.error(e);
} finally {
setRestoring(null);
}
};
if (loading) {
return (
<div className="flex items-center justify-center min-h-64">
<div className="text-slate-400 text-sm animate-pulse">...</div>
</div>
);
}
return (
<div className="p-4 max-w-5xl mx-auto">
<div className="flex items-center justify-between mb-5">
<div>
<h1 className="text-lg font-bold text-slate-800"></h1>
<p className="text-slate-500 text-xs mt-0.5"></p>
</div>
<Link
href="/strategy-plaza"
className="text-xs text-blue-600 hover:underline"
>
广
</Link>
</div>
{strategies.length === 0 ? (
<div className="text-center text-slate-400 text-sm py-16"></div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{strategies.map((s) => {
const isProfit = s.net_usdt >= 0;
const is24hProfit = s.pnl_usdt_24h >= 0;
const balancePct = ((s.current_balance / s.initial_balance) * 100).toFixed(1);
return (
<div key={s.strategy_id} className="rounded-xl border border-slate-200 bg-white overflow-hidden opacity-80">
{/* Header */}
<div className="px-4 py-3 border-b border-slate-100 flex items-center justify-between bg-slate-50">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-slate-600 text-sm">{s.display_name}</h3>
<span className="text-[10px] text-slate-400 bg-slate-200 px-1.5 py-0.5 rounded-full"></span>
</div>
<span className="text-[10px] text-slate-400">{s.symbol.replace("USDT", "")}</span>
</div>
{/* PnL */}
<div className="px-4 pt-3 pb-2">
<div className="flex items-end justify-between mb-2">
<div>
<div className="text-[10px] text-slate-400 mb-0.5"></div>
<div className="text-xl font-bold text-slate-700">
{s.current_balance.toLocaleString()}
<span className="text-xs font-normal text-slate-400 ml-1">USDT</span>
</div>
</div>
<div className="text-right">
<div className="text-[10px] text-slate-400 mb-0.5"></div>
<div className={`text-lg font-bold ${isProfit ? "text-emerald-600" : "text-red-500"}`}>
{isProfit ? "+" : ""}{s.net_usdt.toLocaleString()} U
</div>
</div>
</div>
{/* Balance bar */}
<div className="mb-3">
<div className="flex justify-between text-[10px] text-slate-400 mb-1">
<span>{balancePct}%</span>
<span>{s.initial_balance.toLocaleString()} USDT </span>
</div>
<div className="w-full bg-slate-100 rounded-full h-1.5">
<div
className={`h-1.5 rounded-full ${isProfit ? "bg-emerald-400" : "bg-red-300"}`}
style={{ width: `${Math.min(100, Math.max(0, (s.current_balance / s.initial_balance) * 100))}%` }}
/>
</div>
</div>
{/* Stats */}
<div className="grid grid-cols-3 gap-2 mb-3">
<div className="bg-slate-50 rounded-lg p-2 text-center">
<div className="text-[10px] text-slate-400"></div>
<div className="text-sm font-bold text-slate-600">{s.win_rate}%</div>
</div>
<div className="bg-slate-50 rounded-lg p-2 text-center">
<div className="text-[10px] text-slate-400">R</div>
<div className={`text-sm font-bold ${s.net_r >= 0 ? "text-emerald-600" : "text-red-500"}`}>
{s.net_r >= 0 ? "+" : ""}{s.net_r}R
</div>
</div>
<div className="bg-slate-50 rounded-lg p-2 text-center">
<div className="text-[10px] text-slate-400"></div>
<div className="text-sm font-bold text-slate-700">{s.trade_count}</div>
</div>
</div>
</div>
{/* Footer */}
<div className="px-4 py-2.5 border-t border-slate-100 flex items-center justify-between bg-slate-50/60">
<div className="flex items-center gap-1">
{is24hProfit ? <TrendingUp size={12} className="text-emerald-500" /> : <TrendingDown size={12} className="text-red-400" />}
<span className="text-[10px] text-slate-500">
{formatTime(s.deprecated_at)}
</span>
</div>
<button
onClick={() => handleRestore(s.strategy_id, s.display_name)}
disabled={restoring === s.strategy_id}
className="flex items-center gap-1 text-[11px] px-2.5 py-1 rounded-lg bg-blue-50 text-blue-600 hover:bg-blue-100 disabled:opacity-50 transition-colors font-medium"
>
<RotateCcw size={11} />
{restoring === s.strategy_id ? "启用中..." : "重新启用"}
</button>
</div>
</div>
);
})}
</div>
)}
</div>
);
}

View File

@ -4,6 +4,7 @@ import { useEffect, useState, useCallback } from "react";
import { authFetch } from "@/lib/auth"; import { authFetch } from "@/lib/auth";
import { useAuth } from "@/lib/auth"; import { useAuth } from "@/lib/auth";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation";
import { import {
TrendingUp, TrendingUp,
TrendingDown, TrendingDown,
@ -12,11 +13,16 @@ import {
AlertCircle, AlertCircle,
CheckCircle, CheckCircle,
PauseCircle, PauseCircle,
Plus,
Settings,
Trash2,
PlusCircle,
} from "lucide-react"; } from "lucide-react";
interface StrategyCard { interface StrategyCard {
id: string; strategy_id: string;
display_name: string; display_name: string;
symbol: string;
status: "running" | "paused" | "error"; status: "running" | "paused" | "error";
started_at: number; started_at: number;
initial_balance: number; initial_balance: number;
@ -82,128 +88,238 @@ function StatusBadge({ status }: { status: string }) {
); );
} }
function StrategyCardComponent({ s }: { s: StrategyCard }) { // ── AddBalanceModal ────────────────────────────────────────────────────────────
const isProfit = s.net_usdt >= 0; function AddBalanceModal({
const is24hProfit = s.pnl_usdt_24h >= 0; strategy,
const balancePct = ((s.current_balance / s.initial_balance) * 100).toFixed(1); onClose,
onSuccess,
}: {
strategy: StrategyCard;
onClose: () => void;
onSuccess: () => void;
}) {
const [amount, setAmount] = useState(1000);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState("");
const handleSubmit = async () => {
if (amount <= 0) { setError("金额必须大于0"); return; }
setSubmitting(true);
setError("");
try {
const res = await authFetch(`/api/strategies/${strategy.strategy_id}/add-balance`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ amount }),
});
if (!res.ok) throw new Error("追加失败");
onSuccess();
onClose();
} catch (e) {
setError(e instanceof Error ? e.message : "未知错误");
} finally {
setSubmitting(false);
}
};
return ( return (
<Link href={`/strategy-plaza/${s.id}`}> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
<div className="rounded-xl border border-slate-200 bg-white overflow-hidden hover:border-blue-300 hover:shadow-md transition-all cursor-pointer group"> <div className="bg-white rounded-xl shadow-xl p-5 w-80">
{/* Header */} <h3 className="font-semibold text-slate-800 text-sm mb-1"></h3>
<div className="px-4 py-3 border-b border-slate-100 flex items-center justify-between"> <p className="text-[11px] text-slate-500 mb-3">{strategy.display_name}</p>
<div className="flex items-center gap-2"> <div className="mb-3">
<h3 className="font-semibold text-slate-800 text-sm group-hover:text-blue-700 transition-colors"> <label className="text-xs text-slate-600 mb-1 block"> (USDT)</label>
{s.display_name} <input
</h3> type="number"
<StatusBadge status={s.status} /> value={amount}
</div> min={100}
<span className="text-[10px] text-slate-400 flex items-center gap-1"> step={100}
<Clock size={9} /> onChange={(e) => setAmount(parseFloat(e.target.value) || 0)}
{formatDuration(s.started_at)} className="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm"
</span> />
<p className="text-[10px] text-slate-400 mt-1">
{(strategy.initial_balance + amount).toLocaleString()} USDT /
{(strategy.current_balance + amount).toLocaleString()} USDT
</p>
</div> </div>
{error && <p className="text-xs text-red-500 mb-2">{error}</p>}
{/* Main PnL */} <div className="flex gap-2">
<div className="px-4 pt-3 pb-2"> <button onClick={onClose} className="flex-1 py-2 rounded-lg border border-slate-200 text-sm text-slate-600 hover:bg-slate-50"></button>
<div className="flex items-end justify-between mb-2"> <button
<div> onClick={handleSubmit}
<div className="text-[10px] text-slate-400 mb-0.5"></div> disabled={submitting}
<div className="text-xl font-bold text-slate-800"> className="flex-1 py-2 rounded-lg bg-blue-600 text-white text-sm font-medium hover:bg-blue-700 disabled:opacity-50"
{s.current_balance.toLocaleString()} >
<span className="text-xs font-normal text-slate-400 ml-1">USDT</span> {submitting ? "追加中..." : "确认追加"}
</div> </button>
</div>
<div className="text-right">
<div className="text-[10px] text-slate-400 mb-0.5"></div>
<div className={`text-lg font-bold ${isProfit ? "text-emerald-600" : "text-red-500"}`}>
{isProfit ? "+" : ""}{s.net_usdt.toLocaleString()} U
</div>
</div>
</div>
{/* Balance Bar */}
<div className="mb-3">
<div className="flex justify-between text-[10px] text-slate-400 mb-1">
<span>{balancePct}%</span>
<span>{s.initial_balance.toLocaleString()} USDT </span>
</div>
<div className="w-full bg-slate-100 rounded-full h-1.5">
<div
className={`h-1.5 rounded-full ${isProfit ? "bg-emerald-400" : "bg-red-400"}`}
style={{ width: `${Math.min(100, Math.max(0, (s.current_balance / s.initial_balance) * 100))}%` }}
/>
</div>
</div>
{/* Stats Row */}
<div className="grid grid-cols-3 gap-2 mb-3">
<div className="bg-slate-50 rounded-lg p-2 text-center">
<div className="text-[10px] text-slate-400"></div>
<div className={`text-sm font-bold ${s.win_rate >= 50 ? "text-emerald-600" : s.win_rate >= 45 ? "text-amber-600" : "text-red-500"}`}>
{s.win_rate}%
</div>
</div>
<div className="bg-slate-50 rounded-lg p-2 text-center">
<div className="text-[10px] text-slate-400">R</div>
<div className={`text-sm font-bold ${s.net_r >= 0 ? "text-emerald-600" : "text-red-500"}`}>
{s.net_r >= 0 ? "+" : ""}{s.net_r}R
</div>
</div>
<div className="bg-slate-50 rounded-lg p-2 text-center">
<div className="text-[10px] text-slate-400"></div>
<div className="text-sm font-bold text-slate-700">{s.trade_count}</div>
</div>
</div>
{/* Avg win/loss */}
<div className="flex gap-2 mb-3">
<div className="flex-1 bg-emerald-50 rounded-lg px-2.5 py-1.5">
<span className="text-[10px] text-emerald-600"></span>
<span className="float-right text-[10px] font-bold text-emerald-600">+{s.avg_win_r}R</span>
</div>
<div className="flex-1 bg-red-50 rounded-lg px-2.5 py-1.5">
<span className="text-[10px] text-red-500"></span>
<span className="float-right text-[10px] font-bold text-red-500">{s.avg_loss_r}R</span>
</div>
</div>
</div>
{/* Footer */}
<div className="px-4 py-2.5 border-t border-slate-100 flex items-center justify-between bg-slate-50/60">
<div className="flex items-center gap-1">
{is24hProfit ? (
<TrendingUp size={12} className="text-emerald-500" />
) : (
<TrendingDown size={12} className="text-red-500" />
)}
<span className={`text-[10px] font-medium ${is24hProfit ? "text-emerald-600" : "text-red-500"}`}>
24h {is24hProfit ? "+" : ""}{s.pnl_usdt_24h.toLocaleString()} U
</span>
</div>
<div className="flex items-center gap-1 text-[10px] text-slate-400">
<Activity size={9} />
{s.open_positions > 0 ? (
<span className="text-amber-600 font-medium">{s.open_positions}</span>
) : (
<span>: {formatTime(s.last_trade_at)}</span>
)}
</div>
</div> </div>
</div> </div>
</Link> </div>
); );
} }
// ── StrategyCardComponent ──────────────────────────────────────────────────────
function StrategyCardComponent({
s,
onDeprecate,
onAddBalance,
}: {
s: StrategyCard;
onDeprecate: (s: StrategyCard) => void;
onAddBalance: (s: StrategyCard) => void;
}) {
const isProfit = s.net_usdt >= 0;
const is24hProfit = s.pnl_usdt_24h >= 0;
const balancePct = ((s.current_balance / s.initial_balance) * 100).toFixed(1);
const symbolShort = s.symbol?.replace("USDT", "") || "";
return (
<div className="rounded-xl border border-slate-200 bg-white overflow-hidden hover:border-blue-300 hover:shadow-md transition-all group">
{/* Header */}
<div className="px-4 py-3 border-b border-slate-100 flex items-center justify-between">
<div className="flex items-center gap-2">
<Link href={`/strategy-plaza/${s.strategy_id}`}>
<h3 className="font-semibold text-slate-800 text-sm group-hover:text-blue-700 transition-colors cursor-pointer hover:underline">
{s.display_name}
</h3>
</Link>
<StatusBadge status={s.status} />
{symbolShort && (
<span className="text-[10px] text-slate-400 bg-slate-100 px-1.5 py-0.5 rounded-full">{symbolShort}</span>
)}
</div>
<span className="text-[10px] text-slate-400 flex items-center gap-1">
<Clock size={9} />
{formatDuration(s.started_at)}
</span>
</div>
{/* Main PnL */}
<div className="px-4 pt-3 pb-2">
<div className="flex items-end justify-between mb-2">
<div>
<div className="text-[10px] text-slate-400 mb-0.5"></div>
<div className="text-xl font-bold text-slate-800">
{s.current_balance.toLocaleString()}
<span className="text-xs font-normal text-slate-400 ml-1">USDT</span>
</div>
</div>
<div className="text-right">
<div className="text-[10px] text-slate-400 mb-0.5"></div>
<div className={`text-lg font-bold ${isProfit ? "text-emerald-600" : "text-red-500"}`}>
{isProfit ? "+" : ""}{s.net_usdt.toLocaleString()} U
</div>
</div>
</div>
{/* Balance Bar */}
<div className="mb-3">
<div className="flex justify-between text-[10px] text-slate-400 mb-1">
<span>{balancePct}%</span>
<span>{s.initial_balance.toLocaleString()} USDT </span>
</div>
<div className="w-full bg-slate-100 rounded-full h-1.5">
<div
className={`h-1.5 rounded-full ${isProfit ? "bg-emerald-400" : "bg-red-400"}`}
style={{ width: `${Math.min(100, Math.max(0, (s.current_balance / s.initial_balance) * 100))}%` }}
/>
</div>
</div>
{/* Stats Row */}
<div className="grid grid-cols-3 gap-2 mb-3">
<div className="bg-slate-50 rounded-lg p-2 text-center">
<div className="text-[10px] text-slate-400"></div>
<div className={`text-sm font-bold ${s.win_rate >= 50 ? "text-emerald-600" : s.win_rate >= 45 ? "text-amber-600" : "text-red-500"}`}>
{s.win_rate}%
</div>
</div>
<div className="bg-slate-50 rounded-lg p-2 text-center">
<div className="text-[10px] text-slate-400">R</div>
<div className={`text-sm font-bold ${s.net_r >= 0 ? "text-emerald-600" : "text-red-500"}`}>
{s.net_r >= 0 ? "+" : ""}{s.net_r}R
</div>
</div>
<div className="bg-slate-50 rounded-lg p-2 text-center">
<div className="text-[10px] text-slate-400"></div>
<div className="text-sm font-bold text-slate-700">{s.trade_count}</div>
</div>
</div>
{/* Avg win/loss */}
<div className="flex gap-2 mb-3">
<div className="flex-1 bg-emerald-50 rounded-lg px-2.5 py-1.5">
<span className="text-[10px] text-emerald-600"></span>
<span className="float-right text-[10px] font-bold text-emerald-600">+{s.avg_win_r}R</span>
</div>
<div className="flex-1 bg-red-50 rounded-lg px-2.5 py-1.5">
<span className="text-[10px] text-red-500"></span>
<span className="float-right text-[10px] font-bold text-red-500">{s.avg_loss_r}R</span>
</div>
</div>
</div>
{/* Footer */}
<div className="px-4 py-2.5 border-t border-slate-100 flex items-center justify-between bg-slate-50/60">
<div className="flex items-center gap-1">
{is24hProfit ? (
<TrendingUp size={12} className="text-emerald-500" />
) : (
<TrendingDown size={12} className="text-red-500" />
)}
<span className={`text-[10px] font-medium ${is24hProfit ? "text-emerald-600" : "text-red-500"}`}>
24h {is24hProfit ? "+" : ""}{s.pnl_usdt_24h.toLocaleString()} U
</span>
</div>
<div className="flex items-center gap-1 text-[10px] text-slate-400">
<Activity size={9} />
{s.open_positions > 0 ? (
<span className="text-amber-600 font-medium">{s.open_positions}</span>
) : (
<span>: {formatTime(s.last_trade_at)}</span>
)}
</div>
</div>
{/* Action Buttons */}
<div className="px-4 py-2.5 border-t border-slate-100 flex gap-2">
<Link
href={`/strategy-plaza/${s.strategy_id}/edit`}
className="flex-1 flex items-center justify-center gap-1.5 py-1.5 rounded-lg border border-slate-200 text-[11px] text-slate-600 hover:bg-slate-50 transition-colors"
>
<Settings size={11} />
</Link>
<button
onClick={() => onAddBalance(s)}
className="flex-1 flex items-center justify-center gap-1.5 py-1.5 rounded-lg border border-blue-200 text-[11px] text-blue-600 hover:bg-blue-50 transition-colors"
>
<PlusCircle size={11} />
</button>
<button
onClick={() => onDeprecate(s)}
className="flex items-center justify-center gap-1 px-2.5 py-1.5 rounded-lg border border-red-200 text-[11px] text-red-500 hover:bg-red-50 transition-colors"
>
<Trash2 size={11} />
</button>
</div>
</div>
);
}
// ── Main Page ─────────────────────────────────────────────────────────────────
export default function StrategyPlazaPage() { export default function StrategyPlazaPage() {
useAuth(); useAuth();
const router = useRouter();
const [strategies, setStrategies] = useState<StrategyCard[]>([]); const [strategies, setStrategies] = useState<StrategyCard[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [lastUpdated, setLastUpdated] = useState<Date | null>(null); const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
const [addBalanceTarget, setAddBalanceTarget] = useState<StrategyCard | null>(null);
const fetchData = useCallback(async () => { const fetchData = useCallback(async () => {
try { try {
const res = await authFetch("/api/strategy-plaza"); const res = await authFetch("/api/strategies");
const data = await res.json(); const data = await res.json();
setStrategies(data.strategies || []); setStrategies(data.strategies || []);
setLastUpdated(new Date()); setLastUpdated(new Date());
@ -220,6 +336,20 @@ export default function StrategyPlazaPage() {
return () => clearInterval(interval); return () => clearInterval(interval);
}, [fetchData]); }, [fetchData]);
const handleDeprecate = async (s: StrategyCard) => {
if (!confirm(`确认废弃策略「${s.display_name}」?\n\n废弃后策略停止运行数据永久保留可在废弃列表中重新启用。`)) return;
try {
await authFetch(`/api/strategies/${s.strategy_id}/deprecate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ confirm: true }),
});
await fetchData();
} catch (e) {
console.error(e);
}
};
if (loading) { if (loading) {
return ( return (
<div className="flex items-center justify-center min-h-64"> <div className="flex items-center justify-center min-h-64">
@ -234,25 +364,57 @@ export default function StrategyPlazaPage() {
<div className="flex items-center justify-between mb-5"> <div className="flex items-center justify-between mb-5">
<div> <div>
<h1 className="text-lg font-bold text-slate-800">广</h1> <h1 className="text-lg font-bold text-slate-800">广</h1>
<p className="text-slate-500 text-xs mt-0.5"></p> <p className="text-slate-500 text-xs mt-0.5"></p>
</div>
<div className="flex items-center gap-3">
{lastUpdated && (
<div className="text-[10px] text-slate-400 flex items-center gap-1">
<span className="inline-block w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse" />
{lastUpdated.toLocaleTimeString("zh-CN", { timeZone: "Asia/Shanghai", hour12: false })}
</div>
)}
<button
onClick={() => router.push("/strategy-plaza/create")}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-blue-600 text-white text-xs font-medium hover:bg-blue-700 transition-colors"
>
<Plus size={13} />
</button>
</div> </div>
{lastUpdated && (
<div className="text-[10px] text-slate-400 flex items-center gap-1">
<span className="inline-block w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse" />
{lastUpdated.toLocaleTimeString("zh-CN", { timeZone: "Asia/Shanghai", hour12: false })}
</div>
)}
</div> </div>
{/* Strategy Cards */} {/* Strategy Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{strategies.map((s) => ( {strategies.map((s) => (
<StrategyCardComponent key={s.id} s={s} /> <StrategyCardComponent
key={s.strategy_id}
s={s}
onDeprecate={handleDeprecate}
onAddBalance={setAddBalanceTarget}
/>
))} ))}
</div> </div>
{strategies.length === 0 && ( {strategies.length === 0 && (
<div className="text-center text-slate-400 text-sm py-16"></div> <div className="text-center text-slate-400 text-sm py-16">
<p className="mb-3"></p>
<button
onClick={() => router.push("/strategy-plaza/create")}
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-xl bg-blue-600 text-white text-sm hover:bg-blue-700"
>
<Plus size={14} />
</button>
</div>
)}
{/* Add Balance Modal */}
{addBalanceTarget && (
<AddBalanceModal
strategy={addBalanceTarget}
onClose={() => setAddBalanceTarget(null)}
onSuccess={fetchData}
/>
)} )}
</div> </div>
); );

View File

@ -7,7 +7,7 @@ import { useAuth } from "@/lib/auth";
import { import {
LayoutDashboard, Info, LayoutDashboard, Info,
Menu, X, Zap, LogIn, UserPlus, Menu, X, Zap, LogIn, UserPlus,
ChevronLeft, ChevronRight, Activity, LogOut, Monitor, LineChart, Bolt ChevronLeft, ChevronRight, Activity, LogOut, Monitor, LineChart, Bolt, Archive
} from "lucide-react"; } from "lucide-react";
const navItems = [ const navItems = [
@ -15,6 +15,7 @@ const navItems = [
{ href: "/trades", label: "成交流", icon: Activity }, { href: "/trades", label: "成交流", icon: Activity },
{ href: "/live", label: "⚡ 实盘交易", icon: Bolt, section: "── 实盘 ──" }, { href: "/live", label: "⚡ 实盘交易", icon: Bolt, section: "── 实盘 ──" },
{ href: "/strategy-plaza", label: "策略广场", icon: Zap, section: "── 策略 ──" }, { href: "/strategy-plaza", label: "策略广场", icon: Zap, section: "── 策略 ──" },
{ href: "/strategy-plaza/deprecated", label: "废弃策略", icon: Archive },
{ href: "/server", label: "服务器", icon: Monitor }, { href: "/server", label: "服务器", icon: Monitor },
{ href: "/about", label: "说明", icon: Info }, { href: "/about", label: "说明", icon: Info },
]; ];

View File

@ -0,0 +1,462 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { authFetch } from "@/lib/auth";
import { useAuth } from "@/lib/auth";
import Link from "next/link";
import { ArrowLeft, Save, Info } from "lucide-react";
// ─── Types ────────────────────────────────────────────────────────────────────
export interface StrategyFormData {
display_name: string;
symbol: string;
direction: string;
initial_balance: number;
cvd_fast_window: string;
cvd_slow_window: string;
weight_direction: number;
weight_env: number;
weight_aux: number;
weight_momentum: number;
entry_score: number;
gate_obi_enabled: boolean;
obi_threshold: number;
gate_whale_enabled: boolean;
whale_cvd_threshold: number;
gate_vol_enabled: boolean;
atr_percentile_min: number;
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;
description: string;
}
export const DEFAULT_FORM: StrategyFormData = {
display_name: "",
symbol: "BTCUSDT",
direction: "both",
initial_balance: 10000,
cvd_fast_window: "30m",
cvd_slow_window: "4h",
weight_direction: 55,
weight_env: 25,
weight_aux: 15,
weight_momentum: 5,
entry_score: 75,
gate_obi_enabled: true,
obi_threshold: 0.3,
gate_whale_enabled: true,
whale_cvd_threshold: 0.0,
gate_vol_enabled: true,
atr_percentile_min: 20,
gate_spot_perp_enabled: false,
spot_perp_threshold: 0.002,
sl_atr_multiplier: 1.5,
tp1_ratio: 0.75,
tp2_ratio: 1.5,
timeout_minutes: 240,
flip_threshold: 80,
description: "",
};
// ─── Helper Components ────────────────────────────────────────────────────────
function FieldLabel({ label, hint }: { label: string; hint?: string }) {
return (
<div className="flex items-center gap-1 mb-1">
<span className="text-xs font-medium text-slate-600">{label}</span>
{hint && (
<span className="group relative">
<Info size={11} className="text-slate-400 cursor-help" />
<span className="hidden group-hover:block absolute left-4 top-0 z-10 w-48 text-[10px] bg-slate-800 text-white rounded px-2 py-1">
{hint}
</span>
</span>
)}
</div>
);
}
function NumberInput({
value, onChange, min, max, step = 1, disabled = false
}: {
value: number; onChange: (v: number) => void;
min: number; max: number; step?: number; disabled?: boolean;
}) {
return (
<input
type="number"
value={value}
min={min}
max={max}
step={step}
disabled={disabled}
onChange={(e) => {
const v = parseFloat(e.target.value);
if (!isNaN(v)) onChange(Math.min(max, Math.max(min, v)));
}}
className="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm text-slate-800 focus:outline-none focus:ring-2 focus:ring-blue-300 disabled:bg-slate-50 disabled:text-slate-400"
/>
);
}
function SelectInput({
value, onChange, options
}: {
value: string; onChange: (v: string) => void;
options: { label: string; value: string }[];
}) {
return (
<select
value={value}
onChange={(e) => onChange(e.target.value)}
className="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm text-slate-800 focus:outline-none focus:ring-2 focus:ring-blue-300 bg-white"
>
{options.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
);
}
function GateRow({
label, hint, enabled, onToggle, children
}: {
label: string; hint: string; enabled: boolean; onToggle: () => void; children: React.ReactNode;
}) {
return (
<div className={`border rounded-lg p-3 transition-colors ${enabled ? "border-blue-200 bg-blue-50/30" : "border-slate-200 bg-slate-50/50"}`}>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-1">
<span className="text-xs font-medium text-slate-700">{label}</span>
<span className="group relative">
<Info size={11} className="text-slate-400 cursor-help" />
<span className="hidden group-hover:block absolute left-4 top-0 z-10 w-52 text-[10px] bg-slate-800 text-white rounded px-2 py-1">
{hint}
</span>
</span>
</div>
<button
type="button"
onClick={onToggle}
className={`relative w-10 h-5 rounded-full transition-colors ${enabled ? "bg-blue-500" : "bg-slate-300"}`}
>
<span className={`absolute top-0.5 w-4 h-4 bg-white rounded-full shadow transition-transform ${enabled ? "translate-x-5" : "translate-x-0.5"}`} />
</button>
</div>
{enabled && <div className="mt-2">{children}</div>}
</div>
);
}
// ─── Main Form Component ──────────────────────────────────────────────────────
interface StrategyFormProps {
mode: "create" | "edit";
initialData: StrategyFormData;
strategyId?: string;
onSuccess?: (id: string) => void;
isBalanceEditable?: boolean;
}
export default function StrategyForm({ mode, initialData, strategyId, onSuccess, isBalanceEditable = true }: StrategyFormProps) {
useAuth();
const router = useRouter();
const [form, setForm] = useState<StrategyFormData>(initialData);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState("");
const set = <K extends keyof StrategyFormData>(key: K, value: StrategyFormData[K]) => {
setForm((prev) => ({ ...prev, [key]: value }));
};
const weightsTotal = form.weight_direction + form.weight_env + form.weight_aux + form.weight_momentum;
const weightsOk = weightsTotal === 100;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!weightsOk) {
setError(`权重合计必须等于 100当前为 ${weightsTotal}`);
return;
}
if (!form.display_name.trim()) {
setError("策略名称不能为空");
return;
}
setError("");
setSubmitting(true);
try {
const payload: Record<string, unknown> = { ...form };
if (!payload.description) delete payload.description;
let res: Response;
if (mode === "create") {
res = await authFetch("/api/strategies", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
} else {
// Edit: only send changed fields (excluding symbol & initial_balance)
const { symbol: _s, initial_balance: _b, ...editPayload } = payload;
void _s; void _b;
res = await authFetch(`/api/strategies/${strategyId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(editPayload),
});
}
if (!res.ok) {
const err = await res.json();
throw new Error(err.detail?.[0]?.msg || err.detail || "请求失败");
}
const data = await res.json();
const newId = data.strategy?.strategy_id || strategyId || "";
if (onSuccess) onSuccess(newId);
else router.push(`/strategy-plaza/${newId}`);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "未知错误");
} finally {
setSubmitting(false);
}
};
return (
<form onSubmit={handleSubmit} className="space-y-6">
{/* ── 基础信息 ── */}
<div className="bg-white border border-slate-200 rounded-xl p-4">
<h3 className="text-sm font-semibold text-slate-700 mb-3"></h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<FieldLabel label="策略名称" hint="自由命名最多50字符" />
<input
type="text"
value={form.display_name}
onChange={(e) => set("display_name", e.target.value)}
placeholder="例如我的BTC激进策略"
maxLength={50}
className="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm text-slate-800 focus:outline-none focus:ring-2 focus:ring-blue-300"
/>
</div>
<div>
<FieldLabel label="交易对" />
<SelectInput
value={form.symbol}
onChange={(v) => set("symbol", v)}
options={[
{ label: "BTC/USDT", value: "BTCUSDT" },
{ label: "ETH/USDT", value: "ETHUSDT" },
{ label: "SOL/USDT", value: "SOLUSDT" },
{ label: "XRP/USDT", value: "XRPUSDT" },
]}
/>
</div>
<div>
<FieldLabel label="交易方向" />
<SelectInput
value={form.direction}
onChange={(v) => set("direction", v)}
options={[
{ label: "多空双向", value: "both" },
{ label: "只做多", value: "long_only" },
{ label: "只做空", value: "short_only" },
]}
/>
</div>
<div>
<FieldLabel label="初始资金 (USDT)" hint="最少 1,000 USDT" />
<NumberInput
value={form.initial_balance}
onChange={(v) => set("initial_balance", v)}
min={1000}
max={1000000}
step={1000}
disabled={mode === "edit" && !isBalanceEditable}
/>
</div>
<div className="md:col-span-2">
<FieldLabel label="策略描述(可选)" />
<input
type="text"
value={form.description}
onChange={(e) => set("description", e.target.value)}
placeholder="简短描述这个策略的思路"
maxLength={200}
className="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm text-slate-800 focus:outline-none focus:ring-2 focus:ring-blue-300"
/>
</div>
</div>
</div>
{/* ── CVD 窗口 ── */}
<div className="bg-white border border-slate-200 rounded-xl p-4">
<h3 className="text-sm font-semibold text-slate-700 mb-3">CVD </h3>
<div className="grid grid-cols-2 gap-4">
<div>
<FieldLabel label="快线 CVD" hint="短周期CVD捕捉近期买卖力量" />
<SelectInput
value={form.cvd_fast_window}
onChange={(v) => set("cvd_fast_window", v)}
options={[
{ label: "5m超短线", value: "5m" },
{ label: "15m短线", value: "15m" },
{ label: "30m中短线", value: "30m" },
]}
/>
</div>
<div>
<FieldLabel label="慢线 CVD" hint="长周期CVD反映趋势方向" />
<SelectInput
value={form.cvd_slow_window}
onChange={(v) => set("cvd_slow_window", v)}
options={[
{ label: "30m", value: "30m" },
{ label: "1h推荐", value: "1h" },
{ label: "4h趋势", value: "4h" },
]}
/>
</div>
</div>
</div>
{/* ── 四层权重 ── */}
<div className="bg-white border border-slate-200 rounded-xl p-4">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold text-slate-700"></h3>
<span className={`text-xs font-bold px-2 py-0.5 rounded-full ${weightsOk ? "bg-emerald-100 text-emerald-700" : "bg-red-100 text-red-600"}`}>
: {weightsTotal}/100
</span>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<FieldLabel label="方向得分权重" hint="CVD方向信号的权重建议50-65" />
<NumberInput value={form.weight_direction} onChange={(v) => set("weight_direction", v)} min={10} max={80} />
</div>
<div>
<FieldLabel label="环境得分权重" hint="市场环境OI/FR/资金费率)的权重" />
<NumberInput value={form.weight_env} onChange={(v) => set("weight_env", v)} min={5} max={60} />
</div>
<div>
<FieldLabel label="辅助因子权重" hint="清算/现货溢价等辅助信号的权重" />
<NumberInput value={form.weight_aux} onChange={(v) => set("weight_aux", v)} min={0} max={40} />
</div>
<div>
<FieldLabel label="动量权重" hint="短期价格动量信号的权重" />
<NumberInput value={form.weight_momentum} onChange={(v) => set("weight_momentum", v)} min={0} max={20} />
</div>
</div>
</div>
{/* ── 入场阈值 ── */}
<div className="bg-white border border-slate-200 rounded-xl p-4">
<h3 className="text-sm font-semibold text-slate-700 mb-3"></h3>
<div className="grid grid-cols-2 gap-4">
<div>
<FieldLabel label="入场最低总分" hint="信号总分超过此值才开仓默认75越高越严格" />
<NumberInput value={form.entry_score} onChange={(v) => set("entry_score", v)} min={60} max={95} />
</div>
</div>
</div>
{/* ── 四道 Gate ── */}
<div className="bg-white border border-slate-200 rounded-xl p-4">
<h3 className="text-sm font-semibold text-slate-700 mb-3">Gate</h3>
<div className="space-y-3">
<GateRow
label="Gate 1订单簿失衡 (OBI)"
hint="要求订单簿方向与信号一致OBI越高越严格"
enabled={form.gate_obi_enabled}
onToggle={() => set("gate_obi_enabled", !form.gate_obi_enabled)}
>
<FieldLabel label="OBI 最低阈值" hint="0.1(宽松) ~ 0.9(严格)默认0.3" />
<NumberInput value={form.obi_threshold} onChange={(v) => set("obi_threshold", v)} min={0.1} max={0.9} step={0.05} />
</GateRow>
<GateRow
label="Gate 2大单 CVD 门"
hint="要求大单累积净买入方向与信号一致"
enabled={form.gate_whale_enabled}
onToggle={() => set("gate_whale_enabled", !form.gate_whale_enabled)}
>
<FieldLabel label="Whale CVD 阈值" hint="-1到1多头信号时大单CVD需>此值默认0" />
<NumberInput value={form.whale_cvd_threshold} onChange={(v) => set("whale_cvd_threshold", v)} min={-1} max={1} step={0.1} />
</GateRow>
<GateRow
label="Gate 3波动率门 (ATR%)"
hint="要求当前ATR百分位高于最低值过滤低波动时段"
enabled={form.gate_vol_enabled}
onToggle={() => set("gate_vol_enabled", !form.gate_vol_enabled)}
>
<FieldLabel label="ATR 最低百分位" hint="0-80默认20低于此值不开仓" />
<NumberInput value={form.atr_percentile_min} onChange={(v) => set("atr_percentile_min", v)} min={5} max={80} />
</GateRow>
<GateRow
label="Gate 4现货/永续溢价门"
hint="要求现货与永续价格溢价低于阈值,过滤套利异常时段"
enabled={form.gate_spot_perp_enabled}
onToggle={() => set("gate_spot_perp_enabled", !form.gate_spot_perp_enabled)}
>
<FieldLabel label="溢价率阈值" hint="0.0005~0.01,溢价超过此值不开仓" />
<NumberInput value={form.spot_perp_threshold} onChange={(v) => set("spot_perp_threshold", v)} min={0.0005} max={0.01} step={0.0005} />
</GateRow>
</div>
</div>
{/* ── 风控参数 ── */}
<div className="bg-white border border-slate-200 rounded-xl p-4">
<h3 className="text-sm font-semibold text-slate-700 mb-3"></h3>
<div className="grid grid-cols-2 gap-4">
<div>
<FieldLabel label="SL 宽度 (× ATR)" hint="止损距离 = SL倍数 × ATR默认1.5" />
<NumberInput value={form.sl_atr_multiplier} onChange={(v) => set("sl_atr_multiplier", v)} min={0.5} max={3.0} step={0.1} />
</div>
<div>
<FieldLabel label="TP1 目标 (× RD)" hint="第一止盈 = TP1倍数 × 风险距离默认0.75" />
<NumberInput value={form.tp1_ratio} onChange={(v) => set("tp1_ratio", v)} min={0.3} max={2.0} step={0.05} />
</div>
<div>
<FieldLabel label="TP2 目标 (× RD)" hint="第二止盈 = TP2倍数 × 风险距离默认1.5" />
<NumberInput value={form.tp2_ratio} onChange={(v) => set("tp2_ratio", v)} min={0.5} max={4.0} step={0.1} />
</div>
<div>
<FieldLabel label="超时时间 (分钟)" hint="持仓超过此时间自动平仓默认240min" />
<NumberInput value={form.timeout_minutes} onChange={(v) => set("timeout_minutes", v)} min={30} max={1440} step={30} />
</div>
<div>
<FieldLabel label="反转平仓阈值 (分)" hint="对手方向信号分≥此值时平仓默认80" />
<NumberInput value={form.flip_threshold} onChange={(v) => set("flip_threshold", v)} min={60} max={95} />
</div>
</div>
</div>
{/* ── Error & Submit ── */}
{error && (
<div className="bg-red-50 border border-red-200 rounded-lg px-4 py-2 text-sm text-red-600">
{error}
</div>
)}
<div className="flex gap-3">
<Link
href="/strategy-plaza"
className="flex items-center gap-2 px-4 py-2.5 rounded-xl border border-slate-200 text-sm text-slate-600 hover:bg-slate-50 transition-colors"
>
<ArrowLeft size={15} />
</Link>
<button
type="submit"
disabled={submitting || !weightsOk}
className="flex items-center gap-2 px-6 py-2.5 rounded-xl bg-blue-600 text-white text-sm font-medium hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<Save size={15} />
{submitting ? "保存中..." : mode === "create" ? "创建并启动" : "保存参数"}
</button>
</div>
</form>
);
}