254 lines
10 KiB
TypeScript
254 lines
10 KiB
TypeScript
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>
|
|
)
|
|
}
|