From 4d2c00ac301c8ba397f4f5ca3f0af8c0a4c7966b Mon Sep 17 00:00:00 2001 From: Adrian Flores Cortes Date: Sun, 25 Jan 2026 11:23:33 -0600 Subject: [PATCH] [OQI-009] feat: Add MT4 trading components and WebSocket hook - QuickOrderPanel: One-click trading with lot presets - TradeExecutionHistory: Session trade history with P&L stats - TradingMetricsCard: Daily trading metrics and performance - useMT4WebSocket: Real-time account/position updates hook Co-Authored-By: Claude Opus 4.5 --- .../trading/components/QuickOrderPanel.tsx | 332 +++++++++++++++ .../components/TradeExecutionHistory.tsx | 401 ++++++++++++++++++ .../trading/components/TradingMetricsCard.tsx | 316 ++++++++++++++ src/modules/trading/components/index.ts | 3 + src/modules/trading/hooks/index.ts | 11 + src/modules/trading/hooks/useMT4WebSocket.ts | 337 +++++++++++++++ 6 files changed, 1400 insertions(+) create mode 100644 src/modules/trading/components/QuickOrderPanel.tsx create mode 100644 src/modules/trading/components/TradeExecutionHistory.tsx create mode 100644 src/modules/trading/components/TradingMetricsCard.tsx create mode 100644 src/modules/trading/hooks/index.ts create mode 100644 src/modules/trading/hooks/useMT4WebSocket.ts diff --git a/src/modules/trading/components/QuickOrderPanel.tsx b/src/modules/trading/components/QuickOrderPanel.tsx new file mode 100644 index 0000000..8cb430e --- /dev/null +++ b/src/modules/trading/components/QuickOrderPanel.tsx @@ -0,0 +1,332 @@ +/** + * QuickOrderPanel Component + * Compact order entry for fast one-click trading + */ + +import React, { useState, useCallback } from 'react'; +import { + TrendingUp, + TrendingDown, + Loader2, + Settings, + Zap, + AlertTriangle, + Check, +} from 'lucide-react'; +import { executeMT4Trade } from '../../../services/trading.service'; + +interface QuickOrderPanelProps { + symbol: string; + currentPrice: number; + spread?: number; + accountBalance?: number; + onOrderExecuted?: (ticket: number, type: 'buy' | 'sell') => void; + onError?: (error: string) => void; + compact?: boolean; +} + +interface LotPreset { + label: string; + lots: number; +} + +const DEFAULT_PRESETS: LotPreset[] = [ + { label: '0.01', lots: 0.01 }, + { label: '0.05', lots: 0.05 }, + { label: '0.10', lots: 0.10 }, + { label: '0.25', lots: 0.25 }, + { label: '0.50', lots: 0.50 }, + { label: '1.00', lots: 1.00 }, +]; + +const QuickOrderPanel: React.FC = ({ + symbol, + currentPrice, + spread = 0, + accountBalance = 0, + onOrderExecuted, + onError, + compact = false, +}) => { + const [selectedLots, setSelectedLots] = useState(0.01); + const [customLots, setCustomLots] = useState(''); + const [isExecuting, setIsExecuting] = useState<'buy' | 'sell' | null>(null); + const [lastResult, setLastResult] = useState<{ type: 'success' | 'error'; message: string } | null>(null); + const [showSettings, setShowSettings] = useState(false); + const [slPips, setSlPips] = useState(20); + const [tpPips, setTpPips] = useState(40); + const [useSLTP, setUseSLTP] = useState(true); + + const activeLots = customLots ? parseFloat(customLots) : selectedLots; + const bidPrice = currentPrice - spread / 2; + const askPrice = currentPrice + spread / 2; + + // Calculate pip value (simplified - assumes forex major pairs) + const pipValue = symbol.includes('JPY') ? 0.01 : 0.0001; + + const calculateSLTP = (type: 'buy' | 'sell') => { + const price = type === 'buy' ? askPrice : bidPrice; + if (!useSLTP) return { sl: undefined, tp: undefined }; + + if (type === 'buy') { + return { + sl: price - slPips * pipValue, + tp: price + tpPips * pipValue, + }; + } else { + return { + sl: price + slPips * pipValue, + tp: price - tpPips * pipValue, + }; + } + }; + + const estimateRisk = () => { + if (!accountBalance || !useSLTP) return null; + // Rough estimate: lot * pip value * SL pips + const riskPerPip = activeLots * 10; // ~$10 per pip per lot for majors + const totalRisk = riskPerPip * slPips; + const riskPercent = (totalRisk / accountBalance) * 100; + return { totalRisk, riskPercent }; + }; + + const risk = estimateRisk(); + + const executeOrder = useCallback(async (type: 'buy' | 'sell') => { + if (isExecuting || activeLots <= 0) return; + + setIsExecuting(type); + setLastResult(null); + + try { + const { sl, tp } = calculateSLTP(type); + + const result = await executeMT4Trade({ + symbol, + type: type === 'buy' ? 'BUY' : 'SELL', + lots: activeLots, + stopLoss: sl, + takeProfit: tp, + }); + + setLastResult({ type: 'success', message: `#${result.ticket} ${type.toUpperCase()} ${activeLots} lots` }); + onOrderExecuted?.(result.ticket, type); + + // Clear success message after 3 seconds + setTimeout(() => setLastResult(null), 3000); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Order failed'; + setLastResult({ type: 'error', message: errorMessage }); + onError?.(errorMessage); + } finally { + setIsExecuting(null); + } + }, [symbol, activeLots, useSLTP, slPips, tpPips, askPrice, bidPrice, onOrderExecuted, onError]); + + if (compact) { + return ( +
+ {symbol} + + + +
+ ); + } + + return ( +
+ {/* Header */} +
+
+ +

Quick Order

+
+ +
+ + {/* Symbol & Price */} +
+
{symbol}
+
+ {bidPrice.toFixed(5)} + | + {askPrice.toFixed(5)} +
+ {spread > 0 && ( +
+ Spread: {(spread / pipValue).toFixed(1)} pips +
+ )} +
+ + {/* Lot Size Presets */} +
+ +
+ {DEFAULT_PRESETS.map((preset) => ( + + ))} +
+ setCustomLots(e.target.value)} + placeholder="Custom lots..." + step="0.01" + min="0.01" + className="w-full mt-2 px-3 py-2 bg-gray-900 border border-gray-700 rounded-lg text-white text-sm placeholder-gray-500 focus:outline-none focus:border-blue-500" + /> +
+ + {/* Settings Panel */} + {showSettings && ( +
+ + + {useSLTP && ( +
+
+ + setSlPips(parseInt(e.target.value) || 0)} + className="w-full px-2 py-1.5 bg-gray-800 border border-gray-700 rounded text-sm text-white" + /> +
+
+ + setTpPips(parseInt(e.target.value) || 0)} + className="w-full px-2 py-1.5 bg-gray-800 border border-gray-700 rounded text-sm text-white" + /> +
+
+ )} +
+ )} + + {/* Risk Warning */} + {risk && risk.riskPercent > 2 && ( +
+ + + Risk: ${risk.totalRisk.toFixed(2)} ({risk.riskPercent.toFixed(1)}% of balance) + +
+ )} + + {/* Buy/Sell Buttons */} +
+ + +
+ + {/* Result Message */} + {lastResult && ( +
+ {lastResult.type === 'success' ? ( + + ) : ( + + )} + + {lastResult.message} + +
+ )} + + {/* Active Lots Display */} +
+ Trading {activeLots.toFixed(2)} lots +
+
+ ); +}; + +export default QuickOrderPanel; diff --git a/src/modules/trading/components/TradeExecutionHistory.tsx b/src/modules/trading/components/TradeExecutionHistory.tsx new file mode 100644 index 0000000..2efaf39 --- /dev/null +++ b/src/modules/trading/components/TradeExecutionHistory.tsx @@ -0,0 +1,401 @@ +/** + * TradeExecutionHistory Component + * Display session/daily trade history with performance stats + */ + +import React, { useState, useEffect } from 'react'; +import { + History, + TrendingUp, + TrendingDown, + Clock, + DollarSign, + Target, + XCircle, + Download, + RefreshCw, + Loader2, + ChevronDown, + ChevronUp, + Filter, +} from 'lucide-react'; + +interface HistoricalTrade { + ticket: number; + symbol: string; + type: 'BUY' | 'SELL'; + lots: number; + openPrice: number; + closePrice: number; + openTime: string; + closeTime: string; + profit: number; + commission: number; + swap: number; + stopLoss?: number; + takeProfit?: number; + closedBy: 'manual' | 'sl' | 'tp' | 'margin_call'; +} + +interface TradeExecutionHistoryProps { + trades?: HistoricalTrade[]; + onRefresh?: () => Promise; + maxItems?: number; + showExport?: boolean; + compact?: boolean; +} + +type SortField = 'closeTime' | 'profit' | 'symbol'; +type SortDirection = 'asc' | 'desc'; +type FilterType = 'all' | 'profitable' | 'losing'; + +const TradeExecutionHistory: React.FC = ({ + trades: initialTrades = [], + onRefresh, + maxItems = 50, + showExport = true, + compact = false, +}) => { + const [trades, setTrades] = useState(initialTrades); + const [loading, setLoading] = useState(false); + const [sortField, setSortField] = useState('closeTime'); + const [sortDirection, setSortDirection] = useState('desc'); + const [filterType, setFilterType] = useState('all'); + const [expandedTrade, setExpandedTrade] = useState(null); + + useEffect(() => { + setTrades(initialTrades); + }, [initialTrades]); + + const handleRefresh = async () => { + if (!onRefresh) return; + setLoading(true); + try { + const newTrades = await onRefresh(); + setTrades(newTrades); + } catch (err) { + console.error('Failed to refresh trades:', err); + } finally { + setLoading(false); + } + }; + + // Calculate summary stats + const stats = React.useMemo(() => { + const winning = trades.filter((t) => t.profit > 0); + const losing = trades.filter((t) => t.profit < 0); + const totalProfit = trades.reduce((sum, t) => sum + t.profit, 0); + const totalCommission = trades.reduce((sum, t) => sum + t.commission, 0); + const totalSwap = trades.reduce((sum, t) => sum + t.swap, 0); + const netProfit = totalProfit - totalCommission + totalSwap; + const winRate = trades.length > 0 ? (winning.length / trades.length) * 100 : 0; + const avgWin = winning.length > 0 + ? winning.reduce((sum, t) => sum + t.profit, 0) / winning.length + : 0; + const avgLoss = losing.length > 0 + ? Math.abs(losing.reduce((sum, t) => sum + t.profit, 0) / losing.length) + : 0; + const profitFactor = avgLoss > 0 ? avgWin / avgLoss : avgWin > 0 ? Infinity : 0; + + return { + totalTrades: trades.length, + winning: winning.length, + losing: losing.length, + breakeven: trades.length - winning.length - losing.length, + totalProfit, + netProfit, + totalCommission, + totalSwap, + winRate, + avgWin, + avgLoss, + profitFactor, + bestTrade: trades.length > 0 ? Math.max(...trades.map((t) => t.profit)) : 0, + worstTrade: trades.length > 0 ? Math.min(...trades.map((t) => t.profit)) : 0, + }; + }, [trades]); + + // Filter and sort trades + const filteredTrades = React.useMemo(() => { + let result = [...trades]; + + // Apply filter + if (filterType === 'profitable') { + result = result.filter((t) => t.profit > 0); + } else if (filterType === 'losing') { + result = result.filter((t) => t.profit < 0); + } + + // Apply sort + result.sort((a, b) => { + let comparison = 0; + switch (sortField) { + case 'closeTime': + comparison = new Date(a.closeTime).getTime() - new Date(b.closeTime).getTime(); + break; + case 'profit': + comparison = a.profit - b.profit; + break; + case 'symbol': + comparison = a.symbol.localeCompare(b.symbol); + break; + } + return sortDirection === 'asc' ? comparison : -comparison; + }); + + return result.slice(0, maxItems); + }, [trades, filterType, sortField, sortDirection, maxItems]); + + const handleSort = (field: SortField) => { + if (sortField === field) { + setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc'); + } else { + setSortField(field); + setSortDirection('desc'); + } + }; + + const formatTime = (dateString: string) => { + const date = new Date(dateString); + return date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }); + }; + + const formatDate = (dateString: string) => { + const date = new Date(dateString); + return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); + }; + + const getCloseReasonIcon = (closedBy: HistoricalTrade['closedBy']) => { + switch (closedBy) { + case 'tp': + return ; + case 'sl': + return ; + case 'margin_call': + return ; + default: + return ; + } + }; + + const handleExport = () => { + const headers = ['Ticket', 'Symbol', 'Type', 'Lots', 'Open Price', 'Close Price', 'Open Time', 'Close Time', 'Profit', 'Commission', 'Swap', 'Closed By']; + const rows = trades.map((t) => [ + t.ticket, + t.symbol, + t.type, + t.lots, + t.openPrice, + t.closePrice, + t.openTime, + t.closeTime, + t.profit.toFixed(2), + t.commission.toFixed(2), + t.swap.toFixed(2), + t.closedBy, + ]); + + const csv = [headers, ...rows].map((row) => row.join(',')).join('\n'); + const blob = new Blob([csv], { type: 'text/csv' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `trade-history-${new Date().toISOString().split('T')[0]}.csv`; + a.click(); + URL.revokeObjectURL(url); + }; + + return ( +
+ {/* Header */} +
+
+ +

Trade History

+ ({trades.length}) +
+
+ {onRefresh && ( + + )} + {showExport && trades.length > 0 && ( + + )} +
+
+ + {/* Summary Stats */} + {!compact && trades.length > 0 && ( +
+
+
= 0 ? 'text-green-400' : 'text-red-400'}`}> + {stats.netProfit >= 0 ? '+' : ''}{stats.netProfit.toFixed(2)} +
+
Net P&L
+
+
+
{stats.winRate.toFixed(1)}%
+
Win Rate
+
+
+
+ {stats.profitFactor === Infinity ? '∞' : stats.profitFactor.toFixed(2)} +
+
Profit Factor
+
+
+
+ {stats.winning} + / + {stats.losing} +
+
W/L
+
+
+ )} + + {/* Filter & Sort */} +
+
+ + +
+
+ Sort: + + +
+
+ + {/* Trade List */} + {loading && trades.length === 0 ? ( +
+ +
+ ) : filteredTrades.length === 0 ? ( +
+ +

No trades yet

+
+ ) : ( +
+ {filteredTrades.map((trade) => ( +
+
setExpandedTrade(expandedTrade === trade.ticket ? null : trade.ticket)} + className="flex items-center justify-between p-3 cursor-pointer hover:bg-gray-800/50 transition-colors" + > +
+
+ {trade.type === 'BUY' ? ( + + ) : ( + + )} +
+
+
+ {trade.symbol} + {trade.lots} lots + {getCloseReasonIcon(trade.closedBy)} +
+
+ {formatDate(trade.closeTime)} {formatTime(trade.closeTime)} +
+
+
+
+
= 0 ? 'text-green-400' : 'text-red-400'}`}> + {trade.profit >= 0 ? '+' : ''}{trade.profit.toFixed(2)} +
+ {expandedTrade === trade.ticket ? ( + + ) : ( + + )} +
+
+ + {/* Expanded Details */} + {expandedTrade === trade.ticket && ( +
+
+
+
Entry
+
{trade.openPrice.toFixed(5)}
+
{formatTime(trade.openTime)}
+
+
+
Exit
+
{trade.closePrice.toFixed(5)}
+
{formatTime(trade.closeTime)}
+
+
+
Fees
+
+ {(trade.commission + trade.swap).toFixed(2)} +
+
+ C: {trade.commission.toFixed(2)} / S: {trade.swap.toFixed(2)} +
+
+
+ {(trade.stopLoss || trade.takeProfit) && ( +
+ {trade.stopLoss && ( +
+ SL:{' '} + {trade.stopLoss.toFixed(5)} +
+ )} + {trade.takeProfit && ( +
+ TP:{' '} + {trade.takeProfit.toFixed(5)} +
+ )} +
+ )} +
+ )} +
+ ))} +
+ )} +
+ ); +}; + +export default TradeExecutionHistory; diff --git a/src/modules/trading/components/TradingMetricsCard.tsx b/src/modules/trading/components/TradingMetricsCard.tsx new file mode 100644 index 0000000..a283370 --- /dev/null +++ b/src/modules/trading/components/TradingMetricsCard.tsx @@ -0,0 +1,316 @@ +/** + * TradingMetricsCard Component + * Display daily/session trading performance metrics + */ + +import React, { useMemo } from 'react'; +import { + BarChart3, + TrendingUp, + TrendingDown, + Target, + Award, + Flame, + Clock, + DollarSign, + Percent, + ArrowUpCircle, + ArrowDownCircle, +} from 'lucide-react'; + +interface DailyMetrics { + date: string; + totalTrades: number; + winningTrades: number; + losingTrades: number; + grossProfit: number; + grossLoss: number; + netProfit: number; + commission: number; + swap: number; + pips: number; + volume: number; // total lots + largestWin: number; + largestLoss: number; + avgWin: number; + avgLoss: number; + consecutiveWins: number; + consecutiveLosses: number; + tradingHours: number; +} + +interface TradingMetricsCardProps { + metrics: DailyMetrics; + previousMetrics?: DailyMetrics; + showComparison?: boolean; + compact?: boolean; +} + +const TradingMetricsCard: React.FC = ({ + metrics, + previousMetrics, + showComparison = true, + compact = false, +}) => { + const winRate = metrics.totalTrades > 0 + ? (metrics.winningTrades / metrics.totalTrades) * 100 + : 0; + + const profitFactor = metrics.grossLoss > 0 + ? Math.abs(metrics.grossProfit / metrics.grossLoss) + : metrics.grossProfit > 0 ? Infinity : 0; + + const expectancy = metrics.totalTrades > 0 + ? metrics.netProfit / metrics.totalTrades + : 0; + + const riskRewardRatio = metrics.avgLoss > 0 + ? metrics.avgWin / Math.abs(metrics.avgLoss) + : metrics.avgWin > 0 ? Infinity : 0; + + // Comparison calculations + const comparison = useMemo(() => { + if (!previousMetrics || !showComparison) return null; + + const prevWinRate = previousMetrics.totalTrades > 0 + ? (previousMetrics.winningTrades / previousMetrics.totalTrades) * 100 + : 0; + + return { + netProfitChange: metrics.netProfit - previousMetrics.netProfit, + winRateChange: winRate - prevWinRate, + tradesChange: metrics.totalTrades - previousMetrics.totalTrades, + pipsChange: metrics.pips - previousMetrics.pips, + }; + }, [metrics, previousMetrics, showComparison, winRate]); + + const formatChange = (value: number, suffix: string = '') => { + const sign = value >= 0 ? '+' : ''; + return `${sign}${value.toFixed(value % 1 === 0 ? 0 : 1)}${suffix}`; + }; + + const ChangeIndicator = ({ value, suffix = '' }: { value: number; suffix?: string }) => ( + = 0 ? 'text-green-400' : 'text-red-400'}`}> + {formatChange(value, suffix)} + + ); + + if (compact) { + return ( +
+
+
+ + Today +
+
= 0 ? 'text-green-400' : 'text-red-400'}`}> + {metrics.netProfit >= 0 ? '+' : ''}{metrics.netProfit.toFixed(2)} +
+
+
+ {metrics.totalTrades} trades + {winRate.toFixed(0)}% win + {metrics.pips >= 0 ? '+' : ''}{metrics.pips} pips +
+
+ ); + } + + return ( +
+ {/* Header */} +
+
+ +

Daily Metrics

+
+
+ {new Date(metrics.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })} +
+
+ + {/* Main P&L */} +
+
= 0 ? 'text-green-400' : 'text-red-400'}`}> + {metrics.netProfit >= 0 ? '+' : ''}${metrics.netProfit.toFixed(2)} +
+
Net Profit/Loss
+ {comparison && ( +
+ +
+ )} +
+ + {/* Key Stats Grid */} +
+ {/* Win Rate */} +
+
+ +
+
{winRate.toFixed(1)}%
+
Win Rate
+ {comparison && } +
+ + {/* Profit Factor */} +
+
+ +
+
+ {profitFactor === Infinity ? '∞' : profitFactor.toFixed(2)} +
+
Profit Factor
+
+ + {/* Total Trades */} +
+
+ +
+
{metrics.totalTrades}
+
Trades
+ {comparison && } +
+
+ + {/* Win/Loss Breakdown */} +
+
+
+
+ + Winners +
+ {metrics.winningTrades} +
+
+ Gross: +${metrics.grossProfit.toFixed(2)} +
+
+
+
+
+ + Losers +
+ {metrics.losingTrades} +
+
+ Gross: ${metrics.grossLoss.toFixed(2)} +
+
+
+ + {/* Detailed Stats */} +
+
+
+ + Avg Win / Loss +
+
+ ${metrics.avgWin.toFixed(2)} + / + ${Math.abs(metrics.avgLoss).toFixed(2)} +
+
+ +
+
+ + R:R Ratio +
+
+ {riskRewardRatio === Infinity ? '∞' : riskRewardRatio.toFixed(2)} +
+
+ +
+
+ + Total Pips +
+
= 0 ? 'text-green-400' : 'text-red-400'}`}> + {metrics.pips >= 0 ? '+' : ''}{metrics.pips} +
+
+ +
+
+ + Volume +
+
{metrics.volume.toFixed(2)} lots
+
+ +
+
+ + Trading Time +
+
{metrics.tradingHours.toFixed(1)}h
+
+ +
+
+ + Expectancy +
+
= 0 ? 'text-green-400' : 'text-red-400'}`}> + ${expectancy.toFixed(2)}/trade +
+
+
+ + {/* Streaks & Records */} +
+
+
+ + Best Trade +
+
+${metrics.largestWin.toFixed(2)}
+
+
+
+ + Worst Trade +
+
${metrics.largestLoss.toFixed(2)}
+
+
+ + {/* Consecutive Streaks */} +
+
+
Win Streak
+
{metrics.consecutiveWins}
+
+
+
+
Loss Streak
+
{metrics.consecutiveLosses}
+
+
+ + {/* Fees */} + {(metrics.commission !== 0 || metrics.swap !== 0) && ( +
+
+ Commission: + ${metrics.commission.toFixed(2)} +
+
+ Swap: + ${metrics.swap.toFixed(2)} +
+
+ )} +
+ ); +}; + +export default TradingMetricsCard; diff --git a/src/modules/trading/components/index.ts b/src/modules/trading/components/index.ts index e215eea..cc30c3b 100644 --- a/src/modules/trading/components/index.ts +++ b/src/modules/trading/components/index.ts @@ -40,3 +40,6 @@ export { default as RiskMonitor } from './RiskMonitor'; export { default as MT4PositionsManager } from './MT4PositionsManager'; export { default as AdvancedOrderEntry } from './AdvancedOrderEntry'; export { default as AccountHealthDashboard } from './AccountHealthDashboard'; +export { default as QuickOrderPanel } from './QuickOrderPanel'; +export { default as TradeExecutionHistory } from './TradeExecutionHistory'; +export { default as TradingMetricsCard } from './TradingMetricsCard'; diff --git a/src/modules/trading/hooks/index.ts b/src/modules/trading/hooks/index.ts new file mode 100644 index 0000000..b2ab51c --- /dev/null +++ b/src/modules/trading/hooks/index.ts @@ -0,0 +1,11 @@ +/** + * Trading Hooks - Index Export + * OQI-009: Trading Execution (MT4 Gateway) + */ + +export { useMT4WebSocket } from './useMT4WebSocket'; +export type { + MT4AccountInfo, + MT4Position, + MT4Order, +} from './useMT4WebSocket'; diff --git a/src/modules/trading/hooks/useMT4WebSocket.ts b/src/modules/trading/hooks/useMT4WebSocket.ts new file mode 100644 index 0000000..ff114d2 --- /dev/null +++ b/src/modules/trading/hooks/useMT4WebSocket.ts @@ -0,0 +1,337 @@ +/** + * useMT4WebSocket Hook + * Real-time WebSocket connection for MT4 account and position updates + */ + +import { useState, useEffect, useCallback, useRef } from 'react'; + +// Types +export interface MT4AccountInfo { + login: number; + name: string; + server: string; + balance: number; + equity: number; + margin: number; + freeMargin: number; + marginLevel: number; + leverage: number; + currency: string; + profit: number; +} + +export interface MT4Position { + ticket: number; + symbol: string; + type: 'BUY' | 'SELL'; + lots: number; + openPrice: number; + currentPrice: number; + stopLoss: number | null; + takeProfit: number | null; + profit: number; + swap: number; + commission: number; + openTime: string; + comment?: string; + magicNumber?: number; +} + +export interface MT4Order { + ticket: number; + symbol: string; + type: 'BUY_LIMIT' | 'SELL_LIMIT' | 'BUY_STOP' | 'SELL_STOP'; + lots: number; + price: number; + stopLoss: number | null; + takeProfit: number | null; + expiration?: string; + comment?: string; +} + +interface WebSocketMessage { + type: 'account' | 'positions' | 'orders' | 'trade' | 'error' | 'connected' | 'disconnected' | 'heartbeat'; + data?: unknown; + error?: string; + timestamp?: string; +} + +interface UseMT4WebSocketOptions { + url?: string; + autoConnect?: boolean; + reconnectInterval?: number; + maxReconnectAttempts?: number; + heartbeatInterval?: number; + onAccountUpdate?: (account: MT4AccountInfo) => void; + onPositionsUpdate?: (positions: MT4Position[]) => void; + onOrdersUpdate?: (orders: MT4Order[]) => void; + onTradeEvent?: (event: TradeEvent) => void; + onError?: (error: string) => void; + onConnectionChange?: (connected: boolean) => void; +} + +interface TradeEvent { + type: 'opened' | 'closed' | 'modified' | 'sl_hit' | 'tp_hit' | 'margin_call'; + ticket: number; + symbol: string; + profit?: number; + timestamp: string; +} + +interface UseMT4WebSocketReturn { + connected: boolean; + connecting: boolean; + account: MT4AccountInfo | null; + positions: MT4Position[]; + orders: MT4Order[]; + error: string | null; + lastUpdate: Date | null; + reconnectAttempts: number; + connect: () => void; + disconnect: () => void; + subscribe: (channels: string[]) => void; + unsubscribe: (channels: string[]) => void; +} + +const DEFAULT_WS_URL = 'ws://localhost:3082/mt4'; +const DEFAULT_RECONNECT_INTERVAL = 3000; +const DEFAULT_MAX_RECONNECT_ATTEMPTS = 10; +const DEFAULT_HEARTBEAT_INTERVAL = 30000; + +export function useMT4WebSocket(options: UseMT4WebSocketOptions = {}): UseMT4WebSocketReturn { + const { + url = DEFAULT_WS_URL, + autoConnect = true, + reconnectInterval = DEFAULT_RECONNECT_INTERVAL, + maxReconnectAttempts = DEFAULT_MAX_RECONNECT_ATTEMPTS, + heartbeatInterval = DEFAULT_HEARTBEAT_INTERVAL, + onAccountUpdate, + onPositionsUpdate, + onOrdersUpdate, + onTradeEvent, + onError, + onConnectionChange, + } = options; + + const [connected, setConnected] = useState(false); + const [connecting, setConnecting] = useState(false); + const [account, setAccount] = useState(null); + const [positions, setPositions] = useState([]); + const [orders, setOrders] = useState([]); + const [error, setError] = useState(null); + const [lastUpdate, setLastUpdate] = useState(null); + const [reconnectAttempts, setReconnectAttempts] = useState(0); + + const wsRef = useRef(null); + const reconnectTimeoutRef = useRef(null); + const heartbeatTimeoutRef = useRef(null); + const subscribedChannelsRef = useRef>(new Set(['account', 'positions', 'orders'])); + + // Cleanup function + const cleanup = useCallback(() => { + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current); + reconnectTimeoutRef.current = null; + } + if (heartbeatTimeoutRef.current) { + clearTimeout(heartbeatTimeoutRef.current); + heartbeatTimeoutRef.current = null; + } + }, []); + + // Reset heartbeat timer + const resetHeartbeat = useCallback(() => { + if (heartbeatTimeoutRef.current) { + clearTimeout(heartbeatTimeoutRef.current); + } + heartbeatTimeoutRef.current = setTimeout(() => { + // Send ping if connected + if (wsRef.current?.readyState === WebSocket.OPEN) { + wsRef.current.send(JSON.stringify({ type: 'ping' })); + } + resetHeartbeat(); + }, heartbeatInterval); + }, [heartbeatInterval]); + + // Handle incoming messages + const handleMessage = useCallback((event: MessageEvent) => { + try { + const message: WebSocketMessage = JSON.parse(event.data); + setLastUpdate(new Date()); + resetHeartbeat(); + + switch (message.type) { + case 'account': + const accountData = message.data as MT4AccountInfo; + setAccount(accountData); + onAccountUpdate?.(accountData); + break; + + case 'positions': + const positionsData = message.data as MT4Position[]; + setPositions(positionsData); + onPositionsUpdate?.(positionsData); + break; + + case 'orders': + const ordersData = message.data as MT4Order[]; + setOrders(ordersData); + onOrdersUpdate?.(ordersData); + break; + + case 'trade': + const tradeEvent = message.data as TradeEvent; + onTradeEvent?.(tradeEvent); + break; + + case 'error': + const errorMsg = message.error || 'Unknown WebSocket error'; + setError(errorMsg); + onError?.(errorMsg); + break; + + case 'connected': + setConnected(true); + setConnecting(false); + setError(null); + setReconnectAttempts(0); + onConnectionChange?.(true); + // Subscribe to channels + if (wsRef.current?.readyState === WebSocket.OPEN) { + wsRef.current.send(JSON.stringify({ + type: 'subscribe', + channels: Array.from(subscribedChannelsRef.current), + })); + } + break; + + case 'disconnected': + setConnected(false); + onConnectionChange?.(false); + break; + + case 'heartbeat': + // Server heartbeat response - connection is alive + break; + + default: + console.log('Unknown message type:', message.type); + } + } catch (err) { + console.error('Failed to parse WebSocket message:', err); + } + }, [onAccountUpdate, onPositionsUpdate, onOrdersUpdate, onTradeEvent, onError, onConnectionChange, resetHeartbeat]); + + // Connect to WebSocket + const connect = useCallback(() => { + if (wsRef.current?.readyState === WebSocket.OPEN || connecting) { + return; + } + + cleanup(); + setConnecting(true); + setError(null); + + try { + const ws = new WebSocket(url); + + ws.onopen = () => { + console.log('MT4 WebSocket connected'); + resetHeartbeat(); + }; + + ws.onmessage = handleMessage; + + ws.onerror = (event) => { + console.error('MT4 WebSocket error:', event); + setError('WebSocket connection error'); + onError?.('WebSocket connection error'); + }; + + ws.onclose = (event) => { + console.log('MT4 WebSocket closed:', event.code, event.reason); + setConnected(false); + setConnecting(false); + onConnectionChange?.(false); + cleanup(); + + // Attempt reconnect if not intentionally closed + if (event.code !== 1000 && reconnectAttempts < maxReconnectAttempts) { + setReconnectAttempts((prev) => prev + 1); + reconnectTimeoutRef.current = setTimeout(() => { + console.log(`Attempting reconnect (${reconnectAttempts + 1}/${maxReconnectAttempts})...`); + connect(); + }, reconnectInterval); + } + }; + + wsRef.current = ws; + } catch (err) { + console.error('Failed to create WebSocket:', err); + setConnecting(false); + setError('Failed to create WebSocket connection'); + onError?.('Failed to create WebSocket connection'); + } + }, [url, connecting, reconnectAttempts, maxReconnectAttempts, reconnectInterval, cleanup, handleMessage, resetHeartbeat, onError, onConnectionChange]); + + // Disconnect from WebSocket + const disconnect = useCallback(() => { + cleanup(); + if (wsRef.current) { + wsRef.current.close(1000, 'User disconnect'); + wsRef.current = null; + } + setConnected(false); + setConnecting(false); + setReconnectAttempts(0); + }, [cleanup]); + + // Subscribe to specific channels + const subscribe = useCallback((channels: string[]) => { + channels.forEach((ch) => subscribedChannelsRef.current.add(ch)); + if (wsRef.current?.readyState === WebSocket.OPEN) { + wsRef.current.send(JSON.stringify({ + type: 'subscribe', + channels, + })); + } + }, []); + + // Unsubscribe from channels + const unsubscribe = useCallback((channels: string[]) => { + channels.forEach((ch) => subscribedChannelsRef.current.delete(ch)); + if (wsRef.current?.readyState === WebSocket.OPEN) { + wsRef.current.send(JSON.stringify({ + type: 'unsubscribe', + channels, + })); + } + }, []); + + // Auto-connect on mount + useEffect(() => { + if (autoConnect) { + connect(); + } + return () => { + disconnect(); + }; + }, [autoConnect]); // Only run on mount/unmount + + return { + connected, + connecting, + account, + positions, + orders, + error, + lastUpdate, + reconnectAttempts, + connect, + disconnect, + subscribe, + unsubscribe, + }; +} + +export default useMT4WebSocket;