diff --git a/dashboard-next/.gitignore b/dashboard-next/.gitignore
new file mode 100644
index 0000000..53da332
--- /dev/null
+++ b/dashboard-next/.gitignore
@@ -0,0 +1,5 @@
+node_modules/
+.next/
+out/
+out-www/
+_next-app-backup/
diff --git a/dashboard-next/components.json b/dashboard-next/components.json
new file mode 100644
index 0000000..30fbc61
--- /dev/null
+++ b/dashboard-next/components.json
@@ -0,0 +1,21 @@
+{
+ "$schema": "https://ui.shadcn.com/schema.json",
+ "style": "new-york",
+ "rsc": true,
+ "tsx": true,
+ "tailwind": {
+ "config": "",
+ "css": "src/app/globals.css",
+ "baseColor": "slate",
+ "cssVariables": true,
+ "prefix": ""
+ },
+ "iconLibrary": "lucide",
+ "aliases": {
+ "components": "@/components",
+ "utils": "@/lib/utils",
+ "ui": "@/components/ui",
+ "lib": "@/lib",
+ "hooks": "@/hooks"
+ }
+}
diff --git a/dashboard-next/next.config.ts b/dashboard-next/next.config.ts
new file mode 100644
index 0000000..566e9b1
--- /dev/null
+++ b/dashboard-next/next.config.ts
@@ -0,0 +1,10 @@
+import type { NextConfig } from "next";
+
+const nextConfig: NextConfig = {
+ output: "export",
+ images: { unoptimized: true },
+ trailingSlash: true,
+ basePath: "/cv",
+};
+
+export default nextConfig;
diff --git a/dashboard-next/package.json b/dashboard-next/package.json
new file mode 100644
index 0000000..4e1a056
--- /dev/null
+++ b/dashboard-next/package.json
@@ -0,0 +1,34 @@
+{
+ "name": "ftdt-quant-dashboard",
+ "version": "1.0.0",
+ "private": true,
+ "scripts": {
+ "dev": "next dev",
+ "build": "next build",
+ "start": "next start",
+ "lint": "next lint"
+ },
+ "dependencies": {
+ "@radix-ui/react-collapsible": "^1.1.0",
+ "@radix-ui/react-dialog": "^1.1.0",
+ "@radix-ui/react-select": "^2.1.0",
+ "@radix-ui/react-tabs": "^1.1.0",
+ "class-variance-authority": "^0.7.1",
+ "clsx": "^2.1.1",
+ "framer-motion": "^11.0.0",
+ "lightweight-charts": "^4.2.0",
+ "lucide-react": "^0.454.0",
+ "next": "^16.3.0",
+ "react": "^19.0.0",
+ "react-dom": "^19.0.0",
+ "tailwind-merge": "^2.6.0",
+ "tailwindcss": "^4.0.0"
+ },
+ "devDependencies": {
+ "@tailwindcss/postcss": "^4.0.0",
+ "@types/node": "^20.0.0",
+ "@types/react": "^19.0.0",
+ "@types/react-dom": "^19.0.0",
+ "typescript": "^5.0.0"
+ }
+}
diff --git a/dashboard-next/postcss.config.mjs b/dashboard-next/postcss.config.mjs
new file mode 100644
index 0000000..7059fe9
--- /dev/null
+++ b/dashboard-next/postcss.config.mjs
@@ -0,0 +1,6 @@
+const config = {
+ plugins: {
+ "@tailwindcss/postcss": {},
+ },
+};
+export default config;
diff --git a/dashboard-next/src/app/globals.css b/dashboard-next/src/app/globals.css
new file mode 100644
index 0000000..b5adc38
--- /dev/null
+++ b/dashboard-next/src/app/globals.css
@@ -0,0 +1,78 @@
+@import "tailwindcss";
+
+@custom-variant dark (&:is(.dark *));
+
+@theme inline {
+ --color-background: var(--background);
+ --color-foreground: var(--foreground);
+ --color-card: var(--card);
+ --color-card-foreground: var(--card-foreground);
+ --color-popover: var(--popover);
+ --color-popover-foreground: var(--popover-foreground);
+ --color-primary: var(--primary);
+ --color-primary-foreground: var(--primary-foreground);
+ --color-secondary: var(--secondary);
+ --color-secondary-foreground: var(--secondary-foreground);
+ --color-muted: var(--muted);
+ --color-muted-foreground: var(--muted-foreground);
+ --color-accent: var(--accent);
+ --color-accent-foreground: var(--accent-foreground);
+ --color-destructive: var(--destructive);
+ --color-border: var(--border);
+ --color-input: var(--input);
+ --color-ring: var(--ring);
+ --color-chart-1: var(--chart-1);
+ --color-chart-2: var(--chart-2);
+ --color-chart-3: var(--chart-3);
+ --color-chart-4: var(--chart-4);
+ --color-chart-5: var(--chart-5);
+ --radius-sm: calc(var(--radius) - 4px);
+ --radius-md: calc(var(--radius) - 2px);
+ --radius-lg: var(--radius);
+ --radius-xl: calc(var(--radius) + 4px);
+ --font-sans: var(--font-inter), ui-sans-serif, system-ui, sans-serif;
+ --font-mono: var(--font-jetbrains-mono), ui-monospace, monospace;
+}
+
+:root {
+ --radius: 0.5rem;
+}
+
+.dark {
+ --background: oklch(0.0588 0.0162 269.6475);
+ --foreground: oklch(0.985 0 0);
+ --card: oklch(0.1059 0.0201 269.5991);
+ --card-foreground: oklch(0.985 0 0);
+ --popover: oklch(0.1059 0.0201 269.5991);
+ --popover-foreground: oklch(0.985 0 0);
+ --primary: oklch(0.985 0 0);
+ --primary-foreground: oklch(0.0588 0.0162 269.6475);
+ --secondary: oklch(0.1776 0 0);
+ --secondary-foreground: oklch(0.985 0 0);
+ --muted: oklch(0.1776 0 0);
+ --muted-foreground: oklch(0.7559 0.0125 239.9659);
+ --accent: oklch(0.1776 0 0);
+ --accent-foreground: oklch(0.985 0 0);
+ --destructive: oklch(0.602 0.2378 25.3312);
+ --border: oklch(1 0 0 / 0.1);
+ --input: oklch(1 0 0 / 0.15);
+ --ring: oklch(0.7559 0.0125 239.9659);
+ --chart-1: oklch(0.646 0.222 41.116);
+ --chart-2: oklch(0.6 0.118 184.704);
+ --chart-3: oklch(0.398 0.07 227.392);
+ --chart-4: oklch(0.828 0.189 84.429);
+ --chart-5: oklch(0.769 0.188 70.08);
+}
+
+* {
+ border-color: var(--border);
+ outline-color: var(--ring);
+}
+
+body {
+ background: var(--background);
+ color: var(--foreground);
+ font-family: var(--font-sans);
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
diff --git a/dashboard-next/src/app/layout.tsx b/dashboard-next/src/app/layout.tsx
new file mode 100644
index 0000000..d922890
--- /dev/null
+++ b/dashboard-next/src/app/layout.tsx
@@ -0,0 +1,28 @@
+import type { Metadata } from "next";
+import { Inter, JetBrains_Mono } from "next/font/google";
+import "./globals.css";
+
+const inter = Inter({
+ subsets: ["latin"],
+ variable: "--font-inter",
+});
+
+const jetbrainsMono = JetBrains_Mono({
+ subsets: ["latin"],
+ variable: "--font-jetbrains-mono",
+});
+
+export const metadata: Metadata = {
+ title: "FTDT Quant Lab",
+ description: "Professional quantitative trading dashboard — live testnet, paper mainnet, historical backtests",
+};
+
+export default function RootLayout({ children }: { children: React.ReactNode }) {
+ return (
+
+
+ {children}
+
+
+ );
+}
diff --git a/dashboard-next/src/app/page.tsx b/dashboard-next/src/app/page.tsx
new file mode 100644
index 0000000..43145a2
--- /dev/null
+++ b/dashboard-next/src/app/page.tsx
@@ -0,0 +1,349 @@
+"use client";
+
+import { useState, useEffect, useCallback } from "react";
+import { motion, AnimatePresence } from "framer-motion";
+import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
+import { ChevronDown, ChevronRight, Activity, Database, TrendingUp, TrendingDown, ArrowLeft } from "lucide-react";
+import { StrategyCard } from "@/components/strategy-card";
+import { EquityChart } from "@/components/equity-chart";
+import { PositionsPanel } from "@/components/positions-panel";
+import { useLiveMetrics, usePaperMetrics, fetchHistorical, fetchBacktestDetail, recalcBacktest } from "@/lib/api";
+import type { Strategy, BacktestSummary, BacktestFull, Trade, Position, Order } from "@/lib/types";
+
+type Tab = "live" | "paper" | "historical";
+
+const STRAT_COLORS = ["#22c55e","#3b82f6","#a855f7","#f59e0b","#ef4444","#06b6d4","#ec4899","#84cc16","#6366f1","#14b8a6","#f97316","#8b5cf6"];
+
+export default function Dashboard() {
+ const [tab, setTab] = useState("live");
+ const { data: liveData, connected: liveConn } = useLiveMetrics();
+ const { data: paperData } = usePaperMetrics();
+ const [historical, setHistorical] = useState>({});
+
+ const [detailOpen, setDetailOpen] = useState(false);
+ const [detailName, setDetailName] = useState("");
+ const [detailTab, setDetailTab] = useState("live");
+ const [btFull, setBtFull] = useState(null);
+ const [feeOn, setFeeOn] = useState(true);
+ const [feeTier, setFeeTier] = useState(0);
+ const [stakingTier, setStakingTier] = useState("none");
+ const [posOpen, setPosOpen] = useState(false);
+
+ useEffect(() => { fetchHistorical().then(setHistorical); }, []);
+
+ const strategies = tab === "live" ? liveData?.strategies ?? {}
+ : tab === "paper" ? paperData?.strategies ?? {}
+ : {};
+
+ const handleCardClick = useCallback(async (name: string, t: Tab) => {
+ setDetailName(name);
+ setDetailTab(t);
+ setBtFull(null);
+ setDetailOpen(true);
+ if (t === "historical") {
+ const ht = historical[name];
+ if (ht) {
+ try {
+ const full = await fetchBacktestDetail(ht.name);
+ setBtFull(full);
+ } catch { /* ignore */ }
+ }
+ }
+ }, [historical]);
+
+ const handleFeeRecalc = useCallback(async () => {
+ if (!detailName || detailTab !== "historical") return;
+ const ht = historical[detailName];
+ if (!ht) return;
+ try {
+ const full = await recalcBacktest(ht.name, feeTier, stakingTier);
+ setBtFull(full);
+ } catch { /* ignore */ }
+ }, [detailName, detailTab, feeTier, stakingTier, historical]);
+
+ const detailStrat = detailTab === "historical" ? null
+ : (tab === "paper" ? paperData : liveData)?.strategies?.[detailName] ?? null;
+
+ let detailEquity: { t: number; v: number }[] = [];
+ let detailTrades: Trade[] = [];
+ let detailPositions: Position[] = [];
+ let detailOrders: Order[] = [];
+
+ if (detailTab === "live" && liveData) {
+ if (detailName) {
+ detailEquity = (liveData.strategy_equity ?? {})[detailName] ?? liveData.equity_history ?? [];
+ }
+ detailTrades = (liveData.trades ?? []).filter((t) => t.strategy === detailName);
+ detailPositions = (liveData.open_positions ?? []).filter((p) => p.strategy === detailName);
+ detailOrders = liveData.open_orders ?? [];
+ } else if (detailTab === "paper" && paperData) {
+ if (detailName) {
+ detailEquity = (paperData.strategy_equity ?? {})[detailName] ?? paperData.equity_history ?? [];
+ }
+ detailTrades = (paperData.per_strategy_trades ?? {})[detailName] ?? [];
+ detailPositions = (paperData.open_positions ?? []).filter((p) => p.strategy === detailName);
+ detailOrders = paperData.open_orders ?? [];
+ } else if (detailTab === "historical" && btFull) {
+ detailEquity = (btFull.equity_curve ?? []).map((e) => ({
+ t: typeof e.t === "string" ? Math.floor(new Date(e.t).getTime() / 1000) : e.t,
+ v: e.v,
+ }));
+ detailTrades = btFull.trades ?? [];
+ }
+
+ if (detailOpen) {
+ return (
+
+
+
+
+
+
+
{detailName}
+
+ {detailStrat && (
+ <>
+ {detailStrat.type}
+
+ {detailStrat.status?.toUpperCase()}
+
+ {detailStrat.instrument}
+ >
+ )}
+ {detailTab === "historical" && btFull && (
+
+ 30d · {btFull.total_trades} trades · {btFull.fee_model ?? "taker"} model
+
+ )}
+
+
+
+ {detailTab === "historical" && (
+
+
+
+
+
+
+ )}
+
+
+
+
+ {detailStrat && (
+
+ {detailStrat.description || "No description available."}
+
+ )}
+ {detailTab === "historical" && btFull && (
+
+ {btFull.strategy} — {btFull.num_periods} periods, {btFull.total_trades} trades,
+ total fees ${btFull.fees_total?.toFixed(2)}, model: {btFull.fee_model ?? "taker"}
+
+ )}
+
+
+ {detailStrat ? (
+ [
+ { l: "PnL", v: `$${detailStrat.pnl?.toFixed(4)}` },
+ { l: "PnL%", v: `${detailStrat.pnl_pct >= 0 ? "+" : ""}${detailStrat.pnl_pct?.toFixed(2)}%`, up: detailStrat.pnl_pct >= 0 },
+ { l: "Trades", v: String(detailStrat.trades_today ?? 0) },
+ { l: "Win Rate", v: `${Math.round((detailStrat.win_rate ?? 0) * 100)}%` },
+ { l: "Fees", v: `$${(detailStrat.fee_paid ?? 0).toFixed(4)}`, up: false },
+ { l: "Position", v: (detailStrat.position ?? 0).toFixed(4) },
+ ].map(({ l, v, up }) => (
+
+ ))
+ ) : detailTab === "historical" && btFull ? (
+ [
+ { l: feeOn ? "Net PnL" : "Gross PnL", v: `${(feeOn ? (btFull.pnl_net_pct ?? btFull.pnl_pct ?? 0) : (btFull.pnl_gross_pct ?? btFull.pnl_pct ?? 0)) >= 0 ? "+" : ""}${(feeOn ? (btFull.pnl_net_pct ?? btFull.pnl_pct ?? 0) : (btFull.pnl_gross_pct ?? btFull.pnl_pct ?? 0)).toFixed(2)}%`, up: (feeOn ? (btFull.pnl_net_pct ?? btFull.pnl_pct ?? 0) : (btFull.pnl_gross_pct ?? btFull.pnl_pct ?? 0)) >= 0 },
+ { l: "Sharpe", v: (btFull.sharpe ?? 0).toFixed(2) },
+ { l: "Sortino", v: (btFull.sortino ?? 0).toFixed(2) },
+ { l: "Max DD", v: `${((btFull.max_dd ?? 0) * 100).toFixed(1)}%`, up: false },
+ { l: "Win Rate", v: `${Math.round((btFull.win_rate ?? 0) * 100)}%` },
+ { l: "Fees", v: `$${(btFull.fees_total ?? 0).toFixed(2)}`, up: false },
+ ].map(({ l, v, up }) => (
+
+ ))
+ ) : (
+
Loading...
+ )}
+
+
+ {detailEquity.length > 0 && (
+
+
+
+ )}
+
+ {detailTab !== "historical" && (detailPositions.length > 0 || detailOrders.length > 0) && (
+
+
+ Open Positions & Orders {detailName ? `for ${detailName}` : ""}
+
+
+
+ )}
+
+
+
+ Trade History {detailTrades.length > 0 ? `(${detailTrades.length})` : ""}
+
+ {detailTrades.length > 0 ? (
+
+
+
+
+ Time
+ Side
+ Size
+ Price
+ PnL
+ Fee
+ Reason
+
+
+
+ {detailTrades.slice(-200).reverse().map((t, i) => {
+ const tp = detailTab === "historical"
+ ? (feeOn ? (t.pnl_net ?? t.pnl ?? 0) : (t.pnl_gross ?? t.pnl ?? 0))
+ : (t.pnl ?? 0);
+ return (
+
+ {(t.time ?? "").substring(0, 16)}
+
+ = 0 ? "bg-green-500/10 text-green-500" : "bg-red-500/10 text-red-500"}`}>
+ {t.side ?? "—"}
+
+
+ {t.size}
+ ${(t.price ?? 0).toFixed(1)}
+ = 0 ? "text-green-500" : "text-red-500"}`}>
+ {tp >= 0 ? "+" : ""}${Math.abs(tp).toFixed(4)}
+
+ ${(t.fee ?? 0).toFixed(4)}
+
+ {t.reason ?? "—"}
+
+
+ );
+ })}
+
+
+
+ ) : (
+
No trades recorded yet
+ )}
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
FTDT Quant Lab
+
+ {tab === "live" ? `Live Testnet · Equity $${liveData?.total_equity?.toFixed(2) ?? "—"}`
+ : tab === "paper" ? `Paper Mainnet · Equity $${paperData?.total_equity?.toLocaleString() ?? "—"}`
+ : "Historical · Mainnet Real Data"}
+
+
+
+
+ {liveConn ? "LIVE" : "OFFLINE"}
+
+
+
+
+
+ setTab(v as Tab)} className="max-w-[1440px] mx-auto px-6">
+
+
+ LiveTestnet
+
+
+ Paper$100K Mainnet
+
+
+ HistoricalReal Data
+
+
+
+
+
+
+
+
+ {Object.entries(strategies).map(([name, s], i) => (
+
+ handleCardClick(name, tab)} />
+
+ ))}
+
+ {tab === "historical" && Object.entries(historical).map(([name, b], i) => (
+
+ handleCardClick(name, "historical")}
+ badge={`30d · ${b.coin ?? "BTC"} · Mainnet`}
+ stats={[{ label: "Sharpe", value: b.sharpe.toFixed(2) }, { label: "Max DD", value: `${(b.max_dd * 100).toFixed(1)}%`, negative: true }, { label: "Win", value: `${Math.round(b.win_rate * 100)}%` }]}
+ pnlPct={b.pnl_pct} status="REAL DATA" />
+
+ ))}
+
+
+
+
+ {posOpen ? : }
+ Open Positions & Orders ({liveData?.open_positions?.length ?? 0} pos · {liveData?.open_orders?.length ?? 0} ord)
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/dashboard-next/src/components/equity-chart.tsx b/dashboard-next/src/components/equity-chart.tsx
new file mode 100644
index 0000000..b29aded
--- /dev/null
+++ b/dashboard-next/src/components/equity-chart.tsx
@@ -0,0 +1,80 @@
+"use client";
+
+import { useEffect, useRef } from "react";
+import { createChart, ColorType } from "lightweight-charts";
+
+interface EquityChartProps {
+ data: { t: number; v: number }[];
+ color?: string;
+ height?: number;
+}
+
+export function EquityChart({ data, color = "#3b82f6", height = 260 }: EquityChartProps) {
+ const containerRef = useRef(null);
+ const chartRef = useRef | null>(null);
+ const seriesRef = useRef(null);
+
+ const seriesColor = color.startsWith("#") ? color : "#3b82f6";
+
+ useEffect(() => {
+ if (!containerRef.current || data.length === 0) return;
+
+ const chart = createChart(containerRef.current, {
+ layout: {
+ background: { type: ColorType.Solid, color: "transparent" },
+ textColor: "#b0b7c0",
+ },
+ grid: {
+ vertLines: { color: "rgba(255,255,255,0.03)" },
+ horzLines: { color: "rgba(255,255,255,0.03)" },
+ },
+ rightPriceScale: { borderColor: "rgba(255,255,255,0.08)" },
+ timeScale: { borderColor: "rgba(255,255,255,0.08)", timeVisible: true },
+ crosshair: { mode: 0 },
+ width: containerRef.current.clientWidth,
+ height,
+ });
+
+ const series = chart.addAreaSeries({
+ lineColor: seriesColor,
+ topColor: `${seriesColor}26`,
+ bottomColor: `${seriesColor}05`,
+ lineWidth: 2,
+ });
+
+ const pts = data.map((d) => ({
+ time: d.t as import("lightweight-charts").UTCTimestamp,
+ value: d.v,
+ }));
+ series.setData(pts);
+ chart.timeScale().fitContent();
+
+ chartRef.current = chart;
+ seriesRef.current = series;
+
+ const handleResize = () => {
+ if (containerRef.current && chartRef.current) {
+ chartRef.current.applyOptions({ width: containerRef.current.clientWidth });
+ }
+ };
+ window.addEventListener("resize", handleResize);
+
+ return () => {
+ window.removeEventListener("resize", handleResize);
+ chart.remove();
+ };
+ }, [data, seriesColor, height]);
+
+ useEffect(() => {
+ if (seriesRef.current && data.length > 0) {
+ const pts = data.map((d) => ({
+ time: d.t as import("lightweight-charts").UTCTimestamp,
+ value: d.v,
+ }));
+ seriesRef.current.setData(pts);
+ chartRef.current?.timeScale().fitContent();
+ }
+ }, [data]);
+
+ return ;
+}
diff --git a/dashboard-next/src/components/positions-panel.tsx b/dashboard-next/src/components/positions-panel.tsx
new file mode 100644
index 0000000..0f74064
--- /dev/null
+++ b/dashboard-next/src/components/positions-panel.tsx
@@ -0,0 +1,77 @@
+"use client";
+
+import { Card } from "@/components/ui/card";
+import { Badge } from "@/components/ui/badge";
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
+import type { Position, Order } from "@/lib/types";
+
+interface Props {
+ positions: Position[];
+ orders: Order[];
+}
+
+export function PositionsPanel({ positions, orders }: Props) {
+ return (
+
+ {positions.length > 0 && (
+
+
Positions ({positions.length})
+
+
+
+ Strategy
+ Size
+ Entry
+ PnL
+
+
+
+ {positions.map((p, i) => (
+
+ {p.strategy}
+ {p.size}
+ ${p.entry_px?.toFixed(1) ?? "—"}
+ = 0 ? "text-green-500" : "text-red-500"}`}>
+ {(p.pnl ?? 0) >= 0 ? "+" : ""}${(p.pnl ?? 0).toFixed(4)}
+
+
+ ))}
+
+
+
+ )}
+ {orders.length > 0 && (
+
+
Orders ({orders.length})
+
+
+
+ Coin
+ Side
+ Size
+ Limit
+
+
+
+ {orders.map((o, i) => (
+
+ {o.coin}
+
+
+ {o.side === "B" ? "BUY" : "SELL"}
+
+
+ {o.sz}
+ ${o.limitPx ?? "—"}
+
+ ))}
+
+
+
+ )}
+ {positions.length === 0 && orders.length === 0 && (
+ No open positions or orders
+ )}
+
+ );
+}
diff --git a/dashboard-next/src/components/strategy-card.tsx b/dashboard-next/src/components/strategy-card.tsx
new file mode 100644
index 0000000..30f8119
--- /dev/null
+++ b/dashboard-next/src/components/strategy-card.tsx
@@ -0,0 +1,95 @@
+"use client";
+
+import { Card } from "@/components/ui/card";
+import { Badge } from "@/components/ui/badge";
+import type { Strategy } from "@/lib/types";
+import { TrendingUp, TrendingDown } from "lucide-react";
+
+interface StrategyCardProps {
+ name: string;
+ strategy?: Strategy;
+ tab: "live" | "paper" | "backtest" | "historical";
+ onClick: () => void;
+ badge?: string;
+ stats?: { label: string; value: string; negative?: boolean }[];
+ pnlPct?: number;
+ status?: string;
+}
+
+export function StrategyCard({ name, strategy, tab, onClick, badge, stats, pnlPct, status }: StrategyCardProps) {
+ if (strategy) {
+ const equity = strategy.allocation + (strategy.pnl ?? 0);
+ const isUp = equity >= strategy.allocation;
+ const pnl = strategy.pnl ?? 0;
+ const pnlPctVal = strategy.pnl_pct ?? 0;
+
+ return (
+
+
+
+
{name}
+
+ ${strategy.allocation} · {strategy.type}
+
+
+
+
+ {strategy.status?.toUpperCase()}
+
+
+ {strategy.fee_model?.toUpperCase()}
+
+
+
+
+
+ {isUp ? : }
+ ${equity.toFixed(2)}
+
+
+
+ PnL: = 0 ? "text-green-500" : "text-red-500"}>{pnl >= 0 ? "+" : ""}{pnl.toFixed(2)} ({pnlPctVal >= 0 ? "+" : ""}{pnlPctVal.toFixed(2)}%)
+ Trades: {strategy.trades_today ?? 0}
+ Win: {Math.round((strategy.win_rate ?? 0) * 100)}%
+ Pos: {(strategy.position ?? 0).toFixed(4)}
+
+
+
+ Alloc: ${strategy.allocation} · Max pos: {strategy.max_position ?? "—"} · Stop: {strategy.stop_loss ?? "—"}
+
+
+ );
+ }
+
+ // Backtest / Historical card
+ return (
+
+
+
+
{name}
+
{badge ?? "Backtest"}
+
+
{status ?? "BACKTEST"}
+
+ = 0 ? "text-green-500" : "text-red-500"}`}>
+ {(pnlPct ?? 0) >= 0 ? : }
+ {(pnlPct ?? 0) >= 0 ? "+" : ""}{(pnlPct ?? 0).toFixed(2)}%
+
+ {stats && (
+
+ {stats.map((s) => (
+
+ {s.label}: {s.value}
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/dashboard-next/src/components/ui/badge.tsx b/dashboard-next/src/components/ui/badge.tsx
new file mode 100644
index 0000000..3f5b422
--- /dev/null
+++ b/dashboard-next/src/components/ui/badge.tsx
@@ -0,0 +1,29 @@
+import * as React from "react";
+import { cn } from "@/lib/utils";
+
+const badgeVariants = {
+ default: "border-transparent bg-primary text-primary-foreground",
+ secondary: "border-transparent bg-secondary text-secondary-foreground",
+ destructive: "border-transparent bg-destructive text-destructive-foreground",
+ outline: "text-foreground",
+};
+
+function Badge({
+ className,
+ variant = "default",
+ ...props
+}: React.ComponentProps<"div"> & { variant?: keyof typeof badgeVariants }) {
+ return (
+ svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
+ badgeVariants[variant],
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+export { Badge };
diff --git a/dashboard-next/src/components/ui/button.tsx b/dashboard-next/src/components/ui/button.tsx
new file mode 100644
index 0000000..ee37e52
--- /dev/null
+++ b/dashboard-next/src/components/ui/button.tsx
@@ -0,0 +1,37 @@
+import * as React from "react";
+import { cn } from "@/lib/utils";
+
+function Button({
+ className,
+ variant = "default",
+ size = "default",
+ ...props
+}: React.ComponentProps<"button"> & {
+ variant?: "default" | "ghost" | "outline";
+ size?: "default" | "sm" | "icon";
+}) {
+ const variants: Record
= {
+ default: "bg-primary text-primary-foreground hover:bg-primary/90",
+ ghost: "hover:bg-accent hover:text-accent-foreground",
+ outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
+ };
+ const sizes: Record = {
+ default: "h-9 px-4 py-2",
+ sm: "h-8 rounded-md px-3 text-xs",
+ icon: "h-9 w-9",
+ };
+ return (
+
+ );
+}
+
+export { Button };
diff --git a/dashboard-next/src/components/ui/card.tsx b/dashboard-next/src/components/ui/card.tsx
new file mode 100644
index 0000000..56dd516
--- /dev/null
+++ b/dashboard-next/src/components/ui/card.tsx
@@ -0,0 +1,37 @@
+import * as React from "react";
+import { cn } from "@/lib/utils";
+
+function Card({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+ img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return
;
+}
+
+function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
+ return
;
+}
+
+function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
+ return
;
+}
+
+function CardContent({ className, ...props }: React.ComponentProps<"div">) {
+ return
;
+}
+
+function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
+ return
;
+}
+
+export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
diff --git a/dashboard-next/src/components/ui/collapsible.tsx b/dashboard-next/src/components/ui/collapsible.tsx
new file mode 100644
index 0000000..863ae03
--- /dev/null
+++ b/dashboard-next/src/components/ui/collapsible.tsx
@@ -0,0 +1,47 @@
+"use client";
+
+import * as React from "react";
+import { cn } from "@/lib/utils";
+
+interface CollapsibleContextType {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+}
+const CollapsibleContext = React.createContext
(null);
+
+function Collapsible({ open, onOpenChange, className, children, ...props }: React.ComponentProps<"div"> & CollapsibleContextType) {
+ return (
+
+
+ {children}
+
+
+ );
+}
+
+function CollapsibleTrigger({ className, children, ...props }: React.ComponentProps<"button">) {
+ const ctx = React.useContext(CollapsibleContext);
+ return (
+
+ );
+}
+
+function CollapsibleContent({ className, children, ...props }: React.ComponentProps<"div">) {
+ const ctx = React.useContext(CollapsibleContext);
+ if (!ctx?.open) return null;
+ return (
+
+ {children}
+
+ );
+}
+
+export { Collapsible, CollapsibleTrigger, CollapsibleContent };
diff --git a/dashboard-next/src/components/ui/select.tsx b/dashboard-next/src/components/ui/select.tsx
new file mode 100644
index 0000000..aa51633
--- /dev/null
+++ b/dashboard-next/src/components/ui/select.tsx
@@ -0,0 +1,26 @@
+"use client";
+
+import * as React from "react";
+import { cn } from "@/lib/utils";
+import { ChevronDown } from "lucide-react";
+
+function Select({ value, onChange, className, children, ...props }: React.ComponentProps<"select"> & { onChange?: (e: React.ChangeEvent) => void }) {
+ return (
+
+ );
+}
+
+export { Select };
diff --git a/dashboard-next/src/components/ui/sheet.tsx b/dashboard-next/src/components/ui/sheet.tsx
new file mode 100644
index 0000000..e179f92
--- /dev/null
+++ b/dashboard-next/src/components/ui/sheet.tsx
@@ -0,0 +1,42 @@
+"use client";
+
+import * as React from "react";
+import { cn } from "@/lib/utils";
+import { X } from "lucide-react";
+
+function Sheet({ open, onOpenChange, children }: { open: boolean; onOpenChange: (open: boolean) => void; children: React.ReactNode }) {
+ if (!open) return null;
+ return (
+ onOpenChange(false)}>
+
e.stopPropagation()}>
+ {children}
+
+
+ );
+}
+
+function SheetContent({ side = "right", className, children, ...props }: React.ComponentProps<"div"> & { side?: "right" | "left" }) {
+ return (
+
+ {children}
+
+ );
+}
+
+function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return ;
+}
+
+function SheetTitle({ className, ...props }: React.ComponentProps<"h2">) {
+ return ;
+}
+
+export { Sheet, SheetContent, SheetHeader, SheetTitle };
diff --git a/dashboard-next/src/components/ui/table.tsx b/dashboard-next/src/components/ui/table.tsx
new file mode 100644
index 0000000..ac37a6b
--- /dev/null
+++ b/dashboard-next/src/components/ui/table.tsx
@@ -0,0 +1,28 @@
+import * as React from "react";
+import { cn } from "@/lib/utils";
+
+function Table({ className, ...props }: React.ComponentProps<"table">) {
+ return ;
+}
+
+function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
+ return ;
+}
+
+function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
+ return ;
+}
+
+function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
+ return
;
+}
+
+function TableHead({ className, ...props }: React.ComponentProps<"th">) {
+ return | ;
+}
+
+function TableCell({ className, ...props }: React.ComponentProps<"td">) {
+ return | ;
+}
+
+export { Table, TableHeader, TableBody, TableRow, TableHead, TableCell };
diff --git a/dashboard-next/src/components/ui/tabs.tsx b/dashboard-next/src/components/ui/tabs.tsx
new file mode 100644
index 0000000..0c98c9c
--- /dev/null
+++ b/dashboard-next/src/components/ui/tabs.tsx
@@ -0,0 +1,51 @@
+"use client";
+
+import * as React from "react";
+import { cn } from "@/lib/utils";
+
+const TabsContext = React.createContext<{ value: string; onValueChange: (v: string) => void } | null>(null);
+
+function Tabs({ value, onValueChange, className, children, ...props }: React.ComponentProps<"div"> & { value: string; onValueChange: (v: string) => void }) {
+ return (
+
+
+ {children}
+
+
+ );
+}
+
+function TabsList({ className, ...props }: React.ComponentProps<"div">) {
+ return ;
+}
+
+function TabsTrigger({ value, className, children, ...props }: React.ComponentProps<"button"> & { value: string }) {
+ const ctx = React.useContext(TabsContext);
+ const active = ctx?.value === value;
+ return (
+
+ );
+}
+
+function TabsContent({ value, className, children, ...props }: React.ComponentProps<"div"> & { value: string }) {
+ const ctx = React.useContext(TabsContext);
+ if (ctx?.value !== value) return null;
+ return (
+
+ {children}
+
+ );
+}
+
+export { Tabs, TabsList, TabsTrigger, TabsContent };
diff --git a/dashboard-next/src/lib/api.ts b/dashboard-next/src/lib/api.ts
new file mode 100644
index 0000000..ecc8475
--- /dev/null
+++ b/dashboard-next/src/lib/api.ts
@@ -0,0 +1,106 @@
+"use client";
+
+import { useEffect, useRef, useState } from "react";
+import type { LiveMetrics, PaperMetrics } from "./types";
+
+const API_BASE = "/cv/api";
+
+export function useLiveMetrics() {
+ const [data, setData] = useState(null);
+ const [connected, setConnected] = useState(false);
+ const wsRef = useRef(null);
+ const timerRef = useRef | undefined>(undefined);
+
+ useEffect(() => {
+ const WS_BASE = `${window.location.protocol === "https:" ? "wss:" : "ws:"}//${window.location.host}/cv/ws`;
+
+ function connect() {
+ try {
+ const ws = new WebSocket(WS_BASE);
+ wsRef.current = ws;
+ ws.onopen = () => setConnected(true);
+ ws.onclose = () => {
+ setConnected(false);
+ timerRef.current = setTimeout(connect, 5000);
+ };
+ ws.onmessage = (e) => {
+ try {
+ const d = JSON.parse(e.data) as LiveMetrics;
+ setData(d);
+ } catch { /* ignore */ }
+ };
+ } catch {
+ timerRef.current = setTimeout(connect, 5000);
+ }
+ }
+
+ connect();
+ return () => {
+ wsRef.current?.close();
+ clearTimeout(timerRef.current);
+ };
+ }, []);
+
+ return { data, connected };
+}
+
+export function usePaperMetrics() {
+ const [data, setData] = useState(null);
+ const [connected, setConnected] = useState(false);
+ const wsRef = useRef(null);
+
+ useEffect(() => {
+ const base = `${window.location.protocol === "https:" ? "wss:" : "ws:"}//${window.location.host}/cv/ws/paper`;
+ const connect = () => {
+ try {
+ const ws = new WebSocket(base);
+ wsRef.current = ws;
+ ws.onopen = () => setConnected(true);
+ ws.onclose = () => {
+ setConnected(false);
+ setTimeout(connect, 5000);
+ };
+ ws.onmessage = (e) => {
+ try {
+ const d = JSON.parse(e.data) as PaperMetrics;
+ setData(d);
+ } catch { /* ignore */ }
+ };
+ } catch {
+ setTimeout(connect, 5000);
+ }
+ };
+ connect();
+ return () => wsRef.current?.close();
+ }, []);
+
+ return { data, connected };
+}
+
+export async function fetchHistorical(): Promise> {
+ const res = await fetch(`${API_BASE}/backtests/historical`);
+ const list: import("./types").BacktestSummary[] = await res.json();
+ const byStrat: Record = {};
+ for (const b of list) {
+ if (!byStrat[b.strategy]) byStrat[b.strategy] = b;
+ }
+ return byStrat;
+}
+
+export async function fetchBacktestDetail(name: string): Promise {
+ const res = await fetch(`${API_BASE}/backtest/historical/${encodeURIComponent(name)}`);
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ return res.json();
+}
+
+export async function recalcBacktest(
+ name: string,
+ feeTier: number,
+ stakingTier: string,
+): Promise {
+ const res = await fetch(
+ `${API_BASE}/backtest/${encodeURIComponent(name)}/recalc?fee_tier=${feeTier}&staking_tier=${stakingTier}`,
+ );
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ return res.json();
+}
diff --git a/dashboard-next/src/lib/types.ts b/dashboard-next/src/lib/types.ts
new file mode 100644
index 0000000..29be13a
--- /dev/null
+++ b/dashboard-next/src/lib/types.ts
@@ -0,0 +1,118 @@
+// ═══════════ FTDT Quant Lab — Type Definitions ═══════════
+
+export interface Strategy {
+ allocation: number;
+ instrument: string;
+ pnl: number;
+ pnl_pct: number;
+ position: number;
+ trades_today: number;
+ wins: number;
+ win_rate: number;
+ status: "running" | "idle";
+ size: number;
+ fee_paid: number;
+ fee_model: "maker" | "taker";
+ type: string;
+ description: string;
+ signals: { signal: string; ts: number }[];
+ max_position?: number;
+ stop_loss?: number;
+ entry_price?: number;
+}
+
+export interface Trade {
+ time: string;
+ strategy: string;
+ side: string;
+ size: number;
+ price: number;
+ pnl: number;
+ fee: number;
+ reason?: string;
+ pnl_net?: number;
+ pnl_gross?: number;
+}
+
+export interface Position {
+ strategy: string;
+ size: number;
+ entry_px: number;
+ pnl?: number;
+}
+
+export interface Order {
+ coin: string;
+ side: "B" | "A";
+ sz: number;
+ limitPx?: string;
+ cloid?: string;
+}
+
+export interface LiveMetrics {
+ timestamp: number;
+ wallet: string;
+ total_equity: number;
+ base_equity: number;
+ total_pnl: number;
+ total_pnl_pct: number;
+ reserve: number;
+ equity_history: { t: number; v: number }[];
+ strategy_equity: Record;
+ strategies: Record;
+ trades: Trade[];
+ status: string;
+ testnet_up: boolean;
+ open_positions: Position[];
+ open_orders: Order[];
+}
+
+export interface PaperMetrics {
+ timestamp: number;
+ total_equity: number;
+ base_equity: number;
+ total_pnl: number;
+ total_pnl_pct: number;
+ regime: string;
+ equity_history: { t: number; v: number }[];
+ strategy_equity: Record;
+ strategies: Record;
+ per_strategy_trades: Record;
+ open_positions: Position[];
+ open_orders: Order[];
+}
+
+export interface BacktestSummary {
+ name: string;
+ strategy: string;
+ start: string;
+ end: string;
+ sharpe: number;
+ sortino: number;
+ pnl_pct: number;
+ max_dd: number;
+ win_rate: number;
+ total_trades: number;
+ coin?: string;
+}
+
+export interface BacktestFull {
+ name?: string;
+ strategy: string;
+ pnl_net?: number;
+ pnl_gross?: number;
+ pnl_pct?: number;
+ pnl_net_pct?: number;
+ pnl_gross_pct?: number;
+ pnl: number;
+ sharpe: number;
+ sortino: number;
+ max_dd: number;
+ win_rate: number;
+ total_trades: number;
+ fees_total: number;
+ num_periods: number;
+ fee_model: string;
+ equity_curve: { t: number | string; v: number }[];
+ trades: Trade[];
+}
diff --git a/dashboard-next/src/lib/utils.ts b/dashboard-next/src/lib/utils.ts
new file mode 100644
index 0000000..a5ef193
--- /dev/null
+++ b/dashboard-next/src/lib/utils.ts
@@ -0,0 +1,6 @@
+import { clsx, type ClassValue } from "clsx";
+import { twMerge } from "tailwind-merge";
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs));
+}
diff --git a/dashboard-next/tsconfig.json b/dashboard-next/tsconfig.json
new file mode 100644
index 0000000..543bd8f
--- /dev/null
+++ b/dashboard-next/tsconfig.json
@@ -0,0 +1,44 @@
+{
+ "compilerOptions": {
+ "target": "ES2017",
+ "lib": [
+ "dom",
+ "dom.iterable",
+ "esnext"
+ ],
+ "allowJs": true,
+ "skipLibCheck": true,
+ "strict": true,
+ "noEmit": true,
+ "esModuleInterop": true,
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "jsx": "react-jsx",
+ "incremental": true,
+ "plugins": [
+ {
+ "name": "next"
+ }
+ ],
+ "paths": {
+ "@/*": [
+ "./src/*"
+ ]
+ }
+ },
+ "include": [
+ "next-env.d.ts",
+ "src/**/*.ts",
+ "src/**/*.tsx",
+ ".next/types/**/*.ts",
+ ".next/dev/types/**/*.ts"
+ ],
+ "exclude": [
+ "node_modules",
+ "out",
+ "out-www",
+ "_next-app-backup"
+ ]
+}