[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 MT4PositionsManager } from './MT4PositionsManager';
|
||||||
export { default as AdvancedOrderEntry } from './AdvancedOrderEntry';
|
export { default as AdvancedOrderEntry } from './AdvancedOrderEntry';
|
||||||
export { default as AccountHealthDashboard } from './AccountHealthDashboard';
|
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