feat: dashboard trading views -- portfolio, trades, strategies, news, backtest
Add Layout with sidebar navigation and top bar (portfolio value, trading status indicator). Implement Portfolio page with equity curve (TradingView lightweight-charts), positions table, and metrics row. Add TradeLog with filters, pagination, and expandable row details. Add Strategies page with weight allocation pie chart and weight history line chart (Recharts). Add NewsFeed with sentiment badges and ticker filtering. Add Backtest page with config form, run submission, and results panel. Include WebSocket hook for real-time cache invalidation and portfolio query hooks.
This commit is contained in:
parent
f121f376ae
commit
8d6e666280
12 changed files with 1761 additions and 0 deletions
89
dashboard/src/components/EquityCurve.tsx
Normal file
89
dashboard/src/components/EquityCurve.tsx
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import { useEffect, useRef } from 'react';
|
||||
import { createChart, type IChartApi, type ISeriesApi, LineSeries } from 'lightweight-charts';
|
||||
|
||||
interface EquityCurveProps {
|
||||
data: Array<{ timestamp: string; value: number }>;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
export function EquityCurve({ data, height = 300 }: EquityCurveProps) {
|
||||
const chartContainerRef = useRef<HTMLDivElement>(null);
|
||||
const chartRef = useRef<IChartApi | null>(null);
|
||||
const seriesRef = useRef<ISeriesApi<'Line'> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!chartContainerRef.current) return;
|
||||
|
||||
const chart = createChart(chartContainerRef.current, {
|
||||
height,
|
||||
layout: {
|
||||
background: { color: '#1e293b' },
|
||||
textColor: '#94a3b8',
|
||||
},
|
||||
grid: {
|
||||
vertLines: { color: '#334155' },
|
||||
horzLines: { color: '#334155' },
|
||||
},
|
||||
crosshair: {
|
||||
mode: 0,
|
||||
},
|
||||
rightPriceScale: {
|
||||
borderColor: '#475569',
|
||||
},
|
||||
timeScale: {
|
||||
borderColor: '#475569',
|
||||
timeVisible: true,
|
||||
},
|
||||
});
|
||||
|
||||
const series = chart.addSeries(LineSeries, {
|
||||
color: '#3b82f6',
|
||||
lineWidth: 2,
|
||||
priceFormat: {
|
||||
type: 'custom',
|
||||
formatter: (price: number) =>
|
||||
'$' + price.toLocaleString('en-US', { minimumFractionDigits: 2 }),
|
||||
},
|
||||
});
|
||||
|
||||
chartRef.current = chart;
|
||||
seriesRef.current = series;
|
||||
|
||||
const handleResize = () => {
|
||||
if (chartContainerRef.current) {
|
||||
chart.applyOptions({ width: chartContainerRef.current.clientWidth });
|
||||
}
|
||||
};
|
||||
window.addEventListener('resize', handleResize);
|
||||
handleResize();
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
chart.remove();
|
||||
chartRef.current = null;
|
||||
seriesRef.current = null;
|
||||
};
|
||||
}, [height]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!seriesRef.current || !data.length) return;
|
||||
|
||||
const chartData = data.map((point) => ({
|
||||
time: point.timestamp.split('T')[0] as string,
|
||||
value: point.value,
|
||||
}));
|
||||
|
||||
seriesRef.current.setData(chartData as any);
|
||||
|
||||
if (chartRef.current) {
|
||||
chartRef.current.timeScale().fitContent();
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={chartContainerRef}
|
||||
className="w-full rounded-lg overflow-hidden"
|
||||
/>
|
||||
);
|
||||
}
|
||||
141
dashboard/src/components/Layout.tsx
Normal file
141
dashboard/src/components/Layout.tsx
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
import { NavLink, Outlet, useNavigate } from 'react-router-dom';
|
||||
import { usePortfolio } from '../hooks/usePortfolio';
|
||||
import { useWebSocket } from '../hooks/useWebSocket';
|
||||
|
||||
const navItems = [
|
||||
{ to: '/portfolio', label: 'Portfolio', icon: 'M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6' },
|
||||
{ to: '/trades', label: 'Trades', icon: 'M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2' },
|
||||
{ to: '/strategies', label: 'Strategies', icon: 'M13 10V3L4 14h7v7l9-11h-7z' },
|
||||
{ to: '/news', label: 'News', icon: 'M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2' },
|
||||
{ to: '/backtest', label: 'Backtest', icon: 'M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z' },
|
||||
];
|
||||
|
||||
export function Layout() {
|
||||
const navigate = useNavigate();
|
||||
const { data: portfolio } = usePortfolio();
|
||||
const { lastEvent } = useWebSocket();
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
const portfolioValue = portfolio?.total_value ?? 0;
|
||||
const dailyPnl = portfolio?.daily_pnl ?? 0;
|
||||
const isActive = portfolio?.trading_active ?? false;
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-slate-900">
|
||||
{/* Sidebar */}
|
||||
<aside className="w-64 bg-slate-800 border-r border-slate-700 flex flex-col">
|
||||
<div className="p-6 border-b border-slate-700">
|
||||
<h1 className="text-xl font-bold text-white">Trading Bot</h1>
|
||||
<p className="text-xs text-slate-400 mt-1">Automated Trading Dashboard</p>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 p-4 space-y-1">
|
||||
{navItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors ${
|
||||
isActive
|
||||
? 'bg-blue-600/20 text-blue-400'
|
||||
: 'text-slate-300 hover:bg-slate-700 hover:text-white'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5 flex-shrink-0"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d={item.icon}
|
||||
/>
|
||||
</svg>
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="p-4 border-t border-slate-700">
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex items-center gap-2 w-full px-3 py-2 text-sm text-slate-400 hover:text-white hover:bg-slate-700 rounded-lg transition-colors"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
|
||||
/>
|
||||
</svg>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* Top bar */}
|
||||
<header className="h-16 bg-slate-800 border-b border-slate-700 flex items-center justify-between px-6">
|
||||
<div className="flex items-center gap-6">
|
||||
<div>
|
||||
<span className="text-sm text-slate-400">Portfolio Value</span>
|
||||
<p className="text-lg font-semibold text-white">
|
||||
${portfolioValue.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm text-slate-400">Daily P&L</span>
|
||||
<p
|
||||
className={`text-lg font-semibold ${
|
||||
dailyPnl >= 0 ? 'text-green-400' : 'text-red-400'
|
||||
}`}
|
||||
>
|
||||
{dailyPnl >= 0 ? '+' : ''}
|
||||
${dailyPnl.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{lastEvent && (
|
||||
<span className="text-xs text-slate-400 bg-slate-700 px-2 py-1 rounded">
|
||||
{lastEvent.type}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`w-2.5 h-2.5 rounded-full ${
|
||||
isActive ? 'bg-green-400 animate-pulse' : 'bg-red-400'
|
||||
}`}
|
||||
/>
|
||||
<span className="text-sm text-slate-300">
|
||||
{isActive ? 'Active' : 'Paused'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Page content */}
|
||||
<main className="flex-1 overflow-auto p-6">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
27
dashboard/src/components/MetricsRow.tsx
Normal file
27
dashboard/src/components/MetricsRow.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
interface Metric {
|
||||
label: string;
|
||||
value: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
interface MetricsRowProps {
|
||||
metrics: Metric[];
|
||||
}
|
||||
|
||||
export function MetricsRow({ metrics }: MetricsRowProps) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
|
||||
{metrics.map((metric) => (
|
||||
<div
|
||||
key={metric.label}
|
||||
className="bg-slate-800 rounded-lg p-4 border border-slate-700"
|
||||
>
|
||||
<p className="text-xs text-slate-400 mb-1">{metric.label}</p>
|
||||
<p className={`text-lg font-semibold ${metric.color || 'text-white'}`}>
|
||||
{metric.value}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
86
dashboard/src/components/PositionsTable.tsx
Normal file
86
dashboard/src/components/PositionsTable.tsx
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import type { Position } from '../hooks/usePortfolio';
|
||||
import client from '../api/client';
|
||||
|
||||
interface PositionsTableProps {
|
||||
positions: Position[];
|
||||
onPositionClosed?: () => void;
|
||||
}
|
||||
|
||||
export function PositionsTable({ positions, onPositionClosed }: PositionsTableProps) {
|
||||
const handleClose = async (ticker: string) => {
|
||||
if (!confirm(`Close position in ${ticker}?`)) return;
|
||||
try {
|
||||
await client.post('/controls/close-position', { ticker });
|
||||
onPositionClosed?.();
|
||||
} catch (err) {
|
||||
console.error('Failed to close position:', err);
|
||||
}
|
||||
};
|
||||
|
||||
if (!positions.length) {
|
||||
return (
|
||||
<div className="text-center py-8 text-slate-400">
|
||||
No open positions
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-700">
|
||||
<th className="text-left py-3 px-4 text-slate-400 font-medium">Ticker</th>
|
||||
<th className="text-right py-3 px-4 text-slate-400 font-medium">Qty</th>
|
||||
<th className="text-right py-3 px-4 text-slate-400 font-medium">Avg Entry</th>
|
||||
<th className="text-right py-3 px-4 text-slate-400 font-medium">Current</th>
|
||||
<th className="text-right py-3 px-4 text-slate-400 font-medium">P&L</th>
|
||||
<th className="text-right py-3 px-4 text-slate-400 font-medium">P&L %</th>
|
||||
<th className="text-right py-3 px-4 text-slate-400 font-medium">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{positions.map((pos) => (
|
||||
<tr
|
||||
key={pos.id}
|
||||
className="border-b border-slate-700/50 hover:bg-slate-800/50 transition-colors"
|
||||
>
|
||||
<td className="py-3 px-4 font-medium text-white">{pos.ticker}</td>
|
||||
<td className="py-3 px-4 text-right text-slate-300">{pos.qty}</td>
|
||||
<td className="py-3 px-4 text-right text-slate-300">
|
||||
${pos.avg_entry.toFixed(2)}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-right text-slate-300">
|
||||
${pos.current_price.toFixed(2)}
|
||||
</td>
|
||||
<td
|
||||
className={`py-3 px-4 text-right font-medium ${
|
||||
pos.unrealized_pnl >= 0 ? 'text-green-400' : 'text-red-400'
|
||||
}`}
|
||||
>
|
||||
{pos.unrealized_pnl >= 0 ? '+' : ''}
|
||||
${pos.unrealized_pnl.toFixed(2)}
|
||||
</td>
|
||||
<td
|
||||
className={`py-3 px-4 text-right font-medium ${
|
||||
pos.unrealized_pnl_pct >= 0 ? 'text-green-400' : 'text-red-400'
|
||||
}`}
|
||||
>
|
||||
{pos.unrealized_pnl_pct >= 0 ? '+' : ''}
|
||||
{pos.unrealized_pnl_pct.toFixed(2)}%
|
||||
</td>
|
||||
<td className="py-3 px-4 text-right">
|
||||
<button
|
||||
onClick={() => handleClose(pos.ticker)}
|
||||
className="px-3 py-1 text-xs bg-red-600/20 text-red-400 hover:bg-red-600/30 rounded transition-colors"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
43
dashboard/src/components/SentimentBadge.tsx
Normal file
43
dashboard/src/components/SentimentBadge.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
interface SentimentBadgeProps {
|
||||
score: number;
|
||||
size?: 'sm' | 'md';
|
||||
}
|
||||
|
||||
export function SentimentBadge({ score, size = 'md' }: SentimentBadgeProps) {
|
||||
let bgColor: string;
|
||||
let textColor: string;
|
||||
let label: string;
|
||||
|
||||
if (score >= 0.3) {
|
||||
bgColor = 'bg-green-900/50';
|
||||
textColor = 'text-green-400';
|
||||
label = 'Positive';
|
||||
} else if (score <= -0.3) {
|
||||
bgColor = 'bg-red-900/50';
|
||||
textColor = 'text-red-400';
|
||||
label = 'Negative';
|
||||
} else {
|
||||
bgColor = 'bg-yellow-900/50';
|
||||
textColor = 'text-yellow-400';
|
||||
label = 'Neutral';
|
||||
}
|
||||
|
||||
const sizeClasses = size === 'sm' ? 'px-2 py-0.5 text-xs' : 'px-3 py-1 text-sm';
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 rounded-full font-medium ${bgColor} ${textColor} ${sizeClasses}`}
|
||||
>
|
||||
<span
|
||||
className={`rounded-full ${size === 'sm' ? 'w-1.5 h-1.5' : 'w-2 h-2'} ${
|
||||
score >= 0.3
|
||||
? 'bg-green-400'
|
||||
: score <= -0.3
|
||||
? 'bg-red-400'
|
||||
: 'bg-yellow-400'
|
||||
}`}
|
||||
/>
|
||||
{label} ({score >= 0 ? '+' : ''}{score.toFixed(2)})
|
||||
</span>
|
||||
);
|
||||
}
|
||||
83
dashboard/src/hooks/usePortfolio.ts
Normal file
83
dashboard/src/hooks/usePortfolio.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import { useQuery } from '@tanstack/react-query';
|
||||
import client from '../api/client';
|
||||
|
||||
export interface PortfolioSummary {
|
||||
total_value: number;
|
||||
cash: number;
|
||||
buying_power: number;
|
||||
daily_pnl: number;
|
||||
daily_pnl_pct: number;
|
||||
total_pnl: number;
|
||||
total_pnl_pct: number;
|
||||
trading_active: boolean;
|
||||
}
|
||||
|
||||
export interface Position {
|
||||
id: string;
|
||||
ticker: string;
|
||||
qty: number;
|
||||
avg_entry: number;
|
||||
current_price: number;
|
||||
unrealized_pnl: number;
|
||||
unrealized_pnl_pct: number;
|
||||
stop_loss: number | null;
|
||||
take_profit: number | null;
|
||||
}
|
||||
|
||||
export interface EquityPoint {
|
||||
timestamp: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface PortfolioMetrics {
|
||||
roi: number;
|
||||
sharpe: number;
|
||||
win_rate: number;
|
||||
max_drawdown: number;
|
||||
total_trades: number;
|
||||
avg_hold_duration: string;
|
||||
}
|
||||
|
||||
export function usePortfolio() {
|
||||
return useQuery<PortfolioSummary>({
|
||||
queryKey: ['portfolio'],
|
||||
queryFn: async () => {
|
||||
const response = await client.get('/portfolio');
|
||||
return response.data;
|
||||
},
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function usePositions() {
|
||||
return useQuery<Position[]>({
|
||||
queryKey: ['positions'],
|
||||
queryFn: async () => {
|
||||
const response = await client.get('/portfolio/positions');
|
||||
return response.data;
|
||||
},
|
||||
refetchInterval: 15_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useEquityCurve(period: string = '1M') {
|
||||
return useQuery<EquityPoint[]>({
|
||||
queryKey: ['equity-curve', period],
|
||||
queryFn: async () => {
|
||||
const response = await client.get('/portfolio/history', {
|
||||
params: { period },
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function usePortfolioMetrics() {
|
||||
return useQuery<PortfolioMetrics>({
|
||||
queryKey: ['portfolio', 'metrics'],
|
||||
queryFn: async () => {
|
||||
const response = await client.get('/portfolio/metrics');
|
||||
return response.data;
|
||||
},
|
||||
});
|
||||
}
|
||||
108
dashboard/src/hooks/useWebSocket.ts
Normal file
108
dashboard/src/hooks/useWebSocket.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
export interface WebSocketEvent {
|
||||
type: string;
|
||||
data: Record<string, unknown>;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
const WS_RECONNECT_DELAY = 3000;
|
||||
const WS_MAX_RECONNECT_DELAY = 30000;
|
||||
|
||||
export function useWebSocket() {
|
||||
const queryClient = useQueryClient();
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const reconnectDelayRef = useRef(WS_RECONNECT_DELAY);
|
||||
const [lastEvent, setLastEvent] = useState<WebSocketEvent | null>(null);
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
|
||||
const handleMessage = useCallback(
|
||||
(event: MessageEvent) => {
|
||||
try {
|
||||
const parsed: WebSocketEvent = JSON.parse(event.data);
|
||||
setLastEvent(parsed);
|
||||
|
||||
// Invalidate relevant TanStack Query caches based on event type
|
||||
switch (parsed.type) {
|
||||
case 'trade_executed':
|
||||
queryClient.invalidateQueries({ queryKey: ['portfolio'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['positions'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['trades'] });
|
||||
break;
|
||||
case 'signal_generated':
|
||||
queryClient.invalidateQueries({ queryKey: ['signals'] });
|
||||
break;
|
||||
case 'portfolio_update':
|
||||
queryClient.invalidateQueries({ queryKey: ['portfolio'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['equity-curve'] });
|
||||
break;
|
||||
case 'news_scored':
|
||||
queryClient.invalidateQueries({ queryKey: ['news'] });
|
||||
break;
|
||||
case 'strategy_update':
|
||||
queryClient.invalidateQueries({ queryKey: ['strategies'] });
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
// Ignore non-JSON messages
|
||||
}
|
||||
},
|
||||
[queryClient]
|
||||
);
|
||||
|
||||
const connect = useCallback(() => {
|
||||
const token = localStorage.getItem('access_token');
|
||||
if (!token) return;
|
||||
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const host = window.location.host;
|
||||
const wsUrl = `${protocol}//${host}/ws?token=${encodeURIComponent(token)}`;
|
||||
|
||||
const ws = new WebSocket(wsUrl);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
setIsConnected(true);
|
||||
reconnectDelayRef.current = WS_RECONNECT_DELAY;
|
||||
};
|
||||
|
||||
ws.onmessage = handleMessage;
|
||||
|
||||
ws.onclose = () => {
|
||||
setIsConnected(false);
|
||||
wsRef.current = null;
|
||||
|
||||
// Reconnect with exponential backoff
|
||||
reconnectTimeoutRef.current = setTimeout(() => {
|
||||
reconnectDelayRef.current = Math.min(
|
||||
reconnectDelayRef.current * 2,
|
||||
WS_MAX_RECONNECT_DELAY
|
||||
);
|
||||
connect();
|
||||
}, reconnectDelayRef.current);
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
ws.close();
|
||||
};
|
||||
}, [handleMessage]);
|
||||
|
||||
useEffect(() => {
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current);
|
||||
}
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close();
|
||||
}
|
||||
};
|
||||
}, [connect]);
|
||||
|
||||
return { lastEvent, isConnected };
|
||||
}
|
||||
297
dashboard/src/pages/Backtest.tsx
Normal file
297
dashboard/src/pages/Backtest.tsx
Normal file
|
|
@ -0,0 +1,297 @@
|
|||
import { useState } from 'react';
|
||||
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||
import client from '../api/client';
|
||||
import { EquityCurve } from '../components/EquityCurve';
|
||||
import { MetricsRow } from '../components/MetricsRow';
|
||||
|
||||
interface BacktestConfig {
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
initial_capital: number;
|
||||
strategies: string[];
|
||||
}
|
||||
|
||||
interface BacktestResult {
|
||||
run_id: string;
|
||||
status: 'pending' | 'running' | 'completed' | 'failed';
|
||||
equity_curve: Array<{ timestamp: string; value: number }>;
|
||||
metrics: {
|
||||
total_return: number;
|
||||
annualized_return: number;
|
||||
sharpe_ratio: number;
|
||||
max_drawdown: number;
|
||||
win_rate: number;
|
||||
total_trades: number;
|
||||
avg_hold_duration: string;
|
||||
} | null;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface StrategyOption {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export default function Backtest() {
|
||||
const [startDate, setStartDate] = useState('2025-01-01');
|
||||
const [endDate, setEndDate] = useState('2026-01-01');
|
||||
const [initialCapital, setInitialCapital] = useState(100000);
|
||||
const [selectedStrategies, setSelectedStrategies] = useState<string[]>([]);
|
||||
const [currentRunId, setCurrentRunId] = useState<string | null>(null);
|
||||
|
||||
const { data: strategyOptions } = useQuery<StrategyOption[]>({
|
||||
queryKey: ['strategies-options'],
|
||||
queryFn: async () => {
|
||||
const response = await client.get('/strategies');
|
||||
return response.data;
|
||||
},
|
||||
});
|
||||
|
||||
const { data: result, isLoading: resultLoading } = useQuery<BacktestResult>({
|
||||
queryKey: ['backtest', currentRunId],
|
||||
queryFn: async () => {
|
||||
const response = await client.get(`/backtest/${currentRunId}`);
|
||||
return response.data;
|
||||
},
|
||||
enabled: !!currentRunId,
|
||||
refetchInterval: (query) => {
|
||||
const data = query.state.data;
|
||||
if (data && (data.status === 'pending' || data.status === 'running')) {
|
||||
return 2000;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
});
|
||||
|
||||
const runMutation = useMutation({
|
||||
mutationFn: async (config: BacktestConfig) => {
|
||||
const response = await client.post('/backtest/run', config);
|
||||
return response.data;
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
setCurrentRunId(data.run_id);
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!startDate || !endDate || selectedStrategies.length === 0) return;
|
||||
runMutation.mutate({
|
||||
start_date: startDate,
|
||||
end_date: endDate,
|
||||
initial_capital: initialCapital,
|
||||
strategies: selectedStrategies,
|
||||
});
|
||||
};
|
||||
|
||||
const toggleStrategy = (name: string) => {
|
||||
setSelectedStrategies((prev) =>
|
||||
prev.includes(name) ? prev.filter((s) => s !== name) : [...prev, name]
|
||||
);
|
||||
};
|
||||
|
||||
const metricsDisplay = result?.metrics
|
||||
? [
|
||||
{
|
||||
label: 'Total Return',
|
||||
value: `${result.metrics.total_return >= 0 ? '+' : ''}${result.metrics.total_return.toFixed(2)}%`,
|
||||
color: result.metrics.total_return >= 0 ? 'text-green-400' : 'text-red-400',
|
||||
},
|
||||
{
|
||||
label: 'Annualized Return',
|
||||
value: `${result.metrics.annualized_return >= 0 ? '+' : ''}${result.metrics.annualized_return.toFixed(2)}%`,
|
||||
color: result.metrics.annualized_return >= 0 ? 'text-green-400' : 'text-red-400',
|
||||
},
|
||||
{
|
||||
label: 'Sharpe Ratio',
|
||||
value: result.metrics.sharpe_ratio.toFixed(2),
|
||||
color: result.metrics.sharpe_ratio >= 1 ? 'text-green-400' : 'text-yellow-400',
|
||||
},
|
||||
{
|
||||
label: 'Max Drawdown',
|
||||
value: `${result.metrics.max_drawdown.toFixed(2)}%`,
|
||||
color: 'text-red-400',
|
||||
},
|
||||
{
|
||||
label: 'Win Rate',
|
||||
value: `${(result.metrics.win_rate * 100).toFixed(1)}%`,
|
||||
color: result.metrics.win_rate >= 0.5 ? 'text-green-400' : 'text-red-400',
|
||||
},
|
||||
{
|
||||
label: 'Trade Count',
|
||||
value: result.metrics.total_trades.toString(),
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-2xl font-bold text-white">Backtesting</h2>
|
||||
|
||||
{/* Config form */}
|
||||
<div className="bg-slate-800 rounded-xl p-6 border border-slate-700">
|
||||
<h3 className="text-lg font-semibold text-white mb-4">
|
||||
Configuration
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||
<div>
|
||||
<label className="block text-xs text-slate-400 mb-1">
|
||||
Start Date
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white text-sm focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-slate-400 mb-1">
|
||||
End Date
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={endDate}
|
||||
onChange={(e) => setEndDate(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white text-sm focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-slate-400 mb-1">
|
||||
Initial Capital ($)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={initialCapital}
|
||||
onChange={(e) => setInitialCapital(Number(e.target.value))}
|
||||
min={1000}
|
||||
step={1000}
|
||||
className="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white text-sm focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-slate-400 mb-1">
|
||||
Strategies
|
||||
</label>
|
||||
<div className="space-y-2 mt-1">
|
||||
{strategyOptions?.map((s) => (
|
||||
<label
|
||||
key={s.id}
|
||||
className="flex items-center gap-2 cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedStrategies.includes(s.name)}
|
||||
onChange={() => toggleStrategy(s.name)}
|
||||
className="w-4 h-4 rounded border-slate-600 bg-slate-700 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-slate-300">{s.name}</span>
|
||||
</label>
|
||||
)) ?? (
|
||||
<span className="text-xs text-slate-500">Loading strategies...</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={
|
||||
runMutation.isPending ||
|
||||
selectedStrategies.length === 0 ||
|
||||
!startDate ||
|
||||
!endDate
|
||||
}
|
||||
className="px-6 py-2.5 bg-blue-600 hover:bg-blue-700 disabled:bg-slate-600 disabled:cursor-not-allowed text-white font-medium rounded-lg transition-colors flex items-center gap-2"
|
||||
>
|
||||
{runMutation.isPending ? (
|
||||
<>
|
||||
<span className="inline-block w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||
Starting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
Run Backtest
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{runMutation.isError && (
|
||||
<p className="mt-3 text-sm text-red-400">
|
||||
Failed to start backtest. Please try again.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Results panel */}
|
||||
{currentRunId && (
|
||||
<div className="space-y-6">
|
||||
{/* Status */}
|
||||
{result && (result.status === 'pending' || result.status === 'running') && (
|
||||
<div className="bg-slate-800 rounded-xl p-6 border border-slate-700 text-center">
|
||||
<span className="inline-block w-8 h-8 border-2 border-blue-400/30 border-t-blue-400 rounded-full animate-spin mb-4" />
|
||||
<p className="text-white font-medium">
|
||||
Backtest {result.status}...
|
||||
</p>
|
||||
<p className="text-sm text-slate-400 mt-1">
|
||||
This may take a moment
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result?.status === 'failed' && (
|
||||
<div className="bg-red-900/20 rounded-xl p-6 border border-red-700">
|
||||
<p className="text-red-400 font-medium">Backtest Failed</p>
|
||||
<p className="text-sm text-red-300 mt-1">
|
||||
{result.error || 'An unknown error occurred'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result?.status === 'completed' && (
|
||||
<>
|
||||
{/* Metrics */}
|
||||
{metricsDisplay.length > 0 && (
|
||||
<MetricsRow metrics={metricsDisplay} />
|
||||
)}
|
||||
|
||||
{/* Equity curve */}
|
||||
{result.equity_curve && result.equity_curve.length > 0 && (
|
||||
<div className="bg-slate-800 rounded-xl p-6 border border-slate-700">
|
||||
<h3 className="text-lg font-semibold text-white mb-4">
|
||||
Equity Curve
|
||||
</h3>
|
||||
<EquityCurve data={result.equity_curve} height={350} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{resultLoading && !result && (
|
||||
<div className="flex justify-center py-8">
|
||||
<span className="inline-block w-8 h-8 border-2 border-blue-400/30 border-t-blue-400 rounded-full animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
164
dashboard/src/pages/NewsFeed.tsx
Normal file
164
dashboard/src/pages/NewsFeed.tsx
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import client from '../api/client';
|
||||
import { SentimentBadge } from '../components/SentimentBadge';
|
||||
|
||||
interface Article {
|
||||
id: string;
|
||||
title: string;
|
||||
source: string;
|
||||
url: string;
|
||||
published_at: string;
|
||||
ticker: string;
|
||||
sentiment_score: number;
|
||||
confidence: number;
|
||||
model_used: string;
|
||||
}
|
||||
|
||||
interface NewsResponse {
|
||||
articles: Article[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export default function NewsFeed() {
|
||||
const [ticker, setTicker] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const pageSize = 20;
|
||||
|
||||
const { data, isLoading } = useQuery<NewsResponse>({
|
||||
queryKey: ['news', ticker, page],
|
||||
queryFn: async () => {
|
||||
const params: Record<string, string | number> = { page, page_size: pageSize };
|
||||
if (ticker) params.ticker = ticker;
|
||||
const response = await client.get('/news', { params });
|
||||
return response.data;
|
||||
},
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
|
||||
const totalPages = data ? Math.ceil(data.total / pageSize) : 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-2xl font-bold text-white">News & Sentiment</h2>
|
||||
<span className="text-sm text-slate-400">
|
||||
{data?.total ?? 0} articles
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Ticker filter */}
|
||||
<div className="bg-slate-800 rounded-xl p-4 border border-slate-700">
|
||||
<div className="flex gap-4 items-end">
|
||||
<div className="flex-1 max-w-xs">
|
||||
<label className="block text-xs text-slate-400 mb-1">
|
||||
Filter by Ticker
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={ticker}
|
||||
onChange={(e) => {
|
||||
setTicker(e.target.value.toUpperCase());
|
||||
setPage(1);
|
||||
}}
|
||||
placeholder="e.g. AAPL, TSLA"
|
||||
className="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white text-sm placeholder-slate-400 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
{ticker && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setTicker('');
|
||||
setPage(1);
|
||||
}}
|
||||
className="px-3 py-2 text-sm text-slate-400 hover:text-white bg-slate-700 rounded-lg transition-colors"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Article cards */}
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-16">
|
||||
<span className="inline-block w-8 h-8 border-2 border-blue-400/30 border-t-blue-400 rounded-full animate-spin" />
|
||||
</div>
|
||||
) : data && data.articles.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{data.articles.map((article) => (
|
||||
<div
|
||||
key={article.id}
|
||||
className="bg-slate-800 rounded-xl p-5 border border-slate-700 hover:border-slate-600 transition-colors"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<a
|
||||
href={article.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-white font-medium hover:text-blue-400 transition-colors block truncate"
|
||||
>
|
||||
{article.title}
|
||||
</a>
|
||||
<div className="flex items-center gap-3 mt-2 text-xs text-slate-400">
|
||||
<span>{article.source}</span>
|
||||
<span>|</span>
|
||||
<span>
|
||||
{new Date(article.published_at).toLocaleString()}
|
||||
</span>
|
||||
{article.ticker && (
|
||||
<>
|
||||
<span>|</span>
|
||||
<span className="px-1.5 py-0.5 bg-slate-700 rounded text-slate-300 font-medium">
|
||||
${article.ticker}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<span>|</span>
|
||||
<span className="text-slate-500">
|
||||
via {article.model_used}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-2 flex-shrink-0">
|
||||
<SentimentBadge score={article.sentiment_score} />
|
||||
<span className="text-xs text-slate-500">
|
||||
conf: {(article.confidence * 100).toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-16 text-slate-400 bg-slate-800 rounded-xl border border-slate-700">
|
||||
No articles found{ticker ? ` for ${ticker}` : ''}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
className="px-4 py-2 text-sm bg-slate-800 border border-slate-700 text-slate-300 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed hover:bg-slate-700 transition-colors"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span className="text-sm text-slate-400">
|
||||
Page {page} of {totalPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={page === totalPages}
|
||||
className="px-4 py-2 text-sm bg-slate-800 border border-slate-700 text-slate-300 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed hover:bg-slate-700 transition-colors"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
143
dashboard/src/pages/Portfolio.tsx
Normal file
143
dashboard/src/pages/Portfolio.tsx
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
import { useState } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { usePortfolio, usePositions, useEquityCurve, usePortfolioMetrics } from '../hooks/usePortfolio';
|
||||
import { EquityCurve } from '../components/EquityCurve';
|
||||
import { PositionsTable } from '../components/PositionsTable';
|
||||
import { MetricsRow } from '../components/MetricsRow';
|
||||
|
||||
const PERIODS = ['1D', '1W', '1M', '3M', '6M', '1Y', 'ALL'] as const;
|
||||
|
||||
export default function Portfolio() {
|
||||
const [period, setPeriod] = useState<string>('1M');
|
||||
const queryClient = useQueryClient();
|
||||
const { data: portfolio, isLoading: portfolioLoading } = usePortfolio();
|
||||
const { data: positions, isLoading: positionsLoading } = usePositions();
|
||||
const { data: equityData, isLoading: equityLoading } = useEquityCurve(period);
|
||||
const { data: metrics } = usePortfolioMetrics();
|
||||
|
||||
const handlePositionClosed = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['positions'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['portfolio'] });
|
||||
};
|
||||
|
||||
const metricsDisplay = metrics
|
||||
? [
|
||||
{
|
||||
label: 'Total ROI',
|
||||
value: `${metrics.roi >= 0 ? '+' : ''}${metrics.roi.toFixed(2)}%`,
|
||||
color: metrics.roi >= 0 ? 'text-green-400' : 'text-red-400',
|
||||
},
|
||||
{
|
||||
label: 'Sharpe Ratio',
|
||||
value: metrics.sharpe.toFixed(2),
|
||||
color: metrics.sharpe >= 1 ? 'text-green-400' : metrics.sharpe >= 0 ? 'text-yellow-400' : 'text-red-400',
|
||||
},
|
||||
{
|
||||
label: 'Win Rate',
|
||||
value: `${(metrics.win_rate * 100).toFixed(1)}%`,
|
||||
color: metrics.win_rate >= 0.5 ? 'text-green-400' : 'text-red-400',
|
||||
},
|
||||
{
|
||||
label: 'Max Drawdown',
|
||||
value: `${metrics.max_drawdown.toFixed(2)}%`,
|
||||
color: 'text-red-400',
|
||||
},
|
||||
{
|
||||
label: 'Total Trades',
|
||||
value: metrics.total_trades.toString(),
|
||||
},
|
||||
{
|
||||
label: 'Avg Hold Duration',
|
||||
value: metrics.avg_hold_duration,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Portfolio value card */}
|
||||
<div className="bg-slate-800 rounded-xl p-6 border border-slate-700">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-white">Portfolio Overview</h2>
|
||||
<p className="text-slate-400 text-sm mt-1">Real-time portfolio performance</p>
|
||||
</div>
|
||||
{portfolio && (
|
||||
<div className="text-right">
|
||||
<p className="text-3xl font-bold text-white">
|
||||
${portfolio.total_value.toLocaleString('en-US', { minimumFractionDigits: 2 })}
|
||||
</p>
|
||||
<p
|
||||
className={`text-lg font-semibold ${
|
||||
portfolio.daily_pnl >= 0 ? 'text-green-400' : 'text-red-400'
|
||||
}`}
|
||||
>
|
||||
{portfolio.daily_pnl >= 0 ? '+' : ''}
|
||||
${portfolio.daily_pnl.toLocaleString('en-US', { minimumFractionDigits: 2 })}
|
||||
{' '}
|
||||
({portfolio.daily_pnl_pct >= 0 ? '+' : ''}{portfolio.daily_pnl_pct.toFixed(2)}%)
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{portfolioLoading && (
|
||||
<div className="flex justify-center py-8">
|
||||
<span className="inline-block w-8 h-8 border-2 border-blue-400/30 border-t-blue-400 rounded-full animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Equity curve */}
|
||||
<div className="bg-slate-800 rounded-xl p-6 border border-slate-700">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-white">Equity Curve</h3>
|
||||
<div className="flex gap-1 bg-slate-700 rounded-lg p-1">
|
||||
{PERIODS.map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => setPeriod(p)}
|
||||
className={`px-3 py-1 text-xs rounded-md transition-colors ${
|
||||
period === p
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'text-slate-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{equityLoading ? (
|
||||
<div className="flex justify-center py-16">
|
||||
<span className="inline-block w-8 h-8 border-2 border-blue-400/30 border-t-blue-400 rounded-full animate-spin" />
|
||||
</div>
|
||||
) : equityData && equityData.length > 0 ? (
|
||||
<EquityCurve data={equityData} height={350} />
|
||||
) : (
|
||||
<div className="text-center py-16 text-slate-400">
|
||||
No equity data available for this period
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Key metrics */}
|
||||
{metricsDisplay.length > 0 && <MetricsRow metrics={metricsDisplay} />}
|
||||
|
||||
{/* Positions table */}
|
||||
<div className="bg-slate-800 rounded-xl p-6 border border-slate-700">
|
||||
<h3 className="text-lg font-semibold text-white mb-4">Open Positions</h3>
|
||||
{positionsLoading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<span className="inline-block w-8 h-8 border-2 border-blue-400/30 border-t-blue-400 rounded-full animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<PositionsTable
|
||||
positions={positions ?? []}
|
||||
onPositionClosed={handlePositionClosed}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
235
dashboard/src/pages/Strategies.tsx
Normal file
235
dashboard/src/pages/Strategies.tsx
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
PieChart,
|
||||
Pie,
|
||||
Cell,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Legend,
|
||||
} from 'recharts';
|
||||
import client from '../api/client';
|
||||
|
||||
interface Strategy {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
current_weight: number;
|
||||
active: boolean;
|
||||
win_rate: number;
|
||||
total_pnl: number;
|
||||
total_trades: number;
|
||||
}
|
||||
|
||||
interface WeightHistoryPoint {
|
||||
timestamp: string;
|
||||
[strategyName: string]: string | number;
|
||||
}
|
||||
|
||||
const COLORS = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899'];
|
||||
|
||||
export default function Strategies() {
|
||||
const { data: strategies, isLoading } = useQuery<Strategy[]>({
|
||||
queryKey: ['strategies'],
|
||||
queryFn: async () => {
|
||||
const response = await client.get('/strategies');
|
||||
return response.data;
|
||||
},
|
||||
});
|
||||
|
||||
const { data: weightHistory } = useQuery<WeightHistoryPoint[]>({
|
||||
queryKey: ['strategies', 'weight-history'],
|
||||
queryFn: async () => {
|
||||
const response = await client.get('/strategies/weight-history');
|
||||
return response.data;
|
||||
},
|
||||
});
|
||||
|
||||
const pieData =
|
||||
strategies?.map((s) => ({
|
||||
name: s.name,
|
||||
value: s.current_weight,
|
||||
})) ?? [];
|
||||
|
||||
const strategyNames = strategies?.map((s) => s.name) ?? [];
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex justify-center py-16">
|
||||
<span className="inline-block w-8 h-8 border-2 border-blue-400/30 border-t-blue-400 rounded-full animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-2xl font-bold text-white">Strategy Performance</h2>
|
||||
|
||||
{/* Strategy cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{strategies?.map((strategy, idx) => (
|
||||
<div
|
||||
key={strategy.id}
|
||||
className="bg-slate-800 rounded-xl p-5 border border-slate-700"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-lg font-semibold text-white">{strategy.name}</h3>
|
||||
<span
|
||||
className="px-2.5 py-1 rounded-full text-xs font-medium"
|
||||
style={{
|
||||
backgroundColor: `${COLORS[idx % COLORS.length]}20`,
|
||||
color: COLORS[idx % COLORS.length],
|
||||
}}
|
||||
>
|
||||
{(strategy.current_weight * 100).toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-400 mb-4">{strategy.description}</p>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div>
|
||||
<p className="text-xs text-slate-400">Win Rate</p>
|
||||
<p
|
||||
className={`text-sm font-semibold ${
|
||||
strategy.win_rate >= 0.5 ? 'text-green-400' : 'text-red-400'
|
||||
}`}
|
||||
>
|
||||
{(strategy.win_rate * 100).toFixed(1)}%
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-400">Total P&L</p>
|
||||
<p
|
||||
className={`text-sm font-semibold ${
|
||||
strategy.total_pnl >= 0 ? 'text-green-400' : 'text-red-400'
|
||||
}`}
|
||||
>
|
||||
{strategy.total_pnl >= 0 ? '+' : ''}${strategy.total_pnl.toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-400">Trades</p>
|
||||
<p className="text-sm font-semibold text-white">
|
||||
{strategy.total_trades}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{!strategy.active && (
|
||||
<span className="mt-3 inline-block px-2 py-0.5 bg-slate-700 text-slate-400 text-xs rounded">
|
||||
Inactive
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Charts row */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Weight allocation pie chart */}
|
||||
<div className="bg-slate-800 rounded-xl p-6 border border-slate-700">
|
||||
<h3 className="text-lg font-semibold text-white mb-4">
|
||||
Weight Allocation
|
||||
</h3>
|
||||
{pieData.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={pieData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={60}
|
||||
outerRadius={100}
|
||||
paddingAngle={4}
|
||||
dataKey="value"
|
||||
label={({ name, value }) =>
|
||||
`${name}: ${(value * 100).toFixed(1)}%`
|
||||
}
|
||||
>
|
||||
{pieData.map((_, idx) => (
|
||||
<Cell
|
||||
key={`cell-${idx}`}
|
||||
fill={COLORS[idx % COLORS.length]}
|
||||
/>
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
formatter={(value) => `${(Number(value) * 100).toFixed(1)}%`}
|
||||
contentStyle={{
|
||||
backgroundColor: '#1e293b',
|
||||
border: '1px solid #475569',
|
||||
borderRadius: '0.5rem',
|
||||
color: '#e2e8f0',
|
||||
}}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-[300px] text-slate-400">
|
||||
No strategy data available
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Weight history line chart */}
|
||||
<div className="bg-slate-800 rounded-xl p-6 border border-slate-700">
|
||||
<h3 className="text-lg font-semibold text-white mb-4">
|
||||
Weight History
|
||||
</h3>
|
||||
{weightHistory && weightHistory.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<LineChart data={weightHistory}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
|
||||
<XAxis
|
||||
dataKey="timestamp"
|
||||
stroke="#94a3b8"
|
||||
tick={{ fontSize: 11 }}
|
||||
tickFormatter={(value) =>
|
||||
new Date(value).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})
|
||||
}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="#94a3b8"
|
||||
tick={{ fontSize: 11 }}
|
||||
tickFormatter={(value) => `${(value * 100).toFixed(0)}%`}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value) => `${(Number(value) * 100).toFixed(1)}%`}
|
||||
contentStyle={{
|
||||
backgroundColor: '#1e293b',
|
||||
border: '1px solid #475569',
|
||||
borderRadius: '0.5rem',
|
||||
color: '#e2e8f0',
|
||||
}}
|
||||
labelFormatter={(label) =>
|
||||
new Date(label).toLocaleDateString()
|
||||
}
|
||||
/>
|
||||
<Legend />
|
||||
{strategyNames.map((name, idx) => (
|
||||
<Line
|
||||
key={name}
|
||||
type="monotone"
|
||||
dataKey={name}
|
||||
stroke={COLORS[idx % COLORS.length]}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
/>
|
||||
))}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-[300px] text-slate-400">
|
||||
No weight history available
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
345
dashboard/src/pages/TradeLog.tsx
Normal file
345
dashboard/src/pages/TradeLog.tsx
Normal file
|
|
@ -0,0 +1,345 @@
|
|||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import client from '../api/client';
|
||||
|
||||
interface Trade {
|
||||
id: string;
|
||||
ticker: string;
|
||||
side: 'BUY' | 'SELL';
|
||||
qty: number;
|
||||
price: number;
|
||||
pnl: number | null;
|
||||
strategy_name: string;
|
||||
timestamp: string;
|
||||
status: string;
|
||||
signal_detail?: {
|
||||
direction: string;
|
||||
strength: number;
|
||||
strategy_sources: Record<string, number>;
|
||||
};
|
||||
news_context?: Array<{
|
||||
title: string;
|
||||
source: string;
|
||||
sentiment_score: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface TradesResponse {
|
||||
trades: Trade[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
}
|
||||
|
||||
export default function TradeLog() {
|
||||
const [page, setPage] = useState(1);
|
||||
const [ticker, setTicker] = useState('');
|
||||
const [strategy, setStrategy] = useState('');
|
||||
const [profitable, setProfitable] = useState<boolean | null>(null);
|
||||
const [dateFrom, setDateFrom] = useState('');
|
||||
const [dateTo, setDateTo] = useState('');
|
||||
const [expandedRow, setExpandedRow] = useState<string | null>(null);
|
||||
|
||||
const { data, isLoading } = useQuery<TradesResponse>({
|
||||
queryKey: ['trades', page, ticker, strategy, profitable, dateFrom, dateTo],
|
||||
queryFn: async () => {
|
||||
const params: Record<string, string | number | boolean> = {
|
||||
page,
|
||||
page_size: 20,
|
||||
};
|
||||
if (ticker) params.ticker = ticker;
|
||||
if (strategy) params.strategy = strategy;
|
||||
if (profitable !== null) params.profitable = profitable;
|
||||
if (dateFrom) params.date_from = dateFrom;
|
||||
if (dateTo) params.date_to = dateTo;
|
||||
|
||||
const response = await client.get('/trades', { params });
|
||||
return response.data;
|
||||
},
|
||||
});
|
||||
|
||||
const { data: strategies } = useQuery<Array<{ id: string; name: string }>>({
|
||||
queryKey: ['strategies-list'],
|
||||
queryFn: async () => {
|
||||
const response = await client.get('/strategies');
|
||||
return response.data;
|
||||
},
|
||||
});
|
||||
|
||||
const totalPages = data ? Math.ceil(data.total / data.page_size) : 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-2xl font-bold text-white">Trade Log</h2>
|
||||
<span className="text-sm text-slate-400">
|
||||
{data?.total ?? 0} total trades
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Filter bar */}
|
||||
<div className="bg-slate-800 rounded-xl p-4 border border-slate-700">
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<div className="flex-1 min-w-[150px]">
|
||||
<label className="block text-xs text-slate-400 mb-1">Ticker</label>
|
||||
<input
|
||||
type="text"
|
||||
value={ticker}
|
||||
onChange={(e) => { setTicker(e.target.value.toUpperCase()); setPage(1); }}
|
||||
placeholder="e.g. AAPL"
|
||||
className="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white text-sm placeholder-slate-400 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-[150px]">
|
||||
<label className="block text-xs text-slate-400 mb-1">Strategy</label>
|
||||
<select
|
||||
value={strategy}
|
||||
onChange={(e) => { setStrategy(e.target.value); setPage(1); }}
|
||||
className="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white text-sm focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">All strategies</option>
|
||||
{strategies?.map((s) => (
|
||||
<option key={s.id} value={s.name}>
|
||||
{s.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex-1 min-w-[140px]">
|
||||
<label className="block text-xs text-slate-400 mb-1">From</label>
|
||||
<input
|
||||
type="date"
|
||||
value={dateFrom}
|
||||
onChange={(e) => { setDateFrom(e.target.value); setPage(1); }}
|
||||
className="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white text-sm focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-[140px]">
|
||||
<label className="block text-xs text-slate-400 mb-1">To</label>
|
||||
<input
|
||||
type="date"
|
||||
value={dateTo}
|
||||
onChange={(e) => { setDateTo(e.target.value); setPage(1); }}
|
||||
className="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white text-sm focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-[120px]">
|
||||
<label className="block text-xs text-slate-400 mb-1">Profitable</label>
|
||||
<select
|
||||
value={profitable === null ? '' : profitable.toString()}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
setProfitable(v === '' ? null : v === 'true');
|
||||
setPage(1);
|
||||
}}
|
||||
className="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white text-sm focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">All</option>
|
||||
<option value="true">Profitable</option>
|
||||
<option value="false">Losing</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Trade table */}
|
||||
<div className="bg-slate-800 rounded-xl border border-slate-700 overflow-hidden">
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-16">
|
||||
<span className="inline-block w-8 h-8 border-2 border-blue-400/30 border-t-blue-400 rounded-full animate-spin" />
|
||||
</div>
|
||||
) : data && data.trades.length > 0 ? (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-700 bg-slate-800/80">
|
||||
<th className="text-left py-3 px-4 text-slate-400 font-medium">Ticker</th>
|
||||
<th className="text-left py-3 px-4 text-slate-400 font-medium">Side</th>
|
||||
<th className="text-right py-3 px-4 text-slate-400 font-medium">Qty</th>
|
||||
<th className="text-right py-3 px-4 text-slate-400 font-medium">Price</th>
|
||||
<th className="text-right py-3 px-4 text-slate-400 font-medium">P&L</th>
|
||||
<th className="text-left py-3 px-4 text-slate-400 font-medium">Strategy</th>
|
||||
<th className="text-left py-3 px-4 text-slate-400 font-medium">Time</th>
|
||||
<th className="w-8"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.trades.map((trade) => (
|
||||
<>
|
||||
<tr
|
||||
key={trade.id}
|
||||
className="border-b border-slate-700/50 hover:bg-slate-700/30 cursor-pointer transition-colors"
|
||||
onClick={() =>
|
||||
setExpandedRow(expandedRow === trade.id ? null : trade.id)
|
||||
}
|
||||
>
|
||||
<td className="py-3 px-4 font-medium text-white">
|
||||
{trade.ticker}
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<span
|
||||
className={`px-2 py-0.5 rounded text-xs font-medium ${
|
||||
trade.side === 'BUY'
|
||||
? 'bg-green-900/50 text-green-400'
|
||||
: 'bg-red-900/50 text-red-400'
|
||||
}`}
|
||||
>
|
||||
{trade.side}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-4 text-right text-slate-300">
|
||||
{trade.qty}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-right text-slate-300">
|
||||
${trade.price.toFixed(2)}
|
||||
</td>
|
||||
<td
|
||||
className={`py-3 px-4 text-right font-medium ${
|
||||
trade.pnl !== null
|
||||
? trade.pnl >= 0
|
||||
? 'text-green-400'
|
||||
: 'text-red-400'
|
||||
: 'text-slate-400'
|
||||
}`}
|
||||
>
|
||||
{trade.pnl !== null
|
||||
? `${trade.pnl >= 0 ? '+' : ''}$${trade.pnl.toFixed(2)}`
|
||||
: '-'}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-slate-300">
|
||||
{trade.strategy_name}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-slate-400 text-xs">
|
||||
{new Date(trade.timestamp).toLocaleString()}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-slate-400">
|
||||
<svg
|
||||
className={`w-4 h-4 transition-transform ${
|
||||
expandedRow === trade.id ? 'rotate-180' : ''
|
||||
}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</td>
|
||||
</tr>
|
||||
{expandedRow === trade.id && (
|
||||
<tr key={`${trade.id}-detail`} className="bg-slate-900/50">
|
||||
<td colSpan={8} className="py-4 px-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Signal detail */}
|
||||
{trade.signal_detail && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-slate-300 mb-2">
|
||||
Signal Detail
|
||||
</h4>
|
||||
<div className="space-y-1 text-xs text-slate-400">
|
||||
<p>
|
||||
Direction:{' '}
|
||||
<span className="text-white">
|
||||
{trade.signal_detail.direction}
|
||||
</span>
|
||||
</p>
|
||||
<p>
|
||||
Strength:{' '}
|
||||
<span className="text-white">
|
||||
{(trade.signal_detail.strength * 100).toFixed(1)}%
|
||||
</span>
|
||||
</p>
|
||||
<p className="mt-2">Strategy Contributions:</p>
|
||||
{Object.entries(
|
||||
trade.signal_detail.strategy_sources
|
||||
).map(([name, weight]) => (
|
||||
<p key={name} className="ml-2">
|
||||
{name}:{' '}
|
||||
<span className="text-white">
|
||||
{(weight * 100).toFixed(1)}%
|
||||
</span>
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* News context */}
|
||||
{trade.news_context && trade.news_context.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-slate-300 mb-2">
|
||||
News Context
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{trade.news_context.map((article, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="text-xs bg-slate-800 rounded p-2"
|
||||
>
|
||||
<p className="text-white">
|
||||
{article.title}
|
||||
</p>
|
||||
<p className="text-slate-400 mt-1">
|
||||
{article.source} | Sentiment:{' '}
|
||||
<span
|
||||
className={
|
||||
article.sentiment_score >= 0.3
|
||||
? 'text-green-400'
|
||||
: article.sentiment_score <= -0.3
|
||||
? 'text-red-400'
|
||||
: 'text-yellow-400'
|
||||
}
|
||||
>
|
||||
{article.sentiment_score.toFixed(2)}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-16 text-slate-400">
|
||||
No trades found matching your filters
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between p-4 border-t border-slate-700">
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
className="px-4 py-2 text-sm bg-slate-700 text-slate-300 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed hover:bg-slate-600 transition-colors"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span className="text-sm text-slate-400">
|
||||
Page {page} of {totalPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={page === totalPages}
|
||||
className="px-4 py-2 text-sm bg-slate-700 text-slate-300 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed hover:bg-slate-600 transition-colors"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue