Spaces:
Sleeping
Sleeping
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>LT History</title> | |
| <link rel="stylesheet" href="/static/css/common.css"> | |
| <link rel="stylesheet" href="/static/css/scan.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 id="preset-modal" class="modal-overlay hidden"> | |
| <div class="modal-content"> | |
| <h3 class="modal-title">Manage Presets</h3> | |
| <div id="preset-list" class="list-layout" style="margin-bottom: 16px; max-height: 300px; overflow-y: auto;"></div> | |
| <input type="text" id="new-preset-input" class="modal-input" placeholder="New preset name"> | |
| <div class="modal-actions"> | |
| <button class="btn btn-primary" onclick="addNewPreset()">Add</button> | |
| <button class="btn btn-secondary" onclick="closePresetModal()">Close</button> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="page-wrapper"> | |
| <div class="control-panel"> | |
| <!-- Fila 1: Filtro principal --> | |
| <div class="filter-row"> | |
| <div class="filter-controls-row"> | |
| <div class="date-range-picker"> | |
| <input type="date" id="date-from" class="date-picker-input" title="Filter from date"> | |
| <span class="date-separator">→</span> | |
| <input type="date" id="date-to" class="date-picker-input" title="Filter to date"> | |
| </div> | |
| <select id="filter-select" onchange="selectFilter(this.value)"> | |
| <option value="">-- Preset --</option> | |
| </select> | |
| <button class="btn btn-secondary btn-small" onclick="openPresetModal()">Manage</button> | |
| </div> | |
| <textarea id="filter-input" class="filter-textarea" | |
| placeholder="Paste symbols or enter filter expression..." | |
| spellcheck="false">date > '2026-01-01' and volume > 5_000_000 and rel_vol > 5 sort date desc</textarea> | |
| </div> | |
| <!-- Fila 2: Navegación --> | |
| <div class="nav-row"> | |
| <div class="view-toggle view-toggle--main"> | |
| <button class="view-btn active" id="view-grid-btn" onclick="setView('grid')">Grid</button> | |
| <button class="view-btn" id="view-list-btn" onclick="setView('list')">List</button> | |
| <button class="view-btn" id="view-calendar-btn" onclick="setView('calendar')">Calendar</button> | |
| <div class="view-dropdown"> | |
| <button class="view-btn view-dropdown-toggle" onclick="toggleViewDropdown()"> | |
| More <span class="dropdown-arrow">▼</span> | |
| </button> | |
| <div class="view-dropdown-menu" id="view-dropdown-menu"> | |
| <button class="view-dropdown-item" id="view-stats-btn" onclick="setView('stats')">Stats</button> | |
| <button class="view-dropdown-item" id="view-backtest-btn" onclick="setView('backtest')">Backtest</button> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="tf-selector"> | |
| <button class="tf-btn" data-tf="1D" onclick="setGlobalTimeframe('1D', this)">1D</button> | |
| <button class="tf-btn" data-tf="1Y" onclick="setGlobalTimeframe('1Y', this)">1Y</button> | |
| <button class="tf-btn" data-tf="1H" onclick="setGlobalTimeframe('1H', this)">1H</button> | |
| <button class="tf-btn" data-tf="30M" onclick="setGlobalTimeframe('30M', this)">30M</button> | |
| <button class="tf-btn" data-tf="15M" onclick="setGlobalTimeframe('15M', this)">15M</button> | |
| <button class="tf-btn" data-tf="5M" onclick="setGlobalTimeframe('5M', this)">5M</button> | |
| <button class="tf-btn" data-tf="1M" onclick="setGlobalTimeframe('1M', this)">1M</button> | |
| </div> | |
| <span class="date-badge" id="date-badge"> | |
| <span id="date-badge-text"></span> | |
| <button class="remove-date" onclick="clearDateFilter()" title="Remove date filter">×</button> | |
| </span> | |
| <button class="btn btn-secondary btn-small" id="copy-symbols-btn" onclick="copySymbols()" title="Copy all result symbols">Copy</button> | |
| <button class="btn btn-primary" onclick="runScan()">Run</button> | |
| </div> | |
| </div> | |
| <div id="status" class="status">Ready</div> | |
| <!-- Grid View (Default) --> | |
| <div id="results-grid" class="results-grid"></div> | |
| <!-- List View (Hidden by default) --> | |
| <div id="results-list-view" class="results-table-container hidden"> | |
| <table class="results-table" id="results-list-table"> | |
| <thead> | |
| <tr id="results-list-header"></tr> | |
| </thead> | |
| <tbody id="results-list-body"></tbody> | |
| </table> | |
| </div> | |
| <!-- Calendar View (Hidden by default) --> | |
| <div id="calendar-view" class="calendar-view hidden"> | |
| <div class="calendar-layout"> | |
| <div class="calendar-sidebar"> | |
| <div class="sidebar-chart-container"> | |
| <div class="sidebar-chart-header"> | |
| <span id="sidebar-symbol" class="sidebar-symbol">Select a stock</span> | |
| <div class="sidebar-chart-tf"> | |
| <button class="tf-btn-mini active" onclick="setSidebarTF('1D', this)">1D</button> | |
| <button class="tf-btn-mini" onclick="setSidebarTF('1Y', this)">1Y</button> | |
| <button class="tf-btn-mini" onclick="setSidebarTF('1H', this)">1H</button> | |
| <button class="tf-btn-mini" onclick="setSidebarTF('30M', this)">30M</button> | |
| <button class="tf-btn-mini" onclick="setSidebarTF('15M', this)">15M</button> | |
| <button class="tf-btn-mini" onclick="setSidebarTF('5M', this)">5M</button> | |
| <button class="tf-btn-mini" onclick="setSidebarTF('1M', this)">1M</button> | |
| </div> | |
| </div> | |
| <stock-chart-card id="sidebar-chart-card" class="sidebar-chart" compact context="scanner"></stock-chart-card> | |
| </div> | |
| <div class="sidebar-list-container"> | |
| <div class="sidebar-list-header"> | |
| <span id="sidebar-list-date">--</span> | |
| <span id="sidebar-list-count">0 stocks</span> | |
| </div> | |
| <div id="sidebar-list" class="sidebar-list"> | |
| <div class="empty-state-mini">Click a day on the calendar</div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="calendar-main"> | |
| <div class="calendar-nav-header"> | |
| <h2 id="calendar-title">Jan 2026</h2> | |
| <div class="calendar-nav-controls"> | |
| <button class="cal-nav-btn" onclick="navigateMonth(-1)">‹</button> | |
| <button class="cal-nav-btn" onclick="navigateMonth(1)">›</button> | |
| </div> | |
| </div> | |
| <div id="calendar-grid" class="calendar-grid"></div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Stats View (Hidden by default) --> | |
| <div id="stats-view" class="stats-view hidden"> | |
| <div class="stats-topbar"> | |
| <div class="input-group"> | |
| <label class="input-label">Offset</label> | |
| <input type="number" id="stats-offset-input" class="input-field" value="2" min="0" max="20"> | |
| </div> | |
| <button class="btn btn-primary" onclick="ScannerStats.run()">Analyze</button> | |
| <div class="view-toggle"> | |
| <button class="view-btn active" data-mode="times" onclick="ScannerStats.setViewMode('times', this)">Times</button> | |
| <button class="view-btn" data-mode="volume" onclick="ScannerStats.setViewMode('volume', this)">Volume</button> | |
| <button class="view-btn" data-mode="price" onclick="ScannerStats.setViewMode('price', this)">Price</button> | |
| <button class="view-btn" data-mode="intraday" onclick="ScannerStats.setViewMode('intraday', this)">Intraday</button> | |
| </div> | |
| </div> | |
| <div id="stats-empty-state" class="empty-state"> | |
| <h3>Ready for Analysis</h3> | |
| <p>Select the <strong>Max Offset</strong> (history depth) and click <strong>Analyze</strong> below to start the statistical study on current results.</p> | |
| </div> | |
| <div id="stats-no-results-state" class="empty-state hidden"> | |
| <h3 class="text-negative">No Results Found</h3> | |
| <p>No active tickers. Please <strong>Run</strong> from the <strong>Grid</strong> view first or adjust your filters.</p> | |
| </div> | |
| <div id="stats-viz-wrapper" class="u-hidden"> | |
| <div class="viz-container"> | |
| <div class="viz-panel"> | |
| <div class="panel-head">Pattern Matrix: % High → Low</div> | |
| <div class="viz-content"> | |
| <div id="heatmap-matrix" class="heatmap-matrix"></div> | |
| </div> | |
| <div class="viz-hint">Green = high before low (continuation). Red = low before high (reversal).</div> | |
| </div> | |
| <div class="viz-panel"> | |
| <div class="panel-head">Trend by Session</div> | |
| <div class="viz-content"> | |
| <div id="line-chart" class="line-chart"></div> | |
| <div class="chart-legend" id="stats-chart-legend"></div> | |
| </div> | |
| <div class="viz-hint">Lines show % high before low. Rising = pattern strengthens. Above 50% = high tended first.</div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="stats-panel"> | |
| <div class="stats-panel-head"> | |
| Raw Study Table | |
| <button class="export-btn" onclick="ScannerStats.exportCsv()">Export CSV</button> | |
| </div> | |
| <table id="stats-raw-table" class="raw-table"></table> | |
| </div> | |
| </div> | |
| <!-- Backtest View (Hidden by default) --> | |
| <div id="backtest-view" class="backtest-view hidden"> | |
| <div class="backtest-control-panel"> | |
| <div class="bt-input-row"> | |
| <div class="input-group"> | |
| <label class="input-label">Entry Condition</label> | |
| <input type="text" id="bt-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="bt-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="bt-offset-input" class="input-field" value="0" min="0" max="30"> | |
| </div> | |
| <button class="btn btn-primary" id="bt-run-btn" onclick="ScannerBacktest.run()">Run Backtest</button> | |
| </div> | |
| </div> | |
| <div id="bt-empty-state" class="empty-state"> | |
| <h3>Ready for Backtest</h3> | |
| <p>Configure your entry/exit conditions and click <strong>Run Backtest</strong> to test the strategy on current results.</p> | |
| </div> | |
| <div id="bt-no-results-state" class="empty-state hidden"> | |
| <h3 class="text-negative">No Results Found</h3> | |
| <p>No scanner results to backtest. Please run a scan in the <strong>Grid</strong> view first.</p> | |
| </div> | |
| <div class="summary-bar" id="bt-summary-bar" class="u-hidden"> | |
| <div class="summary-card"> | |
| <div class="summary-label">Win Rate</div> | |
| <div class="summary-value" id="bt-win-rate">0%</div> | |
| </div> | |
| <div class="summary-card" id="bt-card-profit"> | |
| <div class="summary-label">Total Profit</div> | |
| <div class="summary-value" id="bt-total-profit">$0</div> | |
| </div> | |
| <div class="summary-card"> | |
| <div class="summary-label">Profit Factor</div> | |
| <div class="summary-value" id="bt-profit-factor">0</div> | |
| </div> | |
| <div class="summary-card"> | |
| <div class="summary-label">Expectancy</div> | |
| <div class="summary-value" id="bt-expectancy">$0</div> | |
| </div> | |
| <div class="summary-card"> | |
| <div class="summary-label">Avg Win / Loss</div> | |
| <div class="summary-value" id="bt-avg-win-loss">$0 / $0</div> | |
| </div> | |
| <div class="summary-card"> | |
| <div class="summary-label">Max Drawdown</div> | |
| <div class="summary-value text-negative" id="bt-max-drawdown">0%</div> | |
| </div> | |
| <div class="summary-card"> | |
| <div class="summary-label">Final Equity</div> | |
| <div class="summary-value" id="bt-final-equity">$0</div> | |
| </div> | |
| </div> | |
| <div id="bt-equity-chart-container" class="equity-chart-container u-hidden"> | |
| <div class="summary-label">Equity Curve</div> | |
| <div id="bt-equity-chart" class="equity-chart"></div> | |
| </div> | |
| <div class="bt-view-controls" id="bt-view-controls" class="u-hidden"> | |
| <div class="summary-label">Execution Results</div> | |
| <div class="view-toggle"> | |
| <button class="view-btn active" id="bt-view-grid-btn" onclick="ScannerBacktest.switchView('grid')">Grid</button> | |
| <button class="view-btn" id="bt-view-list-btn" onclick="ScannerBacktest.switchView('list')">Trade Log</button> | |
| </div> | |
| <button class="btn btn-secondary btn-small" id="bt-export-btn" onclick="ScannerBacktest.exportCsv()">Export CSV</button> | |
| </div> | |
| <div id="bt-results-grid" class="results-grid" class="u-hidden"></div> | |
| <div id="bt-results-list" class="trade-log-container" class="u-hidden"> | |
| <table class="trade-table" id="bt-trade-table"> | |
| <thead> | |
| <tr> | |
| <th data-column="symbol" onclick="ScannerBacktest.sortTable('symbol')">Symbol</th> | |
| <th data-column="date" onclick="ScannerBacktest.sortTable('date')">Date</th> | |
| <th data-column="status" onclick="ScannerBacktest.sortTable('status')">Status</th> | |
| <th data-column="ret" onclick="ScannerBacktest.sortTable('ret')">Return</th> | |
| <th data-column="profit" onclick="ScannerBacktest.sortTable('profit')">Profit</th> | |
| <th data-column="entry" onclick="ScannerBacktest.sortTable('entry')">Entry</th> | |
| <th data-column="exit" onclick="ScannerBacktest.sortTable('exit')">Exit</th> | |
| <th data-column="sector" onclick="ScannerBacktest.sortTable('sector')">Sector</th> | |
| <th data-column="gap_pct" onclick="ScannerBacktest.sortTable('gap_pct')">Gap %</th> | |
| </tr> | |
| </thead> | |
| <tbody id="bt-trade-table-body"></tbody> | |
| </table> | |
| </div> | |
| </div> | |
| <div id="stats-tooltip" class="stats-tooltip"></div> | |
| <script src="/static/js/financial-api.js"></script> | |
| <script src="/static/js/chart-card-element.js"></script> | |
| <script src="/static/js/inspector-panel.js"></script> | |
| <script src="/static/js/timeframe-selector.js"></script> | |
| <script src="/static/js/view-toggle.js"></script> | |
| <script> | |
| let globalTimeframe = localStorage.getItem('scanner_global_tf') || '1D'; | |
| let activeView = 'grid'; | |
| let savedFilters = []; | |
| let results = []; | |
| const VIEW_MODES = { | |
| times: [ | |
| { key: 'pm_low_time', label: 'PM Low' }, | |
| { key: 'pm_high_time', label: 'PM High' }, | |
| { key: 'rth_low_time', label: 'Mkt Low' }, | |
| { key: 'rth_high_time', label: 'Mkt High' }, | |
| { key: 'ah_low_time', label: 'AH Low' }, | |
| { key: 'ah_high_time', label: 'AH High' } | |
| ], | |
| volume: [ | |
| { key: 'pm_low_vol', label: 'PM Low Vol' }, | |
| { key: 'pm_high_vol', label: 'PM High Vol' }, | |
| { key: 'rth_low_vol', label: 'Mkt Low Vol' }, | |
| { key: 'rth_high_vol', label: 'Mkt High Vol' }, | |
| { key: 'ah_low_vol', label: 'AH Low Vol' }, | |
| { key: 'ah_high_vol', label: 'AH High Vol' } | |
| ], | |
| price: [ | |
| { key: 'pm_low_price', label: 'PM Low $' }, | |
| { key: 'pm_high_price', label: 'PM High $' }, | |
| { key: 'rth_low_price', label: 'Mkt Low $' }, | |
| { key: 'rth_high_price', label: 'Mkt High $' }, | |
| { key: 'ah_low_price', label: 'AH Low $' }, | |
| { key: 'ah_high_price', label: 'AH High $' } | |
| ], | |
| intraday: [ | |
| { key: 'hod_time', label: 'HOD Time' }, | |
| { key: 'lod_time', label: 'LOD Time' }, | |
| { key: 'pm_high_pct', label: 'PM High %' }, | |
| { key: 'pm_low_pct', label: 'PM Low %' }, | |
| { key: 'first_hour_vol_pct', label: '1st Hr Vol %' }, | |
| { key: 'orb_15m_bullish', label: 'ORB 15m ↑' } | |
| ] | |
| }; | |
| const ScannerStats = { | |
| viewMode: 'times', | |
| sortState: { offset: null, column: null, direction: 'asc' }, | |
| sessionLabels: { premarket: 'PM', market: 'MKT', afterhours: 'AH' }, | |
| sessionColors: { premarket: '#3b82f6', market: '#f97316', afterhours: '#22c55e' }, | |
| studyData: null, | |
| sortedRows: [], | |
| async run() { | |
| const emptyState = document.getElementById('stats-empty-state'); | |
| const noResultsState = document.getElementById('stats-no-results-state'); | |
| const vizWrapper = document.getElementById('stats-viz-wrapper'); | |
| const status = document.getElementById('status'); | |
| if (!results || results.length === 0) { | |
| status.textContent = 'No scanner results to analyze.'; | |
| if (noResultsState) noResultsState.classList.remove('hidden'); | |
| if (emptyState) emptyState.classList.add('hidden'); | |
| return; | |
| } | |
| status.textContent = 'Running statistical study...'; | |
| if (noResultsState) noResultsState.classList.add('hidden'); | |
| if (emptyState) emptyState.classList.add('hidden'); | |
| if (vizWrapper) vizWrapper.style.display = 'none'; | |
| try { | |
| const offsetInput = document.getElementById('stats-offset-input'); | |
| const maxOffset = offsetInput ? parseInt(offsetInput.value, 10) : 2; | |
| // Normalize results to ensure we have a 'date' field the backend can rely on easily | |
| // Anchor strictly on signal_date as requested | |
| const normalizedCandidates = results.map(r => ({ | |
| ...r, | |
| date: r.signal_date || r.date | |
| })); | |
| const response = await fetch('/api/stats', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ | |
| candidates: normalizedCandidates, | |
| max_offset: maxOffset | |
| }) | |
| }); | |
| const data = await response.json(); | |
| if (!response.ok) throw new Error(data.detail || 'Request failed'); | |
| this.studyData = data; | |
| this.renderTable(); | |
| this.renderVisualizations(); | |
| const count = data.row_count || 0; | |
| status.innerHTML = `<span style="color:var(--positive)">Analysis Complete:</span> ${count} stocks analyzed. | |
| ${data.rows?.some(r => Object.keys(r).length < (maxOffset + 2)) ? | |
| '<span style="color:var(--text-muted);font-size:0.7rem">(Some future offsets were empty due to missing calendar days)</span>' : ''}`; | |
| } catch (e) { | |
| status.textContent = `Stats Error: ${e.message}`; | |
| if (emptyState) emptyState.classList.remove('hidden'); | |
| } | |
| }, | |
| setViewMode(mode, btn) { | |
| this.viewMode = mode; | |
| this.sortState = { offset: null, column: null, direction: 'asc' }; | |
| btn.parentElement.querySelectorAll('.view-btn').forEach(b => b.classList.remove('active')); | |
| btn.classList.add('active'); | |
| if (this.studyData) { | |
| this.renderTable(); | |
| this.renderVisualizations(); | |
| } | |
| }, | |
| renderVisualizations() { | |
| const wrapper = document.getElementById('stats-viz-wrapper'); | |
| if (wrapper) wrapper.style.display = 'block'; | |
| this.renderHeatmap(); | |
| this.renderLineChart(); | |
| }, | |
| renderHeatmap() { | |
| const container = document.getElementById('heatmap-matrix'); | |
| if (!container || !this.studyData) return; | |
| const sessions = ['premarket', 'market', 'afterhours']; | |
| const maxOffset = this.studyData.max_offset || 0; | |
| const getColor = (pct) => { | |
| if (pct <= 50) { | |
| const hue = (pct / 50) * 60; | |
| return `hsl(${hue}, 80%, 45%)`; | |
| } else { | |
| const hue = 60 + ((pct - 50) / 50) * 80; | |
| return `hsl(${hue}, 80%, 35%)`; | |
| } | |
| }; | |
| let html = '<div class="hm-cell header"></div>'; | |
| sessions.forEach(s => html += `<div class="hm-cell header">${this.sessionLabels[s]}</div>`); | |
| for (let o = 0; o <= maxOffset; o++) { | |
| html += `<div class="hm-cell row-label">+${o}</div>`; | |
| sessions.forEach(s => { | |
| const row = this.studyData.summary_rows.find(r => r.offset === o && r.session === s); | |
| const pct = row ? row.high_before_low_pct : 0; | |
| const bg = getColor(pct); | |
| const hint = pct >= 50 ? 'H→L' : 'L→H'; | |
| html += ` | |
| <div class="hm-cell" style="background:${bg}" | |
| onmouseenter="ScannerStats.showTooltip(event, ${o}, '${s}', ${pct}, '${row?.median_low_time || ''}', '${row?.median_high_time || ''}')" | |
| onmousemove="ScannerStats.moveTooltip(event)" | |
| onmouseleave="ScannerStats.hideTooltip()"> | |
| <span class="pct">${pct.toFixed(0)}%</span> | |
| <span class="hint">${hint}</span> | |
| </div>`; | |
| }); | |
| } | |
| container.innerHTML = html; | |
| }, | |
| renderLineChart() { | |
| const container = document.getElementById('line-chart'); | |
| const legend = document.getElementById('stats-chart-legend'); | |
| if (!container || !this.studyData) return; | |
| const sessions = ['premarket', 'market', 'afterhours']; | |
| const maxOffset = this.studyData.max_offset || 0; | |
| const width = 380, height = 200; | |
| const padding = { top: 15, right: 15, bottom: 30, left: 35 }; | |
| const chartW = width - padding.left - padding.right, chartH = height - padding.top - padding.bottom; | |
| const xScale = (o) => padding.left + (o / Math.max(maxOffset, 1)) * chartW; | |
| const yScale = (p) => padding.top + chartH - (p / 100) * chartH; | |
| let svg = `<svg viewBox="0 0 ${width} ${height}" style="width: 100%; height: 100%;">`; | |
| for (let i = 0; i <= 4; i++) { | |
| const y = padding.top + (chartH / 4) * i; | |
| svg += `<line class="grid-line" x1="${padding.left}" y1="${y}" x2="${width - padding.right}" y2="${y}" stroke="var(--border)" stroke-width="1" />`; | |
| svg += `<text class="axis-label" x="${padding.left - 4}" y="${y + 3}" text-anchor="end" fill="var(--text-muted)" style="font-size: 9px;">${100 - i * 25}</text>`; | |
| } | |
| for (let o = 0; o <= maxOffset; o++) { | |
| svg += `<text class="axis-label" x="${xScale(o)}" y="${height - 8}" text-anchor="middle" fill="var(--text-muted)" style="font-size: 9px;">+${o}</text>`; | |
| } | |
| sessions.forEach(session => { | |
| const color = this.sessionColors[session]; | |
| const pts = []; | |
| for (let o = 0; o <= maxOffset; o++) { | |
| const row = this.studyData.summary_rows.find(r => r.offset === o && r.session === session); | |
| pts.push({ offset: o, pct: row ? row.high_before_low_pct : 0, low: row?.median_low_time, high: row?.median_high_time }); | |
| } | |
| const pathD = pts.map((p, i) => `${i === 0 ? 'M' : 'L'} ${xScale(p.offset)} ${yScale(p.pct)}`).join(' '); | |
| svg += `<path class="line" d="${pathD}" fill="none" stroke="${color}" stroke-width="2" />`; | |
| pts.forEach(p => { | |
| svg += `<circle class="point" cx="${xScale(p.offset)}" cy="${yScale(p.pct)}" fill="${color}" stroke="${color}" r="4" | |
| onmouseenter="ScannerStats.showTooltip(event, ${p.offset}, '${session}', ${p.pct}, '${p.low || ''}', '${p.high || ''}')" | |
| onmousemove="ScannerStats.moveTooltip(event)" | |
| onmouseleave="ScannerStats.hideTooltip()" style="cursor: pointer;" />`; | |
| }); | |
| }); | |
| svg += `</svg>`; | |
| container.innerHTML = svg; | |
| if (legend) { | |
| legend.innerHTML = sessions.map(s => `<div class="chart-legend-item"><div class="chart-legend-color" style="background:${this.sessionColors[s]}"></div><span>${this.sessionLabels[s]}</span></div>`).join(''); | |
| } | |
| }, | |
| renderTable() { | |
| const table = document.getElementById('stats-raw-table'); | |
| if (!table || !this.studyData) return; | |
| const data = this.studyData; | |
| const columns = VIEW_MODES[this.viewMode]; | |
| const maxOffset = data.max_offset || 0; | |
| if (this.sortState.column !== null) { | |
| this.sortedRows = [...data.rows].sort((a, b) => { | |
| const av = this.getSortVal(a, this.sortState.offset, this.sortState.column); | |
| const bv = this.getSortVal(b, this.sortState.offset, this.sortState.column); | |
| if (av === bv) return 0; | |
| const cmp = av < bv ? -1 : 1; | |
| return this.sortState.direction === 'asc' ? cmp : -cmp; | |
| }); | |
| } else { | |
| this.sortedRows = [...data.rows]; | |
| } | |
| const headTop = `<tr><th class="sticky-left" rowspan="2">Symbol</th><th rowspan="2">Date</th>${Array.from({ length: maxOffset + 1 }, (_, o) => `<th class="group-head" colspan="${columns.length}">Offset ${o}</th>`).join('')}</tr>`; | |
| const headBottom = `<tr>${Array.from({ length: maxOffset + 1 }, (_, o) => columns.map(col => { | |
| const active = this.sortState.offset === o && this.sortState.column === col.key; | |
| const arrow = active ? (this.sortState.direction === 'asc' ? ' ▲' : ' ▼') : ''; | |
| return `<th class="sortable-col" onclick="ScannerStats.handleSort(${o}, '${col.key}')" style="cursor: pointer;">${col.label}${arrow}</th>`; | |
| }).join('')).join('')}</tr>`; | |
| const body = this.sortedRows.map((row, idx) => ` | |
| <tr onclick="ScannerStats.toggleCharts(${idx})" style="cursor:pointer"> | |
| <td class="sticky-left symbol-cell">${row.symbol}</td> | |
| <td>${row.date}</td> | |
| ${Array.from({ length: maxOffset + 1 }, (_, o) => { | |
| const g = row[`offset_${o}`] || {}; | |
| return columns.map(c => `<td>${g[c.key] ?? ''}</td>`).join(''); | |
| }).join('')} | |
| </tr> | |
| `).join(''); | |
| table.innerHTML = `<thead>${headTop}${headBottom}</thead><tbody>${body}</tbody>`; | |
| }, | |
| getSortVal(row, offset, key) { | |
| const v = (row[`offset_${offset}`] || {})[key]; | |
| if (v === '' || v === null || v === undefined) return -Infinity; | |
| const n = parseFloat(v); | |
| return isNaN(n) ? String(v) : n; | |
| }, | |
| handleSort(offset, column) { | |
| if (this.sortState.offset === offset && this.sortState.column === column) { | |
| this.sortState.direction = this.sortState.direction === 'asc' ? 'desc' : 'asc'; | |
| } else { | |
| this.sortState = { offset, column, direction: 'asc' }; | |
| } | |
| this.renderTable(); | |
| }, | |
| toggleCharts(idx) { | |
| const trId = `stats-chart-row-${idx}`; | |
| const existing = document.getElementById(trId); | |
| if (existing) { existing.remove(); return; } | |
| const row = this.sortedRows[idx]; | |
| if (!row) return; | |
| const maxOffset = this.studyData.max_offset || 0; | |
| const colSpan = 2 + (maxOffset + 1) * VIEW_MODES[this.viewMode].length; | |
| const first = row.offset_0?.date || row.date; | |
| const last = row[`offset_${maxOffset}`]?.date || row.date; | |
| const markers = []; | |
| for (let o = 0; o <= maxOffset; o++) { | |
| const d = row[`offset_${o}`]?.date; | |
| if (d) markers.push({ time: Math.floor(new Date(d + 'T00:00:00Z').getTime() / 1000), position: 'aboveBar', color: '#3b82f6', shape: 'circle', text: `+${o}` }); | |
| } | |
| const tr = document.createElement('tr'); | |
| tr.id = trId; | |
| tr.className = 'chart-row'; | |
| tr.innerHTML = ` | |
| <td colspan="${colSpan}"> | |
| <div class="chart-row-inner"> | |
| <div> | |
| <stock-chart-card symbol="${row.symbol}" timeframe="1D" context="scanner" date-range="${first}|${last}" signal-date="${first}" compact markers='${JSON.stringify(markers)}'></stock-chart-card> | |
| </div> | |
| <div> | |
| <stock-chart-card symbol="${row.symbol}" timeframe="1H" context="scanner" date-range="${first}|${last}" signal-date="${first}" compact markers='${JSON.stringify(markers)}'></stock-chart-card> | |
| </div> | |
| </div> | |
| </td>`; | |
| const tbody = document.querySelector('#stats-raw-table tbody'); | |
| const dataRows = tbody.querySelectorAll('tr:not(.chart-row)'); | |
| if (dataRows[idx]) dataRows[idx].after(tr); | |
| else if (tbody) tbody.appendChild(tr); | |
| }, | |
| exportCsv() { | |
| if (!this.studyData) return; | |
| const columns = VIEW_MODES[this.viewMode]; | |
| const maxOffset = this.studyData.max_offset || 0; | |
| const headers = ['Symbol', 'Date', ...Array.from({ length: maxOffset + 1 }, (_, o) => columns.map(c => `${c.label} +${o}`)).flat()]; | |
| const csv = [headers.join(','), ...this.sortedRows.map(r => [r.symbol, r.date, ...Array.from({ length: maxOffset + 1 }, (_, o) => columns.map(c => { | |
| let v = (r[`offset_${o}`] || {})[c.key] ?? ''; | |
| return (typeof v === 'string' && v.includes(',')) ? `"${v}"` : v; | |
| })).flat()].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 = `scanner_stats_${new Date().toISOString().split('T')[0]}.csv`; | |
| a.click(); | |
| }, | |
| showTooltip(e, offset, session, pct, low, high) { | |
| const t = document.getElementById('stats-tooltip'); | |
| if (!t) return; | |
| t.innerHTML = ` | |
| <div style="font-weight:700;margin-bottom:6px">Offset ${offset} - ${this.sessionLabels[session]}</div> | |
| <div class="row"><span class="label">High→Low:</span><span class="value">${pct}%</span></div> | |
| <div class="row"><span class="label">Median Low:</span><span class="value">${low || 'N/A'}</span></div> | |
| <div class="row"><span class="label">Median High:</span><span class="value">${high || 'N/A'}</span></div>`; | |
| t.style.display = 'block'; | |
| this.moveTooltip(e); | |
| }, | |
| moveTooltip(e) { | |
| const t = document.getElementById('stats-tooltip'); | |
| if (!t) return; | |
| t.style.left = (e.clientX + 15) + 'px'; | |
| t.style.top = (e.clientY + 15) + 'px'; | |
| }, | |
| hideTooltip() { | |
| const t = document.getElementById('stats-tooltip'); | |
| if (t) t.style.display = 'none'; | |
| } | |
| }; | |
| // Calendar-specific state | |
| const ScannerCalendar = { | |
| currentYear: new Date().getFullYear(), | |
| currentMonth: new Date().getMonth() + 1, | |
| calendarData: {}, | |
| selectedDate: null, | |
| selectedSymbol: null, | |
| chartTimeframe: '1D', | |
| monthNames: ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"], | |
| init() { | |
| this.renderGrid(); | |
| }, | |
| async loadData() { | |
| const input = document.getElementById('filter-input').value.trim(); | |
| const status = document.getElementById('status'); | |
| status.innerText = `Fetching calendar data...`; | |
| const params = { | |
| year: this.currentYear, | |
| month: this.currentMonth | |
| }; | |
| // Use the same detection logic as runScan | |
| if (/^[A-Z0-9,\s\n\t]+$/i.test(input) && !/(\s(and|or|not)\s|[><=])/i.test(input)) { | |
| params.symbols = input; | |
| } else { | |
| params.filter = input; | |
| } | |
| try { | |
| const response = await FinancialAPI.fetchScanCalendar(params); | |
| this.calendarData = response.data || {}; | |
| this.renderGrid(); | |
| status.innerText = `[Calendar] Loaded ${this.currentMonth}/${this.currentYear}`; | |
| // Auto-select last day with data on initial load | |
| if (!this.selectedDate && Object.keys(this.calendarData).length > 0) { | |
| const sortedDates = Object.keys(this.calendarData).sort(); | |
| const lastDate = sortedDates[sortedDates.length - 1]; | |
| this.selectDate(lastDate); | |
| } else if (this.selectedDate) { | |
| // If a date was selected, refresh its list | |
| this.showDateDetails(this.selectedDate); | |
| } | |
| } catch (e) { | |
| status.innerText = 'Calendar Error: ' + e.message; | |
| } | |
| }, | |
| renderGrid() { | |
| const container = document.getElementById('calendar-grid'); | |
| if (!container) return; | |
| const today = new Date(); | |
| const todayStr = today.toISOString().split('T')[0]; | |
| const firstDay = new Date(this.currentYear, this.currentMonth - 1, 1); | |
| const lastDay = new Date(this.currentYear, this.currentMonth, 0); | |
| const daysInMonth = lastDay.getDate(); | |
| let startDay = firstDay.getDay() - 1; // Mon-based | |
| if (startDay < 0) startDay = 6; | |
| document.getElementById('calendar-title').textContent = `${this.monthNames[this.currentMonth - 1]} ${this.currentYear}`; | |
| let html = ''; | |
| const weekdays = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; | |
| weekdays.forEach(d => html += `<div class="cal-weekday">${d}</div>`); | |
| // Padding | |
| for (let i = 0; i < startDay; i++) { | |
| html += `<div class="cal-day other-month"></div>`; | |
| } | |
| // Month days | |
| for (let d = 1; d <= daysInMonth; d++) { | |
| const dateStr = `${this.currentYear}-${String(this.currentMonth).padStart(2, '0')}-${String(d).padStart(2, '0')}`; | |
| const stocks = this.calendarData[dateStr] || []; | |
| const isToday = dateStr === todayStr; | |
| const isSelected = dateStr === this.selectedDate; | |
| let classes = 'cal-day'; | |
| if (isToday) classes += ' today'; | |
| if (isSelected) classes += ' selected'; | |
| html += `<div class="${classes}" onclick="ScannerCalendar.selectDate('${dateStr}')"> | |
| <div class="cal-day-num">${d}</div> | |
| ${stocks.slice(0, 5).map(s => ` | |
| <div class="cal-stock" onclick="event.stopPropagation(); openAnalyzeModalFor('${s.symbol}')"> | |
| <span class="cal-stock-symbol">${s.symbol}</span> | |
| <span class="cal-stock-price">$${s.close_price || s.open_price}</span> | |
| <span class="cal-stock-pct ${s.return_pct >= 0 ? 'positive' : 'negative'}">${s.return_pct >= 0 ? '+' : ''}${s.return_pct}%</span> | |
| <span class="cal-stock-vol">${this.formatVolume(s.volume)}</span> | |
| </div> | |
| `).join('')} | |
| ${stocks.length > 5 ? `<div style="font-size: 8px; text-align:center; color: var(--text-dim);">+${stocks.length - 5} more</div>` : ''} | |
| </div>`; | |
| } | |
| container.innerHTML = html; | |
| }, | |
| selectDate(dateStr) { | |
| this.selectedDate = dateStr; | |
| this.renderGrid(); | |
| // Auto-select first stock if available | |
| const stocks = this.calendarData[dateStr] || []; | |
| if (stocks.length > 0) { | |
| this.selectedSymbol = stocks[0].symbol; | |
| } | |
| this.showDateDetails(dateStr); | |
| // Load chart for auto-selected stock | |
| if (stocks.length > 0) { | |
| this.loadChart(stocks[0].symbol); | |
| } | |
| }, | |
| showDateDetails(dateStr) { | |
| const stocks = this.calendarData[dateStr] || []; | |
| const listEl = document.getElementById('sidebar-list'); | |
| document.getElementById('sidebar-list-date').textContent = dateStr; | |
| document.getElementById('sidebar-list-count').textContent = `${stocks.length} stocks`; | |
| if (stocks.length === 0) { | |
| listEl.innerHTML = '<div class="empty-state-mini">No results for this day</div>'; | |
| return; | |
| } | |
| listEl.innerHTML = stocks.map(s => { | |
| const isGapPos = s.pct >= 0; | |
| const isRetPos = s.return_pct >= 0; | |
| const isSelected = s.symbol === this.selectedSymbol; | |
| return ` | |
| <div class="sidebar-stock-item ${isSelected ? 'active' : ''}" onclick="ScannerCalendar.selectStock('${s.symbol}')"> | |
| <div class="sidebar-stock-header"> | |
| <span class="sidebar-stock-symbol" onclick="event.stopPropagation(); openAnalyzeModalFor('${s.symbol}')">$${s.symbol}</span> | |
| <span class="sidebar-metric-value ${isRetPos ? 'positive' : 'negative'}"> | |
| ${isRetPos ? '▲' : '▼'} ${isRetPos ? '+' : ''}${s.return_pct}% | |
| </span> | |
| </div> | |
| <div class="sidebar-stock-metrics-grid"> | |
| <div class="sidebar-metric"> | |
| <span class="sidebar-metric-label">Gap:</span> | |
| <span class="sidebar-metric-value ${isGapPos ? 'positive' : 'negative'}">${isGapPos ? '+' : ''}${s.pct}%</span> | |
| </div> | |
| <div class="sidebar-metric"> | |
| <span class="sidebar-metric-label">Price:</span> | |
| <span class="sidebar-metric-value">$${s.open_price}</span> | |
| </div> | |
| <div class="sidebar-metric"> | |
| <span class="sidebar-metric-label">Vol:</span> | |
| <span class="sidebar-metric-value">${this.formatVolume(s.volume)}</span> | |
| </div> | |
| </div> | |
| </div> | |
| `}).join(''); | |
| }, | |
| formatVolume(vol) { | |
| if (vol >= 1000000) return (vol / 1000000).toFixed(1) + 'M'; | |
| if (vol >= 1000) return (vol / 1000).toFixed(1) + 'K'; | |
| return vol; | |
| }, | |
| selectStock(symbol) { | |
| this.selectedSymbol = symbol; | |
| document.querySelectorAll('.sidebar-stock-item').forEach(el => { | |
| el.classList.toggle('active', el.querySelector('.sidebar-stock-symbol').textContent === symbol); | |
| }); | |
| this.loadChart(symbol); | |
| }, | |
| loadChart(symbol) { | |
| const chartCard = document.getElementById('sidebar-chart-card'); | |
| const symbolEl = document.getElementById('sidebar-symbol'); | |
| if (chartCard) { | |
| chartCard.setAttribute('symbol', symbol); | |
| chartCard.setAttribute('timeframe', this.chartTimeframe); | |
| // Force data fetch if chart is already loaded, otherwise trigger lazy load | |
| if (chartCard.isLoaded) { | |
| chartCard.fetchData(); | |
| } else { | |
| // Force immediate load for calendar auto-select | |
| chartCard.isLoaded = true; | |
| chartCard.fetchData(); | |
| } | |
| } | |
| if (symbolEl) { | |
| symbolEl.textContent = symbol; | |
| } | |
| } | |
| }; | |
| // ======================================== | |
| // Scanner Backtest Module | |
| // ======================================== | |
| const ScannerBacktest = { | |
| trades: [], | |
| columnMap: {}, | |
| currentView: 'list', | |
| sortColumn: 'date', | |
| sortDirection: 'desc', | |
| equityChart: null, | |
| equityResizeObserver: null, | |
| async run() { | |
| this.saveFormValues(); | |
| const emptyState = document.getElementById('bt-empty-state'); | |
| const noResultsState = document.getElementById('bt-no-results-state'); | |
| const summaryBar = document.getElementById('bt-summary-bar'); | |
| const viewControls = document.getElementById('bt-view-controls'); | |
| const equityContainer = document.getElementById('bt-equity-chart-container'); | |
| const status = document.getElementById('status'); | |
| const btn = document.getElementById('bt-run-btn'); | |
| if (!results || results.length === 0) { | |
| status.textContent = 'No scanner results to backtest.'; | |
| if (noResultsState) noResultsState.classList.remove('hidden'); | |
| if (emptyState) emptyState.classList.add('hidden'); | |
| return; | |
| } | |
| status.textContent = 'Running backtest...'; | |
| if (noResultsState) noResultsState.classList.add('hidden'); | |
| if (emptyState) emptyState.classList.add('hidden'); | |
| summaryBar.style.display = 'none'; | |
| viewControls.style.display = 'none'; | |
| equityContainer.style.display = 'none'; | |
| document.getElementById('bt-equity-chart').innerHTML = ''; | |
| document.getElementById('bt-results-grid').style.display = 'none'; | |
| document.getElementById('bt-results-list').style.display = 'none'; | |
| document.getElementById('bt-trade-table-body').innerHTML = ''; | |
| if (btn) btn.disabled = true; | |
| try { | |
| const entry = document.getElementById('bt-entry-input').value; | |
| const exit = document.getElementById('bt-exit-input').value; | |
| const offset = document.getElementById('bt-offset-input').value; | |
| const candidates = results.map(r => ({ | |
| symbol: r.symbol, | |
| date: r.signal_date || r.date | |
| })); | |
| const urlParams = new URLSearchParams({ | |
| candidates: JSON.stringify(candidates), | |
| entry: entry, | |
| exit: exit, | |
| offset: offset | |
| }); | |
| const response = await fetch(`/api/backtest?${urlParams}`); | |
| const result = await response.json(); | |
| if (result.error) throw new Error(result.error); | |
| this.trades = result.trades || []; | |
| this.columnMap = result.column_map || {}; | |
| if (this.trades.length === 0) { | |
| status.textContent = 'No trades generated. Check your entry/exit conditions.'; | |
| return; | |
| } | |
| // Save candidates for stats | |
| const studyCandidates = this.trades | |
| .filter(t => t.symbol && t.date) | |
| .map(t => ({ symbol: t.symbol, date: t.date })); | |
| sessionStorage.setItem('last_candidates', JSON.stringify(studyCandidates)); | |
| // Update summary | |
| const summary = result.summary; | |
| document.getElementById('bt-win-rate').textContent = summary.win_rate + '%'; | |
| document.getElementById('bt-profit-factor').textContent = summary.profit_factor; | |
| document.getElementById('bt-expectancy').textContent = (summary.expectancy >= 0 ? '+' : '') + '$' + summary.expectancy.toFixed(2); | |
| document.getElementById('bt-avg-win-loss').textContent = `$${summary.avg_win.toFixed(0)} / $${Math.abs(summary.avg_loss).toFixed(0)}`; | |
| document.getElementById('bt-max-drawdown').textContent = summary.max_drawdown + '%'; | |
| const profitEl = document.getElementById('bt-total-profit'); | |
| profitEl.textContent = (summary.total_profit >= 0 ? '+' : '') + '$' + summary.total_profit.toFixed(2); | |
| document.getElementById('bt-card-profit').className = 'summary-card ' + (summary.total_profit >= 0 ? 'positive' : 'negative'); | |
| const equityEl = document.getElementById('bt-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'; | |
| viewControls.style.display = 'flex'; | |
| status.textContent = `Backtest complete: ${this.trades.length} trades`; | |
| // Render Equity Curve | |
| if (result.equity_curve && result.equity_curve.length > 0) { | |
| this.renderEquityChart(result.equity_curve); | |
| } | |
| // Cache for restoration | |
| this.cacheResult(result); | |
| // Initial render | |
| this.renderResults(); | |
| } catch (e) { | |
| status.textContent = `Backtest Error: ${e.message}`; | |
| if (emptyState) emptyState.classList.remove('hidden'); | |
| } finally { | |
| if (btn) btn.disabled = false; | |
| } | |
| }, | |
| switchView(view) { | |
| this.currentView = view; | |
| document.getElementById('bt-view-grid-btn').classList.toggle('active', view === 'grid'); | |
| document.getElementById('bt-view-list-btn').classList.toggle('active', view === 'list'); | |
| this.renderResults(); | |
| }, | |
| renderResults() { | |
| const grid = document.getElementById('bt-results-grid'); | |
| const list = document.getElementById('bt-results-list'); | |
| if (this.currentView === 'grid') { | |
| grid.style.display = 'grid'; | |
| list.style.display = 'none'; | |
| grid.innerHTML = ''; | |
| this.trades.forEach(trade => { | |
| const card = this.createTradeCard(trade); | |
| grid.appendChild(card); | |
| this.renderTradeChart(trade, `bt-chart-${trade.symbol}-${trade.date}`); | |
| }); | |
| } else { | |
| grid.style.display = 'none'; | |
| list.style.display = 'block'; | |
| this.renderListView(); | |
| this.updateSortIndicators(); | |
| } | |
| }, | |
| sortTable(column) { | |
| if (this.sortColumn === column) { | |
| this.sortDirection = this.sortDirection === 'asc' ? 'desc' : 'asc'; | |
| } else { | |
| this.sortColumn = column; | |
| this.sortDirection = column === 'date' ? 'desc' : 'asc'; | |
| } | |
| this.updateSortIndicators(); | |
| this.renderListView(); | |
| }, | |
| updateSortIndicators() { | |
| document.querySelectorAll('#bt-trade-table th[data-column]').forEach(th => { | |
| const col = th.getAttribute('data-column'); | |
| const baseName = col === 'gap_pct' ? 'Gap %' : col.charAt(0).toUpperCase() + col.slice(1); | |
| th.textContent = baseName; | |
| if (this.sortColumn === col) { | |
| th.textContent += this.sortDirection === 'asc' ? ' ▲' : ' ▼'; | |
| } | |
| }); | |
| }, | |
| renderListView() { | |
| const tbody = document.getElementById('bt-trade-table-body'); | |
| tbody.innerHTML = ''; | |
| const sorted = [...this.trades].sort((a, b) => { | |
| let valA = a[this.sortColumn]; | |
| let valB = b[this.sortColumn]; | |
| if (this.sortColumn === 'date') { | |
| valA = new Date(valA); | |
| valB = new Date(valB); | |
| } else if (this.sortColumn === 'status') { | |
| const statusA = a.profit >= 0 ? 0 : 1; | |
| const statusB = b.profit >= 0 ? 0 : 1; | |
| return this.sortDirection === 'asc' ? statusA - statusB : statusB - statusA; | |
| } else if (typeof valA === 'number' && typeof valB === 'number') { | |
| valA = valA ?? (this.sortDirection === 'asc' ? Infinity : -Infinity); | |
| valB = valB ?? (this.sortDirection === 'asc' ? Infinity : -Infinity); | |
| } else { | |
| valA = valA ?? ''; | |
| valB = valB ?? ''; | |
| } | |
| if (valA < valB) return this.sortDirection === 'asc' ? -1 : 1; | |
| if (valA > valB) return this.sortDirection === 'asc' ? 1 : -1; | |
| return 0; | |
| }); | |
| sorted.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 = () => this.toggleRowChart(idx, trade); | |
| tbody.appendChild(tr); | |
| }); | |
| }, | |
| toggleRowChart(idx, trade) { | |
| const tbody = document.getElementById('bt-trade-table-body'); | |
| const existing = document.getElementById(`bt-chart-row-${idx}`); | |
| if (existing) { existing.remove(); return; } | |
| const chartTr = document.createElement('tr'); | |
| chartTr.id = `bt-chart-row-${idx}`; | |
| chartTr.className = 'bt-chart-row'; | |
| chartTr.innerHTML = ` | |
| <td colspan="9"> | |
| <div class="bt-expanded-chart-container" id="bt-expanded-chart-${idx}"></div> | |
| </td> | |
| `; | |
| const rows = tbody.querySelectorAll('tr:not(.bt-chart-row)'); | |
| rows[idx].after(chartTr); | |
| this.renderTradeChart(trade, `bt-expanded-chart-${idx}`); | |
| }, | |
| async 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 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: this.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 d = new Date(unixSec * 1000); | |
| return `${String(d.getUTCHours()).padStart(2, '0')}:${String(d.getUTCMinutes()).padStart(2, '0')}`; | |
| }; | |
| 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 }); | |
| } | |
| 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 style="display:flex;align-items:center;justify-content:center;height:100%;color:var(--text-muted);font-size:0.75rem;">Error</div>'; | |
| } | |
| }, | |
| toNyTimestamp(unixTs) { | |
| if (!unixTs) return unixTs; | |
| 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 | |
| }); | |
| 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); | |
| }, | |
| renderEquityChart(data) { | |
| const container = document.getElementById('bt-equity-chart'); | |
| const wrapper = document.getElementById('bt-equity-chart-container'); | |
| if (!container || !data || data.length === 0) return; | |
| wrapper.style.display = 'block'; | |
| container.innerHTML = ''; | |
| this.equityChart = 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 = this.equityChart.addLineSeries({ color: '#3b82f6', lineWidth: 2, title: 'Equity' }); | |
| lineSeries.setData(data.map(d => ({ time: d.date, value: d.equity }))); | |
| this.equityChart.timeScale().fitContent(); | |
| this.equityResizeObserver = new ResizeObserver(entries => { | |
| if (entries[0]) this.equityChart.applyOptions({ width: entries[0].contentRect.width }); | |
| }); | |
| this.equityResizeObserver.observe(container); | |
| }, | |
| 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="bt-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; | |
| }, | |
| exportCsv() { | |
| if (!this.trades || this.trades.length === 0) return; | |
| const sorted = [...this.trades].sort((a, b) => new Date(a.date) - new Date(b.date)); | |
| const originalHeaders = Object.keys(sorted[0]); | |
| const headerMap = {}; | |
| originalHeaders.forEach(h => { headerMap[h] = this.columnMap[h] || h; }); | |
| const headers = originalHeaders.map(h => headerMap[h]); | |
| const rows = sorted.map(trade => { | |
| return originalHeaders.map(header => { | |
| let val = trade[header]; | |
| if (val === undefined || val === null) val = ""; | |
| if (typeof val === 'number' && !Number.isInteger(val)) val = val.toFixed(4); | |
| 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", `scanner_backtest_${new Date().toISOString().split('T')[0]}_trades.csv`); | |
| link.style.visibility = 'hidden'; | |
| document.body.appendChild(link); | |
| link.click(); | |
| document.body.removeChild(link); | |
| }, | |
| saveFormValues() { | |
| const values = { | |
| entry: document.getElementById('bt-entry-input').value, | |
| exit: document.getElementById('bt-exit-input').value, | |
| offset: document.getElementById('bt-offset-input').value | |
| }; | |
| localStorage.setItem('backtest_form_values', JSON.stringify(values)); | |
| }, | |
| cacheResult(result) { | |
| sessionStorage.setItem('backtest_last_result', JSON.stringify(result)); | |
| } | |
| }; | |
| function setView(view) { | |
| activeView = view; | |
| localStorage.setItem('scanner_view_mode', view); | |
| document.getElementById('view-grid-btn').classList.toggle('active', view === 'grid'); | |
| document.getElementById('view-list-btn').classList.toggle('active', view === 'list'); | |
| document.getElementById('view-calendar-btn').classList.toggle('active', view === 'calendar'); | |
| document.getElementById('view-stats-btn').classList.toggle('active', view === 'stats'); | |
| document.getElementById('view-backtest-btn').classList.toggle('active', view === 'backtest'); | |
| // Close dropdown when selecting a view | |
| document.getElementById('view-dropdown-menu').classList.remove('open'); | |
| const gridEl = document.getElementById('results-grid'); | |
| const listEl = document.getElementById('results-list-view'); | |
| const calendarEl = document.getElementById('calendar-view'); | |
| const statsEl = document.getElementById('stats-view'); | |
| const backtestEl = document.getElementById('backtest-view'); | |
| if (view === 'calendar') { | |
| gridEl.classList.add('hidden'); | |
| listEl.classList.add('hidden'); | |
| statsEl.classList.add('hidden'); | |
| backtestEl.classList.add('hidden'); | |
| calendarEl.classList.remove('hidden'); | |
| ScannerCalendar.loadData(); | |
| } else if (view === 'stats') { | |
| gridEl.classList.add('hidden'); | |
| listEl.classList.add('hidden'); | |
| calendarEl.classList.add('hidden'); | |
| backtestEl.classList.add('hidden'); | |
| statsEl.classList.remove('hidden'); | |
| const hasData = !!ScannerStats.studyData; | |
| document.getElementById('stats-empty-state').classList.toggle('hidden', hasData); | |
| document.getElementById('stats-no-results-state').classList.toggle('hidden', results.length > 0 || hasData); | |
| if (!hasData && results.length > 0) { | |
| document.getElementById('stats-offset-input').focus(); | |
| } | |
| } else if (view === 'backtest') { | |
| gridEl.classList.add('hidden'); | |
| listEl.classList.add('hidden'); | |
| calendarEl.classList.add('hidden'); | |
| statsEl.classList.add('hidden'); | |
| backtestEl.classList.remove('hidden'); | |
| const hasData = !!ScannerBacktest.trades.length; | |
| document.getElementById('bt-empty-state').classList.toggle('hidden', hasData); | |
| document.getElementById('bt-no-results-state').classList.toggle('hidden', results.length > 0 || hasData); | |
| if (!hasData && results.length > 0) { | |
| document.getElementById('bt-entry-input').focus(); | |
| } | |
| } else if (view === 'list') { | |
| gridEl.classList.add('hidden'); | |
| calendarEl.classList.add('hidden'); | |
| statsEl.classList.add('hidden'); | |
| backtestEl.classList.add('hidden'); | |
| listEl.classList.remove('hidden'); | |
| renderListView(); | |
| } else { | |
| calendarEl.classList.add('hidden'); | |
| statsEl.classList.add('hidden'); | |
| backtestEl.classList.add('hidden'); | |
| listEl.classList.add('hidden'); | |
| gridEl.classList.remove('hidden'); | |
| if (!results || results.length === 0) runScan(); | |
| } | |
| } | |
| function navigateMonth(delta) { | |
| ScannerCalendar.currentMonth += delta; | |
| if (ScannerCalendar.currentMonth > 12) { | |
| ScannerCalendar.currentMonth = 1; | |
| ScannerCalendar.currentYear++; | |
| } else if (ScannerCalendar.currentMonth < 1) { | |
| ScannerCalendar.currentMonth = 12; | |
| ScannerCalendar.currentYear--; | |
| } | |
| ScannerCalendar.loadData(); | |
| } | |
| function setSidebarTF(tf, btn) { | |
| ScannerCalendar.chartTimeframe = tf; | |
| btn.parentElement.querySelectorAll('button').forEach(b => b.classList.remove('active')); | |
| btn.classList.add('active'); | |
| if (ScannerCalendar.selectedSymbol) { | |
| ScannerCalendar.loadChart(ScannerCalendar.selectedSymbol); | |
| } | |
| } | |
| // Detect dates from filter expression | |
| function detectDateFrom(expr) { | |
| const m = expr.match(/\bdate\s*(?:>=|>)\s*'(\d{4}-\d{2}-\d{2})'/i); | |
| return m ? m[1] : null; | |
| } | |
| function detectDateTo(expr) { | |
| const m = expr.match(/\bdate\s*(?:<=|<)\s*'(\d{4}-\d{2}-\d{2})'/i); | |
| return m ? m[1] : null; | |
| } | |
| function detectDate(expr) { | |
| const m = expr.match(/\bdate\s*(?:==|=)\s*'(\d{4}-\d{2}-\d{2})'/i); | |
| return m ? m[1] : null; | |
| } | |
| function formatDateRange(fromStr, toStr) { | |
| const parts = []; | |
| if (fromStr) { | |
| const p = fromStr.split('-'); | |
| parts.push(`${p[1]}/${p[2]}`); | |
| } | |
| if (toStr) { | |
| const p = toStr.split('-'); | |
| parts.push(`${p[1]}/${p[2]}`); | |
| } | |
| return parts.join(' → '); | |
| } | |
| function updateDateBadge(fromStr, toStr) { | |
| const badge = document.getElementById('date-badge'); | |
| const text = document.getElementById('date-badge-text'); | |
| if (fromStr || toStr) { | |
| text.textContent = formatDateRange(fromStr, toStr); | |
| badge.classList.add('active'); | |
| } else { | |
| badge.classList.remove('active'); | |
| } | |
| } | |
| function clearDateFilter() { | |
| document.getElementById('date-from').value = ''; | |
| document.getElementById('date-to').value = ''; | |
| const filterInput = document.getElementById('filter-input'); | |
| let expr = filterInput.value.trim(); | |
| expr = expr.replace(/\bdate\s*(?:==|>=|<=|>|<)\s*'\d{4}-\d{2}-\d{2}'\s*(and\s*)?/gi, ''); | |
| expr = expr.replace(/\s*(and\s+)?\bdate\s*(?:==|>=|<=|>|<)\s*'\d{4}-\d{2}-\d{2}'/gi, ''); | |
| expr = expr.replace(/\band\s+and\b/gi, 'and').replace(/^\s*and\s+/gi, '').replace(/\s+and\s*$/gi, ''); | |
| filterInput.value = expr.trim(); | |
| updateDateBadge(null, null); | |
| runScan(); | |
| } | |
| function updateFilterFromDatePicker() { | |
| const fromVal = document.getElementById('date-from').value; | |
| const toVal = document.getElementById('date-to').value; | |
| const filterInput = document.getElementById('filter-input'); | |
| let expr = filterInput.value.trim(); | |
| if (!fromVal && !toVal) { | |
| // Remove all date conditions | |
| expr = expr.replace(/\bdate\s*(?:==|>=|<=|>|<)\s*'\d{4}-\d{2}-\d{2}'\s*(and\s*)?/gi, ''); | |
| expr = expr.replace(/\s*(and\s+)?\bdate\s*(?:==|>=|<=|>|<)\s*'\d{4}-\d{2}-\d{2}'/gi, ''); | |
| expr = expr.replace(/\band\s+and\b/gi, 'and').replace(/^\s*and\s+/gi, '').replace(/\s+and\s*$/gi, ''); | |
| filterInput.value = expr.trim(); | |
| updateDateBadge(null, null); | |
| runScan(); | |
| return; | |
| } | |
| // Remove existing date conditions | |
| expr = expr.replace(/\bdate\s*(?:==|>=|<=|>|<)\s*'\d{4}-\d{2}-\d{2}'\s*(and\s*)?/gi, ''); | |
| expr = expr.replace(/\s*(and\s+)?\bdate\s*(?:==|>=|<=|>|<)\s*'\d{4}-\d{2}-\d{2}'/gi, ''); | |
| expr = expr.replace(/\band\s+and\b/gi, 'and').replace(/^\s*and\s+/gi, '').replace(/\s+and\s*$/gi, ''); | |
| expr = expr.trim(); | |
| // Build new date conditions | |
| const dateConditions = []; | |
| if (fromVal) { | |
| dateConditions.push(`date >= '${fromVal}'`); | |
| } | |
| if (toVal) { | |
| dateConditions.push(`date <= '${toVal}'`); | |
| } | |
| const dateExpr = dateConditions.join(' and '); | |
| if (expr) { | |
| expr = `${dateExpr} and ${expr}`; | |
| } else { | |
| expr = dateExpr; | |
| } | |
| filterInput.value = expr; | |
| updateDateBadge(fromVal, toVal); | |
| runScan(); | |
| } | |
| function syncDatePickerFromFilter(expr) { | |
| // Check for exact date first | |
| const exactDate = detectDate(expr); | |
| if (exactDate) { | |
| document.getElementById('date-from').value = exactDate; | |
| document.getElementById('date-to').value = exactDate; | |
| updateDateBadge(exactDate, exactDate); | |
| return; | |
| } | |
| // Check for date range | |
| const fromDate = detectDateFrom(expr); | |
| const toDate = detectDateTo(expr); | |
| document.getElementById('date-from').value = fromDate || ''; | |
| document.getElementById('date-to').value = toDate || ''; | |
| updateDateBadge(fromDate, toDate); | |
| } | |
| document.addEventListener('DOMContentLoaded', async () => { | |
| const savedFilter = localStorage.getItem('scanner_last_filter'); | |
| const filterInput = document.getElementById('filter-input'); | |
| // Date pickers start empty | |
| if (document.getElementById('date-from')) { | |
| document.getElementById('date-from').value = ''; | |
| } | |
| if (document.getElementById('date-to')) { | |
| document.getElementById('date-to').value = ''; | |
| } | |
| if (savedFilter) { | |
| filterInput.value = savedFilter; | |
| } else { | |
| filterInput.value = `date > '2026-01-01' and volume > 5_000_000 and rel_vol > 5 sort date desc`; | |
| } | |
| // Sync date pickers and badge to current filter | |
| syncDatePickerFromFilter(filterInput.value); | |
| updateTfButtons(); | |
| // View dropdown handlers | |
| window.toggleViewDropdown = function() { | |
| document.getElementById('view-dropdown-menu').classList.toggle('open'); | |
| }; | |
| // Close dropdown when clicking outside | |
| document.addEventListener('click', (e) => { | |
| const dropdown = document.querySelector('.view-dropdown'); | |
| if (dropdown && !dropdown.contains(e.target)) { | |
| document.getElementById('view-dropdown-menu').classList.remove('open'); | |
| } | |
| }); | |
| await refreshFiltersDropdown(); | |
| // Pre-load watchlist to show correct state on buttons | |
| window.scannerWatchlist = await FinancialAPI.loadWatchlist(); | |
| const cachedResults = sessionStorage.getItem('scanner_last_results'); | |
| if (cachedResults && activeView === 'grid') { | |
| renderResults(JSON.parse(cachedResults)); | |
| const count = JSON.parse(cachedResults).length; | |
| document.getElementById('status').innerText = `Restored ${count} results from session`; | |
| } | |
| // Restore backtest form values | |
| const btSaved = localStorage.getItem('backtest_form_values'); | |
| if (btSaved) { | |
| try { | |
| const vals = JSON.parse(btSaved); | |
| if (vals.entry) document.getElementById('bt-entry-input').value = vals.entry; | |
| if (vals.exit) document.getElementById('bt-exit-input').value = vals.exit; | |
| if (vals.offset) document.getElementById('bt-offset-input').value = vals.offset; | |
| } catch (e) { /* ignore */ } | |
| } | |
| // Restore last backtest result | |
| const btCached = sessionStorage.getItem('backtest_last_result'); | |
| if (btCached) { | |
| try { | |
| const btResult = JSON.parse(btCached); | |
| if (btResult && btResult.trades) { | |
| ScannerBacktest.trades = btResult.trades; | |
| ScannerBacktest.columnMap = btResult.column_map || {}; | |
| const summary = btResult.summary; | |
| document.getElementById('bt-win-rate').textContent = summary.win_rate + '%'; | |
| document.getElementById('bt-profit-factor').textContent = summary.profit_factor; | |
| document.getElementById('bt-expectancy').textContent = (summary.expectancy >= 0 ? '+' : '') + '$' + summary.expectancy.toFixed(2); | |
| document.getElementById('bt-avg-win-loss').textContent = `$${summary.avg_win.toFixed(0)} / $${Math.abs(summary.avg_loss).toFixed(0)}`; | |
| document.getElementById('bt-max-drawdown').textContent = summary.max_drawdown + '%'; | |
| const profitEl = document.getElementById('bt-total-profit'); | |
| profitEl.textContent = (summary.total_profit >= 0 ? '+' : '') + '$' + summary.total_profit.toFixed(2); | |
| document.getElementById('bt-card-profit').className = 'summary-card ' + (summary.total_profit >= 0 ? 'positive' : 'negative'); | |
| const equityEl = document.getElementById('bt-final-equity'); | |
| equityEl.textContent = '$' + summary.final_equity.toLocaleString(); | |
| equityEl.parentElement.className = 'summary-card ' + (summary.final_equity >= summary.initial_capital ? 'positive' : 'negative'); | |
| document.getElementById('bt-summary-bar').style.display = 'grid'; | |
| document.getElementById('bt-view-controls').style.display = 'flex'; | |
| document.getElementById('bt-empty-state').classList.add('hidden'); | |
| if (btResult.equity_curve && btResult.equity_curve.length > 0) { | |
| ScannerBacktest.renderEquityChart(btResult.equity_curve); | |
| } | |
| ScannerBacktest.renderResults(); | |
| } | |
| } catch (e) { /* ignore */ } | |
| } | |
| }); | |
| async function refreshFiltersDropdown() { | |
| savedFilters = await FinancialAPI.loadFilters(); | |
| const select = document.getElementById('filter-select'); | |
| if (!select) return; | |
| select.innerHTML = '<option value="">-- Select Preset --</option>'; | |
| savedFilters.forEach((f, index) => { | |
| const opt = document.createElement('option'); | |
| opt.value = index; | |
| opt.textContent = f.name; | |
| select.appendChild(opt); | |
| }); | |
| } | |
| function selectFilter(indexStr) { | |
| if (indexStr === "") return; | |
| const idx = parseInt(indexStr, 10); | |
| if (!isNaN(idx) && savedFilters[idx]) { | |
| document.getElementById('filter-input').value = savedFilters[idx].expression; | |
| runScan(); | |
| } | |
| document.getElementById('filter-select').value = ""; | |
| } | |
| // Preset Modal Functions | |
| function openPresetModal() { | |
| document.getElementById('preset-modal').classList.remove('hidden'); | |
| renderPresetList(); | |
| } | |
| function closePresetModal() { | |
| document.getElementById('preset-modal').classList.add('hidden'); | |
| } | |
| function renderPresetList() { | |
| const list = document.getElementById('preset-list'); | |
| if (!savedFilters.length) { | |
| list.innerHTML = '<div class="text-dim" style="padding: 12px;">No presets saved</div>'; | |
| return; | |
| } | |
| list.innerHTML = savedFilters.map((f, idx) => ` | |
| <div class="preset-item"> | |
| <div class="preset-info"> | |
| <strong>${f.name}</strong> | |
| <span class="text-dim" style="font-size: 11px; display: block; max-width: 250px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${f.expression}</span> | |
| </div> | |
| <div class="preset-actions"> | |
| <button class="btn btn-secondary btn-small" onclick="loadPreset(${idx})">Load</button> | |
| <button class="btn btn-danger btn-small" onclick="deletePreset('${f.name}')">Delete</button> | |
| </div> | |
| </div> | |
| `).join(''); | |
| } | |
| function loadPreset(idx) { | |
| if (savedFilters[idx]) { | |
| document.getElementById('filter-input').value = savedFilters[idx].expression; | |
| closePresetModal(); | |
| runScan(); | |
| } | |
| } | |
| async function addNewPreset() { | |
| const expression = document.getElementById('filter-input').value.trim(); | |
| if (!expression) { | |
| alert("Filter expression is empty. Enter a filter first."); | |
| return; | |
| } | |
| const input = document.getElementById('new-preset-input'); | |
| const name = input.value.trim(); | |
| if (!name) { | |
| alert("Enter a name for the preset."); | |
| return; | |
| } | |
| await FinancialAPI.manageFilters('save', name, expression); | |
| input.value = ''; | |
| await refreshFiltersDropdown(); | |
| renderPresetList(); | |
| } | |
| async function deletePreset(name) { | |
| const confirmDel = confirm(`Delete preset "${name}"?`); | |
| if (!confirmDel) return; | |
| await FinancialAPI.manageFilters('delete', name); | |
| await refreshFiltersDropdown(); | |
| renderPresetList(); | |
| } | |
| function copySymbols() { | |
| const symbols = (results || []) | |
| .map(r => r.symbol) | |
| .filter(Boolean) | |
| .join(','); | |
| if (!symbols) return; | |
| const btn = document.getElementById('copy-symbols-btn'); | |
| navigator.clipboard.writeText(symbols).then(() => { | |
| const original = btn.textContent; | |
| btn.textContent = 'Copied!'; | |
| setTimeout(() => { btn.textContent = original; }, 1500); | |
| }); | |
| } | |
| function updateTfButtons() { | |
| document.querySelectorAll('.tf-btn').forEach(btn => { | |
| btn.classList.toggle('active', btn.dataset.tf === globalTimeframe); | |
| }); | |
| } | |
| async function setGlobalTimeframe(tf, btn) { | |
| globalTimeframe = tf; | |
| localStorage.setItem('scanner_global_tf', tf); | |
| updateTfButtons(); | |
| document.querySelectorAll('stock-chart-card').forEach(card => { | |
| card.setAttribute('timeframe', tf); | |
| }); | |
| } | |
| async function runScan() { | |
| const input = document.getElementById('filter-input').value.trim(); | |
| const status = document.getElementById('status'); | |
| localStorage.setItem('scanner_last_filter', input); | |
| if (activeView === 'calendar') { | |
| ScannerCalendar.loadData(); | |
| return; | |
| } else if (activeView === 'stats') { | |
| ScannerStats.studyData = null; | |
| ScannerStats.run(); | |
| return; | |
| } else if (activeView === 'backtest') { | |
| ScannerBacktest.trades = []; | |
| ScannerBacktest.run(); | |
| return; | |
| } | |
| const grid = document.getElementById('results-grid'); | |
| status.innerText = `Scanning...`; | |
| grid.innerHTML = ''; | |
| try { | |
| const params = { timeframe: globalTimeframe }; | |
| if (/^[A-Z0-9,\s\n\t]+$/i.test(input) && !/(\s(and|or|not)\s|[><=])/i.test(input)) { | |
| params.symbols = input; | |
| } else { | |
| params.filter = input; | |
| } | |
| const data = await FinancialAPI.fetchScanResults(params); | |
| status.innerText = `[${globalTimeframe}] Found ${data.count} result${data.count !== 1 ? 's' : ''}`; | |
| sessionStorage.setItem('scanner_last_results', JSON.stringify(data.results)); | |
| renderResults(data.results); | |
| } catch (e) { | |
| status.innerText = 'Error: ' + e.message; | |
| } | |
| } | |
| function renderResults(data) { | |
| results = data || []; | |
| // Clear stats cache when results change | |
| ScannerStats.studyData = null; | |
| const statsViz = document.getElementById('stats-viz-wrapper'); | |
| if (statsViz) statsViz.style.display = 'none'; | |
| const statsTable = document.getElementById('stats-raw-table'); | |
| if (statsTable) statsTable.innerHTML = ''; | |
| const grid = document.getElementById('results-grid'); | |
| const copyBtn = document.getElementById('copy-symbols-btn'); | |
| if (copyBtn) copyBtn.style.display = results.length > 0 ? '' : 'none'; | |
| grid.innerHTML = ''; | |
| results.forEach(res => { | |
| const card = document.createElement('stock-chart-card'); | |
| card.setAttribute('symbol', res.symbol); | |
| card.setAttribute('timeframe', globalTimeframe); | |
| card.setAttribute('show-actions', ''); | |
| card.setAttribute('context', 'scanner'); | |
| card.style.cursor = 'default'; | |
| card.onclick = null; | |
| if (res.date) { | |
| card.setAttribute('signal-date', res.date); | |
| let markerTime = res.date; | |
| if (!['1D', '1Y'].includes(globalTimeframe)) { | |
| markerTime = Math.floor(new Date(res.date + 'T00:00:00Z').getTime() / 1000); | |
| } | |
| card.setAttribute('markers', JSON.stringify([{ time: markerTime, position: 'aboveBar', color: '#ffffff', shape: 'arrowDown', text: 'SIGNAL' }])); | |
| } | |
| const inWatchlist = isInWatchlist(res.symbol); | |
| card.innerHTML = `<div slot="actions"><button class="unified-chart-action-btn ${inWatchlist ? 'added' : ''}" onclick="addToWatchlist('${res.symbol}', this)" data-symbol="${res.symbol}">★</button></div>`; | |
| grid.appendChild(card); | |
| }); | |
| } | |
| function renderListView() { | |
| if (!results || results.length === 0) { | |
| document.getElementById('results-list-header').innerHTML = ''; | |
| document.getElementById('results-list-body').innerHTML = '<tr><td colspan="100" style="text-align:center;color:var(--text-muted);padding:40px;">No results. Run a scan first.</td></tr>'; | |
| return; | |
| } | |
| // Collect all numeric columns from first result (they should be consistent) | |
| const excludeKeys = new Set(['symbol', 'date', 'signal_date', 'daily_bars']); | |
| const sampleKeys = Object.keys(results[0]).filter(k => !excludeKeys.has(k)); | |
| // Build header | |
| const headers = ['<th class="symbol-cell" style="cursor:pointer" onclick="sortListView(\'symbol\')">Symbol</th>']; | |
| headers.push('<th style="cursor:pointer" onclick="sortListView(\'date\')">Date</th>'); | |
| sampleKeys.forEach(k => { | |
| const label = k.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); | |
| headers.push(`<th style="cursor:pointer" onclick="sortListView('${k}')">${label}</th>`); | |
| }); | |
| headers.push('<th>Actions</th>'); | |
| document.getElementById('results-list-header').innerHTML = headers.join(''); | |
| // Build rows | |
| const body = document.getElementById('results-list-body'); | |
| body.innerHTML = results.map(res => { | |
| const cells = []; | |
| cells.push(`<td class="symbol-cell">${res.symbol}</td>`); | |
| cells.push(`<td>${res.date || res.signal_date || '--'}</td>`); | |
| sampleKeys.forEach(k => { | |
| const val = res[k]; | |
| let display = val != null ? (typeof val === 'number' ? (Number.isFinite(val) ? (Math.abs(val) > 1000 ? val.toLocaleString() : val.toFixed(2)) : val) : val) : '--'; | |
| const cls = (typeof val === 'number' && val > 0) ? 'positive' : (typeof val === 'number' && val < 0) ? 'negative' : ''; | |
| cells.push(`<td class="${cls}">${display}</td>`); | |
| }); | |
| const inWatchlist = isInWatchlist(res.symbol); | |
| cells.push(`<td><button class="unified-chart-action-btn ${inWatchlist ? 'added' : ''}" onclick="event.stopPropagation(); addToWatchlist('${res.symbol}', this)">★</button></td>`); | |
| return `<tr>${cells.join('')}</tr>`; | |
| }).join(''); | |
| } | |
| let listViewSortKey = null; | |
| let listViewSortAsc = true; | |
| function sortListView(key) { | |
| if (listViewSortKey === key) { | |
| listViewSortAsc = !listViewSortAsc; | |
| } else { | |
| listViewSortKey = key; | |
| listViewSortAsc = true; | |
| } | |
| results.sort((a, b) => { | |
| let va = a[key], vb = b[key]; | |
| if (va == null) return 1; | |
| if (vb == null) return -1; | |
| if (typeof va === 'number' && typeof vb === 'number') { | |
| return listViewSortAsc ? va - vb : vb - va; | |
| } | |
| va = String(va).toLowerCase(); | |
| vb = String(vb).toLowerCase(); | |
| if (va < vb) return listViewSortAsc ? -1 : 1; | |
| if (va > vb) return listViewSortAsc ? 1 : -1; | |
| return 0; | |
| }); | |
| renderListView(); | |
| } | |
| function isInWatchlist(symbol) { | |
| return window.scannerWatchlist?.some(s => s.symbol === symbol) || false; | |
| } | |
| async function addToWatchlist(symbol, btn) { | |
| try { | |
| const inWatchlist = isInWatchlist(symbol); | |
| if (inWatchlist) { | |
| await FinancialAPI.manageWatchlist('remove', symbol); | |
| window.scannerWatchlist = window.scannerWatchlist.filter(s => s.symbol !== symbol); | |
| } else { | |
| await FinancialAPI.manageWatchlist('add', symbol); | |
| window.scannerWatchlist.push({ symbol }); | |
| } | |
| // Update visual state explicitly | |
| if (isInWatchlist(symbol)) { | |
| btn.classList.add('added'); | |
| } else { | |
| btn.classList.remove('added'); | |
| } | |
| } catch (e) { | |
| alert('Connection error'); | |
| } | |
| } | |
| function closeInspector() { | |
| document.getElementById('inspector-container').classList.remove('active'); | |
| document.getElementById('inspector-overlay').classList.remove('active'); | |
| } | |
| function openInspector(symbol, data = {}) { | |
| InspectorPanel.open(symbol, data); | |
| document.getElementById('inspector-container').classList.add('active'); | |
| document.getElementById('inspector-overlay').classList.add('active'); | |
| } | |
| document.getElementById('filter-input').addEventListener('keydown', (e) => { | |
| if (e.key === 'Enter' && !e.shiftKey) { | |
| e.preventDefault(); | |
| runScan(); | |
| } | |
| }); | |
| let _badgeTimeout; | |
| document.getElementById('filter-input').addEventListener('input', () => { | |
| clearTimeout(_badgeTimeout); | |
| _badgeTimeout = setTimeout(() => { | |
| syncDatePickerFromFilter(document.getElementById('filter-input').value); | |
| }, 500); | |
| }); | |
| if (document.getElementById('date-from')) { | |
| document.getElementById('date-from').addEventListener('change', updateFilterFromDatePicker); | |
| } | |
| if (document.getElementById('date-to')) { | |
| document.getElementById('date-to').addEventListener('change', updateFilterFromDatePicker); | |
| } | |
| // Backtest input Enter key handlers | |
| ['bt-entry-input', 'bt-exit-input', 'bt-offset-input'].forEach(id => { | |
| const el = document.getElementById(id); | |
| if (el) el.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); ScannerBacktest.run(); } }); | |
| }); | |
| </script> | |
| <script src="/static/js/analyze-modal.js"></script> | |
| <script src="/static/js/nav.js"></script> | |
| </body> | |
| </html> |