From 253858d46a795f4676c0714cf1b3e4b6517fa479 Mon Sep 17 00:00:00 2001 From: Joe Tretter Date: Wed, 1 Apr 2026 22:38:04 -0500 Subject: [PATCH] I think that's it for now. --- backend/__pycache__/app.cpython-313.pyc | Bin 13729 -> 13729 bytes backend/__pycache__/flex_lots.cpython-313.pyc | Bin 8622 -> 10167 bytes backend/flex_lots.py | 63 ++- frontend/app.js | 416 +++++++++++++++--- frontend/index.html | 91 ++-- 5 files changed, 467 insertions(+), 103 deletions(-) diff --git a/backend/__pycache__/app.cpython-313.pyc b/backend/__pycache__/app.cpython-313.pyc index 2a09d32bcd4076266ad6c5a77f77ae2a471e4562..be56aa7cd6cf6136cae39d1427738a4b1fac02a0 100644 GIT binary patch delta 14 WcmZ3Oy)b*jZBE9R&38DDY6Ac?IR=sd delta 14 WcmZ3Oy)b*jZB9o0&38DDY6Ac>D+Xl% diff --git a/backend/__pycache__/flex_lots.cpython-313.pyc b/backend/__pycache__/flex_lots.cpython-313.pyc index 1253ee3fc5ca9e65a85fbd0ca6de401b92028635..7c5b30a5f23d1c29b80c06d2b6807307abffb9ce 100644 GIT binary patch delta 3837 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 delta 2461 zcmZvde{3699l+nSeg1i8$3O0DC${6*`Q`j*)1_&bq-sLbHOkg3*={FNnKWEtx4qUq zXLnAwmW@=}#EiN>QoS)URH>P^(IBBx6bXieP{fcXO&~SB2^kY4B#0F@xiU% zUd`1}hn$Eg?vD0h zfHd6thaj$_io?Hl#&9Gy`gT%BesZDW3)U=d@+o(e{J|af3ej5~8*Zby*AV*39`Ym4 zOiP3YI+2>Nqn{q}f&9Sko(Nh$2;hm}))7|0_QT+y4UzY_gUm6&!L46$A7Ol)yz6UY zPJ-h2)dZ2C70SHaf`4zscGNW`(Nwjq$XM|jrcQIM5;F%t_LPR;G^W% zkpT9aYxYG0s{ybFB~BDZq2}ZeL=G%M{YruVcvr3w&8){TU5gsbM)wwrBu)xQ{-M2& zQbXC6eT%;_Q{^Ro5vobz#mLByArlm@7@fgM(W(lP<=J>+%A;`riOtzovKxIKw~?ot z!wj>4#iaQO+sOOPqt;Y5P44lne5#S8F@j&(d_lg2H~l-{lOG{>Tf%%tBfeyBct6`k zwk>HNym~g%ux5J+8;d`Be&h|YJ0%y$F3AhBTjD@wBoD|Qi3Qm!1wi&m!38XZBwi9E zzu14k_QZbMC()#J`fKERY_5~$t2&p9x>8b#>iI_Ys!dmN`6{DdC~76mLjD;$K)w`j z>9fwvK38Rn%X#&rT*^PET+Ubh=ah5BS+yXkT3#+F*YY_HY6odOVkgt@?x<-9>mjljQlk|m9DavW$jXaulcn>>Rmq|mh;+DUM*#8WH!-ZHfTm5 zUrBV1ouEF<$}%`;P<=>)y#qy|_yQ;nQAIly^vuv`Ggs|0_^LULwyAcK{4UXs!^GJd zUo~5Jo@%G5AFIhPEHt)5o1ywes&~^RE-Y)xQeMkZZJvt9s8}-XbWiH`sw1adQS_pw znI{T;9qk+yba%B2R45>H+7U_evsQuZwuU_~!_#T1-msF-Ti+yilgCq)_U_HecV_Or zv@IJ)Ctugsjv2n>jyL&p@%}UO#`M{;m3*4)Tjdk$E4Qz{b@hH`*k~Ow_|dX^Hxysb z-&WpI?sq?KB!-R9NZD2Kgmyf!-DqlK=+5xl!<$(nJ#0ir%1>4zsq$3a`t4w)=ji)G zM$h=#(gUG$d#OBG85-Ledg_<6U%X{qe{wUt6C3zAa_IiNT%Otuc5bZPxk~=t#**>2 z5U!9&s+ZY*(Fo6ysZ{2yz`HAK>{f9zZzK-atnm0-HuQ$F!Q64Z?b_@zl8+g}V2uHN z&5k&JE&omBrn2EQgiOsrl}^OP8?#+h;YM6+eZJ}XpjN<$ZMy!H7Tx07qu8!P1!ymmz7COj4X{jJ?YOa82Flp9Hfp1+eZ4{O3o>hL z&x6)-)s|D1N_7_+4JNL@@DI0M(vmMFXXVlb(|FBYQ1nukI?sd8D>UQ{DroR)@zTn2 zA)htpkZ*McafsaQJbU0Gbujyl{!Ra<$29trtTz}fX6-dH*46ALfN-uO{S;a1>P>Sv f^f2iJA=vONe(1H~gAcvKxalu1VkaKZ=!yPsb^|^n 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
- - - -