[OQI-003] feat: Add advanced market depth and screener components

- OrderBookDepthVisualization: Canvas-based depth chart with bid/ask areas
- MarketDepthPanel: Complete market depth with filters and grouping
- SymbolComparisonChart: Multi-symbol comparison with normalization modes
- TradingScreener: Advanced screener with filters and saved presets

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Adrian Flores Cortes 2026-01-25 14:24:52 -06:00
parent 5ee7f14f25
commit c145878c24
5 changed files with 2247 additions and 0 deletions

View File

@ -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<MarketDepthPanelProps> = ({
data,
onPriceClick,
onRefresh,
onExport,
isLoading = false,
displayRows = 15,
compact = false,
}) => {
const [viewMode, setViewMode] = useState<ViewMode>('table');
const [grouping, setGrouping] = useState<GroupingLevel>('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<number, OrderLevel>();
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 (
<div
key={`${side}-${level.price}-${index}`}
className={`relative grid grid-cols-4 text-xs py-1 hover:bg-gray-700/50 cursor-pointer group transition-colors ${
isLarge ? 'bg-yellow-500/10' : ''
}`}
onClick={() => onPriceClick?.(level.price, side)}
>
{/* Background bar */}
<div
className={`absolute ${side === 'bid' ? 'right-0' : 'left-0'} top-0 bottom-0 ${bgColorClass}/10 group-hover:${bgColorClass}/20 transition-colors`}
style={{ width: `${barWidth}%` }}
/>
{/* Content */}
{side === 'bid' ? (
<>
<span className="relative text-gray-500 font-mono text-right pr-2">{formatQuantity(level.total)}</span>
<span className="relative text-gray-300 font-mono text-right pr-2">{formatQuantity(level.quantity)}</span>
<span className={`relative ${colorClass} font-mono text-right pr-2`}>{formatPrice(level.price)}</span>
<span className="relative text-gray-600 font-mono text-center">{level.orders || '-'}</span>
</>
) : (
<>
<span className="relative text-gray-600 font-mono text-center">{level.orders || '-'}</span>
<span className={`relative ${colorClass} font-mono pl-2`}>{formatPrice(level.price)}</span>
<span className="relative text-gray-300 font-mono pl-2">{formatQuantity(level.quantity)}</span>
<span className="relative text-gray-500 font-mono pl-2">{formatQuantity(level.total)}</span>
</>
)}
{/* Large order indicator */}
{isLarge && (
<div className="absolute right-1 top-1/2 -translate-y-1/2">
<div className="w-1.5 h-1.5 rounded-full bg-yellow-400 animate-pulse" />
</div>
)}
</div>
);
};
return (
<div className={`bg-gray-800/50 rounded-lg ${compact ? 'p-2' : 'p-4'} flex flex-col h-full`}>
{/* Header */}
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Bars3BottomLeftIcon className="w-5 h-5 text-purple-400" />
<h3 className={`font-semibold text-white ${compact ? 'text-xs' : 'text-sm'}`}>
Market Depth
</h3>
{data && (
<span className="text-xs text-gray-400 hidden sm:inline">
{data.symbol}
</span>
)}
</div>
<div className="flex items-center gap-1">
{/* View Mode Toggle */}
<div className="flex items-center bg-gray-900/50 rounded p-0.5">
<button
onClick={() => setViewMode('table')}
className={`p-1 rounded ${viewMode === 'table' ? 'bg-gray-700 text-white' : 'text-gray-400 hover:text-white'}`}
title="Table View"
>
<TableCellsIcon className="w-4 h-4" />
</button>
<button
onClick={() => setViewMode('chart')}
className={`p-1 rounded ${viewMode === 'chart' ? 'bg-gray-700 text-white' : 'text-gray-400 hover:text-white'}`}
title="Chart View"
>
<ChartBarIcon className="w-4 h-4" />
</button>
<button
onClick={() => setViewMode('split')}
className={`p-1 rounded ${viewMode === 'split' ? 'bg-gray-700 text-white' : 'text-gray-400 hover:text-white'}`}
title="Split View"
>
<ArrowsUpDownIcon className="w-4 h-4" />
</button>
</div>
<button
onClick={() => setShowFilters(!showFilters)}
className={`p-1.5 rounded transition-colors ${showFilters ? 'bg-blue-500/20 text-blue-400' : 'text-gray-400 hover:text-white hover:bg-gray-700'}`}
title="Filters"
>
<FunnelIcon className="w-4 h-4" />
</button>
{onExport && (
<button
onClick={() => handleExport('csv')}
className="p-1.5 text-gray-400 hover:text-white hover:bg-gray-700 rounded transition-colors"
title="Export"
>
<ArrowDownTrayIcon className="w-4 h-4" />
</button>
)}
{onRefresh && (
<button
onClick={onRefresh}
disabled={isLoading}
className="p-1.5 text-gray-400 hover:text-white hover:bg-gray-700 rounded transition-colors"
title="Refresh"
>
<ArrowPathIcon className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />
</button>
)}
</div>
</div>
{/* Filters Panel */}
{showFilters && (
<div className="mb-3 p-3 bg-gray-900/50 rounded-lg">
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 text-xs">
<div>
<label className="text-gray-400 block mb-1">Price Grouping</label>
<select
value={grouping}
onChange={(e) => setGrouping(e.target.value as GroupingLevel)}
className="w-full bg-gray-800 border border-gray-700 rounded px-2 py-1 text-white text-xs"
>
<option value="none">None</option>
<option value="0.01">0.01</option>
<option value="0.1">0.1</option>
<option value="1">1</option>
<option value="10">10</option>
<option value="100">100</option>
</select>
</div>
<div>
<label className="text-gray-400 block mb-1">Min Quantity</label>
<input
type="number"
value={minQuantity}
onChange={(e) => 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"
/>
</div>
<div className="col-span-2 flex items-end">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={highlightLarge}
onChange={(e) => setHighlightLarge(e.target.checked)}
className="rounded bg-gray-700 border-gray-600"
/>
<span className="text-gray-300">Highlight large orders</span>
</label>
</div>
</div>
</div>
)}
{/* Stats Bar */}
{data && (
<div className="grid grid-cols-4 gap-2 mb-3 text-xs">
<div className="bg-gray-900/50 rounded px-2 py-1.5">
<div className="text-gray-500">Mid Price</div>
<div className="text-white font-mono">{formatPrice(data.midPrice)}</div>
</div>
<div className="bg-gray-900/50 rounded px-2 py-1.5">
<div className="text-gray-500">Spread</div>
<div className="text-white font-mono">{data.spreadPercentage.toFixed(3)}%</div>
</div>
<div className="bg-gray-900/50 rounded px-2 py-1.5">
<div className="text-gray-500">Imbalance</div>
<div className={`font-mono ${data.imbalance > 1 ? 'text-green-400' : data.imbalance < 1 ? 'text-red-400' : 'text-white'}`}>
{data.imbalance.toFixed(2)}
</div>
</div>
<div className="bg-gray-900/50 rounded px-2 py-1.5">
<div className="text-gray-500">Updated</div>
<div className="text-white font-mono">
{data.lastUpdate.toLocaleTimeString()}
</div>
</div>
</div>
)}
{/* Main Content */}
<div className="flex-1 overflow-hidden">
{viewMode === 'table' && filteredData && (
<div className="flex gap-2 h-full">
{/* Bids Side */}
<div className="flex-1 flex flex-col">
<div className="grid grid-cols-4 text-xs text-gray-500 border-b border-gray-700 pb-1 mb-1">
<span className="text-right pr-2">Total</span>
<span className="text-right pr-2">Size</span>
<span className="text-right pr-2 text-green-400">Bid</span>
<span className="text-center">#</span>
</div>
<div className="flex-1 overflow-y-auto space-y-0.5">
{filteredData.bids.slice(0, displayRows).map((level, idx) =>
renderOrderRow(level, 'bid', idx)
)}
</div>
</div>
{/* Asks Side */}
<div className="flex-1 flex flex-col">
<div className="grid grid-cols-4 text-xs text-gray-500 border-b border-gray-700 pb-1 mb-1">
<span className="text-center">#</span>
<span className="pl-2 text-red-400">Ask</span>
<span className="pl-2">Size</span>
<span className="pl-2">Total</span>
</div>
<div className="flex-1 overflow-y-auto space-y-0.5">
{filteredData.asks.slice(0, displayRows).map((level, idx) =>
renderOrderRow(level, 'ask', idx)
)}
</div>
</div>
</div>
)}
{viewMode === 'chart' && (
<div className="h-full flex items-center justify-center text-gray-500 text-sm">
<div className="text-center">
<ChartBarIcon className="w-12 h-12 mx-auto mb-2 opacity-50" />
<p>Use OrderBookDepthVisualization for chart view</p>
</div>
</div>
)}
{viewMode === 'split' && filteredData && (
<div className="h-full flex flex-col gap-2">
{/* Compact table */}
<div className="flex-1 flex gap-2 overflow-hidden">
<div className="flex-1 overflow-y-auto">
{filteredData.bids.slice(0, Math.floor(displayRows / 2)).map((level, idx) =>
renderOrderRow(level, 'bid', idx)
)}
</div>
<div className="flex-1 overflow-y-auto">
{filteredData.asks.slice(0, Math.floor(displayRows / 2)).map((level, idx) =>
renderOrderRow(level, 'ask', idx)
)}
</div>
</div>
</div>
)}
{/* Empty State */}
{!isLoading && !data && (
<div className="h-full flex items-center justify-center">
<div className="text-center text-gray-500">
<Bars3BottomLeftIcon className="w-12 h-12 mx-auto mb-2 opacity-50" />
<p className="text-sm">No market depth data available</p>
</div>
</div>
)}
{/* Loading State */}
{isLoading && (
<div className="h-full flex items-center justify-center">
<ArrowPathIcon className="w-8 h-8 text-blue-400 animate-spin" />
</div>
)}
</div>
{/* Legend */}
{!compact && highlightLarge && (
<div className="flex items-center gap-4 mt-2 pt-2 border-t border-gray-700 text-xs text-gray-500">
<div className="flex items-center gap-1">
<div className="w-1.5 h-1.5 rounded-full bg-yellow-400" />
<span>Large order (&gt;2x avg)</span>
</div>
<div className="flex items-center gap-1">
<div className="w-3 h-2 bg-green-500/20 rounded" />
<span>Bid volume</span>
</div>
<div className="flex items-center gap-1">
<div className="w-3 h-2 bg-red-500/20 rounded" />
<span>Ask volume</span>
</div>
</div>
)}
</div>
);
};
export default MarketDepthPanel;

View File

@ -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<DepthChartConfig>;
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<OrderBookDepthVisualizationProps> = ({
data,
config: userConfig,
onPriceClick,
onRefresh,
isLoading = false,
height = 300,
compact = false,
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(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<DepthChartConfig>({
...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<HTMLCanvasElement>) => {
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<HTMLCanvasElement>) => {
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 (
<div className={`bg-gray-800/50 rounded-lg ${compact ? 'p-2' : 'p-4'} flex flex-col`}>
{/* Header */}
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<ChartBarIcon className="w-5 h-5 text-blue-400" />
<h3 className={`font-semibold text-white ${compact ? 'text-xs' : 'text-sm'}`}>
Market Depth
</h3>
{data && (
<span className="text-xs text-gray-400">
Spread: {formatPrice(data.spread)} ({data.spreadPercentage.toFixed(3)}%)
</span>
)}
</div>
<div className="flex items-center gap-1">
<button
onClick={() => setShowSettings(!showSettings)}
className="p-1.5 text-gray-400 hover:text-white hover:bg-gray-700 rounded transition-colors"
title="Settings"
>
<Cog6ToothIcon className="w-4 h-4" />
</button>
{onRefresh && (
<button
onClick={onRefresh}
disabled={isLoading}
className="p-1.5 text-gray-400 hover:text-white hover:bg-gray-700 rounded transition-colors"
title="Refresh"
>
<ArrowPathIcon className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />
</button>
)}
</div>
</div>
{/* Settings Panel */}
{showSettings && (
<div className="mb-3 p-3 bg-gray-900/50 rounded-lg space-y-2">
<div className="grid grid-cols-2 gap-3 text-xs">
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={localConfig.showGrid}
onChange={(e) => setLocalConfig({ ...localConfig, showGrid: e.target.checked })}
className="rounded bg-gray-700 border-gray-600"
/>
<span className="text-gray-300">Show Grid</span>
</label>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={localConfig.showMidLine}
onChange={(e) => setLocalConfig({ ...localConfig, showMidLine: e.target.checked })}
className="rounded bg-gray-700 border-gray-600"
/>
<span className="text-gray-300">Show Mid Line</span>
</label>
<div className="col-span-2">
<label className="text-gray-300 block mb-1">Price Range: {localConfig.priceRange}%</label>
<input
type="range"
min="1"
max="20"
value={localConfig.priceRange}
onChange={(e) => setLocalConfig({ ...localConfig, priceRange: Number(e.target.value) })}
className="w-full h-1 bg-gray-700 rounded-lg appearance-none cursor-pointer"
/>
</div>
</div>
</div>
)}
{/* Chart Container */}
<div ref={containerRef} className="relative flex-1" style={{ minHeight: height }}>
<canvas
ref={canvasRef}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
onClick={handleCanvasClick}
className="w-full cursor-crosshair"
style={{ height }}
/>
{/* Tooltip */}
{tooltip?.visible && (
<div
className="absolute pointer-events-none bg-gray-900 border border-gray-700 rounded px-2 py-1 text-xs shadow-lg z-10"
style={{
left: tooltip.x + 10,
top: tooltip.y - 40,
transform: tooltip.x > (canvasRef.current?.width || 0) / 2 ? 'translateX(-100%)' : 'none',
}}
>
<div className={`font-semibold ${tooltip.side === 'bid' ? 'text-green-400' : 'text-red-400'}`}>
{tooltip.side === 'bid' ? 'BID' : 'ASK'}
</div>
<div className="text-gray-300">Price: {formatPrice(tooltip.price)}</div>
<div className="text-gray-300">Qty: {formatQuantity(tooltip.quantity)}</div>
<div className="text-gray-400">Total: {formatQuantity(tooltip.total)}</div>
</div>
)}
{/* Loading Overlay */}
{isLoading && (
<div className="absolute inset-0 bg-gray-900/50 flex items-center justify-center">
<ArrowPathIcon className="w-8 h-8 text-blue-400 animate-spin" />
</div>
)}
{/* Empty State */}
{!isLoading && !data && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="text-center text-gray-500">
<ChartBarIcon className="w-12 h-12 mx-auto mb-2 opacity-50" />
<p className="text-sm">No depth data available</p>
</div>
</div>
)}
</div>
{/* Legend */}
{!compact && (
<div className="flex items-center justify-center gap-6 mt-3 text-xs">
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded" style={{ backgroundColor: config.bidColor }} />
<span className="text-gray-400">Bids (Buy Orders)</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded" style={{ backgroundColor: config.askColor }} />
<span className="text-gray-400">Asks (Sell Orders)</span>
</div>
</div>
)}
</div>
);
};
export default OrderBookDepthVisualization;

View File

@ -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<SymbolComparisonChartProps> = ({
symbols,
onAddSymbol,
onRemoveSymbol,
onToggleVisibility,
onRefresh,
onExport,
onTimeframeChange,
isLoading = false,
height = 400,
compact = false,
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [normalization, setNormalization] = useState<NormalizationMode>('percentage');
const [timeframe, setTimeframe] = useState<TimeframeOption>('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<HTMLCanvasElement>) => {
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 (
<div className={`bg-gray-800/50 rounded-lg ${compact ? 'p-2' : 'p-4'} flex flex-col`}>
{/* Header */}
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<ChartBarSquareIcon className="w-5 h-5 text-cyan-400" />
<h3 className={`font-semibold text-white ${compact ? 'text-xs' : 'text-sm'}`}>
Symbol Comparison
</h3>
<span className="text-xs text-gray-400">
{visibleSymbols.length} / {symbols.length} symbols
</span>
</div>
<div className="flex items-center gap-1">
{onAddSymbol && (
<button
onClick={onAddSymbol}
disabled={symbols.length >= 8}
className="p-1.5 text-gray-400 hover:text-white hover:bg-gray-700 rounded transition-colors disabled:opacity-50"
title="Add Symbol"
>
<PlusIcon className="w-4 h-4" />
</button>
)}
<button
onClick={() => setShowSettings(!showSettings)}
className={`p-1.5 rounded transition-colors ${showSettings ? 'bg-blue-500/20 text-blue-400' : 'text-gray-400 hover:text-white hover:bg-gray-700'}`}
title="Settings"
>
<Cog6ToothIcon className="w-4 h-4" />
</button>
{onExport && (
<button
onClick={onExport}
className="p-1.5 text-gray-400 hover:text-white hover:bg-gray-700 rounded transition-colors"
title="Export"
>
<ArrowDownTrayIcon className="w-4 h-4" />
</button>
)}
{onRefresh && (
<button
onClick={onRefresh}
disabled={isLoading}
className="p-1.5 text-gray-400 hover:text-white hover:bg-gray-700 rounded transition-colors"
title="Refresh"
>
<ArrowPathIcon className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />
</button>
)}
</div>
</div>
{/* Timeframe Selector */}
<div className="flex items-center gap-1 mb-3">
{TIMEFRAMES.map((tf) => (
<button
key={tf}
onClick={() => handleTimeframeChange(tf)}
className={`px-2 py-1 text-xs rounded transition-colors ${
timeframe === tf
? 'bg-blue-500 text-white'
: 'bg-gray-700 text-gray-400 hover:text-white'
}`}
>
{tf}
</button>
))}
</div>
{/* Settings Panel */}
{showSettings && (
<div className="mb-3 p-3 bg-gray-900/50 rounded-lg">
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 text-xs">
<div>
<label className="text-gray-400 block mb-1">Normalization</label>
<select
value={normalization}
onChange={(e) => setNormalization(e.target.value as NormalizationMode)}
className="w-full bg-gray-800 border border-gray-700 rounded px-2 py-1 text-white text-xs"
>
<option value="percentage">Percentage Change</option>
<option value="indexed">Indexed (100)</option>
<option value="absolute">Absolute Price</option>
</select>
</div>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={showGrid}
onChange={(e) => setShowGrid(e.target.checked)}
className="rounded bg-gray-700 border-gray-600"
/>
<span className="text-gray-300">Show Grid</span>
</label>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={showLegend}
onChange={(e) => setShowLegend(e.target.checked)}
className="rounded bg-gray-700 border-gray-600"
/>
<span className="text-gray-300">Show Legend</span>
</label>
</div>
</div>
)}
{/* Symbol Pills */}
{showLegend && symbols.length > 0 && (
<div className="flex flex-wrap gap-2 mb-3">
{symbols.map((symbol) => (
<div
key={symbol.symbol}
className={`flex items-center gap-1.5 px-2 py-1 rounded text-xs transition-opacity ${
symbol.visible ? 'opacity-100' : 'opacity-50'
}`}
style={{ backgroundColor: `${symbol.color}20`, borderColor: symbol.color, borderWidth: 1 }}
>
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: symbol.color }} />
<span className="text-white font-medium">{symbol.symbol}</span>
<span className={symbol.changePercent >= 0 ? 'text-green-400' : 'text-red-400'}>
{symbol.changePercent >= 0 ? '+' : ''}{symbol.changePercent.toFixed(2)}%
</span>
{onToggleVisibility && (
<button
onClick={() => onToggleVisibility(symbol.symbol)}
className="p-0.5 hover:bg-gray-700 rounded"
>
{symbol.visible ? (
<EyeIcon className="w-3 h-3 text-gray-400" />
) : (
<EyeSlashIcon className="w-3 h-3 text-gray-400" />
)}
</button>
)}
{onRemoveSymbol && (
<button
onClick={() => onRemoveSymbol(symbol.symbol)}
className="p-0.5 hover:bg-red-500/20 rounded"
>
<XMarkIcon className="w-3 h-3 text-gray-400 hover:text-red-400" />
</button>
)}
</div>
))}
</div>
)}
{/* Chart Container */}
<div ref={containerRef} className="relative flex-1" style={{ minHeight: height }}>
<canvas
ref={canvasRef}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
className="w-full cursor-crosshair"
style={{ height }}
/>
{/* Tooltip */}
{hoveredPoint && (
<div
className="absolute pointer-events-none bg-gray-900 border border-gray-700 rounded px-3 py-2 text-xs shadow-lg z-10"
style={{
left: hoveredPoint.x + 15,
top: 30,
transform: hoveredPoint.x > (canvasRef.current?.width || 0) - 150 ? 'translateX(-100%)' : 'none',
}}
>
<div className="text-gray-400 mb-1">
{new Date(hoveredPoint.timestamp).toLocaleDateString(undefined, {
weekday: 'short',
month: 'short',
day: 'numeric',
year: 'numeric',
})}
</div>
{hoveredPoint.values.map((v) => (
<div key={v.symbol} className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: v.color }} />
<span className="text-white">{v.symbol}:</span>
<span style={{ color: v.color }}>
{normalization === 'percentage'
? `${v.value >= 0 ? '+' : ''}${v.value.toFixed(2)}%`
: v.value.toFixed(2)}
</span>
</div>
))}
</div>
)}
{/* Loading Overlay */}
{isLoading && (
<div className="absolute inset-0 bg-gray-900/50 flex items-center justify-center">
<ArrowPathIcon className="w-8 h-8 text-cyan-400 animate-spin" />
</div>
)}
{/* Empty State */}
{!isLoading && symbols.length === 0 && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="text-center text-gray-500">
<ChartBarSquareIcon className="w-12 h-12 mx-auto mb-2 opacity-50" />
<p className="text-sm">No symbols to compare</p>
{onAddSymbol && (
<button
onClick={onAddSymbol}
className="mt-2 px-3 py-1 bg-blue-500 text-white rounded text-xs hover:bg-blue-600"
>
Add Symbol
</button>
)}
</div>
</div>
)}
</div>
</div>
);
};
export default SymbolComparisonChart;

View File

@ -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<ScreenerFilter, 'id'>[] }[] = [
{
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<TradingScreenerProps> = ({
results,
savedScreeners = [],
onSearch,
onFilterChange,
onSaveScreener,
onLoadScreener,
onDeleteScreener,
onSymbolClick,
onAddToWatchlist,
onToggleFavorite,
onExport,
onRefresh,
isLoading = false,
compact = false,
}) => {
const [searchQuery, setSearchQuery] = useState('');
const [filters, setFilters] = useState<ScreenerFilter[]>([]);
const [showFilters, setShowFilters] = useState(false);
const [showSavedScreeners, setShowSavedScreeners] = useState(false);
const [sortField, setSortField] = useState<SortField>('changePercent');
const [sortDirection, setSortDirection] = useState<SortDirection>('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<ScreenerFilter>) => {
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' ? (
<ChevronUpIcon className="w-3 h-3 inline ml-1" />
) : (
<ChevronDownIcon className="w-3 h-3 inline ml-1" />
);
};
return (
<div className={`bg-gray-800/50 rounded-lg ${compact ? 'p-2' : 'p-4'} flex flex-col h-full`}>
{/* Header */}
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<FunnelIcon className="w-5 h-5 text-amber-400" />
<h3 className={`font-semibold text-white ${compact ? 'text-xs' : 'text-sm'}`}>
Stock Screener
</h3>
<span className="text-xs text-gray-400">
{filteredResults.length} / {results.length} results
</span>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => setShowFilters(!showFilters)}
className={`p-1.5 rounded transition-colors ${showFilters ? 'bg-amber-500/20 text-amber-400' : 'text-gray-400 hover:text-white hover:bg-gray-700'}`}
title="Filters"
>
<AdjustmentsHorizontalIcon className="w-4 h-4" />
</button>
<button
onClick={() => setShowSavedScreeners(!showSavedScreeners)}
className={`p-1.5 rounded transition-colors ${showSavedScreeners ? 'bg-blue-500/20 text-blue-400' : 'text-gray-400 hover:text-white hover:bg-gray-700'}`}
title="Saved Screeners"
>
<BookmarkIcon className="w-4 h-4" />
</button>
{onExport && (
<button
onClick={() => onExport('csv')}
className="p-1.5 text-gray-400 hover:text-white hover:bg-gray-700 rounded transition-colors"
title="Export"
>
<ArrowDownTrayIcon className="w-4 h-4" />
</button>
)}
{onRefresh && (
<button
onClick={onRefresh}
disabled={isLoading}
className="p-1.5 text-gray-400 hover:text-white hover:bg-gray-700 rounded transition-colors"
title="Refresh"
>
<ArrowPathIcon className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />
</button>
)}
</div>
</div>
{/* Search Bar */}
<div className="relative mb-3">
<MagnifyingGlassIcon className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="text"
value={searchQuery}
onChange={(e) => {
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"
/>
</div>
{/* Filter Presets */}
<div className="flex flex-wrap gap-1 mb-3">
{FILTER_PRESETS.map((preset) => (
<button
key={preset.name}
onClick={() => loadPreset(preset)}
className="px-2 py-1 text-xs bg-gray-700 text-gray-300 hover:bg-gray-600 rounded transition-colors"
>
{preset.name}
</button>
))}
</div>
{/* Saved Screeners Panel */}
{showSavedScreeners && (
<div className="mb-3 p-3 bg-gray-900/50 rounded-lg">
<div className="text-xs text-gray-400 mb-2">Saved Screeners</div>
{savedScreeners.length > 0 ? (
<div className="space-y-1">
{savedScreeners.map((screener) => (
<div
key={screener.id}
className="flex items-center justify-between p-2 bg-gray-800 rounded hover:bg-gray-700 transition-colors"
>
<button
onClick={() => onLoadScreener?.(screener)}
className="text-sm text-white hover:text-blue-400"
>
{screener.name}
</button>
<button
onClick={() => onDeleteScreener?.(screener.id)}
className="p-1 text-gray-400 hover:text-red-400"
>
<TrashIcon className="w-3 h-3" />
</button>
</div>
))}
</div>
) : (
<div className="text-xs text-gray-500">No saved screeners</div>
)}
{/* Save Current */}
{filters.length > 0 && onSaveScreener && (
<div className="flex gap-2 mt-2">
<input
type="text"
value={newScreenerName}
onChange={(e) => 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"
/>
<button
onClick={handleSaveScreener}
disabled={!newScreenerName.trim()}
className="px-2 py-1 bg-blue-500 text-white text-xs rounded hover:bg-blue-600 disabled:opacity-50"
>
Save
</button>
</div>
)}
</div>
)}
{/* Filters Panel */}
{showFilters && (
<div className="mb-3 p-3 bg-gray-900/50 rounded-lg">
<div className="flex items-center justify-between mb-2">
<span className="text-xs text-gray-400">Active Filters</span>
<button
onClick={addFilter}
className="flex items-center gap-1 px-2 py-1 text-xs bg-gray-700 text-white rounded hover:bg-gray-600"
>
<PlusIcon className="w-3 h-3" />
Add Filter
</button>
</div>
{filters.length > 0 ? (
<div className="space-y-2">
{filters.map((filter) => (
<div
key={filter.id}
className="flex items-center gap-2 p-2 bg-gray-800 rounded"
>
<input
type="checkbox"
checked={filter.enabled}
onChange={(e) => updateFilter(filter.id, { enabled: e.target.checked })}
className="rounded bg-gray-700 border-gray-600"
/>
<select
value={filter.field as string}
onChange={(e) => updateFilter(filter.id, { field: e.target.value as keyof ScreenerResult })}
className="bg-gray-700 border border-gray-600 rounded px-2 py-1 text-xs text-white"
>
{FILTERABLE_FIELDS.map((f) => (
<option key={f.field as string} value={f.field as string}>
{f.label}
</option>
))}
</select>
<select
value={filter.operator}
onChange={(e) => updateFilter(filter.id, { operator: e.target.value as ScreenerFilter['operator'] })}
className="bg-gray-700 border border-gray-600 rounded px-2 py-1 text-xs text-white"
>
<option value="gt">&gt;</option>
<option value="gte">&gt;=</option>
<option value="lt">&lt;</option>
<option value="lte">&lt;=</option>
<option value="eq">=</option>
<option value="contains">contains</option>
</select>
<input
type={FILTERABLE_FIELDS.find((f) => 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"
/>
<button
onClick={() => removeFilter(filter.id)}
className="p-1 text-gray-400 hover:text-red-400"
>
<TrashIcon className="w-3 h-3" />
</button>
</div>
))}
</div>
) : (
<div className="text-xs text-gray-500 text-center py-2">No filters applied</div>
)}
</div>
)}
{/* Results Table */}
<div className="flex-1 overflow-auto">
<table className="w-full text-xs">
<thead className="sticky top-0 bg-gray-800">
<tr className="border-b border-gray-700">
<th className="text-left py-2 px-2 text-gray-400 font-medium">
<button onClick={() => handleSort('symbol')} className="hover:text-white">
Symbol{renderSortIndicator('symbol')}
</button>
</th>
<th className="text-right py-2 px-2 text-gray-400 font-medium">
<button onClick={() => handleSort('price')} className="hover:text-white">
Price{renderSortIndicator('price')}
</button>
</th>
<th className="text-right py-2 px-2 text-gray-400 font-medium">
<button onClick={() => handleSort('changePercent')} className="hover:text-white">
Change{renderSortIndicator('changePercent')}
</button>
</th>
<th className="text-right py-2 px-2 text-gray-400 font-medium hidden sm:table-cell">
<button onClick={() => handleSort('volume')} className="hover:text-white">
Volume{renderSortIndicator('volume')}
</button>
</th>
<th className="text-right py-2 px-2 text-gray-400 font-medium hidden md:table-cell">
<button onClick={() => handleSort('marketCap')} className="hover:text-white">
Mkt Cap{renderSortIndicator('marketCap')}
</button>
</th>
<th className="text-right py-2 px-2 text-gray-400 font-medium hidden lg:table-cell">
<button onClick={() => handleSort('rsi')} className="hover:text-white">
RSI{renderSortIndicator('rsi')}
</button>
</th>
<th className="text-center py-2 px-2 text-gray-400 font-medium w-20">Actions</th>
</tr>
</thead>
<tbody>
{filteredResults.map((result) => (
<tr
key={result.symbol}
className="border-b border-gray-700/50 hover:bg-gray-700/30 transition-colors cursor-pointer"
onClick={() => onSymbolClick?.(result.symbol)}
>
<td className="py-2 px-2">
<div className="flex items-center gap-2">
<button
onClick={(e) => {
e.stopPropagation();
onToggleFavorite?.(result.symbol);
}}
className="text-gray-400 hover:text-yellow-400"
>
{result.isFavorite ? (
<StarIcon className="w-4 h-4 text-yellow-400" />
) : (
<StarOutlineIcon className="w-4 h-4" />
)}
</button>
<div>
<div className="text-white font-medium">{result.symbol}</div>
<div className="text-gray-500 text-[10px] truncate max-w-[100px]">{result.name}</div>
</div>
</div>
</td>
<td className="py-2 px-2 text-right text-white font-mono">{formatPrice(result.price)}</td>
<td className={`py-2 px-2 text-right font-mono ${result.changePercent >= 0 ? 'text-green-400' : 'text-red-400'}`}>
{result.changePercent >= 0 ? '+' : ''}{result.changePercent.toFixed(2)}%
</td>
<td className="py-2 px-2 text-right text-gray-300 font-mono hidden sm:table-cell">
{formatNumber(result.volume, 0)}
</td>
<td className="py-2 px-2 text-right text-gray-300 font-mono hidden md:table-cell">
{formatNumber(result.marketCap, 1)}
</td>
<td className="py-2 px-2 text-right hidden lg:table-cell">
{result.rsi !== undefined && (
<span
className={`font-mono ${
result.rsi > 70 ? 'text-red-400' : result.rsi < 30 ? 'text-green-400' : 'text-gray-300'
}`}
>
{result.rsi.toFixed(0)}
</span>
)}
</td>
<td className="py-2 px-2">
<div className="flex items-center justify-center gap-1">
<button
onClick={(e) => {
e.stopPropagation();
onSymbolClick?.(result.symbol);
}}
className="p-1 text-gray-400 hover:text-blue-400 hover:bg-blue-500/20 rounded"
title="View Chart"
>
<ChartBarIcon className="w-4 h-4" />
</button>
{onAddToWatchlist && (
<button
onClick={(e) => {
e.stopPropagation();
onAddToWatchlist(result.symbol);
}}
className="p-1 text-gray-400 hover:text-green-400 hover:bg-green-500/20 rounded"
title="Add to Watchlist"
>
<PlusIcon className="w-4 h-4" />
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
{/* Empty State */}
{!isLoading && filteredResults.length === 0 && (
<div className="flex flex-col items-center justify-center py-12 text-gray-500">
<FunnelIcon className="w-12 h-12 mb-2 opacity-50" />
<p className="text-sm">No results match your criteria</p>
<button
onClick={() => {
setSearchQuery('');
setFilters([]);
}}
className="mt-2 text-xs text-blue-400 hover:underline"
>
Clear all filters
</button>
</div>
)}
{/* Loading State */}
{isLoading && (
<div className="flex items-center justify-center py-12">
<ArrowPathIcon className="w-8 h-8 text-amber-400 animate-spin" />
</div>
)}
</div>
</div>
);
};
export default TradingScreener;

View File

@ -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';