arbitrage-engine/frontend/app/strategy-plaza/page.tsx

578 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { 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,
Clock,
Activity,
AlertCircle,
CheckCircle,
PauseCircle,
Plus,
Settings,
Trash2,
PlusCircle,
} from "lucide-react";
interface StrategyCard {
strategy_id: string;
display_name: string;
symbol: string;
status: "running" | "paused" | "error";
started_at: number;
initial_balance: number;
current_balance: number;
net_usdt: number;
net_r: number;
trade_count: number;
win_rate: number;
avg_win_r: number;
avg_loss_r: number;
open_positions: number;
pnl_usdt_24h: number;
pnl_r_24h: number;
std_r: number;
last_trade_at: number | null;
}
function formatDuration(ms: number): string {
const totalSec = Math.floor((Date.now() - ms) / 1000);
const d = Math.floor(totalSec / 86400);
const h = Math.floor((totalSec % 86400) / 3600);
const m = Math.floor((totalSec % 3600) / 60);
if (d > 0) return `${d}${h}小时`;
if (h > 0) return `${h}小时 ${m}`;
return `${m}分钟`;
}
function formatTime(ms: number | null): string {
if (!ms) return "—";
const d = new Date(ms);
return d.toLocaleString("zh-CN", {
timeZone: "Asia/Shanghai",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
}
function StatusBadge({ status }: { status: string }) {
if (status === "running") {
return (
<span className="flex items-center gap-1 text-xs text-emerald-600 font-medium">
<CheckCircle size={11} className="text-emerald-500" />
</span>
);
}
if (status === "paused") {
return (
<span className="flex items-center gap-1 text-xs text-amber-600 font-medium">
<PauseCircle size={11} className="text-amber-500" />
</span>
);
}
return (
<span className="flex items-center gap-1 text-xs text-red-600 font-medium">
<AlertCircle size={11} className="text-red-500" />
</span>
);
}
// ── 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 is24hProfit = s.pnl_usdt_24h >= 0;
const balancePct = ((s.current_balance / s.initial_balance) * 100).toFixed(1);
const symbolShort = s.symbol?.replace("USDT", "") || "";
return (
<div className="rounded-xl border border-slate-200 bg-white overflow-hidden hover:border-blue-300 hover:shadow-md transition-all group">
{/* Header */}
<div className="px-4 py-3 border-b border-slate-100 flex items-center justify-between">
<div className="flex items-center gap-2">
<Link href={`/strategy-plaza/${s.strategy_id}`}>
<h3 className="font-semibold text-slate-800 text-sm group-hover:text-blue-700 transition-colors cursor-pointer hover:underline">
{s.display_name}
</h3>
</Link>
<StatusBadge status={s.status} />
{symbolShort && (
<span className="text-[10px] text-slate-400 bg-slate-100 px-1.5 py-0.5 rounded-full">{symbolShort}</span>
)}
</div>
<span className="text-[10px] text-slate-400 flex items-center gap-1">
<Clock size={9} />
{formatDuration(s.started_at)}
</span>
</div>
{/* Main PnL */}
<div className="px-4 pt-3 pb-2">
<div className="flex items-end justify-between mb-2">
<div>
<div className="text-[10px] text-slate-400 mb-0.5"></div>
<div className="text-xl font-bold text-slate-800">
{s.current_balance.toLocaleString()}
<span className="text-xs font-normal text-slate-400 ml-1">USDT</span>
</div>
</div>
<div className="text-right">
<div className="text-[10px] text-slate-400 mb-0.5"></div>
<div className={`text-lg font-bold ${isProfit ? "text-emerald-600" : "text-red-500"}`}>
{isProfit ? "+" : ""}{s.net_usdt.toLocaleString()} U
</div>
</div>
</div>
{/* Balance Bar */}
<div className="mb-3">
<div className="flex justify-between text-[10px] text-slate-400 mb-1">
<span>{balancePct}%</span>
<span>{s.initial_balance.toLocaleString()} USDT </span>
</div>
<div className="w-full bg-slate-100 rounded-full h-1.5">
<div
className={`h-1.5 rounded-full ${isProfit ? "bg-emerald-400" : "bg-red-400"}`}
style={{ width: `${Math.min(100, Math.max(0, (s.current_balance / s.initial_balance) * 100))}%` }}
/>
</div>
</div>
{/* Stats Row */}
<div className="grid grid-cols-3 gap-2 mb-3">
<div className="bg-slate-50 rounded-lg p-2 text-center">
<div className="text-[10px] text-slate-400"></div>
<div className={`text-sm font-bold ${s.win_rate >= 50 ? "text-emerald-600" : s.win_rate >= 45 ? "text-amber-600" : "text-red-500"}`}>
{s.win_rate}%
</div>
</div>
<div className="bg-slate-50 rounded-lg p-2 text-center">
<div className="text-[10px] text-slate-400">R</div>
<div className={`text-sm font-bold ${s.net_r >= 0 ? "text-emerald-600" : "text-red-500"}`}>
{s.net_r >= 0 ? "+" : ""}{s.net_r}R
</div>
</div>
<div className="bg-slate-50 rounded-lg p-2 text-center">
<div className="text-[10px] text-slate-400"></div>
<div className="text-sm font-bold text-slate-700">{s.trade_count}</div>
</div>
</div>
{/* Avg win/loss */}
<div className="flex gap-2 mb-3">
<div className="flex-1 bg-emerald-50 rounded-lg px-2.5 py-1.5">
<span className="text-[10px] text-emerald-600"></span>
<span className="float-right text-[10px] font-bold text-emerald-600">+{s.avg_win_r}R</span>
</div>
<div className="flex-1 bg-red-50 rounded-lg px-2.5 py-1.5">
<span className="text-[10px] text-red-500"></span>
<span className="float-right text-[10px] font-bold text-red-500">{s.avg_loss_r}R</span>
</div>
</div>
</div>
{/* Footer */}
<div className="px-4 py-2.5 border-t border-slate-100 flex items-center justify-between bg-slate-50/60">
<div className="flex items-center gap-1">
{is24hProfit ? (
<TrendingUp size={12} className="text-emerald-500" />
) : (
<TrendingDown size={12} className="text-red-500" />
)}
<span className={`text-[10px] font-medium ${is24hProfit ? "text-emerald-600" : "text-red-500"}`}>
24h {is24hProfit ? "+" : ""}{s.pnl_usdt_24h.toLocaleString()} U
</span>
</div>
<div className="flex items-center gap-1 text-[10px] text-slate-400">
<Activity size={9} />
{s.open_positions > 0 ? (
<span className="text-amber-600 font-medium">{s.open_positions}</span>
) : (
<span>: {formatTime(s.last_trade_at)}</span>
)}
</div>
</div>
{/* Action Buttons */}
<div className="px-4 py-2.5 border-t border-slate-100 flex gap-2">
<Link
href={`/strategy-plaza/${s.strategy_id}/edit`}
className="flex-1 flex items-center justify-center gap-1.5 py-1.5 rounded-lg border border-slate-200 text-[11px] text-slate-600 hover:bg-slate-50 transition-colors"
>
<Settings size={11} />
</Link>
<button
onClick={() => onAddBalance(s)}
className="flex-1 flex items-center justify-center gap-1.5 py-1.5 rounded-lg border border-blue-200 text-[11px] text-blue-600 hover:bg-blue-50 transition-colors"
>
<PlusCircle size={11} />
</button>
<button
onClick={() => onDeprecate(s)}
className="flex items-center justify-center gap-1 px-2.5 py-1.5 rounded-lg border border-red-200 text-[11px] text-red-500 hover:bg-red-50 transition-colors"
>
<Trash2 size={11} />
</button>
</div>
</div>
);
}
// ── Main Page ─────────────────────────────────────────────────────────────────
export default function StrategyPlazaPage() {
useAuth();
const router = useRouter();
const [strategies, setStrategies] = useState<StrategyCard[]>([]);
const [loading, setLoading] = useState(true);
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
const [addBalanceTarget, setAddBalanceTarget] = useState<StrategyCard | null>(null);
type SymbolFilter = "ALL" | "BTCUSDT" | "ETHUSDT" | "XRPUSDT" | "SOLUSDT";
type StatusFilter = "all" | "running" | "paused" | "error";
type PnlFilter = "all" | "positive" | "negative";
type PositionFilter = "all" | "with_open" | "no_open";
type SortKey = "recent" | "net_usdt_desc" | "net_usdt_asc" | "pnl24h_desc" | "pnl24h_asc";
const [symbolFilter, setSymbolFilter] = useState<SymbolFilter>("ALL");
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
const [pnlFilter, setPnlFilter] = useState<PnlFilter>("all");
const [positionFilter, setPositionFilter] = useState<PositionFilter>("all");
const [sortKey, setSortKey] = useState<SortKey>("net_usdt_desc");
const fetchData = useCallback(async () => {
try {
const res = await authFetch("/api/strategies");
const data = await res.json();
setStrategies(data.strategies || []);
setLastUpdated(new Date());
} catch (e) {
console.error(e);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchData();
const interval = setInterval(fetchData, 30000);
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);
}
};
const filteredStrategies = strategies
.filter((s) => {
if (symbolFilter !== "ALL" && s.symbol !== symbolFilter) return false;
if (statusFilter !== "all" && s.status !== statusFilter) return false;
if (pnlFilter === "positive" && s.net_usdt <= 0) return false;
if (pnlFilter === "negative" && s.net_usdt >= 0) return false;
if (positionFilter === "with_open" && s.open_positions <= 0) return false;
if (positionFilter === "no_open" && s.open_positions > 0) return false;
return true;
})
.sort((a, b) => {
switch (sortKey) {
case "net_usdt_desc":
return b.net_usdt - a.net_usdt;
case "net_usdt_asc":
return a.net_usdt - b.net_usdt;
case "pnl24h_desc":
return b.pnl_usdt_24h - a.pnl_usdt_24h;
case "pnl24h_asc":
return a.pnl_usdt_24h - b.pnl_usdt_24h;
case "recent":
default: {
const aTs = a.last_trade_at ?? a.started_at ?? 0;
const bTs = b.last_trade_at ?? b.started_at ?? 0;
return bTs - aTs;
}
}
});
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">
{/* Header */}
<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>
<div className="flex items-center gap-3">
{lastUpdated && (
<div className="text-[10px] text-slate-400 flex items-center gap-1">
<span className="inline-block w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse" />
{lastUpdated.toLocaleTimeString("zh-CN", { timeZone: "Asia/Shanghai", hour12: false })}
</div>
)}
<button
onClick={() => router.push("/strategy-plaza/create")}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-blue-600 text-white text-xs font-medium hover:bg-blue-700 transition-colors"
>
<Plus size={13} />
</button>
</div>
</div>
{/* Filters & Sorting */}
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
{/* 左侧:币种 + 盈亏过滤 */}
<div className="flex flex-wrap items-center gap-2">
<div className="flex items-center gap-1 text-[11px] text-slate-400">
<span>:</span>
{(["ALL", "BTCUSDT", "ETHUSDT", "XRPUSDT", "SOLUSDT"] as SymbolFilter[]).map((sym) => {
const label = sym === "ALL" ? "全部" : sym.replace("USDT", "");
const active = symbolFilter === sym;
return (
<button
key={sym}
onClick={() => setSymbolFilter(sym)}
className={`px-2 py-0.5 rounded-full border text-[11px] ${
active
? "border-blue-500 bg-blue-50 text-blue-600"
: "border-slate-200 text-slate-500 hover:bg-slate-50"
}`}
>
{label}
</button>
);
})}
</div>
<div className="flex items-center gap-1 text-[11px] text-slate-400">
<span>:</span>
{[
{ key: "all" as PnlFilter, label: "全部" },
{ key: "positive" as PnlFilter, label: "仅盈利" },
{ key: "negative" as PnlFilter, label: "仅亏损" },
].map((opt) => {
const active = pnlFilter === opt.key;
return (
<button
key={opt.key}
onClick={() => setPnlFilter(opt.key)}
className={`px-2 py-0.5 rounded-full border text-[11px] ${
active
? "border-emerald-500 bg-emerald-50 text-emerald-600"
: "border-slate-200 text-slate-500 hover:bg-slate-50"
}`}
>
{opt.label}
</button>
);
})}
</div>
</div>
{/* 右侧:状态 + 持仓 + 排序 */}
<div className="flex flex-wrap items-center gap-2">
<div className="flex items-center gap-1 text-[11px] text-slate-400">
<span>:</span>
{[
{ key: "all" as StatusFilter, label: "全部" },
{ key: "running" as StatusFilter, label: "运行中" },
{ key: "paused" as StatusFilter, label: "已暂停" },
{ key: "error" as StatusFilter, label: "异常" },
].map((opt) => {
const active = statusFilter === opt.key;
return (
<button
key={opt.key}
onClick={() => setStatusFilter(opt.key)}
className={`px-2 py-0.5 rounded-full border text-[11px] ${
active
? "border-slate-700 bg-slate-800 text-white"
: "border-slate-200 text-slate-500 hover:bg-slate-50"
}`}
>
{opt.label}
</button>
);
})}
</div>
<div className="flex items-center gap-1 text-[11px] text-slate-400">
<span>:</span>
{[
{ key: "all" as PositionFilter, label: "全部" },
{ key: "with_open" as PositionFilter, label: "有持仓" },
{ key: "no_open" as PositionFilter, label: "无持仓" },
].map((opt) => {
const active = positionFilter === opt.key;
return (
<button
key={opt.key}
onClick={() => setPositionFilter(opt.key)}
className={`px-2 py-0.5 rounded-full border text-[11px] ${
active
? "border-amber-500 bg-amber-50 text-amber-600"
: "border-slate-200 text-slate-500 hover:bg-slate-50"
}`}
>
{opt.label}
</button>
);
})}
</div>
<div className="flex items-center gap-1 text-[11px] text-slate-400">
<span>:</span>
<select
value={sortKey}
onChange={(e) => setSortKey(e.target.value as SortKey)}
className="border border-slate-200 rounded-lg px-2 py-1 text-[11px] text-slate-700 bg-white"
>
<option value="net_usdt_desc"></option>
<option value="net_usdt_asc"></option>
<option value="pnl24h_desc">24h </option>
<option value="pnl24h_asc">24h </option>
<option value="recent"></option>
</select>
</div>
</div>
</div>
{/* Strategy Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{filteredStrategies.map((s) => (
<StrategyCardComponent
key={s.strategy_id}
s={s}
onDeprecate={handleDeprecate}
onAddBalance={setAddBalanceTarget}
/>
))}
</div>
{filteredStrategies.length === 0 && (
<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>
);
}