diff --git a/frontend/app/strategy-plaza/[id]/edit/page.tsx b/frontend/app/strategy-plaza/[id]/edit/page.tsx new file mode 100644 index 0000000..c0fe57d --- /dev/null +++ b/frontend/app/strategy-plaza/[id]/edit/page.tsx @@ -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(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
加载中...
; + if (error) return
{error}
; + if (!formData) return null; + + return ( +
+
+

调整策略参数

+

修改保存后15秒内生效,不影响当前持仓

+
+ router.push(`/strategy-plaza/${sid}`)} + /> +
+ ); +} diff --git a/frontend/app/strategy-plaza/[id]/page.tsx b/frontend/app/strategy-plaza/[id]/page.tsx index 0b47768..f2b0264 100644 --- a/frontend/app/strategy-plaza/[id]/page.tsx +++ b/frontend/app/strategy-plaza/[id]/page.tsx @@ -11,6 +11,7 @@ import { PauseCircle, AlertCircle, Clock, + Settings, } from "lucide-react"; // ─── 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 PaperV53Middle = dynamic(() => import("@/app/paper-v53middle/page"), { ssr: false }); +// ─── UUID → legacy strategy name map ───────────────────────────── +const UUID_TO_LEGACY: Record = { + "00000000-0000-0000-0000-000000000053": "v53", + "00000000-0000-0000-0000-000000000054": "v53_middle", + "00000000-0000-0000-0000-000000000055": "v53_fast", +}; + // ─── Types ──────────────────────────────────────────────────────── interface StrategySummary { - id: string; + strategy_id?: string; + id?: string; display_name: string; status: string; started_at: number; @@ -39,9 +48,36 @@ interface StrategySummary { pnl_usdt_24h: number; pnl_r_24h: number; cvd_windows?: string; + cvd_fast_window?: string; + cvd_slow_window?: 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 ────────────────────────────────────────────────────── function fmtDur(ms: number) { const s = Math.floor((Date.now() - ms) / 1000); @@ -54,24 +90,104 @@ function fmtDur(ms: number) { } function StatusBadge({ status }: { status: string }) { - if (status === "running") return 运行中; - if (status === "paused") return 已暂停; - return 异常; + if (status === "running") return 运行中; + if (status === "paused") return 已暂停; + return 异常; +} + +// ─── Config Tab ─────────────────────────────────────────────────── +function ConfigTab({ detail, strategyId }: { detail: StrategyDetail; strategyId: string }) { + const router = useRouter(); + + const row = (label: string, value: string | number | boolean) => ( +
+ {label} + {String(value)} +
+ ); + + const gateRow = (label: string, enabled: boolean, threshold: string) => ( +
+
+ + {label} +
+ + {enabled ? threshold : "已关闭"} + +
+ ); + + return ( +
+
+ +
+ +
+ {/* 基础配置 */} +
+

基础配置

+ {row("交易对", detail.symbol)} + {row("交易方向", detail.direction === "both" ? "多空双向" : detail.direction === "long_only" ? "只做多" : "只做空")} + {row("CVD 快线", detail.cvd_fast_window)} + {row("CVD 慢线", detail.cvd_slow_window)} +
+ + {/* 四层权重 */} +
+

四层权重

+ {row("方向权重", `${detail.weight_direction}%`)} + {row("环境权重", `${detail.weight_env}%`)} + {row("辅助权重", `${detail.weight_aux}%`)} + {row("动量权重", `${detail.weight_momentum}%`)} + {row("入场阈值", `${detail.entry_score} 分`)} +
+ + {/* 四道 Gate */} +
+

过滤门控 (Gate)

+ {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}`)} +
+ + {/* 风控参数 */} +
+

风控参数

+ {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} 分`)} +
+
+
+ ); } // ─── Content router ─────────────────────────────────────────────── function SignalsContent({ strategyId }: { strategyId: string }) { - if (strategyId === "v53") return ; - if (strategyId === "v53_fast") return ; - if (strategyId === "v53_middle") return ; - return
未知策略: {strategyId}
; + const legacy = UUID_TO_LEGACY[strategyId] || strategyId; + if (legacy === "v53") return ; + if (legacy === "v53_fast") return ; + if (legacy === "v53_middle") return ; + return
暂无信号引擎页面
; } function PaperContent({ strategyId }: { strategyId: string }) { - if (strategyId === "v53") return ; - if (strategyId === "v53_fast") return ; - if (strategyId === "v53_middle") return ; - return
未知策略: {strategyId}
; + const legacy = UUID_TO_LEGACY[strategyId] || strategyId; + if (legacy === "v53") return ; + if (legacy === "v53_fast") return ; + if (legacy === "v53_middle") return ; + return
暂无模拟盘页面
; } // ─── Main Page ──────────────────────────────────────────────────── @@ -83,9 +199,64 @@ export default function StrategyDetailPage() { const tab = searchParams?.get("tab") || "signals"; const [summary, setSummary] = useState(null); + const [detail, setDetail] = useState(null); 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 { const r = await authFetch(`/api/strategy-plaza/${strategyId}/summary`); if (r.ok) { @@ -97,10 +268,10 @@ export default function StrategyDetailPage() { }, [strategyId]); useEffect(() => { - fetchSummary(); - const iv = setInterval(fetchSummary, 30000); + fetchData().finally(() => setLoading(false)); + const iv = setInterval(fetchData, 30000); return () => clearInterval(iv); - }, [fetchSummary]); + }, [fetchData]); if (loading) { return ( @@ -111,17 +282,20 @@ export default function StrategyDetailPage() { } 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 (
{/* Back + Strategy Header */}
- + 策略广场 - / - {summary?.display_name ?? strategyId} + / + {summary?.display_name ?? strategyId}
{/* Summary Bar */} @@ -131,8 +305,8 @@ export default function StrategyDetailPage() { 运行 {fmtDur(summary.started_at)} - {summary.cvd_windows && ( - CVD {summary.cvd_windows} + {cvdLabel && ( + CVD {cvdLabel} )} 胜率 = 50 ? "text-emerald-600 font-bold" : "text-amber-600 font-bold"}>{summary.win_rate}% @@ -148,6 +322,7 @@ export default function StrategyDetailPage() { {[ { key: "signals", label: "📊 信号引擎" }, { key: "paper", label: "📈 模拟盘" }, + { key: "config", label: "⚙️ 参数配置" }, ].map(({ key, label }) => (
diff --git a/frontend/app/strategy-plaza/create/page.tsx b/frontend/app/strategy-plaza/create/page.tsx new file mode 100644 index 0000000..514baf8 --- /dev/null +++ b/frontend/app/strategy-plaza/create/page.tsx @@ -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 ( +
+
+

新增策略

+

配置策略参数,创建后立即开始运行

+
+ router.push(`/strategy-plaza/${id}`)} + /> +
+ ); +} diff --git a/frontend/app/strategy-plaza/deprecated/page.tsx b/frontend/app/strategy-plaza/deprecated/page.tsx new file mode 100644 index 0000000..d7b7fe2 --- /dev/null +++ b/frontend/app/strategy-plaza/deprecated/page.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [restoring, setRestoring] = useState(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 ( +
+
加载中...
+
+ ); + } + + return ( +
+
+
+

废弃策略

+

数据永久保留,可随时重新启用

+
+ + ← 返回策略广场 + +
+ + {strategies.length === 0 ? ( +
暂无废弃策略
+ ) : ( +
+ {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 ( +
+ {/* Header */} +
+
+

{s.display_name}

+ 已废弃 +
+ {s.symbol.replace("USDT", "")} +
+ + {/* PnL */} +
+
+
+
废弃时余额
+
+ {s.current_balance.toLocaleString()} + USDT +
+
+
+
累计盈亏
+
+ {isProfit ? "+" : ""}{s.net_usdt.toLocaleString()} U +
+
+
+ + {/* Balance bar */} +
+
+ {balancePct}% + {s.initial_balance.toLocaleString()} USDT 初始 +
+
+
+
+
+ + {/* Stats */} +
+
+
胜率
+
{s.win_rate}%
+
+
+
净R
+
= 0 ? "text-emerald-600" : "text-red-500"}`}> + {s.net_r >= 0 ? "+" : ""}{s.net_r}R +
+
+
+
交易数
+
{s.trade_count}
+
+
+
+ + {/* Footer */} +
+
+ {is24hProfit ? : } + + 废弃于 {formatTime(s.deprecated_at)} + +
+ +
+
+ ); + })} +
+ )} +
+ ); +} diff --git a/frontend/app/strategy-plaza/page.tsx b/frontend/app/strategy-plaza/page.tsx index 473725e..27038b5 100644 --- a/frontend/app/strategy-plaza/page.tsx +++ b/frontend/app/strategy-plaza/page.tsx @@ -4,6 +4,7 @@ import { useEffect, useState, useCallback } from "react"; import { authFetch } from "@/lib/auth"; import { useAuth } from "@/lib/auth"; import Link from "next/link"; +import { useRouter } from "next/navigation"; import { TrendingUp, TrendingDown, @@ -12,11 +13,16 @@ import { AlertCircle, CheckCircle, PauseCircle, + Plus, + Settings, + Trash2, + PlusCircle, } from "lucide-react"; interface StrategyCard { - id: string; + strategy_id: string; display_name: string; + symbol: string; status: "running" | "paused" | "error"; started_at: number; initial_balance: number; @@ -82,128 +88,238 @@ function StatusBadge({ status }: { status: string }) { ); } -function StrategyCardComponent({ s }: { s: StrategyCard }) { - const isProfit = s.net_usdt >= 0; - const is24hProfit = s.pnl_usdt_24h >= 0; - const balancePct = ((s.current_balance / s.initial_balance) * 100).toFixed(1); +// ── 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 ( - -
- {/* Header */} -
-
-

- {s.display_name} -

- -
- - - {formatDuration(s.started_at)} - +
+
+

追加余额

+

策略:{strategy.display_name}

+
+ + setAmount(parseFloat(e.target.value) || 0)} + className="w-full border border-slate-200 rounded-lg px-3 py-2 text-sm" + /> +

+ 追加后初始资金:{(strategy.initial_balance + amount).toLocaleString()} USDT / + 余额:{(strategy.current_balance + amount).toLocaleString()} USDT +

- - {/* Main PnL */} -
-
-
-
当前余额
-
- {s.current_balance.toLocaleString()} - USDT -
-
-
-
累计盈亏
-
- {isProfit ? "+" : ""}{s.net_usdt.toLocaleString()} U -
-
-
- - {/* Balance Bar */} -
-
- {balancePct}% - {s.initial_balance.toLocaleString()} USDT 初始 -
-
-
-
-
- - {/* Stats Row */} -
-
-
胜率
-
= 50 ? "text-emerald-600" : s.win_rate >= 45 ? "text-amber-600" : "text-red-500"}`}> - {s.win_rate}% -
-
-
-
净R
-
= 0 ? "text-emerald-600" : "text-red-500"}`}> - {s.net_r >= 0 ? "+" : ""}{s.net_r}R -
-
-
-
交易数
-
{s.trade_count}
-
-
- - {/* Avg win/loss */} -
-
- 平均赢 - +{s.avg_win_r}R -
-
- 平均亏 - {s.avg_loss_r}R -
-
-
- - {/* Footer */} -
-
- {is24hProfit ? ( - - ) : ( - - )} - - 24h {is24hProfit ? "+" : ""}{s.pnl_usdt_24h.toLocaleString()} U - -
-
- - {s.open_positions > 0 ? ( - {s.open_positions}仓持仓中 - ) : ( - 上次: {formatTime(s.last_trade_at)} - )} -
+ {error &&

{error}

} +
+ +
- +
); } +// ── 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 ( +
+ {/* Header */} +
+
+ +

+ {s.display_name} +

+ + + {symbolShort && ( + {symbolShort} + )} +
+ + + {formatDuration(s.started_at)} + +
+ + {/* Main PnL */} +
+
+
+
当前余额
+
+ {s.current_balance.toLocaleString()} + USDT +
+
+
+
累计盈亏
+
+ {isProfit ? "+" : ""}{s.net_usdt.toLocaleString()} U +
+
+
+ + {/* Balance Bar */} +
+
+ {balancePct}% + {s.initial_balance.toLocaleString()} USDT 初始 +
+
+
+
+
+ + {/* Stats Row */} +
+
+
胜率
+
= 50 ? "text-emerald-600" : s.win_rate >= 45 ? "text-amber-600" : "text-red-500"}`}> + {s.win_rate}% +
+
+
+
净R
+
= 0 ? "text-emerald-600" : "text-red-500"}`}> + {s.net_r >= 0 ? "+" : ""}{s.net_r}R +
+
+
+
交易数
+
{s.trade_count}
+
+
+ + {/* Avg win/loss */} +
+
+ 平均赢 + +{s.avg_win_r}R +
+
+ 平均亏 + {s.avg_loss_r}R +
+
+
+ + {/* Footer */} +
+
+ {is24hProfit ? ( + + ) : ( + + )} + + 24h {is24hProfit ? "+" : ""}{s.pnl_usdt_24h.toLocaleString()} U + +
+
+ + {s.open_positions > 0 ? ( + {s.open_positions}仓持仓中 + ) : ( + 上次: {formatTime(s.last_trade_at)} + )} +
+
+ + {/* Action Buttons */} +
+ + + 调整参数 + + + +
+
+ ); +} + +// ── Main Page ───────────────────────────────────────────────────────────────── export default function StrategyPlazaPage() { useAuth(); + const router = useRouter(); const [strategies, setStrategies] = useState([]); const [loading, setLoading] = useState(true); const [lastUpdated, setLastUpdated] = useState(null); + const [addBalanceTarget, setAddBalanceTarget] = useState(null); const fetchData = useCallback(async () => { try { - const res = await authFetch("/api/strategy-plaza"); + const res = await authFetch("/api/strategies"); const data = await res.json(); setStrategies(data.strategies || []); setLastUpdated(new Date()); @@ -220,6 +336,20 @@ export default function StrategyPlazaPage() { return () => clearInterval(interval); }, [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) { return (
@@ -234,25 +364,57 @@ export default function StrategyPlazaPage() {

策略广场

-

点击卡片查看信号引擎和模拟盘详情

+

点击策略名查看信号引擎和模拟盘详情

+
+
+ {lastUpdated && ( +
+ + {lastUpdated.toLocaleTimeString("zh-CN", { timeZone: "Asia/Shanghai", hour12: false })} +
+ )} +
- {lastUpdated && ( -
- - {lastUpdated.toLocaleTimeString("zh-CN", { timeZone: "Asia/Shanghai", hour12: false })} -
- )}
{/* Strategy Cards */}
{strategies.map((s) => ( - + ))}
{strategies.length === 0 && ( -
暂无策略数据
+
+

暂无运行中的策略

+ +
+ )} + + {/* Add Balance Modal */} + {addBalanceTarget && ( + setAddBalanceTarget(null)} + onSuccess={fetchData} + /> )}
); diff --git a/frontend/components/Sidebar.tsx b/frontend/components/Sidebar.tsx index 1228219..afdba0c 100644 --- a/frontend/components/Sidebar.tsx +++ b/frontend/components/Sidebar.tsx @@ -7,7 +7,7 @@ import { useAuth } from "@/lib/auth"; import { LayoutDashboard, Info, Menu, X, Zap, LogIn, UserPlus, - ChevronLeft, ChevronRight, Activity, LogOut, Monitor, LineChart, Bolt + ChevronLeft, ChevronRight, Activity, LogOut, Monitor, LineChart, Bolt, Archive } from "lucide-react"; const navItems = [ @@ -15,6 +15,7 @@ const navItems = [ { href: "/trades", label: "成交流", icon: Activity }, { href: "/live", label: "⚡ 实盘交易", icon: Bolt, section: "── 实盘 ──" }, { href: "/strategy-plaza", label: "策略广场", icon: Zap, section: "── 策略 ──" }, + { href: "/strategy-plaza/deprecated", label: "废弃策略", icon: Archive }, { href: "/server", label: "服务器", icon: Monitor }, { href: "/about", label: "说明", icon: Info }, ]; diff --git a/frontend/components/StrategyForm.tsx b/frontend/components/StrategyForm.tsx new file mode 100644 index 0000000..b5a85aa --- /dev/null +++ b/frontend/components/StrategyForm.tsx @@ -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 ( +
+ {label} + {hint && ( + + + + {hint} + + + )} +
+ ); +} + +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 ( + { + 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 ( + + ); +} + +function GateRow({ + label, hint, enabled, onToggle, children +}: { + label: string; hint: string; enabled: boolean; onToggle: () => void; children: React.ReactNode; +}) { + return ( +
+
+
+ {label} + + + + {hint} + + +
+ +
+ {enabled &&
{children}
} +
+ ); +} + +// ─── 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(initialData); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(""); + + const set = (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 = { ...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 ( +
+ {/* ── 基础信息 ── */} +
+

基础信息

+
+
+ + 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" + /> +
+
+ + set("symbol", v)} + options={[ + { label: "BTC/USDT", value: "BTCUSDT" }, + { label: "ETH/USDT", value: "ETHUSDT" }, + { label: "SOL/USDT", value: "SOLUSDT" }, + { label: "XRP/USDT", value: "XRPUSDT" }, + ]} + /> +
+
+ + set("direction", v)} + options={[ + { label: "多空双向", value: "both" }, + { label: "只做多", value: "long_only" }, + { label: "只做空", value: "short_only" }, + ]} + /> +
+
+ + set("initial_balance", v)} + min={1000} + max={1000000} + step={1000} + disabled={mode === "edit" && !isBalanceEditable} + /> +
+
+ + 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" + /> +
+
+
+ + {/* ── CVD 窗口 ── */} +
+

CVD 窗口

+
+
+ + set("cvd_fast_window", v)} + options={[ + { label: "5m(超短线)", value: "5m" }, + { label: "15m(短线)", value: "15m" }, + { label: "30m(中短线)", value: "30m" }, + ]} + /> +
+
+ + set("cvd_slow_window", v)} + options={[ + { label: "30m", value: "30m" }, + { label: "1h(推荐)", value: "1h" }, + { label: "4h(趋势)", value: "4h" }, + ]} + /> +
+
+
+ + {/* ── 四层权重 ── */} +
+
+

四层评分权重

+ + 合计: {weightsTotal}/100 + +
+
+
+ + set("weight_direction", v)} min={10} max={80} /> +
+
+ + set("weight_env", v)} min={5} max={60} /> +
+
+ + set("weight_aux", v)} min={0} max={40} /> +
+
+ + set("weight_momentum", v)} min={0} max={20} /> +
+
+
+ + {/* ── 入场阈值 ── */} +
+

入场阈值

+
+
+ + set("entry_score", v)} min={60} max={95} /> +
+
+
+ + {/* ── 四道 Gate ── */} +
+

过滤门控(Gate)

+
+ set("gate_obi_enabled", !form.gate_obi_enabled)} + > + + set("obi_threshold", v)} min={0.1} max={0.9} step={0.05} /> + + set("gate_whale_enabled", !form.gate_whale_enabled)} + > + + set("whale_cvd_threshold", v)} min={-1} max={1} step={0.1} /> + + set("gate_vol_enabled", !form.gate_vol_enabled)} + > + + set("atr_percentile_min", v)} min={5} max={80} /> + + set("gate_spot_perp_enabled", !form.gate_spot_perp_enabled)} + > + + set("spot_perp_threshold", v)} min={0.0005} max={0.01} step={0.0005} /> + +
+
+ + {/* ── 风控参数 ── */} +
+

风控参数

+
+
+ + set("sl_atr_multiplier", v)} min={0.5} max={3.0} step={0.1} /> +
+
+ + set("tp1_ratio", v)} min={0.3} max={2.0} step={0.05} /> +
+
+ + set("tp2_ratio", v)} min={0.5} max={4.0} step={0.1} /> +
+
+ + set("timeout_minutes", v)} min={30} max={1440} step={30} /> +
+
+ + set("flip_threshold", v)} min={60} max={95} /> +
+
+
+ + {/* ── Error & Submit ── */} + {error && ( +
+ {error} +
+ )} + +
+ + + 返回 + + +
+
+ ); +}