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:
2
frontend/.env.example
Normal file
2
frontend/.env.example
Normal 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
26
frontend/.gitignore
vendored
Normal 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
13
frontend/index.html
Normal 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
3168
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
32
frontend/package.json
Normal file
32
frontend/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
36
frontend/src/App.tsx
Normal file
36
frontend/src/App.tsx
Normal 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;
|
||||||
49
frontend/src/components/alerts/AlertItem.tsx
Normal file
49
frontend/src/components/alerts/AlertItem.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
93
frontend/src/components/alerts/AlertPanel.tsx
Normal file
93
frontend/src/components/alerts/AlertPanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
42
frontend/src/components/charts/BatteryChart.tsx
Normal file
42
frontend/src/components/charts/BatteryChart.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
47
frontend/src/components/charts/GenerationChart.tsx
Normal file
47
frontend/src/components/charts/GenerationChart.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
36
frontend/src/components/charts/ModelMetricsChart.tsx
Normal file
36
frontend/src/components/charts/ModelMetricsChart.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
39
frontend/src/components/charts/PnLChart.tsx
Normal file
39
frontend/src/components/charts/PnLChart.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
32
frontend/src/components/charts/PriceChart.tsx
Normal file
32
frontend/src/components/charts/PriceChart.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
26
frontend/src/components/common/Error.tsx
Normal file
26
frontend/src/components/common/Error.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
26
frontend/src/components/common/Header.tsx
Normal file
26
frontend/src/components/common/Header.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
frontend/src/components/common/Loading.tsx
Normal file
12
frontend/src/components/common/Loading.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
38
frontend/src/components/common/Sidebar.tsx
Normal file
38
frontend/src/components/common/Sidebar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
149
frontend/src/components/forms/BacktestForm.tsx
Normal file
149
frontend/src/components/forms/BacktestForm.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
91
frontend/src/components/forms/SettingsForm.tsx
Normal file
91
frontend/src/components/forms/SettingsForm.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
105
frontend/src/components/forms/TrainingForm.tsx
Normal file
105
frontend/src/components/forms/TrainingForm.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
51
frontend/src/components/tables/ArbitrageTable.tsx
Normal file
51
frontend/src/components/tables/ArbitrageTable.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
70
frontend/src/components/tables/ModelListTable.tsx
Normal file
70
frontend/src/components/tables/ModelListTable.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
56
frontend/src/components/tables/TradeLogTable.tsx
Normal file
56
frontend/src/components/tables/TradeLogTable.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
185
frontend/src/hooks/useApi.ts
Normal file
185
frontend/src/hooks/useApi.ts
Normal 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'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
21
frontend/src/hooks/useWebSocket.ts
Normal file
21
frontend/src/hooks/useWebSocket.ts
Normal 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
22
frontend/src/index.css
Normal 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;
|
||||||
|
}
|
||||||
65
frontend/src/lib/constants.ts
Normal file
65
frontend/src/lib/constants.ts
Normal 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',
|
||||||
|
};
|
||||||
80
frontend/src/lib/formatters.ts
Normal file
80
frontend/src/lib/formatters.ts
Normal 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
28
frontend/src/lib/utils.ts
Normal 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
19
frontend/src/main.tsx
Normal 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>,
|
||||||
|
);
|
||||||
167
frontend/src/pages/Backtest.tsx
Normal file
167
frontend/src/pages/Backtest.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
120
frontend/src/pages/Dashboard.tsx
Normal file
120
frontend/src/pages/Dashboard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
142
frontend/src/pages/Models.tsx
Normal file
142
frontend/src/pages/Models.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
70
frontend/src/pages/Settings.tsx
Normal file
70
frontend/src/pages/Settings.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
126
frontend/src/pages/Trading.tsx
Normal file
126
frontend/src/pages/Trading.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
102
frontend/src/services/api.ts
Normal file
102
frontend/src/services/api.ts
Normal 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;
|
||||||
211
frontend/src/services/types.ts
Normal file
211
frontend/src/services/types.ts
Normal 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;
|
||||||
|
}
|
||||||
93
frontend/src/services/websocket.ts
Normal file
93
frontend/src/services/websocket.ts
Normal 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();
|
||||||
33
frontend/src/store/backtestSlice.ts
Normal file
33
frontend/src/store/backtestSlice.ts
Normal 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 }),
|
||||||
|
});
|
||||||
45
frontend/src/store/dashboardSlice.ts
Normal file
45
frontend/src/store/dashboardSlice.ts
Normal 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
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
});
|
||||||
14
frontend/src/store/index.ts
Normal file
14
frontend/src/store/index.ts
Normal 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>;
|
||||||
28
frontend/src/store/modelsSlice.ts
Normal file
28
frontend/src/store/modelsSlice.ts
Normal 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],
|
||||||
|
})),
|
||||||
|
});
|
||||||
39
frontend/src/store/tradingSlice.ts
Normal file
39
frontend/src/store/tradingSlice.ts
Normal 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 };
|
||||||
|
}),
|
||||||
|
});
|
||||||
63
frontend/tailwind.config.js
Normal file
63
frontend/tailwind.config.js
Normal 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
25
frontend/tsconfig.json
Normal 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" }]
|
||||||
|
}
|
||||||
10
frontend/tsconfig.node.json
Normal file
10
frontend/tsconfig.node.json
Normal 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
25
frontend/vite.config.ts
Normal 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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user