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 = `
Date filters: use either wildcards like 2025-07* or comparisons like >2025-01-01.
| Type | -Security | -Underlying | -Qty | -Avg Price | -Cost Basis | -Market Price | -Market Value | -Gain $ | -Gain % | -CAGR % | -Days Since Buy | -LTCG | -Buy Time |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| - - | -- | - | - | - | - | - | - | - | - | - | - | - - | -