Local changes: Updated model training, removed debug instrumentation, and configuration improvements
This commit is contained in:
99
frontend/src/components/AlertHistory.tsx
Normal file
99
frontend/src/components/AlertHistory.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import {
|
||||
Box,
|
||||
Paper,
|
||||
Typography,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
} from '@mui/material'
|
||||
import { alertsApi, AlertResponse } from '../api/alerts'
|
||||
import DataFreshness from './DataFreshness'
|
||||
import { settingsApi } from '../api/settings'
|
||||
import { formatDate } from '../utils/formatters'
|
||||
|
||||
export default function AlertHistory() {
|
||||
const { data: alerts, isLoading } = useQuery({
|
||||
queryKey: ['alerts'],
|
||||
queryFn: () => alertsApi.listAlerts(false),
|
||||
refetchInterval: 5000,
|
||||
})
|
||||
|
||||
const { data: generalSettings } = useQuery({
|
||||
queryKey: ['general-settings'],
|
||||
queryFn: () => settingsApi.getGeneralSettings(),
|
||||
})
|
||||
|
||||
const triggeredAlerts = alerts?.filter((alert) => alert.triggered) || []
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', p: 5 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Typography variant="h6">Alert History</Typography>
|
||||
{alerts && alerts.length > 0 && (
|
||||
<DataFreshness timestamp={alerts[0]?.updated_at} />
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{triggeredAlerts.length === 0 ? (
|
||||
<Paper sx={{ p: 3 }}>
|
||||
<Typography variant="body1" color="text.secondary" align="center">
|
||||
No alerts have been triggered yet
|
||||
</Typography>
|
||||
</Paper>
|
||||
) : (
|
||||
<TableContainer component={Paper}>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Alert Name</TableCell>
|
||||
<TableCell>Type</TableCell>
|
||||
<TableCell>Condition</TableCell>
|
||||
<TableCell>Triggered At</TableCell>
|
||||
<TableCell>Status</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{triggeredAlerts.map((alert: AlertResponse) => (
|
||||
<TableRow key={alert.id}>
|
||||
<TableCell>{alert.name}</TableCell>
|
||||
<TableCell>
|
||||
<Chip label={alert.alert_type} size="small" variant="outlined" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{alert.condition?.symbol || 'N/A'}
|
||||
{alert.condition?.price_threshold && ` @ $${alert.condition.price_threshold}`}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{formatDate(alert.triggered_at || '', generalSettings)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={alert.enabled ? 'Enabled' : 'Disabled'}
|
||||
color={alert.enabled ? 'success' : 'default'}
|
||||
size="small"
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
99
frontend/src/components/Chart.tsx
Normal file
99
frontend/src/components/Chart.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { createChart, ColorType, IChartApi, ISeriesApi } from 'lightweight-charts'
|
||||
import { Box, Typography } from '@mui/material'
|
||||
|
||||
interface ChartProps {
|
||||
data: {
|
||||
time: number
|
||||
open: number
|
||||
high: number
|
||||
low: number
|
||||
close: number
|
||||
}[]
|
||||
height?: number
|
||||
colors?: {
|
||||
backgroundColor?: string
|
||||
lineColor?: string
|
||||
textColor?: string
|
||||
areaTopColor?: string
|
||||
areaBottomColor?: string
|
||||
}
|
||||
}
|
||||
|
||||
export default function Chart({ data, height = 400, colors }: ChartProps) {
|
||||
const chartContainerRef = useRef<HTMLDivElement>(null)
|
||||
const chartRef = useRef<IChartApi | null>(null)
|
||||
const candlestickSeriesRef = useRef<ISeriesApi<"Candlestick"> | null>(null)
|
||||
|
||||
// Validate data
|
||||
if (!data || !Array.isArray(data) || data.length === 0) {
|
||||
return (
|
||||
<Box sx={{ width: '100%', height, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
No chart data available
|
||||
</Typography>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!chartContainerRef.current) return
|
||||
|
||||
try {
|
||||
const handleResize = () => {
|
||||
if (chartRef.current && chartContainerRef.current) {
|
||||
chartRef.current.applyOptions({ width: chartContainerRef.current.clientWidth })
|
||||
}
|
||||
}
|
||||
|
||||
const chart = createChart(chartContainerRef.current, {
|
||||
layout: {
|
||||
background: { type: ColorType.Solid, color: colors?.backgroundColor || '#1E1E1E' },
|
||||
textColor: colors?.textColor || '#DDD',
|
||||
},
|
||||
grid: {
|
||||
vertLines: { color: '#333' },
|
||||
horzLines: { color: '#333' },
|
||||
},
|
||||
width: chartContainerRef.current.clientWidth,
|
||||
height,
|
||||
})
|
||||
|
||||
const candlestickSeries = chart.addCandlestickSeries({
|
||||
upColor: '#26a69a',
|
||||
downColor: '#ef5350',
|
||||
borderVisible: false,
|
||||
wickUpColor: '#26a69a',
|
||||
wickDownColor: '#ef5350',
|
||||
})
|
||||
|
||||
if (data && data.length > 0) {
|
||||
candlestickSeries.setData(data as any)
|
||||
}
|
||||
|
||||
chartRef.current = chart
|
||||
candlestickSeriesRef.current = candlestickSeries
|
||||
|
||||
window.addEventListener('resize', handleResize)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize)
|
||||
chart.remove()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Chart initialization error:', error)
|
||||
}
|
||||
}, [colors, height])
|
||||
|
||||
useEffect(() => {
|
||||
if (candlestickSeriesRef.current && data && data.length > 0) {
|
||||
try {
|
||||
candlestickSeriesRef.current.setData(data as any)
|
||||
} catch (error) {
|
||||
console.error('Chart data update error:', error)
|
||||
}
|
||||
}
|
||||
}, [data])
|
||||
|
||||
return <Box ref={chartContainerRef} sx={{ width: '100%', height }} />
|
||||
}
|
||||
126
frontend/src/components/ChartGrid.tsx
Normal file
126
frontend/src/components/ChartGrid.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import { useQueries } from '@tanstack/react-query'
|
||||
import { Box, Paper, Typography, Button, Grid, Skeleton } from '@mui/material'
|
||||
import { Refresh } from '@mui/icons-material'
|
||||
import Chart from './Chart'
|
||||
import RealtimePrice from './RealtimePrice'
|
||||
import { marketDataApi } from '../api/marketData'
|
||||
|
||||
interface ChartGridProps {
|
||||
symbols: string[]
|
||||
onAnalyze?: (symbol: string) => void
|
||||
isAnalyzing?: boolean
|
||||
}
|
||||
|
||||
export default function ChartGrid({ symbols, onAnalyze, isAnalyzing }: ChartGridProps) {
|
||||
// Fetch OHLCV data for all symbols in parallel
|
||||
const ohlcvQueries = useQueries({
|
||||
queries: symbols.map((symbol) => ({
|
||||
queryKey: ['market-data', symbol],
|
||||
queryFn: () => marketDataApi.getOHLCV(symbol),
|
||||
refetchInterval: 60000,
|
||||
staleTime: 30000,
|
||||
})),
|
||||
})
|
||||
|
||||
// Determine grid columns based on symbol count
|
||||
const getGridColumns = () => {
|
||||
if (symbols.length === 1) return 12
|
||||
if (symbols.length === 2) return 6
|
||||
return 6 // 2x2 grid for 3-4 symbols
|
||||
}
|
||||
|
||||
const chartHeight = symbols.length === 1 ? 400 : 280
|
||||
|
||||
return (
|
||||
<Grid container spacing={2}>
|
||||
{symbols.map((symbol, index) => {
|
||||
const query = ohlcvQueries[index]
|
||||
const isLoading = query?.isLoading
|
||||
const data = query?.data
|
||||
|
||||
return (
|
||||
<Grid item xs={12} md={getGridColumns()} key={symbol}>
|
||||
<Paper
|
||||
sx={{
|
||||
p: 2,
|
||||
height: '100%',
|
||||
transition: 'all 0.3s ease',
|
||||
'&:hover': {
|
||||
boxShadow: 4,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
mb: 1.5,
|
||||
flexWrap: 'wrap',
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
<Typography
|
||||
variant="subtitle1"
|
||||
sx={{ fontWeight: 600, lineHeight: 1.2 }}
|
||||
>
|
||||
{symbol}
|
||||
</Typography>
|
||||
<RealtimePrice
|
||||
symbol={symbol}
|
||||
size="small"
|
||||
showProvider={false}
|
||||
showChange={true}
|
||||
/>
|
||||
</Box>
|
||||
{onAnalyze && (
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
startIcon={<Refresh />}
|
||||
onClick={() => onAnalyze(symbol)}
|
||||
disabled={isAnalyzing}
|
||||
sx={{ minWidth: 'auto', px: 1.5 }}
|
||||
>
|
||||
Analyze
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Chart */}
|
||||
{isLoading ? (
|
||||
<Skeleton
|
||||
variant="rectangular"
|
||||
width="100%"
|
||||
height={chartHeight}
|
||||
sx={{ borderRadius: 1 }}
|
||||
/>
|
||||
) : data && Array.isArray(data) && data.length > 0 ? (
|
||||
<Box sx={{ height: chartHeight }}>
|
||||
<Chart data={data} height={chartHeight} />
|
||||
</Box>
|
||||
) : (
|
||||
<Box
|
||||
sx={{
|
||||
height: chartHeight,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
bgcolor: 'action.hover',
|
||||
borderRadius: 1,
|
||||
}}
|
||||
>
|
||||
<Typography color="text.secondary" variant="body2">
|
||||
No data for {symbol}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
</Grid>
|
||||
)
|
||||
})}
|
||||
</Grid>
|
||||
)
|
||||
}
|
||||
64
frontend/src/components/DataFreshness.tsx
Normal file
64
frontend/src/components/DataFreshness.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { Typography, Chip, Tooltip } from '@mui/material'
|
||||
import { AccessTime } from '@mui/icons-material'
|
||||
import { useMemo } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { settingsApi } from '../api/settings'
|
||||
import { formatDate } from '../utils/formatters'
|
||||
|
||||
interface DataFreshnessProps {
|
||||
timestamp: string | Date | null | undefined
|
||||
refreshInterval?: number
|
||||
}
|
||||
|
||||
export default function DataFreshness({ timestamp, refreshInterval = 5000 }: DataFreshnessProps) {
|
||||
const { data: generalSettings } = useQuery({
|
||||
queryKey: ['general-settings'],
|
||||
queryFn: settingsApi.getGeneralSettings,
|
||||
})
|
||||
|
||||
const freshness = useMemo(() => {
|
||||
if (!timestamp) return { status: 'unknown', text: 'No data', color: 'default' as const }
|
||||
|
||||
const lastUpdate = new Date(timestamp)
|
||||
const now = new Date()
|
||||
const ageMs = now.getTime() - lastUpdate.getTime()
|
||||
const ageSeconds = Math.floor(ageMs / 1000)
|
||||
|
||||
if (ageSeconds < refreshInterval / 1000) {
|
||||
return { status: 'fresh', text: 'Just now', color: 'success' as const }
|
||||
} else if (ageSeconds < 60) {
|
||||
return { status: 'fresh', text: `${ageSeconds}s ago`, color: 'success' as const }
|
||||
} else if (ageSeconds < 300) {
|
||||
const minutes = Math.floor(ageSeconds / 60)
|
||||
return { status: 'stale', text: `${minutes}m ago`, color: 'warning' as const }
|
||||
} else {
|
||||
const minutes = Math.floor(ageSeconds / 60)
|
||||
return { status: 'outdated', text: `${minutes}m ago`, color: 'error' as const }
|
||||
}
|
||||
}, [timestamp, refreshInterval])
|
||||
|
||||
if (!timestamp) {
|
||||
return (
|
||||
<Chip
|
||||
icon={<AccessTime />}
|
||||
label="No data"
|
||||
size="small"
|
||||
color="default"
|
||||
variant="outlined"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip title={`Last updated: ${formatDate(timestamp, generalSettings)}`}>
|
||||
<Chip
|
||||
icon={<AccessTime />}
|
||||
label={freshness.text}
|
||||
size="small"
|
||||
color={freshness.color}
|
||||
variant="outlined"
|
||||
/>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
39
frontend/src/components/ErrorDisplay.tsx
Normal file
39
frontend/src/components/ErrorDisplay.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { Alert, AlertTitle, Button, Box } from '@mui/material'
|
||||
import { Refresh } from '@mui/icons-material'
|
||||
|
||||
interface ErrorDisplayProps {
|
||||
error: Error | string
|
||||
onRetry?: () => void
|
||||
title?: string
|
||||
}
|
||||
|
||||
export default function ErrorDisplay({ error, onRetry, title = 'Error' }: ErrorDisplayProps) {
|
||||
const errorMessage = error instanceof Error ? error.message : error
|
||||
|
||||
return (
|
||||
<Alert
|
||||
severity="error"
|
||||
action={
|
||||
onRetry && (
|
||||
<Button color="inherit" size="small" onClick={onRetry} startIcon={<Refresh />}>
|
||||
Retry
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
>
|
||||
<AlertTitle>{title}</AlertTitle>
|
||||
{errorMessage}
|
||||
{error instanceof Error && error.stack && (
|
||||
<Box sx={{ mt: 1 }}>
|
||||
<details>
|
||||
<summary style={{ cursor: 'pointer', fontSize: '12px' }}>Technical Details</summary>
|
||||
<pre style={{ fontSize: '11px', marginTop: '8px', overflow: 'auto' }}>
|
||||
{error.stack}
|
||||
</pre>
|
||||
</details>
|
||||
</Box>
|
||||
)}
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
|
||||
18
frontend/src/components/HelpTooltip.tsx
Normal file
18
frontend/src/components/HelpTooltip.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Tooltip, IconButton } from '@mui/material'
|
||||
import { HelpOutline } from '@mui/icons-material'
|
||||
|
||||
interface HelpTooltipProps {
|
||||
title: string
|
||||
placement?: 'top' | 'bottom' | 'left' | 'right'
|
||||
}
|
||||
|
||||
export default function HelpTooltip({ title, placement = 'top' }: HelpTooltipProps) {
|
||||
return (
|
||||
<Tooltip title={title} placement={placement}>
|
||||
<IconButton size="small" sx={{ p: 0.5, ml: 0.5 }}>
|
||||
<HelpOutline fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
39
frontend/src/components/InfoCard.tsx
Normal file
39
frontend/src/components/InfoCard.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { Card, CardContent, Typography, Box, IconButton, Collapse } from '@mui/material'
|
||||
import { Info, ExpandMore, ExpandLess } from '@mui/icons-material'
|
||||
import { useState } from 'react'
|
||||
|
||||
interface InfoCardProps {
|
||||
title: string
|
||||
children: React.ReactNode
|
||||
collapsible?: boolean
|
||||
}
|
||||
|
||||
export default function InfoCard({ title, children, collapsible = false }: InfoCardProps) {
|
||||
const [expanded, setExpanded] = useState(!collapsible)
|
||||
|
||||
return (
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: collapsible && !expanded ? 0 : 2 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Info color="primary" fontSize="small" />
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||
{title}
|
||||
</Typography>
|
||||
</Box>
|
||||
{collapsible && (
|
||||
<IconButton size="small" onClick={() => setExpanded(!expanded)}>
|
||||
{expanded ? <ExpandLess /> : <ExpandMore />}
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
<Collapse in={expanded}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{children}
|
||||
</Typography>
|
||||
</Collapse>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
132
frontend/src/components/Layout.tsx
Normal file
132
frontend/src/components/Layout.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import { ReactNode } from 'react'
|
||||
import { useNavigate, useLocation } from 'react-router-dom'
|
||||
import {
|
||||
Box,
|
||||
Drawer,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemButton,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
AppBar,
|
||||
Toolbar,
|
||||
Typography,
|
||||
Container,
|
||||
} from '@mui/material'
|
||||
import {
|
||||
Dashboard,
|
||||
AccountBalance,
|
||||
Assessment,
|
||||
Settings,
|
||||
Psychology,
|
||||
ShoppingCart,
|
||||
} from '@mui/icons-material'
|
||||
import logo from '../assets/logo.png'
|
||||
|
||||
const Logo = () => (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
p: 2,
|
||||
mt: 'auto',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component="img"
|
||||
src={logo}
|
||||
alt="FXQ One Logo"
|
||||
sx={{
|
||||
height: 120,
|
||||
width: 'auto',
|
||||
maxWidth: '90%',
|
||||
filter: 'drop-shadow(0 0 12px rgba(255, 152, 0, 0.7)) drop-shadow(0 4px 8px rgba(0, 0, 0, 0.6))',
|
||||
transition: 'filter 0.3s ease, transform 0.3s ease',
|
||||
'&:hover': {
|
||||
filter: 'drop-shadow(0 0 20px rgba(255, 152, 0, 0.9)) drop-shadow(0 4px 12px rgba(0, 0, 0, 0.7))',
|
||||
transform: 'scale(1.08)',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
|
||||
const drawerWidth = 200
|
||||
|
||||
interface LayoutProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
const menuItems = [
|
||||
{ text: 'Dashboard', icon: <Dashboard />, path: '/' },
|
||||
{ text: 'Strategies', icon: <Psychology />, path: '/strategies' },
|
||||
{ text: 'Trading', icon: <ShoppingCart />, path: '/trading' },
|
||||
{ text: 'Portfolio', icon: <AccountBalance />, path: '/portfolio' },
|
||||
{ text: 'Backtesting', icon: <Assessment />, path: '/backtesting' },
|
||||
{ text: 'Settings', icon: <Settings />, path: '/settings' },
|
||||
]
|
||||
|
||||
export default function Layout({ children }: LayoutProps) {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex' }}>
|
||||
<AppBar
|
||||
position="fixed"
|
||||
sx={{ zIndex: (theme) => theme.zIndex.drawer + 1 }}
|
||||
>
|
||||
<Toolbar>
|
||||
<Typography variant="h6" noWrap component="div" sx={{ fontWeight: 'bold' }}>
|
||||
FXQ One
|
||||
</Typography>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
<Drawer
|
||||
variant="permanent"
|
||||
sx={{
|
||||
width: drawerWidth,
|
||||
flexShrink: 0,
|
||||
'& .MuiDrawer-paper': {
|
||||
width: drawerWidth,
|
||||
boxSizing: 'border-box',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Toolbar />
|
||||
<Box sx={{ overflow: 'auto', flexGrow: 1 }}>
|
||||
<List>
|
||||
{menuItems.map((item) => (
|
||||
<ListItem key={item.text} disablePadding>
|
||||
<ListItemButton
|
||||
selected={location.pathname === item.path}
|
||||
onClick={() => navigate(item.path)}
|
||||
>
|
||||
<ListItemIcon sx={{ color: 'inherit', minWidth: 40 }}>{item.icon}</ListItemIcon>
|
||||
<ListItemText primary={item.text} />
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Box>
|
||||
<Logo />
|
||||
</Drawer>
|
||||
<Box
|
||||
component="main"
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
bgcolor: 'background.default',
|
||||
minHeight: '100vh',
|
||||
}}
|
||||
>
|
||||
<Toolbar />
|
||||
<Container maxWidth="xl" sx={{ py: 3 }}>
|
||||
{children}
|
||||
</Container>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
64
frontend/src/components/LoadingSkeleton.tsx
Normal file
64
frontend/src/components/LoadingSkeleton.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { Skeleton, Box } from '@mui/material'
|
||||
|
||||
interface LoadingSkeletonProps {
|
||||
variant?: 'table' | 'card' | 'list' | 'text'
|
||||
rows?: number
|
||||
}
|
||||
|
||||
export default function LoadingSkeleton({ variant = 'text', rows = 3 }: LoadingSkeletonProps) {
|
||||
if (variant === 'table') {
|
||||
return (
|
||||
<Box>
|
||||
{Array.from({ length: rows }).map((_, i) => (
|
||||
<Box key={i} sx={{ display: 'flex', gap: 2, mb: 2 }}>
|
||||
<Skeleton variant="rectangular" width="20%" height={40} />
|
||||
<Skeleton variant="rectangular" width="15%" height={40} />
|
||||
<Skeleton variant="rectangular" width="15%" height={40} />
|
||||
<Skeleton variant="rectangular" width="15%" height={40} />
|
||||
<Skeleton variant="rectangular" width="15%" height={40} />
|
||||
<Skeleton variant="rectangular" width="20%" height={40} />
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
if (variant === 'card') {
|
||||
return (
|
||||
<Box>
|
||||
{Array.from({ length: rows }).map((_, i) => (
|
||||
<Box key={i} sx={{ mb: 2 }}>
|
||||
<Skeleton variant="rectangular" height={200} />
|
||||
<Box sx={{ p: 2 }}>
|
||||
<Skeleton variant="text" width="60%" />
|
||||
<Skeleton variant="text" width="40%" />
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
if (variant === 'list') {
|
||||
return (
|
||||
<Box>
|
||||
{Array.from({ length: rows }).map((_, i) => (
|
||||
<Box key={i} sx={{ display: 'flex', gap: 2, mb: 2, alignItems: 'center' }}>
|
||||
<Skeleton variant="circular" width={40} height={40} />
|
||||
<Skeleton variant="text" width="70%" height={40} />
|
||||
<Skeleton variant="text" width="20%" height={40} />
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{Array.from({ length: rows }).map((_, i) => (
|
||||
<Skeleton key={i} variant="text" sx={{ mb: 1 }} />
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
97
frontend/src/components/OperationsPanel.tsx
Normal file
97
frontend/src/components/OperationsPanel.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
Box,
|
||||
Paper,
|
||||
Typography,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
Chip,
|
||||
IconButton,
|
||||
Collapse,
|
||||
LinearProgress,
|
||||
} from '@mui/material'
|
||||
import { ExpandMore, ExpandLess, PlayArrow, Stop } from '@mui/icons-material'
|
||||
|
||||
interface Operation {
|
||||
id: string
|
||||
type: 'backtest' | 'optimization' | 'strategy' | 'order'
|
||||
name: string
|
||||
status: 'running' | 'queued' | 'completed' | 'failed'
|
||||
progress?: number
|
||||
startTime?: Date
|
||||
estimatedTimeRemaining?: number
|
||||
}
|
||||
|
||||
interface OperationsPanelProps {
|
||||
operations?: Operation[]
|
||||
onCancel?: (id: string) => void
|
||||
}
|
||||
|
||||
export default function OperationsPanel({ operations = [], onCancel }: OperationsPanelProps) {
|
||||
const [expanded, setExpanded] = useState(true)
|
||||
|
||||
const runningOperations = operations.filter((op) => op.status === 'running' || op.status === 'queued')
|
||||
const completedOperations = operations.filter((op) => op.status === 'completed' || op.status === 'failed')
|
||||
|
||||
if (operations.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Paper sx={{ p: 2 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
|
||||
<Typography variant="h6">
|
||||
Operations ({runningOperations.length} running)
|
||||
</Typography>
|
||||
<IconButton size="small" onClick={() => setExpanded(!expanded)}>
|
||||
{expanded ? <ExpandLess /> : <ExpandMore />}
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
<Collapse in={expanded}>
|
||||
<List dense>
|
||||
{runningOperations.map((op) => (
|
||||
<ListItem key={op.id}>
|
||||
<ListItemText
|
||||
primary={op.name}
|
||||
secondary={
|
||||
<Box>
|
||||
<Chip
|
||||
label={op.status}
|
||||
size="small"
|
||||
color={op.status === 'running' ? 'primary' : 'default'}
|
||||
sx={{ mr: 1 }}
|
||||
/>
|
||||
{op.progress !== undefined && (
|
||||
<Box sx={{ mt: 1, width: '100%' }}>
|
||||
<LinearProgress variant="determinate" value={op.progress} />
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{op.progress}%
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
{onCancel && op.status === 'running' && (
|
||||
<IconButton size="small" onClick={() => onCancel(op.id)}>
|
||||
<Stop fontSize="small" />
|
||||
</IconButton>
|
||||
)}
|
||||
</ListItem>
|
||||
))}
|
||||
{runningOperations.length === 0 && (
|
||||
<ListItem>
|
||||
<ListItemText
|
||||
primary="No running operations"
|
||||
secondary="All operations are idle"
|
||||
/>
|
||||
</ListItem>
|
||||
)}
|
||||
</List>
|
||||
</Collapse>
|
||||
</Paper>
|
||||
)
|
||||
}
|
||||
|
||||
120
frontend/src/components/OrderConfirmationDialog.tsx
Normal file
120
frontend/src/components/OrderConfirmationDialog.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
Typography,
|
||||
Box,
|
||||
Alert,
|
||||
} from '@mui/material'
|
||||
import { OrderSide, OrderType } from '../types'
|
||||
|
||||
interface OrderConfirmationDialogProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onConfirm: () => void
|
||||
symbol: string
|
||||
side: OrderSide
|
||||
orderType: OrderType
|
||||
quantity: number
|
||||
price?: number
|
||||
stopPrice?: number
|
||||
takeProfitPrice?: number
|
||||
trailingPercent?: number
|
||||
}
|
||||
|
||||
export default function OrderConfirmationDialog({
|
||||
open,
|
||||
onClose,
|
||||
onConfirm,
|
||||
symbol,
|
||||
side,
|
||||
orderType,
|
||||
quantity,
|
||||
price,
|
||||
stopPrice,
|
||||
takeProfitPrice,
|
||||
trailingPercent,
|
||||
}: OrderConfirmationDialogProps) {
|
||||
const estimatedTotal = price ? quantity * price : null
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>
|
||||
Confirm {side.toUpperCase()} Order
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Alert severity="warning" sx={{ mb: 2 }}>
|
||||
Please verify all details carefully before confirming.
|
||||
</Alert>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<Typography variant="body2" color="text.secondary">Symbol:</Typography>
|
||||
<Typography variant="body2" fontWeight="bold">{symbol}</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<Typography variant="body2" color="text.secondary">Side:</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
fontWeight="bold"
|
||||
color={side === OrderSide.BUY ? 'success.main' : 'error.main'}
|
||||
>
|
||||
{side.toUpperCase()}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<Typography variant="body2" color="text.secondary">Type:</Typography>
|
||||
<Typography variant="body2" fontWeight="bold">{orderType.replace('_', ' ')}</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<Typography variant="body2" color="text.secondary">Quantity:</Typography>
|
||||
<Typography variant="body2" fontWeight="bold">{quantity.toFixed(8)}</Typography>
|
||||
</Box>
|
||||
{price && (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<Typography variant="body2" color="text.secondary">Price:</Typography>
|
||||
<Typography variant="body2" fontWeight="bold">${price.toFixed(2)}</Typography>
|
||||
</Box>
|
||||
)}
|
||||
{stopPrice && (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<Typography variant="body2" color="text.secondary">Stop Price:</Typography>
|
||||
<Typography variant="body2" fontWeight="bold">${stopPrice.toFixed(2)}</Typography>
|
||||
</Box>
|
||||
)}
|
||||
{takeProfitPrice && (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<Typography variant="body2" color="text.secondary">Take Profit Price:</Typography>
|
||||
<Typography variant="body2" fontWeight="bold">${takeProfitPrice.toFixed(2)}</Typography>
|
||||
</Box>
|
||||
)}
|
||||
{trailingPercent && (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<Typography variant="body2" color="text.secondary">Trailing Percent:</Typography>
|
||||
<Typography variant="body2" fontWeight="bold">{(trailingPercent * 100).toFixed(2)}%</Typography>
|
||||
</Box>
|
||||
)}
|
||||
{estimatedTotal && (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mt: 1, pt: 1, borderTop: 1, borderColor: 'divider' }}>
|
||||
<Typography variant="body1" fontWeight="bold">Est. Total:</Typography>
|
||||
<Typography variant="body1" fontWeight="bold">${estimatedTotal.toFixed(2)}</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
<Button
|
||||
onClick={onConfirm}
|
||||
variant="contained"
|
||||
color={side === OrderSide.BUY ? 'success' : 'error'}
|
||||
>
|
||||
Confirm Order
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
316
frontend/src/components/OrderForm.tsx
Normal file
316
frontend/src/components/OrderForm.tsx
Normal file
@@ -0,0 +1,316 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
TextField,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
MenuItem,
|
||||
Grid,
|
||||
Alert,
|
||||
Box,
|
||||
Typography,
|
||||
Divider,
|
||||
Collapse,
|
||||
} from '@mui/material'
|
||||
import { ExpandMore, ExpandLess } from '@mui/icons-material'
|
||||
import { tradingApi } from '../api/trading'
|
||||
import { OrderCreate, OrderSide, OrderType } from '../types'
|
||||
import { useSnackbar } from '../contexts/SnackbarContext'
|
||||
import RealtimePrice from './RealtimePrice'
|
||||
import ProviderStatusDisplay from './ProviderStatus'
|
||||
|
||||
interface OrderFormProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
exchanges: Array<{ id: number; name: string }>
|
||||
paperTrading: boolean
|
||||
onSuccess: () => void
|
||||
}
|
||||
|
||||
const CRYPTO_PAIRS = [
|
||||
'BTC/USD',
|
||||
'ETH/USD',
|
||||
'BTC/USDT',
|
||||
'ETH/USDT',
|
||||
'SOL/USD',
|
||||
'ADA/USD',
|
||||
'XRP/USD',
|
||||
'DOGE/USD',
|
||||
'DOT/USD',
|
||||
'MATIC/USD',
|
||||
'AVAX/USD',
|
||||
'LINK/USD',
|
||||
]
|
||||
|
||||
const ORDER_TYPES = [
|
||||
{ value: OrderType.MARKET, label: 'Market' },
|
||||
{ value: OrderType.LIMIT, label: 'Limit' },
|
||||
{ value: OrderType.STOP_LOSS, label: 'Stop Loss' },
|
||||
{ value: OrderType.TAKE_PROFIT, label: 'Take Profit' },
|
||||
{ value: OrderType.TRAILING_STOP, label: 'Trailing Stop' },
|
||||
{ value: OrderType.OCO, label: 'OCO (One-Cancels-Other)' },
|
||||
{ value: OrderType.ICEBERG, label: 'Iceberg' },
|
||||
]
|
||||
|
||||
export default function OrderForm({
|
||||
open,
|
||||
onClose,
|
||||
exchanges,
|
||||
paperTrading,
|
||||
onSuccess,
|
||||
}: OrderFormProps) {
|
||||
const queryClient = useQueryClient()
|
||||
const { showError } = useSnackbar()
|
||||
const [exchangeId, setExchangeId] = useState<number | ''>('')
|
||||
const [symbol, setSymbol] = useState('BTC/USD')
|
||||
const [side, setSide] = useState<OrderSide>(OrderSide.BUY)
|
||||
const [orderType, setOrderType] = useState<OrderType>(OrderType.MARKET)
|
||||
const [quantity, setQuantity] = useState('')
|
||||
const [price, setPrice] = useState('')
|
||||
const [stopPrice, setStopPrice] = useState('')
|
||||
const [showAdvanced, setShowAdvanced] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (exchanges.length > 0 && !exchangeId) {
|
||||
setExchangeId(exchanges[0].id)
|
||||
}
|
||||
}, [exchanges, exchangeId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
// Reset form when dialog closes
|
||||
setExchangeId(exchanges.length > 0 ? exchanges[0].id : '')
|
||||
setSymbol('BTC/USD')
|
||||
setSide(OrderSide.BUY)
|
||||
setOrderType(OrderType.MARKET)
|
||||
setQuantity('')
|
||||
setPrice('')
|
||||
setStopPrice('')
|
||||
setShowAdvanced(false)
|
||||
}
|
||||
}, [open, exchanges])
|
||||
|
||||
const createOrderMutation = useMutation({
|
||||
mutationFn: (order: OrderCreate) => tradingApi.createOrder(order),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['orders'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['positions'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['balance'] })
|
||||
onSuccess()
|
||||
},
|
||||
})
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!exchangeId) {
|
||||
showError('Please select an exchange')
|
||||
return
|
||||
}
|
||||
|
||||
if (!quantity || parseFloat(quantity) <= 0) {
|
||||
showError('Please enter a valid quantity')
|
||||
return
|
||||
}
|
||||
|
||||
const requiresPrice = [
|
||||
OrderType.LIMIT,
|
||||
OrderType.STOP_LOSS,
|
||||
OrderType.TAKE_PROFIT,
|
||||
].includes(orderType)
|
||||
|
||||
if (requiresPrice && (!price || parseFloat(price) <= 0)) {
|
||||
showError('Please enter a valid price for this order type')
|
||||
return
|
||||
}
|
||||
|
||||
const order: OrderCreate = {
|
||||
exchange_id: exchangeId as number,
|
||||
symbol,
|
||||
side,
|
||||
order_type: orderType,
|
||||
quantity: parseFloat(quantity),
|
||||
price: requiresPrice ? parseFloat(price) : undefined,
|
||||
paper_trading: paperTrading,
|
||||
}
|
||||
|
||||
createOrderMutation.mutate(order)
|
||||
}
|
||||
|
||||
const requiresPrice = [
|
||||
OrderType.LIMIT,
|
||||
OrderType.STOP_LOSS,
|
||||
OrderType.TAKE_PROFIT,
|
||||
].includes(orderType)
|
||||
|
||||
const requiresStopPrice = [
|
||||
OrderType.STOP_LOSS,
|
||||
OrderType.TRAILING_STOP,
|
||||
].includes(orderType)
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>Place Order</DialogTitle>
|
||||
<DialogContent>
|
||||
<Grid container spacing={2} sx={{ mt: 1 }}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<FormControl fullWidth required>
|
||||
<InputLabel>Exchange</InputLabel>
|
||||
<Select
|
||||
value={exchangeId}
|
||||
label="Exchange"
|
||||
onChange={(e) => setExchangeId(e.target.value as number)}
|
||||
>
|
||||
{exchanges.map((ex) => (
|
||||
<MenuItem key={ex.id} value={ex.id}>
|
||||
{ex.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<FormControl fullWidth required>
|
||||
<InputLabel>Symbol</InputLabel>
|
||||
<Select
|
||||
value={symbol}
|
||||
label="Symbol"
|
||||
onChange={(e) => setSymbol(e.target.value)}
|
||||
>
|
||||
{CRYPTO_PAIRS.map((pair) => (
|
||||
<MenuItem key={pair} value={pair}>
|
||||
{pair}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Box sx={{ mb: 1 }}>
|
||||
<RealtimePrice symbol={symbol} size="small" showProvider={false} showChange={true} />
|
||||
</Box>
|
||||
<Divider sx={{ my: 1 }} />
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<FormControl fullWidth required>
|
||||
<InputLabel>Side</InputLabel>
|
||||
<Select
|
||||
value={side}
|
||||
label="Side"
|
||||
onChange={(e) => setSide(e.target.value as OrderSide)}
|
||||
>
|
||||
<MenuItem value={OrderSide.BUY}>Buy</MenuItem>
|
||||
<MenuItem value={OrderSide.SELL}>Sell</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<FormControl fullWidth required>
|
||||
<InputLabel>Order Type</InputLabel>
|
||||
<Select
|
||||
value={orderType}
|
||||
label="Order Type"
|
||||
onChange={(e) => setOrderType(e.target.value as OrderType)}
|
||||
>
|
||||
{ORDER_TYPES.map((type) => (
|
||||
<MenuItem key={type.value} value={type.value}>
|
||||
{type.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Quantity"
|
||||
type="number"
|
||||
value={quantity}
|
||||
onChange={(e) => setQuantity(e.target.value)}
|
||||
required
|
||||
inputProps={{ min: 0, step: 0.00000001 }}
|
||||
helperText="Amount to buy or sell"
|
||||
/>
|
||||
</Grid>
|
||||
{requiresPrice && (
|
||||
<Grid item xs={12}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Price"
|
||||
type="number"
|
||||
value={price}
|
||||
onChange={(e) => setPrice(e.target.value)}
|
||||
required
|
||||
inputProps={{ min: 0, step: 0.01 }}
|
||||
helperText="Limit price for the order"
|
||||
/>
|
||||
</Grid>
|
||||
)}
|
||||
{requiresStopPrice && (
|
||||
<Grid item xs={12}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Stop Price"
|
||||
type="number"
|
||||
value={stopPrice}
|
||||
onChange={(e) => setStopPrice(e.target.value)}
|
||||
inputProps={{ min: 0, step: 0.01 }}
|
||||
helperText="Stop price for stop-loss or trailing stop orders"
|
||||
/>
|
||||
</Grid>
|
||||
)}
|
||||
<Grid item xs={12}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||
endIcon={showAdvanced ? <ExpandLess /> : <ExpandMore />}
|
||||
>
|
||||
Advanced Options
|
||||
</Button>
|
||||
</Box>
|
||||
<Collapse in={showAdvanced}>
|
||||
<Box sx={{ mt: 2, p: 2, bgcolor: 'background.default', borderRadius: 1 }}>
|
||||
<Typography variant="body2" color="text.secondary" gutterBottom>
|
||||
Advanced order types (OCO, Iceberg) require additional configuration.
|
||||
These features are available in the API but may need custom implementation.
|
||||
</Typography>
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Grid>
|
||||
{paperTrading && (
|
||||
<Grid item xs={12}>
|
||||
<Alert severity="info">
|
||||
This order will be executed in paper trading mode with virtual funds.
|
||||
</Alert>
|
||||
</Grid>
|
||||
)}
|
||||
{createOrderMutation.isError && (
|
||||
<Grid item xs={12}>
|
||||
<Alert severity="error">
|
||||
{createOrderMutation.error instanceof Error
|
||||
? createOrderMutation.error.message
|
||||
: 'Failed to place order'}
|
||||
</Alert>
|
||||
</Grid>
|
||||
)}
|
||||
</Grid>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleSubmit}
|
||||
disabled={createOrderMutation.isPending}
|
||||
>
|
||||
{createOrderMutation.isPending ? 'Placing...' : 'Place Order'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
240
frontend/src/components/PositionCard.tsx
Normal file
240
frontend/src/components/PositionCard.tsx
Normal file
@@ -0,0 +1,240 @@
|
||||
import { useState } from 'react'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
TextField,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
MenuItem,
|
||||
Box,
|
||||
Grid,
|
||||
Chip,
|
||||
Alert,
|
||||
Divider,
|
||||
} from '@mui/material'
|
||||
import { Close } from '@mui/icons-material'
|
||||
import { tradingApi } from '../api/trading'
|
||||
import { PositionResponse, OrderCreate, OrderSide, OrderType } from '../types'
|
||||
import { useSnackbar } from '../contexts/SnackbarContext'
|
||||
|
||||
interface PositionCardProps {
|
||||
position: PositionResponse
|
||||
paperTrading: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export default function PositionCard({
|
||||
position,
|
||||
paperTrading,
|
||||
onClose,
|
||||
}: PositionCardProps) {
|
||||
const queryClient = useQueryClient()
|
||||
const { showError } = useSnackbar()
|
||||
const [closeDialogOpen, setCloseDialogOpen] = useState(false)
|
||||
const [closeType, setCloseType] = useState<OrderType>(OrderType.MARKET)
|
||||
const [closePrice, setClosePrice] = useState('')
|
||||
|
||||
const closePositionMutation = useMutation({
|
||||
mutationFn: (order: OrderCreate) => tradingApi.createOrder(order),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['positions'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['orders'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['balance'] })
|
||||
setCloseDialogOpen(false)
|
||||
onClose()
|
||||
},
|
||||
})
|
||||
|
||||
const handleClosePosition = () => {
|
||||
setCloseDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleConfirmClose = () => {
|
||||
// Extract exchange_id from position or use default
|
||||
// In a real implementation, you'd get this from the position or context
|
||||
const exchangeId = 1 // This should come from position data or context
|
||||
|
||||
const requiresPrice = closeType === OrderType.LIMIT
|
||||
|
||||
if (requiresPrice && (!closePrice || parseFloat(closePrice) <= 0)) {
|
||||
showError('Please enter a valid price for limit orders')
|
||||
return
|
||||
}
|
||||
|
||||
const order: OrderCreate = {
|
||||
exchange_id: exchangeId,
|
||||
symbol: position.symbol,
|
||||
side: OrderSide.SELL,
|
||||
order_type: closeType,
|
||||
quantity: position.quantity,
|
||||
price: requiresPrice ? parseFloat(closePrice) : undefined,
|
||||
paper_trading: paperTrading,
|
||||
}
|
||||
|
||||
closePositionMutation.mutate(order)
|
||||
}
|
||||
|
||||
const pnlPercent =
|
||||
position.entry_price > 0
|
||||
? ((position.current_price - position.entry_price) / position.entry_price) * 100
|
||||
: 0
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start', mb: 2 }}>
|
||||
<Typography variant="h6">{position.symbol}</Typography>
|
||||
<Chip
|
||||
label={pnlPercent >= 0 ? `+${pnlPercent.toFixed(2)}%` : `${pnlPercent.toFixed(2)}%`}
|
||||
color={pnlPercent >= 0 ? 'success' : 'error'}
|
||||
size="small"
|
||||
/>
|
||||
</Box>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={6}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Quantity
|
||||
</Typography>
|
||||
<Typography variant="body1" sx={{ fontWeight: 500 }}>
|
||||
{Number(position.quantity).toFixed(8)}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Entry Price
|
||||
</Typography>
|
||||
<Typography variant="body1" sx={{ fontWeight: 500 }}>
|
||||
${Number(position.entry_price).toFixed(2)}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Current Price
|
||||
</Typography>
|
||||
<Typography variant="body1" sx={{ fontWeight: 500 }}>
|
||||
${Number(position.current_price).toFixed(2)}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Value
|
||||
</Typography>
|
||||
<Typography variant="body1" sx={{ fontWeight: 500 }}>
|
||||
${(Number(position.quantity) * Number(position.current_price)).toFixed(2)}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Divider sx={{ my: 1 }} />
|
||||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Unrealized P&L
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
fontWeight: 'bold',
|
||||
color: position.unrealized_pnl >= 0 ? 'success.main' : 'error.main',
|
||||
}}
|
||||
>
|
||||
{position.unrealized_pnl >= 0 ? '+' : ''}
|
||||
${Number(position.unrealized_pnl).toFixed(2)}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Realized P&L
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{
|
||||
fontWeight: 500,
|
||||
color: position.realized_pnl >= 0 ? 'success.main' : 'error.main',
|
||||
}}
|
||||
>
|
||||
{position.realized_pnl >= 0 ? '+' : ''}
|
||||
${Number(position.realized_pnl).toFixed(2)}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="error"
|
||||
fullWidth
|
||||
startIcon={<Close />}
|
||||
onClick={handleClosePosition}
|
||||
sx={{ mt: 1 }}
|
||||
>
|
||||
Close Position
|
||||
</Button>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog open={closeDialogOpen} onClose={() => setCloseDialogOpen(false)} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>Close Position</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Alert severity="info" sx={{ mb: 2 }}>
|
||||
Closing {Number(position.quantity).toFixed(8)} {position.symbol} at current price of ${Number(position.current_price).toFixed(2)}
|
||||
</Alert>
|
||||
<FormControl fullWidth sx={{ mb: 2 }}>
|
||||
<InputLabel>Order Type</InputLabel>
|
||||
<Select
|
||||
value={closeType}
|
||||
label="Order Type"
|
||||
onChange={(e) => {
|
||||
setCloseType(e.target.value as OrderType)
|
||||
setClosePrice('')
|
||||
}}
|
||||
>
|
||||
<MenuItem value={OrderType.MARKET}>Market</MenuItem>
|
||||
<MenuItem value={OrderType.LIMIT}>Limit</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
{closeType === OrderType.LIMIT && (
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Limit Price"
|
||||
type="number"
|
||||
value={closePrice}
|
||||
onChange={(e) => setClosePrice(e.target.value)}
|
||||
inputProps={{ min: 0, step: 0.01 }}
|
||||
helperText="Price at which to close the position"
|
||||
/>
|
||||
)}
|
||||
{closePositionMutation.isError && (
|
||||
<Alert severity="error" sx={{ mt: 2 }}>
|
||||
{closePositionMutation.error instanceof Error
|
||||
? closePositionMutation.error.message
|
||||
: 'Failed to close position'}
|
||||
</Alert>
|
||||
)}
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setCloseDialogOpen(false)}>Cancel</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="error"
|
||||
onClick={handleConfirmClose}
|
||||
disabled={closePositionMutation.isPending}
|
||||
>
|
||||
{closePositionMutation.isPending ? 'Closing...' : 'Close Position'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
45
frontend/src/components/ProgressOverlay.tsx
Normal file
45
frontend/src/components/ProgressOverlay.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { Box, CircularProgress, Typography, LinearProgress } from '@mui/material'
|
||||
|
||||
interface ProgressOverlayProps {
|
||||
message?: string
|
||||
progress?: number
|
||||
variant?: 'indeterminate' | 'determinate'
|
||||
}
|
||||
|
||||
export default function ProgressOverlay({
|
||||
message = 'Loading...',
|
||||
progress,
|
||||
variant = 'indeterminate',
|
||||
}: ProgressOverlayProps) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
bgcolor: 'rgba(255, 255, 255, 0.8)',
|
||||
zIndex: 1000,
|
||||
}}
|
||||
>
|
||||
<CircularProgress sx={{ mb: 2 }} />
|
||||
{variant === 'determinate' && progress !== undefined && (
|
||||
<Box sx={{ width: '200px', mb: 2 }}>
|
||||
<LinearProgress variant="determinate" value={progress} />
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mt: 0.5 }}>
|
||||
{progress.toFixed(0)}%
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{message}
|
||||
</Typography>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
132
frontend/src/components/ProviderStatus.tsx
Normal file
132
frontend/src/components/ProviderStatus.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import { Box, Chip, Tooltip, Typography, CircularProgress } from '@mui/material'
|
||||
import { CheckCircle, Error, Warning, CloudOff, Info } from '@mui/icons-material'
|
||||
import { useProviderStatus } from '../hooks/useProviderStatus'
|
||||
import StatusIndicator from './StatusIndicator'
|
||||
|
||||
interface ProviderStatusProps {
|
||||
compact?: boolean
|
||||
showDetails?: boolean
|
||||
}
|
||||
|
||||
export default function ProviderStatusDisplay({ compact = false, showDetails = false }: ProviderStatusProps) {
|
||||
const { status, isLoading, error } = useProviderStatus()
|
||||
|
||||
if (isLoading) {
|
||||
return compact ? (
|
||||
<CircularProgress size={16} />
|
||||
) : (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<CircularProgress size={20} />
|
||||
<Typography variant="body2">Loading provider status...</Typography>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Chip
|
||||
icon={<Error />}
|
||||
label="Provider status unavailable"
|
||||
color="error"
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (!status) {
|
||||
return null
|
||||
}
|
||||
|
||||
const activeProvider = status.active_provider
|
||||
const providerHealth = activeProvider ? status.providers[activeProvider] : null
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<Tooltip
|
||||
title={
|
||||
activeProvider
|
||||
? `Active: ${activeProvider}${providerHealth ? ` (${providerHealth.status})` : ''}`
|
||||
: 'No active provider'
|
||||
}
|
||||
>
|
||||
<Box>
|
||||
{providerHealth ? (
|
||||
<StatusIndicator
|
||||
status={
|
||||
providerHealth.status === 'healthy'
|
||||
? 'connected'
|
||||
: providerHealth.status === 'degraded'
|
||||
? 'warning'
|
||||
: 'error'
|
||||
}
|
||||
label={activeProvider || 'None'}
|
||||
/>
|
||||
) : (
|
||||
<Chip label={activeProvider || 'None'} size="small" color="default" variant="outlined" />
|
||||
)}
|
||||
</Box>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
|
||||
<Typography variant="subtitle2">Data Providers</Typography>
|
||||
{activeProvider && (
|
||||
<Chip
|
||||
label={`Active: ${activeProvider}`}
|
||||
color={providerHealth?.status === 'healthy' ? 'success' : 'default'}
|
||||
size="small"
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{showDetails && (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
||||
{Object.entries(status.providers).map(([name, health]) => (
|
||||
<Box
|
||||
key={name}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
p: 1,
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
borderRadius: 1,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<StatusIndicator
|
||||
status={
|
||||
health.status === 'healthy'
|
||||
? 'connected'
|
||||
: health.status === 'degraded'
|
||||
? 'warning'
|
||||
: 'error'
|
||||
}
|
||||
label={name}
|
||||
/>
|
||||
{name === activeProvider && (
|
||||
<Chip label="Active" size="small" color="primary" variant="outlined" />
|
||||
)}
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center' }}>
|
||||
{health.avg_response_time > 0 && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{health.avg_response_time.toFixed(3)}s avg
|
||||
</Typography>
|
||||
)}
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{health.success_count} success, {health.failure_count} failures
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
185
frontend/src/components/RealtimePrice.tsx
Normal file
185
frontend/src/components/RealtimePrice.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Box, Typography, Chip, CircularProgress, Tooltip, Fade } from '@mui/material'
|
||||
import { TrendingUp, TrendingDown } from '@mui/icons-material'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { marketDataApi, TickerData } from '../api/marketData'
|
||||
import { useWebSocketContext } from './WebSocketProvider'
|
||||
|
||||
interface RealtimePriceProps {
|
||||
symbol: string
|
||||
showProvider?: boolean
|
||||
showChange?: boolean
|
||||
size?: 'small' | 'medium' | 'large'
|
||||
onPriceUpdate?: (price: number) => void
|
||||
}
|
||||
|
||||
export default function RealtimePrice({
|
||||
symbol,
|
||||
showProvider = true,
|
||||
showChange = true,
|
||||
size = 'medium',
|
||||
onPriceUpdate,
|
||||
}: RealtimePriceProps) {
|
||||
const { isConnected, lastMessage } = useWebSocketContext()
|
||||
const [currentPrice, setCurrentPrice] = useState<number | null>(null)
|
||||
const [previousPrice, setPreviousPrice] = useState<number | null>(null)
|
||||
const [priceChange, setPriceChange] = useState<number>(0)
|
||||
const [flash, setFlash] = useState(false)
|
||||
|
||||
const { data: ticker, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ['ticker', symbol],
|
||||
queryFn: () => marketDataApi.getTicker(symbol),
|
||||
refetchInterval: 5000, // Refetch every 5 seconds as fallback
|
||||
enabled: !!symbol,
|
||||
})
|
||||
|
||||
// Update price when ticker data changes
|
||||
useEffect(() => {
|
||||
// Only update if we have a valid price (including 0, but not null/undefined)
|
||||
if (ticker?.last !== undefined && ticker?.last !== null) {
|
||||
const newPrice = ticker.last
|
||||
|
||||
setCurrentPrice((prevPrice) => {
|
||||
// Check if price has changed
|
||||
if (prevPrice !== null && newPrice !== prevPrice) {
|
||||
setPreviousPrice(prevPrice)
|
||||
setPriceChange(newPrice - prevPrice)
|
||||
|
||||
// Flash animation
|
||||
setFlash(true)
|
||||
setTimeout(() => setFlash(false), 2000)
|
||||
|
||||
// Call callback if provided
|
||||
if (onPriceUpdate) {
|
||||
onPriceUpdate(newPrice)
|
||||
}
|
||||
}
|
||||
|
||||
// Always update currentPrice when we have valid ticker data
|
||||
// This ensures the price persists even if ticker becomes undefined later
|
||||
return newPrice
|
||||
})
|
||||
}
|
||||
}, [ticker?.last, onPriceUpdate])
|
||||
|
||||
// Listen for WebSocket price updates
|
||||
useEffect(() => {
|
||||
if (!isConnected || !lastMessage) return
|
||||
|
||||
try {
|
||||
const message = typeof lastMessage === 'string' ? JSON.parse(lastMessage) : lastMessage
|
||||
|
||||
if (message.type === 'price_update' && message.symbol === symbol && message.price !== undefined && message.price !== null) {
|
||||
const newPrice = parseFloat(message.price)
|
||||
|
||||
// Validate the parsed price
|
||||
if (isNaN(newPrice)) return
|
||||
|
||||
setCurrentPrice((prevPrice) => {
|
||||
if (prevPrice !== null && newPrice !== prevPrice) {
|
||||
setPreviousPrice(prevPrice)
|
||||
setPriceChange(newPrice - prevPrice)
|
||||
|
||||
// Flash animation
|
||||
setFlash(true)
|
||||
setTimeout(() => setFlash(false), 2000)
|
||||
|
||||
if (onPriceUpdate) {
|
||||
onPriceUpdate(newPrice)
|
||||
}
|
||||
}
|
||||
|
||||
return newPrice
|
||||
})
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore parsing errors
|
||||
}
|
||||
}, [isConnected, lastMessage, symbol, onPriceUpdate])
|
||||
|
||||
if (isLoading && currentPrice === null) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<CircularProgress size={size === 'small' ? 16 : size === 'large' ? 24 : 20} />
|
||||
<Typography variant={size === 'small' ? 'body2' : size === 'large' ? 'h6' : 'body1'}>
|
||||
Loading...
|
||||
</Typography>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Typography variant={size === 'small' ? 'body2' : 'body1'} color="error">
|
||||
Error loading price
|
||||
</Typography>
|
||||
)
|
||||
}
|
||||
|
||||
// Use currentPrice if available, otherwise fall back to ticker?.last
|
||||
// Only show 0 if both are explicitly 0 (not null/undefined)
|
||||
const price = currentPrice !== null ? currentPrice : (ticker?.last !== undefined && ticker?.last !== null ? ticker.last : null)
|
||||
const isPositive = priceChange >= 0
|
||||
const changePercent = currentPrice !== null && previousPrice !== null ? ((priceChange / previousPrice) * 100).toFixed(2) : null
|
||||
|
||||
const priceVariant = size === 'small' ? 'body2' : size === 'large' ? 'h5' : 'h6'
|
||||
const priceColor = flash ? (isPositive ? 'success.main' : 'error.main') : 'text.primary'
|
||||
|
||||
// Don't render price if we don't have a valid price value
|
||||
if (price === null) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<CircularProgress size={size === 'small' ? 16 : size === 'large' ? 24 : 20} />
|
||||
<Typography variant={size === 'small' ? 'body2' : size === 'large' ? 'h6' : 'body1'}>
|
||||
Loading price...
|
||||
</Typography>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
|
||||
<Typography
|
||||
variant={priceVariant}
|
||||
sx={{
|
||||
color: priceColor,
|
||||
fontWeight: 600,
|
||||
transition: 'color 0.3s',
|
||||
}}
|
||||
>
|
||||
${price.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 8 })}
|
||||
</Typography>
|
||||
|
||||
{showChange && changePercent && (
|
||||
<Chip
|
||||
icon={isPositive ? <TrendingUp /> : <TrendingDown />}
|
||||
label={`${isPositive ? '+' : ''}${changePercent}%`}
|
||||
color={isPositive ? 'success' : 'error'}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
)}
|
||||
|
||||
{showProvider && ticker?.provider && (
|
||||
<Tooltip title={`Data provider: ${ticker.provider}`}>
|
||||
<Chip label={ticker.provider} size="small" variant="outlined" color="default" />
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{ticker && (
|
||||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
24h: ${ticker.high.toFixed(2)} / ${ticker.low.toFixed(2)}
|
||||
</Typography>
|
||||
{ticker.volume > 0 && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Vol: ${ticker.volume.toLocaleString('en-US', { maximumFractionDigits: 0 })}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
253
frontend/src/components/SpreadChart.tsx
Normal file
253
frontend/src/components/SpreadChart.tsx
Normal file
@@ -0,0 +1,253 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import {
|
||||
Box,
|
||||
Paper,
|
||||
Typography,
|
||||
Grid,
|
||||
Chip,
|
||||
Tooltip,
|
||||
CircularProgress,
|
||||
} from '@mui/material'
|
||||
import {
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
SwapHoriz,
|
||||
} from '@mui/icons-material'
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip as RechartsTooltip,
|
||||
ResponsiveContainer,
|
||||
ReferenceLine,
|
||||
} from 'recharts'
|
||||
import { marketDataApi } from '../api/marketData'
|
||||
|
||||
interface SpreadChartProps {
|
||||
primarySymbol: string
|
||||
secondarySymbol: string
|
||||
lookbackPeriod?: number
|
||||
zScoreThreshold?: number
|
||||
}
|
||||
|
||||
export default function SpreadChart({
|
||||
primarySymbol,
|
||||
secondarySymbol,
|
||||
lookbackPeriod = 20,
|
||||
zScoreThreshold = 2.0,
|
||||
}: SpreadChartProps) {
|
||||
|
||||
// Fetch spread data from backend
|
||||
const { data: spreadResponse, isLoading, error } = useQuery({
|
||||
queryKey: ['spread-data', primarySymbol, secondarySymbol, lookbackPeriod],
|
||||
queryFn: () => marketDataApi.getSpreadData(
|
||||
primarySymbol,
|
||||
secondarySymbol,
|
||||
'1h',
|
||||
lookbackPeriod + 30
|
||||
),
|
||||
refetchInterval: 60000, // Refresh every minute
|
||||
staleTime: 30000,
|
||||
})
|
||||
|
||||
const spreadData = spreadResponse?.data ?? []
|
||||
const currentZScore = spreadResponse?.currentZScore ?? 0
|
||||
const currentSpread = spreadResponse?.currentSpread ?? 0
|
||||
|
||||
// Determine signal state
|
||||
const getSignalState = () => {
|
||||
if (currentZScore > zScoreThreshold) {
|
||||
return { label: `Short Spread (Sell ${primarySymbol})`, color: 'error' as const, icon: <TrendingDown /> }
|
||||
} else if (currentZScore < -zScoreThreshold) {
|
||||
return { label: `Long Spread (Buy ${primarySymbol})`, color: 'success' as const, icon: <TrendingUp /> }
|
||||
}
|
||||
return { label: 'Neutral (No Signal)', color: 'default' as const, icon: <SwapHoriz /> }
|
||||
}
|
||||
|
||||
const signalState = getSignalState()
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Paper sx={{ p: 2, display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: 400 }}>
|
||||
<CircularProgress />
|
||||
<Typography sx={{ ml: 2 }}>Loading spread data...</Typography>
|
||||
</Paper>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Paper sx={{ p: 2, minHeight: 400 }}>
|
||||
<Typography color="error">Failed to load spread data: {(error as Error).message}</Typography>
|
||||
</Paper>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Paper sx={{ p: 2 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Box>
|
||||
<Typography variant="h6">
|
||||
Pairs Trading: {primarySymbol} / {secondarySymbol}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Statistical Arbitrage - Spread Analysis
|
||||
</Typography>
|
||||
</Box>
|
||||
<Chip
|
||||
icon={signalState.icon}
|
||||
label={signalState.label}
|
||||
color={signalState.color}
|
||||
variant="outlined"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Key Metrics */}
|
||||
<Grid container spacing={2} sx={{ mb: 3 }}>
|
||||
<Grid item xs={4}>
|
||||
<Paper elevation={0} sx={{ p: 1.5, bgcolor: 'background.default', textAlign: 'center' }}>
|
||||
<Typography variant="caption" color="text.secondary">Current Spread</Typography>
|
||||
<Typography variant="h5">{currentSpread?.toFixed(4) ?? 'N/A'}</Typography>
|
||||
</Paper>
|
||||
</Grid>
|
||||
<Grid item xs={4}>
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 1.5,
|
||||
bgcolor: Math.abs(currentZScore) > zScoreThreshold
|
||||
? (currentZScore > 0 ? 'error.dark' : 'success.dark')
|
||||
: 'background.default',
|
||||
textAlign: 'center',
|
||||
transition: 'background-color 0.3s',
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" color="text.secondary">Z-Score</Typography>
|
||||
<Typography
|
||||
variant="h5"
|
||||
sx={{
|
||||
color: Math.abs(currentZScore) > zScoreThreshold ? 'white' : 'inherit'
|
||||
}}
|
||||
>
|
||||
{currentZScore?.toFixed(2) ?? 'N/A'}
|
||||
</Typography>
|
||||
</Paper>
|
||||
</Grid>
|
||||
<Grid item xs={4}>
|
||||
<Paper elevation={0} sx={{ p: 1.5, bgcolor: 'background.default', textAlign: 'center' }}>
|
||||
<Typography variant="caption" color="text.secondary">Threshold</Typography>
|
||||
<Typography variant="h5">±{zScoreThreshold}</Typography>
|
||||
</Paper>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{/* Z-Score Visual Gauge */}
|
||||
<Box sx={{ mb: 3, px: 2 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
|
||||
<Typography variant="caption" color="success.main">-{zScoreThreshold} (Buy)</Typography>
|
||||
<Typography variant="caption" color="text.secondary">0 (Neutral)</Typography>
|
||||
<Typography variant="caption" color="error.main">+{zScoreThreshold} (Sell)</Typography>
|
||||
</Box>
|
||||
<Tooltip title={`Current Z-Score: ${currentZScore?.toFixed(2) ?? 'N/A'}`}>
|
||||
<Box sx={{ position: 'relative', height: 20, bgcolor: 'background.default', borderRadius: 1 }}>
|
||||
{/* Threshold zones */}
|
||||
<Box sx={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
width: `${(1 - zScoreThreshold / 4) * 50}%`,
|
||||
height: '100%',
|
||||
bgcolor: 'success.main',
|
||||
opacity: 0.2,
|
||||
borderRadius: '4px 0 0 4px'
|
||||
}} />
|
||||
<Box sx={{
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
width: `${(1 - zScoreThreshold / 4) * 50}%`,
|
||||
height: '100%',
|
||||
bgcolor: 'error.main',
|
||||
opacity: 0.2,
|
||||
borderRadius: '0 4px 4px 0'
|
||||
}} />
|
||||
{/* Current position indicator */}
|
||||
<Box sx={{
|
||||
position: 'absolute',
|
||||
left: `${Math.min(100, Math.max(0, (currentZScore + 4) / 8 * 100))}%`,
|
||||
transform: 'translateX(-50%)',
|
||||
width: 4,
|
||||
height: '100%',
|
||||
bgcolor: Math.abs(currentZScore) > zScoreThreshold
|
||||
? (currentZScore > 0 ? 'error.main' : 'success.main')
|
||||
: 'primary.main',
|
||||
borderRadius: 1,
|
||||
}} />
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
{/* Spread Chart */}
|
||||
<Typography variant="subtitle2" color="text.secondary" gutterBottom>
|
||||
Spread History (Ratio: {primarySymbol} / {secondarySymbol})
|
||||
</Typography>
|
||||
<Box sx={{ height: 200 }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={spreadData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.1)" />
|
||||
<XAxis
|
||||
dataKey="timestamp"
|
||||
tickFormatter={(t) => new Date(t).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
stroke="rgba(255,255,255,0.5)"
|
||||
/>
|
||||
<YAxis stroke="rgba(255,255,255,0.5)" domain={['auto', 'auto']} />
|
||||
<RechartsTooltip
|
||||
contentStyle={{ backgroundColor: '#1e1e1e', border: '1px solid #333' }}
|
||||
labelFormatter={(t) => new Date(t).toLocaleString()}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="spread"
|
||||
stroke="#8884d8"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</Box>
|
||||
|
||||
{/* Z-Score Chart */}
|
||||
<Typography variant="subtitle2" color="text.secondary" gutterBottom sx={{ mt: 2 }}>
|
||||
Z-Score History
|
||||
</Typography>
|
||||
<Box sx={{ height: 150 }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={spreadData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.1)" />
|
||||
<XAxis
|
||||
dataKey="timestamp"
|
||||
tickFormatter={(t) => new Date(t).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
stroke="rgba(255,255,255,0.5)"
|
||||
/>
|
||||
<YAxis stroke="rgba(255,255,255,0.5)" domain={[-4, 4]} />
|
||||
<RechartsTooltip
|
||||
contentStyle={{ backgroundColor: '#1e1e1e', border: '1px solid #333' }}
|
||||
labelFormatter={(t) => new Date(t).toLocaleString()}
|
||||
/>
|
||||
{/* Threshold lines */}
|
||||
<ReferenceLine y={zScoreThreshold} stroke="#f44336" strokeDasharray="5 5" />
|
||||
<ReferenceLine y={-zScoreThreshold} stroke="#4caf50" strokeDasharray="5 5" />
|
||||
<ReferenceLine y={0} stroke="rgba(255,255,255,0.3)" />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="zScore"
|
||||
stroke="#82ca9d"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</Box>
|
||||
</Paper>
|
||||
)
|
||||
}
|
||||
63
frontend/src/components/StatusIndicator.tsx
Normal file
63
frontend/src/components/StatusIndicator.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import { Chip, Tooltip, Box } from '@mui/material'
|
||||
import { CheckCircle, Error, Warning, CloudOff } from '@mui/icons-material'
|
||||
|
||||
export type StatusType = 'connected' | 'disconnected' | 'error' | 'warning' | 'unknown'
|
||||
|
||||
interface StatusIndicatorProps {
|
||||
status: StatusType
|
||||
label: string
|
||||
tooltip?: string
|
||||
}
|
||||
|
||||
export default function StatusIndicator({ status, label, tooltip }: StatusIndicatorProps) {
|
||||
const getColor = () => {
|
||||
switch (status) {
|
||||
case 'connected':
|
||||
return 'success'
|
||||
case 'disconnected':
|
||||
return 'default'
|
||||
case 'error':
|
||||
return 'error'
|
||||
case 'warning':
|
||||
return 'warning'
|
||||
default:
|
||||
return 'default'
|
||||
}
|
||||
}
|
||||
|
||||
const getIcon = () => {
|
||||
switch (status) {
|
||||
case 'connected':
|
||||
return <CheckCircle fontSize="small" />
|
||||
case 'disconnected':
|
||||
return <CloudOff fontSize="small" />
|
||||
case 'error':
|
||||
return <Error fontSize="small" />
|
||||
case 'warning':
|
||||
return <Warning fontSize="small" />
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const chip = (
|
||||
<Chip
|
||||
icon={getIcon()}
|
||||
label={label}
|
||||
color={getColor()}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
)
|
||||
|
||||
if (tooltip) {
|
||||
return (
|
||||
<Tooltip title={tooltip}>
|
||||
{chip}
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
return chip
|
||||
}
|
||||
|
||||
404
frontend/src/components/StrategyDialog.tsx
Normal file
404
frontend/src/components/StrategyDialog.tsx
Normal file
@@ -0,0 +1,404 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
TextField,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
MenuItem,
|
||||
Grid,
|
||||
Alert,
|
||||
Switch,
|
||||
FormControlLabel,
|
||||
Box,
|
||||
Typography,
|
||||
Tabs,
|
||||
Tab,
|
||||
} from '@mui/material'
|
||||
import { strategiesApi } from '../api/strategies'
|
||||
import { StrategyResponse, StrategyCreate, StrategyUpdate } from '../types'
|
||||
import StrategyParameterForm from './StrategyParameterForm'
|
||||
import { useSnackbar } from '../contexts/SnackbarContext'
|
||||
|
||||
interface StrategyDialogProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
strategy: StrategyResponse | null
|
||||
exchanges: Array<{ id: number; name: string }>
|
||||
onSave: () => void
|
||||
}
|
||||
|
||||
const STRATEGY_TYPES = [
|
||||
{ value: 'rsi', label: 'RSI Strategy' },
|
||||
{ value: 'macd', label: 'MACD Strategy' },
|
||||
{ value: 'moving_average', label: 'Moving Average Crossover' },
|
||||
{ value: 'confirmed', label: 'Confirmed Strategy (Multi-Indicator)' },
|
||||
{ value: 'divergence', label: 'Divergence Strategy' },
|
||||
{ value: 'bollinger_mean_reversion', label: 'Bollinger Bands Mean Reversion' },
|
||||
{ value: 'consensus', label: 'Consensus Strategy (Ensemble)' },
|
||||
{ value: 'dca', label: 'Dollar Cost Averaging' },
|
||||
{ value: 'grid', label: 'Grid Trading' },
|
||||
{ value: 'momentum', label: 'Momentum Strategy' },
|
||||
{ value: 'pairs_trading', label: 'Statistical Arbitrage (Pairs)' },
|
||||
{ value: 'volatility_breakout', label: 'Volatility Breakout' },
|
||||
{ value: 'sentiment', label: 'Sentiment / News Trading' },
|
||||
{ value: 'market_making', label: 'Market Making' },
|
||||
]
|
||||
|
||||
const TIMEFRAMES = ['1m', '5m', '15m', '30m', '1h', '4h', '1d']
|
||||
|
||||
const CRYPTO_PAIRS = [
|
||||
'BTC/USD',
|
||||
'ETH/USD',
|
||||
'BTC/USDT',
|
||||
'ETH/USDT',
|
||||
'SOL/USD',
|
||||
'ADA/USD',
|
||||
'XRP/USD',
|
||||
'DOGE/USD',
|
||||
'DOT/USD',
|
||||
'MATIC/USD',
|
||||
'AVAX/USD',
|
||||
'LINK/USD',
|
||||
]
|
||||
|
||||
export default function StrategyDialog({
|
||||
open,
|
||||
onClose,
|
||||
strategy,
|
||||
exchanges,
|
||||
onSave,
|
||||
}: StrategyDialogProps) {
|
||||
const { showError } = useSnackbar()
|
||||
const [tabValue, setTabValue] = useState(0)
|
||||
const [name, setName] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [strategyType, setStrategyType] = useState('rsi')
|
||||
const [symbol, setSymbol] = useState('BTC/USD')
|
||||
const [exchangeId, setExchangeId] = useState<number | ''>('')
|
||||
const [timeframes, setTimeframes] = useState<string[]>(['1h'])
|
||||
const [paperTrading, setPaperTrading] = useState(true)
|
||||
const [enabled, setEnabled] = useState(false)
|
||||
const [parameters, setParameters] = useState<Record<string, any>>({})
|
||||
|
||||
useEffect(() => {
|
||||
if (strategy) {
|
||||
setName(strategy.name)
|
||||
setDescription(strategy.description || '')
|
||||
setStrategyType(strategy.strategy_type)
|
||||
setSymbol(strategy.parameters?.symbol || 'BTC/USD')
|
||||
setExchangeId(strategy.parameters?.exchange_id || '')
|
||||
setTimeframes(strategy.timeframes || ['1h'])
|
||||
setPaperTrading(strategy.paper_trading)
|
||||
setEnabled(strategy.enabled)
|
||||
// Extract strategy-specific parameters (exclude symbol, exchange_id)
|
||||
const { symbol: _, exchange_id: __, ...strategyParams } = strategy.parameters || {}
|
||||
setParameters(strategyParams)
|
||||
} else {
|
||||
// Reset form
|
||||
setName('')
|
||||
setDescription('')
|
||||
setStrategyType('rsi')
|
||||
setSymbol('BTC/USD')
|
||||
setExchangeId('')
|
||||
setTimeframes(['1h'])
|
||||
setPaperTrading(true)
|
||||
setEnabled(false)
|
||||
setParameters({})
|
||||
}
|
||||
}, [strategy, open])
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: StrategyCreate) => strategiesApi.createStrategy(data),
|
||||
onSuccess: onSave,
|
||||
})
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (data: StrategyUpdate) =>
|
||||
strategiesApi.updateStrategy(strategy!.id, data),
|
||||
onSuccess: onSave,
|
||||
})
|
||||
|
||||
const handleSave = () => {
|
||||
if (!name.trim()) {
|
||||
showError('Strategy name is required')
|
||||
return
|
||||
}
|
||||
|
||||
if (!exchangeId) {
|
||||
showError('Please select an exchange')
|
||||
return
|
||||
}
|
||||
|
||||
const strategyData: StrategyCreate | StrategyUpdate = {
|
||||
name: name.trim(),
|
||||
description: description.trim() || undefined,
|
||||
strategy_type: strategyType,
|
||||
class_name: strategyType,
|
||||
parameters: {
|
||||
...parameters,
|
||||
symbol,
|
||||
exchange_id: exchangeId,
|
||||
},
|
||||
timeframes,
|
||||
paper_trading: paperTrading,
|
||||
enabled: strategy ? enabled : false, // New strategies start disabled
|
||||
}
|
||||
|
||||
if (strategy) {
|
||||
updateMutation.mutate(strategyData as StrategyUpdate)
|
||||
} else {
|
||||
createMutation.mutate(strategyData as StrategyCreate)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="md" fullWidth>
|
||||
<DialogTitle>
|
||||
{strategy ? 'Edit Strategy' : 'Create Strategy'}
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<Tabs value={tabValue} onChange={(_, v) => setTabValue(v)} sx={{ mb: 3 }}>
|
||||
<Tab label="Basic Settings" />
|
||||
<Tab label="Parameters" />
|
||||
<Tab label="Risk Settings" />
|
||||
</Tabs>
|
||||
|
||||
{tabValue === 0 && (
|
||||
<Grid container spacing={2} sx={{ mt: 1 }}>
|
||||
<Grid item xs={12}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Strategy Name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
required
|
||||
helperText="A descriptive name for this strategy"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
multiline
|
||||
rows={2}
|
||||
helperText="Optional description of the strategy"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<FormControl fullWidth required>
|
||||
<InputLabel>Strategy Type</InputLabel>
|
||||
<Select
|
||||
value={strategyType}
|
||||
label="Strategy Type"
|
||||
onChange={(e) => {
|
||||
setStrategyType(e.target.value)
|
||||
setParameters({}) // Reset parameters when type changes
|
||||
}}
|
||||
disabled={!!strategy}
|
||||
>
|
||||
{STRATEGY_TYPES.map((type) => (
|
||||
<MenuItem key={type.value} value={type.value}>
|
||||
{type.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<FormControl fullWidth required>
|
||||
<InputLabel>Symbol</InputLabel>
|
||||
<Select
|
||||
value={symbol}
|
||||
label="Symbol"
|
||||
onChange={(e) => setSymbol(e.target.value)}
|
||||
>
|
||||
{CRYPTO_PAIRS.map((pair) => (
|
||||
<MenuItem key={pair} value={pair}>
|
||||
{pair}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<FormControl fullWidth required>
|
||||
<InputLabel>Exchange</InputLabel>
|
||||
<Select
|
||||
value={exchangeId}
|
||||
label="Exchange"
|
||||
onChange={(e) => setExchangeId(e.target.value as number)}
|
||||
>
|
||||
{exchanges.map((ex) => (
|
||||
<MenuItem key={ex.id} value={ex.id}>
|
||||
{ex.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Timeframes</InputLabel>
|
||||
<Select
|
||||
multiple
|
||||
value={timeframes}
|
||||
label="Timeframes"
|
||||
onChange={(e) => setTimeframes(e.target.value as string[])}
|
||||
renderValue={(selected) => (selected as string[]).join(', ')}
|
||||
>
|
||||
{TIMEFRAMES.map((tf) => (
|
||||
<MenuItem key={tf} value={tf}>
|
||||
{tf}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={paperTrading}
|
||||
onChange={(e) => setPaperTrading(e.target.checked)}
|
||||
/>
|
||||
}
|
||||
label="Paper Trading Mode"
|
||||
/>
|
||||
<Typography variant="caption" display="block" color="text.secondary">
|
||||
Paper trading uses virtual funds for testing
|
||||
</Typography>
|
||||
</Grid>
|
||||
{strategy && (
|
||||
<Grid item xs={12}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={enabled}
|
||||
onChange={(e) => setEnabled(e.target.checked)}
|
||||
/>
|
||||
}
|
||||
label="Enable for Autopilot"
|
||||
/>
|
||||
<Typography variant="caption" display="block" color="text.secondary">
|
||||
Make this strategy available for ML-based selection
|
||||
</Typography>
|
||||
</Grid>
|
||||
)}
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{tabValue === 1 && (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<StrategyParameterForm
|
||||
strategyType={strategyType}
|
||||
parameters={parameters}
|
||||
onChange={setParameters}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{tabValue === 2 && (
|
||||
<Grid container spacing={2} sx={{ mt: 1 }}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Position Size (%)"
|
||||
type="number"
|
||||
value={parameters.position_size_percent || 10}
|
||||
onChange={(e) =>
|
||||
setParameters({
|
||||
...parameters,
|
||||
position_size_percent: parseFloat(e.target.value) || 10,
|
||||
})
|
||||
}
|
||||
inputProps={{ min: 0.1, max: 100, step: 0.1 }}
|
||||
helperText="Percentage of capital to use per trade"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Stop Loss (%)"
|
||||
type="number"
|
||||
value={parameters.stop_loss_percent || 5}
|
||||
onChange={(e) =>
|
||||
setParameters({
|
||||
...parameters,
|
||||
stop_loss_percent: parseFloat(e.target.value) || 5,
|
||||
})
|
||||
}
|
||||
inputProps={{ min: 0.1, max: 50, step: 0.1 }}
|
||||
helperText="Maximum loss percentage before exit"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Take Profit (%)"
|
||||
type="number"
|
||||
value={parameters.take_profit_percent || 10}
|
||||
onChange={(e) =>
|
||||
setParameters({
|
||||
...parameters,
|
||||
take_profit_percent: parseFloat(e.target.value) || 10,
|
||||
})
|
||||
}
|
||||
inputProps={{ min: 0.1, max: 100, step: 0.1 }}
|
||||
helperText="Profit target percentage"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Max Position Size"
|
||||
type="number"
|
||||
value={parameters.max_position_size || ''}
|
||||
onChange={(e) =>
|
||||
setParameters({
|
||||
...parameters,
|
||||
max_position_size: e.target.value ? parseFloat(e.target.value) : undefined,
|
||||
})
|
||||
}
|
||||
inputProps={{ min: 0, step: 0.01 }}
|
||||
helperText="Maximum position size (optional)"
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{(createMutation.isError || updateMutation.isError) && (
|
||||
<Alert severity="error" sx={{ mt: 2 }}>
|
||||
{createMutation.error instanceof Error
|
||||
? createMutation.error.message
|
||||
: updateMutation.error instanceof Error
|
||||
? updateMutation.error.message
|
||||
: 'Failed to save strategy'}
|
||||
</Alert>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleSave}
|
||||
disabled={createMutation.isPending || updateMutation.isPending}
|
||||
>
|
||||
{createMutation.isPending || updateMutation.isPending
|
||||
? 'Saving...'
|
||||
: strategy
|
||||
? 'Update'
|
||||
: 'Create'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
792
frontend/src/components/StrategyParameterForm.tsx
Normal file
792
frontend/src/components/StrategyParameterForm.tsx
Normal file
@@ -0,0 +1,792 @@
|
||||
import { Grid, TextField, FormControl, InputLabel, Select, MenuItem, Typography, Box } from '@mui/material'
|
||||
import { Info } from '@mui/icons-material'
|
||||
|
||||
interface StrategyParameterFormProps {
|
||||
strategyType: string
|
||||
parameters: Record<string, any>
|
||||
onChange: (parameters: Record<string, any>) => void
|
||||
}
|
||||
|
||||
export default function StrategyParameterForm({
|
||||
strategyType,
|
||||
parameters,
|
||||
onChange,
|
||||
}: StrategyParameterFormProps) {
|
||||
const updateParameter = (key: string, value: any) => {
|
||||
onChange({ ...parameters, [key]: value })
|
||||
}
|
||||
|
||||
const renderRSIParameters = () => (
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} md={4}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="RSI Period"
|
||||
type="number"
|
||||
value={parameters.rsi_period || 14}
|
||||
onChange={(e) => updateParameter('rsi_period', parseInt(e.target.value) || 14)}
|
||||
inputProps={{ min: 2, max: 100 }}
|
||||
helperText="Period for RSI calculation"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Oversold Threshold"
|
||||
type="number"
|
||||
value={parameters.oversold || 30}
|
||||
onChange={(e) => updateParameter('oversold', parseFloat(e.target.value) || 30)}
|
||||
inputProps={{ min: 0, max: 50 }}
|
||||
helperText="RSI level considered oversold"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Overbought Threshold"
|
||||
type="number"
|
||||
value={parameters.overbought || 70}
|
||||
onChange={(e) => updateParameter('overbought', parseFloat(e.target.value) || 70)}
|
||||
inputProps={{ min: 50, max: 100 }}
|
||||
helperText="RSI level considered overbought"
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
)
|
||||
|
||||
const renderMACDParameters = () => (
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} md={4}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Fast Period"
|
||||
type="number"
|
||||
value={parameters.fast_period || 12}
|
||||
onChange={(e) => updateParameter('fast_period', parseInt(e.target.value) || 12)}
|
||||
inputProps={{ min: 1, max: 50 }}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Slow Period"
|
||||
type="number"
|
||||
value={parameters.slow_period || 26}
|
||||
onChange={(e) => updateParameter('slow_period', parseInt(e.target.value) || 26)}
|
||||
inputProps={{ min: 1, max: 100 }}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Signal Period"
|
||||
type="number"
|
||||
value={parameters.signal_period || 9}
|
||||
onChange={(e) => updateParameter('signal_period', parseInt(e.target.value) || 9)}
|
||||
inputProps={{ min: 1, max: 50 }}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
)
|
||||
|
||||
const renderMovingAverageParameters = () => (
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} md={4}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Short MA Period"
|
||||
type="number"
|
||||
value={parameters.short_period || 20}
|
||||
onChange={(e) => updateParameter('short_period', parseInt(e.target.value) || 20)}
|
||||
inputProps={{ min: 1, max: 200 }}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Long MA Period"
|
||||
type="number"
|
||||
value={parameters.long_period || 50}
|
||||
onChange={(e) => updateParameter('long_period', parseInt(e.target.value) || 50)}
|
||||
inputProps={{ min: 1, max: 200 }}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>MA Type</InputLabel>
|
||||
<Select
|
||||
value={parameters.ma_type || 'ema'}
|
||||
label="MA Type"
|
||||
onChange={(e) => updateParameter('ma_type', e.target.value)}
|
||||
>
|
||||
<MenuItem value="sma">SMA (Simple)</MenuItem>
|
||||
<MenuItem value="ema">EMA (Exponential)</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
</Grid>
|
||||
)
|
||||
|
||||
const renderDCAParameters = () => (
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Amount per Interval"
|
||||
type="number"
|
||||
value={parameters.amount || 10}
|
||||
onChange={(e) => updateParameter('amount', parseFloat(e.target.value) || 10)}
|
||||
inputProps={{ min: 0.01, step: 0.01 }}
|
||||
helperText="Fixed amount to invest per interval"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Interval</InputLabel>
|
||||
<Select
|
||||
value={parameters.interval || 'daily'}
|
||||
label="Interval"
|
||||
onChange={(e) => updateParameter('interval', e.target.value)}
|
||||
>
|
||||
<MenuItem value="daily">Daily</MenuItem>
|
||||
<MenuItem value="weekly">Weekly</MenuItem>
|
||||
<MenuItem value="monthly">Monthly</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Target Allocation (%)"
|
||||
type="number"
|
||||
value={parameters.target_allocation || 10}
|
||||
onChange={(e) => updateParameter('target_allocation', parseFloat(e.target.value) || 10)}
|
||||
inputProps={{ min: 0.1, max: 100, step: 0.1 }}
|
||||
helperText="Target portfolio allocation percentage"
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
)
|
||||
|
||||
const renderGridParameters = () => (
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Grid Spacing (%)"
|
||||
type="number"
|
||||
value={parameters.grid_spacing || 1}
|
||||
onChange={(e) => updateParameter('grid_spacing', parseFloat(e.target.value) || 1)}
|
||||
inputProps={{ min: 0.1, max: 10, step: 0.1 }}
|
||||
helperText="Percentage spacing between grid levels"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Number of Levels"
|
||||
type="number"
|
||||
value={parameters.num_levels || 10}
|
||||
onChange={(e) => updateParameter('num_levels', parseInt(e.target.value) || 10)}
|
||||
inputProps={{ min: 1, max: 50 }}
|
||||
helperText="Grid levels above and below center"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Profit Target (%)"
|
||||
type="number"
|
||||
value={parameters.profit_target || 2}
|
||||
onChange={(e) => updateParameter('profit_target', parseFloat(e.target.value) || 2)}
|
||||
inputProps={{ min: 0.1, max: 50, step: 0.1 }}
|
||||
helperText="Profit percentage to take"
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
)
|
||||
|
||||
const renderMomentumParameters = () => (
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Lookback Period"
|
||||
type="number"
|
||||
value={parameters.lookback_period || 20}
|
||||
onChange={(e) => updateParameter('lookback_period', parseInt(e.target.value) || 20)}
|
||||
inputProps={{ min: 1, max: 100 }}
|
||||
helperText="Period for momentum calculation"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Momentum Threshold"
|
||||
type="number"
|
||||
value={parameters.momentum_threshold || 0.05}
|
||||
onChange={(e) => updateParameter('momentum_threshold', parseFloat(e.target.value) || 0.05)}
|
||||
inputProps={{ min: 0, max: 1, step: 0.01 }}
|
||||
helperText="Minimum momentum to enter (0.05 = 5%)"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Volume Threshold"
|
||||
type="number"
|
||||
value={parameters.volume_threshold || 1.5}
|
||||
onChange={(e) => updateParameter('volume_threshold', parseFloat(e.target.value) || 1.5)}
|
||||
inputProps={{ min: 1, max: 10, step: 0.1 }}
|
||||
helperText="Volume increase multiplier for confirmation"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Exit Threshold"
|
||||
type="number"
|
||||
value={parameters.exit_threshold || -0.02}
|
||||
onChange={(e) => updateParameter('exit_threshold', parseFloat(e.target.value) || -0.02)}
|
||||
inputProps={{ min: -1, max: 0, step: 0.01 }}
|
||||
helperText="Momentum reversal threshold for exit"
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
)
|
||||
|
||||
const renderConfirmedParameters = () => (
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
RSI Parameters
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="RSI Period"
|
||||
type="number"
|
||||
value={parameters.rsi_period || 14}
|
||||
onChange={(e) => updateParameter('rsi_period', parseInt(e.target.value) || 14)}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="RSI Oversold"
|
||||
type="number"
|
||||
value={parameters.rsi_oversold || 30}
|
||||
onChange={(e) => updateParameter('rsi_oversold', parseFloat(e.target.value) || 30)}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="RSI Overbought"
|
||||
type="number"
|
||||
value={parameters.rsi_overbought || 70}
|
||||
onChange={(e) => updateParameter('rsi_overbought', parseFloat(e.target.value) || 70)}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="subtitle2" gutterBottom sx={{ mt: 2 }}>
|
||||
MACD Parameters
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="MACD Fast"
|
||||
type="number"
|
||||
value={parameters.macd_fast || 12}
|
||||
onChange={(e) => updateParameter('macd_fast', parseInt(e.target.value) || 12)}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="MACD Slow"
|
||||
type="number"
|
||||
value={parameters.macd_slow || 26}
|
||||
onChange={(e) => updateParameter('macd_slow', parseInt(e.target.value) || 26)}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="MACD Signal"
|
||||
type="number"
|
||||
value={parameters.macd_signal || 9}
|
||||
onChange={(e) => updateParameter('macd_signal', parseInt(e.target.value) || 9)}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="subtitle2" gutterBottom sx={{ mt: 2 }}>
|
||||
Moving Average Parameters
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="MA Fast Period"
|
||||
type="number"
|
||||
value={parameters.ma_fast || 10}
|
||||
onChange={(e) => updateParameter('ma_fast', parseInt(e.target.value) || 10)}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="MA Slow Period"
|
||||
type="number"
|
||||
value={parameters.ma_slow || 30}
|
||||
onChange={(e) => updateParameter('ma_slow', parseInt(e.target.value) || 30)}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>MA Type</InputLabel>
|
||||
<Select
|
||||
value={parameters.ma_type || 'ema'}
|
||||
label="MA Type"
|
||||
onChange={(e) => updateParameter('ma_type', e.target.value)}
|
||||
>
|
||||
<MenuItem value="sma">SMA</MenuItem>
|
||||
<MenuItem value="ema">EMA</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="subtitle2" gutterBottom sx={{ mt: 2 }}>
|
||||
Confirmation Settings
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Min Confirmations"
|
||||
type="number"
|
||||
value={parameters.min_confirmations || 2}
|
||||
onChange={(e) => updateParameter('min_confirmations', parseInt(e.target.value) || 2)}
|
||||
inputProps={{ min: 1, max: 3 }}
|
||||
helperText="Minimum indicators that must agree"
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
)
|
||||
|
||||
const renderDivergenceParameters = () => (
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Indicator Type</InputLabel>
|
||||
<Select
|
||||
value={parameters.indicator_type || 'rsi'}
|
||||
label="Indicator Type"
|
||||
onChange={(e) => updateParameter('indicator_type', e.target.value)}
|
||||
>
|
||||
<MenuItem value="rsi">RSI</MenuItem>
|
||||
<MenuItem value="macd">MACD</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Lookback Period"
|
||||
type="number"
|
||||
value={parameters.lookback_period || 20}
|
||||
onChange={(e) => updateParameter('lookback_period', parseInt(e.target.value) || 20)}
|
||||
inputProps={{ min: 5, max: 100 }}
|
||||
helperText="Period for swing detection"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Min Swings Required"
|
||||
type="number"
|
||||
value={parameters.min_swings || 2}
|
||||
onChange={(e) => updateParameter('min_swings', parseInt(e.target.value) || 2)}
|
||||
inputProps={{ min: 1, max: 10 }}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Confidence Threshold"
|
||||
type="number"
|
||||
value={parameters.confidence_threshold || 0.5}
|
||||
onChange={(e) => updateParameter('confidence_threshold', parseFloat(e.target.value) || 0.5)}
|
||||
inputProps={{ min: 0, max: 1, step: 0.1 }}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
)
|
||||
|
||||
const renderBollingerParameters = () => (
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Period"
|
||||
type="number"
|
||||
value={parameters.period || 20}
|
||||
onChange={(e) => updateParameter('period', parseInt(e.target.value) || 20)}
|
||||
inputProps={{ min: 5, max: 100 }}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Std Dev Multiplier"
|
||||
type="number"
|
||||
value={parameters.std_dev_multiplier || 2.0}
|
||||
onChange={(e) => updateParameter('std_dev_multiplier', parseFloat(e.target.value) || 2.0)}
|
||||
inputProps={{ min: 1, max: 5, step: 0.1 }}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Trend MA Period"
|
||||
type="number"
|
||||
value={parameters.trend_ma_period || 50}
|
||||
onChange={(e) => updateParameter('trend_ma_period', parseInt(e.target.value) || 50)}
|
||||
inputProps={{ min: 10, max: 200 }}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Entry Threshold"
|
||||
type="number"
|
||||
value={parameters.entry_threshold || 0.95}
|
||||
onChange={(e) => updateParameter('entry_threshold', parseFloat(e.target.value) || 0.95)}
|
||||
inputProps={{ min: 0, max: 1, step: 0.01 }}
|
||||
helperText="How close to band (0.95 = 95%)"
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
)
|
||||
|
||||
const renderConsensusParameters = () => (
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Min Consensus Count"
|
||||
type="number"
|
||||
value={parameters.min_consensus_count || 2}
|
||||
onChange={(e) => updateParameter('min_consensus_count', parseInt(e.target.value) || 2)}
|
||||
inputProps={{ min: 1, max: 10 }}
|
||||
helperText="Minimum strategies that must agree"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Min Weight Threshold"
|
||||
type="number"
|
||||
value={parameters.min_weight_threshold || 0.3}
|
||||
onChange={(e) => updateParameter('min_weight_threshold', parseFloat(e.target.value) || 0.3)}
|
||||
inputProps={{ min: 0, max: 1, step: 0.1 }}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
)
|
||||
|
||||
const renderPairsTradingParameters = () => (
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} md={12}>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
Statistical Arbitrage trades the spread between the main symbol and a second symbol.
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Second Symbol"
|
||||
value={parameters.second_symbol || 'ETH/USD'}
|
||||
onChange={(e) => updateParameter('second_symbol', e.target.value)}
|
||||
helperText="The correlated asset to pair with"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Lookback Period"
|
||||
type="number"
|
||||
value={parameters.lookback_period || 20}
|
||||
onChange={(e) => updateParameter('lookback_period', parseInt(e.target.value) || 20)}
|
||||
inputProps={{ min: 5, max: 100 }}
|
||||
helperText="Rolling window for Z-Score calc"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Z-Score Threshold"
|
||||
type="number"
|
||||
value={parameters.z_score_threshold || 2.0}
|
||||
onChange={(e) => updateParameter('z_score_threshold', parseFloat(e.target.value) || 2.0)}
|
||||
inputProps={{ min: 1.0, max: 5.0, step: 0.1 }}
|
||||
helperText="Entry trigger (Standard Deviations)"
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
)
|
||||
|
||||
const renderVolatilityBreakoutParameters = () => (
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} md={12}>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
Captures explosive moves after periods of low volatility (squeeze).
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="BB Period"
|
||||
type="number"
|
||||
value={parameters.bb_period || 20}
|
||||
onChange={(e) => updateParameter('bb_period', parseInt(e.target.value) || 20)}
|
||||
inputProps={{ min: 5, max: 50 }}
|
||||
helperText="Bollinger Bands period"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="BB Std Dev"
|
||||
type="number"
|
||||
value={parameters.bb_std_dev || 2.0}
|
||||
onChange={(e) => updateParameter('bb_std_dev', parseFloat(e.target.value) || 2.0)}
|
||||
inputProps={{ min: 1.0, max: 4.0, step: 0.1 }}
|
||||
helperText="Standard deviation multiplier"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Squeeze Threshold"
|
||||
type="number"
|
||||
value={parameters.squeeze_threshold || 0.1}
|
||||
onChange={(e) => updateParameter('squeeze_threshold', parseFloat(e.target.value) || 0.1)}
|
||||
inputProps={{ min: 0.01, max: 0.5, step: 0.01 }}
|
||||
helperText="BB Width for squeeze detection"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Volume Multiplier"
|
||||
type="number"
|
||||
value={parameters.volume_multiplier || 1.5}
|
||||
onChange={(e) => updateParameter('volume_multiplier', parseFloat(e.target.value) || 1.5)}
|
||||
inputProps={{ min: 1.0, max: 5.0, step: 0.1 }}
|
||||
helperText="Min volume vs 20-day avg"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Min ADX"
|
||||
type="number"
|
||||
value={parameters.min_adx || 25}
|
||||
onChange={(e) => updateParameter('min_adx', parseFloat(e.target.value) || 25)}
|
||||
inputProps={{ min: 10, max: 50 }}
|
||||
helperText="Trend strength filter"
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
)
|
||||
|
||||
const renderSentimentParameters = () => (
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} md={12}>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
Trades based on news sentiment and Fear & Greed Index.
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Mode</InputLabel>
|
||||
<Select
|
||||
value={parameters.mode || 'contrarian'}
|
||||
label="Mode"
|
||||
onChange={(e) => updateParameter('mode', e.target.value)}
|
||||
>
|
||||
<MenuItem value="contrarian">Contrarian (Buy Fear, Sell Greed)</MenuItem>
|
||||
<MenuItem value="momentum">Momentum (Follow Sentiment)</MenuItem>
|
||||
<MenuItem value="combo">Combo (News + Fear = Buy)</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Min Sentiment Score"
|
||||
type="number"
|
||||
value={parameters.min_sentiment_score || 0.5}
|
||||
onChange={(e) => updateParameter('min_sentiment_score', parseFloat(e.target.value) || 0.5)}
|
||||
inputProps={{ min: 0.1, max: 1.0, step: 0.1 }}
|
||||
helperText="Threshold for momentum signals"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Fear Threshold"
|
||||
type="number"
|
||||
value={parameters.fear_threshold || 25}
|
||||
onChange={(e) => updateParameter('fear_threshold', parseInt(e.target.value) || 25)}
|
||||
inputProps={{ min: 0, max: 50 }}
|
||||
helperText="F&G value for 'extreme fear'"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Greed Threshold"
|
||||
type="number"
|
||||
value={parameters.greed_threshold || 75}
|
||||
onChange={(e) => updateParameter('greed_threshold', parseInt(e.target.value) || 75)}
|
||||
inputProps={{ min: 50, max: 100 }}
|
||||
helperText="F&G value for 'extreme greed'"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="News Lookback (hours)"
|
||||
type="number"
|
||||
value={parameters.news_lookback_hours || 24}
|
||||
onChange={(e) => updateParameter('news_lookback_hours', parseInt(e.target.value) || 24)}
|
||||
inputProps={{ min: 1, max: 72 }}
|
||||
helperText="How far back to analyze news"
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
)
|
||||
|
||||
const renderMarketMakingParameters = () => (
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} md={12}>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
Places limit orders on both sides of the spread. Best for ranging markets.
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Spread %"
|
||||
type="number"
|
||||
value={parameters.spread_percent || 0.2}
|
||||
onChange={(e) => updateParameter('spread_percent', parseFloat(e.target.value) || 0.2)}
|
||||
inputProps={{ min: 0.05, max: 2.0, step: 0.05 }}
|
||||
helperText="Distance from mid price"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Requote Threshold %"
|
||||
type="number"
|
||||
value={parameters.requote_threshold || 0.5}
|
||||
onChange={(e) => updateParameter('requote_threshold', parseFloat(e.target.value) || 0.5)}
|
||||
inputProps={{ min: 0.1, max: 2.0, step: 0.1 }}
|
||||
helperText="Price move to trigger requote"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Max Inventory"
|
||||
type="number"
|
||||
value={parameters.max_inventory || 1.0}
|
||||
onChange={(e) => updateParameter('max_inventory', parseFloat(e.target.value) || 1.0)}
|
||||
inputProps={{ min: 0.1, max: 10, step: 0.1 }}
|
||||
helperText="Max position before skewing"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Inventory Skew Factor"
|
||||
type="number"
|
||||
value={parameters.inventory_skew_factor || 0.5}
|
||||
onChange={(e) => updateParameter('inventory_skew_factor', parseFloat(e.target.value) || 0.5)}
|
||||
inputProps={{ min: 0, max: 1, step: 0.1 }}
|
||||
helperText="How much to skew quotes"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Max ADX (Trend Filter)"
|
||||
type="number"
|
||||
value={parameters.min_adx || 20}
|
||||
onChange={(e) => updateParameter('min_adx', parseInt(e.target.value) || 20)}
|
||||
inputProps={{ min: 10, max: 40 }}
|
||||
helperText="Skip if ADX above this"
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
)
|
||||
|
||||
const renderParameters = () => {
|
||||
switch (strategyType) {
|
||||
case 'rsi':
|
||||
return renderRSIParameters()
|
||||
case 'macd':
|
||||
return renderMACDParameters()
|
||||
case 'moving_average':
|
||||
return renderMovingAverageParameters()
|
||||
case 'confirmed':
|
||||
return renderConfirmedParameters()
|
||||
case 'divergence':
|
||||
return renderDivergenceParameters()
|
||||
case 'bollinger_mean_reversion':
|
||||
return renderBollingerParameters()
|
||||
case 'consensus':
|
||||
return renderConsensusParameters()
|
||||
case 'dca':
|
||||
return renderDCAParameters()
|
||||
case 'grid':
|
||||
return renderGridParameters()
|
||||
case 'momentum':
|
||||
return renderMomentumParameters()
|
||||
case 'pairs_trading':
|
||||
return renderPairsTradingParameters()
|
||||
case 'volatility_breakout':
|
||||
return renderVolatilityBreakoutParameters()
|
||||
case 'sentiment':
|
||||
return renderSentimentParameters()
|
||||
case 'market_making':
|
||||
return renderMarketMakingParameters()
|
||||
default:
|
||||
return (
|
||||
<Box sx={{ p: 2 }}>
|
||||
<Typography color="text.secondary">
|
||||
No specific parameters for this strategy type.
|
||||
</Typography>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ mb: 2, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Info color="action" fontSize="small" />
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Configure strategy-specific parameters. Default values are provided.
|
||||
</Typography>
|
||||
</Box>
|
||||
{renderParameters()}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
83
frontend/src/components/SystemHealth.tsx
Normal file
83
frontend/src/components/SystemHealth.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { Card, CardContent, Typography, Box, Grid, Chip } from '@mui/material'
|
||||
import { CheckCircle, Error, Warning } from '@mui/icons-material'
|
||||
import StatusIndicator from './StatusIndicator'
|
||||
|
||||
interface SystemHealthProps {
|
||||
websocketStatus: 'connected' | 'disconnected' | 'error'
|
||||
exchangeStatuses?: Array<{ name: string; status: 'connected' | 'disconnected' | 'error' }>
|
||||
databaseStatus?: 'connected' | 'disconnected' | 'error'
|
||||
}
|
||||
|
||||
export default function SystemHealth({
|
||||
websocketStatus,
|
||||
exchangeStatuses = [],
|
||||
databaseStatus = 'connected',
|
||||
}: SystemHealthProps) {
|
||||
const getOverallHealth = () => {
|
||||
const statuses = [
|
||||
websocketStatus,
|
||||
databaseStatus,
|
||||
...exchangeStatuses.map((e) => e.status),
|
||||
]
|
||||
|
||||
if (statuses.some((s) => s === 'error')) return 'error'
|
||||
if (statuses.some((s) => s === 'disconnected')) return 'warning'
|
||||
return 'connected'
|
||||
}
|
||||
|
||||
const overallHealth = getOverallHealth()
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
System Health
|
||||
</Typography>
|
||||
<Grid container spacing={2} sx={{ mt: 1 }}>
|
||||
<Grid item xs={12} md={4}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Overall Status:
|
||||
</Typography>
|
||||
<StatusIndicator
|
||||
status={overallHealth}
|
||||
label={overallHealth === 'connected' ? 'Healthy' : overallHealth === 'error' ? 'Error' : 'Warning'}
|
||||
/>
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<StatusIndicator
|
||||
status={websocketStatus}
|
||||
label="WebSocket"
|
||||
tooltip="Real-time data connection status"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<StatusIndicator
|
||||
status={databaseStatus}
|
||||
label="Database"
|
||||
tooltip="Database connection status"
|
||||
/>
|
||||
</Grid>
|
||||
{exchangeStatuses.length > 0 && (
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="body2" color="text.secondary" gutterBottom>
|
||||
Exchanges:
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
|
||||
{exchangeStatuses.map((exchange) => (
|
||||
<StatusIndicator
|
||||
key={exchange.name}
|
||||
status={exchange.status}
|
||||
label={exchange.name}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Grid>
|
||||
)}
|
||||
</Grid>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
31
frontend/src/components/WebSocketProvider.tsx
Normal file
31
frontend/src/components/WebSocketProvider.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { createContext, useContext, ReactNode } from 'react'
|
||||
import { useWebSocket, WebSocketMessage } from '../hooks/useWebSocket'
|
||||
|
||||
interface WebSocketContextType {
|
||||
isConnected: boolean
|
||||
lastMessage: WebSocketMessage | null
|
||||
messageHistory: WebSocketMessage[]
|
||||
sendMessage: (message: any) => void
|
||||
subscribe: (messageType: string, handler: (message: WebSocketMessage) => void) => () => void
|
||||
}
|
||||
|
||||
const WebSocketContext = createContext<WebSocketContextType | undefined>(undefined)
|
||||
|
||||
export function WebSocketProvider({ children }: { children: ReactNode }) {
|
||||
const wsUrl = import.meta.env.VITE_WS_URL || 'ws://localhost:8000/ws/'
|
||||
const { isConnected, lastMessage, messageHistory, sendMessage, subscribe } = useWebSocket(wsUrl)
|
||||
|
||||
return (
|
||||
<WebSocketContext.Provider value={{ isConnected, lastMessage, messageHistory, sendMessage, subscribe }}>
|
||||
{children}
|
||||
</WebSocketContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useWebSocketContext() {
|
||||
const context = useContext(WebSocketContext)
|
||||
if (!context) {
|
||||
throw new Error('useWebSocketContext must be used within WebSocketProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
2
frontend/src/components/__init__.ts
Normal file
2
frontend/src/components/__init__.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as Layout } from './Layout'
|
||||
export { WebSocketProvider, useWebSocketContext } from './WebSocketProvider'
|
||||
65
frontend/src/components/__tests__/ErrorDisplay.test.tsx
Normal file
65
frontend/src/components/__tests__/ErrorDisplay.test.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import ErrorDisplay from '../ErrorDisplay'
|
||||
|
||||
describe('ErrorDisplay', () => {
|
||||
describe('error message rendering', () => {
|
||||
it('renders error string message', () => {
|
||||
render(<ErrorDisplay error="Test error message" />)
|
||||
expect(screen.getByText('Test error message')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders Error object message', () => {
|
||||
const error = new Error('Error object message')
|
||||
render(<ErrorDisplay error={error} />)
|
||||
expect(screen.getByText('Error object message')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders default title', () => {
|
||||
render(<ErrorDisplay error="Test error" />)
|
||||
expect(screen.getByText('Error')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders custom title', () => {
|
||||
render(<ErrorDisplay error="Test error" title="Custom Error Title" />)
|
||||
expect(screen.getByText('Custom Error Title')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('retry functionality', () => {
|
||||
it('does not show retry button when onRetry is not provided', () => {
|
||||
render(<ErrorDisplay error="Test error" />)
|
||||
expect(screen.queryByText('Retry')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows retry button when onRetry is provided', () => {
|
||||
const onRetry = vi.fn()
|
||||
render(<ErrorDisplay error="Test error" onRetry={onRetry} />)
|
||||
expect(screen.getByText('Retry')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onRetry when retry button is clicked', () => {
|
||||
const onRetry = vi.fn()
|
||||
render(<ErrorDisplay error="Test error" onRetry={onRetry} />)
|
||||
|
||||
fireEvent.click(screen.getByText('Retry'))
|
||||
|
||||
expect(onRetry).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('technical details', () => {
|
||||
it('shows technical details section for Error object with stack', () => {
|
||||
const error = new Error('Error with stack')
|
||||
render(<ErrorDisplay error={error} />)
|
||||
|
||||
expect(screen.getByText('Technical Details')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not show technical details for string errors', () => {
|
||||
render(<ErrorDisplay error="String error" />)
|
||||
|
||||
expect(screen.queryByText('Technical Details')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
166
frontend/src/components/__tests__/PositionCard.test.tsx
Normal file
166
frontend/src/components/__tests__/PositionCard.test.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import PositionCard from '../PositionCard'
|
||||
import { PositionResponse, OrderType } from '../../types'
|
||||
import * as tradingApi from '../../api/trading'
|
||||
|
||||
vi.mock('../../api/trading')
|
||||
vi.mock('../../contexts/SnackbarContext', () => ({
|
||||
useSnackbar: () => ({
|
||||
showError: vi.fn(),
|
||||
showSuccess: vi.fn(),
|
||||
showWarning: vi.fn(),
|
||||
showInfo: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
const mockPosition: PositionResponse = {
|
||||
id: 1,
|
||||
symbol: 'BTC/USD',
|
||||
quantity: 0.5,
|
||||
entry_price: 40000,
|
||||
current_price: 42000,
|
||||
unrealized_pnl: 1000,
|
||||
realized_pnl: 500,
|
||||
side: 'long',
|
||||
opened_at: '2024-01-01T00:00:00Z',
|
||||
}
|
||||
|
||||
describe('PositionCard', () => {
|
||||
let queryClient: QueryClient
|
||||
|
||||
beforeEach(() => {
|
||||
queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
})
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
const renderComponent = (position = mockPosition, paperTrading = false) => {
|
||||
const onClose = vi.fn()
|
||||
return {
|
||||
onClose,
|
||||
...render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<PositionCard position={position} paperTrading={paperTrading} onClose={onClose} />
|
||||
</QueryClientProvider>
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
describe('position data display', () => {
|
||||
it('renders position symbol', () => {
|
||||
renderComponent()
|
||||
expect(screen.getByText('BTC/USD')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders position quantity', () => {
|
||||
renderComponent()
|
||||
expect(screen.getByText('0.50000000')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders entry price', () => {
|
||||
renderComponent()
|
||||
expect(screen.getByText('$40000.00')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders current price', () => {
|
||||
renderComponent()
|
||||
expect(screen.getByText('$42000.00')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('PnL display', () => {
|
||||
it('displays positive unrealized PnL with plus sign', () => {
|
||||
renderComponent()
|
||||
expect(screen.getByText('+$1000.00')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('displays positive realized PnL with plus sign', () => {
|
||||
renderComponent()
|
||||
expect(screen.getByText('+$500.00')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('displays negative unrealized PnL correctly', () => {
|
||||
const negativePosition = { ...mockPosition, unrealized_pnl: -500 }
|
||||
renderComponent(negativePosition)
|
||||
expect(screen.getByText('-$500.00')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows positive percent chip for profitable position', () => {
|
||||
renderComponent()
|
||||
expect(screen.getByText('+5.00%')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows negative percent chip for losing position', () => {
|
||||
const losingPosition = { ...mockPosition, current_price: 38000 }
|
||||
renderComponent(losingPosition)
|
||||
expect(screen.getByText('-5.00%')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('close position functionality', () => {
|
||||
it('shows close position button', () => {
|
||||
renderComponent()
|
||||
expect(screen.getByText('Close Position')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens close dialog when button is clicked', async () => {
|
||||
renderComponent()
|
||||
|
||||
fireEvent.click(screen.getByText('Close Position'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows order type selector in dialog', async () => {
|
||||
renderComponent()
|
||||
|
||||
fireEvent.click(screen.getByText('Close Position'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText('Order Type')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('closes dialog when cancel is clicked', async () => {
|
||||
renderComponent()
|
||||
|
||||
fireEvent.click(screen.getByText('Close Position'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByText('Cancel'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('submits market order when confirmed', async () => {
|
||||
const mockCreateOrder = vi.fn().mockResolvedValue({})
|
||||
vi.mocked(tradingApi.tradingApi.createOrder).mockImplementation(mockCreateOrder)
|
||||
|
||||
renderComponent()
|
||||
|
||||
fireEvent.click(screen.getByText('Close Position'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Click the confirm button (second "Close Position" button in dialog)
|
||||
const buttons = screen.getAllByText('Close Position')
|
||||
fireEvent.click(buttons[1])
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCreateOrder).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
71
frontend/src/components/__tests__/StatusIndicator.test.tsx
Normal file
71
frontend/src/components/__tests__/StatusIndicator.test.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import StatusIndicator from '../StatusIndicator'
|
||||
|
||||
describe('StatusIndicator', () => {
|
||||
describe('rendering', () => {
|
||||
it('renders with label', () => {
|
||||
render(<StatusIndicator status="connected" label="Test Label" />)
|
||||
expect(screen.getByText('Test Label')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders tooltip when provided', () => {
|
||||
render(<StatusIndicator status="connected" label="Test" tooltip="Tooltip text" />)
|
||||
expect(screen.getByText('Test')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('status colors', () => {
|
||||
it('shows success color for connected status', () => {
|
||||
render(<StatusIndicator status="connected" label="Connected" />)
|
||||
const chip = screen.getByText('Connected').closest('.MuiChip-root')
|
||||
expect(chip).toHaveClass('MuiChip-colorSuccess')
|
||||
})
|
||||
|
||||
it('shows error color for error status', () => {
|
||||
render(<StatusIndicator status="error" label="Error" />)
|
||||
const chip = screen.getByText('Error').closest('.MuiChip-root')
|
||||
expect(chip).toHaveClass('MuiChip-colorError')
|
||||
})
|
||||
|
||||
it('shows warning color for warning status', () => {
|
||||
render(<StatusIndicator status="warning" label="Warning" />)
|
||||
const chip = screen.getByText('Warning').closest('.MuiChip-root')
|
||||
expect(chip).toHaveClass('MuiChip-colorWarning')
|
||||
})
|
||||
|
||||
it('shows default color for disconnected status', () => {
|
||||
render(<StatusIndicator status="disconnected" label="Disconnected" />)
|
||||
const chip = screen.getByText('Disconnected').closest('.MuiChip-root')
|
||||
expect(chip).toHaveClass('MuiChip-colorDefault')
|
||||
})
|
||||
|
||||
it('shows default color for unknown status', () => {
|
||||
render(<StatusIndicator status="unknown" label="Unknown" />)
|
||||
const chip = screen.getByText('Unknown').closest('.MuiChip-root')
|
||||
expect(chip).toHaveClass('MuiChip-colorDefault')
|
||||
})
|
||||
})
|
||||
|
||||
describe('icons', () => {
|
||||
it('renders CheckCircle icon for connected status', () => {
|
||||
render(<StatusIndicator status="connected" label="Connected" />)
|
||||
expect(document.querySelector('[data-testid="CheckCircleIcon"]')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders CloudOff icon for disconnected status', () => {
|
||||
render(<StatusIndicator status="disconnected" label="Disconnected" />)
|
||||
expect(document.querySelector('[data-testid="CloudOffIcon"]')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders Error icon for error status', () => {
|
||||
render(<StatusIndicator status="error" label="Error" />)
|
||||
expect(document.querySelector('[data-testid="ErrorIcon"]')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders Warning icon for warning status', () => {
|
||||
render(<StatusIndicator status="warning" label="Warning" />)
|
||||
expect(document.querySelector('[data-testid="WarningIcon"]')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user