diff --git a/src/modules/trading/components/MT4LiveTradesPanel.tsx b/src/modules/trading/components/MT4LiveTradesPanel.tsx new file mode 100644 index 0000000..00318aa --- /dev/null +++ b/src/modules/trading/components/MT4LiveTradesPanel.tsx @@ -0,0 +1,389 @@ +/** + * MT4LiveTradesPanel Component + * Real-time positions dashboard with live P&L updates via WebSocket + */ + +import React, { useState, useMemo } from 'react'; +import { + TrendingUp, + TrendingDown, + X, + Edit3, + RefreshCw, + AlertTriangle, + DollarSign, + Percent, + Activity, + ChevronDown, + ChevronUp, + Filter, + MoreVertical, +} from 'lucide-react'; +import { useMT4WebSocket, MT4Position } from '../hooks/useMT4WebSocket'; +import { closeMT4Position } from '../../../services/trading.service'; + +interface MT4LiveTradesPanelProps { + onModifyPosition?: (position: MT4Position) => void; + onClosePosition?: (ticket: number) => void; + onPositionSelect?: (position: MT4Position) => void; + autoRefresh?: boolean; + compact?: boolean; +} + +type SortField = 'profit' | 'openTime' | 'symbol' | 'lots'; +type SortDirection = 'asc' | 'desc'; +type FilterType = 'all' | 'profitable' | 'losing'; + +const MT4LiveTradesPanel: React.FC = ({ + onModifyPosition, + onClosePosition, + onPositionSelect, + autoRefresh = true, + compact = false, +}) => { + const { connected, positions, account, error } = useMT4WebSocket({ + autoConnect: autoRefresh, + }); + + const [sortField, setSortField] = useState('openTime'); + const [sortDirection, setSortDirection] = useState('desc'); + const [filterType, setFilterType] = useState('all'); + const [expandedTicket, setExpandedTicket] = useState(null); + const [closingTicket, setClosingTicket] = useState(null); + const [showCloseAllConfirm, setShowCloseAllConfirm] = useState(false); + + // Calculate aggregate metrics + const metrics = useMemo(() => { + const totalProfit = positions.reduce((sum, p) => sum + p.profit, 0); + const totalSwap = positions.reduce((sum, p) => sum + p.swap, 0); + const totalCommission = positions.reduce((sum, p) => sum + p.commission, 0); + const winningCount = positions.filter((p) => p.profit > 0).length; + const losingCount = positions.filter((p) => p.profit < 0).length; + const totalLots = positions.reduce((sum, p) => sum + p.lots, 0); + + return { + totalProfit, + totalSwap, + totalCommission, + netProfit: totalProfit + totalSwap - totalCommission, + winningCount, + losingCount, + totalLots, + marginLevel: account?.marginLevel || 0, + }; + }, [positions, account]); + + // Filter and sort positions + const filteredPositions = useMemo(() => { + let result = [...positions]; + + // Filter + if (filterType === 'profitable') { + result = result.filter((p) => p.profit > 0); + } else if (filterType === 'losing') { + result = result.filter((p) => p.profit < 0); + } + + // Sort + result.sort((a, b) => { + let comparison = 0; + switch (sortField) { + case 'profit': + comparison = a.profit - b.profit; + break; + case 'openTime': + comparison = new Date(a.openTime).getTime() - new Date(b.openTime).getTime(); + break; + case 'symbol': + comparison = a.symbol.localeCompare(b.symbol); + break; + case 'lots': + comparison = a.lots - b.lots; + break; + } + return sortDirection === 'asc' ? comparison : -comparison; + }); + + return result; + }, [positions, filterType, sortField, sortDirection]); + + const handleSort = (field: SortField) => { + if (sortField === field) { + setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc'); + } else { + setSortField(field); + setSortDirection('desc'); + } + }; + + const handleClosePosition = async (ticket: number) => { + setClosingTicket(ticket); + try { + await closeMT4Position(ticket); + onClosePosition?.(ticket); + } catch (err) { + console.error('Failed to close position:', err); + } finally { + setClosingTicket(null); + } + }; + + const handleCloseAll = async () => { + setShowCloseAllConfirm(false); + for (const position of positions) { + await handleClosePosition(position.ticket); + } + }; + + const formatTime = (dateString: string) => { + const date = new Date(dateString); + return date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }); + }; + + const getMarginLevelColor = (level: number) => { + if (level >= 200) return 'text-green-400'; + if (level >= 100) return 'text-yellow-400'; + return 'text-red-400'; + }; + + const getProfitColor = (profit: number) => { + if (profit > 0) return 'text-green-400'; + if (profit < 0) return 'text-red-400'; + return 'text-gray-400'; + }; + + return ( +
+ {/* Header */} +
+
+
+ +
+
+

Live Positions

+

+ {positions.length} open • {connected ? 'Connected' : 'Disconnected'} +

+
+
+ +
+ {/* Filter */} + + + {/* Close All Button */} + {positions.length > 0 && ( + + )} +
+
+ + {/* Summary Metrics */} +
+
+
+ {metrics.netProfit >= 0 ? '+' : ''}{metrics.netProfit.toFixed(2)} +
+
Net P&L
+
+
+
+ {metrics.winningCount} + / + {metrics.losingCount} +
+
Win/Lose
+
+
+
{metrics.totalLots.toFixed(2)}
+
Total Lots
+
+
+
+ {metrics.marginLevel.toFixed(0)}% +
+
Margin Level
+
+
+ + {/* Positions List */} + {error ? ( +
+ +

{error}

+
+ ) : filteredPositions.length === 0 ? ( +
+ +

No open positions

+
+ ) : ( +
+ {filteredPositions.map((position) => ( +
+ {/* Main Row */} +
{ + setExpandedTicket(expandedTicket === position.ticket ? null : position.ticket); + onPositionSelect?.(position); + }} + className="flex items-center justify-between p-3 cursor-pointer" + > +
+
+ {position.type === 'BUY' ? ( + + ) : ( + + )} +
+
+
+ {position.symbol} + {position.lots} lots +
+
+ #{position.ticket} • {formatTime(position.openTime)} +
+
+
+ +
+
+
+ {position.profit >= 0 ? '+' : ''}{position.profit.toFixed(2)} +
+
+ {position.openPrice.toFixed(5)} → {position.currentPrice.toFixed(5)} +
+
+ {expandedTicket === position.ticket ? ( + + ) : ( + + )} +
+
+ + {/* Expanded Details */} + {expandedTicket === position.ticket && ( +
+
+
+ Entry: + {position.openPrice.toFixed(5)} +
+
+ Current: + {position.currentPrice.toFixed(5)} +
+
+ SL: + + {position.stopLoss?.toFixed(5) || 'None'} + +
+
+ TP: + + {position.takeProfit?.toFixed(5) || 'None'} + +
+
+ +
+
+ Swap: {position.swap.toFixed(2)} + {' • '} + Comm: {position.commission.toFixed(2)} +
+
+ {onModifyPosition && ( + + )} + +
+
+
+ )} +
+ ))} +
+ )} + + {/* Close All Confirmation Modal */} + {showCloseAllConfirm && ( +
+
+
+ +

Close All Positions?

+

+ This will close {positions.length} positions with a net P&L of{' '} + + {metrics.netProfit >= 0 ? '+' : ''}{metrics.netProfit.toFixed(2)} + +

+
+ + +
+
+
+
+ )} +
+ ); +}; + +export default MT4LiveTradesPanel; diff --git a/src/modules/trading/components/PositionModifierDialog.tsx b/src/modules/trading/components/PositionModifierDialog.tsx new file mode 100644 index 0000000..3c59830 --- /dev/null +++ b/src/modules/trading/components/PositionModifierDialog.tsx @@ -0,0 +1,365 @@ +/** + * PositionModifierDialog Component + * Modal for modifying SL/TP on open positions with preview + */ + +import React, { useState, useEffect, useMemo } from 'react'; +import { + X, + TrendingUp, + TrendingDown, + Target, + Shield, + Calculator, + Loader2, + AlertCircle, + Check, + RefreshCw, +} from 'lucide-react'; +import { modifyMT4Position } from '../../../services/trading.service'; +import { MT4Position } from '../hooks/useMT4WebSocket'; + +interface PositionModifierDialogProps { + position: MT4Position; + onClose: () => void; + onSuccess?: (ticket: number) => void; +} + +type InputMode = 'price' | 'pips' | 'percent'; + +const PositionModifierDialog: React.FC = ({ + position, + onClose, + onSuccess, +}) => { + const [slPrice, setSlPrice] = useState(position.stopLoss?.toString() || ''); + const [tpPrice, setTpPrice] = useState(position.takeProfit?.toString() || ''); + const [inputMode, setInputMode] = useState('price'); + const [slPips, setSlPips] = useState(''); + const [tpPips, setTpPips] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(false); + + const isBuy = position.type === 'BUY'; + const pipValue = position.symbol.includes('JPY') ? 0.01 : 0.0001; + + // Calculate SL/TP from pips + useEffect(() => { + if (inputMode === 'pips') { + if (slPips) { + const pips = parseFloat(slPips); + const price = isBuy + ? position.currentPrice - pips * pipValue + : position.currentPrice + pips * pipValue; + setSlPrice(price.toFixed(5)); + } + if (tpPips) { + const pips = parseFloat(tpPips); + const price = isBuy + ? position.currentPrice + pips * pipValue + : position.currentPrice - pips * pipValue; + setTpPrice(price.toFixed(5)); + } + } + }, [slPips, tpPips, inputMode, position.currentPrice, isBuy, pipValue]); + + // Calculate potential loss/profit + const calculations = useMemo(() => { + const sl = parseFloat(slPrice) || 0; + const tp = parseFloat(tpPrice) || 0; + const current = position.currentPrice; + const entry = position.openPrice; + + // Pips calculation + const slPipsValue = sl ? Math.abs(current - sl) / pipValue : 0; + const tpPipsValue = tp ? Math.abs(tp - current) / pipValue : 0; + + // Approximate P&L (simplified - assumes $10/pip per lot for majors) + const pipValueUsd = 10 * position.lots; + const maxLoss = sl ? slPipsValue * pipValueUsd : null; + const maxProfit = tp ? tpPipsValue * pipValueUsd : null; + + // Risk/Reward ratio + const riskReward = maxLoss && maxProfit ? maxProfit / maxLoss : null; + + // Breakeven price + const breakeven = entry; + + return { + slPips: slPipsValue, + tpPips: tpPipsValue, + maxLoss, + maxProfit, + riskReward, + breakeven, + }; + }, [slPrice, tpPrice, position, pipValue]); + + const handleSetBreakeven = () => { + setSlPrice(position.openPrice.toFixed(5)); + setInputMode('price'); + }; + + const handleSetTrailingPreset = (pips: number) => { + const price = isBuy + ? position.currentPrice - pips * pipValue + : position.currentPrice + pips * pipValue; + setSlPrice(price.toFixed(5)); + setInputMode('price'); + }; + + const handleSubmit = async () => { + setIsSubmitting(true); + setError(null); + + try { + await modifyMT4Position(position.ticket, { + stopLoss: slPrice ? parseFloat(slPrice) : undefined, + takeProfit: tpPrice ? parseFloat(tpPrice) : undefined, + }); + + setSuccess(true); + onSuccess?.(position.ticket); + + // Close after brief success display + setTimeout(() => onClose(), 1500); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to modify position'); + } finally { + setIsSubmitting(false); + } + }; + + const handleRemoveSL = () => setSlPrice(''); + const handleRemoveTP = () => setTpPrice(''); + + return ( +
+
+ {/* Header */} +
+
+
+ {isBuy ? ( + + ) : ( + + )} +
+
+

Modify Position

+

+ {position.symbol} • #{position.ticket} +

+
+
+ +
+ + {/* Content */} +
+ {/* Position Info */} +
+
+
Entry
+
{position.openPrice.toFixed(5)}
+
+
+
Current
+
{position.currentPrice.toFixed(5)}
+
+
+
P&L
+
= 0 ? 'text-green-400' : 'text-red-400'}`}> + {position.profit >= 0 ? '+' : ''}{position.profit.toFixed(2)} +
+
+
+ + {/* Input Mode Toggle */} +
+ Input: + {(['price', 'pips'] as InputMode[]).map((mode) => ( + + ))} +
+ + {/* Stop Loss */} +
+
+ + {slPrice && ( + + )} +
+ {inputMode === 'price' ? ( + setSlPrice(e.target.value)} + step="0.00001" + placeholder="Stop Loss Price" + className="w-full px-4 py-2.5 bg-gray-900 border border-gray-700 rounded-lg text-white font-mono focus:outline-none focus:border-red-500" + /> + ) : ( + setSlPips(e.target.value)} + placeholder="Pips from current price" + className="w-full px-4 py-2.5 bg-gray-900 border border-gray-700 rounded-lg text-white focus:outline-none focus:border-red-500" + /> + )} + {slPrice && ( +
+ {calculations.slPips.toFixed(1)} pips • Max loss: ${calculations.maxLoss?.toFixed(2) || '—'} +
+ )} +
+ + {/* Take Profit */} +
+
+ + {tpPrice && ( + + )} +
+ {inputMode === 'price' ? ( + setTpPrice(e.target.value)} + step="0.00001" + placeholder="Take Profit Price" + className="w-full px-4 py-2.5 bg-gray-900 border border-gray-700 rounded-lg text-white font-mono focus:outline-none focus:border-green-500" + /> + ) : ( + setTpPips(e.target.value)} + placeholder="Pips from current price" + className="w-full px-4 py-2.5 bg-gray-900 border border-gray-700 rounded-lg text-white focus:outline-none focus:border-green-500" + /> + )} + {tpPrice && ( +
+ {calculations.tpPips.toFixed(1)} pips • Max profit: ${calculations.maxProfit?.toFixed(2) || '—'} +
+ )} +
+ + {/* Quick Actions */} +
+ + + +
+ + {/* Risk/Reward Preview */} + {calculations.riskReward && ( +
+
+ + Risk/Reward +
+ 1:{calculations.riskReward.toFixed(2)} +
+ )} + + {/* Error Message */} + {error && ( +
+ + {error} +
+ )} + + {/* Success Message */} + {success && ( +
+ + Position modified successfully +
+ )} +
+ + {/* Footer */} +
+ + +
+
+
+ ); +}; + +export default PositionModifierDialog; diff --git a/src/modules/trading/components/RiskBasedPositionSizer.tsx b/src/modules/trading/components/RiskBasedPositionSizer.tsx new file mode 100644 index 0000000..24b17b2 --- /dev/null +++ b/src/modules/trading/components/RiskBasedPositionSizer.tsx @@ -0,0 +1,390 @@ +/** + * RiskBasedPositionSizer Component + * Standalone risk calculator for position sizing before entry + */ + +import React, { useState, useMemo } from 'react'; +import { + Calculator, + DollarSign, + Percent, + TrendingUp, + TrendingDown, + Target, + Shield, + AlertTriangle, + Info, + Copy, + Check, +} from 'lucide-react'; + +interface RiskBasedPositionSizerProps { + accountBalance?: number; + defaultSymbol?: string; + defaultRiskPercent?: number; + onCalculate?: (result: CalculationResult) => void; + onApplyToOrder?: (lots: number) => void; + compact?: boolean; +} + +interface CalculationResult { + lots: number; + riskAmount: number; + potentialLoss: number; + potentialProfit: number; + riskRewardRatio: number; + pipValue: number; + slPips: number; + tpPips: number; +} + +interface RiskScenario { + riskPercent: number; + lots: number; + riskAmount: number; +} + +const RiskBasedPositionSizer: React.FC = ({ + accountBalance = 10000, + defaultSymbol = 'EURUSD', + defaultRiskPercent = 1, + onCalculate, + onApplyToOrder, + compact = false, +}) => { + const [balance, setBalance] = useState(accountBalance.toString()); + const [riskPercent, setRiskPercent] = useState(defaultRiskPercent.toString()); + const [symbol, setSymbol] = useState(defaultSymbol); + const [entryPrice, setEntryPrice] = useState(''); + const [stopLoss, setStopLoss] = useState(''); + const [takeProfit, setTakeProfit] = useState(''); + const [tradeType, setTradeType] = useState<'BUY' | 'SELL'>('BUY'); + const [copied, setCopied] = useState(false); + + const pipValue = symbol.includes('JPY') ? 0.01 : 0.0001; + const pipValueUsd = 10; // Approximate USD value per pip per lot for majors + + // Calculate position size and metrics + const calculation = useMemo(() => { + const balanceNum = parseFloat(balance) || 0; + const riskPercentNum = parseFloat(riskPercent) || 0; + const entry = parseFloat(entryPrice) || 0; + const sl = parseFloat(stopLoss) || 0; + const tp = parseFloat(takeProfit) || 0; + + if (!balanceNum || !riskPercentNum || !entry || !sl) { + return null; + } + + // Calculate pips to SL + const slPips = Math.abs(entry - sl) / pipValue; + const tpPips = tp ? Math.abs(tp - entry) / pipValue : 0; + + // Risk amount in currency + const riskAmount = (balanceNum * riskPercentNum) / 100; + + // Position size (lots) + const lots = riskAmount / (slPips * pipValueUsd); + const roundedLots = Math.floor(lots * 100) / 100; // Round down to 0.01 + + // Potential P&L + const potentialLoss = roundedLots * slPips * pipValueUsd; + const potentialProfit = tp ? roundedLots * tpPips * pipValueUsd : 0; + + // Risk/Reward ratio + const riskRewardRatio = potentialProfit && potentialLoss ? potentialProfit / potentialLoss : 0; + + const result: CalculationResult = { + lots: roundedLots, + riskAmount, + potentialLoss, + potentialProfit, + riskRewardRatio, + pipValue: roundedLots * pipValueUsd, + slPips, + tpPips, + }; + + onCalculate?.(result); + return result; + }, [balance, riskPercent, entryPrice, stopLoss, takeProfit, symbol, onCalculate]); + + // Generate risk scenarios + const scenarios: RiskScenario[] = useMemo(() => { + const balanceNum = parseFloat(balance) || 0; + const entry = parseFloat(entryPrice) || 0; + const sl = parseFloat(stopLoss) || 0; + + if (!balanceNum || !entry || !sl) return []; + + const slPips = Math.abs(entry - sl) / pipValue; + const percents = [0.5, 1, 2, 5]; + + return percents.map((pct) => { + const risk = (balanceNum * pct) / 100; + const lots = Math.floor((risk / (slPips * pipValueUsd)) * 100) / 100; + return { + riskPercent: pct, + lots, + riskAmount: risk, + }; + }); + }, [balance, entryPrice, stopLoss, symbol]); + + const handleCopyLots = async () => { + if (calculation) { + await navigator.clipboard.writeText(calculation.lots.toFixed(2)); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + }; + + const handleApply = () => { + if (calculation && onApplyToOrder) { + onApplyToOrder(calculation.lots); + } + }; + + const isValidSL = () => { + const entry = parseFloat(entryPrice) || 0; + const sl = parseFloat(stopLoss) || 0; + if (!entry || !sl) return true; + + if (tradeType === 'BUY') { + return sl < entry; + } else { + return sl > entry; + } + }; + + return ( +
+ {/* Header */} +
+ +

Position Sizer

+
+ +
+ {/* Account Balance */} +
+ +
+ + setBalance(e.target.value)} + className="w-full pl-10 pr-4 py-2.5 bg-gray-900 border border-gray-700 rounded-lg text-white focus:outline-none focus:border-blue-500" + /> +
+
+ + {/* Risk Percent */} +
+ +
+ + setRiskPercent(e.target.value)} + step="0.5" + min="0.1" + max="10" + className="w-full pl-10 pr-4 py-2.5 bg-gray-900 border border-gray-700 rounded-lg text-white focus:outline-none focus:border-blue-500" + /> +
+
+ {[0.5, 1, 2, 3].map((pct) => ( + + ))} +
+
+ + {/* Trade Type */} +
+ +
+ + +
+
+ + {/* Entry, SL, TP */} +
+
+ + setEntryPrice(e.target.value)} + step="0.00001" + placeholder="1.08500" + className="w-full px-3 py-2 bg-gray-900 border border-gray-700 rounded-lg text-white text-sm font-mono focus:outline-none focus:border-blue-500" + /> +
+
+ + setStopLoss(e.target.value)} + step="0.00001" + placeholder="1.08300" + className={`w-full px-3 py-2 bg-gray-900 border rounded-lg text-white text-sm font-mono focus:outline-none ${ + !isValidSL() ? 'border-red-500' : 'border-gray-700 focus:border-red-500' + }`} + /> +
+
+ + setTakeProfit(e.target.value)} + step="0.00001" + placeholder="1.08900" + className="w-full px-3 py-2 bg-gray-900 border border-gray-700 rounded-lg text-white text-sm font-mono focus:outline-none focus:border-green-500" + /> +
+
+ + {/* Invalid SL Warning */} + {!isValidSL() && ( +
+ + + Stop loss should be {tradeType === 'BUY' ? 'below' : 'above'} entry price for {tradeType} trades + +
+ )} + + {/* Result */} + {calculation && isValidSL() && ( +
+
+ Recommended Position Size +
+ {calculation.lots.toFixed(2)} + lots + +
+
+ +
+
+
Risk Amount
+
${calculation.riskAmount.toFixed(2)}
+
+
+
Max Loss
+
${calculation.potentialLoss.toFixed(2)}
+
+ {calculation.potentialProfit > 0 && ( + <> +
+
Potential Profit
+
${calculation.potentialProfit.toFixed(2)}
+
+
+
R:R Ratio
+
1:{calculation.riskRewardRatio.toFixed(2)}
+
+ + )} +
+ +
+ + SL: {calculation.slPips.toFixed(1)} pips • Pip value: ${calculation.pipValue.toFixed(2)} +
+ + {onApplyToOrder && ( + + )} +
+ )} + + {/* Risk Scenarios */} + {scenarios.length > 0 && !compact && ( +
+
+ Quick Scenarios +
+
+ {scenarios.map((scenario) => ( + + ))} +
+
+ )} +
+
+ ); +}; + +export default RiskBasedPositionSizer; diff --git a/src/modules/trading/components/TradeAlertsNotificationCenter.tsx b/src/modules/trading/components/TradeAlertsNotificationCenter.tsx new file mode 100644 index 0000000..87d3edd --- /dev/null +++ b/src/modules/trading/components/TradeAlertsNotificationCenter.tsx @@ -0,0 +1,395 @@ +/** + * TradeAlertsNotificationCenter Component + * Unified notification hub for MT4-specific trading events + */ + +import React, { useState, useEffect, useMemo } from 'react'; +import { + Bell, + BellOff, + X, + Check, + AlertTriangle, + TrendingUp, + TrendingDown, + Target, + Shield, + Wifi, + WifiOff, + Volume2, + VolumeX, + Trash2, + Filter, + Clock, + DollarSign, +} from 'lucide-react'; + +export type AlertType = + | 'trade_opened' + | 'trade_closed' + | 'sl_hit' + | 'tp_hit' + | 'margin_warning' + | 'margin_call' + | 'price_alert' + | 'connection_lost' + | 'connection_restored' + | 'order_filled' + | 'order_rejected'; + +export type AlertPriority = 'low' | 'medium' | 'high' | 'critical'; + +export interface TradeAlert { + id: string; + type: AlertType; + priority: AlertPriority; + title: string; + message: string; + symbol?: string; + ticket?: number; + profit?: number; + timestamp: string; + read: boolean; +} + +interface TradeAlertsNotificationCenterProps { + alerts?: TradeAlert[]; + onMarkRead?: (alertId: string) => void; + onMarkAllRead?: () => void; + onDelete?: (alertId: string) => void; + onClearAll?: () => void; + soundEnabled?: boolean; + onSoundToggle?: (enabled: boolean) => void; + maxVisible?: number; + compact?: boolean; +} + +const TradeAlertsNotificationCenter: React.FC = ({ + alerts = [], + onMarkRead, + onMarkAllRead, + onDelete, + onClearAll, + soundEnabled = true, + onSoundToggle, + maxVisible = 50, + compact = false, +}) => { + const [filterType, setFilterType] = useState('all'); + const [showUnreadOnly, setShowUnreadOnly] = useState(false); + const [expandedId, setExpandedId] = useState(null); + + const unreadCount = alerts.filter((a) => !a.read).length; + + // Filter alerts + const filteredAlerts = useMemo(() => { + let result = [...alerts]; + + if (filterType !== 'all') { + result = result.filter((a) => a.type === filterType); + } + + if (showUnreadOnly) { + result = result.filter((a) => !a.read); + } + + return result.slice(0, maxVisible); + }, [alerts, filterType, showUnreadOnly, maxVisible]); + + // Play sound for new critical alerts + useEffect(() => { + if (soundEnabled && alerts.length > 0) { + const latestAlert = alerts[0]; + if (!latestAlert.read && latestAlert.priority === 'critical') { + // In a real app, play notification sound + console.log('Playing alert sound'); + } + } + }, [alerts, soundEnabled]); + + const getAlertIcon = (type: AlertType, priority: AlertPriority) => { + switch (type) { + case 'trade_opened': + case 'order_filled': + return ; + case 'trade_closed': + return ; + case 'sl_hit': + return ; + case 'tp_hit': + return ; + case 'margin_warning': + return ; + case 'margin_call': + return ; + case 'price_alert': + return ; + case 'connection_lost': + return ; + case 'connection_restored': + return ; + case 'order_rejected': + return ; + default: + return ; + } + }; + + const getPriorityColor = (priority: AlertPriority) => { + switch (priority) { + case 'critical': + return 'border-red-500/50 bg-red-500/10'; + case 'high': + return 'border-orange-500/50 bg-orange-500/10'; + case 'medium': + return 'border-yellow-500/50 bg-yellow-500/10'; + default: + return 'border-gray-700 bg-gray-800/50'; + } + }; + + const formatTime = (timestamp: string) => { + const date = new Date(timestamp); + const now = new Date(); + const diff = now.getTime() - date.getTime(); + + if (diff < 60000) return 'Just now'; + if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`; + if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`; + return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); + }; + + const getTypeLabel = (type: AlertType) => { + const labels: Record = { + trade_opened: 'Trade Opened', + trade_closed: 'Trade Closed', + sl_hit: 'Stop Loss Hit', + tp_hit: 'Take Profit Hit', + margin_warning: 'Margin Warning', + margin_call: 'Margin Call', + price_alert: 'Price Alert', + connection_lost: 'Disconnected', + connection_restored: 'Connected', + order_filled: 'Order Filled', + order_rejected: 'Order Rejected', + }; + return labels[type] || type; + }; + + return ( +
+ {/* Header */} +
+
+
+ + {unreadCount > 0 && ( + + {unreadCount > 9 ? '9+' : unreadCount} + + )} +
+
+

Trade Alerts

+

{alerts.length} total • {unreadCount} unread

+
+
+ +
+ {/* Sound Toggle */} + + + {/* Mark All Read */} + {unreadCount > 0 && ( + + )} + + {/* Clear All */} + {alerts.length > 0 && onClearAll && ( + + )} +
+
+ + {/* Filters */} +
+ + + + + + +
+ + {/* Alerts List */} + {filteredAlerts.length === 0 ? ( +
+ +

No alerts

+
+ ) : ( +
+ {filteredAlerts.map((alert) => ( +
{ + if (!alert.read) onMarkRead?.(alert.id); + setExpandedId(expandedId === alert.id ? null : alert.id); + }} + className={`p-3 rounded-lg border cursor-pointer transition-all ${getPriorityColor(alert.priority)} ${ + !alert.read ? 'ring-1 ring-blue-500/30' : '' + }`} + > +
+
+ {getAlertIcon(alert.type, alert.priority)} +
+
+
+
+ + {alert.title} + + {!alert.read && ( + + )} +
+ + {formatTime(alert.timestamp)} + +
+

{alert.message}

+ + {/* Expanded Content */} + {expandedId === alert.id && ( +
+
+
+ + + {new Date(alert.timestamp).toLocaleString()} + +
+ {alert.symbol && ( +
+ + {alert.symbol} +
+ )} + {alert.ticket && ( + #{alert.ticket} + )} + {alert.profit !== undefined && ( +
+ + = 0 ? 'text-green-400' : 'text-red-400'}> + {alert.profit >= 0 ? '+' : ''}{alert.profit.toFixed(2)} + +
+ )} +
+ {onDelete && ( + + )} +
+ )} +
+
+
+ ))} +
+ )} + + {/* Alert Type Legend */} + {!compact && ( +
+
+ + + Critical + + + + High + + + + Medium + + + + Low + +
+
+ )} +
+ ); +}; + +export default TradeAlertsNotificationCenter; diff --git a/src/modules/trading/components/index.ts b/src/modules/trading/components/index.ts index cc30c3b..28d3117 100644 --- a/src/modules/trading/components/index.ts +++ b/src/modules/trading/components/index.ts @@ -43,3 +43,8 @@ export { default as AccountHealthDashboard } from './AccountHealthDashboard'; export { default as QuickOrderPanel } from './QuickOrderPanel'; export { default as TradeExecutionHistory } from './TradeExecutionHistory'; export { default as TradingMetricsCard } from './TradingMetricsCard'; +export { default as MT4LiveTradesPanel } from './MT4LiveTradesPanel'; +export { default as PositionModifierDialog } from './PositionModifierDialog'; +export { default as RiskBasedPositionSizer } from './RiskBasedPositionSizer'; +export { default as TradeAlertsNotificationCenter } from './TradeAlertsNotificationCenter'; +export type { TradeAlert, AlertType, AlertPriority } from './TradeAlertsNotificationCenter';