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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue