[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:
Adrian Flores Cortes 2026-01-25 11:23:33 -06:00
parent d2c0a09b1b
commit 4d2c00ac30
6 changed files with 1400 additions and 0 deletions

View 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;

View 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;

View 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;

View File

@ -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';

View 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';

View 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;