[OQI-006] feat: Add ML confidence and performance tracking components
- ConfidenceMeter: Advanced confidence visualization with model agreement - SignalPerformanceTracker: Signal history with filters and statistics - ModelAccuracyDashboard: Individual model metrics and comparison - BacktestResultsVisualization: Backtest results with trades and monthly returns Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
cbb6637966
commit
e9aa29fccd
432
src/modules/ml/components/BacktestResultsVisualization.tsx
Normal file
432
src/modules/ml/components/BacktestResultsVisualization.tsx
Normal file
@ -0,0 +1,432 @@
|
||||
/**
|
||||
* BacktestResultsVisualization Component
|
||||
* Comprehensive backtest results viewer
|
||||
* OQI-006: Senales ML
|
||||
*/
|
||||
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import {
|
||||
LineChart,
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
Target,
|
||||
BarChart3,
|
||||
Activity,
|
||||
AlertTriangle,
|
||||
DollarSign,
|
||||
Percent,
|
||||
Calendar,
|
||||
Clock,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Download,
|
||||
Zap,
|
||||
Shield,
|
||||
Award,
|
||||
} from 'lucide-react';
|
||||
|
||||
export interface BacktestTrade {
|
||||
id: string;
|
||||
symbol: string;
|
||||
direction: 'BUY' | 'SELL';
|
||||
entryPrice: number;
|
||||
exitPrice: number;
|
||||
entryTime: string;
|
||||
exitTime: string;
|
||||
profit: number;
|
||||
profitPercent: number;
|
||||
duration: number; // minutes
|
||||
}
|
||||
|
||||
export interface BacktestResult {
|
||||
strategyName: string;
|
||||
period: { start: string; end: string };
|
||||
initialCapital: number;
|
||||
finalCapital: number;
|
||||
totalReturn: number;
|
||||
totalReturnPercent: number;
|
||||
totalTrades: number;
|
||||
winningTrades: number;
|
||||
losingTrades: number;
|
||||
winRate: number;
|
||||
profitFactor: number;
|
||||
sharpeRatio: number;
|
||||
sortinoRatio?: number;
|
||||
maxDrawdown: number;
|
||||
maxDrawdownPercent: number;
|
||||
avgWin: number;
|
||||
avgLoss: number;
|
||||
avgRR: number;
|
||||
expectancy: number;
|
||||
avgTradeDuration: number;
|
||||
largestWin: number;
|
||||
largestLoss: number;
|
||||
longestWinStreak: number;
|
||||
longestLoseStreak: number;
|
||||
trades: BacktestTrade[];
|
||||
equityCurve?: { date: string; equity: number; drawdown: number }[];
|
||||
monthlyReturns?: Record<string, number>;
|
||||
}
|
||||
|
||||
interface BacktestResultsVisualizationProps {
|
||||
result: BacktestResult;
|
||||
onExport?: (format: 'csv' | 'pdf') => void;
|
||||
onTradeSelect?: (trade: BacktestTrade) => void;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
const BacktestResultsVisualization: React.FC<BacktestResultsVisualizationProps> = ({
|
||||
result,
|
||||
onExport,
|
||||
onTradeSelect,
|
||||
compact = false,
|
||||
}) => {
|
||||
const [activeTab, setActiveTab] = useState<'overview' | 'trades' | 'monthly'>('overview');
|
||||
const [showAllTrades, setShowAllTrades] = useState(false);
|
||||
const [expandedTradeId, setExpandedTradeId] = useState<string | null>(null);
|
||||
|
||||
const isProfitable = result.totalReturn >= 0;
|
||||
const isGoodPerformance = result.winRate >= 50 && result.profitFactor >= 1.5;
|
||||
|
||||
const getMetricColor = (value: number, thresholds: { good: number; bad: number }, inverse = false) => {
|
||||
if (inverse) {
|
||||
if (value <= thresholds.good) return 'text-green-400';
|
||||
if (value >= thresholds.bad) return 'text-red-400';
|
||||
return 'text-yellow-400';
|
||||
}
|
||||
if (value >= thresholds.good) return 'text-green-400';
|
||||
if (value <= thresholds.bad) return 'text-red-400';
|
||||
return 'text-yellow-400';
|
||||
};
|
||||
|
||||
const formatCurrency = (value: number) => {
|
||||
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(value);
|
||||
};
|
||||
|
||||
const formatPercent = (value: number) => {
|
||||
return `${value >= 0 ? '+' : ''}${value.toFixed(2)}%`;
|
||||
};
|
||||
|
||||
const formatDuration = (minutes: number) => {
|
||||
if (minutes < 60) return `${minutes}m`;
|
||||
if (minutes < 1440) return `${Math.floor(minutes / 60)}h ${minutes % 60}m`;
|
||||
return `${Math.floor(minutes / 1440)}d`;
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
const displayedTrades = useMemo(() => {
|
||||
return showAllTrades ? result.trades : result.trades.slice(0, 10);
|
||||
}, [result.trades, showAllTrades]);
|
||||
|
||||
return (
|
||||
<div className={`bg-gray-800/50 rounded-xl border border-gray-700 ${compact ? 'p-3' : 'p-4'}`}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`p-2 rounded-lg ${isProfitable ? 'bg-green-500/20' : 'bg-red-500/20'}`}>
|
||||
<LineChart className={`w-5 h-5 ${isProfitable ? 'text-green-400' : 'text-red-400'}`} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-white">{result.strategyName}</h3>
|
||||
<p className="text-xs text-gray-500">
|
||||
{formatDate(result.period.start)} - {formatDate(result.period.end)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{onExport && (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => onExport('csv')}
|
||||
className="px-3 py-1.5 bg-gray-700 hover:bg-gray-600 text-white rounded text-xs"
|
||||
>
|
||||
Export CSV
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onExport('pdf')}
|
||||
className="px-3 py-1.5 bg-blue-600 hover:bg-blue-500 text-white rounded text-xs"
|
||||
>
|
||||
Export PDF
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Main Result Card */}
|
||||
<div className={`p-6 rounded-xl mb-6 ${
|
||||
isProfitable
|
||||
? 'bg-gradient-to-r from-green-500/10 to-emerald-500/10 border border-green-500/30'
|
||||
: 'bg-gradient-to-r from-red-500/10 to-rose-500/10 border border-red-500/30'
|
||||
}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-gray-400 text-sm mb-1">Total Return</div>
|
||||
<div className={`text-4xl font-bold ${isProfitable ? 'text-green-400' : 'text-red-400'}`}>
|
||||
{formatCurrency(result.totalReturn)}
|
||||
</div>
|
||||
<div className={`text-lg ${isProfitable ? 'text-green-400' : 'text-red-400'}`}>
|
||||
{formatPercent(result.totalReturnPercent)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
{isGoodPerformance ? (
|
||||
<Award className="w-6 h-6 text-yellow-400" />
|
||||
) : (
|
||||
<AlertTriangle className="w-6 h-6 text-yellow-400" />
|
||||
)}
|
||||
<span className="text-gray-400 text-sm">
|
||||
{isGoodPerformance ? 'Strong Performance' : 'Needs Optimization'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{formatCurrency(result.initialCapital)} → {formatCurrency(result.finalCapital)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
{(['overview', 'trades', 'monthly'] as const).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
className={`px-4 py-2 rounded text-sm font-medium transition-colors ${
|
||||
activeTab === tab
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
{tab.charAt(0).toUpperCase() + tab.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Overview Tab */}
|
||||
{activeTab === 'overview' && (
|
||||
<div className="space-y-4">
|
||||
{/* Key Metrics Grid */}
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<div className="p-3 bg-gray-900/50 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Target className="w-4 h-4 text-blue-400" />
|
||||
<span className="text-xs text-gray-400">Win Rate</span>
|
||||
</div>
|
||||
<div className={`text-xl font-bold ${getMetricColor(result.winRate, { good: 55, bad: 40 })}`}>
|
||||
{result.winRate.toFixed(1)}%
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3 bg-gray-900/50 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Zap className="w-4 h-4 text-yellow-400" />
|
||||
<span className="text-xs text-gray-400">Profit Factor</span>
|
||||
</div>
|
||||
<div className={`text-xl font-bold ${getMetricColor(result.profitFactor, { good: 1.5, bad: 1 })}`}>
|
||||
{result.profitFactor.toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3 bg-gray-900/50 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Activity className="w-4 h-4 text-purple-400" />
|
||||
<span className="text-xs text-gray-400">Sharpe Ratio</span>
|
||||
</div>
|
||||
<div className={`text-xl font-bold ${getMetricColor(result.sharpeRatio, { good: 1.5, bad: 0.5 })}`}>
|
||||
{result.sharpeRatio.toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3 bg-gray-900/50 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Shield className="w-4 h-4 text-red-400" />
|
||||
<span className="text-xs text-gray-400">Max Drawdown</span>
|
||||
</div>
|
||||
<div className={`text-xl font-bold ${getMetricColor(result.maxDrawdownPercent, { good: 10, bad: 25 }, true)}`}>
|
||||
{result.maxDrawdownPercent.toFixed(1)}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Trade Statistics */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="p-4 bg-gray-900/50 rounded-lg">
|
||||
<h4 className="text-sm text-gray-400 mb-3">Trade Statistics</h4>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-500">Total Trades</span>
|
||||
<span className="text-white">{result.totalTrades}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-500">Winning</span>
|
||||
<span className="text-green-400">{result.winningTrades}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-500">Losing</span>
|
||||
<span className="text-red-400">{result.losingTrades}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-500">Avg Duration</span>
|
||||
<span className="text-white">{formatDuration(result.avgTradeDuration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-gray-900/50 rounded-lg">
|
||||
<h4 className="text-sm text-gray-400 mb-3">P&L Analysis</h4>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-500">Avg Win</span>
|
||||
<span className="text-green-400">{formatCurrency(result.avgWin)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-500">Avg Loss</span>
|
||||
<span className="text-red-400">{formatCurrency(result.avgLoss)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-500">Avg R:R</span>
|
||||
<span className="text-white">1:{result.avgRR.toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-500">Expectancy</span>
|
||||
<span className={result.expectancy >= 0 ? 'text-green-400' : 'text-red-400'}>
|
||||
{formatCurrency(result.expectancy)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Streaks & Extremes */}
|
||||
<div className="p-4 bg-gray-900/50 rounded-lg">
|
||||
<h4 className="text-sm text-gray-400 mb-3">Extremes & Streaks</h4>
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<div className="text-center">
|
||||
<div className="text-green-400 font-bold">{formatCurrency(result.largestWin)}</div>
|
||||
<div className="text-xs text-gray-500">Largest Win</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-red-400 font-bold">{formatCurrency(result.largestLoss)}</div>
|
||||
<div className="text-xs text-gray-500">Largest Loss</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-green-400 font-bold">{result.longestWinStreak}</div>
|
||||
<div className="text-xs text-gray-500">Win Streak</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-red-400 font-bold">{result.longestLoseStreak}</div>
|
||||
<div className="text-xs text-gray-500">Lose Streak</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Trades Tab */}
|
||||
{activeTab === 'trades' && (
|
||||
<div>
|
||||
<div className="space-y-2 max-h-[400px] overflow-y-auto">
|
||||
{displayedTrades.map((trade) => {
|
||||
const isExpanded = expandedTradeId === trade.id;
|
||||
return (
|
||||
<div
|
||||
key={trade.id}
|
||||
onClick={() => {
|
||||
setExpandedTradeId(isExpanded ? null : trade.id);
|
||||
onTradeSelect?.(trade);
|
||||
}}
|
||||
className="p-3 bg-gray-900/50 rounded-lg border border-gray-700/50 cursor-pointer hover:border-gray-600 transition-colors"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`p-1.5 rounded ${trade.direction === 'BUY' ? 'bg-green-500/20' : 'bg-red-500/20'}`}>
|
||||
{trade.direction === 'BUY' ? (
|
||||
<TrendingUp className="w-4 h-4 text-green-400" />
|
||||
) : (
|
||||
<TrendingDown className="w-4 h-4 text-red-400" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium text-white">{trade.symbol}</span>
|
||||
<div className="text-xs text-gray-500">{formatDate(trade.entryTime)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="text-right">
|
||||
<div className={`font-mono font-medium ${trade.profit >= 0 ? 'text-green-400' : 'text-red-400'}`}>
|
||||
{trade.profit >= 0 ? '+' : ''}{formatCurrency(trade.profit)}
|
||||
</div>
|
||||
<div className={`text-xs ${trade.profit >= 0 ? 'text-green-400' : 'text-red-400'}`}>
|
||||
{formatPercent(trade.profitPercent)}
|
||||
</div>
|
||||
</div>
|
||||
{isExpanded ? <ChevronUp className="w-4 h-4 text-gray-500" /> : <ChevronDown className="w-4 h-4 text-gray-500" />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="mt-3 pt-3 border-t border-gray-700/50 grid grid-cols-4 gap-3 text-xs">
|
||||
<div>
|
||||
<span className="text-gray-500">Entry:</span>
|
||||
<span className="text-white ml-1 font-mono">{trade.entryPrice.toFixed(5)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">Exit:</span>
|
||||
<span className="text-white ml-1 font-mono">{trade.exitPrice.toFixed(5)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">Duration:</span>
|
||||
<span className="text-white ml-1">{formatDuration(trade.duration)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">Exit:</span>
|
||||
<span className="text-white ml-1">{formatDate(trade.exitTime)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{result.trades.length > 10 && (
|
||||
<button
|
||||
onClick={() => setShowAllTrades(!showAllTrades)}
|
||||
className="w-full mt-3 py-2 text-sm text-blue-400 hover:text-blue-300 text-center"
|
||||
>
|
||||
{showAllTrades ? 'Show Less' : `Show All ${result.trades.length} Trades`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Monthly Tab */}
|
||||
{activeTab === 'monthly' && result.monthlyReturns && (
|
||||
<div>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{Object.entries(result.monthlyReturns).map(([month, returns]) => (
|
||||
<div
|
||||
key={month}
|
||||
className={`p-3 rounded-lg text-center ${
|
||||
returns >= 0 ? 'bg-green-500/10 border border-green-500/20' : 'bg-red-500/10 border border-red-500/20'
|
||||
}`}
|
||||
>
|
||||
<div className="text-xs text-gray-400 mb-1">{month}</div>
|
||||
<div className={`font-bold ${returns >= 0 ? 'text-green-400' : 'text-red-400'}`}>
|
||||
{formatPercent(returns)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BacktestResultsVisualization;
|
||||
302
src/modules/ml/components/ConfidenceMeter.tsx
Normal file
302
src/modules/ml/components/ConfidenceMeter.tsx
Normal file
@ -0,0 +1,302 @@
|
||||
/**
|
||||
* ConfidenceMeter Component
|
||||
* Advanced confidence indicator with uncertainty visualization
|
||||
* OQI-006: Senales ML
|
||||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import {
|
||||
Gauge,
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
Info,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Zap,
|
||||
BarChart3,
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
|
||||
export interface ConfidenceData {
|
||||
overall: number;
|
||||
modelAgreement: number;
|
||||
trend: 'up' | 'down' | 'stable';
|
||||
trendChange: number;
|
||||
modelVotes: {
|
||||
bullish: number;
|
||||
bearish: number;
|
||||
neutral: number;
|
||||
};
|
||||
featureImportance?: {
|
||||
feature: string;
|
||||
contribution: number;
|
||||
direction: 'positive' | 'negative';
|
||||
}[];
|
||||
historicalConfidence?: { timestamp: string; value: number }[];
|
||||
volatility?: number;
|
||||
}
|
||||
|
||||
interface ConfidenceMeterProps {
|
||||
confidence: ConfidenceData;
|
||||
direction?: 'BUY' | 'SELL' | 'NEUTRAL';
|
||||
symbol?: string;
|
||||
showDetails?: boolean;
|
||||
onToggleDetails?: () => void;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
const ConfidenceMeter: React.FC<ConfidenceMeterProps> = ({
|
||||
confidence,
|
||||
direction = 'NEUTRAL',
|
||||
symbol,
|
||||
showDetails = false,
|
||||
onToggleDetails,
|
||||
compact = false,
|
||||
}) => {
|
||||
const [isExpanded, setIsExpanded] = React.useState(showDetails);
|
||||
|
||||
// Get confidence zone
|
||||
const getConfidenceZone = (value: number) => {
|
||||
if (value >= 70) return { label: 'High', color: 'green' };
|
||||
if (value >= 40) return { label: 'Medium', color: 'yellow' };
|
||||
return { label: 'Low', color: 'red' };
|
||||
};
|
||||
|
||||
const zone = useMemo(() => getConfidenceZone(confidence.overall), [confidence.overall]);
|
||||
|
||||
const getZoneColor = (color: string) => {
|
||||
switch (color) {
|
||||
case 'green':
|
||||
return 'text-green-400 bg-green-500/20 border-green-500/30';
|
||||
case 'yellow':
|
||||
return 'text-yellow-400 bg-yellow-500/20 border-yellow-500/30';
|
||||
case 'red':
|
||||
return 'text-red-400 bg-red-500/20 border-red-500/30';
|
||||
default:
|
||||
return 'text-gray-400 bg-gray-500/20 border-gray-500/30';
|
||||
}
|
||||
};
|
||||
|
||||
const getGaugeBackground = (value: number) => {
|
||||
const percentage = Math.min(Math.max(value, 0), 100);
|
||||
const color =
|
||||
percentage >= 70
|
||||
? 'from-green-600 to-green-400'
|
||||
: percentage >= 40
|
||||
? 'from-yellow-600 to-yellow-400'
|
||||
: 'from-red-600 to-red-400';
|
||||
return `bg-gradient-to-r ${color}`;
|
||||
};
|
||||
|
||||
const totalVotes = confidence.modelVotes.bullish + confidence.modelVotes.bearish + confidence.modelVotes.neutral;
|
||||
|
||||
const formatPercent = (value: number) => `${value.toFixed(1)}%`;
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 p-3 bg-gray-800/50 rounded-lg border border-gray-700">
|
||||
<div className={`p-2 rounded-lg ${getZoneColor(zone.color)}`}>
|
||||
<Gauge className="w-5 h-5" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-400">Confidence</span>
|
||||
<span className={`text-lg font-bold ${getZoneColor(zone.color).split(' ')[0]}`}>
|
||||
{confidence.overall.toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1.5 bg-gray-700 rounded-full mt-1 overflow-hidden">
|
||||
<div className={`h-full rounded-full ${getGaugeBackground(confidence.overall)}`} style={{ width: `${confidence.overall}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-gray-800/50 rounded-xl border border-gray-700 p-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`p-2 rounded-lg ${getZoneColor(zone.color)}`}>
|
||||
<Gauge className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-white">Confidence Meter</h3>
|
||||
{symbol && <p className="text-xs text-gray-500">{symbol}</p>}
|
||||
</div>
|
||||
</div>
|
||||
<div className={`px-3 py-1 rounded-full text-xs font-medium ${getZoneColor(zone.color)}`}>
|
||||
{zone.label} Confidence
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Gauge */}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-end justify-between mb-2">
|
||||
<span className="text-4xl font-bold text-white">{confidence.overall.toFixed(0)}%</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{confidence.trend === 'up' ? (
|
||||
<TrendingUp className="w-4 h-4 text-green-400" />
|
||||
) : confidence.trend === 'down' ? (
|
||||
<TrendingDown className="w-4 h-4 text-red-400" />
|
||||
) : (
|
||||
<div className="w-4 h-0.5 bg-gray-500" />
|
||||
)}
|
||||
<span
|
||||
className={`text-sm ${
|
||||
confidence.trend === 'up'
|
||||
? 'text-green-400'
|
||||
: confidence.trend === 'down'
|
||||
? 'text-red-400'
|
||||
: 'text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{confidence.trendChange > 0 ? '+' : ''}
|
||||
{confidence.trendChange.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Gauge Bar */}
|
||||
<div className="relative h-4 bg-gray-700 rounded-full overflow-hidden">
|
||||
{/* Zone indicators */}
|
||||
<div className="absolute inset-0 flex">
|
||||
<div className="w-[40%] bg-red-500/20 border-r border-gray-600" />
|
||||
<div className="w-[30%] bg-yellow-500/20 border-r border-gray-600" />
|
||||
<div className="w-[30%] bg-green-500/20" />
|
||||
</div>
|
||||
{/* Current value */}
|
||||
<div
|
||||
className={`absolute h-full ${getGaugeBackground(confidence.overall)} transition-all duration-500`}
|
||||
style={{ width: `${confidence.overall}%` }}
|
||||
/>
|
||||
{/* Marker */}
|
||||
<div
|
||||
className="absolute top-0 bottom-0 w-1 bg-white shadow-lg"
|
||||
style={{ left: `${confidence.overall}%`, transform: 'translateX(-50%)' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Zone labels */}
|
||||
<div className="flex justify-between mt-1 text-xs text-gray-500">
|
||||
<span>Low (0-40%)</span>
|
||||
<span>Medium (40-70%)</span>
|
||||
<span>High (70-100%)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Model Agreement */}
|
||||
<div className="grid grid-cols-2 gap-4 mb-4">
|
||||
<div className="p-3 bg-gray-900/50 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Users className="w-4 h-4 text-blue-400" />
|
||||
<span className="text-sm text-gray-400">Model Agreement</span>
|
||||
</div>
|
||||
<div className="text-xl font-bold text-white">{formatPercent(confidence.modelAgreement)}</div>
|
||||
</div>
|
||||
|
||||
{confidence.volatility !== undefined && (
|
||||
<div className="p-3 bg-gray-900/50 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Zap className="w-4 h-4 text-yellow-400" />
|
||||
<span className="text-sm text-gray-400">Volatility Risk</span>
|
||||
</div>
|
||||
<div
|
||||
className={`text-xl font-bold ${
|
||||
confidence.volatility >= 70 ? 'text-red-400' : confidence.volatility >= 40 ? 'text-yellow-400' : 'text-green-400'
|
||||
}`}
|
||||
>
|
||||
{confidence.volatility.toFixed(0)}%
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Model Votes */}
|
||||
<div className="mb-4">
|
||||
<div className="text-sm text-gray-400 mb-2">Model Votes ({totalVotes} models)</div>
|
||||
<div className="flex h-3 rounded-full overflow-hidden bg-gray-700">
|
||||
<div
|
||||
className="bg-green-500 transition-all"
|
||||
style={{ width: `${(confidence.modelVotes.bullish / totalVotes) * 100}%` }}
|
||||
title={`Bullish: ${confidence.modelVotes.bullish}`}
|
||||
/>
|
||||
<div
|
||||
className="bg-gray-500 transition-all"
|
||||
style={{ width: `${(confidence.modelVotes.neutral / totalVotes) * 100}%` }}
|
||||
title={`Neutral: ${confidence.modelVotes.neutral}`}
|
||||
/>
|
||||
<div
|
||||
className="bg-red-500 transition-all"
|
||||
style={{ width: `${(confidence.modelVotes.bearish / totalVotes) * 100}%` }}
|
||||
title={`Bearish: ${confidence.modelVotes.bearish}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between mt-1 text-xs">
|
||||
<span className="text-green-400">{confidence.modelVotes.bullish} Bullish</span>
|
||||
<span className="text-gray-400">{confidence.modelVotes.neutral} Neutral</span>
|
||||
<span className="text-red-400">{confidence.modelVotes.bearish} Bearish</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Feature Importance (Expandable) */}
|
||||
{confidence.featureImportance && confidence.featureImportance.length > 0 && (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="flex items-center justify-between w-full p-2 text-sm text-gray-400 hover:bg-gray-800 rounded-lg transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<BarChart3 className="w-4 h-4" />
|
||||
<span>Feature Importance</span>
|
||||
</div>
|
||||
{isExpanded ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="mt-2 space-y-2">
|
||||
{confidence.featureImportance.map((feature, idx) => (
|
||||
<div key={idx} className="flex items-center justify-between p-2 bg-gray-900/50 rounded">
|
||||
<div className="flex items-center gap-2">
|
||||
{feature.direction === 'positive' ? (
|
||||
<CheckCircle className="w-3 h-3 text-green-400" />
|
||||
) : (
|
||||
<AlertTriangle className="w-3 h-3 text-red-400" />
|
||||
)}
|
||||
<span className="text-sm text-gray-300">{feature.feature}</span>
|
||||
</div>
|
||||
<div
|
||||
className={`text-sm font-medium ${
|
||||
feature.direction === 'positive' ? 'text-green-400' : 'text-red-400'
|
||||
}`}
|
||||
>
|
||||
{feature.direction === 'positive' ? '+' : '-'}
|
||||
{Math.abs(feature.contribution).toFixed(1)}%
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Risk Advisory */}
|
||||
{confidence.overall < 40 && (
|
||||
<div className="mt-4 p-3 bg-red-500/10 border border-red-500/30 rounded-lg flex items-start gap-2">
|
||||
<AlertTriangle className="w-5 h-5 text-red-400 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-red-400 font-medium text-sm">Low Confidence Warning</p>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
This signal has low model agreement. Consider waiting for stronger confirmation.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConfidenceMeter;
|
||||
410
src/modules/ml/components/ModelAccuracyDashboard.tsx
Normal file
410
src/modules/ml/components/ModelAccuracyDashboard.tsx
Normal file
@ -0,0 +1,410 @@
|
||||
/**
|
||||
* ModelAccuracyDashboard Component
|
||||
* Individual model accuracy monitoring and comparison
|
||||
* OQI-006: Senales ML
|
||||
*/
|
||||
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import {
|
||||
Brain,
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
Target,
|
||||
BarChart3,
|
||||
Activity,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Clock,
|
||||
RefreshCw,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Zap,
|
||||
Award,
|
||||
AlertTriangle,
|
||||
Filter,
|
||||
} from 'lucide-react';
|
||||
|
||||
export interface ModelMetrics {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'classification' | 'regression' | 'ensemble';
|
||||
accuracy: number;
|
||||
winRate: number;
|
||||
totalPredictions: number;
|
||||
correctPredictions: number;
|
||||
avgConfidence: number;
|
||||
sharpeRatio?: number;
|
||||
profitFactor?: number;
|
||||
mae?: number;
|
||||
rmse?: number;
|
||||
lastUpdated: string;
|
||||
status: 'active' | 'training' | 'inactive';
|
||||
performanceBySymbol?: Record<string, { accuracy: number; predictions: number }>;
|
||||
performanceByTimeframe?: Record<string, { accuracy: number; predictions: number }>;
|
||||
trendData?: { date: string; accuracy: number }[];
|
||||
}
|
||||
|
||||
interface ModelAccuracyDashboardProps {
|
||||
models: ModelMetrics[];
|
||||
selectedModelId?: string;
|
||||
onModelSelect?: (modelId: string) => void;
|
||||
onRefresh?: () => void;
|
||||
isLoading?: boolean;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
const ModelAccuracyDashboard: React.FC<ModelAccuracyDashboardProps> = ({
|
||||
models,
|
||||
selectedModelId,
|
||||
onModelSelect,
|
||||
onRefresh,
|
||||
isLoading = false,
|
||||
compact = false,
|
||||
}) => {
|
||||
const [expandedModelId, setExpandedModelId] = useState<string | null>(selectedModelId || null);
|
||||
const [filterType, setFilterType] = useState<'all' | 'classification' | 'regression' | 'ensemble'>('all');
|
||||
const [sortBy, setSortBy] = useState<'accuracy' | 'predictions' | 'winRate'>('accuracy');
|
||||
const [compareMode, setCompareMode] = useState(false);
|
||||
const [compareModels, setCompareModels] = useState<string[]>([]);
|
||||
|
||||
// Filter and sort models
|
||||
const filteredModels = useMemo(() => {
|
||||
let result = [...models];
|
||||
|
||||
if (filterType !== 'all') {
|
||||
result = result.filter((m) => m.type === filterType);
|
||||
}
|
||||
|
||||
result.sort((a, b) => {
|
||||
switch (sortBy) {
|
||||
case 'accuracy':
|
||||
return b.accuracy - a.accuracy;
|
||||
case 'predictions':
|
||||
return b.totalPredictions - a.totalPredictions;
|
||||
case 'winRate':
|
||||
return b.winRate - a.winRate;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}, [models, filterType, sortBy]);
|
||||
|
||||
// Calculate aggregate stats
|
||||
const aggregateStats = useMemo(() => {
|
||||
const activeModels = models.filter((m) => m.status === 'active');
|
||||
const avgAccuracy = activeModels.reduce((sum, m) => sum + m.accuracy, 0) / (activeModels.length || 1);
|
||||
const avgWinRate = activeModels.reduce((sum, m) => sum + m.winRate, 0) / (activeModels.length || 1);
|
||||
const totalPredictions = models.reduce((sum, m) => sum + m.totalPredictions, 0);
|
||||
const bestModel = models.reduce((best, m) => (m.accuracy > (best?.accuracy || 0) ? m : best), models[0]);
|
||||
|
||||
return { avgAccuracy, avgWinRate, totalPredictions, bestModel, activeModels: activeModels.length };
|
||||
}, [models]);
|
||||
|
||||
const getAccuracyColor = (accuracy: number) => {
|
||||
if (accuracy >= 70) return 'text-green-400';
|
||||
if (accuracy >= 50) return 'text-yellow-400';
|
||||
return 'text-red-400';
|
||||
};
|
||||
|
||||
const getAccuracyBg = (accuracy: number) => {
|
||||
if (accuracy >= 70) return 'bg-green-500';
|
||||
if (accuracy >= 50) return 'bg-yellow-500';
|
||||
return 'bg-red-500';
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case 'active':
|
||||
return <span className="px-2 py-0.5 bg-green-500/20 text-green-400 rounded text-xs">Active</span>;
|
||||
case 'training':
|
||||
return <span className="px-2 py-0.5 bg-yellow-500/20 text-yellow-400 rounded text-xs">Training</span>;
|
||||
default:
|
||||
return <span className="px-2 py-0.5 bg-gray-500/20 text-gray-400 rounded text-xs">Inactive</span>;
|
||||
}
|
||||
};
|
||||
|
||||
const toggleCompare = (modelId: string) => {
|
||||
if (compareModels.includes(modelId)) {
|
||||
setCompareModels(compareModels.filter((id) => id !== modelId));
|
||||
} else if (compareModels.length < 2) {
|
||||
setCompareModels([...compareModels, modelId]);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`bg-gray-800/50 rounded-xl border border-gray-700 ${compact ? 'p-3' : 'p-4'}`}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-blue-500/20 rounded-lg">
|
||||
<Brain className="w-5 h-5 text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-white">Model Accuracy</h3>
|
||||
<p className="text-xs text-gray-500">{aggregateStats.activeModels} active models</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setCompareMode(!compareMode)}
|
||||
className={`px-3 py-1.5 rounded text-xs font-medium transition-colors ${
|
||||
compareMode
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
Compare
|
||||
</button>
|
||||
{onRefresh && (
|
||||
<button
|
||||
onClick={onRefresh}
|
||||
disabled={isLoading}
|
||||
className="p-2 text-gray-400 hover:text-white hover:bg-gray-700 rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Aggregate Stats */}
|
||||
<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 ${getAccuracyColor(aggregateStats.avgAccuracy)}`}>
|
||||
{aggregateStats.avgAccuracy.toFixed(1)}%
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">Avg Accuracy</div>
|
||||
</div>
|
||||
<div className="p-3 bg-gray-900/50 rounded-lg text-center">
|
||||
<div className="text-lg font-bold text-white">{aggregateStats.avgWinRate.toFixed(1)}%</div>
|
||||
<div className="text-xs text-gray-500">Avg Win Rate</div>
|
||||
</div>
|
||||
<div className="p-3 bg-gray-900/50 rounded-lg text-center">
|
||||
<div className="text-lg font-bold text-white">{aggregateStats.totalPredictions.toLocaleString()}</div>
|
||||
<div className="text-xs text-gray-500">Total Predictions</div>
|
||||
</div>
|
||||
<div className="p-3 bg-gray-900/50 rounded-lg text-center">
|
||||
<div className="text-lg font-bold text-yellow-400 truncate" title={aggregateStats.bestModel?.name}>
|
||||
{aggregateStats.bestModel?.name || '-'}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">Best Model</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
{!compact && (
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Filter className="w-4 h-4 text-gray-500" />
|
||||
<select
|
||||
value={filterType}
|
||||
onChange={(e) => setFilterType(e.target.value as any)}
|
||||
className="px-2 py-1 bg-gray-900 border border-gray-700 rounded text-xs text-white"
|
||||
>
|
||||
<option value="all">All Types</option>
|
||||
<option value="classification">Classification</option>
|
||||
<option value="regression">Regression</option>
|
||||
<option value="ensemble">Ensemble</option>
|
||||
</select>
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={(e) => setSortBy(e.target.value as any)}
|
||||
className="px-2 py-1 bg-gray-900 border border-gray-700 rounded text-xs text-white"
|
||||
>
|
||||
<option value="accuracy">Sort by Accuracy</option>
|
||||
<option value="predictions">Sort by Predictions</option>
|
||||
<option value="winRate">Sort by Win Rate</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Compare Section */}
|
||||
{compareMode && compareModels.length === 2 && (
|
||||
<div className="mb-4 p-4 bg-blue-500/10 border border-blue-500/30 rounded-lg">
|
||||
<h4 className="text-sm text-blue-400 font-medium mb-3">Model Comparison</h4>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{compareModels.map((modelId) => {
|
||||
const model = models.find((m) => m.id === modelId);
|
||||
if (!model) return null;
|
||||
return (
|
||||
<div key={model.id} className="p-3 bg-gray-900/50 rounded-lg">
|
||||
<div className="font-medium text-white mb-2">{model.name}</div>
|
||||
<div className="space-y-1 text-xs">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Accuracy</span>
|
||||
<span className={getAccuracyColor(model.accuracy)}>{model.accuracy.toFixed(1)}%</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Win Rate</span>
|
||||
<span className="text-white">{model.winRate.toFixed(1)}%</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Predictions</span>
|
||||
<span className="text-white">{model.totalPredictions}</span>
|
||||
</div>
|
||||
{model.profitFactor && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Profit Factor</span>
|
||||
<span className="text-white">{model.profitFactor.toFixed(2)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Models List */}
|
||||
<div className="space-y-2 max-h-[400px] overflow-y-auto">
|
||||
{filteredModels.map((model) => {
|
||||
const isExpanded = expandedModelId === model.id;
|
||||
const isSelected = compareModels.includes(model.id);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={model.id}
|
||||
className={`p-3 bg-gray-900/50 rounded-lg border transition-all ${
|
||||
isExpanded
|
||||
? 'border-blue-500/50'
|
||||
: isSelected
|
||||
? 'border-yellow-500/50'
|
||||
: 'border-gray-700/50 hover:border-gray-600'
|
||||
}`}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
onClick={() => {
|
||||
if (compareMode) {
|
||||
toggleCompare(model.id);
|
||||
} else {
|
||||
setExpandedModelId(isExpanded ? null : model.id);
|
||||
onModelSelect?.(model.id);
|
||||
}
|
||||
}}
|
||||
className="flex items-center justify-between cursor-pointer"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{compareMode && (
|
||||
<div
|
||||
className={`w-5 h-5 rounded border-2 flex items-center justify-center ${
|
||||
isSelected ? 'border-yellow-500 bg-yellow-500/20' : 'border-gray-600'
|
||||
}`}
|
||||
>
|
||||
{isSelected && <CheckCircle className="w-3 h-3 text-yellow-400" />}
|
||||
</div>
|
||||
)}
|
||||
<div className={`p-1.5 rounded ${model.status === 'active' ? 'bg-blue-500/20' : 'bg-gray-500/20'}`}>
|
||||
<Brain className={`w-4 h-4 ${model.status === 'active' ? 'text-blue-400' : 'text-gray-400'}`} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-white">{model.name}</span>
|
||||
{getStatusBadge(model.status)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{model.type} • {model.totalPredictions} predictions
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Accuracy Bar */}
|
||||
<div className="w-24">
|
||||
<div className="flex items-center justify-between text-xs mb-1">
|
||||
<span className="text-gray-500">Accuracy</span>
|
||||
<span className={getAccuracyColor(model.accuracy)}>{model.accuracy.toFixed(1)}%</span>
|
||||
</div>
|
||||
<div className="h-1.5 bg-gray-700 rounded-full overflow-hidden">
|
||||
<div className={`h-full ${getAccuracyBg(model.accuracy)} rounded-full`} style={{ width: `${model.accuracy}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
{!compareMode && (isExpanded ? <ChevronUp className="w-4 h-4 text-gray-500" /> : <ChevronDown className="w-4 h-4 text-gray-500" />)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded Details */}
|
||||
{isExpanded && !compareMode && (
|
||||
<div className="mt-3 pt-3 border-t border-gray-700/50">
|
||||
{/* Detailed Metrics */}
|
||||
<div className="grid grid-cols-4 gap-3 mb-3">
|
||||
<div className="p-2 bg-gray-800/50 rounded text-center">
|
||||
<div className="text-white font-medium">{model.winRate.toFixed(1)}%</div>
|
||||
<div className="text-xs text-gray-500">Win Rate</div>
|
||||
</div>
|
||||
<div className="p-2 bg-gray-800/50 rounded text-center">
|
||||
<div className="text-white font-medium">{model.avgConfidence.toFixed(1)}%</div>
|
||||
<div className="text-xs text-gray-500">Avg Confidence</div>
|
||||
</div>
|
||||
{model.profitFactor && (
|
||||
<div className="p-2 bg-gray-800/50 rounded text-center">
|
||||
<div className="text-white font-medium">{model.profitFactor.toFixed(2)}</div>
|
||||
<div className="text-xs text-gray-500">Profit Factor</div>
|
||||
</div>
|
||||
)}
|
||||
{model.sharpeRatio && (
|
||||
<div className="p-2 bg-gray-800/50 rounded text-center">
|
||||
<div className="text-white font-medium">{model.sharpeRatio.toFixed(2)}</div>
|
||||
<div className="text-xs text-gray-500">Sharpe Ratio</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Performance by Symbol */}
|
||||
{model.performanceBySymbol && Object.keys(model.performanceBySymbol).length > 0 && (
|
||||
<div className="mb-3">
|
||||
<div className="text-xs text-gray-400 mb-2">Performance by Symbol</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{Object.entries(model.performanceBySymbol).slice(0, 5).map(([symbol, data]) => (
|
||||
<div key={symbol} className="px-2 py-1 bg-gray-800/50 rounded text-xs">
|
||||
<span className="text-gray-400">{symbol}:</span>
|
||||
<span className={`ml-1 ${getAccuracyColor(data.accuracy)}`}>{data.accuracy.toFixed(0)}%</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Performance by Timeframe */}
|
||||
{model.performanceByTimeframe && Object.keys(model.performanceByTimeframe).length > 0 && (
|
||||
<div className="mb-3">
|
||||
<div className="text-xs text-gray-400 mb-2">Performance by Timeframe</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{Object.entries(model.performanceByTimeframe).map(([tf, data]) => (
|
||||
<div key={tf} className="px-2 py-1 bg-gray-800/50 rounded text-xs">
|
||||
<span className="text-gray-400">{tf}:</span>
|
||||
<span className={`ml-1 ${getAccuracyColor(data.accuracy)}`}>{data.accuracy.toFixed(0)}%</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Last Updated */}
|
||||
<div className="flex items-center gap-2 text-xs text-gray-500">
|
||||
<Clock className="w-3 h-3" />
|
||||
Last updated: {formatDate(model.lastUpdated)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModelAccuracyDashboard;
|
||||
412
src/modules/ml/components/SignalPerformanceTracker.tsx
Normal file
412
src/modules/ml/components/SignalPerformanceTracker.tsx
Normal file
@ -0,0 +1,412 @@
|
||||
/**
|
||||
* SignalPerformanceTracker Component
|
||||
* Signal history and performance analysis
|
||||
* OQI-006: Senales ML
|
||||
*/
|
||||
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import {
|
||||
History,
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
Target,
|
||||
Shield,
|
||||
Clock,
|
||||
Filter,
|
||||
Search,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
AlertCircle,
|
||||
DollarSign,
|
||||
Percent,
|
||||
Calendar,
|
||||
BarChart3,
|
||||
Download,
|
||||
} from 'lucide-react';
|
||||
|
||||
export type SignalStatus = 'active' | 'executed' | 'tp_hit' | 'sl_hit' | 'expired' | 'cancelled';
|
||||
|
||||
export interface SignalHistoryEntry {
|
||||
id: string;
|
||||
symbol: string;
|
||||
direction: 'BUY' | 'SELL';
|
||||
confidence: number;
|
||||
entryPrice: number;
|
||||
currentPrice?: number;
|
||||
stopLoss: number;
|
||||
takeProfit: number;
|
||||
status: SignalStatus;
|
||||
createdAt: string;
|
||||
executedAt?: string;
|
||||
closedAt?: string;
|
||||
actualProfit?: number;
|
||||
actualProfitPercent?: number;
|
||||
expectedRR: number;
|
||||
actualRR?: number;
|
||||
modelName: string;
|
||||
reasoning?: string;
|
||||
}
|
||||
|
||||
interface SignalStats {
|
||||
totalSignals: number;
|
||||
executed: number;
|
||||
tpHit: number;
|
||||
slHit: number;
|
||||
expired: number;
|
||||
winRate: number;
|
||||
totalProfit: number;
|
||||
avgProfit: number;
|
||||
avgRR: number;
|
||||
profitFactor: number;
|
||||
}
|
||||
|
||||
interface SignalPerformanceTrackerProps {
|
||||
signals: SignalHistoryEntry[];
|
||||
onSignalSelect?: (signal: SignalHistoryEntry) => void;
|
||||
onExport?: () => void;
|
||||
maxItems?: number;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
const SignalPerformanceTracker: React.FC<SignalPerformanceTrackerProps> = ({
|
||||
signals,
|
||||
onSignalSelect,
|
||||
onExport,
|
||||
maxItems = 50,
|
||||
compact = false,
|
||||
}) => {
|
||||
const [filterSymbol, setFilterSymbol] = useState('');
|
||||
const [filterStatus, setFilterStatus] = useState<SignalStatus | 'all'>('all');
|
||||
const [filterDirection, setFilterDirection] = useState<'BUY' | 'SELL' | 'all'>('all');
|
||||
const [sortBy, setSortBy] = useState<'date' | 'profit' | 'confidence'>('date');
|
||||
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc');
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
|
||||
// Calculate stats
|
||||
const stats: SignalStats = useMemo(() => {
|
||||
const executed = signals.filter((s) => s.status !== 'active' && s.status !== 'cancelled');
|
||||
const winners = signals.filter((s) => s.status === 'tp_hit');
|
||||
const losers = signals.filter((s) => s.status === 'sl_hit');
|
||||
|
||||
const totalProfit = executed.reduce((sum, s) => sum + (s.actualProfit || 0), 0);
|
||||
const totalWins = winners.reduce((sum, s) => sum + (s.actualProfit || 0), 0);
|
||||
const totalLosses = Math.abs(losers.reduce((sum, s) => sum + (s.actualProfit || 0), 0));
|
||||
|
||||
return {
|
||||
totalSignals: signals.length,
|
||||
executed: executed.length,
|
||||
tpHit: winners.length,
|
||||
slHit: losers.length,
|
||||
expired: signals.filter((s) => s.status === 'expired').length,
|
||||
winRate: executed.length > 0 ? (winners.length / executed.length) * 100 : 0,
|
||||
totalProfit,
|
||||
avgProfit: executed.length > 0 ? totalProfit / executed.length : 0,
|
||||
avgRR: executed.filter((s) => s.actualRR).reduce((sum, s) => sum + (s.actualRR || 0), 0) / (executed.filter((s) => s.actualRR).length || 1),
|
||||
profitFactor: totalLosses > 0 ? totalWins / totalLosses : totalWins,
|
||||
};
|
||||
}, [signals]);
|
||||
|
||||
// Filter and sort signals
|
||||
const filteredSignals = useMemo(() => {
|
||||
let result = [...signals];
|
||||
|
||||
if (filterSymbol) {
|
||||
result = result.filter((s) => s.symbol.toLowerCase().includes(filterSymbol.toLowerCase()));
|
||||
}
|
||||
if (filterStatus !== 'all') {
|
||||
result = result.filter((s) => s.status === filterStatus);
|
||||
}
|
||||
if (filterDirection !== 'all') {
|
||||
result = result.filter((s) => s.direction === filterDirection);
|
||||
}
|
||||
|
||||
result.sort((a, b) => {
|
||||
let comparison = 0;
|
||||
switch (sortBy) {
|
||||
case 'date':
|
||||
comparison = new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
|
||||
break;
|
||||
case 'profit':
|
||||
comparison = (b.actualProfit || 0) - (a.actualProfit || 0);
|
||||
break;
|
||||
case 'confidence':
|
||||
comparison = b.confidence - a.confidence;
|
||||
break;
|
||||
}
|
||||
return sortDir === 'desc' ? comparison : -comparison;
|
||||
});
|
||||
|
||||
return result.slice(0, maxItems);
|
||||
}, [signals, filterSymbol, filterStatus, filterDirection, sortBy, sortDir, maxItems]);
|
||||
|
||||
const getStatusIcon = (status: SignalStatus) => {
|
||||
switch (status) {
|
||||
case 'tp_hit':
|
||||
return <CheckCircle className="w-4 h-4 text-green-400" />;
|
||||
case 'sl_hit':
|
||||
return <XCircle className="w-4 h-4 text-red-400" />;
|
||||
case 'expired':
|
||||
return <Clock className="w-4 h-4 text-gray-400" />;
|
||||
case 'active':
|
||||
return <AlertCircle className="w-4 h-4 text-blue-400" />;
|
||||
case 'executed':
|
||||
return <Target className="w-4 h-4 text-yellow-400" />;
|
||||
default:
|
||||
return <AlertCircle className="w-4 h-4 text-gray-400" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusLabel = (status: SignalStatus) => {
|
||||
switch (status) {
|
||||
case 'tp_hit':
|
||||
return 'TP Hit';
|
||||
case 'sl_hit':
|
||||
return 'SL Hit';
|
||||
case 'expired':
|
||||
return 'Expired';
|
||||
case 'active':
|
||||
return 'Active';
|
||||
case 'executed':
|
||||
return 'Running';
|
||||
case 'cancelled':
|
||||
return 'Cancelled';
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`bg-gray-800/50 rounded-xl border border-gray-700 ${compact ? 'p-3' : 'p-4'}`}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-purple-500/20 rounded-lg">
|
||||
<History className="w-5 h-5 text-purple-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-white">Signal Performance</h3>
|
||||
<p className="text-xs text-gray-500">{stats.totalSignals} signals tracked</p>
|
||||
</div>
|
||||
</div>
|
||||
{onExport && (
|
||||
<button
|
||||
onClick={onExport}
|
||||
className="p-2 text-gray-400 hover:text-white hover:bg-gray-700 rounded-lg transition-colors"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<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 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 ${stats.totalProfit >= 0 ? 'text-green-400' : 'text-red-400'}`}>
|
||||
${stats.totalProfit.toFixed(2)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">Total 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.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.tpHit}</span>
|
||||
<span className="text-gray-600">/</span>
|
||||
<span className="text-red-400">{stats.slHit}</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">TP/SL Hits</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
{!compact && (
|
||||
<div className="flex flex-wrap items-center gap-2 mb-4">
|
||||
<div className="relative flex-1 min-w-[150px]">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
|
||||
<input
|
||||
type="text"
|
||||
value={filterSymbol}
|
||||
onChange={(e) => setFilterSymbol(e.target.value)}
|
||||
placeholder="Filter symbol..."
|
||||
className="w-full pl-10 pr-3 py-1.5 bg-gray-900 border border-gray-700 rounded text-sm text-white focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={filterStatus}
|
||||
onChange={(e) => setFilterStatus(e.target.value as SignalStatus | 'all')}
|
||||
className="px-3 py-1.5 bg-gray-900 border border-gray-700 rounded text-sm text-white"
|
||||
>
|
||||
<option value="all">All Status</option>
|
||||
<option value="tp_hit">TP Hit</option>
|
||||
<option value="sl_hit">SL Hit</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="expired">Expired</option>
|
||||
</select>
|
||||
<select
|
||||
value={filterDirection}
|
||||
onChange={(e) => setFilterDirection(e.target.value as 'BUY' | 'SELL' | 'all')}
|
||||
className="px-3 py-1.5 bg-gray-900 border border-gray-700 rounded text-sm text-white"
|
||||
>
|
||||
<option value="all">All Directions</option>
|
||||
<option value="BUY">Buy Only</option>
|
||||
<option value="SELL">Sell Only</option>
|
||||
</select>
|
||||
<select
|
||||
value={`${sortBy}-${sortDir}`}
|
||||
onChange={(e) => {
|
||||
const [field, dir] = e.target.value.split('-');
|
||||
setSortBy(field as 'date' | 'profit' | 'confidence');
|
||||
setSortDir(dir as 'asc' | 'desc');
|
||||
}}
|
||||
className="px-3 py-1.5 bg-gray-900 border border-gray-700 rounded text-sm text-white"
|
||||
>
|
||||
<option value="date-desc">Newest First</option>
|
||||
<option value="date-asc">Oldest First</option>
|
||||
<option value="profit-desc">Highest Profit</option>
|
||||
<option value="profit-asc">Lowest Profit</option>
|
||||
<option value="confidence-desc">Highest Confidence</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Signals List */}
|
||||
{filteredSignals.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-400">No signals found</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2 max-h-[400px] overflow-y-auto">
|
||||
{filteredSignals.map((signal) => {
|
||||
const isExpanded = expandedId === signal.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={signal.id}
|
||||
onClick={() => {
|
||||
setExpandedId(isExpanded ? null : signal.id);
|
||||
onSignalSelect?.(signal);
|
||||
}}
|
||||
className={`p-3 bg-gray-900/50 rounded-lg border cursor-pointer transition-all ${
|
||||
isExpanded ? 'border-blue-500/50' : 'border-gray-700/50 hover:border-gray-600'
|
||||
}`}
|
||||
>
|
||||
{/* Main Row */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`p-1.5 rounded ${signal.direction === 'BUY' ? 'bg-green-500/20' : 'bg-red-500/20'}`}>
|
||||
{signal.direction === '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">{signal.symbol}</span>
|
||||
<span className={`px-2 py-0.5 rounded text-xs ${
|
||||
signal.status === 'tp_hit'
|
||||
? 'bg-green-500/20 text-green-400'
|
||||
: signal.status === 'sl_hit'
|
||||
? 'bg-red-500/20 text-red-400'
|
||||
: signal.status === 'active'
|
||||
? 'bg-blue-500/20 text-blue-400'
|
||||
: 'bg-gray-500/20 text-gray-400'
|
||||
}`}>
|
||||
{getStatusLabel(signal.status)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{formatDate(signal.createdAt)} • {signal.modelName}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="text-right">
|
||||
{signal.actualProfit !== undefined ? (
|
||||
<div className={`font-mono font-medium ${signal.actualProfit >= 0 ? 'text-green-400' : 'text-red-400'}`}>
|
||||
{signal.actualProfit >= 0 ? '+' : ''}${signal.actualProfit.toFixed(2)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="font-mono text-gray-400">--</div>
|
||||
)}
|
||||
<div className="text-xs text-gray-500">
|
||||
Conf: {signal.confidence.toFixed(0)}%
|
||||
</div>
|
||||
</div>
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="w-4 h-4 text-gray-500" />
|
||||
) : (
|
||||
<ChevronDown className="w-4 h-4 text-gray-500" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded Details */}
|
||||
{isExpanded && (
|
||||
<div className="mt-3 pt-3 border-t border-gray-700/50">
|
||||
<div className="grid grid-cols-3 gap-3 text-xs mb-3">
|
||||
<div>
|
||||
<span className="text-gray-500">Entry:</span>
|
||||
<span className="text-white ml-2 font-mono">{signal.entryPrice.toFixed(5)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">SL:</span>
|
||||
<span className="text-red-400 ml-2 font-mono">{signal.stopLoss.toFixed(5)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">TP:</span>
|
||||
<span className="text-green-400 ml-2 font-mono">{signal.takeProfit.toFixed(5)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 text-xs">
|
||||
<div>
|
||||
<span className="text-gray-500">Expected RR:</span>
|
||||
<span className="text-white ml-2">1:{signal.expectedRR.toFixed(2)}</span>
|
||||
</div>
|
||||
{signal.actualRR && (
|
||||
<div>
|
||||
<span className="text-gray-500">Actual RR:</span>
|
||||
<span className={`ml-2 ${signal.actualRR >= 0 ? 'text-green-400' : 'text-red-400'}`}>
|
||||
1:{Math.abs(signal.actualRR).toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{signal.reasoning && (
|
||||
<div className="mt-2 p-2 bg-gray-800/50 rounded text-xs text-gray-400">
|
||||
<strong>Reasoning:</strong> {signal.reasoning}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SignalPerformanceTracker;
|
||||
@ -10,3 +10,13 @@ export { AccuracyMetrics } from './AccuracyMetrics';
|
||||
export { ICTAnalysisCard } from './ICTAnalysisCard';
|
||||
export { EnsembleSignalCard } from './EnsembleSignalCard';
|
||||
export { TradeExecutionModal } from './TradeExecutionModal';
|
||||
|
||||
// Confidence & Performance Components (OQI-006)
|
||||
export { default as ConfidenceMeter } from './ConfidenceMeter';
|
||||
export type { ConfidenceData } from './ConfidenceMeter';
|
||||
export { default as SignalPerformanceTracker } from './SignalPerformanceTracker';
|
||||
export type { SignalHistoryEntry, SignalStatus, SignalStats } from './SignalPerformanceTracker';
|
||||
export { default as ModelAccuracyDashboard } from './ModelAccuracyDashboard';
|
||||
export type { ModelMetrics } from './ModelAccuracyDashboard';
|
||||
export { default as BacktestResultsVisualization } from './BacktestResultsVisualization';
|
||||
export type { BacktestResult, BacktestTrade } from './BacktestResultsVisualization';
|
||||
|
||||
Loading…
Reference in New Issue
Block a user