[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:
parent
5ee7f14f25
commit
c145878c24
457
src/modules/trading/components/MarketDepthPanel.tsx
Normal file
457
src/modules/trading/components/MarketDepthPanel.tsx
Normal 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 (>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;
|
||||||
518
src/modules/trading/components/OrderBookDepthVisualization.tsx
Normal file
518
src/modules/trading/components/OrderBookDepthVisualization.tsx
Normal 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;
|
||||||
619
src/modules/trading/components/SymbolComparisonChart.tsx
Normal file
619
src/modules/trading/components/SymbolComparisonChart.tsx
Normal 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;
|
||||||
643
src/modules/trading/components/TradingScreener.tsx
Normal file
643
src/modules/trading/components/TradingScreener.tsx
Normal 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">></option>
|
||||||
|
<option value="gte">>=</option>
|
||||||
|
<option value="lt"><</option>
|
||||||
|
<option value="lte"><=</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;
|
||||||
@ -41,6 +41,16 @@ export { default as PaperTradingPanel } from './PaperTradingPanel';
|
|||||||
// Utility Components
|
// Utility Components
|
||||||
export { default as ExportButton } from './ExportButton';
|
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)
|
// MT4 Gateway Components (OQI-009)
|
||||||
export { default as MT4ConnectionStatus } from './MT4ConnectionStatus';
|
export { default as MT4ConnectionStatus } from './MT4ConnectionStatus';
|
||||||
export { default as LivePositionCard } from './LivePositionCard';
|
export { default as LivePositionCard } from './LivePositionCard';
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user