diff --git a/dashboard/src/components/EquityCurve.tsx b/dashboard/src/components/EquityCurve.tsx new file mode 100644 index 0000000..f215565 --- /dev/null +++ b/dashboard/src/components/EquityCurve.tsx @@ -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(null); + const chartRef = useRef(null); + const seriesRef = useRef | 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 ( +
+ ); +} diff --git a/dashboard/src/components/Layout.tsx b/dashboard/src/components/Layout.tsx new file mode 100644 index 0000000..e6a5a97 --- /dev/null +++ b/dashboard/src/components/Layout.tsx @@ -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 ( +
+ {/* Sidebar */} + + + {/* Main content */} +
+ {/* Top bar */} +
+
+
+ Portfolio Value +

+ ${portfolioValue.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} +

+
+
+ Daily P&L +

= 0 ? 'text-green-400' : 'text-red-400' + }`} + > + {dailyPnl >= 0 ? '+' : ''} + ${dailyPnl.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} +

+
+
+ +
+ {lastEvent && ( + + {lastEvent.type} + + )} +
+ + + {isActive ? 'Active' : 'Paused'} + +
+
+
+ + {/* Page content */} +
+ +
+
+
+ ); +} diff --git a/dashboard/src/components/MetricsRow.tsx b/dashboard/src/components/MetricsRow.tsx new file mode 100644 index 0000000..96f45c2 --- /dev/null +++ b/dashboard/src/components/MetricsRow.tsx @@ -0,0 +1,27 @@ +interface Metric { + label: string; + value: string; + color?: string; +} + +interface MetricsRowProps { + metrics: Metric[]; +} + +export function MetricsRow({ metrics }: MetricsRowProps) { + return ( +
+ {metrics.map((metric) => ( +
+

{metric.label}

+

+ {metric.value} +

+
+ ))} +
+ ); +} diff --git a/dashboard/src/components/PositionsTable.tsx b/dashboard/src/components/PositionsTable.tsx new file mode 100644 index 0000000..f7db87b --- /dev/null +++ b/dashboard/src/components/PositionsTable.tsx @@ -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 ( +
+ No open positions +
+ ); + } + + return ( +
+ + + + + + + + + + + + + + {positions.map((pos) => ( + + + + + + + + + + ))} + +
TickerQtyAvg EntryCurrentP&LP&L %Actions
{pos.ticker}{pos.qty} + ${pos.avg_entry.toFixed(2)} + + ${pos.current_price.toFixed(2)} + = 0 ? 'text-green-400' : 'text-red-400' + }`} + > + {pos.unrealized_pnl >= 0 ? '+' : ''} + ${pos.unrealized_pnl.toFixed(2)} + = 0 ? 'text-green-400' : 'text-red-400' + }`} + > + {pos.unrealized_pnl_pct >= 0 ? '+' : ''} + {pos.unrealized_pnl_pct.toFixed(2)}% + + +
+
+ ); +} diff --git a/dashboard/src/components/SentimentBadge.tsx b/dashboard/src/components/SentimentBadge.tsx new file mode 100644 index 0000000..f91ff5c --- /dev/null +++ b/dashboard/src/components/SentimentBadge.tsx @@ -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 ( + + = 0.3 + ? 'bg-green-400' + : score <= -0.3 + ? 'bg-red-400' + : 'bg-yellow-400' + }`} + /> + {label} ({score >= 0 ? '+' : ''}{score.toFixed(2)}) + + ); +} diff --git a/dashboard/src/hooks/usePortfolio.ts b/dashboard/src/hooks/usePortfolio.ts new file mode 100644 index 0000000..69b33cc --- /dev/null +++ b/dashboard/src/hooks/usePortfolio.ts @@ -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({ + queryKey: ['portfolio'], + queryFn: async () => { + const response = await client.get('/portfolio'); + return response.data; + }, + refetchInterval: 30_000, + }); +} + +export function usePositions() { + return useQuery({ + 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({ + queryKey: ['equity-curve', period], + queryFn: async () => { + const response = await client.get('/portfolio/history', { + params: { period }, + }); + return response.data; + }, + }); +} + +export function usePortfolioMetrics() { + return useQuery({ + queryKey: ['portfolio', 'metrics'], + queryFn: async () => { + const response = await client.get('/portfolio/metrics'); + return response.data; + }, + }); +} diff --git a/dashboard/src/hooks/useWebSocket.ts b/dashboard/src/hooks/useWebSocket.ts new file mode 100644 index 0000000..a880bba --- /dev/null +++ b/dashboard/src/hooks/useWebSocket.ts @@ -0,0 +1,108 @@ +import { useEffect, useRef, useState, useCallback } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; + +export interface WebSocketEvent { + type: string; + data: Record; + timestamp: string; +} + +const WS_RECONNECT_DELAY = 3000; +const WS_MAX_RECONNECT_DELAY = 30000; + +export function useWebSocket() { + const queryClient = useQueryClient(); + const wsRef = useRef(null); + const reconnectTimeoutRef = useRef | null>(null); + const reconnectDelayRef = useRef(WS_RECONNECT_DELAY); + const [lastEvent, setLastEvent] = useState(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 }; +} diff --git a/dashboard/src/pages/Backtest.tsx b/dashboard/src/pages/Backtest.tsx new file mode 100644 index 0000000..3071616 --- /dev/null +++ b/dashboard/src/pages/Backtest.tsx @@ -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([]); + const [currentRunId, setCurrentRunId] = useState(null); + + const { data: strategyOptions } = useQuery({ + queryKey: ['strategies-options'], + queryFn: async () => { + const response = await client.get('/strategies'); + return response.data; + }, + }); + + const { data: result, isLoading: resultLoading } = useQuery({ + 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 ( +
+

Backtesting

+ + {/* Config form */} +
+

+ Configuration +

+
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+ +
+ {strategyOptions?.map((s) => ( + + )) ?? ( + Loading strategies... + )} +
+
+
+ + + + {runMutation.isError && ( +

+ Failed to start backtest. Please try again. +

+ )} +
+ + {/* Results panel */} + {currentRunId && ( +
+ {/* Status */} + {result && (result.status === 'pending' || result.status === 'running') && ( +
+ +

+ Backtest {result.status}... +

+

+ This may take a moment +

+
+ )} + + {result?.status === 'failed' && ( +
+

Backtest Failed

+

+ {result.error || 'An unknown error occurred'} +

+
+ )} + + {result?.status === 'completed' && ( + <> + {/* Metrics */} + {metricsDisplay.length > 0 && ( + + )} + + {/* Equity curve */} + {result.equity_curve && result.equity_curve.length > 0 && ( +
+

+ Equity Curve +

+ +
+ )} + + )} + + {resultLoading && !result && ( +
+ +
+ )} +
+ )} +
+ ); +} diff --git a/dashboard/src/pages/NewsFeed.tsx b/dashboard/src/pages/NewsFeed.tsx new file mode 100644 index 0000000..68178ec --- /dev/null +++ b/dashboard/src/pages/NewsFeed.tsx @@ -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({ + queryKey: ['news', ticker, page], + queryFn: async () => { + const params: Record = { 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 ( +
+
+

News & Sentiment

+ + {data?.total ?? 0} articles + +
+ + {/* Ticker filter */} +
+
+
+ + { + 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" + /> +
+ {ticker && ( + + )} +
+
+ + {/* Article cards */} + {isLoading ? ( +
+ +
+ ) : data && data.articles.length > 0 ? ( +
+ {data.articles.map((article) => ( +
+
+
+ + {article.title} + +
+ {article.source} + | + + {new Date(article.published_at).toLocaleString()} + + {article.ticker && ( + <> + | + + ${article.ticker} + + + )} + | + + via {article.model_used} + +
+
+
+ + + conf: {(article.confidence * 100).toFixed(0)}% + +
+
+
+ ))} +
+ ) : ( +
+ No articles found{ticker ? ` for ${ticker}` : ''} +
+ )} + + {/* Pagination */} + {totalPages > 1 && ( +
+ + + Page {page} of {totalPages} + + +
+ )} +
+ ); +} diff --git a/dashboard/src/pages/Portfolio.tsx b/dashboard/src/pages/Portfolio.tsx new file mode 100644 index 0000000..fa52e74 --- /dev/null +++ b/dashboard/src/pages/Portfolio.tsx @@ -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('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 ( +
+ {/* Portfolio value card */} +
+
+
+

Portfolio Overview

+

Real-time portfolio performance

+
+ {portfolio && ( +
+

+ ${portfolio.total_value.toLocaleString('en-US', { minimumFractionDigits: 2 })} +

+

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

+
+ )} +
+ + {portfolioLoading && ( +
+ +
+ )} +
+ + {/* Equity curve */} +
+
+

Equity Curve

+
+ {PERIODS.map((p) => ( + + ))} +
+
+ {equityLoading ? ( +
+ +
+ ) : equityData && equityData.length > 0 ? ( + + ) : ( +
+ No equity data available for this period +
+ )} +
+ + {/* Key metrics */} + {metricsDisplay.length > 0 && } + + {/* Positions table */} +
+

Open Positions

+ {positionsLoading ? ( +
+ +
+ ) : ( + + )} +
+
+ ); +} diff --git a/dashboard/src/pages/Strategies.tsx b/dashboard/src/pages/Strategies.tsx new file mode 100644 index 0000000..07ebf8f --- /dev/null +++ b/dashboard/src/pages/Strategies.tsx @@ -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({ + queryKey: ['strategies'], + queryFn: async () => { + const response = await client.get('/strategies'); + return response.data; + }, + }); + + const { data: weightHistory } = useQuery({ + 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 ( +
+ +
+ ); + } + + return ( +
+

Strategy Performance

+ + {/* Strategy cards */} +
+ {strategies?.map((strategy, idx) => ( +
+
+

{strategy.name}

+ + {(strategy.current_weight * 100).toFixed(1)}% + +
+

{strategy.description}

+
+
+

Win Rate

+

= 0.5 ? 'text-green-400' : 'text-red-400' + }`} + > + {(strategy.win_rate * 100).toFixed(1)}% +

+
+
+

Total P&L

+

= 0 ? 'text-green-400' : 'text-red-400' + }`} + > + {strategy.total_pnl >= 0 ? '+' : ''}${strategy.total_pnl.toFixed(2)} +

+
+
+

Trades

+

+ {strategy.total_trades} +

+
+
+ {!strategy.active && ( + + Inactive + + )} +
+ ))} +
+ + {/* Charts row */} +
+ {/* Weight allocation pie chart */} +
+

+ Weight Allocation +

+ {pieData.length > 0 ? ( + + + + `${name}: ${(value * 100).toFixed(1)}%` + } + > + {pieData.map((_, idx) => ( + + ))} + + `${(Number(value) * 100).toFixed(1)}%`} + contentStyle={{ + backgroundColor: '#1e293b', + border: '1px solid #475569', + borderRadius: '0.5rem', + color: '#e2e8f0', + }} + /> + + + ) : ( +
+ No strategy data available +
+ )} +
+ + {/* Weight history line chart */} +
+

+ Weight History +

+ {weightHistory && weightHistory.length > 0 ? ( + + + + + new Date(value).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + }) + } + /> + `${(value * 100).toFixed(0)}%`} + /> + `${(Number(value) * 100).toFixed(1)}%`} + contentStyle={{ + backgroundColor: '#1e293b', + border: '1px solid #475569', + borderRadius: '0.5rem', + color: '#e2e8f0', + }} + labelFormatter={(label) => + new Date(label).toLocaleDateString() + } + /> + + {strategyNames.map((name, idx) => ( + + ))} + + + ) : ( +
+ No weight history available +
+ )} +
+
+
+ ); +} diff --git a/dashboard/src/pages/TradeLog.tsx b/dashboard/src/pages/TradeLog.tsx new file mode 100644 index 0000000..298ee4a --- /dev/null +++ b/dashboard/src/pages/TradeLog.tsx @@ -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; + }; + 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(null); + const [dateFrom, setDateFrom] = useState(''); + const [dateTo, setDateTo] = useState(''); + const [expandedRow, setExpandedRow] = useState(null); + + const { data, isLoading } = useQuery({ + queryKey: ['trades', page, ticker, strategy, profitable, dateFrom, dateTo], + queryFn: async () => { + const params: Record = { + 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>({ + 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 ( +
+
+

Trade Log

+ + {data?.total ?? 0} total trades + +
+ + {/* Filter bar */} +
+
+
+ + { 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" + /> +
+
+ + +
+
+ + { 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" + /> +
+
+ + { 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" + /> +
+
+ + +
+
+
+ + {/* Trade table */} +
+ {isLoading ? ( +
+ +
+ ) : data && data.trades.length > 0 ? ( +
+ + + + + + + + + + + + + + + {data.trades.map((trade) => ( + <> + + setExpandedRow(expandedRow === trade.id ? null : trade.id) + } + > + + + + + + + + + + {expandedRow === trade.id && ( + + + + )} + + ))} + +
TickerSideQtyPriceP&LStrategyTime
+ {trade.ticker} + + + {trade.side} + + + {trade.qty} + + ${trade.price.toFixed(2)} + = 0 + ? 'text-green-400' + : 'text-red-400' + : 'text-slate-400' + }`} + > + {trade.pnl !== null + ? `${trade.pnl >= 0 ? '+' : ''}$${trade.pnl.toFixed(2)}` + : '-'} + + {trade.strategy_name} + + {new Date(trade.timestamp).toLocaleString()} + + + + +
+
+ {/* Signal detail */} + {trade.signal_detail && ( +
+

+ Signal Detail +

+
+

+ Direction:{' '} + + {trade.signal_detail.direction} + +

+

+ Strength:{' '} + + {(trade.signal_detail.strength * 100).toFixed(1)}% + +

+

Strategy Contributions:

+ {Object.entries( + trade.signal_detail.strategy_sources + ).map(([name, weight]) => ( +

+ {name}:{' '} + + {(weight * 100).toFixed(1)}% + +

+ ))} +
+
+ )} + + {/* News context */} + {trade.news_context && trade.news_context.length > 0 && ( +
+

+ News Context +

+
+ {trade.news_context.map((article, idx) => ( +
+

+ {article.title} +

+

+ {article.source} | Sentiment:{' '} + = 0.3 + ? 'text-green-400' + : article.sentiment_score <= -0.3 + ? 'text-red-400' + : 'text-yellow-400' + } + > + {article.sentiment_score.toFixed(2)} + +

+
+ ))} +
+
+ )} +
+
+
+ ) : ( +
+ No trades found matching your filters +
+ )} + + {/* Pagination */} + {totalPages > 1 && ( +
+ + + Page {page} of {totalPages} + + +
+ )} +
+
+ ); +}