Add React frontend for energy trading system

Implements React + TypeScript UI with Vite and Tailwind CSS.
Features dashboard with real-time WebSocket updates, backtesting
page, model management interface, trading controls, and settings.
Includes state management with Zustand, API integration with
Axios/TanStack Query, and interactive charts with Recharts.
This commit is contained in:
2026-02-12 01:01:08 +07:00
parent fe76bc7629
commit c6bca0b7bb
48 changed files with 5978 additions and 0 deletions

2
frontend/.env.example Normal file
View File

@@ -0,0 +1,2 @@
VITE_API_URL=http://localhost:8000
VITE_WS_URL=ws://localhost:8000/ws/real-time

26
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,26 @@
# Dependencies
node_modules/
dist/
# Logs
*.log
npm-debug.log*
# Environment
.env
.env.local
.env.*.local
.env.production
!.env.example
!.env.template
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db

13
frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Energy Trading UI</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

3168
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

32
frontend/package.json Normal file
View File

@@ -0,0 +1,32 @@
{
"name": "energy-trading-ui",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"type-check": "tsc --noEmit"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.20.0",
"recharts": "^2.10.0",
"zustand": "^4.4.0",
"@tanstack/react-query": "^5.0.0",
"axios": "^1.6.0",
"date-fns": "^2.30.0",
"lucide-react": "^0.292.0"
},
"devDependencies": {
"@types/react": "^18.2.37",
"@types/react-dom": "^18.2.15",
"@vitejs/plugin-react": "^4.2.0",
"typescript": "^5.2.2",
"vite": "^5.0.0",
"tailwindcss": "^3.3.5",
"postcss": "^8.4.31",
"autoprefixer": "^10.4.16"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

36
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,36 @@
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { useWebSocket } from '@/hooks/useWebSocket';
import Header from '@/components/common/Header';
import Sidebar from '@/components/common/Sidebar';
import Loading from '@/components/common/Loading';
import Dashboard from '@/pages/Dashboard';
import Backtest from '@/pages/Backtest';
import Models from '@/pages/Models';
import Trading from '@/pages/Trading';
import Settings from '@/pages/Settings';
function App() {
const { isConnected } = useWebSocket();
return (
<BrowserRouter>
<div className="min-h-screen bg-gray-900 text-gray-100">
<Header isConnected={isConnected} />
<div className="flex">
<Sidebar />
<main className="flex-1 p-6">
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/backtest" element={<Backtest />} />
<Route path="/models" element={<Models />} />
<Route path="/trading" element={<Trading />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</main>
</div>
</div>
</BrowserRouter>
);
}
export default App;

View File

@@ -0,0 +1,49 @@
import { X } from 'lucide-react';
import type { Alert } from '@/services/types';
import { formatRelativeTime } from '@/lib/formatters';
interface AlertItemProps {
alert: Alert;
onDismiss?: () => void;
}
export default function AlertItem({ alert, onDismiss }: AlertItemProps) {
const getAlertStyles = (type: string) => {
switch (type) {
case 'price_spike':
return 'bg-red-500/10 border-red-500/50';
case 'arbitrage_opportunity':
return 'bg-green-500/10 border-green-500/50';
case 'battery_low':
return 'bg-yellow-500/10 border-yellow-500/50';
case 'battery_full':
return 'bg-blue-500/10 border-blue-500/50';
case 'strategy_error':
return 'bg-red-500/10 border-red-500/50';
default:
return 'bg-gray-500/10 border-gray-500/50';
}
};
return (
<div className={`flex items-start gap-3 p-3 rounded-lg border ${getAlertStyles(alert.alert_type)}`}>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs font-medium text-gray-400">
{alert.alert_type.replace('_', ' ').toUpperCase()}
</span>
<span className="text-xs text-gray-500">{formatRelativeTime(alert.timestamp)}</span>
</div>
<p className="text-sm text-gray-200">{alert.message}</p>
</div>
{onDismiss && (
<button
onClick={onDismiss}
className="p-1 hover:bg-gray-700/50 rounded transition-colors"
>
<X className="h-4 w-4 text-gray-400" />
</button>
)}
</div>
);
}

View File

@@ -0,0 +1,93 @@
import { X } from 'lucide-react';
import { useState } from 'react';
import type { Alert } from '@/services/types';
import { formatRelativeTime } from '@/lib/formatters';
interface AlertPanelProps {
alerts: Alert[];
onAcknowledge?: (alertId: string) => void;
}
export default function AlertPanel({ alerts, onAcknowledge }: AlertPanelProps) {
const [filter, setFilter] = useState<'all' | 'unacknowledged'>('all');
const filteredAlerts = alerts.filter((alert) =>
filter === 'all' || !alert.acknowledged
);
const getAlertColor = (type: string) => {
switch (type) {
case 'price_spike':
return 'bg-red-500/20 border-red-500 text-red-400';
case 'arbitrage_opportunity':
return 'bg-green-500/20 border-green-500 text-green-400';
case 'battery_low':
return 'bg-yellow-500/20 border-yellow-500 text-yellow-400';
case 'battery_full':
return 'bg-blue-500/20 border-blue-500 text-blue-400';
case 'strategy_error':
return 'bg-red-500/20 border-red-500 text-red-400';
default:
return 'bg-gray-500/20 border-gray-500 text-gray-400';
}
};
if (alerts.length === 0) {
return (
<div className="bg-gray-800 rounded-lg p-6">
<div className="text-center py-4 text-gray-400">No alerts</div>
</div>
);
}
return (
<div className="bg-gray-800 rounded-lg">
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-700">
<h3 className="text-lg font-semibold text-white">Alerts ({filteredAlerts.length})</h3>
<div className="flex gap-2">
<button
onClick={() => setFilter('all')}
className={`px-3 py-1 rounded text-sm ${filter === 'all' ? 'bg-primary-600 text-white' : 'text-gray-400 hover:text-white'}`}
>
All
</button>
<button
onClick={() => setFilter('unacknowledged')}
className={`px-3 py-1 rounded text-sm ${filter === 'unacknowledged' ? 'bg-primary-600 text-white' : 'text-gray-400 hover:text-white'}`}
>
Unacknowledged
</button>
</div>
</div>
<div className="max-h-96 overflow-y-auto">
{filteredAlerts.map((alert) => (
<div
key={alert.alert_id}
className={`p-4 border-b border-gray-700 ${alert.acknowledged ? 'opacity-50' : ''}`}
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className={`px-2 py-0.5 rounded text-xs font-medium border ${getAlertColor(alert.alert_type)}`}>
{alert.alert_type.replace('_', ' ').toUpperCase()}
</span>
<span className="text-xs text-gray-400">{formatRelativeTime(alert.timestamp)}</span>
</div>
<p className="text-white">{alert.message}</p>
</div>
{!alert.acknowledged && onAcknowledge && (
<button
onClick={() => onAcknowledge(alert.alert_id)}
className="p-1 hover:bg-gray-700 rounded transition-colors"
title="Acknowledge"
>
<X className="h-4 w-4 text-gray-400" />
</button>
)}
</div>
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,42 @@
import { Bar, BarChart, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell } from 'recharts';
import type { BatteryState } from '@/services/types';
import { formatPercentage } from '@/lib/formatters';
interface BatteryChartProps {
data: BatteryState[];
}
export default function BatteryChart({ data }: BatteryChartProps) {
const chartData = data.map((b) => ({
id: b.battery_id,
charge: b.charge_level_pct * 100,
capacity: b.capacity_mwh,
}));
const getBarColor = (value: number) => {
if (value >= 80) return '#10b981';
if (value >= 50) return '#3b82f6';
if (value >= 20) return '#f59e0b';
return '#ef4444';
};
return (
<ResponsiveContainer width="100%" height={300}>
<BarChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke="#374151" />
<XAxis dataKey="id" stroke="#9ca3af" />
<YAxis stroke="#9ca3af" label={{ value: 'Charge %', angle: -90, position: 'insideLeft' }} />
<Tooltip
contentStyle={{ backgroundColor: '#1f2937', border: '1px solid #374151', borderRadius: '8px' }}
itemStyle={{ color: '#e5e7eb' }}
formatter={(value: number) => formatPercentage(value / 100)}
/>
<Bar dataKey="charge" name="Charge Level">
{chartData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={getBarColor(entry.charge)} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
);
}

View File

@@ -0,0 +1,47 @@
import { Bar, BarChart, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
interface GenerationData {
fuel: string;
value: number;
}
interface GenerationChartProps {
data: GenerationData[];
}
const FUEL_COLORS: Record<string, string> = {
gas: '#f59e0b',
nuclear: '#10b981',
coal: '#6b7280',
solar: '#fbbf24',
wind: '#3b82f6',
hydro: '#06b6d4',
};
export default function GenerationChart({ data }: GenerationChartProps) {
const chartData = data.map((d) => ({
fuel: d.fuel.charAt(0).toUpperCase() + d.fuel.slice(1),
value: d.value,
}));
return (
<ResponsiveContainer width="100%" height={300}>
<BarChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke="#374151" />
<XAxis dataKey="fuel" stroke="#9ca3af" />
<YAxis stroke="#9ca3af" label={{ value: 'Generation (MW)', angle: -90, position: 'insideLeft' }} />
<Tooltip
contentStyle={{ backgroundColor: '#1f2937', border: '1px solid #374151', borderRadius: '8px' }}
itemStyle={{ color: '#e5e7eb' }}
formatter={(value: number) => `${value.toFixed(1)} MW`}
/>
<Legend />
<Bar dataKey="value" name="Generation" fill="#3b82f6">
{chartData.map((entry, index) => (
<rect key={`cell-${index}`} fill={FUEL_COLORS[entry.fuel.toLowerCase()] || '#3b82f6'} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
);
}

View File

@@ -0,0 +1,36 @@
import { Line, LineChart, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
interface ModelMetricsChartProps {
data: Array<{ epoch: number; [key: string]: number }>;
metrics: string[];
}
export default function ModelMetricsChart({ data, metrics }: ModelMetricsChartProps) {
const COLORS = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6'];
return (
<ResponsiveContainer width="100%" height={300}>
<LineChart data={data}>
<CartesianGrid strokeDasharray="3 3" stroke="#374151" />
<XAxis dataKey="epoch" stroke="#9ca3af" label={{ value: 'Epoch', position: 'insideBottom', offset: -5 }} />
<YAxis stroke="#9ca3af" />
<Tooltip
contentStyle={{ backgroundColor: '#1f2937', border: '1px solid #374151', borderRadius: '8px' }}
itemStyle={{ color: '#e5e7eb' }}
/>
<Legend />
{metrics.map((metric, index) => (
<Line
key={metric}
type="monotone"
dataKey={metric}
stroke={COLORS[index % COLORS.length]}
name={metric.charAt(0).toUpperCase() + metric.slice(1)}
strokeWidth={2}
dot={false}
/>
))}
</LineChart>
</ResponsiveContainer>
);
}

View File

@@ -0,0 +1,39 @@
import { Line, LineChart, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, Area, AreaChart } from 'recharts';
interface PnLChartProps {
data: Array<{ timestamp: string; pnl: number }>;
}
export default function PnLChart({ data }: PnLChartProps) {
const chartData = data.map((d) => ({
time: new Date(d.timestamp).toLocaleTimeString(),
pnl: d.pnl,
}));
const maxPnL = Math.max(...chartData.map((d) => d.pnl), 0);
const minPnL = Math.min(...chartData.map((d) => d.pnl), 0);
return (
<ResponsiveContainer width="100%" height={300}>
<AreaChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke="#374151" />
<XAxis dataKey="time" stroke="#9ca3af" />
<YAxis stroke="#9ca3af" label={{ value: 'P&L (€)', angle: -90, position: 'insideLeft' }} />
<Tooltip
contentStyle={{ backgroundColor: '#1f2937', border: '1px solid #374151', borderRadius: '8px' }}
itemStyle={{ color: '#e5e7eb' }}
formatter={(value: number) => `${value.toFixed(2)}`}
/>
<Legend />
<Area
type="monotone"
dataKey="pnl"
stroke="#3b82f6"
fill="#3b82f6"
fillOpacity={0.3}
name="Profit & Loss"
/>
</AreaChart>
</ResponsiveContainer>
);
}

View File

@@ -0,0 +1,32 @@
import { Line, LineChart, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
import type { PriceData } from '@/services/types';
interface PriceChartProps {
data: PriceData[];
region?: string;
}
export default function PriceChart({ data, region = 'FR' }: PriceChartProps) {
const chartData = data.map((d) => ({
time: new Date(d.timestamp).toLocaleTimeString(),
dayAhead: d.day_ahead_price,
realtime: d.real_time_price,
}));
return (
<ResponsiveContainer width="100%" height={300}>
<LineChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke="#374151" />
<XAxis dataKey="time" stroke="#9ca3af" />
<YAxis stroke="#9ca3af" label={{ value: 'Price (€/MWh)', angle: -90, position: 'insideLeft' }} />
<Tooltip
contentStyle={{ backgroundColor: '#1f2937', border: '1px solid #374151', borderRadius: '8px' }}
itemStyle={{ color: '#e5e7eb' }}
/>
<Legend />
<Line type="monotone" dataKey="dayAhead" stroke="#3b82f6" name="Day Ahead" strokeWidth={2} dot={false} />
<Line type="monotone" dataKey="realtime" stroke="#10b981" name="Real Time" strokeWidth={2} dot={false} />
</LineChart>
</ResponsiveContainer>
);
}

View File

@@ -0,0 +1,26 @@
import { AlertTriangle } from 'lucide-react';
interface ErrorProps {
message?: string;
onRetry?: () => void;
}
export default function Error({ message = 'Something went wrong', onRetry }: ErrorProps) {
return (
<div className="flex items-center justify-center min-h-screen bg-gray-900">
<div className="flex flex-col items-center gap-4 max-w-md">
<AlertTriangle className="h-16 w-16 text-danger-500" />
<h2 className="text-xl font-semibold text-white">Error</h2>
<p className="text-gray-400 text-center">{message}</p>
{onRetry && (
<button
onClick={onRetry}
className="px-6 py-2 bg-primary-600 hover:bg-primary-700 text-white rounded-lg transition-colors"
>
Retry
</button>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,26 @@
import { Zap } from 'lucide-react';
interface HeaderProps {
title?: string;
isConnected?: boolean;
}
export default function Header({ title = 'Energy Trading UI', isConnected = false }: HeaderProps) {
return (
<header className="bg-gray-800 border-b border-gray-700 px-6 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Zap className="h-8 w-8 text-primary-500" />
<h1 className="text-2xl font-bold text-white">{title}</h1>
</div>
<div className="flex items-center gap-4">
<div className={`flex items-center gap-2 px-3 py-1.5 rounded-full text-sm font-medium ${isConnected ? 'bg-green-500/20 text-green-400' : 'bg-red-500/20 text-red-400'}`}>
<span className={`h-2 w-2 rounded-full ${isConnected ? 'bg-green-400' : 'bg-red-400'}`} />
<span>{isConnected ? 'Connected' : 'Disconnected'}</span>
</div>
<div className="text-gray-400 text-sm">v1.0.0</div>
</div>
</div>
</header>
);
}

View File

@@ -0,0 +1,12 @@
export default function Loading() {
return (
<div className="flex items-center justify-center min-h-screen bg-gray-900">
<div className="flex flex-col items-center gap-4">
<div className="relative">
<div className="h-12 w-12 border-4 border-primary-500 border-t-transparent rounded-full animate-spin" />
</div>
<p className="text-gray-400">Loading...</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,38 @@
import { NavLink } from 'react-router-dom';
import { LayoutDashboard, TrendingUp, Brain, Activity, Settings } from 'lucide-react';
const navItems = [
{ path: '/', label: 'Dashboard', icon: LayoutDashboard },
{ path: '/backtest', label: 'Backtest', icon: TrendingUp },
{ path: '/models', label: 'Models', icon: Brain },
{ path: '/trading', label: 'Trading', icon: Activity },
{ path: '/settings', label: 'Settings', icon: Settings },
];
export default function Sidebar() {
return (
<aside className="w-64 bg-gray-900 border-r border-gray-700 min-h-screen">
<nav className="p-4 space-y-2">
{navItems.map((item) => {
const Icon = item.icon;
return (
<NavLink
key={item.path}
to={item.path}
className={({ isActive }) =>
`flex items-center gap-3 px-4 py-3 rounded-lg transition-colors ${
isActive
? 'bg-primary-600 text-white'
: 'text-gray-300 hover:bg-gray-800'
}`
}
>
<Icon className="h-5 w-5" />
<span>{item.label}</span>
</NavLink>
);
})}
</nav>
</aside>
);
}

View File

@@ -0,0 +1,149 @@
import { useState } from 'react';
import type { BacktestConfig, Strategy } from '@/services/types';
import { STRATEGIES } from '@/lib/constants';
interface BacktestFormProps {
onSubmit: (config: BacktestConfig) => void;
isLoading?: boolean;
}
export default function BacktestForm({ onSubmit, isLoading }: BacktestFormProps) {
const [config, setConfig] = useState<BacktestConfig>({
start_date: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
end_date: new Date().toISOString().split('T')[0],
strategies: [],
use_ml: true,
battery_min_reserve: 0.1,
battery_max_charge: 0.9,
arbitrage_min_spread: 5,
});
const [name, setName] = useState('');
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSubmit(config);
};
const toggleStrategy = (strategy: Strategy) => {
setConfig((prev) => ({
...prev,
strategies: prev.strategies.includes(strategy)
? prev.strategies.filter((s) => s !== strategy)
: [...prev.strategies, strategy],
}));
};
return (
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">Backtest Name</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Optional: Enter a name for this backtest"
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white focus:outline-none focus:border-primary-500"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">Start Date</label>
<input
type="date"
value={config.start_date}
onChange={(e) => setConfig({ ...config, start_date: e.target.value })}
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white focus:outline-none focus:border-primary-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">End Date</label>
<input
type="date"
value={config.end_date}
onChange={(e) => setConfig({ ...config, end_date: e.target.value })}
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white focus:outline-none focus:border-primary-500"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">Strategies</label>
<div className="flex flex-wrap gap-2">
{STRATEGIES.map((strategy) => (
<button
key={strategy}
type="button"
onClick={() => toggleStrategy(strategy)}
className={`px-4 py-2 rounded-lg capitalize ${
config.strategies.includes(strategy)
? 'bg-primary-600 text-white'
: 'bg-gray-800 text-gray-300 hover:bg-gray-700'
}`}
>
{strategy}
</button>
))}
</div>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="use-ml"
checked={config.use_ml}
onChange={(e) => setConfig({ ...config, use_ml: e.target.checked })}
className="w-4 h-4 rounded border-gray-700 text-primary-600 focus:ring-primary-500 bg-gray-800"
/>
<label htmlFor="use-ml" className="text-sm text-gray-300">Use ML Models</label>
</div>
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">Battery Min Reserve</label>
<input
type="number"
step="0.01"
min="0"
max="1"
value={config.battery_min_reserve || ''}
onChange={(e) => setConfig({ ...config, battery_min_reserve: parseFloat(e.target.value) })}
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white focus:outline-none focus:border-primary-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">Battery Max Charge</label>
<input
type="number"
step="0.01"
min="0"
max="1"
value={config.battery_max_charge || ''}
onChange={(e) => setConfig({ ...config, battery_max_charge: parseFloat(e.target.value) })}
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white focus:outline-none focus:border-primary-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">Min Spread (/MWh)</label>
<input
type="number"
step="0.1"
min="0"
value={config.arbitrage_min_spread || ''}
onChange={(e) => setConfig({ ...config, arbitrage_min_spread: parseFloat(e.target.value) })}
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white focus:outline-none focus:border-primary-500"
/>
</div>
</div>
<button
type="submit"
disabled={isLoading || config.strategies.length === 0}
className="w-full px-4 py-3 bg-primary-600 hover:bg-primary-700 disabled:bg-gray-700 disabled:text-gray-500 text-white rounded-lg font-medium transition-colors"
>
{isLoading ? 'Starting...' : 'Start Backtest'}
</button>
</form>
);
}

View File

@@ -0,0 +1,91 @@
import { useState, useEffect } from 'react';
import type { AppSettings } from '@/services/types';
interface SettingsFormProps {
onSubmit: (settings: Partial<AppSettings>) => void;
isLoading?: boolean;
}
export default function SettingsForm({ onSubmit, isLoading }: SettingsFormProps) {
const [settings, setSettings] = useState<AppSettings>({
battery_min_reserve: 0.1,
battery_max_charge: 0.9,
arbitrage_min_spread: 5.0,
mining_margin_threshold: 5.0,
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSubmit(settings);
};
return (
<form onSubmit={handleSubmit} className="space-y-6">
<div className="bg-gray-800 p-4 rounded-lg">
<h3 className="text-lg font-semibold text-white mb-4">Battery Settings</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">Minimum Reserve</label>
<input
type="number"
step="0.01"
min="0"
max="1"
value={settings.battery_min_reserve}
onChange={(e) => setSettings({ ...settings, battery_min_reserve: parseFloat(e.target.value) })}
className="w-full px-4 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white focus:outline-none focus:border-primary-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">Maximum Charge</label>
<input
type="number"
step="0.01"
min="0"
max="1"
value={settings.battery_max_charge}
onChange={(e) => setSettings({ ...settings, battery_max_charge: parseFloat(e.target.value) })}
className="w-full px-4 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white focus:outline-none focus:border-primary-500"
/>
</div>
</div>
</div>
<div className="bg-gray-800 p-4 rounded-lg">
<h3 className="text-lg font-semibold text-white mb-4">Trading Settings</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">Min Arbitrage Spread (/MWh)</label>
<input
type="number"
step="0.1"
min="0"
value={settings.arbitrage_min_spread}
onChange={(e) => setSettings({ ...settings, arbitrage_min_spread: parseFloat(e.target.value) })}
className="w-full px-4 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white focus:outline-none focus:border-primary-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">Mining Margin Threshold (/MWh)</label>
<input
type="number"
step="0.1"
min="0"
value={settings.mining_margin_threshold}
onChange={(e) => setSettings({ ...settings, mining_margin_threshold: parseFloat(e.target.value) })}
className="w-full px-4 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white focus:outline-none focus:border-primary-500"
/>
</div>
</div>
</div>
<button
type="submit"
disabled={isLoading}
className="w-full px-4 py-3 bg-primary-600 hover:bg-primary-700 disabled:bg-gray-700 disabled:text-gray-500 text-white rounded-lg font-medium transition-colors"
>
{isLoading ? 'Saving...' : 'Save Settings'}
</button>
</form>
);
}

View File

@@ -0,0 +1,105 @@
import { useState } from 'react';
import type { TrainingRequest, ModelType } from '@/services/types';
import { MODEL_TYPES } from '@/lib/constants';
interface TrainingFormProps {
onSubmit: (request: TrainingRequest) => void;
isLoading?: boolean;
}
export default function TrainingForm({ onSubmit, isLoading }: TrainingFormProps) {
const [request, setRequest] = useState<TrainingRequest>({
model_type: 'price_prediction',
start_date: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
end_date: new Date().toISOString().split('T')[0],
horizon: 60,
hyperparameters: {},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSubmit(request);
};
return (
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">Model Type</label>
<select
value={request.model_type}
onChange={(e) => setRequest({ ...request, model_type: e.target.value as ModelType })}
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white focus:outline-none focus:border-primary-500"
>
{MODEL_TYPES.map((type) => (
<option key={type} value={type}>
{type.charAt(0).toUpperCase() + type.slice(1).replace('_', ' ')}
</option>
))}
</select>
</div>
{request.model_type === 'price_prediction' && (
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">Prediction Horizon (minutes)</label>
<select
value={request.horizon || 60}
onChange={(e) => setRequest({ ...request, horizon: parseInt(e.target.value) })}
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white focus:outline-none focus:border-primary-500"
>
{[1, 5, 15, 30, 60, 120, 240].map((h) => (
<option key={h} value={h}>
{h} minutes
</option>
))}
</select>
</div>
)}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">Start Date</label>
<input
type="date"
value={request.start_date}
onChange={(e) => setRequest({ ...request, start_date: e.target.value })}
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white focus:outline-none focus:border-primary-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">End Date</label>
<input
type="date"
value={request.end_date}
onChange={(e) => setRequest({ ...request, end_date: e.target.value })}
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white focus:outline-none focus:border-primary-500"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">Hyperparameters (JSON)</label>
<textarea
value={JSON.stringify(request.hyperparameters, null, 2)}
onChange={(e) => {
try {
setRequest({ ...request, hyperparameters: JSON.parse(e.target.value) });
} catch (err) {
console.error('Invalid JSON:', err);
}
}}
rows={6}
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white focus:outline-none focus:border-primary-500 font-mono text-sm"
placeholder='{"learning_rate": 0.001, "n_estimators": 100}'
/>
</div>
<button
type="submit"
disabled={isLoading}
className="w-full px-4 py-3 bg-primary-600 hover:bg-primary-700 disabled:bg-gray-700 disabled:text-gray-500 text-white rounded-lg font-medium transition-colors"
>
{isLoading ? 'Training...' : 'Start Training'}
</button>
</form>
);
}

View File

@@ -0,0 +1,51 @@
import type { ArbitrageOpportunity } from '@/services/types';
import { formatPrice, formatVolume, formatTimestamp } from '@/lib/formatters';
interface ArbitrageTableProps {
data: ArbitrageOpportunity[];
}
export default function ArbitrageTable({ data }: ArbitrageTableProps) {
if (data.length === 0) {
return (
<div className="text-center py-8 text-gray-400">
No arbitrage opportunities available
</div>
);
}
return (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-700">
<th className="text-left py-3 px-4 text-gray-400 font-medium">Timestamp</th>
<th className="text-left py-3 px-4 text-gray-400 font-medium">Buy Region</th>
<th className="text-left py-3 px-4 text-gray-400 font-medium">Buy Price</th>
<th className="text-left py-3 px-4 text-gray-400 font-medium">Sell Region</th>
<th className="text-left py-3 px-4 text-gray-400 font-medium">Sell Price</th>
<th className="text-left py-3 px-4 text-gray-400 font-medium">Spread</th>
<th className="text-left py-3 px-4 text-gray-400 font-medium">Volume</th>
</tr>
</thead>
<tbody>
{data.map((opportunity, index) => (
<tr key={index} className="border-b border-gray-800 hover:bg-gray-800/50">
<td className="py-3 px-4 text-gray-300">{formatTimestamp(opportunity.timestamp)}</td>
<td className="py-3 px-4 text-gray-300">{opportunity.buy_region}</td>
<td className="py-3 px-4 text-gray-300">{formatPrice(opportunity.buy_price)}</td>
<td className="py-3 px-4 text-gray-300">{opportunity.sell_region}</td>
<td className="py-3 px-4 text-gray-300">{formatPrice(opportunity.sell_price)}</td>
<td className="py-3 px-4">
<span className="px-2 py-1 bg-green-500/20 text-green-400 rounded font-medium">
+{opportunity.spread.toFixed(2)}
</span>
</td>
<td className="py-3 px-4 text-gray-300">{formatVolume(opportunity.volume_mw)}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}

View File

@@ -0,0 +1,70 @@
import type { ModelInfo, TrainingStatusResponse } from '@/services/types';
import { formatTimestamp } from '@/lib/formatters';
import { MODEL_TYPE_LABELS } from '@/lib/constants';
interface ModelListTableProps {
data: ModelInfo[];
trainingJobs?: Record<string, TrainingStatusResponse>;
}
export default function ModelListTable({ data, trainingJobs }: ModelListTableProps) {
if (data.length === 0) {
return (
<div className="text-center py-8 text-gray-400">
No models available
</div>
);
}
return (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-700">
<th className="text-left py-3 px-4 text-gray-400 font-medium">Model ID</th>
<th className="text-left py-3 px-4 text-gray-400 font-medium">Type</th>
<th className="text-left py-3 px-4 text-gray-400 font-medium">Version</th>
<th className="text-left py-3 px-4 text-gray-400 font-medium">Created</th>
<th className="text-left py-3 px-4 text-gray-400 font-medium">Status</th>
<th className="text-left py-3 px-4 text-gray-400 font-medium">Metrics</th>
</tr>
</thead>
<tbody>
{data.map((model) => {
const trainingJob = trainingJobs?.[model.model_id];
const status = trainingJob ? trainingJob.status : 'completed';
return (
<tr key={model.model_id} className="border-b border-gray-800 hover:bg-gray-800/50">
<td className="py-3 px-4 text-gray-300 font-mono">{model.model_id}</td>
<td className="py-3 px-4 text-gray-300">{MODEL_TYPE_LABELS[model.model_type]}</td>
<td className="py-3 px-4 text-gray-300">{model.version}</td>
<td className="py-3 px-4 text-gray-300">{formatTimestamp(model.created_at)}</td>
<td className="py-3 px-4">
<span className={`px-2 py-1 rounded text-xs font-medium ${
status === 'completed'
? 'bg-green-500/20 text-green-400'
: status === 'running'
? 'bg-blue-500/20 text-blue-400'
: status === 'failed'
? 'bg-red-500/20 text-red-400'
: 'bg-yellow-500/20 text-yellow-400'
}`}>
{status.charAt(0).toUpperCase() + status.slice(1)}
</span>
</td>
<td className="py-3 px-4 text-gray-300 text-xs">
{Object.entries(model.metrics).map(([key, value]) => (
<div key={key}>
<span className="text-gray-400">{key}:</span> {value.toFixed(3)}
</div>
))}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}

View File

@@ -0,0 +1,56 @@
import type { Trade } from '@/services/types';
import { formatPrice, formatVolume, formatTimestamp } from '@/lib/formatters';
import { TRADE_TYPE_LABELS } from '@/lib/constants';
interface TradeLogTableProps {
data: Trade[];
}
export default function TradeLogTable({ data }: TradeLogTableProps) {
if (data.length === 0) {
return (
<div className="text-center py-8 text-gray-400">
No trades executed
</div>
);
}
return (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-700">
<th className="text-left py-3 px-4 text-gray-400 font-medium">Timestamp</th>
<th className="text-left py-3 px-4 text-gray-400 font-medium">Type</th>
<th className="text-left py-3 px-4 text-gray-400 font-medium">Region</th>
<th className="text-left py-3 px-4 text-gray-400 font-medium">Price</th>
<th className="text-left py-3 px-4 text-gray-400 font-medium">Volume</th>
<th className="text-left py-3 px-4 text-gray-400 font-medium">Revenue</th>
</tr>
</thead>
<tbody>
{data.map((trade, index) => (
<tr key={index} className="border-b border-gray-800 hover:bg-gray-800/50">
<td className="py-3 px-4 text-gray-300">{formatTimestamp(trade.timestamp)}</td>
<td className="py-3 px-4">
<span className={`px-2 py-1 rounded text-xs font-medium ${
trade.trade_type === 'buy' || trade.trade_type === 'charge'
? 'bg-blue-500/20 text-blue-400'
: 'bg-green-500/20 text-green-400'
}`}>
{TRADE_TYPE_LABELS[trade.trade_type]}
</span>
</td>
<td className="py-3 px-4 text-gray-300">{trade.region || '-'}</td>
<td className="py-3 px-4 text-gray-300">{formatPrice(trade.price)}</td>
<td className="py-3 px-4 text-gray-300">{formatVolume(trade.volume_mw)}</td>
<td className={`py-3 px-4 font-medium ${trade.revenue >= 0 ? 'text-green-400' : 'text-red-400'}`}>
{trade.revenue >= 0 ? '+' : ''}{trade.revenue.toFixed(2)}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}

View File

@@ -0,0 +1,185 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { dashboardApi, backtestApi, modelsApi, tradingApi, settingsApi } from '@/services/api';
import type {
DashboardSummary,
BacktestConfig,
BacktestStatusResponse,
ModelInfo,
TrainingRequest,
TrainingStatus as TrainingStatusType,
PredictionResponse,
StrategyStatus,
AppSettings,
Strategy,
} from '@/services/types';
export function useDashboardSummary() {
return useQuery({
queryKey: ['dashboard', 'summary'],
queryFn: () => dashboardApi.getSummary().then((r) => r.data),
});
}
export function usePrices() {
return useQuery({
queryKey: ['dashboard', 'prices'],
queryFn: () => dashboardApi.getPrices().then((r) => r.data),
});
}
export function usePriceHistory(region: string, start?: string, end?: string, limit?: number) {
return useQuery({
queryKey: ['dashboard', 'prices', 'history', region, start, end, limit],
queryFn: () =>
dashboardApi.getPriceHistory(region, start, end, limit).then((r) => r.data),
enabled: !!region,
});
}
export function useBatteryStates() {
return useQuery({
queryKey: ['dashboard', 'battery'],
queryFn: () => dashboardApi.getBattery().then((r) => r.data),
});
}
export function useArbitrageOpportunities(minSpread?: number) {
return useQuery({
queryKey: ['dashboard', 'arbitrage', minSpread],
queryFn: () => dashboardApi.getArbitrage(minSpread).then((r) => r.data),
});
}
export function useStartBacktest() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ config, name }: { config: BacktestConfig; name?: string }) =>
backtestApi.start(config, name).then((r) => r.data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['backtest'] });
},
});
}
export function useBacktest(backtestId: string) {
return useQuery({
queryKey: ['backtest', backtestId],
queryFn: () => backtestApi.getStatus(backtestId).then((r) => r.data),
enabled: !!backtestId,
refetchInterval: (data) => {
return data?.status === 'running' ? 1000 : false;
},
});
}
export function useBacktestHistory() {
return useQuery({
queryKey: ['backtest', 'history'],
queryFn: () => backtestApi.getHistory().then((r) => r.data),
});
}
export function useBacktestTrades(backtestId: string, limit?: number) {
return useQuery({
queryKey: ['backtest', backtestId, 'trades', limit],
queryFn: () => backtestApi.getTrades(backtestId, limit).then((r) => r.data),
enabled: !!backtestId,
});
}
export function useDeleteBacktest() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (backtestId: string) => backtestApi.delete(backtestId).then((r) => r.data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['backtest'] });
},
});
}
export function useModels() {
return useQuery({
queryKey: ['models'],
queryFn: () => modelsApi.list().then((r) => r.data),
});
}
export function useTrainModel() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (request: TrainingRequest) =>
modelsApi.train(request).then((r) => r.data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['models'] });
},
});
}
export function useTrainingStatus(trainingId: string) {
return useQuery({
queryKey: ['models', 'training', trainingId],
queryFn: () => modelsApi.getTrainingStatus(trainingId).then((r) => r.data),
enabled: !!trainingId,
refetchInterval: (data) => {
return data?.status === 'running' ? 1000 : false;
},
});
}
export function useModelMetrics(modelId: string) {
return useQuery({
queryKey: ['models', modelId, 'metrics'],
queryFn: () => modelsApi.getMetrics(modelId).then((r) => r.data),
enabled: !!modelId,
});
}
export function usePredict() {
return useMutation({
mutationFn: ({ modelId, timestamp, features }: { modelId: string; timestamp: string; features?: Record<string, unknown> }) =>
modelsApi.predict(modelId, timestamp, features).then((r) => r.data),
});
}
export function useStrategies() {
return useQuery({
queryKey: ['trading', 'strategies'],
queryFn: () => tradingApi.getStrategies().then((r) => r.data),
});
}
export function useToggleStrategy() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ strategy, action }: { strategy: Strategy; action: 'start' | 'stop' }) =>
tradingApi.toggleStrategy(strategy, action).then((r) => r.data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['trading'] });
},
});
}
export function usePositions() {
return useQuery({
queryKey: ['trading', 'positions'],
queryFn: () => tradingApi.getPositions().then((r) => r.data),
});
}
export function useSettings() {
return useQuery({
queryKey: ['settings'],
queryFn: () => settingsApi.get().then((r) => r.data),
});
}
export function useUpdateSettings() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (settings: Partial<AppSettings>) =>
settingsApi.update(settings).then((r) => r.data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['settings'] });
},
});
}

View File

@@ -0,0 +1,21 @@
import { useEffect } from 'react';
import { webSocketService } from '@/services/websocket';
import type { WebSocketEventType } from '@/services/types';
export function useWebSocket() {
useEffect(() => {
webSocketService.connect();
return () => webSocketService.disconnect();
}, []);
const subscribe = <T = unknown>(
eventType: WebSocketEventType,
handler: (data: T) => void
): (() => void) => {
return webSocketService.subscribe<T>(eventType, handler);
};
const isConnected = webSocketService.getConnectionStatus();
return { subscribe, isConnected };
}

22
frontend/src/index.css Normal file
View File

@@ -0,0 +1,22 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
margin: 0;
padding: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}
* {
box-sizing: border-box;
}

View File

@@ -0,0 +1,65 @@
import type { Region, Strategy, TradeType, BacktestStatus, ModelType, TrainingStatus, AlertType } from '@/services/types';
export const REGIONS: Region[] = ['FR', 'BE', 'DE', 'NL', 'UK'];
export const STRATEGIES: Strategy[] = ['fundamental', 'technical', 'ml', 'mining'];
export const TRADE_TYPES: TradeType[] = ['buy', 'sell', 'charge', 'discharge'];
export const BACKTEST_STATUSES: BacktestStatus[] = ['pending', 'running', 'completed', 'failed', 'cancelled'];
export const MODEL_TYPES: ModelType[] = ['price_prediction', 'rl_battery'];
export const TRAINING_STATUSES: TrainingStatus[] = ['pending', 'running', 'completed', 'failed'];
export const ALERT_TYPES: AlertType[] = ['price_spike', 'arbitrage_opportunity', 'battery_low', 'battery_full', 'strategy_error'];
export const REGION_LABELS: Record<Region, string> = {
FR: 'France',
BE: 'Belgium',
DE: 'Germany',
NL: 'Netherlands',
UK: 'United Kingdom',
};
export const STRATEGY_LABELS: Record<Strategy, string> = {
fundamental: 'Fundamental',
technical: 'Technical',
ml: 'ML Based',
mining: 'Mining',
};
export const TRADE_TYPE_LABELS: Record<TradeType, string> = {
buy: 'Buy',
sell: 'Sell',
charge: 'Charge',
discharge: 'Discharge',
};
export const BACKTEST_STATUS_LABELS: Record<BacktestStatus, string> = {
pending: 'Pending',
running: 'Running',
completed: 'Completed',
failed: 'Failed',
cancelled: 'Cancelled',
};
export const MODEL_TYPE_LABELS: Record<ModelType, string> = {
price_prediction: 'Price Prediction',
rl_battery: 'RL Battery',
};
export const TRAINING_STATUS_LABELS: Record<TrainingStatus, string> = {
pending: 'Pending',
running: 'Running',
completed: 'Completed',
failed: 'Failed',
};
export const ALERT_TYPE_LABELS: Record<AlertType, string> = {
price_spike: 'Price Spike',
arbitrage_opportunity: 'Arbitrage Opportunity',
battery_low: 'Battery Low',
battery_full: 'Battery Full',
strategy_error: 'Strategy Error',
};

View File

@@ -0,0 +1,80 @@
export function formatPrice(value: number): string {
return new Intl.NumberFormat('en-EU', {
style: 'currency',
currency: 'EUR',
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(value);
}
export function formatVolume(value: number): string {
if (value >= 1000) {
return `${(value / 1000).toFixed(1)} GW`;
}
return `${value.toFixed(2)} MW`;
}
export function formatPower(value: number): string {
if (value >= 1000) {
return `${(value / 1000).toFixed(2)} GW`;
}
return `${value.toFixed(2)} MW`;
}
export function formatEnergy(value: number): string {
if (value >= 1000) {
return `${(value / 1000).toFixed(2)} GWh`;
}
return `${value.toFixed(2)} MWh`;
}
export function formatPercentage(value: number, decimals: number = 1): string {
return `${value.toFixed(decimals)}%`;
}
export function formatPnL(value: number): string {
const formatted = new Intl.NumberFormat('en-EU', {
style: 'currency',
currency: 'EUR',
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(Math.abs(value));
return value >= 0 ? `+${formatted}` : `-${formatted}`;
}
export function formatRelativeTime(timestamp: string): string {
const now = new Date();
const then = new Date(timestamp);
const diffMs = now.getTime() - then.getTime();
const diffSecs = Math.floor(diffMs / 1000);
const diffMins = Math.floor(diffSecs / 60);
const diffHours = Math.floor(diffMins / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffSecs < 60) return 'just now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return then.toLocaleDateString();
}
export function formatTimestamp(timestamp: string): string {
return new Date(timestamp).toLocaleString();
}
export function formatDuration(start: string, end?: string): string {
const startTime = new Date(start).getTime();
const endTime = end ? new Date(end).getTime() : Date.now();
const diffMs = endTime - startTime;
const diffSecs = Math.floor(diffMs / 1000);
const diffMins = Math.floor(diffSecs / 60);
const diffHours = Math.floor(diffMins / 60);
const parts = [];
if (diffHours > 0) parts.push(`${diffHours}h`);
if (diffMins % 60 > 0) parts.push(`${diffMins % 60}m`);
if (diffSecs % 60 > 0) parts.push(`${diffSecs % 60}s`);
return parts.join(' ') || '0s';
}

28
frontend/src/lib/utils.ts Normal file
View File

@@ -0,0 +1,28 @@
import { type ClassValue, clsx } from 'clsx';
export function cn(...inputs: ClassValue[]) {
return clsx(inputs);
}
export function formatDate(date: string | Date): string {
const d = new Date(date);
return d.toLocaleString();
}
export function formatCurrency(value: number, currency: string = 'EUR'): string {
return new Intl.NumberFormat('en-EU', {
style: 'currency',
currency,
}).format(value);
}
export function formatNumber(value: number, decimals: number = 2): string {
return new Intl.NumberFormat('en-EU', {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
}).format(value);
}
export function formatPercentage(value: number, decimals: number = 2): string {
return `${formatNumber(value * 100, decimals)}%`;
}

19
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,19 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './index.css';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
const queryClient = new QueryClient({
defaultOptions: {
queries: { refetchOnWindowFocus: false },
},
});
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</React.StrictMode>,
);

View File

@@ -0,0 +1,167 @@
import { useState } from 'react';
import { useStartBacktest, useBacktestHistory, useDeleteBacktest } from '@/hooks/useApi';
import type { BacktestConfig } from '@/services/types';
import { BACKTEST_STATUS_LABELS } from '@/lib/constants';
import { formatTimestamp, formatDuration, formatPrice } from '@/lib/formatters';
import BacktestForm from '@/components/forms/BacktestForm';
import TradeLogTable from '@/components/tables/TradeLogTable';
export default function Backtest() {
const [selectedBacktestId, setSelectedBacktestId] = useState<string | null>(null);
const [showForm, setShowForm] = useState(false);
const { mutate: startBacktest, isPending: isStarting } = useStartBacktest();
const { data: historyData, isLoading: isLoadingHistory } = useBacktestHistory();
const { mutate: deleteBacktest } = useDeleteBacktest();
const handleStartBacktest = (config: BacktestConfig) => {
startBacktest({ config }, {
onSuccess: (data) => {
setSelectedBacktestId(data.backtest_id);
setShowForm(false);
},
});
};
const handleDelete = (backtestId: string) => {
if (confirm('Are you sure you want to delete this backtest?')) {
deleteBacktest(backtestId);
if (selectedBacktestId === backtestId) {
setSelectedBacktestId(null);
}
}
};
const getStatusColor = (status: string) => {
switch (status) {
case 'completed': return 'bg-green-500/20 text-green-400';
case 'running': return 'bg-blue-500/20 text-blue-400';
case 'failed': return 'bg-red-500/20 text-red-400';
case 'cancelled': return 'bg-gray-500/20 text-gray-400';
default: return 'bg-yellow-500/20 text-yellow-400';
}
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-white">Backtesting</h1>
<button
onClick={() => setShowForm(!showForm)}
className="px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white rounded-lg font-medium transition-colors"
>
{showForm ? 'Cancel' : 'New Backtest'}
</button>
</div>
{showForm && (
<div className="bg-gray-800 rounded-lg p-6">
<h2 className="text-lg font-semibold text-white mb-4">Configure Backtest</h2>
<BacktestForm onSubmit={handleStartBacktest} isLoading={isStarting} />
</div>
)}
<div className="grid grid-cols-3 gap-6">
<div className="col-span-2">
<div className="bg-gray-800 rounded-lg p-6">
<h2 className="text-lg font-semibold text-white mb-4">Backtest History</h2>
{isLoadingHistory ? (
<div className="text-center py-8 text-gray-400">Loading...</div>
) : historyData && historyData.backtests.length > 0 ? (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-700">
<th className="text-left py-3 px-4 text-gray-400 font-medium">Name</th>
<th className="text-left py-3 px-4 text-gray-400 font-medium">Status</th>
<th className="text-left py-3 px-4 text-gray-400 font-medium">Created</th>
<th className="text-left py-3 px-4 text-gray-400 font-medium">Actions</th>
</tr>
</thead>
<tbody>
{historyData.backtests.map((backtest) => (
<tr
key={backtest.backtest_id}
className={`border-b border-gray-800 cursor-pointer ${selectedBacktestId === backtest.backtest_id ? 'bg-primary-600/20' : 'hover:bg-gray-700'}`}
onClick={() => setSelectedBacktestId(backtest.backtest_id)}
>
<td className="py-3 px-4 text-gray-300">{backtest.name}</td>
<td className="py-3 px-4">
<span className={`px-2 py-1 rounded text-xs font-medium ${getStatusColor(backtest.status)}`}>
{BACKTEST_STATUS_LABELS[backtest.status]}
</span>
</td>
<td className="py-3 px-4 text-gray-300">{formatTimestamp(backtest.created_at)}</td>
<td className="py-3 px-4">
<button
onClick={(e) => {
e.stopPropagation();
handleDelete(backtest.backtest_id);
}}
className="text-red-400 hover:text-red-300"
>
Delete
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="text-center py-8 text-gray-400">No backtests yet</div>
)}
</div>
</div>
{selectedBacktestId && (
<div>
<div className="bg-gray-800 rounded-lg p-6">
<h2 className="text-lg font-semibold text-white mb-4">Backtest Details</h2>
{(() => {
const backtest = historyData?.backtests.find((b) => b.backtest_id === selectedBacktestId);
if (!backtest) return null;
return (
<div className="space-y-4">
<div>
<span className="text-gray-400 text-sm">Name:</span>
<p className="text-white">{backtest.name}</p>
</div>
<div>
<span className="text-gray-400 text-sm">Status:</span>
<p className={`text-white ${getStatusColor(backtest.status)}`}>{BACKTEST_STATUS_LABELS[backtest.status]}</p>
</div>
<div>
<span className="text-gray-400 text-sm">Duration:</span>
<p className="text-white">{formatDuration(backtest.started_at, backtest.completed_at || undefined)}</p>
</div>
{backtest.results && (
<>
<div className="border-t border-gray-700 pt-4">
<h3 className="text-md font-semibold text-white mb-2">Metrics</h3>
<div className="grid grid-cols-2 gap-2 text-sm">
<div><span className="text-gray-400">Revenue:</span> <span className="text-green-400">{formatPrice(backtest.results.metrics.total_revenue)}</span></div>
<div><span className="text-gray-400">Arbitrage:</span> <span className="text-green-400">{formatPrice(backtest.results.metrics.arbitrage_profit)}</span></div>
<div><span className="text-gray-400">Battery:</span> <span className="text-green-400">{formatPrice(backtest.results.metrics.battery_revenue)}</span></div>
<div><span className="text-gray-400">Win Rate:</span> <span className="text-white">{(backtest.results.metrics.win_rate * 100).toFixed(1)}%</span></div>
<div><span className="text-gray-400">Sharpe:</span> <span className="text-white">{backtest.results.metrics.sharpe_ratio.toFixed(2)}</span></div>
<div><span className="text-gray-400">Trades:</span> <span className="text-white">{backtest.results.metrics.total_trades}</span></div>
</div>
</div>
<div className="border-t border-gray-700 pt-4">
<h3 className="text-md font-semibold text-white mb-2">Recent Trades</h3>
<TradeLogTable data={backtest.results.trades.slice(-10)} />
</div>
</>
)}
</div>
);
})()}
</div>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,120 @@
import { useEffect } from 'react';
import { useWebSocket } from '@/hooks/useWebSocket';
import { useStore } from '@/store';
import { useDashboardSummary, usePrices, useBatteryStates, useArbitrageOpportunities } from '@/hooks/useApi';
import { formatPrice, formatVolume, formatPercentage, formatTimestamp } from '@/lib/formatters';
import PriceChart from '@/components/charts/PriceChart';
import BatteryChart from '@/components/charts/BatteryChart';
import ArbitrageTable from '@/components/tables/ArbitrageTable';
import AlertPanel from '@/components/alerts/AlertPanel';
export default function Dashboard() {
const { subscribe, isConnected } = useWebSocket();
const { summary, prices, batteryStates, arbitrageOpportunities, alerts, updateSummary, updatePrices, updateBatteryStates, updateArbitrageOpportunities, addAlert, acknowledgeAlert } = useStore();
const { data: summaryData } = useDashboardSummary();
const { data: pricesData } = usePrices();
const { data: batteryData } = useBatteryStates();
const { data: arbitrageData } = useArbitrageOpportunities();
useEffect(() => {
if (summaryData) updateSummary(summaryData);
}, [summaryData, updateSummary]);
useEffect(() => {
if (pricesData) updatePrices(pricesData.regions);
}, [pricesData, updatePrices]);
useEffect(() => {
if (batteryData) updateBatteryStates(batteryData.batteries);
}, [batteryData, updateBatteryStates]);
useEffect(() => {
if (arbitrageData) updateArbitrageOpportunities(arbitrageData.opportunities);
}, [arbitrageData, updateArbitrageOpportunities]);
useEffect(() => {
const unsubscribePrice = subscribe('price_update', (data: any) => {
console.log('Price update:', data);
updatePrices(data.regions || {});
});
const unsubscribeBattery = subscribe('battery_update', (data: any) => {
console.log('Battery update:', data);
updateBatteryStates([data.battery_state]);
});
const unsubscribeArbitrage = subscribe('arbitrage_opportunity', (data: any) => {
console.log('Arbitrage opportunity:', data);
updateArbitrageOpportunities([data]);
});
const unsubscribeAlert = subscribe('alert_triggered', (data: any) => {
console.log('Alert triggered:', data);
addAlert(data.alert);
});
return () => {
unsubscribePrice();
unsubscribeBattery();
unsubscribeArbitrage();
unsubscribeAlert();
};
}, [subscribe, updatePrices, updateBatteryStates, updateArbitrageOpportunities, addAlert]);
const priceArray = Object.values(prices).slice(-100);
const stats = [
{ label: 'Total Volume', value: summary ? formatVolume(summary.total_volume_mw) : '-', icon: '⚡' },
{ label: 'Avg Price', value: summary ? formatPrice(summary.avg_realtime_price) : '-', icon: '💰' },
{ label: 'Arbitrages', value: summary ? summary.arbitrage_count : '-', icon: '🔄' },
{ label: 'Batteries', value: summary ? `${summary.battery_count} (${formatPercentage(summary.avg_battery_charge / 100)})` : '-', icon: '🔋' },
];
return (
<div className="space-y-6">
<div className="grid grid-cols-4 gap-4">
{stats.map((stat, index) => (
<div key={index} className="bg-gray-800 rounded-lg p-4">
<div className="flex items-center justify-between mb-2">
<span className="text-2xl">{stat.icon}</span>
<span className="text-sm text-gray-400">{stat.label}</span>
</div>
<div className="text-2xl font-bold text-white">{stat.value}</div>
</div>
))}
</div>
<div className="grid grid-cols-2 gap-6">
<div className="bg-gray-800 rounded-lg p-6">
<h2 className="text-lg font-semibold text-white mb-4">Real-Time Prices</h2>
{priceArray.length > 0 ? (
<PriceChart data={priceArray} />
) : (
<div className="text-center py-8 text-gray-400">No price data available</div>
)}
</div>
<div className="bg-gray-800 rounded-lg p-6">
<h2 className="text-lg font-semibold text-white mb-4">Battery States</h2>
{batteryStates.length > 0 ? (
<BatteryChart data={batteryStates} />
) : (
<div className="text-center py-8 text-gray-400">No battery data available</div>
)}
</div>
</div>
<div className="grid grid-cols-2 gap-6">
<div className="bg-gray-800 rounded-lg p-6">
<h2 className="text-lg font-semibold text-white mb-4">Arbitrage Opportunities</h2>
<ArbitrageTable data={arbitrageOpportunities.slice(0, 10)} />
</div>
<div>
<AlertPanel alerts={alerts} onAcknowledge={acknowledgeAlert} />
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,142 @@
import { useState } from 'react';
import { useModels, useTrainModel } from '@/hooks/useApi';
import type { TrainingRequest, ModelInfo } from '@/services/types';
import { MODEL_TYPE_LABELS, TRAINING_STATUS_LABELS } from '@/lib/constants';
import { formatTimestamp } from '@/lib/formatters';
import TrainingForm from '@/components/forms/TrainingForm';
import ModelListTable from '@/components/tables/ModelListTable';
import ModelMetricsChart from '@/components/charts/ModelMetricsChart';
export default function Models() {
const [showTrainingForm, setShowTrainingForm] = useState(false);
const [selectedModel, setSelectedModel] = useState<ModelInfo | null>(null);
const [trainingProgress, setTrainingProgress] = useState<any>(null);
const { data: modelsData, isLoading: isLoadingModels } = useModels();
const { mutate: trainModel, isPending: isTraining } = useTrainModel();
const handleTrainModel = (request: TrainingRequest) => {
trainModel(request, {
onSuccess: (data) => {
setTrainingProgress({ id: data.training_id, status: 'pending', progress: 0 });
setShowTrainingForm(false);
},
});
};
const getStatusColor = (status: string) => {
switch (status) {
case 'completed': return 'bg-green-500/20 text-green-400';
case 'running': return 'bg-blue-500/20 text-blue-400';
case 'failed': return 'bg-red-500/20 text-red-400';
default: return 'bg-yellow-500/20 text-yellow-400';
}
};
const metricsData = selectedModel?.metrics ? [
{ epoch: 1, ...Object.fromEntries(Object.entries(selectedModel.metrics)) },
] : [];
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-white">ML Models</h1>
<button
onClick={() => setShowTrainingForm(!showTrainingForm)}
className="px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white rounded-lg font-medium transition-colors"
>
{showTrainingForm ? 'Cancel' : 'Train New Model'}
</button>
</div>
{showTrainingForm && (
<div className="bg-gray-800 rounded-lg p-6">
<h2 className="text-lg font-semibold text-white mb-4">Train Model</h2>
<TrainingForm onSubmit={handleTrainModel} isLoading={isTraining} />
</div>
)}
{trainingProgress && (
<div className="bg-gray-800 rounded-lg p-4">
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-gray-300">Training {trainingProgress.id}...</span>
<span className={`px-2 py-1 rounded text-xs font-medium ${getStatusColor(trainingProgress.status)}`}>
{TRAINING_STATUS_LABELS[trainingProgress.status as keyof typeof TRAINING_STATUS_LABELS]}
</span>
</div>
<div className="w-full bg-gray-700 rounded-full h-2">
<div
className="bg-primary-600 h-2 rounded-full transition-all"
style={{ width: `${trainingProgress.progress * 100}%` }}
/>
</div>
</div>
)}
<div className="grid grid-cols-3 gap-6">
<div className="col-span-2">
<div className="bg-gray-800 rounded-lg p-6">
<h2 className="text-lg font-semibold text-white mb-4">Available Models</h2>
{isLoadingModels ? (
<div className="text-center py-8 text-gray-400">Loading...</div>
) : modelsData && modelsData.length > 0 ? (
<ModelListTable data={modelsData} />
) : (
<div className="text-center py-8 text-gray-400">No models available. Train one to get started.</div>
)}
</div>
</div>
{selectedModel && (
<div>
<div className="bg-gray-800 rounded-lg p-6">
<h2 className="text-lg font-semibold text-white mb-4">Model Details</h2>
<div className="space-y-4">
<div>
<span className="text-gray-400 text-sm">ID:</span>
<p className="text-white font-mono text-sm">{selectedModel.model_id}</p>
</div>
<div>
<span className="text-gray-400 text-sm">Type:</span>
<p className="text-white">{MODEL_TYPE_LABELS[selectedModel.model_type]}</p>
</div>
<div>
<span className="text-gray-400 text-sm">Version:</span>
<p className="text-white">{selectedModel.version}</p>
</div>
<div>
<span className="text-gray-400 text-sm">Created:</span>
<p className="text-white">{formatTimestamp(selectedModel.created_at)}</p>
</div>
<div className="border-t border-gray-700 pt-4">
<h3 className="text-md font-semibold text-white mb-2">Metrics</h3>
<div className="space-y-2">
{Object.entries(selectedModel.metrics).map(([key, value]) => (
<div key={key} className="flex justify-between">
<span className="text-gray-400 text-sm">{key}:</span>
<span className="text-white">{value.toFixed(3)}</span>
</div>
))}
</div>
</div>
{selectedModel.hyperparameters && Object.keys(selectedModel.hyperparameters).length > 0 && (
<div className="border-t border-gray-700 pt-4">
<h3 className="text-md font-semibold text-white mb-2">Hyperparameters</h3>
<div className="space-y-2">
{Object.entries(selectedModel.hyperparameters).map(([key, value]) => (
<div key={key} className="flex justify-between">
<span className="text-gray-400 text-sm">{key}:</span>
<span className="text-white text-sm">{String(value)}</span>
</div>
))}
</div>
</div>
)}
</div>
</div>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,70 @@
import { useSettings, useUpdateSettings } from '@/hooks/useApi';
import { useState, useEffect } from 'react';
import type { AppSettings } from '@/services/types';
import SettingsForm from '@/components/forms/SettingsForm';
export default function Settings() {
const { data: settingsData } = useSettings();
const { mutate: updateSettings, isPending: isUpdating } = useUpdateSettings();
const [localSettings, setLocalSettings] = useState<AppSettings>({
battery_min_reserve: 0.1,
battery_max_charge: 0.9,
arbitrage_min_spread: 5.0,
mining_margin_threshold: 5.0,
});
useEffect(() => {
if (settingsData) {
setLocalSettings(settingsData);
}
}, [settingsData]);
const handleSave = (updatedSettings: Partial<AppSettings>) => {
updateSettings(updatedSettings, {
onSuccess: (data) => {
console.log('Settings saved:', data);
},
});
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-white">Settings</h1>
</div>
<div className="max-w-2xl">
<div className="bg-gray-800 rounded-lg p-6">
<h2 className="text-lg font-semibold text-white mb-2">Application Configuration</h2>
<p className="text-gray-400 text-sm mb-6">
Configure trading parameters and battery settings. Changes take effect immediately.
</p>
<SettingsForm onSubmit={handleSave} isLoading={isUpdating} />
</div>
<div className="mt-6 bg-gray-800 rounded-lg p-6">
<h2 className="text-lg font-semibold text-white mb-2">API Information</h2>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-400">API URL:</span>
<span className="text-white font-mono">{import.meta.env.VITE_API_URL || 'http://localhost:8000'}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-400">WebSocket URL:</span>
<span className="text-white font-mono">{import.meta.env.VITE_WS_URL || 'ws://localhost:8000/ws/real-time'}</span>
</div>
</div>
</div>
<div className="mt-6 bg-gray-800 rounded-lg p-6">
<h2 className="text-lg font-semibold text-white mb-2">About</h2>
<div className="text-sm text-gray-400">
<p>Energy Trading UI v1.0.0</p>
<p className="mt-1">Real-time energy trading dashboard with backtesting and ML model management.</p>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,126 @@
import { useStrategies, useToggleStrategy, usePositions } from '@/hooks/useApi';
import { STRATEGY_LABELS } from '@/lib/constants';
import { formatPnL, formatRelativeTime } from '@/lib/formatters';
import PnLChart from '@/components/charts/PnLChart';
import TradeLogTable from '@/components/tables/TradeLogTable';
export default function Trading() {
const { data: strategiesData, isLoading: isLoadingStrategies } = useStrategies();
const { data: positionsData } = usePositions();
const { mutate: toggleStrategy, isPending: isToggling } = useToggleStrategy();
const handleToggle = (strategy: string, currentEnabled: boolean) => {
toggleStrategy(
{ strategy: strategy as any, action: currentEnabled ? 'stop' : 'start' },
{
onSuccess: () => {
console.log('Strategy toggled');
},
}
);
};
const pnlData = strategiesData?.map((s) => ({
timestamp: s.last_execution || new Date().toISOString(),
pnl: s.profit_loss,
})) || [];
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-white">Live Trading</h1>
</div>
<div className="grid grid-cols-4 gap-4">
{strategiesData?.map((strategy) => (
<div key={strategy.strategy} className={`bg-gray-800 rounded-lg p-4 border-2 ${strategy.enabled ? 'border-green-500' : 'border-gray-700'}`}>
<div className="flex items-center justify-between mb-3">
<h3 className="text-lg font-semibold text-white">{STRATEGY_LABELS[strategy.strategy]}</h3>
<button
onClick={() => handleToggle(strategy.strategy, strategy.enabled)}
disabled={isToggling}
className={`px-3 py-1 rounded text-sm font-medium transition-colors ${
strategy.enabled
? 'bg-red-600 hover:bg-red-700 text-white'
: 'bg-green-600 hover:bg-green-700 text-white'
}`}
>
{isToggling ? '...' : strategy.enabled ? 'Stop' : 'Start'}
</button>
</div>
<div className="space-y-2">
<div className="flex justify-between">
<span className="text-gray-400 text-sm">P&L:</span>
<span className={`font-medium ${strategy.profit_loss >= 0 ? 'text-green-400' : 'text-red-400'}`}>
{formatPnL(strategy.profit_loss)}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-400 text-sm">Trades:</span>
<span className="text-white">{strategy.total_trades}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-400 text-sm">Last Exec:</span>
<span className="text-white text-sm">{strategy.last_execution ? formatRelativeTime(strategy.last_execution) : '-'}</span>
</div>
</div>
</div>
))}
<div className="bg-gray-800 rounded-lg p-4">
<div className="flex items-center justify-between mb-3">
<h3 className="text-lg font-semibold text-white">Total P&L</h3>
</div>
<div className="text-center py-2">
<span className={`text-3xl font-bold ${strategiesData && strategiesData.reduce((sum, s) => sum + s.profit_loss, 0) >= 0 ? 'text-green-400' : 'text-red-400'}`}>
{strategiesData ? formatPnL(strategiesData.reduce((sum, s) => sum + s.profit_loss, 0)) : '-'}
</span>
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-6">
<div className="bg-gray-800 rounded-lg p-6">
<h2 className="text-lg font-semibold text-white mb-4">P&L Over Time</h2>
{pnlData.length > 0 ? (
<PnLChart data={pnlData} />
) : (
<div className="text-center py-8 text-gray-400">No P&L data available</div>
)}
</div>
<div className="bg-gray-800 rounded-lg p-6">
<h2 className="text-lg font-semibold text-white mb-4">Current Positions</h2>
{positionsData && positionsData.positions.length > 0 ? (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-700">
<th className="text-left py-3 px-4 text-gray-400 font-medium">Region</th>
<th className="text-left py-3 px-4 text-gray-400 font-medium">Position (MW)</th>
<th className="text-left py-3 px-4 text-gray-400 font-medium">Battery Charge</th>
<th className="text-left py-3 px-4 text-gray-400 font-medium">P&L ()</th>
</tr>
</thead>
<tbody>
{positionsData.positions.map((pos: any, index: number) => (
<tr key={index} className="border-b border-gray-800">
<td className="py-3 px-4 text-gray-300">{pos.region}</td>
<td className="py-3 px-4 text-gray-300">{pos.position_mw?.toFixed(2)}</td>
<td className="py-3 px-4 text-gray-300">{pos.battery_charge_pct?.toFixed(1)}%</td>
<td className={`py-3 px-4 ${pos.pnl >= 0 ? 'text-green-400' : 'text-red-400'}`}>
{formatPnL(pos.pnl)}
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="text-center py-8 text-gray-400">No active positions</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,102 @@
import axios from 'axios';
import type {
DashboardSummary,
BacktestConfig,
BacktestMetrics,
BacktestStatus,
BacktestStatusResponse,
ModelInfo,
TrainingRequest,
TrainingStatus as TrainingStatusType,
TrainingStatusResponse,
PredictionResponse,
StrategyStatus,
AppSettings,
Trade,
ArbitrageOpportunity,
PriceData,
BatteryState,
} from './types';
import type { Strategy } from './types';
const api = axios.create({
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:8000',
headers: { 'Content-Type': 'application/json' },
});
export const dashboardApi = {
getSummary: () => api.get<DashboardSummary>('/api/v1/dashboard/summary'),
getPrices: () => api.get<{ regions: Record<string, PriceData> }>('/api/v1/dashboard/prices'),
getPriceHistory: (region: string, start?: string, end?: string, limit?: number) =>
api.get<{ region: string; data: PriceData[] }>('/api/v1/dashboard/prices/history', {
params: { region, start, end, limit },
}),
getBattery: () => api.get<{ batteries: BatteryState[] }>('/api/v1/dashboard/battery'),
getArbitrage: (minSpread?: number) =>
api.get<{ opportunities: ArbitrageOpportunity[]; count: number }>('/api/v1/dashboard/arbitrage', {
params: { min_spread: minSpread },
}),
};
export const backtestApi = {
start: (config: BacktestConfig, name?: string) =>
api.post<{ backtest_id: string; status: BacktestStatus; name: string }>(
'/api/v1/backtest/start',
config,
{ params: { name } }
),
getStatus: (backtestId: string) =>
api.get<BacktestStatusResponse>(`/api/v1/backtest/${backtestId}`),
getResults: (backtestId: string) =>
api.get<{ metrics: BacktestMetrics; trades: Trade[] }>(`/api/v1/backtest/${backtestId}/results`),
getTrades: (backtestId: string, limit?: number) =>
api.get<{ backtest_id: string; trades: Trade[]; total: number }>(
`/api/v1/backtest/${backtestId}/trades`,
{ params: { limit } }
),
getHistory: () =>
api.get<{ backtests: BacktestStatusResponse[]; total: number }>('/api/v1/backtest/history'),
delete: (backtestId: string) =>
api.delete<{ message: string }>(`/api/v1/backtest/${backtestId}`),
};
export const modelsApi = {
list: () => api.get<ModelInfo[]>('/api/v1/models'),
train: (request: TrainingRequest) =>
api.post<{ training_id: string; status: TrainingStatusResponse }>('/api/v1/models/train', request),
getTrainingStatus: (trainingId: string) =>
api.get<TrainingStatusResponse>(`/api/v1/models/${trainingId}/status`),
getMetrics: (modelId: string) =>
api.get<{ model_id: string; metrics: Record<string, number> }>(
`/api/v1/models/${modelId}/metrics`
),
predict: (modelId: string, timestamp: string, features?: Record<string, unknown>) =>
api.post<PredictionResponse>('/api/v1/models/predict', features, {
params: { model_id: modelId, timestamp },
}),
getFeatureImportance: (modelId: string) =>
api.get<{ model_id: string; feature_importance: Record<string, number> }>(
`/api/v1/models/${modelId}/feature-importance`
),
};
export const tradingApi = {
getStrategies: () => api.get<StrategyStatus[]>('/api/v1/trading/strategies'),
toggleStrategy: (strategy: Strategy, action: 'start' | 'stop') =>
api.post<{ status: StrategyStatus }>(
'/api/v1/trading/strategies',
null,
{ params: { strategy, action } }
),
getPositions: () => api.get<{ positions: any[]; total: number }>('/api/v1/trading/positions'),
getOrders: (limit?: number) =>
api.get<{ orders: Trade[]; total: number }>('/api/v1/trading/orders', { params: { limit } }),
};
export const settingsApi = {
get: () => api.get<AppSettings>('/api/v1/settings'),
update: (settings: Partial<AppSettings>) =>
api.post<{ message: string; updated_fields: string[] }>('/api/v1/settings', settings),
};
export default api;

View File

@@ -0,0 +1,211 @@
export enum Region {
FR = 'FR',
BE = 'BE',
DE = 'DE',
NL = 'NL',
UK = 'UK',
}
export enum Strategy {
FUNDAMENTAL = 'fundamental',
TECHNICAL = 'technical',
ML = 'ml',
MINING = 'mining',
}
export enum TradeType {
BUY = 'buy',
SELL = 'sell',
CHARGE = 'charge',
DISCHARGE = 'discharge',
}
export enum BacktestStatus {
PENDING = 'pending',
RUNNING = 'running',
COMPLETED = 'completed',
FAILED = 'failed',
CANCELLED = 'cancelled',
}
export enum ModelType {
PRICE_PREDICTION = 'price_prediction',
RL_BATTERY = 'rl_battery',
}
export enum TrainingStatus {
PENDING = 'pending',
RUNNING = 'running',
COMPLETED = 'completed',
FAILED = 'failed',
}
export enum AlertType {
PRICE_SPIKE = 'price_spike',
ARBITRAGE_OPPORTUNITY = 'arbitrage_opportunity',
BATTERY_LOW = 'battery_low',
BATTERY_FULL = 'battery_full',
STRATEGY_ERROR = 'strategy_error',
}
export interface PriceData {
timestamp: string;
region: Region;
day_ahead_price: number;
real_time_price: number;
volume_mw: number;
}
export interface BatteryState {
timestamp: string;
battery_id: string;
capacity_mwh: number;
charge_level_mwh: number;
charge_level_pct: number;
charge_rate_mw: number;
discharge_rate_mw: number;
efficiency: number;
}
export interface BacktestConfig {
start_date: string;
end_date: string;
strategies: Strategy[];
use_ml: boolean;
battery_min_reserve?: number;
battery_max_charge?: number;
arbitrage_min_spread?: number;
}
export interface BacktestMetrics {
total_revenue: number;
arbitrage_profit: number;
battery_revenue: number;
mining_profit: number;
battery_utilization: number;
price_capture_rate: number;
win_rate: number;
sharpe_ratio: number;
max_drawdown: number;
total_trades: number;
}
export interface BacktestStatusResponse {
backtest_id: string;
name: string;
status: BacktestStatus;
created_at: string;
started_at: string;
completed_at: string | null;
error_message: string | null;
results?: {
metrics: BacktestMetrics;
trades: Trade[];
};
}
export interface ModelInfo {
model_id: string;
model_type: ModelType;
version: string;
created_at: string;
metrics: Record<string, number>;
hyperparameters: Record<string, unknown>;
}
export interface TrainingRequest {
model_type: ModelType;
horizon?: number;
start_date: string;
end_date: string;
hyperparameters: Record<string, unknown>;
}
export interface TrainingStatusResponse {
training_id: string;
status: TrainingStatus;
progress: number;
current_epoch?: number;
total_epochs?: number;
metrics: Record<string, number>;
error_message?: string;
started_at?: string;
completed_at?: string;
}
export interface PredictionResponse {
model_id: string;
timestamp: string;
prediction: number;
confidence?: number;
features_used: string[];
}
export interface StrategyStatus {
strategy: Strategy;
enabled: boolean;
last_execution: string | null;
total_trades: number;
profit_loss: number;
}
export interface AppSettings {
battery_min_reserve: number;
battery_max_charge: number;
arbitrage_min_spread: number;
mining_margin_threshold: number;
}
export interface ArbitrageOpportunity {
timestamp: string;
buy_region: Region;
sell_region: Region;
buy_price: number;
sell_price: number;
spread: number;
volume_mw: number;
}
export interface Trade {
timestamp: string;
backtest_id: string;
trade_type: TradeType;
region?: Region;
price: number;
volume_mw: number;
revenue: number;
battery_id?: string;
}
export interface Alert {
alert_id: string;
alert_type: AlertType;
timestamp: string;
message: string;
data: Record<string, unknown>;
acknowledged: boolean;
}
export interface DashboardSummary {
latest_timestamp: string;
total_volume_mw: number;
avg_realtime_price: number;
arbitrage_count: number;
battery_count: number;
avg_battery_charge: number;
}
export type WebSocketEventType =
| 'price_update'
| 'battery_update'
| 'arbitrage_opportunity'
| 'trade_executed'
| 'alert_triggered'
| 'backtest_progress'
| 'model_training_progress';
export interface WebSocketMessage<T = unknown> {
type: WebSocketEventType;
timestamp: string | null;
data: T;
}

View File

@@ -0,0 +1,93 @@
import type { WebSocketEventType, WebSocketMessage } from './types';
class WebSocketService {
private ws: WebSocket | null = null;
private url: string;
private eventHandlers: Map<WebSocketEventType, Set<(data: unknown) => void>> = new Map();
private isConnected = false;
private reconnectAttempts = 0;
private maxReconnectAttempts = 5;
private reconnectDelay = 1000;
constructor(url: string = import.meta.env.VITE_WS_URL || 'ws://localhost:8000/ws/real-time') {
this.url = url;
}
connect(): void {
try {
this.ws = new WebSocket(this.url);
this.setupEventListeners();
} catch (error) {
console.error('WebSocket connection error:', error);
this.attemptReconnect();
}
}
disconnect(): void {
if (this.ws) {
this.ws.close();
this.ws = null;
this.isConnected = false;
this.reconnectAttempts = 0;
}
}
subscribe<T = unknown>(
eventType: WebSocketEventType,
handler: (data: T) => void
): () => void {
if (!this.eventHandlers.has(eventType)) {
this.eventHandlers.set(eventType, new Set());
}
this.eventHandlers.get(eventType)!.add(handler);
return () => {
this.eventHandlers.get(eventType)?.delete(handler);
};
}
getConnectionStatus(): boolean {
return this.isConnected;
}
private setupEventListeners(): void {
if (!this.ws) return;
this.ws.onopen = () => {
this.isConnected = true;
this.reconnectAttempts = 0;
};
this.ws.onclose = () => {
this.isConnected = false;
this.attemptReconnect();
};
this.ws.onerror = (error) => {
console.error('WebSocket error:', error);
};
this.ws.onmessage = (event) => {
try {
const message: WebSocketMessage = JSON.parse(event.data);
const handlers = this.eventHandlers.get(message.type);
if (handlers) {
handlers.forEach(handler => handler(message.data));
}
} catch (error) {
console.error('Error parsing WebSocket message:', error);
}
};
}
private attemptReconnect(): void {
if (this.reconnectAttempts < this.maxReconnectAttempts) {
setTimeout(() => {
this.reconnectAttempts++;
this.connect();
}, this.reconnectDelay * Math.pow(2, this.reconnectAttempts));
}
}
}
export const webSocketService = new WebSocketService();

View File

@@ -0,0 +1,33 @@
import type { StateCreator } from 'zustand';
import type { BacktestStatus, BacktestStatusResponse, BacktestMetrics, Trade } from '@/services/types';
interface BacktestState {
backtests: Record<string, BacktestStatusResponse>;
currentBacktest: string | null;
isRunning: boolean;
updateBacktest: (backtest: BacktestStatusResponse) => void;
setCurrentBacktest: (backtestId: string | null) => void;
removeBacktest: (backtestId: string) => void;
setRunning: (isRunning: boolean) => void;
}
export const backtestSlice: StateCreator<BacktestState> = (set) => ({
backtests: {},
currentBacktest: null,
isRunning: false,
updateBacktest: (backtest) =>
set((state) => ({
backtests: { ...state.backtests, [backtest.backtest_id]: backtest },
})),
setCurrentBacktest: (backtestId) => set({ currentBacktest: backtestId }),
removeBacktest: (backtestId) =>
set((state) => {
const newBacktests = { ...state.backtests };
delete newBacktests[backtestId];
return {
backtests: newBacktests,
currentBacktest: state.currentBacktest === backtestId ? null : state.currentBacktest,
};
}),
setRunning: (isRunning) => set({ isRunning }),
});

View File

@@ -0,0 +1,45 @@
import type { StateCreator } from 'zustand';
import type { PriceData, BatteryState, ArbitrageOpportunity, Alert } from '@/services/types';
interface DashboardState {
summary: {
latest_timestamp: string;
total_volume_mw: number;
avg_realtime_price: number;
arbitrage_count: number;
battery_count: number;
avg_battery_charge: number;
} | null;
prices: Record<string, PriceData>;
batteryStates: BatteryState[];
arbitrageOpportunities: ArbitrageOpportunity[];
alerts: Alert[];
updateSummary: (summary: DashboardState['summary']) => void;
updatePrices: (prices: Record<string, PriceData>) => void;
updateBatteryStates: (states: BatteryState[]) => void;
updateArbitrageOpportunities: (opportunities: ArbitrageOpportunity[]) => void;
addAlert: (alert: Alert) => void;
acknowledgeAlert: (alertId: string) => void;
}
export const dashboardSlice: StateCreator<DashboardState> = (set) => ({
summary: null,
prices: {},
batteryStates: [],
arbitrageOpportunities: [],
alerts: [],
updateSummary: (summary) => set({ summary }),
updatePrices: (prices) => set({ prices }),
updateBatteryStates: (states) => set({ batteryStates: states }),
updateArbitrageOpportunities: (opportunities) => set({ arbitrageOpportunities: opportunities }),
addAlert: (alert) =>
set((state) => ({
alerts: [alert, ...state.alerts].filter((a, i) => !state.alerts.slice(0, i).some(x => x.alert_id === a.alert_id)).slice(0, 50),
})),
acknowledgeAlert: (alertId) =>
set((state) => ({
alerts: state.alerts.map((alert) =>
alert.alert_id === alertId ? { ...alert, acknowledged: true } : alert
),
})),
});

View File

@@ -0,0 +1,14 @@
import { create } from 'zustand';
import { dashboardSlice } from './dashboardSlice';
import { backtestSlice } from './backtestSlice';
import { modelsSlice } from './modelsSlice';
import { tradingSlice } from './tradingSlice';
export const useStore = create((...args) => ({
...dashboardSlice(...args),
...backtestSlice(...args),
...modelsSlice(...args),
...tradingSlice(...args),
}));
export type AppStore = ReturnType<typeof useStore>;

View File

@@ -0,0 +1,28 @@
import type { StateCreator } from 'zustand';
import type { ModelInfo, TrainingStatus as TrainingStatusType } from '@/services/types';
interface ModelsState {
models: ModelInfo[];
trainingJobs: Record<string, TrainingStatusType>;
selectedModel: string | null;
setModels: (models: ModelInfo[]) => void;
updateTrainingJob: (job: TrainingStatusType) => void;
setSelectedModel: (modelId: string | null) => void;
addModel: (model: ModelInfo) => void;
}
export const modelsSlice: StateCreator<ModelsState> = (set) => ({
models: [],
trainingJobs: {},
selectedModel: null,
setModels: (models) => set({ models }),
updateTrainingJob: (job) =>
set((state) => ({
trainingJobs: { ...state.trainingJobs, [job.training_id]: job },
})),
setSelectedModel: (modelId) => set({ selectedModel: modelId }),
addModel: (model) =>
set((state) => ({
models: [...state.models, model],
})),
});

View File

@@ -0,0 +1,39 @@
import type { StateCreator } from 'zustand';
import type { StrategyStatus } from '@/services/types';
interface TradingState {
strategies: Record<string, StrategyStatus>;
positions: any[];
pnl: number;
trades: any[];
updateStrategy: (strategy: StrategyStatus) => void;
updatePositions: (positions: any[]) => void;
updatePnl: (pnl: number) => void;
addTrade: (trade: any) => void;
setStrategies: (strategies: StrategyStatus[]) => void;
}
export const tradingSlice: StateCreator<TradingState> = (set) => ({
strategies: {},
positions: [],
pnl: 0,
trades: [],
updateStrategy: (strategy) =>
set((state) => ({
strategies: { ...state.strategies, [strategy.strategy]: strategy },
})),
updatePositions: (positions) => set({ positions }),
updatePnl: (pnl) => set({ pnl }),
addTrade: (trade) =>
set((state) => ({
trades: [trade, ...state.trades].slice(0, 100),
})),
setStrategies: (strategies) =>
set(() => {
const strategyMap: Record<string, StrategyStatus> = {};
strategies.forEach((s) => {
strategyMap[s.strategy] = s;
});
return { strategies: strategyMap };
}),
});

View File

@@ -0,0 +1,63 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
darkMode: 'class',
theme: {
extend: {
colors: {
primary: {
50: '#eff6ff',
100: '#dbeafe',
200: '#bfdbfe',
300: '#93c5fd',
400: '#60a5fa',
500: '#3b82f6',
600: '#2563eb',
700: '#1d4ed8',
800: '#1e40af',
900: '#1e3a8a',
},
secondary: {
50: '#ecfdf5',
100: '#d1fae5',
200: '#a7f3d0',
300: '#6ee7b7',
400: '#34d399',
500: '#10b981',
600: '#059669',
700: '#047857',
800: '#065f46',
900: '#064e3b',
},
danger: {
50: '#fef2f2',
100: '#fee2e2',
200: '#fecaca',
300: '#fca5a5',
400: '#f87171',
500: '#ef4444',
600: '#dc2626',
700: '#b91c1c',
800: '#991b1b',
900: '#7f1d1d',
},
warning: {
50: '#fffbeb',
100: '#fef3c7',
200: '#fde68a',
300: '#fcd34d',
400: '#fbbf24',
500: '#f59e0b',
600: '#d97706',
700: '#b45309',
800: '#92400e',
900: '#78350f',
},
},
},
},
plugins: [],
}

25
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

25
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,25 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
},
'/ws': {
target: 'ws://localhost:8000',
ws: true,
},
},
},
});