Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>LT Backtest</title> | |
| <link rel="stylesheet" href="/static/css/common.css"> | |
| <link rel="stylesheet" href="/static/css/backtest.css"> | |
| <script src="https://unpkg.com/lightweight-charts@4.2.1/dist/lightweight-charts.standalone.production.js"></script> | |
| </head> | |
| <body> | |
| <nav class="main-nav" id="main-nav"></nav> | |
| <div class="page-wrapper"> | |
| <div class="control-panel"> | |
| <div class="input-row"> | |
| <div class="input-group"> | |
| <label class="input-label">Symbols (comma-separated)</label> | |
| <input type="text" id="symbols-input" class="input-field" placeholder="AAPL, NVDA, TSLA..."> | |
| </div> | |
| <div class="input-group"> | |
| <label class="input-label">Entry Condition</label> | |
| <input type="text" id="entry-input" class="input-field" value="time == '09:30'" placeholder="time == '09:30'"> | |
| </div> | |
| <div class="input-group"> | |
| <label class="input-label">Exit Condition</label> | |
| <input type="text" id="exit-input" class="input-field" value="time == '16:00'" placeholder="time == '16:00'"> | |
| </div> | |
| <div class="input-group"> | |
| <label class="input-label">Offset (days)</label> | |
| <input type="number" id="offset-input" class="input-field" value="0" min="0" max="30"> | |
| </div> | |
| </div> | |
| <div class="action-row"> | |
| <button class="btn btn-secondary" id="export-btn" onclick="exportToCsv()" style="display: none;">Export to CSV</button> | |
| <button class="btn btn-primary" id="run-btn" onclick="runBacktest()">Run Backtest</button> | |
| </div> | |
| </div> | |
| <div class="summary-bar" id="summary-bar" style="display: none;"> | |
| <div class="summary-card"> | |
| <div class="summary-label">Win Rate</div> | |
| <div class="summary-value" id="win-rate">0%</div> | |
| </div> | |
| <div class="summary-card" id="card-profit"> | |
| <div class="summary-label">Total Profit</div> | |
| <div class="summary-value" id="total-profit">$0</div> | |
| </div> | |
| <div class="summary-card"> | |
| <div class="summary-label">Profit Factor</div> | |
| <div class="summary-value" id="profit-factor">0</div> | |
| </div> | |
| <div class="summary-card"> | |
| <div class="summary-label">Expectancy</div> | |
| <div class="summary-value" id="expectancy">$0</div> | |
| </div> | |
| <div class="summary-card"> | |
| <div class="summary-label">Avg Win / Loss</div> | |
| <div class="summary-value" id="avg-win-loss">$0 / $0</div> | |
| </div> | |
| <div class="summary-card"> | |
| <div class="summary-label">Max Drawdown</div> | |
| <div class="summary-value" id="max-drawdown" style="color: var(--negative);">0%</div> | |
| </div> | |
| <div class="summary-card"> | |
| <div class="summary-label">Final Equity</div> | |
| <div class="summary-value" id="final-equity">$0</div> | |
| </div> | |
| </div> | |
| <div id="equity-chart-container" class="equity-chart-container" style="display: none;"> | |
| <div class="summary-label" style="margin-bottom: 10px;">Equity Curve</div> | |
| <div id="equity-chart" style="height: 300px; width: 100%;"></div> | |
| </div> | |
| <div class="view-controls" id="view-controls" style="display: none;"> | |
| <div class="summary-label">Execution Results</div> | |
| <div class="view-toggle"> | |
| <div class="toggle-btn active" onclick="switchView('grid')" id="view-grid-btn">Grid View</div> | |
| <div class="toggle-btn" onclick="switchView('list')" id="view-list-btn">Trade Log</div> | |
| </div> | |
| </div> | |
| <div id="results-list" class="trade-log-container" style="display: none;"> | |
| <table class="trade-table" id="trade-table"> | |
| <thead> | |
| <tr> | |
| <th data-column="symbol" onclick="sortTable('symbol')">Symbol</th> | |
| <th data-column="date" onclick="sortTable('date')" class="sort-col">Date</th> | |
| <th data-column="status" onclick="sortTable('status')">Status</th> | |
| <th data-column="ret" onclick="sortTable('ret')">Return</th> | |
| <th data-column="profit" onclick="sortTable('profit')">Profit</th> | |
| <th data-column="entry" onclick="sortTable('entry')">Entry</th> | |
| <th data-column="exit" onclick="sortTable('exit')">Exit</th> | |
| <th data-column="sector" onclick="sortTable('sector')">Sector</th> | |
| <th data-column="gap_pct" onclick="sortTable('gap_pct')">Gap %</th> | |
| </tr> | |
| </thead> | |
| <tbody id="trade-table-body"></tbody> | |
| </table> | |
| </div> | |
| <div id="results-grid" class="results-grid"></div> | |
| <div id="status" class="status">Enter symbols and click Run Backtest</div> | |
| </div> | |
| <script src="/static/js/financial-api.js"></script> | |
| <script> | |
| let backtestResults = []; | |
| let backtestColumnMap = {}; | |
| let loadedCandidates = null; | |
| // Sort state | |
| let currentSortColumn = 'date'; | |
| let sortDirection = 'desc'; | |
| // Convert Unix timestamp to NY timezone | |
| function toNyTimestamp(unixTs) { | |
| if (!unixTs) return unixTs; | |
| // Get the date in NY timezone as string components | |
| const nyStr = new Date(unixTs * 1000).toLocaleString('en-US', { | |
| timeZone: 'America/New_York', | |
| year: 'numeric', month: '2-digit', day: '2-digit', | |
| hour: '2-digit', minute: '2-digit', second: '2-digit', | |
| hour12: false | |
| }); | |
| // Parse the NY time string as UTC (not local time) | |
| const [datePart, timePart] = nyStr.split(', '); | |
| const [month, day, year] = datePart.split('/'); | |
| const [hour, minute, second] = timePart.split(':'); | |
| const nyDateAsUtc = Date.UTC( | |
| parseInt(year), parseInt(month) - 1, parseInt(day), | |
| parseInt(hour), parseInt(minute), parseInt(second) | |
| ); | |
| return Math.floor(nyDateAsUtc / 1000); | |
| } | |
| // Format Unix timestamp used in charts (already shifted via toNyTimestamp) | |
| function formatNyTime(unixTs) { | |
| if (!unixTs) return '--:--'; | |
| const date = new Date(unixTs * 1000); | |
| const h = String(date.getUTCHours()).padStart(2, '0'); | |
| const m = String(date.getUTCMinutes()).padStart(2, '0'); | |
| const s = String(date.getUTCSeconds()).padStart(2, '0'); | |
| return `${h}:${m}:${s}`; | |
| } | |
| function initFromScanner() { | |
| const cached = sessionStorage.getItem('scanner_last_results'); | |
| if (cached) { | |
| try { | |
| const results = JSON.parse(cached); | |
| if (results && results.length > 0) { | |
| loadedCandidates = results.map(r => ({ | |
| symbol: r.symbol, | |
| date: r.date | |
| })).slice(0, 50); | |
| const symbolsInput = document.getElementById('symbols-input'); | |
| symbolsInput.value = loadedCandidates.map(c => c.symbol).join(', '); | |
| document.getElementById('status').textContent = | |
| `Loaded ${loadedCandidates.length} symbols from Scanner`; | |
| } | |
| } catch (e) { | |
| console.error('Failed to load scan results:', e); | |
| } | |
| } | |
| } | |
| initFromScanner(); | |
| loadFormValues(); | |
| // Save form values to localStorage | |
| function saveFormValues() { | |
| const values = { | |
| symbols: document.getElementById('symbols-input').value, | |
| entry: document.getElementById('entry-input').value, | |
| exit: document.getElementById('exit-input').value, | |
| offset: document.getElementById('offset-input').value | |
| }; | |
| localStorage.setItem('backtest_form_values', JSON.stringify(values)); | |
| } | |
| // Load form values from localStorage | |
| function loadFormValues() { | |
| const saved = localStorage.getItem('backtest_form_values'); | |
| if (saved) { | |
| try { | |
| const values = JSON.parse(saved); | |
| if (values.symbols) document.getElementById('symbols-input').value = values.symbols; | |
| if (values.entry) document.getElementById('entry-input').value = values.entry; | |
| if (values.exit) document.getElementById('exit-input').value = values.exit; | |
| if (values.offset) document.getElementById('offset-input').value = values.offset; | |
| } catch (e) { | |
| console.error('Failed to load form values:', e); | |
| } | |
| } | |
| } | |
| async function runBacktest() { | |
| saveFormValues(); | |
| const entry = document.getElementById('entry-input').value; | |
| const exit = document.getElementById('exit-input').value; | |
| const offset = document.getElementById('offset-input').value; | |
| const status = document.getElementById('status'); | |
| const grid = document.getElementById('results-grid'); | |
| const summaryBar = document.getElementById('summary-bar'); | |
| const btn = document.getElementById('run-btn'); | |
| let urlParams; | |
| if (loadedCandidates && loadedCandidates.length > 0) { | |
| urlParams = new URLSearchParams({ | |
| candidates: JSON.stringify(loadedCandidates), | |
| entry: entry, | |
| exit: exit, | |
| offset: offset | |
| }); | |
| } else { | |
| const symbols = document.getElementById('symbols-input').value.trim(); | |
| if (!symbols) { | |
| status.textContent = 'Please enter symbols or run a scan first'; | |
| return; | |
| } | |
| urlParams = new URLSearchParams({ | |
| symbols: symbols, | |
| entry: entry, | |
| exit: exit, | |
| offset: offset | |
| }); | |
| } | |
| btn.disabled = true; | |
| status.textContent = 'Running backtest...'; | |
| grid.innerHTML = ''; | |
| summaryBar.style.display = 'none'; | |
| // Clear previous results | |
| const resultsList = document.getElementById('results-list'); | |
| const equityChartContainer = document.getElementById('equity-chart-container'); | |
| const viewControls = document.getElementById('view-controls'); | |
| const exportBtn = document.getElementById('export-btn'); | |
| resultsList.style.display = 'none'; | |
| document.getElementById('trade-table-body').innerHTML = ''; | |
| equityChartContainer.style.display = 'none'; | |
| document.getElementById('equity-chart').innerHTML = ''; | |
| viewControls.style.display = 'none'; | |
| exportBtn.style.display = 'none'; | |
| try { | |
| const response = await fetch(`/api/backtest?${urlParams}`); | |
| const result = await response.json(); | |
| if (result.error) { | |
| status.textContent = 'Error: ' + result.error; | |
| return; | |
| } | |
| backtestResults = result.trades || []; | |
| backtestColumnMap = result.column_map || {}; | |
| // Also save candidates for stats | |
| const candidates = (result.trades || []) | |
| .filter(t => t.symbol && t.date) | |
| .map(t => ({symbol: t.symbol, date: t.date})); | |
| sessionStorage.setItem('last_candidates', JSON.stringify(candidates)); | |
| if (backtestResults.length === 0) { | |
| status.textContent = 'No trades found'; | |
| return; | |
| } | |
| // Update summary | |
| const summary = result.summary; | |
| document.getElementById('win-rate').textContent = summary.win_rate + '%'; | |
| document.getElementById('profit-factor').textContent = summary.profit_factor; | |
| document.getElementById('expectancy').textContent = (summary.expectancy >= 0 ? '+' : '') + '$' + summary.expectancy.toFixed(2); | |
| document.getElementById('avg-win-loss').textContent = `$${summary.avg_win.toFixed(0)} / $${Math.abs(summary.avg_loss).toFixed(0)}`; | |
| document.getElementById('max-drawdown').textContent = summary.max_drawdown + '%'; | |
| const profitEl = document.getElementById('total-profit'); | |
| profitEl.textContent = (summary.total_profit >= 0 ? '+' : '') + '$' + summary.total_profit.toFixed(2); | |
| document.getElementById('card-profit').className = 'summary-card ' + (summary.total_profit >= 0 ? 'positive' : 'negative'); | |
| const equityEl = document.getElementById('final-equity'); | |
| equityEl.textContent = '$' + summary.final_equity.toLocaleString(); | |
| equityEl.parentElement.className = 'summary-card ' + (summary.final_equity >= summary.initial_capital ? 'positive' : 'negative'); | |
| summaryBar.style.display = 'grid'; | |
| document.getElementById('view-controls').style.display = 'flex'; | |
| status.textContent = ''; | |
| // Render Equity Curve | |
| if (result.equity_curve && result.equity_curve.length > 0) { | |
| renderEquityChart(result.equity_curve); | |
| } | |
| // Show export button | |
| document.getElementById('export-btn').style.display = 'block'; | |
| // Cache results | |
| sessionStorage.setItem('backtest_last_result', JSON.stringify(result)); | |
| // Initial render of view | |
| renderResults(); | |
| } catch (e) { | |
| status.textContent = 'Error: ' + e.message; | |
| } finally { | |
| btn.disabled = false; | |
| } | |
| } | |
| let currentView = 'list'; | |
| function switchView(view) { | |
| currentView = view; | |
| document.getElementById('view-grid-btn').classList.toggle('active', view === 'grid'); | |
| document.getElementById('view-list-btn').classList.toggle('active', view === 'list'); | |
| renderResults(); | |
| } | |
| function renderResults() { | |
| const grid = document.getElementById('results-grid'); | |
| const list = document.getElementById('results-list'); | |
| if (currentView === 'grid') { | |
| grid.style.display = 'grid'; | |
| list.style.display = 'none'; | |
| grid.innerHTML = ''; | |
| backtestResults.forEach(trade => { | |
| const card = createTradeCard(trade); | |
| grid.appendChild(card); | |
| renderTradeChart(trade, `chart-${trade.symbol}-${trade.date}`); | |
| }); | |
| } else { | |
| grid.style.display = 'none'; | |
| list.style.display = 'block'; | |
| renderListView(); | |
| updateSortIndicators(); | |
| } | |
| } | |
| function getSortIndicator(col) { | |
| if (currentSortColumn !== col) return ''; | |
| return sortDirection === 'asc' ? ' ▲' : ' ▼'; | |
| } | |
| function sortTable(column) { | |
| if (currentSortColumn === column) { | |
| sortDirection = sortDirection === 'asc' ? 'desc' : 'asc'; | |
| } else { | |
| currentSortColumn = column; | |
| sortDirection = column === 'date' ? 'desc' : 'asc'; | |
| } | |
| updateSortIndicators(); | |
| renderListView(); | |
| } | |
| function updateSortIndicators() { | |
| document.querySelectorAll('.trade-table th[data-column]').forEach(th => { | |
| const col = th.getAttribute('data-column'); | |
| th.textContent = th.getAttribute('data-column') === 'symbol' ? 'Symbol' : | |
| th.getAttribute('data-column') === 'date' ? 'Date' : | |
| th.getAttribute('data-column') === 'status' ? 'Status' : | |
| th.getAttribute('data-column') === 'ret' ? 'Return' : | |
| th.getAttribute('data-column') === 'profit' ? 'Profit' : | |
| th.getAttribute('data-column') === 'entry' ? 'Entry' : | |
| th.getAttribute('data-column') === 'exit' ? 'Exit' : | |
| th.getAttribute('data-column') === 'sector' ? 'Sector' : | |
| th.getAttribute('data-column') === 'gap_pct' ? 'Gap %' : col; | |
| if (currentSortColumn === col) { | |
| th.textContent += sortDirection === 'asc' ? ' ▲' : ' ▼'; | |
| } | |
| }); | |
| } | |
| function renderListView() { | |
| const tbody = document.getElementById('trade-table-body'); | |
| tbody.innerHTML = ''; | |
| // Sort results | |
| const sortedResults = [...backtestResults].sort((a, b) => { | |
| let valA = a[currentSortColumn]; | |
| let valB = b[currentSortColumn]; | |
| // Handle date sorting | |
| if (currentSortColumn === 'date') { | |
| valA = new Date(valA); | |
| valB = new Date(valB); | |
| } else if (currentSortColumn === 'status') { | |
| // WIN comes before LOSS for consistency | |
| const statusA = a.profit >= 0 ? 0 : 1; | |
| const statusB = b.profit >= 0 ? 0 : 1; | |
| return sortDirection === 'asc' ? statusA - statusB : statusB - statusA; | |
| } else if (typeof valA === 'number' && typeof valB === 'number') { | |
| // Handle null/undefined for numeric columns | |
| valA = valA ?? (sortDirection === 'asc' ? Infinity : -Infinity); | |
| valB = valB ?? (sortDirection === 'asc' ? Infinity : -Infinity); | |
| } else { | |
| // String comparison | |
| valA = valA ?? ''; | |
| valB = valB ?? ''; | |
| } | |
| if (valA < valB) return sortDirection === 'asc' ? -1 : 1; | |
| if (valA > valB) return sortDirection === 'asc' ? 1 : -1; | |
| return 0; | |
| }); | |
| sortedResults.forEach((trade, idx) => { | |
| const tr = document.createElement('tr'); | |
| const isPositive = trade.profit >= 0; | |
| const retStr = (trade.ret >= 0 ? '+' : '') + trade.ret.toFixed(2) + '%'; | |
| tr.innerHTML = ` | |
| <td><strong>${trade.symbol}</strong></td> | |
| <td>${trade.date}</td> | |
| <td><span class="badge" style="background:${isPositive ? 'rgba(74, 222, 128, 0.1)' : 'rgba(248, 113, 113, 0.1)'}; color:${isPositive ? 'var(--positive)' : 'var(--negative)'}">${isPositive ? 'WIN' : 'LOSS'}</span></td> | |
| <td class="${isPositive ? 'positive' : 'negative'}">${retStr}</td> | |
| <td class="${isPositive ? 'positive' : 'negative'}">$${trade.profit.toFixed(0)}</td> | |
| <td>${trade.entry.toFixed(2)} (${trade.in})</td> | |
| <td>${trade.exit.toFixed(2)} (${trade.out})</td> | |
| <td><span class="badge">${trade.sector || '--'}</span></td> | |
| <td>${(trade.gap_pct || 0).toFixed(1)}%</td> | |
| `; | |
| tr.onclick = () => toggleRowChart(idx, trade); | |
| tbody.appendChild(tr); | |
| }); | |
| } | |
| function toggleRowChart(idx, trade) { | |
| const tbody = document.getElementById('trade-table-body'); | |
| const existing = document.getElementById(`chart-row-${idx}`); | |
| if (existing) { | |
| existing.remove(); | |
| return; | |
| } | |
| // Close other charts first? Maybe not, user might want to compare. | |
| const chartTr = document.createElement('tr'); | |
| chartTr.id = `chart-row-${idx}`; | |
| chartTr.className = 'chart-row'; | |
| chartTr.innerHTML = ` | |
| <td colspan="9"> | |
| <div class="expanded-chart-container" id="expanded-chart-${idx}"></div> | |
| </td> | |
| `; | |
| // Insert after the clicked tr | |
| const rows = tbody.querySelectorAll('tr:not(.chart-row)'); | |
| rows[idx].after(chartTr); | |
| renderTradeChart(trade, `expanded-chart-${idx}`); | |
| } | |
| async function renderTradeChart(trade, containerId) { | |
| const container = document.getElementById(containerId); | |
| if (!container) return; | |
| try { | |
| const response = await fetch(`/api/scan?symbols=${trade.symbol}&timeframe=1m&start=${trade.date}&end=${trade.date}`); | |
| const result = await response.json(); | |
| const item = result.results?.find(r => r.symbol === trade.symbol); | |
| if (!item || !item.daily_bars || item.daily_bars.length === 0) { | |
| container.innerHTML = '<div class="no-chart-data" style="display:flex;align-items:center;justify-content:center;height:100%;color:var(--text-muted);font-size:0.75rem;">No chart data</div>'; | |
| return; | |
| } | |
| const bars = item.daily_bars; | |
| const candleData = bars.map(b => { | |
| const unixTs = typeof b.time === 'number' ? b.time : new Date(b.time).getTime() / 1000; | |
| return { | |
| time: toNyTimestamp(unixTs), | |
| open: b.open, | |
| high: b.high, | |
| low: b.low, | |
| close: b.close | |
| }; | |
| }); | |
| const chart = LightweightCharts.createChart(container, { | |
| width: container.clientWidth, | |
| height: container.clientHeight || 240, | |
| layout: { background: { color: '#000000' }, textColor: '#666666', fontSize: 10 }, | |
| grid: { vertLines: { color: '#222222' }, horzLines: { color: '#222222' } }, | |
| rightPriceScale: { borderColor: '#222222', autoScale: true }, | |
| timeScale: { borderColor: '#222222', timeVisible: true, secondsVisible: false, timeZone: 'America/New_York', barSpacing: 6 } | |
| }); | |
| const candlestickSeries = chart.addCandlestickSeries({ | |
| upColor: '#4ade80', | |
| downColor: '#f87171', | |
| borderUpColor: '#4ade80', | |
| borderDownColor: '#f87171', | |
| wickUpColor: '#4ade80', | |
| wickDownColor: '#f87171' | |
| }); | |
| candlestickSeries.setData(candleData); | |
| const toNYTimeString = (unixSec) => { | |
| const date = new Date(unixSec * 1000); | |
| const h = String(date.getUTCHours()).padStart(2, '0'); | |
| const m = String(date.getUTCMinutes()).padStart(2, '0'); | |
| return `${h}:${m}`; | |
| }; | |
| const entryTime = candleData.find(b => toNYTimeString(b.time) === trade.in); | |
| const exitTime = candleData.find(b => toNYTimeString(b.time) === trade.out); | |
| if (entryTime && exitTime) { | |
| candlestickSeries.setMarkers([ | |
| { time: entryTime.time, position: 'belowBar', color: '#4ade80', shape: 'arrowUp', text: 'Entry' }, | |
| { time: exitTime.time, position: 'aboveBar', color: '#f87171', shape: 'arrowDown', text: 'Exit' } | |
| ]); | |
| const PADDING = 30; | |
| const entryIdx = candleData.indexOf(entryTime); | |
| const exitIdx = exitTime ? candleData.indexOf(exitTime) : entryIdx; | |
| const fromIdx = Math.max(0, entryIdx - PADDING); | |
| const toIdx = Math.min(candleData.length - 1, exitIdx + PADDING); | |
| chart.timeScale().setVisibleLogicalRange({ from: fromIdx, to: toIdx }); | |
| } else { | |
| chart.timeScale().fitContent(); | |
| } | |
| const resizeObserver = new ResizeObserver(entries => { | |
| if (entries[0]) { | |
| chart.applyOptions({ width: entries[0].contentRect.width }); | |
| } | |
| }); | |
| resizeObserver.observe(container); | |
| } catch (e) { | |
| console.error('Chart error:', e); | |
| container.innerHTML = '<div class="no-chart-data" style="display:flex;align-items:center;justify-content:center;height:100%;color:var(--text-muted);font-size:0.75rem;">Error</div>'; | |
| } | |
| } | |
| function exportToCsv() { | |
| if (!backtestResults || backtestResults.length === 0) return; | |
| // Sort by date ascending (oldest first) for CSV export | |
| const sortedResults = [...backtestResults].sort((a, b) => { | |
| return new Date(a.date) - new Date(b.date); | |
| }); | |
| // Get original headers from data | |
| const originalHeaders = Object.keys(sortedResults[0]); | |
| // Build header mapping: original -> new name (or keep original if no mapping) | |
| const headerMap = {}; | |
| originalHeaders.forEach(h => { | |
| headerMap[h] = backtestColumnMap[h] || h; | |
| }); | |
| // Use new headers in order (symbol first, then mapped columns) | |
| const headers = originalHeaders.map(h => headerMap[h]); | |
| const rows = sortedResults.map(trade => { | |
| return originalHeaders.map(header => { | |
| let val = trade[header]; | |
| if (val === undefined || val === null) val = ""; | |
| // Format numbers | |
| if (typeof val === 'number' && !Number.isInteger(val)) { | |
| val = val.toFixed(4); | |
| } | |
| // Escape commas if string | |
| if (typeof val === 'string' && val.includes(',')) { | |
| val = `"${val}"`; | |
| } | |
| return val; | |
| }).join(','); | |
| }); | |
| const csvContent = [headers.join(','), ...rows].join('\n'); | |
| const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); | |
| const url = URL.createObjectURL(blob); | |
| const link = document.createElement("a"); | |
| link.setAttribute("href", url); | |
| link.setAttribute("download", `backtest_${new Date().toISOString().split('T')[0]}_trades.csv`); | |
| link.style.visibility = 'hidden'; | |
| document.body.appendChild(link); | |
| link.click(); | |
| document.body.removeChild(link); | |
| } | |
| function renderEquityChart(data) { | |
| const container = document.getElementById('equity-chart'); | |
| const wrapper = document.getElementById('equity-chart-container'); | |
| if (!container || !data || data.length === 0) return; | |
| wrapper.style.display = 'block'; | |
| container.innerHTML = ''; | |
| const chart = LightweightCharts.createChart(container, { | |
| width: container.clientWidth, | |
| height: 180, | |
| layout: { background: { color: '#000000' }, textColor: '#666666', fontSize: 10 }, | |
| grid: { vertLines: { color: '#111111' }, horzLines: { color: '#111111' } }, | |
| rightPriceScale: { borderColor: '#222222', autoScale: true }, | |
| timeScale: { borderColor: '#222222' } | |
| }); | |
| const lineSeries = chart.addLineSeries({ | |
| color: '#3b82f6', | |
| lineWidth: 2, | |
| title: 'Equity' | |
| }); | |
| const chartData = data.map(d => ({ | |
| time: d.date, | |
| value: d.equity | |
| })); | |
| lineSeries.setData(chartData); | |
| chart.timeScale().fitContent(); | |
| const resizeObserver = new ResizeObserver(entries => { | |
| if (entries[0]) { | |
| chart.applyOptions({ width: entries[0].contentRect.width }); | |
| } | |
| }); | |
| resizeObserver.observe(container); | |
| } | |
| function createTradeCard(trade) { | |
| const card = document.createElement('div'); | |
| card.className = 'trade-card'; | |
| const isPositive = trade.profit >= 0; | |
| const retStr = (trade.ret >= 0 ? '+' : '') + trade.ret.toFixed(2) + '%'; | |
| card.innerHTML = ` | |
| <div class="trade-header"> | |
| <span class="trade-symbol">${trade.symbol}</span> | |
| <span class="trade-pnl ${isPositive ? 'positive' : 'negative'}">${retStr}</span> | |
| </div> | |
| <div class="trade-chart" id="chart-${trade.symbol}-${trade.date}"></div> | |
| <div class="trade-details"> | |
| <div class="trade-detail"> | |
| <div class="trade-detail-label">Entry</div> | |
| <div class="trade-detail-value">${trade.entry.toFixed(2)}</div> | |
| </div> | |
| <div class="trade-detail"> | |
| <div class="trade-detail-label">Exit</div> | |
| <div class="trade-detail-value">${trade.exit.toFixed(2)}</div> | |
| </div> | |
| <div class="trade-detail"> | |
| <div class="trade-detail-label">In Time</div> | |
| <div class="trade-detail-value">${trade.in}</div> | |
| </div> | |
| <div class="trade-detail"> | |
| <div class="trade-detail-label">Out Time</div> | |
| <div class="trade-detail-value">${trade.out}</div> | |
| </div> | |
| </div> | |
| `; | |
| return card; | |
| } | |
| function restoreCachedBacktest() { | |
| const cached = sessionStorage.getItem('backtest_last_result'); | |
| if (cached) { | |
| try { | |
| const result = JSON.parse(cached); | |
| if (result && result.trades) { | |
| backtestResults = result.trades; | |
| backtestColumnMap = result.column_map || {}; | |
| // Also save candidates for stats | |
| const candidates = (result.trades || []) | |
| .filter(t => t.symbol && t.date) | |
| .map(t => ({symbol: t.symbol, date: t.date})); | |
| sessionStorage.setItem('last_candidates', JSON.stringify(candidates)); | |
| // Update summary | |
| const summary = result.summary; | |
| document.getElementById('win-rate').textContent = summary.win_rate + '%'; | |
| document.getElementById('profit-factor').textContent = summary.profit_factor; | |
| document.getElementById('expectancy').textContent = (summary.expectancy >= 0 ? '+' : '') + '$' + summary.expectancy.toFixed(2); | |
| document.getElementById('avg-win-loss').textContent = `$${summary.avg_win.toFixed(0)} / $${Math.abs(summary.avg_loss).toFixed(0)}`; | |
| document.getElementById('max-drawdown').textContent = summary.max_drawdown + '%'; | |
| const profitEl = document.getElementById('total-profit'); | |
| profitEl.textContent = (summary.total_profit >= 0 ? '+' : '') + '$' + summary.total_profit.toFixed(2); | |
| document.getElementById('card-profit').className = 'summary-card ' + (summary.total_profit >= 0 ? 'positive' : 'negative'); | |
| const equityEl = document.getElementById('final-equity'); | |
| equityEl.textContent = '$' + summary.final_equity.toLocaleString(); | |
| equityEl.parentElement.className = 'summary-card ' + (summary.final_equity >= summary.initial_capital ? 'positive' : 'negative'); | |
| document.getElementById('summary-bar').style.display = 'grid'; | |
| document.getElementById('view-controls').style.display = 'flex'; | |
| document.getElementById('status').textContent = 'Backtest results restored from cache'; | |
| if (result.equity_curve && result.equity_curve.length > 0) { | |
| renderEquityChart(result.equity_curve); | |
| } | |
| document.getElementById('export-btn').style.display = 'block'; | |
| renderResults(); | |
| } | |
| } catch (e) { | |
| console.error('Failed to restore cached backtest:', e); | |
| } | |
| } | |
| } | |
| restoreCachedBacktest(); | |
| document.getElementById('symbols-input').addEventListener('keydown', (e) => { | |
| if (e.key === 'Enter') runBacktest(); | |
| }); | |
| </script> | |
| <script src="/static/js/nav.js"></script> | |
| </body> | |
| </html> | |