From 8d6e66628098c8f959c96069d7c48885e88846a1 Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Sun, 22 Feb 2026 15:54:44 +0000 Subject: [PATCH] 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. --- dashboard/src/components/EquityCurve.tsx | 89 +++++ dashboard/src/components/Layout.tsx | 141 ++++++++ dashboard/src/components/MetricsRow.tsx | 27 ++ dashboard/src/components/PositionsTable.tsx | 86 +++++ dashboard/src/components/SentimentBadge.tsx | 43 +++ dashboard/src/hooks/usePortfolio.ts | 83 +++++ dashboard/src/hooks/useWebSocket.ts | 108 ++++++ dashboard/src/pages/Backtest.tsx | 297 +++++++++++++++++ dashboard/src/pages/NewsFeed.tsx | 164 ++++++++++ dashboard/src/pages/Portfolio.tsx | 143 ++++++++ dashboard/src/pages/Strategies.tsx | 235 +++++++++++++ dashboard/src/pages/TradeLog.tsx | 345 ++++++++++++++++++++ 12 files changed, 1761 insertions(+) create mode 100644 dashboard/src/components/EquityCurve.tsx create mode 100644 dashboard/src/components/Layout.tsx create mode 100644 dashboard/src/components/MetricsRow.tsx create mode 100644 dashboard/src/components/PositionsTable.tsx create mode 100644 dashboard/src/components/SentimentBadge.tsx create mode 100644 dashboard/src/hooks/usePortfolio.ts create mode 100644 dashboard/src/hooks/useWebSocket.ts create mode 100644 dashboard/src/pages/Backtest.tsx create mode 100644 dashboard/src/pages/NewsFeed.tsx create mode 100644 dashboard/src/pages/Portfolio.tsx create mode 100644 dashboard/src/pages/Strategies.tsx create mode 100644 dashboard/src/pages/TradeLog.tsx 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} + + +
+ )} +
+
+ ); +}