diff --git a/src/modules/trading/components/MarketDepthPanel.tsx b/src/modules/trading/components/MarketDepthPanel.tsx new file mode 100644 index 0000000..95b6711 --- /dev/null +++ b/src/modules/trading/components/MarketDepthPanel.tsx @@ -0,0 +1,457 @@ +/** + * MarketDepthPanel Component + * Comprehensive market depth display with order book table and depth chart + * Epic: OQI-003 Trading Charts + */ + +import React, { useState, useCallback, useMemo } from 'react'; +import { + Bars3BottomLeftIcon, + ChartBarIcon, + TableCellsIcon, + ArrowPathIcon, + ArrowsUpDownIcon, + FunnelIcon, + ArrowDownTrayIcon, +} from '@heroicons/react/24/solid'; + +// Types +export interface OrderLevel { + price: number; + quantity: number; + total: number; + orders?: number; + exchange?: string; +} + +export interface MarketDepthData { + symbol: string; + bids: OrderLevel[]; + asks: OrderLevel[]; + midPrice: number; + spread: number; + spreadPercentage: number; + imbalance: number; // bid volume / ask volume ratio + lastUpdate: Date; +} + +export type ViewMode = 'table' | 'chart' | 'split'; +export type GroupingLevel = 'none' | '0.01' | '0.1' | '1' | '10' | '100'; + +interface MarketDepthPanelProps { + data: MarketDepthData | null; + onPriceClick?: (price: number, side: 'bid' | 'ask') => void; + onRefresh?: () => void; + onExport?: (format: 'csv' | 'json') => void; + isLoading?: boolean; + displayRows?: number; + compact?: boolean; +} + +const MarketDepthPanel: React.FC = ({ + data, + onPriceClick, + onRefresh, + onExport, + isLoading = false, + displayRows = 15, + compact = false, +}) => { + const [viewMode, setViewMode] = useState('table'); + const [grouping, setGrouping] = useState('none'); + const [showFilters, setShowFilters] = useState(false); + const [minQuantity, setMinQuantity] = useState(0); + const [highlightLarge, setHighlightLarge] = useState(true); + + // Group orders by price level + const groupedData = useMemo(() => { + if (!data || grouping === 'none') return data; + + const groupingValue = parseFloat(grouping); + + const groupOrders = (orders: OrderLevel[]): OrderLevel[] => { + const grouped = new Map(); + + for (const order of orders) { + const groupedPrice = Math.floor(order.price / groupingValue) * groupingValue; + const existing = grouped.get(groupedPrice); + + if (existing) { + existing.quantity += order.quantity; + existing.orders = (existing.orders || 1) + 1; + } else { + grouped.set(groupedPrice, { + price: groupedPrice, + quantity: order.quantity, + total: 0, + orders: 1, + }); + } + } + + // Recalculate totals + const result = Array.from(grouped.values()); + let runningTotal = 0; + for (const level of result) { + runningTotal += level.quantity; + level.total = runningTotal; + } + + return result; + }; + + return { + ...data, + bids: groupOrders(data.bids), + asks: groupOrders(data.asks), + }; + }, [data, grouping]); + + // Filter by minimum quantity + const filteredData = useMemo(() => { + if (!groupedData || minQuantity === 0) return groupedData; + + return { + ...groupedData, + bids: groupedData.bids.filter((b) => b.quantity >= minQuantity), + asks: groupedData.asks.filter((a) => a.quantity >= minQuantity), + }; + }, [groupedData, minQuantity]); + + // Calculate max quantities for bar visualization + const maxQuantity = useMemo(() => { + if (!filteredData) return 1; + const allQuantities = [ + ...filteredData.bids.map((b) => b.quantity), + ...filteredData.asks.map((a) => a.quantity), + ]; + return Math.max(...allQuantities, 1); + }, [filteredData]); + + // Detect large orders (> 2x average) + const largeOrderThreshold = useMemo(() => { + if (!filteredData) return 0; + const allQuantities = [ + ...filteredData.bids.map((b) => b.quantity), + ...filteredData.asks.map((a) => a.quantity), + ]; + const avg = allQuantities.reduce((a, b) => a + b, 0) / allQuantities.length; + return avg * 2; + }, [filteredData]); + + // Format helpers + const formatPrice = (price: number): string => { + if (price >= 1000) return price.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }); + if (price >= 1) return price.toFixed(4); + return price.toFixed(6); + }; + + const formatQuantity = (qty: number): string => { + if (qty >= 1000000) return `${(qty / 1000000).toFixed(2)}M`; + if (qty >= 1000) return `${(qty / 1000).toFixed(2)}K`; + return qty.toFixed(4); + }; + + const handleExport = useCallback((format: 'csv' | 'json') => { + onExport?.(format); + }, [onExport]); + + // Render order row + const renderOrderRow = ( + level: OrderLevel, + side: 'bid' | 'ask', + index: number + ) => { + const barWidth = (level.quantity / maxQuantity) * 100; + const isLarge = highlightLarge && level.quantity > largeOrderThreshold; + const colorClass = side === 'bid' ? 'text-green-400' : 'text-red-400'; + const bgColorClass = side === 'bid' ? 'bg-green-500' : 'bg-red-500'; + + return ( +
onPriceClick?.(level.price, side)} + > + {/* Background bar */} +
+ + {/* Content */} + {side === 'bid' ? ( + <> + {formatQuantity(level.total)} + {formatQuantity(level.quantity)} + {formatPrice(level.price)} + {level.orders || '-'} + + ) : ( + <> + {level.orders || '-'} + {formatPrice(level.price)} + {formatQuantity(level.quantity)} + {formatQuantity(level.total)} + + )} + + {/* Large order indicator */} + {isLarge && ( +
+
+
+ )} +
+ ); + }; + + return ( +
+ {/* Header */} +
+
+ +

+ Market Depth +

+ {data && ( + + {data.symbol} + + )} +
+ +
+ {/* View Mode Toggle */} +
+ + + +
+ + + + {onExport && ( + + )} + + {onRefresh && ( + + )} +
+
+ + {/* Filters Panel */} + {showFilters && ( +
+
+
+ + +
+
+ + setMinQuantity(parseFloat(e.target.value) || 0)} + min="0" + step="0.01" + className="w-full bg-gray-800 border border-gray-700 rounded px-2 py-1 text-white text-xs" + /> +
+
+ +
+
+
+ )} + + {/* Stats Bar */} + {data && ( +
+
+
Mid Price
+
{formatPrice(data.midPrice)}
+
+
+
Spread
+
{data.spreadPercentage.toFixed(3)}%
+
+
+
Imbalance
+
1 ? 'text-green-400' : data.imbalance < 1 ? 'text-red-400' : 'text-white'}`}> + {data.imbalance.toFixed(2)} +
+
+
+
Updated
+
+ {data.lastUpdate.toLocaleTimeString()} +
+
+
+ )} + + {/* Main Content */} +
+ {viewMode === 'table' && filteredData && ( +
+ {/* Bids Side */} +
+
+ Total + Size + Bid + # +
+
+ {filteredData.bids.slice(0, displayRows).map((level, idx) => + renderOrderRow(level, 'bid', idx) + )} +
+
+ + {/* Asks Side */} +
+
+ # + Ask + Size + Total +
+
+ {filteredData.asks.slice(0, displayRows).map((level, idx) => + renderOrderRow(level, 'ask', idx) + )} +
+
+
+ )} + + {viewMode === 'chart' && ( +
+
+ +

Use OrderBookDepthVisualization for chart view

+
+
+ )} + + {viewMode === 'split' && filteredData && ( +
+ {/* Compact table */} +
+
+ {filteredData.bids.slice(0, Math.floor(displayRows / 2)).map((level, idx) => + renderOrderRow(level, 'bid', idx) + )} +
+
+ {filteredData.asks.slice(0, Math.floor(displayRows / 2)).map((level, idx) => + renderOrderRow(level, 'ask', idx) + )} +
+
+
+ )} + + {/* Empty State */} + {!isLoading && !data && ( +
+
+ +

No market depth data available

+
+
+ )} + + {/* Loading State */} + {isLoading && ( +
+ +
+ )} +
+ + {/* Legend */} + {!compact && highlightLarge && ( +
+
+
+ Large order (>2x avg) +
+
+
+ Bid volume +
+
+
+ Ask volume +
+
+ )} +
+ ); +}; + +export default MarketDepthPanel; diff --git a/src/modules/trading/components/OrderBookDepthVisualization.tsx b/src/modules/trading/components/OrderBookDepthVisualization.tsx new file mode 100644 index 0000000..6adc1a9 --- /dev/null +++ b/src/modules/trading/components/OrderBookDepthVisualization.tsx @@ -0,0 +1,518 @@ +/** + * OrderBookDepthVisualization Component + * Visual depth chart showing cumulative bid/ask volumes + * Epic: OQI-003 Trading Charts + */ + +import React, { useMemo, useRef, useEffect, useState, useCallback } from 'react'; +import { + ChartBarIcon, + ArrowPathIcon, + ArrowsPointingOutIcon, + Cog6ToothIcon, +} from '@heroicons/react/24/solid'; + +// Types +export interface DepthLevel { + price: number; + quantity: number; + total: number; +} + +export interface DepthData { + bids: DepthLevel[]; + asks: DepthLevel[]; + midPrice: number; + spread: number; + spreadPercentage: number; + lastUpdate: Date; +} + +export interface DepthChartConfig { + showGrid: boolean; + showMidLine: boolean; + showTooltip: boolean; + animateChanges: boolean; + bidColor: string; + askColor: string; + fillOpacity: number; + strokeWidth: number; + priceRange: number; // percentage from mid price +} + +interface OrderBookDepthVisualizationProps { + data: DepthData | null; + config?: Partial; + onPriceClick?: (price: number, side: 'bid' | 'ask') => void; + onRefresh?: () => void; + isLoading?: boolean; + height?: number; + compact?: boolean; +} + +const DEFAULT_CONFIG: DepthChartConfig = { + showGrid: true, + showMidLine: true, + showTooltip: true, + animateChanges: true, + bidColor: '#22c55e', + askColor: '#ef4444', + fillOpacity: 0.3, + strokeWidth: 2, + priceRange: 5, +}; + +const OrderBookDepthVisualization: React.FC = ({ + data, + config: userConfig, + onPriceClick, + onRefresh, + isLoading = false, + height = 300, + compact = false, +}) => { + const canvasRef = useRef(null); + const containerRef = useRef(null); + const [tooltip, setTooltip] = useState<{ + visible: boolean; + x: number; + y: number; + price: number; + quantity: number; + total: number; + side: 'bid' | 'ask'; + } | null>(null); + const [showSettings, setShowSettings] = useState(false); + const [localConfig, setLocalConfig] = useState({ + ...DEFAULT_CONFIG, + ...userConfig, + }); + + const config = useMemo(() => ({ ...DEFAULT_CONFIG, ...userConfig, ...localConfig }), [userConfig, localConfig]); + + // Calculate chart dimensions and scales + const chartMetrics = useMemo(() => { + if (!data || !canvasRef.current) return null; + + const canvas = canvasRef.current; + const width = canvas.width; + const chartHeight = canvas.height; + const padding = { top: 20, right: 60, bottom: 30, left: 60 }; + + const chartWidth = width - padding.left - padding.right; + const drawHeight = chartHeight - padding.top - padding.bottom; + + // Find max total for Y scale + const maxBidTotal = data.bids.length > 0 ? Math.max(...data.bids.map((d) => d.total)) : 0; + const maxAskTotal = data.asks.length > 0 ? Math.max(...data.asks.map((d) => d.total)) : 0; + const maxTotal = Math.max(maxBidTotal, maxAskTotal, 1); + + // Price range based on config percentage + const priceRange = data.midPrice * (config.priceRange / 100); + const minPrice = data.midPrice - priceRange; + const maxPrice = data.midPrice + priceRange; + + return { + width, + chartHeight, + chartWidth, + drawHeight, + padding, + maxTotal, + minPrice, + maxPrice, + priceRange, + xScale: (price: number) => { + const normalized = (price - minPrice) / (maxPrice - minPrice); + return padding.left + normalized * chartWidth; + }, + yScale: (total: number) => { + const normalized = total / maxTotal; + return chartHeight - padding.bottom - normalized * drawHeight; + }, + }; + }, [data, config.priceRange]); + + // Draw the depth chart + const drawChart = useCallback(() => { + const canvas = canvasRef.current; + const ctx = canvas?.getContext('2d'); + if (!canvas || !ctx || !data || !chartMetrics) return; + + const { width, chartHeight, padding, xScale, yScale, minPrice, maxPrice, maxTotal } = chartMetrics; + + // Clear canvas + ctx.clearRect(0, 0, width, chartHeight); + + // Background + ctx.fillStyle = '#1f2937'; + ctx.fillRect(0, 0, width, chartHeight); + + // Draw grid + if (config.showGrid) { + ctx.strokeStyle = '#374151'; + ctx.lineWidth = 0.5; + + // Horizontal grid lines (5 lines) + for (let i = 0; i <= 4; i++) { + const y = yScale((maxTotal / 4) * i); + ctx.beginPath(); + ctx.moveTo(padding.left, y); + ctx.lineTo(width - padding.right, y); + ctx.stroke(); + } + + // Vertical grid lines (5 lines) + const priceStep = (maxPrice - minPrice) / 4; + for (let i = 0; i <= 4; i++) { + const x = xScale(minPrice + priceStep * i); + ctx.beginPath(); + ctx.moveTo(x, padding.top); + ctx.lineTo(x, chartHeight - padding.bottom); + ctx.stroke(); + } + } + + // Draw mid price line + if (config.showMidLine) { + const midX = xScale(data.midPrice); + ctx.strokeStyle = '#6b7280'; + ctx.lineWidth = 1; + ctx.setLineDash([5, 5]); + ctx.beginPath(); + ctx.moveTo(midX, padding.top); + ctx.lineTo(midX, chartHeight - padding.bottom); + ctx.stroke(); + ctx.setLineDash([]); + } + + // Draw bid area (left side) + if (data.bids.length > 0) { + const filteredBids = data.bids.filter((b) => b.price >= minPrice && b.price <= data.midPrice); + + if (filteredBids.length > 0) { + // Fill + ctx.fillStyle = config.bidColor + Math.round(config.fillOpacity * 255).toString(16).padStart(2, '0'); + ctx.beginPath(); + ctx.moveTo(xScale(filteredBids[0].price), yScale(0)); + filteredBids.forEach((level) => { + ctx.lineTo(xScale(level.price), yScale(level.total)); + }); + ctx.lineTo(xScale(filteredBids[filteredBids.length - 1].price), yScale(0)); + ctx.closePath(); + ctx.fill(); + + // Stroke + ctx.strokeStyle = config.bidColor; + ctx.lineWidth = config.strokeWidth; + ctx.beginPath(); + ctx.moveTo(xScale(filteredBids[0].price), yScale(filteredBids[0].total)); + filteredBids.forEach((level) => { + ctx.lineTo(xScale(level.price), yScale(level.total)); + }); + ctx.stroke(); + } + } + + // Draw ask area (right side) + if (data.asks.length > 0) { + const filteredAsks = data.asks.filter((a) => a.price <= maxPrice && a.price >= data.midPrice); + + if (filteredAsks.length > 0) { + // Fill + ctx.fillStyle = config.askColor + Math.round(config.fillOpacity * 255).toString(16).padStart(2, '0'); + ctx.beginPath(); + ctx.moveTo(xScale(filteredAsks[0].price), yScale(0)); + filteredAsks.forEach((level) => { + ctx.lineTo(xScale(level.price), yScale(level.total)); + }); + ctx.lineTo(xScale(filteredAsks[filteredAsks.length - 1].price), yScale(0)); + ctx.closePath(); + ctx.fill(); + + // Stroke + ctx.strokeStyle = config.askColor; + ctx.lineWidth = config.strokeWidth; + ctx.beginPath(); + ctx.moveTo(xScale(filteredAsks[0].price), yScale(filteredAsks[0].total)); + filteredAsks.forEach((level) => { + ctx.lineTo(xScale(level.price), yScale(level.total)); + }); + ctx.stroke(); + } + } + + // Draw Y axis labels (quantity) + ctx.fillStyle = '#9ca3af'; + ctx.font = '10px monospace'; + ctx.textAlign = 'right'; + for (let i = 0; i <= 4; i++) { + const value = (maxTotal / 4) * i; + const y = yScale(value); + ctx.fillText(formatQuantity(value), padding.left - 5, y + 3); + } + + // Draw X axis labels (price) + ctx.textAlign = 'center'; + const priceStep = (maxPrice - minPrice) / 4; + for (let i = 0; i <= 4; i++) { + const price = minPrice + priceStep * i; + const x = xScale(price); + ctx.fillText(formatPrice(price), x, chartHeight - padding.bottom + 15); + } + + // Draw mid price label + ctx.fillStyle = '#ffffff'; + ctx.font = '11px monospace'; + ctx.fillText(formatPrice(data.midPrice), xScale(data.midPrice), padding.top - 5); + }, [data, chartMetrics, config]); + + // Handle canvas resize + useEffect(() => { + const canvas = canvasRef.current; + const container = containerRef.current; + if (!canvas || !container) return; + + const resizeObserver = new ResizeObserver((entries) => { + for (const entry of entries) { + const { width } = entry.contentRect; + canvas.width = width; + canvas.height = height; + drawChart(); + } + }); + + resizeObserver.observe(container); + return () => resizeObserver.disconnect(); + }, [height, drawChart]); + + // Redraw on data change + useEffect(() => { + drawChart(); + }, [drawChart]); + + // Handle mouse move for tooltip + const handleMouseMove = useCallback( + (e: React.MouseEvent) => { + if (!config.showTooltip || !data || !chartMetrics) { + setTooltip(null); + return; + } + + const canvas = canvasRef.current; + if (!canvas) return; + + const rect = canvas.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + + const { padding, xScale, minPrice, maxPrice } = chartMetrics; + + // Check if within chart area + if ( + x < padding.left || + x > canvas.width - padding.right || + y < padding.top || + y > canvas.height - padding.bottom + ) { + setTooltip(null); + return; + } + + // Find price at mouse position + const price = minPrice + ((x - padding.left) / (canvas.width - padding.left - padding.right)) * (maxPrice - minPrice); + + // Determine side + const side: 'bid' | 'ask' = price < data.midPrice ? 'bid' : 'ask'; + const levels = side === 'bid' ? data.bids : data.asks; + + // Find closest level + let closest = levels[0]; + let minDist = Math.abs(price - closest?.price); + for (const level of levels) { + const dist = Math.abs(price - level.price); + if (dist < minDist) { + minDist = dist; + closest = level; + } + } + + if (closest) { + setTooltip({ + visible: true, + x: xScale(closest.price), + y: e.clientY - rect.top, + price: closest.price, + quantity: closest.quantity, + total: closest.total, + side, + }); + } + }, + [config.showTooltip, data, chartMetrics] + ); + + const handleMouseLeave = useCallback(() => { + setTooltip(null); + }, []); + + const handleCanvasClick = useCallback( + (e: React.MouseEvent) => { + if (!onPriceClick || !tooltip) return; + onPriceClick(tooltip.price, tooltip.side); + }, + [onPriceClick, tooltip] + ); + + // Format helpers + const formatPrice = (price: number): string => { + if (price >= 1000) return price.toLocaleString(undefined, { maximumFractionDigits: 2 }); + if (price >= 1) return price.toFixed(4); + return price.toFixed(6); + }; + + const formatQuantity = (qty: number): string => { + if (qty >= 1000000) return `${(qty / 1000000).toFixed(1)}M`; + if (qty >= 1000) return `${(qty / 1000).toFixed(1)}K`; + return qty.toFixed(2); + }; + + return ( +
+ {/* Header */} +
+
+ +

+ Market Depth +

+ {data && ( + + Spread: {formatPrice(data.spread)} ({data.spreadPercentage.toFixed(3)}%) + + )} +
+
+ + {onRefresh && ( + + )} +
+
+ + {/* Settings Panel */} + {showSettings && ( +
+
+ + +
+ + setLocalConfig({ ...localConfig, priceRange: Number(e.target.value) })} + className="w-full h-1 bg-gray-700 rounded-lg appearance-none cursor-pointer" + /> +
+
+
+ )} + + {/* Chart Container */} +
+ + + {/* Tooltip */} + {tooltip?.visible && ( +
(canvasRef.current?.width || 0) / 2 ? 'translateX(-100%)' : 'none', + }} + > +
+ {tooltip.side === 'bid' ? 'BID' : 'ASK'} +
+
Price: {formatPrice(tooltip.price)}
+
Qty: {formatQuantity(tooltip.quantity)}
+
Total: {formatQuantity(tooltip.total)}
+
+ )} + + {/* Loading Overlay */} + {isLoading && ( +
+ +
+ )} + + {/* Empty State */} + {!isLoading && !data && ( +
+
+ +

No depth data available

+
+
+ )} +
+ + {/* Legend */} + {!compact && ( +
+
+
+ Bids (Buy Orders) +
+
+
+ Asks (Sell Orders) +
+
+ )} +
+ ); +}; + +export default OrderBookDepthVisualization; diff --git a/src/modules/trading/components/SymbolComparisonChart.tsx b/src/modules/trading/components/SymbolComparisonChart.tsx new file mode 100644 index 0000000..da124c6 --- /dev/null +++ b/src/modules/trading/components/SymbolComparisonChart.tsx @@ -0,0 +1,619 @@ +/** + * SymbolComparisonChart Component + * Multi-symbol overlay chart for comparing price performance + * Epic: OQI-003 Trading Charts + */ + +import React, { useMemo, useRef, useEffect, useState, useCallback } from 'react'; +import { + ChartBarSquareIcon, + PlusIcon, + XMarkIcon, + ArrowPathIcon, + Cog6ToothIcon, + ArrowsPointingOutIcon, + ArrowDownTrayIcon, + EyeIcon, + EyeSlashIcon, +} from '@heroicons/react/24/solid'; + +// Types +export interface PricePoint { + timestamp: number; + price: number; + volume?: number; +} + +export interface SymbolData { + symbol: string; + name: string; + data: PricePoint[]; + color: string; + visible: boolean; + basePrice: number; // Starting price for normalization + currentPrice: number; + change: number; + changePercent: number; +} + +export type NormalizationMode = 'absolute' | 'percentage' | 'indexed'; +export type TimeframeOption = '1D' | '1W' | '1M' | '3M' | '6M' | '1Y' | 'YTD' | 'ALL'; + +interface SymbolComparisonChartProps { + symbols: SymbolData[]; + onAddSymbol?: () => void; + onRemoveSymbol?: (symbol: string) => void; + onToggleVisibility?: (symbol: string) => void; + onRefresh?: () => void; + onExport?: () => void; + onTimeframeChange?: (timeframe: TimeframeOption) => void; + isLoading?: boolean; + height?: number; + compact?: boolean; +} + +const PRESET_COLORS = [ + '#3B82F6', // Blue + '#10B981', // Green + '#F59E0B', // Amber + '#EF4444', // Red + '#8B5CF6', // Purple + '#EC4899', // Pink + '#06B6D4', // Cyan + '#84CC16', // Lime +]; + +const TIMEFRAMES: TimeframeOption[] = ['1D', '1W', '1M', '3M', '6M', '1Y', 'YTD', 'ALL']; + +const SymbolComparisonChart: React.FC = ({ + symbols, + onAddSymbol, + onRemoveSymbol, + onToggleVisibility, + onRefresh, + onExport, + onTimeframeChange, + isLoading = false, + height = 400, + compact = false, +}) => { + const canvasRef = useRef(null); + const containerRef = useRef(null); + const [normalization, setNormalization] = useState('percentage'); + const [timeframe, setTimeframe] = useState('1M'); + const [showSettings, setShowSettings] = useState(false); + const [showGrid, setShowGrid] = useState(true); + const [showLegend, setShowLegend] = useState(true); + const [hoveredPoint, setHoveredPoint] = useState<{ + x: number; + y: number; + timestamp: number; + values: { symbol: string; value: number; color: string }[]; + } | null>(null); + + // Visible symbols only + const visibleSymbols = useMemo(() => symbols.filter((s) => s.visible), [symbols]); + + // Normalize data based on selected mode + const normalizedData = useMemo(() => { + return visibleSymbols.map((symbol) => { + const normalizedPoints = symbol.data.map((point) => { + let value: number; + switch (normalization) { + case 'percentage': + value = ((point.price - symbol.basePrice) / symbol.basePrice) * 100; + break; + case 'indexed': + value = (point.price / symbol.basePrice) * 100; + break; + case 'absolute': + default: + value = point.price; + break; + } + return { ...point, normalizedValue: value }; + }); + return { ...symbol, normalizedData: normalizedPoints }; + }); + }, [visibleSymbols, normalization]); + + // Calculate chart scales + const chartMetrics = useMemo(() => { + if (!canvasRef.current || normalizedData.length === 0) return null; + + const canvas = canvasRef.current; + const width = canvas.width; + const chartHeight = canvas.height; + const padding = { top: 20, right: 80, bottom: 40, left: 60 }; + + const chartWidth = width - padding.left - padding.right; + const drawHeight = chartHeight - padding.top - padding.bottom; + + // Find min/max values and timestamps + let minValue = Infinity; + let maxValue = -Infinity; + let minTime = Infinity; + let maxTime = -Infinity; + + for (const symbol of normalizedData) { + for (const point of symbol.normalizedData) { + minValue = Math.min(minValue, point.normalizedValue); + maxValue = Math.max(maxValue, point.normalizedValue); + minTime = Math.min(minTime, point.timestamp); + maxTime = Math.max(maxTime, point.timestamp); + } + } + + // Add padding to value range + const valueRange = maxValue - minValue; + minValue -= valueRange * 0.1; + maxValue += valueRange * 0.1; + + return { + width, + chartHeight, + chartWidth, + drawHeight, + padding, + minValue, + maxValue, + minTime, + maxTime, + xScale: (timestamp: number) => { + const normalized = (timestamp - minTime) / (maxTime - minTime); + return padding.left + normalized * chartWidth; + }, + yScale: (value: number) => { + const normalized = (value - minValue) / (maxValue - minValue); + return chartHeight - padding.bottom - normalized * drawHeight; + }, + xInverse: (x: number) => { + const normalized = (x - padding.left) / chartWidth; + return minTime + normalized * (maxTime - minTime); + }, + }; + }, [normalizedData]); + + // Draw the chart + const drawChart = useCallback(() => { + const canvas = canvasRef.current; + const ctx = canvas?.getContext('2d'); + if (!canvas || !ctx || !chartMetrics) return; + + const { width, chartHeight, padding, xScale, yScale, minValue, maxValue, minTime, maxTime } = chartMetrics; + + // Clear + ctx.clearRect(0, 0, width, chartHeight); + + // Background + ctx.fillStyle = '#1f2937'; + ctx.fillRect(0, 0, width, chartHeight); + + // Grid + if (showGrid) { + ctx.strokeStyle = '#374151'; + ctx.lineWidth = 0.5; + + // Horizontal lines + const valueStep = (maxValue - minValue) / 5; + for (let i = 0; i <= 5; i++) { + const value = minValue + valueStep * i; + const y = yScale(value); + ctx.beginPath(); + ctx.moveTo(padding.left, y); + ctx.lineTo(width - padding.right, y); + ctx.stroke(); + } + + // Vertical lines + const timeStep = (maxTime - minTime) / 5; + for (let i = 0; i <= 5; i++) { + const time = minTime + timeStep * i; + const x = xScale(time); + ctx.beginPath(); + ctx.moveTo(x, padding.top); + ctx.lineTo(x, chartHeight - padding.bottom); + ctx.stroke(); + } + } + + // Zero line for percentage mode + if (normalization === 'percentage') { + const zeroY = yScale(0); + if (zeroY >= padding.top && zeroY <= chartHeight - padding.bottom) { + ctx.strokeStyle = '#6b7280'; + ctx.lineWidth = 1; + ctx.setLineDash([5, 5]); + ctx.beginPath(); + ctx.moveTo(padding.left, zeroY); + ctx.lineTo(width - padding.right, zeroY); + ctx.stroke(); + ctx.setLineDash([]); + } + } + + // Draw each symbol's line + for (const symbol of normalizedData) { + if (symbol.normalizedData.length < 2) continue; + + ctx.strokeStyle = symbol.color; + ctx.lineWidth = 2; + ctx.beginPath(); + + const firstPoint = symbol.normalizedData[0]; + ctx.moveTo(xScale(firstPoint.timestamp), yScale(firstPoint.normalizedValue)); + + for (let i = 1; i < symbol.normalizedData.length; i++) { + const point = symbol.normalizedData[i]; + ctx.lineTo(xScale(point.timestamp), yScale(point.normalizedValue)); + } + + ctx.stroke(); + + // Draw endpoint marker + const lastPoint = symbol.normalizedData[symbol.normalizedData.length - 1]; + ctx.fillStyle = symbol.color; + ctx.beginPath(); + ctx.arc(xScale(lastPoint.timestamp), yScale(lastPoint.normalizedValue), 4, 0, Math.PI * 2); + ctx.fill(); + } + + // Y-axis labels + ctx.fillStyle = '#9ca3af'; + ctx.font = '10px monospace'; + ctx.textAlign = 'right'; + const valueStep = (maxValue - minValue) / 5; + for (let i = 0; i <= 5; i++) { + const value = minValue + valueStep * i; + const y = yScale(value); + const label = normalization === 'percentage' ? `${value.toFixed(1)}%` : value.toFixed(2); + ctx.fillText(label, padding.left - 5, y + 3); + } + + // X-axis labels + ctx.textAlign = 'center'; + const timeStep = (maxTime - minTime) / 5; + for (let i = 0; i <= 5; i++) { + const time = minTime + timeStep * i; + const x = xScale(time); + const date = new Date(time); + const label = date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); + ctx.fillText(label, x, chartHeight - padding.bottom + 15); + } + + // Right side labels (current values) + normalizedData.forEach((symbol, index) => { + const lastPoint = symbol.normalizedData[symbol.normalizedData.length - 1]; + if (!lastPoint) return; + + const y = yScale(lastPoint.normalizedValue); + ctx.fillStyle = symbol.color; + ctx.textAlign = 'left'; + ctx.font = '10px monospace'; + + const label = normalization === 'percentage' + ? `${lastPoint.normalizedValue >= 0 ? '+' : ''}${lastPoint.normalizedValue.toFixed(2)}%` + : lastPoint.normalizedValue.toFixed(2); + + ctx.fillText(label, width - padding.right + 5, y + 3); + }); + + // Hover crosshair + if (hoveredPoint) { + ctx.strokeStyle = '#6b7280'; + ctx.lineWidth = 1; + ctx.setLineDash([3, 3]); + + // Vertical line + ctx.beginPath(); + ctx.moveTo(hoveredPoint.x, padding.top); + ctx.lineTo(hoveredPoint.x, chartHeight - padding.bottom); + ctx.stroke(); + + ctx.setLineDash([]); + } + }, [chartMetrics, normalizedData, showGrid, normalization, hoveredPoint]); + + // Handle resize + useEffect(() => { + const container = containerRef.current; + const canvas = canvasRef.current; + if (!container || !canvas) return; + + const resizeObserver = new ResizeObserver((entries) => { + for (const entry of entries) { + canvas.width = entry.contentRect.width; + canvas.height = height; + drawChart(); + } + }); + + resizeObserver.observe(container); + return () => resizeObserver.disconnect(); + }, [height, drawChart]); + + // Redraw on data change + useEffect(() => { + drawChart(); + }, [drawChart]); + + // Handle mouse move + const handleMouseMove = useCallback( + (e: React.MouseEvent) => { + if (!chartMetrics || normalizedData.length === 0) { + setHoveredPoint(null); + return; + } + + const canvas = canvasRef.current; + if (!canvas) return; + + const rect = canvas.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + + const { padding, xInverse, xScale, yScale } = chartMetrics; + + if (x < padding.left || x > canvas.width - padding.right) { + setHoveredPoint(null); + return; + } + + const timestamp = xInverse(x); + + // Find closest points for each symbol + const values = normalizedData.map((symbol) => { + let closest = symbol.normalizedData[0]; + let minDist = Math.abs(timestamp - closest.timestamp); + + for (const point of symbol.normalizedData) { + const dist = Math.abs(timestamp - point.timestamp); + if (dist < minDist) { + minDist = dist; + closest = point; + } + } + + return { + symbol: symbol.symbol, + value: closest.normalizedValue, + color: symbol.color, + }; + }); + + setHoveredPoint({ x, y, timestamp, values }); + }, + [chartMetrics, normalizedData] + ); + + const handleMouseLeave = useCallback(() => { + setHoveredPoint(null); + }, []); + + const handleTimeframeChange = (tf: TimeframeOption) => { + setTimeframe(tf); + onTimeframeChange?.(tf); + }; + + return ( +
+ {/* Header */} +
+
+ +

+ Symbol Comparison +

+ + {visibleSymbols.length} / {symbols.length} symbols + +
+ +
+ {onAddSymbol && ( + + )} + + {onExport && ( + + )} + {onRefresh && ( + + )} +
+
+ + {/* Timeframe Selector */} +
+ {TIMEFRAMES.map((tf) => ( + + ))} +
+ + {/* Settings Panel */} + {showSettings && ( +
+
+
+ + +
+ + +
+
+ )} + + {/* Symbol Pills */} + {showLegend && symbols.length > 0 && ( +
+ {symbols.map((symbol) => ( +
+
+ {symbol.symbol} + = 0 ? 'text-green-400' : 'text-red-400'}> + {symbol.changePercent >= 0 ? '+' : ''}{symbol.changePercent.toFixed(2)}% + + {onToggleVisibility && ( + + )} + {onRemoveSymbol && ( + + )} +
+ ))} +
+ )} + + {/* Chart Container */} +
+ + + {/* Tooltip */} + {hoveredPoint && ( +
(canvasRef.current?.width || 0) - 150 ? 'translateX(-100%)' : 'none', + }} + > +
+ {new Date(hoveredPoint.timestamp).toLocaleDateString(undefined, { + weekday: 'short', + month: 'short', + day: 'numeric', + year: 'numeric', + })} +
+ {hoveredPoint.values.map((v) => ( +
+
+ {v.symbol}: + + {normalization === 'percentage' + ? `${v.value >= 0 ? '+' : ''}${v.value.toFixed(2)}%` + : v.value.toFixed(2)} + +
+ ))} +
+ )} + + {/* Loading Overlay */} + {isLoading && ( +
+ +
+ )} + + {/* Empty State */} + {!isLoading && symbols.length === 0 && ( +
+
+ +

No symbols to compare

+ {onAddSymbol && ( + + )} +
+
+ )} +
+
+ ); +}; + +export default SymbolComparisonChart; diff --git a/src/modules/trading/components/TradingScreener.tsx b/src/modules/trading/components/TradingScreener.tsx new file mode 100644 index 0000000..dd3033c --- /dev/null +++ b/src/modules/trading/components/TradingScreener.tsx @@ -0,0 +1,643 @@ +/** + * TradingScreener Component + * Advanced screener with filters for stocks/forex/crypto + * Epic: OQI-003 Trading Charts + */ + +import React, { useState, useMemo, useCallback } from 'react'; +import { + FunnelIcon, + MagnifyingGlassIcon, + ArrowPathIcon, + ChevronUpIcon, + ChevronDownIcon, + StarIcon, + PlusIcon, + AdjustmentsHorizontalIcon, + ArrowDownTrayIcon, + BookmarkIcon, + TrashIcon, + ChartBarIcon, +} from '@heroicons/react/24/solid'; +import { StarIcon as StarOutlineIcon } from '@heroicons/react/24/outline'; + +// Types +export interface ScreenerResult { + symbol: string; + name: string; + sector?: string; + industry?: string; + exchange: string; + price: number; + change: number; + changePercent: number; + volume: number; + avgVolume: number; + marketCap?: number; + pe?: number; + eps?: number; + high52w: number; + low52w: number; + rsi?: number; + macdSignal?: 'bullish' | 'bearish' | 'neutral'; + sma20?: number; + sma50?: number; + sma200?: number; + atr?: number; + beta?: number; + dividendYield?: number; + isFavorite?: boolean; +} + +export interface ScreenerFilter { + id: string; + field: keyof ScreenerResult; + operator: 'gt' | 'lt' | 'eq' | 'gte' | 'lte' | 'between' | 'contains'; + value: number | string | [number, number]; + enabled: boolean; +} + +export interface SavedScreener { + id: string; + name: string; + filters: ScreenerFilter[]; + createdAt: Date; +} + +type SortDirection = 'asc' | 'desc'; +type SortField = keyof ScreenerResult; + +interface TradingScreenerProps { + results: ScreenerResult[]; + savedScreeners?: SavedScreener[]; + onSearch?: (query: string) => void; + onFilterChange?: (filters: ScreenerFilter[]) => void; + onSaveScreener?: (name: string, filters: ScreenerFilter[]) => void; + onLoadScreener?: (screener: SavedScreener) => void; + onDeleteScreener?: (id: string) => void; + onSymbolClick?: (symbol: string) => void; + onAddToWatchlist?: (symbol: string) => void; + onToggleFavorite?: (symbol: string) => void; + onExport?: (format: 'csv' | 'json') => void; + onRefresh?: () => void; + isLoading?: boolean; + compact?: boolean; +} + +const FILTER_PRESETS: { name: string; filters: Omit[] }[] = [ + { + name: 'High Volume', + filters: [ + { field: 'volume', operator: 'gt', value: 1000000, enabled: true }, + { field: 'changePercent', operator: 'gt', value: 0, enabled: true }, + ], + }, + { + name: 'Oversold (RSI < 30)', + filters: [{ field: 'rsi', operator: 'lt', value: 30, enabled: true }], + }, + { + name: 'Overbought (RSI > 70)', + filters: [{ field: 'rsi', operator: 'gt', value: 70, enabled: true }], + }, + { + name: 'Golden Cross', + filters: [ + { field: 'sma50', operator: 'gt', value: 0, enabled: true }, + { field: 'sma200', operator: 'gt', value: 0, enabled: true }, + ], + }, + { + name: 'Near 52W High', + filters: [{ field: 'changePercent', operator: 'gt', value: 5, enabled: true }], + }, +]; + +const FILTERABLE_FIELDS: { field: keyof ScreenerResult; label: string; type: 'number' | 'string' }[] = [ + { field: 'price', label: 'Price', type: 'number' }, + { field: 'changePercent', label: 'Change %', type: 'number' }, + { field: 'volume', label: 'Volume', type: 'number' }, + { field: 'marketCap', label: 'Market Cap', type: 'number' }, + { field: 'pe', label: 'P/E Ratio', type: 'number' }, + { field: 'rsi', label: 'RSI', type: 'number' }, + { field: 'beta', label: 'Beta', type: 'number' }, + { field: 'dividendYield', label: 'Dividend Yield', type: 'number' }, + { field: 'sector', label: 'Sector', type: 'string' }, + { field: 'exchange', label: 'Exchange', type: 'string' }, +]; + +const TradingScreener: React.FC = ({ + results, + savedScreeners = [], + onSearch, + onFilterChange, + onSaveScreener, + onLoadScreener, + onDeleteScreener, + onSymbolClick, + onAddToWatchlist, + onToggleFavorite, + onExport, + onRefresh, + isLoading = false, + compact = false, +}) => { + const [searchQuery, setSearchQuery] = useState(''); + const [filters, setFilters] = useState([]); + const [showFilters, setShowFilters] = useState(false); + const [showSavedScreeners, setShowSavedScreeners] = useState(false); + const [sortField, setSortField] = useState('changePercent'); + const [sortDirection, setSortDirection] = useState('desc'); + const [newScreenerName, setNewScreenerName] = useState(''); + + // Apply filters and sorting + const filteredResults = useMemo(() => { + let filtered = [...results]; + + // Search filter + if (searchQuery) { + const query = searchQuery.toLowerCase(); + filtered = filtered.filter( + (r) => + r.symbol.toLowerCase().includes(query) || + r.name.toLowerCase().includes(query) || + r.sector?.toLowerCase().includes(query) || + r.industry?.toLowerCase().includes(query) + ); + } + + // Apply custom filters + for (const filter of filters.filter((f) => f.enabled)) { + filtered = filtered.filter((r) => { + const value = r[filter.field]; + if (value === undefined || value === null) return false; + + switch (filter.operator) { + case 'gt': + return typeof value === 'number' && value > (filter.value as number); + case 'lt': + return typeof value === 'number' && value < (filter.value as number); + case 'gte': + return typeof value === 'number' && value >= (filter.value as number); + case 'lte': + return typeof value === 'number' && value <= (filter.value as number); + case 'eq': + return value === filter.value; + case 'between': + const [min, max] = filter.value as [number, number]; + return typeof value === 'number' && value >= min && value <= max; + case 'contains': + return typeof value === 'string' && value.toLowerCase().includes((filter.value as string).toLowerCase()); + default: + return true; + } + }); + } + + // Sort + filtered.sort((a, b) => { + const aVal = a[sortField]; + const bVal = b[sortField]; + if (aVal === undefined || aVal === null) return 1; + if (bVal === undefined || bVal === null) return -1; + const comparison = aVal < bVal ? -1 : aVal > bVal ? 1 : 0; + return sortDirection === 'asc' ? comparison : -comparison; + }); + + return filtered; + }, [results, searchQuery, filters, sortField, sortDirection]); + + // Handle sort + const handleSort = useCallback((field: SortField) => { + if (field === sortField) { + setSortDirection((d) => (d === 'asc' ? 'desc' : 'asc')); + } else { + setSortField(field); + setSortDirection('desc'); + } + }, [sortField]); + + // Add new filter + const addFilter = useCallback(() => { + const newFilter: ScreenerFilter = { + id: `filter-${Date.now()}`, + field: 'price', + operator: 'gt', + value: 0, + enabled: true, + }; + const newFilters = [...filters, newFilter]; + setFilters(newFilters); + onFilterChange?.(newFilters); + }, [filters, onFilterChange]); + + // Update filter + const updateFilter = useCallback((id: string, updates: Partial) => { + const newFilters = filters.map((f) => (f.id === id ? { ...f, ...updates } : f)); + setFilters(newFilters); + onFilterChange?.(newFilters); + }, [filters, onFilterChange]); + + // Remove filter + const removeFilter = useCallback((id: string) => { + const newFilters = filters.filter((f) => f.id !== id); + setFilters(newFilters); + onFilterChange?.(newFilters); + }, [filters, onFilterChange]); + + // Load preset + const loadPreset = useCallback((preset: typeof FILTER_PRESETS[0]) => { + const newFilters = preset.filters.map((f, i) => ({ + ...f, + id: `preset-${Date.now()}-${i}`, + })); + setFilters(newFilters); + onFilterChange?.(newFilters); + }, [onFilterChange]); + + // Save screener + const handleSaveScreener = useCallback(() => { + if (newScreenerName.trim() && filters.length > 0) { + onSaveScreener?.(newScreenerName.trim(), filters); + setNewScreenerName(''); + } + }, [newScreenerName, filters, onSaveScreener]); + + // Format helpers + const formatNumber = (num: number | undefined, decimals = 2): string => { + if (num === undefined || num === null) return '-'; + if (Math.abs(num) >= 1e9) return `${(num / 1e9).toFixed(1)}B`; + if (Math.abs(num) >= 1e6) return `${(num / 1e6).toFixed(1)}M`; + if (Math.abs(num) >= 1e3) return `${(num / 1e3).toFixed(1)}K`; + return num.toFixed(decimals); + }; + + const formatPrice = (price: number): string => { + if (price >= 1000) return price.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }); + if (price >= 1) return price.toFixed(2); + return price.toFixed(4); + }; + + // Render sort indicator + const renderSortIndicator = (field: SortField) => { + if (sortField !== field) return null; + return sortDirection === 'asc' ? ( + + ) : ( + + ); + }; + + return ( +
+ {/* Header */} +
+
+ +

+ Stock Screener +

+ + {filteredResults.length} / {results.length} results + +
+ +
+ + + {onExport && ( + + )} + {onRefresh && ( + + )} +
+
+ + {/* Search Bar */} +
+ + { + setSearchQuery(e.target.value); + onSearch?.(e.target.value); + }} + placeholder="Search by symbol, name, sector..." + className="w-full bg-gray-900/50 border border-gray-700 rounded-lg pl-9 pr-4 py-2 text-sm text-white placeholder-gray-500 focus:border-amber-500 focus:outline-none" + /> +
+ + {/* Filter Presets */} +
+ {FILTER_PRESETS.map((preset) => ( + + ))} +
+ + {/* Saved Screeners Panel */} + {showSavedScreeners && ( +
+
Saved Screeners
+ {savedScreeners.length > 0 ? ( +
+ {savedScreeners.map((screener) => ( +
+ + +
+ ))} +
+ ) : ( +
No saved screeners
+ )} + + {/* Save Current */} + {filters.length > 0 && onSaveScreener && ( +
+ setNewScreenerName(e.target.value)} + placeholder="Screener name..." + className="flex-1 bg-gray-800 border border-gray-700 rounded px-2 py-1 text-xs text-white" + /> + +
+ )} +
+ )} + + {/* Filters Panel */} + {showFilters && ( +
+
+ Active Filters + +
+ + {filters.length > 0 ? ( +
+ {filters.map((filter) => ( +
+ updateFilter(filter.id, { enabled: e.target.checked })} + className="rounded bg-gray-700 border-gray-600" + /> + + + f.field === filter.field)?.type === 'number' ? 'number' : 'text'} + value={filter.value as string | number} + onChange={(e) => + updateFilter(filter.id, { + value: e.target.type === 'number' ? parseFloat(e.target.value) || 0 : e.target.value, + }) + } + className="w-24 bg-gray-700 border border-gray-600 rounded px-2 py-1 text-xs text-white" + /> + +
+ ))} +
+ ) : ( +
No filters applied
+ )} +
+ )} + + {/* Results Table */} +
+ + + + + + + + + + + + + + {filteredResults.map((result) => ( + onSymbolClick?.(result.symbol)} + > + + + + + + + + + ))} + +
+ + + + + + + + + + + + Actions
+
+ +
+
{result.symbol}
+
{result.name}
+
+
+
{formatPrice(result.price)}= 0 ? 'text-green-400' : 'text-red-400'}`}> + {result.changePercent >= 0 ? '+' : ''}{result.changePercent.toFixed(2)}% + + {formatNumber(result.volume, 0)} + + {formatNumber(result.marketCap, 1)} + + {result.rsi !== undefined && ( + 70 ? 'text-red-400' : result.rsi < 30 ? 'text-green-400' : 'text-gray-300' + }`} + > + {result.rsi.toFixed(0)} + + )} + +
+ + {onAddToWatchlist && ( + + )} +
+
+ + {/* Empty State */} + {!isLoading && filteredResults.length === 0 && ( +
+ +

No results match your criteria

+ +
+ )} + + {/* Loading State */} + {isLoading && ( +
+ +
+ )} +
+
+ ); +}; + +export default TradingScreener; diff --git a/src/modules/trading/components/index.ts b/src/modules/trading/components/index.ts index 785579e..f426fa0 100644 --- a/src/modules/trading/components/index.ts +++ b/src/modules/trading/components/index.ts @@ -41,6 +41,16 @@ export { default as PaperTradingPanel } from './PaperTradingPanel'; // Utility Components export { default as ExportButton } from './ExportButton'; +// Advanced Chart Components (OQI-003) +export { default as OrderBookDepthVisualization } from './OrderBookDepthVisualization'; +export type { DepthLevel, DepthData, DepthChartConfig } from './OrderBookDepthVisualization'; +export { default as MarketDepthPanel } from './MarketDepthPanel'; +export type { OrderLevel, MarketDepthData, ViewMode, GroupingLevel } from './MarketDepthPanel'; +export { default as SymbolComparisonChart } from './SymbolComparisonChart'; +export type { PricePoint, SymbolData, NormalizationMode, TimeframeOption } from './SymbolComparisonChart'; +export { default as TradingScreener } from './TradingScreener'; +export type { ScreenerResult, ScreenerFilter, SavedScreener } from './TradingScreener'; + // MT4 Gateway Components (OQI-009) export { default as MT4ConnectionStatus } from './MT4ConnectionStatus'; export { default as LivePositionCard } from './LivePositionCard';