stocks / static /scan.html
Arrechenash's picture
Initial Commit
da67450
<!DOCTYPE html>
<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">&times;</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)">&#8249;</button>
<button class="cal-nav-btn" onclick="navigateMonth(1)">&#8250;</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>