/**
* Performance Analytics Dashboard Component
* Tracks trading performance, win rates, and P&L
*/
class PerformanceDashboard {
constructor(containerId, options = {}) {
this.container = document.getElementById(containerId);
this.options = {
currency: 'USD',
riskPerTrade: 0.5, // percentage
colors: {
profit: '#3fb950',
loss: '#f85149',
neutral: '#8b949e',
breakeven: '#d29922',
...options.colors
},
...options
};
this.stats = {
totalTrades: 0,
wins: 0,
losses: 0,
breakevens: 0,
totalPnL: 0,
dailyPnL: 0,
weeklyPnL: 0,
avgWin: 0,
avgLoss: 0,
avgRR: 0,
expectancy: 0,
maxDrawdown: 0,
currentDrawdown: 0,
bestTrade: null,
worstTrade: null,
byPattern: {}
};
this.trades = [];
this._init();
}
_init() {
this.container.innerHTML = `
| Time |
Symbol |
Side |
Pattern |
Entry |
Exit |
P&L |
R |
| No trades recorded |
Daily Loss Limit:
0% used
Trades Today:
0 / 8
✓ Trading Allowed
`;
this._cacheElements();
this._initChart();
this._initControls();
}
_cacheElements() {
this.els = {
dailyPnL: document.getElementById('perfDailyPnL'),
dailyPct: document.getElementById('perfDailyPct'),
winRate: document.getElementById('perfWinRate'),
winLoss: document.getElementById('perfWinLoss'),
avgRR: document.getElementById('perfAvgRR'),
expectancy: document.getElementById('perfExpectancy'),
drawdown: document.getElementById('perfDrawdown'),
maxDD: document.getElementById('perfMaxDD'),
chartCanvas: document.getElementById('perfChartCanvas'),
patterns: document.getElementById('perfPatterns'),
tradesTbody: document.getElementById('perfTradesTbody'),
riskFill: document.getElementById('perfRiskFill'),
riskPct: document.getElementById('perfRiskPct'),
tradesToday: document.getElementById('perfTradesToday'),
riskStatus: document.getElementById('perfRiskStatus')
};
}
_initChart() {
this.chartCtx = this.els.chartCanvas.getContext('2d');
this.equityCurve = [];
this._resizeChart();
window.addEventListener('resize', () => this._resizeChart());
}
_resizeChart() {
const container = this.els.chartCanvas.parentElement;
const rect = container.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
this.els.chartCanvas.width = rect.width * dpr;
this.els.chartCanvas.height = rect.height * dpr;
this.els.chartCanvas.style.width = rect.width + 'px';
this.els.chartCanvas.style.height = rect.height + 'px';
this.chartCtx.scale(dpr, dpr);
this.chartWidth = rect.width;
this.chartHeight = rect.height;
this._renderChart();
}
_initControls() {
// Chart range buttons
document.querySelectorAll('.perf-chart-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
document.querySelectorAll('.perf-chart-btn').forEach(b => b.classList.remove('active'));
e.target.classList.add('active');
this.chartRange = e.target.dataset.range;
this._renderChart();
});
});
// Export button
document.getElementById('perfExport').addEventListener('click', () => {
this._exportCSV();
});
this.chartRange = 'day';
}
/**
* Update with new stats
*/
setStats(stats) {
this.stats = { ...this.stats, ...stats };
this._render();
}
/**
* Add completed trade
*/
addTrade(trade) {
this.trades.unshift({
...trade,
id: trade.id || Date.now(),
timestamp: trade.timestamp || Date.now()
});
// Update stats
this._recalculateStats();
this._render();
}
/**
* Set full trade history
*/
setTrades(trades) {
this.trades = trades || [];
this._recalculateStats();
this._render();
}
_recalculateStats() {
const today = new Date().toDateString();
const todayTrades = this.trades.filter(t =>
new Date(t.timestamp).toDateString() === today
);
let wins = 0, losses = 0, breakevens = 0;
let totalWinPnL = 0, totalLossPnL = 0;
let dailyPnL = 0;
let equity = 0;
let peak = 0;
let maxDD = 0;
const byPattern = {};
this.trades.forEach(trade => {
const pnl = trade.pnl || 0;
const rMultiple = trade.rMultiple || 0;
if (pnl > 0) {
wins++;
totalWinPnL += pnl;
} else if (pnl < 0) {
losses++;
totalLossPnL += Math.abs(pnl);
} else {
breakevens++;
}
// Equity curve
equity += pnl;
if (equity > peak) peak = equity;
const dd = peak > 0 ? ((peak - equity) / peak) * 100 : 0;
if (dd > maxDD) maxDD = dd;
// Pattern tracking
const pattern = trade.pattern || 'Unknown';
if (!byPattern[pattern]) {
byPattern[pattern] = { wins: 0, losses: 0, pnl: 0, trades: 0 };
}
byPattern[pattern].trades++;
byPattern[pattern].pnl += pnl;
if (pnl > 0) byPattern[pattern].wins++;
else if (pnl < 0) byPattern[pattern].losses++;
});
todayTrades.forEach(t => dailyPnL += (t.pnl || 0));
const totalTrades = wins + losses + breakevens;
const winRate = totalTrades > 0 ? (wins / totalTrades) * 100 : 0;
const avgWin = wins > 0 ? totalWinPnL / wins : 0;
const avgLoss = losses > 0 ? totalLossPnL / losses : 0;
const avgRR = avgLoss > 0 ? avgWin / avgLoss : 0;
// Expectancy = (Win% × Avg Win) - (Loss% × Avg Loss)
const expectancy = totalTrades > 0
? ((winRate / 100) * avgWin) - (((100 - winRate) / 100) * avgLoss)
: 0;
const currentDD = peak > 0 ? ((peak - equity) / peak) * 100 : 0;
this.stats = {
totalTrades,
wins,
losses,
breakevens,
totalPnL: equity,
dailyPnL,
avgWin,
avgLoss,
avgRR,
expectancy,
maxDrawdown: maxDD,
currentDrawdown: currentDD,
byPattern,
tradesToday: todayTrades.length
};
// Build equity curve
this._buildEquityCurve();
}
_buildEquityCurve() {
this.equityCurve = [];
let equity = 0;
const sortedTrades = [...this.trades].sort((a, b) =>
(a.timestamp || 0) - (b.timestamp || 0)
);
sortedTrades.forEach(trade => {
equity += (trade.pnl || 0);
this.equityCurve.push({
time: trade.timestamp,
value: equity
});
});
}
_render() {
this._renderSummary();
this._renderChart();
this._renderPatterns();
this._renderTrades();
this._renderRisk();
}
_renderSummary() {
const { dailyPnL, wins, losses, avgRR, expectancy, currentDrawdown, maxDrawdown } = this.stats;
const totalTrades = wins + losses + (this.stats.breakevens || 0);
const winRate = totalTrades > 0 ? (wins / totalTrades) * 100 : 0;
// Daily P&L
this.els.dailyPnL.textContent = this._formatCurrency(dailyPnL);
this.els.dailyPnL.className = 'perf-card-value ' +
(dailyPnL > 0 ? 'perf-profit' : dailyPnL < 0 ? 'perf-loss' : '');
const dailyPct = 2; // Assuming 2% daily limit
this.els.dailyPct.textContent = `${((dailyPnL / 10000) * 100).toFixed(2)}%`; // Relative to account
// Win Rate
this.els.winRate.textContent = winRate.toFixed(1) + '%';
this.els.winRate.className = 'perf-card-value ' +
(winRate >= 50 ? 'perf-profit' : 'perf-loss');
this.els.winLoss.textContent = `${wins}W / ${losses}L`;
// Avg R:R
this.els.avgRR.textContent = avgRR.toFixed(2);
this.els.expectancy.textContent = `Exp: ${expectancy >= 0 ? '+' : ''}${expectancy.toFixed(2)}R`;
// Drawdown
this.els.drawdown.textContent = currentDrawdown.toFixed(1) + '%';
this.els.drawdown.className = 'perf-card-value perf-dd ' +
(currentDrawdown > 5 ? 'perf-loss' : currentDrawdown > 2 ? 'perf-warn' : '');
this.els.maxDD.textContent = `Max: ${maxDrawdown.toFixed(1)}%`;
}
_renderChart() {
const ctx = this.chartCtx;
if (!ctx) return;
ctx.clearRect(0, 0, this.chartWidth, this.chartHeight);
// Filter by range
let data = this.equityCurve;
const now = Date.now();
switch (this.chartRange) {
case 'day':
data = data.filter(d => now - d.time < 86400000);
break;
case 'week':
data = data.filter(d => now - d.time < 604800000);
break;
case 'month':
data = data.filter(d => now - d.time < 2592000000);
break;
}
if (data.length < 2) {
ctx.fillStyle = '#8b949e';
ctx.font = '12px Consolas';
ctx.textAlign = 'center';
ctx.fillText('Not enough data', this.chartWidth / 2, this.chartHeight / 2);
return;
}
// Calculate scales
const values = data.map(d => d.value);
const minVal = Math.min(0, ...values);
const maxVal = Math.max(0, ...values);
const range = maxVal - minVal || 1;
const padding = 20;
const xScale = (this.chartWidth - padding * 2) / (data.length - 1);
const yScale = (this.chartHeight - padding * 2) / range;
// Draw zero line
const zeroY = this.chartHeight - padding - (0 - minVal) * yScale;
ctx.strokeStyle = '#30363d';
ctx.lineWidth = 1;
ctx.setLineDash([5, 5]);
ctx.beginPath();
ctx.moveTo(padding, zeroY);
ctx.lineTo(this.chartWidth - padding, zeroY);
ctx.stroke();
ctx.setLineDash([]);
// Draw equity line
ctx.strokeStyle = data[data.length - 1].value >= 0 ? this.options.colors.profit : this.options.colors.loss;
ctx.lineWidth = 2;
ctx.beginPath();
data.forEach((point, i) => {
const x = padding + i * xScale;
const y = this.chartHeight - padding - (point.value - minVal) * yScale;
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
});
ctx.stroke();
// Fill area
const lastPoint = data[data.length - 1];
const fillColor = lastPoint.value >= 0
? 'rgba(63, 185, 80, 0.1)'
: 'rgba(248, 81, 73, 0.1)';
ctx.fillStyle = fillColor;
ctx.lineTo(this.chartWidth - padding, zeroY);
ctx.lineTo(padding, zeroY);
ctx.closePath();
ctx.fill();
}
_renderPatterns() {
const { byPattern } = this.stats;
const patterns = Object.entries(byPattern);
if (patterns.length === 0) {
this.els.patterns.innerHTML = 'No pattern data yet
';
return;
}
const html = patterns.map(([name, data]) => {
const winRate = data.trades > 0 ? (data.wins / data.trades) * 100 : 0;
const pnlClass = data.pnl > 0 ? 'perf-profit' : data.pnl < 0 ? 'perf-loss' : '';
return `
${name}
${data.trades} trades
${winRate.toFixed(0)}%
${this._formatCurrency(data.pnl)}
`;
}).join('');
this.els.patterns.innerHTML = html;
}
_renderTrades() {
const recentTrades = this.trades.slice(0, 20);
if (recentTrades.length === 0) {
this.els.tradesTbody.innerHTML = `
| No trades recorded |
`;
return;
}
const html = recentTrades.map(trade => {
const pnlClass = trade.pnl > 0 ? 'perf-profit' : trade.pnl < 0 ? 'perf-loss' : 'perf-be';
const rMultiple = trade.rMultiple || (trade.pnl / (trade.risk || 1));
return `
| ${this._formatTime(trade.timestamp)} |
${trade.symbol || '--'} |
${trade.side?.toUpperCase() || '--'} |
${trade.pattern || '--'} |
${this._formatPrice(trade.entry)} |
${this._formatPrice(trade.exit)} |
${this._formatCurrency(trade.pnl)} |
${rMultiple >= 0 ? '+' : ''}${rMultiple.toFixed(1)}R |
`;
}).join('');
this.els.tradesTbody.innerHTML = html;
}
_renderRisk() {
const { dailyPnL, tradesToday } = this.stats;
const dailyLimit = 2; // 2% daily loss limit
const accountValue = 10000; // Placeholder
const maxDailyLoss = accountValue * (dailyLimit / 100);
const usedPct = dailyPnL < 0 ? Math.min(100, (Math.abs(dailyPnL) / maxDailyLoss) * 100) : 0;
this.els.riskFill.style.width = usedPct + '%';
this.els.riskFill.className = 'perf-risk-fill ' +
(usedPct > 80 ? 'perf-risk-danger' : usedPct > 50 ? 'perf-risk-warning' : '');
this.els.riskPct.textContent = usedPct.toFixed(0) + '% used';
this.els.tradesToday.textContent = `${tradesToday || 0} / 8`;
// Risk status badge
const canTrade = usedPct < 100;
this.els.riskStatus.innerHTML = canTrade
? '✓ Trading Allowed'
: '✕ Daily Limit Reached';
}
_formatCurrency(value) {
const sign = value >= 0 ? '+' : '';
return sign + '$' + Math.abs(value).toFixed(2);
}
_formatPrice(price) {
if (!price) return '--';
if (price >= 1000) return price.toFixed(1);
if (price >= 1) return price.toFixed(2);
return price.toFixed(4);
}
_formatTime(timestamp) {
if (!timestamp) return '--';
return new Date(timestamp).toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
hour12: false
});
}
_exportCSV() {
const headers = ['Time', 'Symbol', 'Side', 'Pattern', 'Entry', 'Exit', 'P&L', 'R Multiple'];
const rows = this.trades.map(t => [
new Date(t.timestamp).toISOString(),
t.symbol,
t.side,
t.pattern,
t.entry,
t.exit,
t.pnl,
t.rMultiple
]);
const csv = [headers, ...rows].map(r => r.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 = `trades_${new Date().toISOString().split('T')[0]}.csv`;
a.click();
URL.revokeObjectURL(url);
}
destroy() {
this.container.innerHTML = '';
}
}
// Export for use in main app
window.PerformanceDashboard = PerformanceDashboard;