I think that's it for now.

This commit is contained in:
2026-04-01 22:38:04 -05:00
parent 1835fb89d8
commit 253858d46a
5 changed files with 467 additions and 103 deletions

View File

@@ -32,14 +32,22 @@ def _normalize_buy_time(open_dt: str | None) -> str | None:
return open_dt 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: if cost_basis in (None, 0) or gain_dollars is None or not buy_time:
return None return None
try: try:
start = datetime.fromisoformat(buy_time).replace(tzinfo=timezone.utc) start = datetime.fromisoformat(buy_time).replace(tzinfo=timezone.utc)
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
years = max((now - start).total_seconds() / (365.25 * 24 * 3600), 1e-9) 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)) 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) end_value = start_value + float(gain_dollars)
if start_value <= 0 or end_value <= 0: if start_value <= 0 or end_value <= 0:
return None return None
@@ -73,6 +81,20 @@ def _instrument_key(attrs: dict) -> str:
return (attrs.get("symbol") or "").strip() 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]: def parse_flex_lots(xml_path: str | Path) -> list[dict]:
root = ET.parse(xml_path).getroot() root = ET.parse(xml_path).getroot()
lots: list[dict] = [] 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" security_type = (attrs.get("assetCategory") or "").upper() or "STK"
multiplier = _as_float(attrs.get("multiplier"), 1.0) or 1.0 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 raw_position = _as_float(attrs.get("position"), 0.0) or 0.0
side = (attrs.get("side") or "").strip().lower() 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")) mark_price = _as_float(attrs.get("markPrice"))
market_price = mark_price market_price = mark_price
market_value = _as_float(attrs.get("positionValue")) market_value = _as_float(attrs.get("positionValue"))
cost_basis = _as_float(attrs.get("costBasisMoney")) 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")) avg_price = _as_float(attrs.get("costBasisPrice"))
if avg_price is None: if avg_price is None:
open_price = _as_float(attrs.get("openPrice")) avg_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")) buy_time = _normalize_buy_time(attrs.get("openDateTime"))
gain_dollars = _as_float(attrs.get("fifoPnlUnrealized"))
# 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 gain_percent = None
if cost_basis not in (None, 0) and gain_dollars is not None: if cost_basis not in (None, 0) and gain_dollars is not None:
gain_percent = (gain_dollars / abs(cost_basis)) * 100.0 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) days_since_buy = _compute_days_since_buy(buy_time)
ltcg = "X" if days_since_buy is not None and days_since_buy >= 365 else "" 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")), "con_id": _as_float(attrs.get("conid")),
"symbol": attrs.get("symbol"), "symbol": attrs.get("symbol"),
"display_symbol": _display_symbol(attrs), "display_symbol": _display_symbol(attrs),
"currency": attrs.get("currency") or None,
"security_type": security_type, "security_type": security_type,
"underlying_symbol": attrs.get("underlyingSymbol") or attrs.get("symbol"), "underlying_symbol": attrs.get("underlyingSymbol") or attrs.get("symbol"),
"expiry": _normalize_expiry(attrs.get("expiry")), "expiry": _normalize_expiry(attrs.get("expiry")),

View File

@@ -1,11 +1,17 @@
// Minimal vanilla-JS frontend to poll only positions and render the positions table
const qs = (s) => document.querySelector(s); const qs = (s) => document.querySelector(s);
const ptbody = qs('#positions-table tbody'); const ptbody = qs('#positions-table tbody');
const headers = Array.from(document.querySelectorAll('#positions-table thead tr.sort-row th')); const pthead = qs('#positions-table thead');
const filterInputs = Array.from(document.querySelectorAll('.filter-input')); const aggregateCheckbox = qs('#aggregate-same-day');
const refreshButton = qs('#refresh-lots');
let latestPositions = []; let latestPositions = [];
let sortState = []; let sortState = [];
let filterState = {}; 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([ const numericColumns = new Set([
'qty', 'qty',
'avg_price', 'avg_price',
@@ -19,9 +25,28 @@ const numericColumns = new Set([
]); ]);
const dateColumns = new Set(['buy_time']); 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 = { const columnAccessors = {
security_type: (p) => p.security_type ?? '', security_type: (p) => p.security_type ?? '',
display_symbol: (p) => p.display_symbol ?? p.symbol ?? '', display_symbol: (p) => p.display_symbol ?? p.symbol ?? '',
currency: (p) => p.currency ?? '',
underlying_symbol: (p) => p.underlying_symbol ?? '', underlying_symbol: (p) => p.underlying_symbol ?? '',
qty: (p) => p.qty, qty: (p) => p.qty,
avg_price: (p) => p.avg_price, avg_price: (p) => p.avg_price,
@@ -36,6 +61,15 @@ const columnAccessors = {
buy_time: (p) => p.buy_time ?? '', 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) { function asNumber(value) {
if (value == null || value === '') return null; if (value == null || value === '') return null;
const parsed = Number(value); const parsed = Number(value);
@@ -57,6 +91,56 @@ function formatPercent(value) {
return `${formatNumber(num, 2)}%`; return `${formatNumber(num, 2)}%`;
} }
function escapeHtml(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
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) { async function fetchJson(path) {
const res = await fetch(path); const res = await fetch(path);
if (!res.ok) throw new Error('Network response not ok'); 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) { function sortPositions(list) {
if (!sortState.length) return [...list]; if (!sortState.length) return [...list];
return [...list].sort((left, right) => { return [...list].sort((left, right) => {
@@ -151,45 +294,168 @@ function sortPositions(list) {
}); });
} }
function updateHeaderIndicators() { function getCurrencyOptions() {
for (const header of headers) { const currencies = [...new Set(latestPositions.map((position) => position.currency).filter(Boolean))].sort((a, b) => a.localeCompare(b));
const column = header.dataset.column; return [{ value: '', label: 'All' }, ...currencies.map((currency) => ({ value: currency, label: currency }))];
const existing = header.querySelector('.sort-meta'); }
if (existing) existing.remove();
const ruleIndex = sortState.findIndex((rule) => rule.column === column); function captureFocusState() {
if (ruleIndex === -1) continue; 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 rule = sortState[ruleIndex];
const meta = document.createElement('span'); const meta = document.createElement('span');
meta.className = 'sort-meta'; meta.className = 'sort-meta';
meta.textContent = `${rule.direction === 'asc' ? '▲' : '▼'} ${ruleIndex + 1}`; meta.textContent = `${rule.direction === 'asc' ? '▲' : '▼'} ${ruleIndex + 1}`;
header.appendChild(meta); 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'
? '<span class="ltcg-icon good" title="Long-term capital gains eligible">&#10003;</span>'
: '<span class="ltcg-icon bad" title="Less than one year old">&#10007;</span>';
case 'buy_time':
return escapeHtml(row.buy_time ?? '');
default:
return '';
} }
} }
function renderPositions(list) { function renderPositions(list) {
const focusState = captureFocusState();
renderHeader();
ptbody.innerHTML = ''; 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 row = document.createElement('tr');
const securityType = p.security_type ?? ''; for (const column of activeColumns) {
const displaySymbol = p.display_symbol ?? p.symbol ?? ''; const cell = document.createElement('td');
const underlyingSymbol = p.underlying_symbol ?? ''; if (column.numeric) cell.className = 'num';
const qty = formatNumber(p.qty, 2); if (column.key === 'ltcg') cell.className = 'icon-cell';
const avg = formatNumber(p.avg_price ?? p.averageCost ?? p.average_cost, 2); cell.innerHTML = renderCell(column.key, rowData);
const cost = formatNumber(p.cost_basis, 2); row.appendChild(cell);
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
? '<span class="ltcg-icon good" title="Long-term capital gains eligible">&#10003;</span>'
: '<span class="ltcg-icon bad" title="Less than one year old">&#10007;</span>';
row.innerHTML = `<td>${securityType}</td><td>${displaySymbol}</td><td>${underlyingSymbol}</td><td class="num">${qty}</td><td class="num">${avg}</td><td class="num">${cost}</td><td class="num">${mprice}</td><td class="num">${mvalue}</td><td class="num">${gain}</td><td class="num">${gainp}</td><td class="num">${cagr}</td><td class="num">${daysSinceBuy}</td><td class="icon-cell">${ltcgIcon}</td><td>${p.buy_time ?? ''}</td>`;
ptbody.appendChild(row); ptbody.appendChild(row);
} }
updateHeaderIndicators(); restoreFocusState(focusState);
} }
function cycleSort(column, multi) { function cycleSort(column, multi) {
@@ -206,6 +472,7 @@ function cycleSort(column, multi) {
} }
} }
renderPositions(latestPositions); renderPositions(latestPositions);
saveTableState();
return; return;
} }
@@ -220,36 +487,87 @@ function cycleSort(column, multi) {
} }
} }
renderPositions(latestPositions); renderPositions(latestPositions);
saveTableState();
} }
async function pollPositions() { function onFilterInput(event) {
try { const input = event.currentTarget;
const positions = await fetchJson('/api/lots');
latestPositions = Array.isArray(positions) ? positions : [];
renderPositions(latestPositions);
} catch (e) {
console.warn('Positions polling error', e);
}
}
for (const header of headers) {
header.addEventListener('click', (event) => {
cycleSort(header.dataset.column, event.shiftKey);
});
}
for (const input of filterInputs) {
input.addEventListener('input', () => {
const value = input.value.trim(); const value = input.value.trim();
if (value) { if (value) {
filterState[input.dataset.column] = value; filterState[input.dataset.column] = value;
} else { } else {
delete filterState[input.dataset.column]; 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); renderPositions(latestPositions);
}); });
} }
if (refreshButton) {
refreshButton.addEventListener('click', () => {
pollPositions();
});
}
// start polling every 15 seconds
pollPositions(); pollPositions();
setInterval(pollPositions, 15000);

View File

@@ -16,7 +16,14 @@
margin-top: 12px; margin-top: 12px;
background: #fff; 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 { th, td {
padding: 8px; padding: 8px;
text-align: left; text-align: left;
@@ -26,6 +33,31 @@
th:first-child, td:first-child { border-left: 1px solid #ddd } th:first-child, td:first-child { border-left: 1px solid #ddd }
.section-head { display: flex; align-items: center; gap: 10px } .section-head { display: flex; align-items: center; gap: 10px }
.help-wrap { position: relative; display: inline-flex; align-items: center } .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 { .help-badge {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -68,6 +100,7 @@
user-select: none; user-select: none;
white-space: nowrap; white-space: nowrap;
} }
th.dragging { opacity: 0.55 }
thead tr.sort-row th { thead tr.sort-row th {
top: 0; top: 0;
height: var(--sort-row-height); height: var(--sort-row-height);
@@ -92,7 +125,7 @@
thead tr.filter-row th:hover { background: #fafafa } thead tr.filter-row th:hover { background: #fafafa }
.filter-input { .filter-input {
width: 100%; width: 100%;
min-width: 82px; min-width: 0;
margin: 0; margin: 0;
padding: 5px 6px; padding: 5px 6px;
box-sizing: border-box; box-sizing: border-box;
@@ -100,6 +133,12 @@
border: 1px solid #cfd3d7; border: 1px solid #cfd3d7;
background: #fff; 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(odd) { background: #ffffff }
tbody tr:nth-child(even) { background: #f3f3f3 } tbody tr:nth-child(even) { background: #f3f3f3 }
td.num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap } td.num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap }
@@ -142,53 +181,21 @@
<p><strong>Date filters:</strong> use either wildcards like <code>2025-07*</code> or comparisons like <code>&gt;2025-01-01</code>.</p> <p><strong>Date filters:</strong> use either wildcards like <code>2025-07*</code> or comparisons like <code>&gt;2025-01-01</code>.</p>
</div> </div>
</div> </div>
<label class="aggregate-toggle">
<input id="aggregate-same-day" type="checkbox" checked />
<span>Aggregate same-day lots</span>
</label>
<button id="refresh-lots" class="refresh-button" type="button" title="Refresh lots">
<span class="refresh-icon">&#x21bb;</span>
<span>Refresh</span>
</button>
</div> </div>
<div class="table-wrap"> <div class="table-wrap">
<table id="positions-table"> <table id="positions-table">
<thead> <thead>
<tr class="sort-row"> <tr class="sort-row">
<th data-column="security_type">Type</th>
<th data-column="display_symbol">Security</th>
<th data-column="underlying_symbol">Underlying</th>
<th data-column="qty">Qty</th>
<th data-column="avg_price">Avg Price</th>
<th data-column="cost_basis">Cost Basis</th>
<th data-column="market_price">Market Price</th>
<th data-column="market_value">Market Value</th>
<th data-column="gain_dollars">Gain $</th>
<th data-column="gain_percent">Gain %</th>
<th data-column="cagr_percent">CAGR %</th>
<th data-column="days_since_buy">Days Since Buy</th>
<th data-column="ltcg">LTCG</th>
<th data-column="buy_time">Buy Time</th>
</tr> </tr>
<tr class="filter-row"> <tr class="filter-row">
<th>
<select class="filter-input" data-column="security_type" title="Filter by security type.">
<option value="">All</option>
<option value="STK">STK</option>
<option value="OPT">OPT</option>
</select>
</th>
<th><input class="filter-input" data-column="display_symbol" placeholder="AM*" title="Text filter. Use * and ? wildcards." /></th>
<th><input class="filter-input" data-column="underlying_symbol" placeholder="A*" title="Text filter. Use * and ? wildcards." /></th>
<th><input class="filter-input" data-column="qty" placeholder=">100" title="Numeric filter. Examples: >100, <=0, 25" /></th>
<th><input class="filter-input" data-column="avg_price" placeholder="<20" title="Numeric filter. Examples: >100, <=0, 25" /></th>
<th><input class="filter-input" data-column="cost_basis" placeholder=">1000" title="Numeric filter. Examples: >100, <=0, 25" /></th>
<th><input class="filter-input" data-column="market_price" placeholder="<10" title="Numeric filter. Examples: >100, <=0, 25" /></th>
<th><input class="filter-input" data-column="market_value" placeholder=">5000" title="Numeric filter. Examples: >100, <=0, 25" /></th>
<th><input class="filter-input" data-column="gain_dollars" placeholder=">0" title="Numeric filter. Examples: >100, <=0, 25" /></th>
<th><input class="filter-input" data-column="gain_percent" placeholder=">10" title="Numeric filter. Examples: >100, <=0, 25" /></th>
<th><input class="filter-input" data-column="cagr_percent" placeholder=">5" title="Numeric filter. Examples: >100, <=0, 25" /></th>
<th><input class="filter-input" data-column="days_since_buy" placeholder=">365" title="Numeric filter. Examples: >100, <=0, 25" /></th>
<th>
<select class="filter-input" data-column="ltcg" title="Filter by LTCG eligibility.">
<option value="">All</option>
<option value="X">Long-term</option>
<option value="!X">Short-term</option>
</select>
</th>
<th><input class="filter-input" data-column="buy_time" placeholder=">2025-01-01" title="Date filter. Use * and ? wildcards or comparisons like >2025-01-01." /></th>
</tr> </tr>
</thead> </thead>
<tbody></tbody> <tbody></tbody>