const qs = (s) => document.querySelector(s); const ptbody = qs('#positions-table tbody'); const pthead = qs('#positions-table thead'); const aggregateCheckbox = qs('#aggregate-same-day'); const refreshButton = qs('#refresh-lots'); let latestPositions = []; let sortState = []; let filterState = {}; let aggregateSameDay = true; let columnOrder = []; let dragColumnKey = null; let isRefreshing = false; const STORAGE_KEY = 'ib-dashboard-open-lots-table-state'; const numericColumns = new Set([ 'qty', 'avg_price', 'cost_basis', 'market_price', 'market_value', 'gain_dollars', 'gain_percent', 'cagr_percent', 'days_since_buy', ]); const dateColumns = new Set(['buy_time']); const columnDefs = [ { key: 'security_type', label: 'Type', filter: 'select', options: [{ value: '', label: 'All' }, { value: 'STK', label: 'STK' }, { value: 'OPT', label: 'OPT' }] }, { key: 'display_symbol', label: 'Security', filter: 'text', placeholder: 'AM*' }, { key: 'currency', label: 'Currency', filter: 'select-dynamic', options: [{ value: '', label: 'All' }] }, { key: 'underlying_symbol', label: 'Underlying', filter: 'text', placeholder: 'A*' }, { key: 'qty', label: 'Qty', filter: 'text', placeholder: '>100', numeric: true }, { key: 'avg_price', label: 'Avg Price', filter: 'text', placeholder: '<20', numeric: true }, { key: 'cost_basis', label: 'Cost Basis', filter: 'text', placeholder: '>1000', numeric: true }, { key: 'market_price', label: 'Market Price', filter: 'text', placeholder: '<10', numeric: true }, { key: 'market_value', label: 'Market Value', filter: 'text', placeholder: '>5000', numeric: true }, { key: 'gain_dollars', label: 'Gain $', filter: 'text', placeholder: '>0', numeric: true }, { key: 'gain_percent', label: 'Gain %', filter: 'text', placeholder: '>10', numeric: true }, { key: 'cagr_percent', label: 'CAGR %', filter: 'text', placeholder: '>5', numeric: true }, { key: 'days_since_buy', label: 'Days Since Buy', filter: 'text', placeholder: '>365', numeric: true }, { key: 'ltcg', label: 'LTCG', filter: 'select', options: [{ value: '', label: 'All' }, { value: 'X', label: 'Long-term' }, { value: '!X', label: 'Short-term' }] }, { key: 'buy_time', label: 'Buy Time', filter: 'text', placeholder: '>2025-01-01', date: true }, ]; const columnAccessors = { security_type: (p) => p.security_type ?? '', display_symbol: (p) => p.display_symbol ?? p.symbol ?? '', currency: (p) => p.currency ?? '', underlying_symbol: (p) => p.underlying_symbol ?? '', qty: (p) => p.qty, avg_price: (p) => p.avg_price, cost_basis: (p) => p.cost_basis, market_price: (p) => p.market_price, market_value: (p) => p.market_value, gain_dollars: (p) => p.gain_dollars, gain_percent: (p) => p.gain_percent, cagr_percent: (p) => p.cagr_percent, days_since_buy: (p) => p.days_since_buy, ltcg: (p) => p.ltcg ?? '', buy_time: (p) => p.buy_time ?? '', }; const defaultColumnOrder = columnDefs.map((column) => column.key); function getActiveColumns() { const ordered = columnOrder.length ? columnOrder : defaultColumnOrder; return ordered .map((key) => columnDefs.find((column) => column.key === key)) .filter(Boolean); } function asNumber(value) { if (value == null || value === '') return null; const parsed = Number(value); return Number.isFinite(parsed) ? parsed : null; } function formatNumber(value, digits = 2) { const num = asNumber(value); if (num == null) return ''; return num.toLocaleString(undefined, { minimumFractionDigits: digits, maximumFractionDigits: digits, }); } function formatPercent(value) { const num = asNumber(value); if (num == null) return ''; return `${formatNumber(num, 2)}%`; } function escapeHtml(value) { return String(value ?? '') .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } function saveTableState() { try { localStorage.setItem(STORAGE_KEY, JSON.stringify({ sortState, filterState, aggregateSameDay, columnOrder, })); } catch (error) { console.warn('Failed to save table state', error); } } function loadTableState() { try { const raw = localStorage.getItem(STORAGE_KEY); if (!raw) return; const parsed = JSON.parse(raw); if (Array.isArray(parsed.sortState)) { sortState = parsed.sortState.filter((rule) => rule && typeof rule.column === 'string' && typeof rule.direction === 'string'); } if (parsed.filterState && typeof parsed.filterState === 'object') { filterState = Object.fromEntries( Object.entries(parsed.filterState).filter(([, value]) => typeof value === 'string' && value.trim() !== '') ); } if (typeof parsed.aggregateSameDay === 'boolean') { aggregateSameDay = parsed.aggregateSameDay; } if (Array.isArray(parsed.columnOrder)) { const valid = parsed.columnOrder.filter((key) => defaultColumnOrder.includes(key)); if (valid.length) { const missing = defaultColumnOrder.filter((key) => !valid.includes(key)); columnOrder = [...valid, ...missing]; } } } catch (error) { console.warn('Failed to load table state', error); } } async function fetchJson(path) { const res = await fetch(path); if (!res.ok) throw new Error('Network response not ok'); return res.json(); } function compareValues(a, b) { const aMissing = a == null || a === ''; const bMissing = b == null || b === ''; if (aMissing && bMissing) return 0; if (aMissing) return 1; if (bMissing) return -1; if (typeof a === 'number' && typeof b === 'number') return a - b; return String(a).localeCompare(String(b), undefined, { numeric: true, sensitivity: 'base' }); } function escapeRegex(text) { return text.replace(/[.+^${}()|[\]\\]/g, '\\$&'); } function wildcardToRegex(pattern) { const escaped = escapeRegex(pattern).replace(/\*/g, '.*').replace(/\?/g, '.'); return new RegExp(`^${escaped}$`, 'i'); } function parseComparator(expression) { const match = String(expression).trim().match(/^(<=|>=|<|>|=)\s*(.+)$/); if (!match) return null; return { operator: match[1], value: match[2].trim() }; } function compareByOperator(left, operator, right) { if (left == null || right == null) return false; switch (operator) { case '<': return left < right; case '<=': return left <= right; case '>': return left > right; case '>=': return left >= right; case '=': return left === right; default: return false; } } function matchesFilter(column, rawValue, expression) { const filter = String(expression ?? '').trim(); if (!filter) return true; const value = rawValue ?? ''; if (column === 'ltcg' && filter === '!X') { return String(value).trim().toUpperCase() !== 'X'; } if (numericColumns.has(column)) { const numericValue = asNumber(value); const comparator = parseComparator(filter); if (comparator) return compareByOperator(numericValue, comparator.operator, asNumber(comparator.value)); return numericValue === asNumber(filter); } if (dateColumns.has(column)) { const normalizedValue = String(value); const comparator = parseComparator(filter); if (comparator) return compareByOperator(normalizedValue, comparator.operator, comparator.value); return wildcardToRegex(filter).test(normalizedValue); } return wildcardToRegex(filter).test(String(value)); } function filterPositions(list) { return list.filter((row) => Object.entries(filterState).every(([column, expression]) => { const accessor = columnAccessors[column]; if (!accessor) return true; return matchesFilter(column, accessor(row), expression); })); } function toDateOnly(value) { return String(value ?? '').slice(0, 10); } function aggregateLots(list) { if (!aggregateSameDay) return [...list]; const groups = new Map(); for (const lot of list) { const buyDate = toDateOnly(lot.buy_time); const key = `${lot.instrument_key ?? ''}|${buyDate}`; const existing = groups.get(key); if (!existing) { groups.set(key, { ...lot, buy_time: buyDate || lot.buy_time, }); continue; } existing.qty = (existing.qty ?? 0) + (lot.qty ?? 0); existing.cost_basis = (existing.cost_basis ?? 0) + (lot.cost_basis ?? 0); existing.market_value = (existing.market_value ?? 0) + (lot.market_value ?? 0); existing.gain_dollars = (existing.gain_dollars ?? 0) + (lot.gain_dollars ?? 0); existing.days_since_buy = Math.min( asNumber(existing.days_since_buy) ?? Number.MAX_SAFE_INTEGER, asNumber(lot.days_since_buy) ?? Number.MAX_SAFE_INTEGER, ); existing.ltcg = existing.ltcg === 'X' && lot.ltcg === 'X' ? 'X' : ''; const qty = asNumber(existing.qty); const costBasis = asNumber(existing.cost_basis); const marketValue = asNumber(existing.market_value); existing.avg_price = qty ? Math.abs(costBasis / qty) : null; existing.market_price = qty ? Math.abs(marketValue / qty) : null; existing.gain_percent = costBasis ? (existing.gain_dollars / Math.abs(costBasis)) * 100 : null; const cagrBase = Math.abs(costBasis ?? 0); if (cagrBase > 0 && existing.gain_dollars != null && existing.days_since_buy != null) { const years = Math.max(existing.days_since_buy / 365.25, 1e-9); if (years < 1) { existing.cagr_percent = existing.gain_percent; } else if ((costBasis ?? 0) < 0) { const endLiability = Math.abs(existing.market_value ?? 0); existing.cagr_percent = endLiability > 0 ? (((cagrBase / endLiability) ** (1 / years)) - 1) * 100 : null; } else { const endingValue = cagrBase + existing.gain_dollars; existing.cagr_percent = endingValue > 0 ? (((endingValue / cagrBase) ** (1 / years)) - 1) * 100 : null; } } else { existing.cagr_percent = null; } } return Array.from(groups.values()); } function sortPositions(list) { if (!sortState.length) return [...list]; return [...list].sort((left, right) => { for (const rule of sortState) { const accessor = columnAccessors[rule.column]; if (!accessor) continue; const leftValue = accessor(left); const rightValue = accessor(right); const result = numericColumns.has(rule.column) ? compareValues(asNumber(leftValue), asNumber(rightValue)) : compareValues(leftValue, rightValue); if (result !== 0) return rule.direction === 'asc' ? result : -result; } return 0; }); } function getCurrencyOptions() { const currencies = [...new Set(latestPositions.map((position) => position.currency).filter(Boolean))].sort((a, b) => a.localeCompare(b)); return [{ value: '', label: 'All' }, ...currencies.map((currency) => ({ value: currency, label: currency }))]; } function captureFocusState() { const active = document.activeElement; if (!active || !active.classList || !active.classList.contains('filter-input')) { return null; } return { column: active.dataset.column, selectionStart: typeof active.selectionStart === 'number' ? active.selectionStart : null, selectionEnd: typeof active.selectionEnd === 'number' ? active.selectionEnd : null, }; } function restoreFocusState(state) { if (!state?.column) return; const input = document.querySelector(`.filter-input[data-column="${state.column}"]`); if (!input) return; input.focus(); if (typeof state.selectionStart === 'number' && typeof state.selectionEnd === 'number' && typeof input.setSelectionRange === 'function') { input.setSelectionRange(state.selectionStart, state.selectionEnd); } } function createFilterControl(column) { const value = filterState[column.key] ?? ''; if (column.filter === 'select' || column.filter === 'select-dynamic') { const select = document.createElement('select'); select.className = 'filter-input filter-select'; select.dataset.column = column.key; const options = column.filter === 'select-dynamic' ? getCurrencyOptions() : column.options; for (const option of options) { const element = document.createElement('option'); element.value = option.value; element.textContent = option.label; select.appendChild(element); } select.value = value; if (value) { select.classList.add('filter-active'); } select.addEventListener('change', onFilterInput); return select; } const input = document.createElement('input'); input.className = 'filter-input filter-text'; input.dataset.column = column.key; input.placeholder = column.placeholder ?? ''; input.value = value; if (value) { input.classList.add('filter-active'); } input.addEventListener('input', onFilterInput); return input; } function renderHeader() { pthead.innerHTML = ''; const sortRow = document.createElement('tr'); sortRow.className = 'sort-row'; const filterRow = document.createElement('tr'); filterRow.className = 'filter-row'; for (const column of getActiveColumns()) { const sortTh = document.createElement('th'); sortTh.dataset.column = column.key; sortTh.draggable = true; if (column.numeric) sortTh.classList.add('num'); const label = document.createElement('span'); label.textContent = column.label; sortTh.appendChild(label); const ruleIndex = sortState.findIndex((rule) => rule.column === column.key); if (ruleIndex !== -1) { const rule = sortState[ruleIndex]; const meta = document.createElement('span'); meta.className = 'sort-meta'; meta.textContent = `${rule.direction === 'asc' ? '▲' : '▼'} ${ruleIndex + 1}`; sortTh.appendChild(meta); } sortTh.addEventListener('click', (event) => { if (event.target.closest('.drag-handle')) return; cycleSort(column.key, event.shiftKey); }); sortTh.addEventListener('dragstart', onHeaderDragStart); sortTh.addEventListener('dragover', onHeaderDragOver); sortTh.addEventListener('drop', onHeaderDrop); sortTh.addEventListener('dragend', onHeaderDragEnd); sortRow.appendChild(sortTh); const filterTh = document.createElement('th'); if (column.numeric) filterTh.classList.add('num'); filterTh.appendChild(createFilterControl(column)); filterRow.appendChild(filterTh); } pthead.appendChild(sortRow); pthead.appendChild(filterRow); } function renderCell(columnKey, row) { switch (columnKey) { case 'security_type': return escapeHtml(row.security_type ?? ''); case 'display_symbol': return escapeHtml(row.display_symbol ?? row.symbol ?? ''); case 'currency': return escapeHtml(row.currency ?? ''); case 'underlying_symbol': return escapeHtml(row.underlying_symbol ?? ''); case 'qty': return formatNumber(row.qty, 2); case 'avg_price': return formatNumber(row.avg_price ?? row.averageCost ?? row.average_cost, 2); case 'cost_basis': return formatNumber(row.cost_basis, 2); case 'market_price': return formatNumber(row.market_price, 2); case 'market_value': return formatNumber(row.market_value, 2); case 'gain_dollars': return formatNumber(row.gain_dollars, 2); case 'gain_percent': return formatPercent(row.gain_percent); case 'cagr_percent': return formatPercent(row.cagr_percent); case 'days_since_buy': return formatNumber(row.days_since_buy, 0); case 'ltcg': return row.ltcg === 'X' ? '' : ''; case 'buy_time': return escapeHtml(row.buy_time ?? ''); default: return ''; } } function renderPositions(list) { const focusState = captureFocusState(); renderHeader(); ptbody.innerHTML = ''; const activeColumns = getActiveColumns(); for (const rowData of sortPositions(filterPositions(aggregateLots(list)))) { const row = document.createElement('tr'); for (const column of activeColumns) { const cell = document.createElement('td'); if (column.numeric) cell.className = 'num'; if (column.key === 'ltcg') cell.className = 'icon-cell'; cell.innerHTML = renderCell(column.key, rowData); row.appendChild(cell); } ptbody.appendChild(row); } restoreFocusState(focusState); } function cycleSort(column, multi) { const existingIndex = sortState.findIndex((rule) => rule.column === column); if (!multi) { if (existingIndex === -1) { sortState = [{ column, direction: 'asc' }]; } else { const current = sortState[existingIndex]; if (current.direction === 'asc') { sortState = [{ column, direction: 'desc' }]; } else { sortState = []; } } renderPositions(latestPositions); saveTableState(); return; } if (existingIndex === -1) { sortState.push({ column, direction: 'asc' }); } else { const current = sortState[existingIndex]; if (current.direction === 'asc') { sortState[existingIndex] = { column, direction: 'desc' }; } else { sortState.splice(existingIndex, 1); } } renderPositions(latestPositions); saveTableState(); } function onFilterInput(event) { const input = event.currentTarget; const value = input.value.trim(); if (value) { filterState[input.dataset.column] = value; } else { delete filterState[input.dataset.column]; } saveTableState(); renderPositions(latestPositions); } function onHeaderDragStart(event) { dragColumnKey = event.currentTarget.dataset.column; event.dataTransfer.effectAllowed = 'move'; event.dataTransfer.setData('text/plain', dragColumnKey); event.currentTarget.classList.add('dragging'); } function onHeaderDragOver(event) { event.preventDefault(); event.dataTransfer.dropEffect = 'move'; } function onHeaderDrop(event) { event.preventDefault(); const targetKey = event.currentTarget.dataset.column; const sourceKey = dragColumnKey || event.dataTransfer.getData('text/plain'); if (!sourceKey || sourceKey === targetKey) return; const currentOrder = getActiveColumns().map((column) => column.key); const sourceIndex = currentOrder.indexOf(sourceKey); const targetIndex = currentOrder.indexOf(targetKey); if (sourceIndex === -1 || targetIndex === -1) return; currentOrder.splice(targetIndex, 0, currentOrder.splice(sourceIndex, 1)[0]); columnOrder = currentOrder; saveTableState(); renderPositions(latestPositions); } function onHeaderDragEnd(event) { dragColumnKey = null; event.currentTarget.classList.remove('dragging'); } async function pollPositions() { if (isRefreshing) return; isRefreshing = true; if (refreshButton) refreshButton.disabled = true; try { const positions = await fetchJson('/api/lots'); latestPositions = Array.isArray(positions) ? positions : []; renderPositions(latestPositions); } catch (e) { console.warn('Positions polling error', e); } finally { isRefreshing = false; if (refreshButton) refreshButton.disabled = false; } } loadTableState(); if (!columnOrder.length) { columnOrder = [...defaultColumnOrder]; } if (aggregateCheckbox) { aggregateCheckbox.checked = aggregateSameDay; aggregateCheckbox.addEventListener('change', () => { aggregateSameDay = aggregateCheckbox.checked; saveTableState(); renderPositions(latestPositions); }); } if (refreshButton) { refreshButton.addEventListener('click', () => { pollPositions(); }); } pollPositions();