[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 <noreply@anthropic.com>
This commit is contained in:
parent
d2c0a09b1b
commit
4d2c00ac30
332
src/modules/trading/components/QuickOrderPanel.tsx
Normal file
332
src/modules/trading/components/QuickOrderPanel.tsx
Normal file
@ -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<QuickOrderPanelProps> = ({
|
||||
symbol,
|
||||
currentPrice,
|
||||
spread = 0,
|
||||
accountBalance = 0,
|
||||
onOrderExecuted,
|
||||
onError,
|
||||
compact = false,
|
||||
}) => {
|
||||
const [selectedLots, setSelectedLots] = useState<number>(0.01);
|
||||
const [customLots, setCustomLots] = useState<string>('');
|
||||
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<number>(20);
|
||||
const [tpPips, setTpPips] = useState<number>(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 (
|
||||
<div className="flex items-center gap-2 p-2 bg-gray-800/50 rounded-lg">
|
||||
<span className="text-sm font-mono text-gray-400">{symbol}</span>
|
||||
<select
|
||||
value={selectedLots}
|
||||
onChange={(e) => setSelectedLots(parseFloat(e.target.value))}
|
||||
className="px-2 py-1 bg-gray-900 border border-gray-700 rounded text-sm text-white"
|
||||
>
|
||||
{DEFAULT_PRESETS.map((preset) => (
|
||||
<option key={preset.lots} value={preset.lots}>{preset.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
onClick={() => executeOrder('buy')}
|
||||
disabled={!!isExecuting}
|
||||
className="px-3 py-1 bg-green-600 hover:bg-green-500 text-white rounded text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
{isExecuting === 'buy' ? <Loader2 className="w-4 h-4 animate-spin" /> : 'BUY'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => executeOrder('sell')}
|
||||
disabled={!!isExecuting}
|
||||
className="px-3 py-1 bg-red-600 hover:bg-red-500 text-white rounded text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
{isExecuting === 'sell' ? <Loader2 className="w-4 h-4 animate-spin" /> : 'SELL'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 bg-gray-800/50 rounded-xl border border-gray-700">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap className="w-5 h-5 text-yellow-400" />
|
||||
<h3 className="font-semibold text-white">Quick Order</h3>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowSettings(!showSettings)}
|
||||
className={`p-1.5 rounded-lg transition-colors ${
|
||||
showSettings ? 'bg-blue-600 text-white' : 'text-gray-400 hover:text-white hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
<Settings className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Symbol & Price */}
|
||||
<div className="text-center mb-4">
|
||||
<div className="text-lg font-bold text-white">{symbol}</div>
|
||||
<div className="flex items-center justify-center gap-4 mt-1">
|
||||
<span className="text-red-400 font-mono">{bidPrice.toFixed(5)}</span>
|
||||
<span className="text-xs text-gray-500">|</span>
|
||||
<span className="text-green-400 font-mono">{askPrice.toFixed(5)}</span>
|
||||
</div>
|
||||
{spread > 0 && (
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
Spread: {(spread / pipValue).toFixed(1)} pips
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Lot Size Presets */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-xs text-gray-400 mb-2">Lot Size</label>
|
||||
<div className="grid grid-cols-6 gap-1">
|
||||
{DEFAULT_PRESETS.map((preset) => (
|
||||
<button
|
||||
key={preset.lots}
|
||||
onClick={() => {
|
||||
setSelectedLots(preset.lots);
|
||||
setCustomLots('');
|
||||
}}
|
||||
className={`py-1.5 text-xs font-medium rounded transition-colors ${
|
||||
selectedLots === preset.lots && !customLots
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
{preset.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
value={customLots}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Settings Panel */}
|
||||
{showSettings && (
|
||||
<div className="mb-4 p-3 bg-gray-900/50 rounded-lg space-y-3">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={useSLTP}
|
||||
onChange={(e) => setUseSLTP(e.target.checked)}
|
||||
className="w-4 h-4 rounded border-gray-600 bg-gray-900 text-blue-600"
|
||||
/>
|
||||
<span className="text-sm text-gray-300">Auto SL/TP</span>
|
||||
</label>
|
||||
|
||||
{useSLTP && (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-400 mb-1">SL (pips)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={slPips}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-400 mb-1">TP (pips)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={tpPips}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Risk Warning */}
|
||||
{risk && risk.riskPercent > 2 && (
|
||||
<div className="flex items-center gap-2 p-2 mb-4 bg-yellow-500/10 border border-yellow-500/30 rounded-lg">
|
||||
<AlertTriangle className="w-4 h-4 text-yellow-400 flex-shrink-0" />
|
||||
<span className="text-xs text-yellow-400">
|
||||
Risk: ${risk.totalRisk.toFixed(2)} ({risk.riskPercent.toFixed(1)}% of balance)
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Buy/Sell Buttons */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
onClick={() => executeOrder('sell')}
|
||||
disabled={!!isExecuting || activeLots <= 0}
|
||||
className="flex flex-col items-center gap-1 py-3 bg-red-600 hover:bg-red-500 text-white rounded-lg font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isExecuting === 'sell' ? (
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<TrendingDown className="w-5 h-5" />
|
||||
<span>SELL</span>
|
||||
<span className="text-xs opacity-75">{bidPrice.toFixed(5)}</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => executeOrder('buy')}
|
||||
disabled={!!isExecuting || activeLots <= 0}
|
||||
className="flex flex-col items-center gap-1 py-3 bg-green-600 hover:bg-green-500 text-white rounded-lg font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isExecuting === 'buy' ? (
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<TrendingUp className="w-5 h-5" />
|
||||
<span>BUY</span>
|
||||
<span className="text-xs opacity-75">{askPrice.toFixed(5)}</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Result Message */}
|
||||
{lastResult && (
|
||||
<div className={`flex items-center gap-2 mt-3 p-2 rounded-lg ${
|
||||
lastResult.type === 'success'
|
||||
? 'bg-green-500/10 border border-green-500/30'
|
||||
: 'bg-red-500/10 border border-red-500/30'
|
||||
}`}>
|
||||
{lastResult.type === 'success' ? (
|
||||
<Check className="w-4 h-4 text-green-400" />
|
||||
) : (
|
||||
<AlertTriangle className="w-4 h-4 text-red-400" />
|
||||
)}
|
||||
<span className={`text-xs ${
|
||||
lastResult.type === 'success' ? 'text-green-400' : 'text-red-400'
|
||||
}`}>
|
||||
{lastResult.message}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Active Lots Display */}
|
||||
<div className="mt-3 text-center text-xs text-gray-500">
|
||||
Trading <span className="text-white font-medium">{activeLots.toFixed(2)}</span> lots
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default QuickOrderPanel;
|
||||
401
src/modules/trading/components/TradeExecutionHistory.tsx
Normal file
401
src/modules/trading/components/TradeExecutionHistory.tsx
Normal file
@ -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<HistoricalTrade[]>;
|
||||
maxItems?: number;
|
||||
showExport?: boolean;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
type SortField = 'closeTime' | 'profit' | 'symbol';
|
||||
type SortDirection = 'asc' | 'desc';
|
||||
type FilterType = 'all' | 'profitable' | 'losing';
|
||||
|
||||
const TradeExecutionHistory: React.FC<TradeExecutionHistoryProps> = ({
|
||||
trades: initialTrades = [],
|
||||
onRefresh,
|
||||
maxItems = 50,
|
||||
showExport = true,
|
||||
compact = false,
|
||||
}) => {
|
||||
const [trades, setTrades] = useState<HistoricalTrade[]>(initialTrades);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [sortField, setSortField] = useState<SortField>('closeTime');
|
||||
const [sortDirection, setSortDirection] = useState<SortDirection>('desc');
|
||||
const [filterType, setFilterType] = useState<FilterType>('all');
|
||||
const [expandedTrade, setExpandedTrade] = useState<number | null>(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 <Target className="w-3 h-3 text-green-400" title="Take Profit" />;
|
||||
case 'sl':
|
||||
return <XCircle className="w-3 h-3 text-red-400" title="Stop Loss" />;
|
||||
case 'margin_call':
|
||||
return <XCircle className="w-3 h-3 text-orange-400" title="Margin Call" />;
|
||||
default:
|
||||
return <Clock className="w-3 h-3 text-gray-400" title="Manual Close" />;
|
||||
}
|
||||
};
|
||||
|
||||
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 (
|
||||
<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-2">
|
||||
<History className="w-5 h-5 text-blue-400" />
|
||||
<h3 className="font-semibold text-white">Trade History</h3>
|
||||
<span className="text-sm text-gray-500">({trades.length})</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{onRefresh && (
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
disabled={loading}
|
||||
className="p-1.5 text-gray-400 hover:text-white hover:bg-gray-700 rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
)}
|
||||
{showExport && trades.length > 0 && (
|
||||
<button
|
||||
onClick={handleExport}
|
||||
className="p-1.5 text-gray-400 hover:text-white hover:bg-gray-700 rounded-lg transition-colors"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary Stats */}
|
||||
{!compact && trades.length > 0 && (
|
||||
<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 ${stats.netProfit >= 0 ? 'text-green-400' : 'text-red-400'}`}>
|
||||
{stats.netProfit >= 0 ? '+' : ''}{stats.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">{stats.winRate.toFixed(1)}%</div>
|
||||
<div className="text-xs text-gray-500">Win Rate</div>
|
||||
</div>
|
||||
<div className="p-3 bg-gray-900/50 rounded-lg text-center">
|
||||
<div className="text-lg font-bold text-white">
|
||||
{stats.profitFactor === Infinity ? '∞' : stats.profitFactor.toFixed(2)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">Profit Factor</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">{stats.winning}</span>
|
||||
<span className="text-gray-500">/</span>
|
||||
<span className="text-red-400">{stats.losing}</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">W/L</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filter & Sort */}
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter className="w-4 h-4 text-gray-500" />
|
||||
<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-sm text-white"
|
||||
>
|
||||
<option value="all">All Trades</option>
|
||||
<option value="profitable">Profitable</option>
|
||||
<option value="losing">Losing</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-xs text-gray-500">
|
||||
Sort:
|
||||
<button
|
||||
onClick={() => handleSort('closeTime')}
|
||||
className={`px-2 py-1 rounded ${sortField === 'closeTime' ? 'bg-gray-700 text-white' : 'hover:bg-gray-700'}`}
|
||||
>
|
||||
Time
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleSort('profit')}
|
||||
className={`px-2 py-1 rounded ${sortField === 'profit' ? 'bg-gray-700 text-white' : 'hover:bg-gray-700'}`}
|
||||
>
|
||||
P&L
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Trade List */}
|
||||
{loading && trades.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="w-6 h-6 text-blue-400 animate-spin" />
|
||||
</div>
|
||||
) : filteredTrades.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<History className="w-10 h-10 text-gray-600 mx-auto mb-2" />
|
||||
<p className="text-gray-500 text-sm">No trades yet</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2 max-h-[400px] overflow-y-auto">
|
||||
{filteredTrades.map((trade) => (
|
||||
<div
|
||||
key={trade.ticket}
|
||||
className="bg-gray-900/50 rounded-lg border border-gray-700/50 overflow-hidden"
|
||||
>
|
||||
<div
|
||||
onClick={() => setExpandedTrade(expandedTrade === trade.ticket ? null : trade.ticket)}
|
||||
className="flex items-center justify-between p-3 cursor-pointer hover:bg-gray-800/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`p-1.5 rounded ${trade.type === 'BUY' ? 'bg-green-500/20' : 'bg-red-500/20'}`}>
|
||||
{trade.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">{trade.symbol}</span>
|
||||
<span className="text-xs text-gray-500">{trade.lots} lots</span>
|
||||
{getCloseReasonIcon(trade.closedBy)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{formatDate(trade.closeTime)} {formatTime(trade.closeTime)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`text-right font-mono font-medium ${trade.profit >= 0 ? 'text-green-400' : 'text-red-400'}`}>
|
||||
{trade.profit >= 0 ? '+' : ''}{trade.profit.toFixed(2)}
|
||||
</div>
|
||||
{expandedTrade === trade.ticket ? (
|
||||
<ChevronUp className="w-4 h-4 text-gray-500" />
|
||||
) : (
|
||||
<ChevronDown className="w-4 h-4 text-gray-500" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded Details */}
|
||||
{expandedTrade === trade.ticket && (
|
||||
<div className="px-3 pb-3 pt-1 border-t border-gray-700/50">
|
||||
<div className="grid grid-cols-3 gap-3 text-xs">
|
||||
<div>
|
||||
<div className="text-gray-500">Entry</div>
|
||||
<div className="text-white font-mono">{trade.openPrice.toFixed(5)}</div>
|
||||
<div className="text-gray-600">{formatTime(trade.openTime)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-500">Exit</div>
|
||||
<div className="text-white font-mono">{trade.closePrice.toFixed(5)}</div>
|
||||
<div className="text-gray-600">{formatTime(trade.closeTime)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-500">Fees</div>
|
||||
<div className="text-gray-400 font-mono">
|
||||
{(trade.commission + trade.swap).toFixed(2)}
|
||||
</div>
|
||||
<div className="text-gray-600">
|
||||
C: {trade.commission.toFixed(2)} / S: {trade.swap.toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{(trade.stopLoss || trade.takeProfit) && (
|
||||
<div className="flex gap-4 mt-2 pt-2 border-t border-gray-700/50 text-xs">
|
||||
{trade.stopLoss && (
|
||||
<div>
|
||||
<span className="text-gray-500">SL:</span>{' '}
|
||||
<span className="text-red-400 font-mono">{trade.stopLoss.toFixed(5)}</span>
|
||||
</div>
|
||||
)}
|
||||
{trade.takeProfit && (
|
||||
<div>
|
||||
<span className="text-gray-500">TP:</span>{' '}
|
||||
<span className="text-green-400 font-mono">{trade.takeProfit.toFixed(5)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TradeExecutionHistory;
|
||||
316
src/modules/trading/components/TradingMetricsCard.tsx
Normal file
316
src/modules/trading/components/TradingMetricsCard.tsx
Normal file
@ -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<TradingMetricsCardProps> = ({
|
||||
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 }) => (
|
||||
<span className={`text-xs ${value >= 0 ? 'text-green-400' : 'text-red-400'}`}>
|
||||
{formatChange(value, suffix)}
|
||||
</span>
|
||||
);
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<div className="p-3 bg-gray-800/50 rounded-lg border border-gray-700">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<BarChart3 className="w-4 h-4 text-blue-400" />
|
||||
<span className="text-sm text-gray-400">Today</span>
|
||||
</div>
|
||||
<div className={`font-bold ${metrics.netProfit >= 0 ? 'text-green-400' : 'text-red-400'}`}>
|
||||
{metrics.netProfit >= 0 ? '+' : ''}{metrics.netProfit.toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-2 text-xs text-gray-500">
|
||||
<span>{metrics.totalTrades} trades</span>
|
||||
<span>{winRate.toFixed(0)}% win</span>
|
||||
<span>{metrics.pips >= 0 ? '+' : ''}{metrics.pips} pips</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 bg-gray-800/50 rounded-xl border border-gray-700">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<BarChart3 className="w-5 h-5 text-blue-400" />
|
||||
<h3 className="font-semibold text-white">Daily Metrics</h3>
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
{new Date(metrics.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main P&L */}
|
||||
<div className="text-center p-4 bg-gray-900/50 rounded-lg mb-4">
|
||||
<div className={`text-3xl font-bold ${metrics.netProfit >= 0 ? 'text-green-400' : 'text-red-400'}`}>
|
||||
{metrics.netProfit >= 0 ? '+' : ''}${metrics.netProfit.toFixed(2)}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 mt-1">Net Profit/Loss</div>
|
||||
{comparison && (
|
||||
<div className="mt-2">
|
||||
<ChangeIndicator value={comparison.netProfitChange} suffix=" vs yesterday" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Key Stats Grid */}
|
||||
<div className="grid grid-cols-3 gap-3 mb-4">
|
||||
{/* Win Rate */}
|
||||
<div className="p-3 bg-gray-900/50 rounded-lg text-center">
|
||||
<div className="flex items-center justify-center gap-1 mb-1">
|
||||
<Target className="w-4 h-4 text-blue-400" />
|
||||
</div>
|
||||
<div className="text-lg font-bold text-white">{winRate.toFixed(1)}%</div>
|
||||
<div className="text-xs text-gray-500">Win Rate</div>
|
||||
{comparison && <ChangeIndicator value={comparison.winRateChange} suffix="%" />}
|
||||
</div>
|
||||
|
||||
{/* Profit Factor */}
|
||||
<div className="p-3 bg-gray-900/50 rounded-lg text-center">
|
||||
<div className="flex items-center justify-center gap-1 mb-1">
|
||||
<Award className="w-4 h-4 text-yellow-400" />
|
||||
</div>
|
||||
<div className="text-lg font-bold text-white">
|
||||
{profitFactor === Infinity ? '∞' : profitFactor.toFixed(2)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">Profit Factor</div>
|
||||
</div>
|
||||
|
||||
{/* Total Trades */}
|
||||
<div className="p-3 bg-gray-900/50 rounded-lg text-center">
|
||||
<div className="flex items-center justify-center gap-1 mb-1">
|
||||
<TrendingUp className="w-4 h-4 text-green-400" />
|
||||
</div>
|
||||
<div className="text-lg font-bold text-white">{metrics.totalTrades}</div>
|
||||
<div className="text-xs text-gray-500">Trades</div>
|
||||
{comparison && <ChangeIndicator value={comparison.tradesChange} />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Win/Loss Breakdown */}
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<div className="flex-1 bg-gray-900/50 rounded-lg p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<ArrowUpCircle className="w-4 h-4 text-green-400" />
|
||||
<span className="text-sm text-gray-400">Winners</span>
|
||||
</div>
|
||||
<span className="text-green-400 font-medium">{metrics.winningTrades}</span>
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-gray-500">
|
||||
Gross: <span className="text-green-400">+${metrics.grossProfit.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 bg-gray-900/50 rounded-lg p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<ArrowDownCircle className="w-4 h-4 text-red-400" />
|
||||
<span className="text-sm text-gray-400">Losers</span>
|
||||
</div>
|
||||
<span className="text-red-400 font-medium">{metrics.losingTrades}</span>
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-gray-500">
|
||||
Gross: <span className="text-red-400">${metrics.grossLoss.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Detailed Stats */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between py-2 border-b border-gray-700/50">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-400">
|
||||
<DollarSign className="w-4 h-4" />
|
||||
<span>Avg Win / Loss</span>
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
<span className="text-green-400">${metrics.avgWin.toFixed(2)}</span>
|
||||
<span className="text-gray-600"> / </span>
|
||||
<span className="text-red-400">${Math.abs(metrics.avgLoss).toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between py-2 border-b border-gray-700/50">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-400">
|
||||
<Percent className="w-4 h-4" />
|
||||
<span>R:R Ratio</span>
|
||||
</div>
|
||||
<div className="text-sm text-white">
|
||||
{riskRewardRatio === Infinity ? '∞' : riskRewardRatio.toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between py-2 border-b border-gray-700/50">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-400">
|
||||
<TrendingUp className="w-4 h-4" />
|
||||
<span>Total Pips</span>
|
||||
</div>
|
||||
<div className={`text-sm font-mono ${metrics.pips >= 0 ? 'text-green-400' : 'text-red-400'}`}>
|
||||
{metrics.pips >= 0 ? '+' : ''}{metrics.pips}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between py-2 border-b border-gray-700/50">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-400">
|
||||
<BarChart3 className="w-4 h-4" />
|
||||
<span>Volume</span>
|
||||
</div>
|
||||
<div className="text-sm text-white">{metrics.volume.toFixed(2)} lots</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between py-2 border-b border-gray-700/50">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-400">
|
||||
<Clock className="w-4 h-4" />
|
||||
<span>Trading Time</span>
|
||||
</div>
|
||||
<div className="text-sm text-white">{metrics.tradingHours.toFixed(1)}h</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between py-2 border-b border-gray-700/50">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-400">
|
||||
<DollarSign className="w-4 h-4" />
|
||||
<span>Expectancy</span>
|
||||
</div>
|
||||
<div className={`text-sm font-mono ${expectancy >= 0 ? 'text-green-400' : 'text-red-400'}`}>
|
||||
${expectancy.toFixed(2)}/trade
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Streaks & Records */}
|
||||
<div className="grid grid-cols-2 gap-3 mt-4">
|
||||
<div className="p-3 bg-gray-900/50 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Flame className="w-4 h-4 text-orange-400" />
|
||||
<span className="text-xs text-gray-400">Best Trade</span>
|
||||
</div>
|
||||
<div className="text-green-400 font-bold">+${metrics.largestWin.toFixed(2)}</div>
|
||||
</div>
|
||||
<div className="p-3 bg-gray-900/50 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<TrendingDown className="w-4 h-4 text-red-400" />
|
||||
<span className="text-xs text-gray-400">Worst Trade</span>
|
||||
</div>
|
||||
<div className="text-red-400 font-bold">${metrics.largestLoss.toFixed(2)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Consecutive Streaks */}
|
||||
<div className="flex items-center justify-center gap-4 mt-4 pt-4 border-t border-gray-700/50">
|
||||
<div className="text-center">
|
||||
<div className="text-xs text-gray-500 mb-1">Win Streak</div>
|
||||
<div className="text-green-400 font-bold">{metrics.consecutiveWins}</div>
|
||||
</div>
|
||||
<div className="w-px h-8 bg-gray-700" />
|
||||
<div className="text-center">
|
||||
<div className="text-xs text-gray-500 mb-1">Loss Streak</div>
|
||||
<div className="text-red-400 font-bold">{metrics.consecutiveLosses}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Fees */}
|
||||
{(metrics.commission !== 0 || metrics.swap !== 0) && (
|
||||
<div className="mt-4 pt-3 border-t border-gray-700/50 text-xs text-gray-500">
|
||||
<div className="flex justify-between">
|
||||
<span>Commission:</span>
|
||||
<span className="text-gray-400">${metrics.commission.toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between mt-1">
|
||||
<span>Swap:</span>
|
||||
<span className="text-gray-400">${metrics.swap.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TradingMetricsCard;
|
||||
@ -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';
|
||||
|
||||
11
src/modules/trading/hooks/index.ts
Normal file
11
src/modules/trading/hooks/index.ts
Normal file
@ -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';
|
||||
337
src/modules/trading/hooks/useMT4WebSocket.ts
Normal file
337
src/modules/trading/hooks/useMT4WebSocket.ts
Normal file
@ -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<MT4AccountInfo | null>(null);
|
||||
const [positions, setPositions] = useState<MT4Position[]>([]);
|
||||
const [orders, setOrders] = useState<MT4Order[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [lastUpdate, setLastUpdate] = useState<Date | null>(null);
|
||||
const [reconnectAttempts, setReconnectAttempts] = useState(0);
|
||||
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const heartbeatTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const subscribedChannelsRef = useRef<Set<string>>(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;
|
||||
Loading…
Reference in New Issue
Block a user