[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
|
||||
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';
|
||||
|
||||
Loading…
Reference in New Issue
Block a user