[OQI-009] feat: Add MT4 trading dashboard and risk management components
- MT4LiveTradesPanel: Real-time positions dashboard with aggregate metrics - PositionModifierDialog: Modal for modifying SL/TP with price/pips modes - RiskBasedPositionSizer: Position size calculator based on risk percentage - TradeAlertsNotificationCenter: Unified notification hub for MT4 events Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
c7626f841c
commit
51c0a846c0
389
src/modules/trading/components/MT4LiveTradesPanel.tsx
Normal file
389
src/modules/trading/components/MT4LiveTradesPanel.tsx
Normal file
@ -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<MT4LiveTradesPanelProps> = ({
|
||||
onModifyPosition,
|
||||
onClosePosition,
|
||||
onPositionSelect,
|
||||
autoRefresh = true,
|
||||
compact = false,
|
||||
}) => {
|
||||
const { connected, positions, account, error } = useMT4WebSocket({
|
||||
autoConnect: autoRefresh,
|
||||
});
|
||||
|
||||
const [sortField, setSortField] = useState<SortField>('openTime');
|
||||
const [sortDirection, setSortDirection] = useState<SortDirection>('desc');
|
||||
const [filterType, setFilterType] = useState<FilterType>('all');
|
||||
const [expandedTicket, setExpandedTicket] = useState<number | null>(null);
|
||||
const [closingTicket, setClosingTicket] = useState<number | null>(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 (
|
||||
<div className={`bg-gray-800/50 rounded-xl border border-gray-700 ${compact ? 'p-3' : 'p-4'}`}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`p-2 rounded-lg ${connected ? 'bg-green-500/20' : 'bg-red-500/20'}`}>
|
||||
<Activity className={`w-5 h-5 ${connected ? 'text-green-400' : 'text-red-400'}`} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-white">Live Positions</h3>
|
||||
<p className="text-xs text-gray-500">
|
||||
{positions.length} open • {connected ? 'Connected' : 'Disconnected'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Filter */}
|
||||
<select
|
||||
value={filterType}
|
||||
onChange={(e) => setFilterType(e.target.value as FilterType)}
|
||||
className="px-2 py-1 bg-gray-900 border border-gray-700 rounded text-xs text-white"
|
||||
>
|
||||
<option value="all">All</option>
|
||||
<option value="profitable">Winning</option>
|
||||
<option value="losing">Losing</option>
|
||||
</select>
|
||||
|
||||
{/* Close All Button */}
|
||||
{positions.length > 0 && (
|
||||
<button
|
||||
onClick={() => setShowCloseAllConfirm(true)}
|
||||
className="px-3 py-1 bg-red-600/20 hover:bg-red-600/30 text-red-400 rounded text-xs font-medium transition-colors"
|
||||
>
|
||||
Close All
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary Metrics */}
|
||||
<div className="grid grid-cols-4 gap-3 mb-4">
|
||||
<div className="p-3 bg-gray-900/50 rounded-lg text-center">
|
||||
<div className={`text-lg font-bold ${getProfitColor(metrics.netProfit)}`}>
|
||||
{metrics.netProfit >= 0 ? '+' : ''}{metrics.netProfit.toFixed(2)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">Net P&L</div>
|
||||
</div>
|
||||
<div className="p-3 bg-gray-900/50 rounded-lg text-center">
|
||||
<div className="text-lg font-bold text-white">
|
||||
<span className="text-green-400">{metrics.winningCount}</span>
|
||||
<span className="text-gray-600">/</span>
|
||||
<span className="text-red-400">{metrics.losingCount}</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">Win/Lose</div>
|
||||
</div>
|
||||
<div className="p-3 bg-gray-900/50 rounded-lg text-center">
|
||||
<div className="text-lg font-bold text-white">{metrics.totalLots.toFixed(2)}</div>
|
||||
<div className="text-xs text-gray-500">Total Lots</div>
|
||||
</div>
|
||||
<div className="p-3 bg-gray-900/50 rounded-lg text-center">
|
||||
<div className={`text-lg font-bold ${getMarginLevelColor(metrics.marginLevel)}`}>
|
||||
{metrics.marginLevel.toFixed(0)}%
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">Margin Level</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Positions List */}
|
||||
{error ? (
|
||||
<div className="text-center py-8">
|
||||
<AlertTriangle className="w-10 h-10 text-red-400 mx-auto mb-2" />
|
||||
<p className="text-gray-400">{error}</p>
|
||||
</div>
|
||||
) : filteredPositions.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<Activity className="w-10 h-10 text-gray-600 mx-auto mb-2" />
|
||||
<p className="text-gray-400">No open positions</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2 max-h-[400px] overflow-y-auto">
|
||||
{filteredPositions.map((position) => (
|
||||
<div
|
||||
key={position.ticket}
|
||||
className={`bg-gray-900/50 rounded-lg border transition-all ${
|
||||
expandedTicket === position.ticket
|
||||
? 'border-blue-500/50'
|
||||
: 'border-gray-700/50 hover:border-gray-600'
|
||||
}`}
|
||||
>
|
||||
{/* Main Row */}
|
||||
<div
|
||||
onClick={() => {
|
||||
setExpandedTicket(expandedTicket === position.ticket ? null : position.ticket);
|
||||
onPositionSelect?.(position);
|
||||
}}
|
||||
className="flex items-center justify-between p-3 cursor-pointer"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`p-1.5 rounded ${position.type === 'BUY' ? 'bg-green-500/20' : 'bg-red-500/20'}`}>
|
||||
{position.type === 'BUY' ? (
|
||||
<TrendingUp className="w-4 h-4 text-green-400" />
|
||||
) : (
|
||||
<TrendingDown className="w-4 h-4 text-red-400" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-white">{position.symbol}</span>
|
||||
<span className="text-xs text-gray-500">{position.lots} lots</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
#{position.ticket} • {formatTime(position.openTime)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="text-right">
|
||||
<div className={`font-mono font-medium ${getProfitColor(position.profit)}`}>
|
||||
{position.profit >= 0 ? '+' : ''}{position.profit.toFixed(2)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{position.openPrice.toFixed(5)} → {position.currentPrice.toFixed(5)}
|
||||
</div>
|
||||
</div>
|
||||
{expandedTicket === position.ticket ? (
|
||||
<ChevronUp className="w-4 h-4 text-gray-500" />
|
||||
) : (
|
||||
<ChevronDown className="w-4 h-4 text-gray-500" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded Details */}
|
||||
{expandedTicket === position.ticket && (
|
||||
<div className="px-3 pb-3 pt-1 border-t border-gray-700/50">
|
||||
<div className="grid grid-cols-4 gap-3 text-xs mb-3">
|
||||
<div>
|
||||
<span className="text-gray-500">Entry:</span>
|
||||
<span className="text-white ml-1 font-mono">{position.openPrice.toFixed(5)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">Current:</span>
|
||||
<span className="text-white ml-1 font-mono">{position.currentPrice.toFixed(5)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">SL:</span>
|
||||
<span className={`ml-1 font-mono ${position.stopLoss ? 'text-red-400' : 'text-gray-600'}`}>
|
||||
{position.stopLoss?.toFixed(5) || 'None'}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">TP:</span>
|
||||
<span className={`ml-1 font-mono ${position.takeProfit ? 'text-green-400' : 'text-gray-600'}`}>
|
||||
{position.takeProfit?.toFixed(5) || 'None'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-xs text-gray-500">
|
||||
Swap: <span className="text-gray-400">{position.swap.toFixed(2)}</span>
|
||||
{' • '}
|
||||
Comm: <span className="text-gray-400">{position.commission.toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{onModifyPosition && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onModifyPosition(position);
|
||||
}}
|
||||
className="flex items-center gap-1 px-2 py-1 text-xs text-blue-400 hover:bg-blue-500/10 rounded transition-colors"
|
||||
>
|
||||
<Edit3 className="w-3 h-3" />
|
||||
Modify
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleClosePosition(position.ticket);
|
||||
}}
|
||||
disabled={closingTicket === position.ticket}
|
||||
className="flex items-center gap-1 px-2 py-1 text-xs text-red-400 hover:bg-red-500/10 rounded transition-colors disabled:opacity-50"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
{closingTicket === position.ticket ? 'Closing...' : 'Close'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Close All Confirmation Modal */}
|
||||
{showCloseAllConfirm && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70">
|
||||
<div className="w-full max-w-sm bg-gray-800 rounded-xl p-6">
|
||||
<div className="text-center">
|
||||
<AlertTriangle className="w-12 h-12 text-red-400 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-semibold text-white mb-2">Close All Positions?</h3>
|
||||
<p className="text-gray-400 text-sm mb-4">
|
||||
This will close {positions.length} positions with a net P&L of{' '}
|
||||
<span className={getProfitColor(metrics.netProfit)}>
|
||||
{metrics.netProfit >= 0 ? '+' : ''}{metrics.netProfit.toFixed(2)}
|
||||
</span>
|
||||
</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => setShowCloseAllConfirm(false)}
|
||||
className="flex-1 py-2.5 bg-gray-700 hover:bg-gray-600 text-white rounded-lg font-medium"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCloseAll}
|
||||
className="flex-1 py-2.5 bg-red-600 hover:bg-red-500 text-white rounded-lg font-medium"
|
||||
>
|
||||
Close All
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MT4LiveTradesPanel;
|
||||
365
src/modules/trading/components/PositionModifierDialog.tsx
Normal file
365
src/modules/trading/components/PositionModifierDialog.tsx
Normal file
@ -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<PositionModifierDialogProps> = ({
|
||||
position,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}) => {
|
||||
const [slPrice, setSlPrice] = useState<string>(position.stopLoss?.toString() || '');
|
||||
const [tpPrice, setTpPrice] = useState<string>(position.takeProfit?.toString() || '');
|
||||
const [inputMode, setInputMode] = useState<InputMode>('price');
|
||||
const [slPips, setSlPips] = useState<string>('');
|
||||
const [tpPips, setTpPips] = useState<string>('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70">
|
||||
<div className="w-full max-w-md bg-gray-800 rounded-xl shadow-2xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-700">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`p-2 rounded-lg ${isBuy ? 'bg-green-500/20' : 'bg-red-500/20'}`}>
|
||||
{isBuy ? (
|
||||
<TrendingUp className="w-5 h-5 text-green-400" />
|
||||
) : (
|
||||
<TrendingDown className="w-5 h-5 text-red-400" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="font-semibold text-white">Modify Position</h2>
|
||||
<p className="text-sm text-gray-400">
|
||||
{position.symbol} • #{position.ticket}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 text-gray-400 hover:text-white hover:bg-gray-700 rounded-lg"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Position Info */}
|
||||
<div className="grid grid-cols-3 gap-3 p-3 bg-gray-900/50 rounded-lg">
|
||||
<div className="text-center">
|
||||
<div className="text-xs text-gray-500">Entry</div>
|
||||
<div className="font-mono text-white">{position.openPrice.toFixed(5)}</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-xs text-gray-500">Current</div>
|
||||
<div className="font-mono text-white">{position.currentPrice.toFixed(5)}</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-xs text-gray-500">P&L</div>
|
||||
<div className={`font-mono ${position.profit >= 0 ? 'text-green-400' : 'text-red-400'}`}>
|
||||
{position.profit >= 0 ? '+' : ''}{position.profit.toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Input Mode Toggle */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-gray-500">Input:</span>
|
||||
{(['price', 'pips'] as InputMode[]).map((mode) => (
|
||||
<button
|
||||
key={mode}
|
||||
onClick={() => setInputMode(mode)}
|
||||
className={`px-3 py-1 text-xs rounded ${
|
||||
inputMode === mode
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
{mode.charAt(0).toUpperCase() + mode.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Stop Loss */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="flex items-center gap-2 text-sm text-gray-300">
|
||||
<Shield className="w-4 h-4 text-red-400" />
|
||||
Stop Loss
|
||||
</label>
|
||||
{slPrice && (
|
||||
<button
|
||||
onClick={handleRemoveSL}
|
||||
className="text-xs text-red-400 hover:text-red-300"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{inputMode === 'price' ? (
|
||||
<input
|
||||
type="number"
|
||||
value={slPrice}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
type="number"
|
||||
value={slPips}
|
||||
onChange={(e) => 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 && (
|
||||
<div className="mt-1 text-xs text-gray-500">
|
||||
{calculations.slPips.toFixed(1)} pips • Max loss: ${calculations.maxLoss?.toFixed(2) || '—'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Take Profit */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="flex items-center gap-2 text-sm text-gray-300">
|
||||
<Target className="w-4 h-4 text-green-400" />
|
||||
Take Profit
|
||||
</label>
|
||||
{tpPrice && (
|
||||
<button
|
||||
onClick={handleRemoveTP}
|
||||
className="text-xs text-green-400 hover:text-green-300"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{inputMode === 'price' ? (
|
||||
<input
|
||||
type="number"
|
||||
value={tpPrice}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
type="number"
|
||||
value={tpPips}
|
||||
onChange={(e) => 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 && (
|
||||
<div className="mt-1 text-xs text-gray-500">
|
||||
{calculations.tpPips.toFixed(1)} pips • Max profit: ${calculations.maxProfit?.toFixed(2) || '—'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
onClick={handleSetBreakeven}
|
||||
className="flex items-center gap-1 px-3 py-1.5 bg-gray-700 hover:bg-gray-600 text-gray-300 rounded text-xs"
|
||||
>
|
||||
<RefreshCw className="w-3 h-3" />
|
||||
Breakeven
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleSetTrailingPreset(20)}
|
||||
className="px-3 py-1.5 bg-gray-700 hover:bg-gray-600 text-gray-300 rounded text-xs"
|
||||
>
|
||||
SL -20 pips
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleSetTrailingPreset(50)}
|
||||
className="px-3 py-1.5 bg-gray-700 hover:bg-gray-600 text-gray-300 rounded text-xs"
|
||||
>
|
||||
SL -50 pips
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Risk/Reward Preview */}
|
||||
{calculations.riskReward && (
|
||||
<div className="flex items-center justify-between p-3 bg-blue-500/10 border border-blue-500/30 rounded-lg">
|
||||
<div className="flex items-center gap-2">
|
||||
<Calculator className="w-4 h-4 text-blue-400" />
|
||||
<span className="text-sm text-blue-400">Risk/Reward</span>
|
||||
</div>
|
||||
<span className="text-white font-bold">1:{calculations.riskReward.toFixed(2)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 p-3 bg-red-500/10 border border-red-500/30 rounded-lg">
|
||||
<AlertCircle className="w-5 h-5 text-red-400" />
|
||||
<span className="text-sm text-red-400">{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Success Message */}
|
||||
{success && (
|
||||
<div className="flex items-center gap-2 p-3 bg-green-500/10 border border-green-500/30 rounded-lg">
|
||||
<Check className="w-5 h-5 text-green-400" />
|
||||
<span className="text-sm text-green-400">Position modified successfully</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center gap-3 p-4 border-t border-gray-700">
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={isSubmitting}
|
||||
className="flex-1 py-2.5 bg-gray-700 hover:bg-gray-600 text-white rounded-lg font-medium disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting || (!slPrice && !tpPrice)}
|
||||
className="flex-1 flex items-center justify-center gap-2 py-2.5 bg-blue-600 hover:bg-blue-500 text-white rounded-lg font-medium disabled:opacity-50"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Modifying...
|
||||
</>
|
||||
) : (
|
||||
'Apply Changes'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PositionModifierDialog;
|
||||
390
src/modules/trading/components/RiskBasedPositionSizer.tsx
Normal file
390
src/modules/trading/components/RiskBasedPositionSizer.tsx
Normal file
@ -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<RiskBasedPositionSizerProps> = ({
|
||||
accountBalance = 10000,
|
||||
defaultSymbol = 'EURUSD',
|
||||
defaultRiskPercent = 1,
|
||||
onCalculate,
|
||||
onApplyToOrder,
|
||||
compact = false,
|
||||
}) => {
|
||||
const [balance, setBalance] = useState<string>(accountBalance.toString());
|
||||
const [riskPercent, setRiskPercent] = useState<string>(defaultRiskPercent.toString());
|
||||
const [symbol, setSymbol] = useState(defaultSymbol);
|
||||
const [entryPrice, setEntryPrice] = useState<string>('');
|
||||
const [stopLoss, setStopLoss] = useState<string>('');
|
||||
const [takeProfit, setTakeProfit] = useState<string>('');
|
||||
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<CalculationResult | null>(() => {
|
||||
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 (
|
||||
<div className={`bg-gray-800/50 rounded-xl border border-gray-700 ${compact ? 'p-3' : 'p-4'}`}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Calculator className="w-5 h-5 text-blue-400" />
|
||||
<h3 className="font-semibold text-white">Position Sizer</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Account Balance */}
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1.5">Account Balance</label>
|
||||
<div className="relative">
|
||||
<DollarSign className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
|
||||
<input
|
||||
type="number"
|
||||
value={balance}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Risk Percent */}
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1.5">Risk Per Trade</label>
|
||||
<div className="relative">
|
||||
<Percent className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
|
||||
<input
|
||||
type="number"
|
||||
value={riskPercent}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2 mt-2">
|
||||
{[0.5, 1, 2, 3].map((pct) => (
|
||||
<button
|
||||
key={pct}
|
||||
onClick={() => setRiskPercent(pct.toString())}
|
||||
className={`px-3 py-1 text-xs rounded ${
|
||||
parseFloat(riskPercent) === pct
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
{pct}%
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Trade Type */}
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1.5">Trade Direction</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
onClick={() => setTradeType('BUY')}
|
||||
className={`flex items-center justify-center gap-2 py-2.5 rounded-lg font-medium transition-colors ${
|
||||
tradeType === 'BUY'
|
||||
? 'bg-green-600 text-white'
|
||||
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
<TrendingUp className="w-4 h-4" />
|
||||
BUY
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTradeType('SELL')}
|
||||
className={`flex items-center justify-center gap-2 py-2.5 rounded-lg font-medium transition-colors ${
|
||||
tradeType === 'SELL'
|
||||
? 'bg-red-600 text-white'
|
||||
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
<TrendingDown className="w-4 h-4" />
|
||||
SELL
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Entry, SL, TP */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">Entry Price</label>
|
||||
<input
|
||||
type="number"
|
||||
value={entryPrice}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="flex items-center gap-1 text-xs text-gray-500 mb-1">
|
||||
<Shield className="w-3 h-3 text-red-400" />
|
||||
Stop Loss
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={stopLoss}
|
||||
onChange={(e) => 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'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="flex items-center gap-1 text-xs text-gray-500 mb-1">
|
||||
<Target className="w-3 h-3 text-green-400" />
|
||||
Take Profit
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={takeProfit}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Invalid SL Warning */}
|
||||
{!isValidSL() && (
|
||||
<div className="flex items-center gap-2 p-2 bg-red-500/10 border border-red-500/30 rounded-lg">
|
||||
<AlertTriangle className="w-4 h-4 text-red-400" />
|
||||
<span className="text-xs text-red-400">
|
||||
Stop loss should be {tradeType === 'BUY' ? 'below' : 'above'} entry price for {tradeType} trades
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Result */}
|
||||
{calculation && isValidSL() && (
|
||||
<div className="p-4 bg-blue-500/10 border border-blue-500/30 rounded-xl space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-400">Recommended Position Size</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-2xl font-bold text-white">{calculation.lots.toFixed(2)}</span>
|
||||
<span className="text-gray-400">lots</span>
|
||||
<button
|
||||
onClick={handleCopyLots}
|
||||
className="p-1.5 text-gray-400 hover:text-white hover:bg-gray-700 rounded"
|
||||
title="Copy"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="w-4 h-4 text-green-400" />
|
||||
) : (
|
||||
<Copy className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 pt-3 border-t border-blue-500/20">
|
||||
<div>
|
||||
<div className="text-xs text-gray-500">Risk Amount</div>
|
||||
<div className="text-red-400 font-medium">${calculation.riskAmount.toFixed(2)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-gray-500">Max Loss</div>
|
||||
<div className="text-red-400 font-medium">${calculation.potentialLoss.toFixed(2)}</div>
|
||||
</div>
|
||||
{calculation.potentialProfit > 0 && (
|
||||
<>
|
||||
<div>
|
||||
<div className="text-xs text-gray-500">Potential Profit</div>
|
||||
<div className="text-green-400 font-medium">${calculation.potentialProfit.toFixed(2)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-gray-500">R:R Ratio</div>
|
||||
<div className="text-white font-medium">1:{calculation.riskRewardRatio.toFixed(2)}</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 pt-2 text-xs text-gray-500">
|
||||
<Info className="w-3 h-3" />
|
||||
<span>SL: {calculation.slPips.toFixed(1)} pips • Pip value: ${calculation.pipValue.toFixed(2)}</span>
|
||||
</div>
|
||||
|
||||
{onApplyToOrder && (
|
||||
<button
|
||||
onClick={handleApply}
|
||||
className="w-full py-2.5 bg-blue-600 hover:bg-blue-500 text-white rounded-lg font-medium transition-colors"
|
||||
>
|
||||
Apply to Order
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Risk Scenarios */}
|
||||
{scenarios.length > 0 && !compact && (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-xs text-gray-500">Quick Scenarios</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{scenarios.map((scenario) => (
|
||||
<button
|
||||
key={scenario.riskPercent}
|
||||
onClick={() => setRiskPercent(scenario.riskPercent.toString())}
|
||||
className={`p-2 rounded-lg text-center transition-colors ${
|
||||
parseFloat(riskPercent) === scenario.riskPercent
|
||||
? 'bg-blue-600/20 border border-blue-500/50'
|
||||
: 'bg-gray-900/50 border border-gray-700 hover:border-gray-600'
|
||||
}`}
|
||||
>
|
||||
<div className="text-xs text-gray-400">{scenario.riskPercent}%</div>
|
||||
<div className="text-white font-bold">{scenario.lots.toFixed(2)}</div>
|
||||
<div className="text-xs text-red-400">${scenario.riskAmount.toFixed(0)}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RiskBasedPositionSizer;
|
||||
395
src/modules/trading/components/TradeAlertsNotificationCenter.tsx
Normal file
395
src/modules/trading/components/TradeAlertsNotificationCenter.tsx
Normal file
@ -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<TradeAlertsNotificationCenterProps> = ({
|
||||
alerts = [],
|
||||
onMarkRead,
|
||||
onMarkAllRead,
|
||||
onDelete,
|
||||
onClearAll,
|
||||
soundEnabled = true,
|
||||
onSoundToggle,
|
||||
maxVisible = 50,
|
||||
compact = false,
|
||||
}) => {
|
||||
const [filterType, setFilterType] = useState<AlertType | 'all'>('all');
|
||||
const [showUnreadOnly, setShowUnreadOnly] = useState(false);
|
||||
const [expandedId, setExpandedId] = useState<string | null>(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 <TrendingUp className="w-4 h-4 text-blue-400" />;
|
||||
case 'trade_closed':
|
||||
return <Check className="w-4 h-4 text-gray-400" />;
|
||||
case 'sl_hit':
|
||||
return <Shield className="w-4 h-4 text-red-400" />;
|
||||
case 'tp_hit':
|
||||
return <Target className="w-4 h-4 text-green-400" />;
|
||||
case 'margin_warning':
|
||||
return <AlertTriangle className="w-4 h-4 text-yellow-400" />;
|
||||
case 'margin_call':
|
||||
return <AlertTriangle className="w-4 h-4 text-red-400" />;
|
||||
case 'price_alert':
|
||||
return <Bell className="w-4 h-4 text-purple-400" />;
|
||||
case 'connection_lost':
|
||||
return <WifiOff className="w-4 h-4 text-red-400" />;
|
||||
case 'connection_restored':
|
||||
return <Wifi className="w-4 h-4 text-green-400" />;
|
||||
case 'order_rejected':
|
||||
return <X className="w-4 h-4 text-red-400" />;
|
||||
default:
|
||||
return <Bell className="w-4 h-4 text-gray-400" />;
|
||||
}
|
||||
};
|
||||
|
||||
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<AlertType, string> = {
|
||||
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 (
|
||||
<div className={`bg-gray-800/50 rounded-xl border border-gray-700 ${compact ? 'p-3' : 'p-4'}`}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative">
|
||||
<Bell className="w-5 h-5 text-blue-400" />
|
||||
{unreadCount > 0 && (
|
||||
<span className="absolute -top-1 -right-1 w-4 h-4 bg-red-500 rounded-full text-[10px] text-white flex items-center justify-center font-bold">
|
||||
{unreadCount > 9 ? '9+' : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-white">Trade Alerts</h3>
|
||||
<p className="text-xs text-gray-500">{alerts.length} total • {unreadCount} unread</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Sound Toggle */}
|
||||
<button
|
||||
onClick={() => onSoundToggle?.(!soundEnabled)}
|
||||
className={`p-2 rounded-lg transition-colors ${
|
||||
soundEnabled
|
||||
? 'text-blue-400 bg-blue-500/10'
|
||||
: 'text-gray-500 hover:bg-gray-700'
|
||||
}`}
|
||||
title={soundEnabled ? 'Sound On' : 'Sound Off'}
|
||||
>
|
||||
{soundEnabled ? <Volume2 className="w-4 h-4" /> : <VolumeX className="w-4 h-4" />}
|
||||
</button>
|
||||
|
||||
{/* Mark All Read */}
|
||||
{unreadCount > 0 && (
|
||||
<button
|
||||
onClick={onMarkAllRead}
|
||||
className="p-2 text-gray-400 hover:text-white hover:bg-gray-700 rounded-lg transition-colors"
|
||||
title="Mark all as read"
|
||||
>
|
||||
<Check className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Clear All */}
|
||||
{alerts.length > 0 && onClearAll && (
|
||||
<button
|
||||
onClick={onClearAll}
|
||||
className="p-2 text-gray-400 hover:text-red-400 hover:bg-gray-700 rounded-lg transition-colors"
|
||||
title="Clear all"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex items-center gap-2 mb-4 overflow-x-auto pb-2">
|
||||
<Filter className="w-4 h-4 text-gray-500 flex-shrink-0" />
|
||||
<button
|
||||
onClick={() => setFilterType('all')}
|
||||
className={`px-2 py-1 text-xs rounded whitespace-nowrap ${
|
||||
filterType === 'all' ? 'bg-blue-600 text-white' : 'bg-gray-700 text-gray-300'
|
||||
}`}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setFilterType('trade_opened')}
|
||||
className={`px-2 py-1 text-xs rounded whitespace-nowrap ${
|
||||
filterType === 'trade_opened' ? 'bg-blue-600 text-white' : 'bg-gray-700 text-gray-300'
|
||||
}`}
|
||||
>
|
||||
Trades
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setFilterType('sl_hit')}
|
||||
className={`px-2 py-1 text-xs rounded whitespace-nowrap ${
|
||||
filterType === 'sl_hit' ? 'bg-blue-600 text-white' : 'bg-gray-700 text-gray-300'
|
||||
}`}
|
||||
>
|
||||
SL/TP
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setFilterType('margin_warning')}
|
||||
className={`px-2 py-1 text-xs rounded whitespace-nowrap ${
|
||||
filterType === 'margin_warning' ? 'bg-blue-600 text-white' : 'bg-gray-700 text-gray-300'
|
||||
}`}
|
||||
>
|
||||
Margin
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowUnreadOnly(!showUnreadOnly)}
|
||||
className={`px-2 py-1 text-xs rounded whitespace-nowrap ${
|
||||
showUnreadOnly ? 'bg-blue-600 text-white' : 'bg-gray-700 text-gray-300'
|
||||
}`}
|
||||
>
|
||||
Unread
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Alerts List */}
|
||||
{filteredAlerts.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<BellOff className="w-10 h-10 text-gray-600 mx-auto mb-2" />
|
||||
<p className="text-gray-400">No alerts</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2 max-h-[400px] overflow-y-auto">
|
||||
{filteredAlerts.map((alert) => (
|
||||
<div
|
||||
key={alert.id}
|
||||
onClick={() => {
|
||||
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' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="p-1.5 bg-gray-900/50 rounded-lg flex-shrink-0">
|
||||
{getAlertIcon(alert.type, alert.priority)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`font-medium ${alert.read ? 'text-gray-400' : 'text-white'}`}>
|
||||
{alert.title}
|
||||
</span>
|
||||
{!alert.read && (
|
||||
<span className="w-2 h-2 bg-blue-500 rounded-full" />
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-gray-500 flex-shrink-0">
|
||||
{formatTime(alert.timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-400 mt-0.5 truncate">{alert.message}</p>
|
||||
|
||||
{/* Expanded Content */}
|
||||
{expandedId === alert.id && (
|
||||
<div className="mt-3 pt-3 border-t border-gray-700/50">
|
||||
<div className="flex flex-wrap gap-3 text-xs">
|
||||
<div className="flex items-center gap-1">
|
||||
<Clock className="w-3 h-3 text-gray-500" />
|
||||
<span className="text-gray-400">
|
||||
{new Date(alert.timestamp).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
{alert.symbol && (
|
||||
<div className="flex items-center gap-1">
|
||||
<TrendingUp className="w-3 h-3 text-gray-500" />
|
||||
<span className="text-gray-400">{alert.symbol}</span>
|
||||
</div>
|
||||
)}
|
||||
{alert.ticket && (
|
||||
<span className="text-gray-500">#{alert.ticket}</span>
|
||||
)}
|
||||
{alert.profit !== undefined && (
|
||||
<div className="flex items-center gap-1">
|
||||
<DollarSign className="w-3 h-3 text-gray-500" />
|
||||
<span className={alert.profit >= 0 ? 'text-green-400' : 'text-red-400'}>
|
||||
{alert.profit >= 0 ? '+' : ''}{alert.profit.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{onDelete && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(alert.id);
|
||||
}}
|
||||
className="mt-2 text-xs text-red-400 hover:text-red-300"
|
||||
>
|
||||
Delete alert
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Alert Type Legend */}
|
||||
{!compact && (
|
||||
<div className="mt-4 pt-4 border-t border-gray-700">
|
||||
<div className="flex flex-wrap gap-3 text-xs text-gray-500">
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-2 h-2 bg-red-500 rounded-full" />
|
||||
Critical
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-2 h-2 bg-orange-500 rounded-full" />
|
||||
High
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-2 h-2 bg-yellow-500 rounded-full" />
|
||||
Medium
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-2 h-2 bg-gray-500 rounded-full" />
|
||||
Low
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TradeAlertsNotificationCenter;
|
||||
@ -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';
|
||||
|
||||
Loading…
Reference in New Issue
Block a user