feat(v54-frontend): add create/edit/deprecated pages, config tab, sidebar entry
This commit is contained in:
parent
9d44885188
commit
f8f13a48d5
75
frontend/app/strategy-plaza/[id]/edit/page.tsx
Normal file
75
frontend/app/strategy-plaza/[id]/edit/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
|||||||
24
frontend/app/strategy-plaza/create/page.tsx
Normal file
24
frontend/app/strategy-plaza/create/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
196
frontend/app/strategy-plaza/deprecated/page.tsx
Normal file
196
frontend/app/strategy-plaza/deprecated/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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,21 +88,105 @@ function StatusBadge({ status }: { status: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function StrategyCardComponent({ s }: { s: StrategyCard }) {
|
// ── AddBalanceModal ────────────────────────────────────────────────────────────
|
||||||
|
function AddBalanceModal({
|
||||||
|
strategy,
|
||||||
|
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 (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
|
||||||
|
<div className="bg-white rounded-xl shadow-xl p-5 w-80">
|
||||||
|
<h3 className="font-semibold text-slate-800 text-sm mb-1">追加余额</h3>
|
||||||
|
<p className="text-[11px] text-slate-500 mb-3">策略:{strategy.display_name}</p>
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="text-xs text-slate-600 mb-1 block">追加金额 (USDT)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={amount}
|
||||||
|
min={100}
|
||||||
|
step={100}
|
||||||
|
onChange={(e) => setAmount(parseFloat(e.target.value) || 0)}
|
||||||
|
className="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
<p className="text-[10px] text-slate-400 mt-1">
|
||||||
|
追加后初始资金:{(strategy.initial_balance + amount).toLocaleString()} USDT /
|
||||||
|
余额:{(strategy.current_balance + amount).toLocaleString()} USDT
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-xs text-red-500 mb-2">{error}</p>}
|
||||||
|
<div className="flex gap-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>
|
||||||
|
<button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={submitting}
|
||||||
|
className="flex-1 py-2 rounded-lg bg-blue-600 text-white text-sm font-medium hover:bg-blue-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{submitting ? "追加中..." : "确认追加"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── StrategyCardComponent ──────────────────────────────────────────────────────
|
||||||
|
function StrategyCardComponent({
|
||||||
|
s,
|
||||||
|
onDeprecate,
|
||||||
|
onAddBalance,
|
||||||
|
}: {
|
||||||
|
s: StrategyCard;
|
||||||
|
onDeprecate: (s: StrategyCard) => void;
|
||||||
|
onAddBalance: (s: StrategyCard) => void;
|
||||||
|
}) {
|
||||||
const isProfit = s.net_usdt >= 0;
|
const isProfit = s.net_usdt >= 0;
|
||||||
const is24hProfit = s.pnl_usdt_24h >= 0;
|
const is24hProfit = s.pnl_usdt_24h >= 0;
|
||||||
const balancePct = ((s.current_balance / s.initial_balance) * 100).toFixed(1);
|
const balancePct = ((s.current_balance / s.initial_balance) * 100).toFixed(1);
|
||||||
|
const symbolShort = s.symbol?.replace("USDT", "") || "";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link href={`/strategy-plaza/${s.id}`}>
|
<div className="rounded-xl border border-slate-200 bg-white overflow-hidden hover:border-blue-300 hover:shadow-md transition-all group">
|
||||||
<div className="rounded-xl border border-slate-200 bg-white overflow-hidden hover:border-blue-300 hover:shadow-md transition-all cursor-pointer group">
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="px-4 py-3 border-b border-slate-100 flex items-center justify-between">
|
<div className="px-4 py-3 border-b border-slate-100 flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<h3 className="font-semibold text-slate-800 text-sm group-hover:text-blue-700 transition-colors">
|
<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}
|
{s.display_name}
|
||||||
</h3>
|
</h3>
|
||||||
|
</Link>
|
||||||
<StatusBadge status={s.status} />
|
<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>
|
</div>
|
||||||
<span className="text-[10px] text-slate-400 flex items-center gap-1">
|
<span className="text-[10px] text-slate-400 flex items-center gap-1">
|
||||||
<Clock size={9} />
|
<Clock size={9} />
|
||||||
@ -190,20 +280,46 @@ function StrategyCardComponent({ s }: { s: StrategyCard }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</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>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
{lastUpdated && (
|
{lastUpdated && (
|
||||||
<div className="text-[10px] text-slate-400 flex items-center gap-1">
|
<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" />
|
<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 })}
|
{lastUpdated.toLocaleTimeString("zh-CN", { timeZone: "Asia/Shanghai", hour12: false })}
|
||||||
</div>
|
</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>
|
</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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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 },
|
||||||
];
|
];
|
||||||
|
|||||||
462
frontend/components/StrategyForm.tsx
Normal file
462
frontend/components/StrategyForm.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user