diff --git a/backend/__pycache__/app.cpython-313.pyc b/backend/__pycache__/app.cpython-313.pyc index 2a09d32..be56aa7 100644 Binary files a/backend/__pycache__/app.cpython-313.pyc and b/backend/__pycache__/app.cpython-313.pyc differ diff --git a/backend/__pycache__/flex_lots.cpython-313.pyc b/backend/__pycache__/flex_lots.cpython-313.pyc index 1253ee3..7c5b30a 100644 Binary files a/backend/__pycache__/flex_lots.cpython-313.pyc and b/backend/__pycache__/flex_lots.cpython-313.pyc differ diff --git a/backend/flex_lots.py b/backend/flex_lots.py index e8055ee..ec08067 100644 --- a/backend/flex_lots.py +++ b/backend/flex_lots.py @@ -32,14 +32,22 @@ def _normalize_buy_time(open_dt: str | None) -> str | None: return open_dt -def _compute_cagr_percent(cost_basis, gain_dollars, buy_time: str | None): +def _compute_cagr_percent(cost_basis, gain_dollars, market_value, buy_time: str | None): if cost_basis in (None, 0) or gain_dollars is None or not buy_time: return None try: start = datetime.fromisoformat(buy_time).replace(tzinfo=timezone.utc) now = datetime.now(timezone.utc) years = max((now - start).total_seconds() / (365.25 * 24 * 3600), 1e-9) + gain_percent = (float(gain_dollars) / abs(float(cost_basis))) * 100.0 + if years < 1.0: + return gain_percent start_value = abs(float(cost_basis)) + if float(cost_basis) < 0: + end_liability = abs(float(market_value)) if market_value is not None else abs(float(cost_basis) + float(gain_dollars)) + if start_value <= 0 or end_liability <= 0: + return None + return (((start_value / end_liability) ** (1.0 / years)) - 1.0) * 100.0 end_value = start_value + float(gain_dollars) if start_value <= 0 or end_value <= 0: return None @@ -73,6 +81,20 @@ def _instrument_key(attrs: dict) -> str: return (attrs.get("symbol") or "").strip() +def _signed_quantity(raw_position, side: str, cost_basis, market_value): + quantity = abs(raw_position or 0.0) + if quantity == 0: + return 0.0 + normalized_side = (side or "").strip().lower() + if normalized_side == "short" or (raw_position or 0.0) < 0: + return -quantity + if cost_basis is not None and float(cost_basis) < 0: + return -quantity + if market_value is not None and float(market_value) < 0: + return -quantity + return quantity + + def parse_flex_lots(xml_path: str | Path) -> list[dict]: root = ET.parse(xml_path).getroot() lots: list[dict] = [] @@ -83,27 +105,43 @@ def parse_flex_lots(xml_path: str | Path) -> list[dict]: security_type = (attrs.get("assetCategory") or "").upper() or "STK" multiplier = _as_float(attrs.get("multiplier"), 1.0) or 1.0 + fx_rate_to_base = _as_float(attrs.get("fxRateToBase"), 1.0) or 1.0 raw_position = _as_float(attrs.get("position"), 0.0) or 0.0 side = (attrs.get("side") or "").strip().lower() - qty = -abs(raw_position) if side == "short" or raw_position < 0 else abs(raw_position) mark_price = _as_float(attrs.get("markPrice")) market_price = mark_price market_value = _as_float(attrs.get("positionValue")) - cost_basis = _as_float(attrs.get("costBasisMoney")) - avg_price = _as_float(attrs.get("costBasisPrice")) - if avg_price is None: - open_price = _as_float(attrs.get("openPrice")) - avg_price = open_price - if avg_price is None and qty: - divisor = qty * multiplier if security_type == "OPT" else qty - avg_price = abs(cost_basis / divisor) if cost_basis is not None and divisor else None - buy_time = _normalize_buy_time(attrs.get("openDateTime")) + cost_basis_base = _as_float(attrs.get("costBasisMoney")) + capital_gain_base = _as_float(attrs.get("unrealizedCapitalGainsPnl")) gain_dollars = _as_float(attrs.get("fifoPnlUnrealized")) + qty = _signed_quantity(raw_position, side, cost_basis_base, market_value) + + # Flex OpenPosition rows mix currencies for non-base holdings: + # mark/positionValue are in instrument currency, while costBasis/openPrice + # are in account base. Reconstruct local-cost values so they line up with TWS. + cost_basis = cost_basis_base + if market_value is not None and capital_gain_base is not None and fx_rate_to_base: + cost_basis = market_value - (capital_gain_base / fx_rate_to_base) + + avg_price = None + if qty: + divisor = abs(qty) * multiplier if security_type == "OPT" else abs(qty) + avg_price = abs(cost_basis / divisor) if cost_basis is not None and divisor else None + if avg_price is None: + avg_price = _as_float(attrs.get("costBasisPrice")) + if avg_price is None: + avg_price = _as_float(attrs.get("openPrice")) + buy_time = _normalize_buy_time(attrs.get("openDateTime")) + + # Prefer local-currency capital gain to keep gain/cost/value in one currency. + if capital_gain_base is not None and fx_rate_to_base: + gain_dollars = capital_gain_base / fx_rate_to_base + gain_percent = None if cost_basis not in (None, 0) and gain_dollars is not None: gain_percent = (gain_dollars / abs(cost_basis)) * 100.0 - cagr_percent = _compute_cagr_percent(cost_basis, gain_dollars, buy_time) + cagr_percent = _compute_cagr_percent(cost_basis, gain_dollars, market_value, buy_time) days_since_buy = _compute_days_since_buy(buy_time) ltcg = "X" if days_since_buy is not None and days_since_buy >= 365 else "" @@ -112,6 +150,7 @@ def parse_flex_lots(xml_path: str | Path) -> list[dict]: "con_id": _as_float(attrs.get("conid")), "symbol": attrs.get("symbol"), "display_symbol": _display_symbol(attrs), + "currency": attrs.get("currency") or None, "security_type": security_type, "underlying_symbol": attrs.get("underlyingSymbol") or attrs.get("symbol"), "expiry": _normalize_expiry(attrs.get("expiry")), diff --git a/frontend/app.js b/frontend/app.js index 452c4fb..98cf6c8 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -1,11 +1,17 @@ -// Minimal vanilla-JS frontend to poll only positions and render the positions table const qs = (s) => document.querySelector(s); const ptbody = qs('#positions-table tbody'); -const headers = Array.from(document.querySelectorAll('#positions-table thead tr.sort-row th')); -const filterInputs = Array.from(document.querySelectorAll('.filter-input')); +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', @@ -19,9 +25,28 @@ const numericColumns = new Set([ ]); 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, @@ -36,6 +61,15 @@ const columnAccessors = { 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); @@ -57,6 +91,56 @@ function formatPercent(value) { 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'); @@ -134,6 +218,65 @@ function filterPositions(list) { })); } +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) => { @@ -151,45 +294,168 @@ function sortPositions(list) { }); } -function updateHeaderIndicators() { - for (const header of headers) { - const column = header.dataset.column; - const existing = header.querySelector('.sort-meta'); - if (existing) existing.remove(); - const ruleIndex = sortState.findIndex((rule) => rule.column === column); - if (ruleIndex === -1) continue; - const rule = sortState[ruleIndex]; - const meta = document.createElement('span'); - meta.className = 'sort-meta'; - meta.textContent = `${rule.direction === 'asc' ? '▲' : '▼'} ${ruleIndex + 1}`; - header.appendChild(meta); +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 = ''; - for (const p of sortPositions(filterPositions(list))) { + const activeColumns = getActiveColumns(); + for (const rowData of sortPositions(filterPositions(aggregateLots(list)))) { const row = document.createElement('tr'); - const securityType = p.security_type ?? ''; - const displaySymbol = p.display_symbol ?? p.symbol ?? ''; - const underlyingSymbol = p.underlying_symbol ?? ''; - const qty = formatNumber(p.qty, 2); - const avg = formatNumber(p.avg_price ?? p.averageCost ?? p.average_cost, 2); - const cost = formatNumber(p.cost_basis, 2); - const mprice = formatNumber(p.market_price, 2); - const mvalue = formatNumber(p.market_value, 2); - const gain = formatNumber(p.gain_dollars, 2); - const gainp = formatPercent(p.gain_percent); - const cagr = formatPercent(p.cagr_percent); - const daysSinceBuy = formatNumber(p.days_since_buy, 0); - const isLongTerm = p.ltcg === 'X'; - const ltcgIcon = isLongTerm - ? '' - : ''; - row.innerHTML = `${securityType}${displaySymbol}${underlyingSymbol}${qty}${avg}${cost}${mprice}${mvalue}${gain}${gainp}${cagr}${daysSinceBuy}${ltcgIcon}${p.buy_time ?? ''}`; + 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); } - updateHeaderIndicators(); + restoreFocusState(focusState); } function cycleSort(column, multi) { @@ -206,6 +472,7 @@ function cycleSort(column, multi) { } } renderPositions(latestPositions); + saveTableState(); return; } @@ -220,36 +487,87 @@ function cycleSort(column, multi) { } } 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; } } -for (const header of headers) { - header.addEventListener('click', (event) => { - cycleSort(header.dataset.column, event.shiftKey); - }); +loadTableState(); +if (!columnOrder.length) { + columnOrder = [...defaultColumnOrder]; } - -for (const input of filterInputs) { - input.addEventListener('input', () => { - const value = input.value.trim(); - if (value) { - filterState[input.dataset.column] = value; - } else { - delete filterState[input.dataset.column]; - } +if (aggregateCheckbox) { + aggregateCheckbox.checked = aggregateSameDay; + aggregateCheckbox.addEventListener('change', () => { + aggregateSameDay = aggregateCheckbox.checked; + saveTableState(); renderPositions(latestPositions); }); } +if (refreshButton) { + refreshButton.addEventListener('click', () => { + pollPositions(); + }); +} -// start polling every 15 seconds pollPositions(); -setInterval(pollPositions, 15000); diff --git a/frontend/index.html b/frontend/index.html index 788a774..8fd549e 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -16,7 +16,14 @@ margin-top: 12px; background: #fff; } - table { border-collapse: separate; border-spacing: 0; width: 100%; margin-top: 0; background: #fff } + table { + border-collapse: separate; + border-spacing: 0; + width: max-content; + margin-top: 0; + background: #fff; + table-layout: auto; + } th, td { padding: 8px; text-align: left; @@ -26,6 +33,31 @@ th:first-child, td:first-child { border-left: 1px solid #ddd } .section-head { display: flex; align-items: center; gap: 10px } .help-wrap { position: relative; display: inline-flex; align-items: center } + .aggregate-toggle { display: inline-flex; align-items: center; gap: 8px; font-weight: 600 } + .aggregate-toggle input { margin: 0 } + .refresh-button { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + border: 1px solid #bfc7d1; + border-radius: 8px; + background: #f7f9fb; + color: #19324d; + font-size: 14px; + font-weight: 700; + cursor: pointer; + margin-right: 0; + } + .refresh-button:hover { background: #eef3f8 } + .refresh-button:disabled { + cursor: wait; + opacity: 0.7; + } + .refresh-icon { + font-size: 16px; + line-height: 1; + } .help-badge { display: inline-flex; align-items: center; @@ -68,6 +100,7 @@ user-select: none; white-space: nowrap; } + th.dragging { opacity: 0.55 } thead tr.sort-row th { top: 0; height: var(--sort-row-height); @@ -92,7 +125,7 @@ thead tr.filter-row th:hover { background: #fafafa } .filter-input { width: 100%; - min-width: 82px; + min-width: 0; margin: 0; padding: 5px 6px; box-sizing: border-box; @@ -100,6 +133,12 @@ border: 1px solid #cfd3d7; background: #fff; } + .filter-input.filter-text { width: 9ch } + .filter-input.filter-select { width: auto; min-width: 5.5ch; max-width: 100% } + .filter-input.filter-active { + background: #f9e1e8; + border-color: #e5b8c7; + } tbody tr:nth-child(odd) { background: #ffffff } tbody tr:nth-child(even) { background: #f3f3f3 } td.num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap } @@ -142,53 +181,21 @@

Date filters: use either wildcards like 2025-07* or comparisons like >2025-01-01.

+ +
- - - - - - - - - - - - - - - - - - - - - - - - - - - -
TypeSecurityUnderlyingQtyAvg PriceCost BasisMarket PriceMarket ValueGain $Gain %CAGR %Days Since BuyLTCGBuy Time
- - - -