diegobeyl/backtesting
2
1/**
2 * Donchian Backtesting Application
3 * Frontend JavaScript
4 */
5
6// =====================================================
7// Configuration & State
8// =====================================================
9
10const API_BASE = '';
11const WS_URL = `ws://${window.location.host}/api/ws`;
12
13let ws = null;
14let currentConfig = null;
15let isRunning = false;
16const APP_VERSION = "1.2.2";
17console.log(`Donchian Backtester v${APP_VERSION} loading...`);
18
19// =====================================================
20// DOM Elements
21// =====================================================
22
23const elements = {
24 connectionStatus: document.getElementById('connectionStatus'),
25 configForm: document.getElementById('configForm'),
26 runBtn: document.getElementById('runBtn'),
27 resetConfigBtn: document.getElementById('resetConfigBtn'),
28 progressContainer: document.getElementById('progressContainer'),
29 progressFill: document.getElementById('progressFill'),
30 progressText: document.getElementById('progressText'),
31 metricsGrid: document.getElementById('metricsGrid'),
32 // Metrics
33 metricReturn: document.getElementById('metricReturn'),
34 metricReturnPct: document.getElementById('metricReturnPct'),
35 metricSharpe: document.getElementById('metricSharpe'),
36 metricDrawdown: document.getElementById('metricDrawdown'),
37 metricWinRate: document.getElementById('metricWinRate'),
38 metricProfitFactor: document.getElementById('metricProfitFactor'),
39 metricTrades: document.getElementById('metricTrades'),
40 // Charts
41 candlestickChart: document.getElementById('candlestickChart'),
42 equityChart: document.getElementById('equityChart'),
43 tradesTableBody: document.getElementById('tradesTableBody'),
44};
45
46// =====================================================
47// WebSocket Connection
48// =====================================================
49
50function connectWebSocket() {
51 ws = new WebSocket(WS_URL);
52
53 ws.onopen = () => {
54 console.log('WebSocket connected');
55 updateConnectionStatus('connected');
56 };
57
58 ws.onclose = () => {
59 console.log('WebSocket disconnected');
60 updateConnectionStatus('disconnected');
61 // Reconnect after 3 seconds
62 setTimeout(connectWebSocket, 3000);
63 };
64
65 ws.onerror = (error) => {
66 console.error('WebSocket error:', error);
67 updateConnectionStatus('disconnected');
68 };
69
70 ws.onmessage = (event) => {
71 try {
72 const message = JSON.parse(event.data);
73 handleWebSocketMessage(message);
74 } catch (e) {
75 console.error('Failed to parse WebSocket message:', e);
76 }
77 };
78}
79
80function handleWebSocketMessage(message) {
81 switch (message.type) {
82 case 'connected':
83 if (message.config) {
84 currentConfig = message.config;
85 updateFormFromConfig(message.config);
86 }
87 break;
88
89 case 'config_updated':
90 currentConfig = message.config;
91 updateFormFromConfig(message.config);
92 showNotification('Configuration updated', 'success');
93 break;
94
95 case 'status':
96 updateProgress(message.progress, message.message);
97 break;
98
99 case 'result':
100 handleBacktestResult(message.data);
101 break;
102
103 case 'heartbeat':
104 // Keep alive
105 break;
106 }
107}
108
109function updateConnectionStatus(status) {
110 const statusDot = elements.connectionStatus.querySelector('.status-dot');
111 const statusText = elements.connectionStatus.querySelector('.status-text');
112
113 statusDot.className = 'status-dot ' + status;
114 statusText.textContent = status === 'connected' ? 'Connected' :
115 status === 'disconnected' ? 'Disconnected' : 'Connecting...';
116}
117
118// =====================================================
119// API Functions
120// =====================================================
121
122async function fetchAPI(endpoint, options = {}) {
123 const response = await fetch(`${API_BASE}/api${endpoint}`, {
124 headers: {
125 'Content-Type': 'application/json',
126 ...options.headers,
127 },
128 ...options,
129 });
130
131 if (!response.ok) {
132 const error = await response.json().catch(() => ({ detail: 'Unknown error' }));
133 throw new Error(error.detail || 'API request failed');
134 }
135
136 return response.json();
137}
138
139async function loadSymbols() {
140 try {
141 const data = await fetchAPI('/symbols');
142 const datalist = document.getElementById('symbolList');
143 if (datalist) {
144 datalist.innerHTML = data.symbols.map(s =>
145 `<option value="${s}">`
146 ).join('');
147 }
148 } catch (error) {
149 console.error('Failed to load symbols:', error);
150 }
151}
152
153async function loadConfig() {
154 try {
155 const config = await fetchAPI('/config');
156 currentConfig = config;
157 updateFormFromConfig(config);
158 } catch (error) {
159 console.error('Failed to load config:', error);
160 }
161}
162
163async function saveConfig(config) {
164 try {
165 await fetchAPI('/config', {
166 method: 'PUT',
167 body: JSON.stringify({ config }),
168 });
169 } catch (error) {
170 console.error('Failed to save config:', error);
171 showNotification('Failed to save configuration', 'error');
172 }
173}
174
175async function runBacktest(config) {
176 try {
177 isRunning = true;
178 elements.runBtn.disabled = true;
179 elements.runBtn.innerHTML = '<span class="btn-icon">⏳</span> Running...';
180 elements.progressContainer.style.display = 'block';
181
182 const result = await fetchAPI('/backtest/run', {
183 method: 'POST',
184 body: JSON.stringify({ config }),
185 });
186
187 console.log(`[v${APP_VERSION}] Backtest result received:`, result);
188 handleBacktestResult(result);
189
190 } catch (error) {
191 console.error('Backtest error:', error);
192 showNotification('Backtest failed: ' + error.message, 'error');
193 } finally {
194 isRunning = false;
195 elements.progressContainer.style.display = 'none';
196 elements.runBtn.disabled = false;
197 elements.runBtn.innerHTML = '<span class="btn-icon">▶</span> Run Backtest';
198 }
199}
200
201async function resetConfig() {
202 try {
203 await fetchAPI('/config/reset', { method: 'POST' });
204 await loadConfig();
205 showNotification('Configuration reset to defaults', 'success');
206 } catch (error) {
207 console.error('Failed to reset config:', error);
208 showNotification('Failed to reset configuration', 'error');
209 }
210}
211
212// =====================================================
213// Form Handling
214// =====================================================
215
216function updateFormFromConfig(config) {
217 Object.keys(config).forEach(key => {
218 const element = document.getElementById(key);
219 if (element) {
220 if (element.type === 'checkbox') {
221 element.checked = config[key];
222 } else {
223 element.value = config[key];
224 }
225 }
226 });
227}
228
229function getFormConfig() {
230 const formData = new FormData(elements.configForm);
231 const config = {};
232
233 formData.forEach((value, key) => {
234 const element = document.getElementById(key);
235 if (element.type === 'number') {
236 config[key] = parseFloat(value);
237 } else if (element.type === 'checkbox') {
238 config[key] = element.checked;
239 } else {
240 config[key] = value;
241 }
242 });
243
244 // Handle checkbox that might not be in formData when unchecked
245 const checkbox = document.getElementById('stop_resets_support');
246 config.stop_resets_support = checkbox.checked;
247
248 return config;
249}
250
251// =====================================================
252// Progress Updates
253// =====================================================
254
255function updateProgress(progress, message) {
256 elements.progressFill.style.width = `${progress}%`;
257 elements.progressText.textContent = message;
258
259 if (progress >= 100) {
260 setTimeout(() => {
261 elements.progressContainer.style.display = 'none';
262 }, 1000);
263 }
264}
265
266// =====================================================
267// Results Display
268// =====================================================
269
270function handleBacktestResult(result) {
271 if (!result.success) {
272 showNotification('Backtest failed: ' + (result.error || 'Unknown error'), 'error');
273 return;
274 }
275
276 // Update metrics
277 if (result.metrics) {
278 updateMetrics(result.metrics);
279 }
280
281 // Update charts
282 if (result.chart_data) {
283 renderCandlestickChart(result.chart_data, result.config);
284 }
285
286 if (result.equity_curve) {
287 renderEquityChart(result.equity_curve);
288 }
289
290 // Update trades table
291 if (result.trades) {
292 renderTradesTable(result.trades);
293 }
294
295 showNotification(`Backtest completed in ${result.execution_time.toFixed(2)}s`, 'success');
296}
297
298function updateMetrics(metrics) {
299 console.log(`[v${APP_VERSION}] Updating metrics:`, metrics);
300 // Total Return
301 const returnValue = metrics.total_return;
302 elements.metricReturn.textContent = formatCurrency(returnValue);
303 elements.metricReturnPct.textContent = formatPercent(metrics.total_return_percent);
304
305 // Update main card styling based on profit/loss
306 const returnCard = elements.metricReturn.closest('.metric-card');
307 if (returnValue >= 0) {
308 returnCard.classList.remove('negative');
309 returnCard.classList.add('positive');
310 elements.metricReturn.className = 'metric-value positive';
311 } else {
312 returnCard.classList.remove('positive');
313 returnCard.classList.add('negative');
314 elements.metricReturn.className = 'metric-value negative';
315 }
316
317 // Sharpe Ratio
318 elements.metricSharpe.textContent = metrics.sharpe_ratio.toFixed(2);
319
320 // Max Drawdown
321 elements.metricDrawdown.textContent = formatPercent(metrics.max_drawdown_percent);
322
323 // Win Rate
324 elements.metricWinRate.textContent = formatPercent(metrics.win_rate);
325
326 // Profit Factor
327 elements.metricProfitFactor.textContent = metrics.profit_factor.toFixed(2);
328
329 // Total Trades
330 elements.metricTrades.textContent = metrics.total_trades;
331}
332
333// =====================================================
334// Chart Rendering
335// =====================================================
336
337function renderCandlestickChart(chartData, config) {
338 const ohlc = chartData.ohlc;
339 const donchian = chartData.donchian;
340 const signals = chartData.signals;
341 const structural = chartData.structural || [];
342 const stopLoss = chartData.stop_loss || [];
343
344 const traces = [];
345
346 // 1. Candlestick trace
347 traces.push({
348 type: 'candlestick',
349 x: ohlc.map(d => d.time),
350 open: ohlc.map(d => d.open),
351 high: ohlc.map(d => d.high),
352 low: ohlc.map(d => d.low),
353 close: ohlc.map(d => d.close),
354 name: config.symbol,
355 increasing: { line: { color: '#3fb950', width: 1 } },
356 decreasing: { line: { color: '#f85149', width: 1 } },
357 });
358
359 // 2. Donchian Channels (Lighter)
360 traces.push({
361 type: 'scatter',
362 mode: 'lines',
363 x: donchian.map(d => d.time),
364 y: donchian.map(d => d.high),
365 name: 'Donchian High',
366 line: { color: '#f85149', width: 1, dash: 'dot' },
367 opacity: 0.4,
368 connectgaps: false
369 });
370
371 traces.push({
372 type: 'scatter',
373 mode: 'lines',
374 x: donchian.map(d => d.time),
375 y: donchian.map(d => d.low),
376 name: 'Donchian Low',
377 line: { color: '#3fb950', width: 1, dash: 'dot' },
378 opacity: 0.4,
379 connectgaps: false
380 });
381
382 // 3. Structural Levels
383 if (structural.length > 0) {
384 traces.push({
385 type: 'scatter',
386 mode: 'lines',
387 x: structural.map(d => d.time),
388 y: structural.map(d => d.resistance),
389 name: 'Resistance',
390 line: { color: '#00fbff', width: 3, dash: 'dash' },
391 connectgaps: false
392 });
393
394 traces.push({
395 type: 'scatter',
396 mode: 'lines',
397 x: structural.map(d => d.time),
398 y: structural.map(d => d.support),
399 name: 'Support',
400 line: { color: '#ff00ff', width: 3, dash: 'dash' },
401 connectgaps: false
402 });
403 }
404
405 // 4. Stop Loss Line (Staircase)
406 if (stopLoss.length > 0) {
407 traces.push({
408 type: 'scatter',
409 mode: 'lines+markers',
410 x: stopLoss.map(d => d.time),
411 y: stopLoss.map(d => d.sl),
412 name: 'Stop Loss',
413 line: { color: '#ff9900', width: 3, shape: 'hv' },
414 marker: { size: 6, color: '#ff9900', symbol: 'circle' },
415 connectgaps: false
416 });
417 }
418
419 // 5. Entry signals
420 const entryLong = signals.filter(s => s.type === 'entry' && s.direction === 'long');
421 const entryShort = signals.filter(s => s.type === 'entry' && s.direction === 'short');
422 const exitLong = signals.filter(s => s.type === 'exit' && s.direction === 'long');
423 const exitShort = signals.filter(s => s.type === 'exit' && s.direction === 'short');
424
425 traces.push({
426 type: 'scatter',
427 mode: 'markers',
428 x: entryLong.map(s => s.time),
429 y: entryLong.map(s => s.price),
430 name: 'Long Entry',
431 marker: { symbol: 'triangle-up', size: 16, color: '#3fb950', line: { color: 'white', width: 1 } },
432 });
433
434 traces.push({
435 type: 'scatter',
436 mode: 'markers',
437 x: entryShort.map(s => s.time),
438 y: entryShort.map(s => s.price),
439 name: 'Short Entry',
440 marker: { symbol: 'triangle-down', size: 16, color: '#f85149', line: { color: 'white', width: 1 } },
441 });
442
443 traces.push({
444 type: 'scatter',
445 mode: 'markers',
446 x: exitLong.map(s => s.time),
447 y: exitLong.map(s => s.price),
448 name: 'Long Exit',
449 marker: { symbol: 'x', size: 12, color: '#ffffff', line: { color: '#3fb950', width: 1 } },
450 });
451
452 traces.push({
453 marker: { symbol: 'x', size: 12, color: '#ffffff', line: { color: '#f85149', width: 1 } },
454 });
455
456 // 6. Background shapes based on tendency
457 const shapes = [];
458 if (structural && structural.length > 0) {
459 let currentStart = structural[0].time;
460 let currentTend = structural[0].tendency;
461
462 for (let i = 1; i <= structural.length; i++) {
463 const item = structural[i];
464 const nextTend = item ? item.tendency : null;
465
466 if (nextTend !== currentTend) {
467 const currentEnd = item ? item.time : structural[structural.length - 1].time;
468
469 shapes.push({
470 type: 'rect',
471 xref: 'x',
472 yref: 'paper',
473 x0: currentStart,
474 y0: 0,
475 x1: currentEnd,
476 y1: 1,
477 fillcolor: currentTend === 1 ? 'rgba(63, 185, 80, 0.08)' : 'rgba(248, 81, 73, 0.08)',
478 line: { width: 0 },
479 layer: 'below'
480 });
481
482 if (item) {
483 currentStart = item.time;
484 currentTend = nextTend;
485 }
486 }
487 }
488 }
489
490 const layout = {
491 paper_bgcolor: '#161b22',
492 plot_bgcolor: '#0d1117',
493 font: { color: '#f0f6fc' },
494 xaxis: {
495 rangeslider: { visible: false },
496 gridcolor: '#21262d',
497 type: 'date'
498 },
499 yaxis: {
500 gridcolor: '#21262d',
501 fixedrange: false
502 },
503 legend: {
504 orientation: 'h',
505 y: 1.1,
506 bgcolor: 'rgba(0,0,0,0)'
507 },
508 margin: { l: 60, r: 20, t: 30, b: 40 },
509 hovermode: 'x unified',
510 shapes: shapes
511 };
512
513 Plotly.newPlot(elements.candlestickChart, traces, layout, { responsive: true });
514}
515
516function renderEquityChart(equityCurve) {
517 const equityTrace = {
518 type: 'scatter',
519 mode: 'lines',
520 x: equityCurve.map(d => d.time),
521 y: equityCurve.map(d => d.equity),
522 name: 'Equity',
523 line: { color: '#58a6ff', width: 2 },
524 fill: 'tozeroy',
525 fillcolor: 'rgba(88, 166, 255, 0.1)',
526 };
527
528 const layout = {
529 title: 'Equity Curve',
530 paper_bgcolor: '#161b22',
531 plot_bgcolor: '#0d1117',
532 font: { color: '#f0f6fc' },
533 xaxis: {
534 gridcolor: '#21262d',
535 },
536 yaxis: {
537 gridcolor: '#21262d',
538 title: 'Equity ($)',
539 },
540 margin: { l: 60, r: 20, t: 60, b: 40 },
541 };
542
543 Plotly.newPlot(elements.equityChart, [equityTrace], layout, { responsive: true });
544}
545
546function renderTradesTable(trades) {
547 if (trades.length === 0) {
548 elements.tradesTableBody.innerHTML = `
549 <tr><td colspan="8" class="no-data">No trades executed</td></tr>
550 `;
551 return;
552 }
553
554 elements.tradesTableBody.innerHTML = trades.map(trade => `
555 <tr>
556 <td>${formatDateTime(trade.entry_time)}</td>
557 <td>${formatDateTime(trade.exit_time)}</td>
558 <td><span class="trade-type ${trade.type.toLowerCase()}">${trade.type}</span></td>
559 <td>${formatPrice(trade.entry_price)}</td>
560 <td>${formatPrice(trade.exit_price)}</td>
561 <td>${Math.abs(trade.size).toFixed(4)}</td>
562 <td class="${trade.pnl >= 0 ? 'pnl-positive' : 'pnl-negative'}">${formatCurrency(trade.pnl)}</td>
563 <td class="${trade.return_percent >= 0 ? 'pnl-positive' : 'pnl-negative'}">${formatPercent(trade.return_percent)}</td>
564 </tr>
565 `).join('');
566}
567
568// =====================================================
569// Tab Navigation
570// =====================================================
571
572function setupTabs() {
573 const tabBtns = document.querySelectorAll('.tab-btn');
574 const panels = document.querySelectorAll('.chart-panel');
575
576 tabBtns.forEach(btn => {
577 btn.addEventListener('click', () => {
578 const tab = btn.dataset.tab;
579
580 // Update buttons
581 tabBtns.forEach(b => b.classList.remove('active'));
582 btn.classList.add('active');
583
584 // Update panels
585 panels.forEach(p => p.classList.remove('active'));
586 document.getElementById(`${tab}Panel`).classList.add('active');
587
588 // Trigger resize for Plotly charts
589 window.dispatchEvent(new Event('resize'));
590 });
591 });
592}
593
594// =====================================================
595// Utility Functions
596// =====================================================
597
598function formatCurrency(value) {
599 const isNegative = value < 0;
600 const formatted = Math.abs(value).toLocaleString('en-US', {
601 minimumFractionDigits: 2,
602 maximumFractionDigits: 2
603 });
604 return (isNegative ? '-' : '+') + '$' + formatted;
605}
606
607function formatPercent(value) {
608 return (value >= 0 ? '+' : '') + value.toFixed(2) + '%';
609}
610
611function formatPrice(value) {
612 return value.toLocaleString('en-US', {
613 minimumFractionDigits: 2,
614 maximumFractionDigits: 5
615 });
616}
617
618function formatDateTime(dateStr) {
619 const date = new Date(dateStr);
620 return date.toLocaleDateString('en-US', {
621 month: 'short',
622 day: 'numeric',
623 year: '2-digit'
624 });
625}
626
627function showNotification(message, type = 'info') {
628 // Simple alert for now - could be enhanced with toast notifications
629 console.log(`[${type.toUpperCase()}] ${message}`);
630}
631
632// =====================================================
633// Event Listeners
634// =====================================================
635
636function setupEventListeners() {
637 // Form submission
638 elements.configForm.addEventListener('submit', async (e) => {
639 e.preventDefault();
640 const config = getFormConfig();
641 await runBacktest(config);
642 });
643
644 // Reset config button
645 elements.resetConfigBtn.addEventListener('click', resetConfig);
646
647 // Auto-save config on change (debounced)
648 let saveTimeout = null;
649 elements.configForm.addEventListener('change', () => {
650 if (saveTimeout) clearTimeout(saveTimeout);
651 saveTimeout = setTimeout(() => {
652 const config = getFormConfig();
653 saveConfig(config);
654 }, 500);
655 });
656}
657
658// =====================================================
659// Initialization
660// =====================================================
661
662async function init() {
663 console.log('Initializing Donchian Backtesting App...');
664
665 // Load initial data
666 await loadSymbols();
667 await loadConfig();
668
669 // Setup UI
670 setupTabs();
671 setupEventListeners();
672
673 // Connect WebSocket
674 connectWebSocket();
675
676 console.log('App initialized');
677}
678
679// Start the app when DOM is ready
680document.addEventListener('DOMContentLoaded', init);
681 