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 }) => ( +
+

{l}

+

{v}

+
+ )) + ) : 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 }) => ( +
+

{l}

+

{v}

+
+ )) + ) : ( +
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 ( + + ); +} + +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" + ] +}