Local changes: Updated model training, removed debug instrumentation, and configuration improvements

This commit is contained in:
kfox
2025-12-26 01:15:43 -05:00
commit cc60da49e7
388 changed files with 57127 additions and 0 deletions

View 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>
)
}

View 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 }} />
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
</>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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
}

View File

@@ -0,0 +1,2 @@
export { default as Layout } from './Layout'
export { WebSocketProvider, useWebSocketContext } from './WebSocketProvider'

View 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()
})
})
})

View 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()
})
})
})
})

View 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()
})
})
})