2026-04-01 21:44:21 -05:00
|
|
|
const qs = (s) => document.querySelector(s);
|
|
|
|
|
const ptbody = qs('#positions-table tbody');
|
2026-04-01 22:38:04 -05:00
|
|
|
const pthead = qs('#positions-table thead');
|
|
|
|
|
const aggregateCheckbox = qs('#aggregate-same-day');
|
|
|
|
|
const refreshButton = qs('#refresh-lots');
|
2026-04-01 21:44:21 -05:00
|
|
|
let latestPositions = [];
|
|
|
|
|
let sortState = [];
|
|
|
|
|
let filterState = {};
|
2026-04-01 22:38:04 -05:00
|
|
|
let aggregateSameDay = true;
|
|
|
|
|
let columnOrder = [];
|
|
|
|
|
let dragColumnKey = null;
|
|
|
|
|
let isRefreshing = false;
|
|
|
|
|
|
|
|
|
|
const STORAGE_KEY = 'ib-dashboard-open-lots-table-state';
|
2026-04-01 21:44:21 -05:00
|
|
|
const numericColumns = new Set([
|
|
|
|
|
'qty',
|
|
|
|
|
'avg_price',
|
|
|
|
|
'cost_basis',
|
|
|
|
|
'market_price',
|
|
|
|
|
'market_value',
|
|
|
|
|
'gain_dollars',
|
|
|
|
|
'gain_percent',
|
|
|
|
|
'cagr_percent',
|
|
|
|
|
'days_since_buy',
|
|
|
|
|
]);
|
|
|
|
|
const dateColumns = new Set(['buy_time']);
|
|
|
|
|
|
2026-04-01 22:38:04 -05:00
|
|
|
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 },
|
|
|
|
|
];
|
|
|
|
|
|
2026-04-01 21:44:21 -05:00
|
|
|
const columnAccessors = {
|
|
|
|
|
security_type: (p) => p.security_type ?? '',
|
|
|
|
|
display_symbol: (p) => p.display_symbol ?? p.symbol ?? '',
|
2026-04-01 22:38:04 -05:00
|
|
|
currency: (p) => p.currency ?? '',
|
2026-04-01 21:44:21 -05:00
|
|
|
underlying_symbol: (p) => p.underlying_symbol ?? '',
|
|
|
|
|
qty: (p) => p.qty,
|
|
|
|
|
avg_price: (p) => p.avg_price,
|
|
|
|
|
cost_basis: (p) => p.cost_basis,
|
|
|
|
|
market_price: (p) => p.market_price,
|
|
|
|
|
market_value: (p) => p.market_value,
|
|
|
|
|
gain_dollars: (p) => p.gain_dollars,
|
|
|
|
|
gain_percent: (p) => p.gain_percent,
|
|
|
|
|
cagr_percent: (p) => p.cagr_percent,
|
|
|
|
|
days_since_buy: (p) => p.days_since_buy,
|
|
|
|
|
ltcg: (p) => p.ltcg ?? '',
|
|
|
|
|
buy_time: (p) => p.buy_time ?? '',
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-01 22:38:04 -05:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 21:44:21 -05:00
|
|
|
function asNumber(value) {
|
|
|
|
|
if (value == null || value === '') return null;
|
|
|
|
|
const parsed = Number(value);
|
|
|
|
|
return Number.isFinite(parsed) ? parsed : null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function formatNumber(value, digits = 2) {
|
|
|
|
|
const num = asNumber(value);
|
|
|
|
|
if (num == null) return '';
|
|
|
|
|
return num.toLocaleString(undefined, {
|
|
|
|
|
minimumFractionDigits: digits,
|
|
|
|
|
maximumFractionDigits: digits,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function formatPercent(value) {
|
|
|
|
|
const num = asNumber(value);
|
|
|
|
|
if (num == null) return '';
|
|
|
|
|
return `${formatNumber(num, 2)}%`;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 22:38:04 -05:00
|
|
|
function escapeHtml(value) {
|
|
|
|
|
return String(value ?? '')
|
|
|
|
|
.replace(/&/g, '&')
|
|
|
|
|
.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);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 21:44:21 -05:00
|
|
|
async function fetchJson(path) {
|
|
|
|
|
const res = await fetch(path);
|
|
|
|
|
if (!res.ok) throw new Error('Network response not ok');
|
|
|
|
|
return res.json();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function compareValues(a, b) {
|
|
|
|
|
const aMissing = a == null || a === '';
|
|
|
|
|
const bMissing = b == null || b === '';
|
|
|
|
|
if (aMissing && bMissing) return 0;
|
|
|
|
|
if (aMissing) return 1;
|
|
|
|
|
if (bMissing) return -1;
|
|
|
|
|
if (typeof a === 'number' && typeof b === 'number') return a - b;
|
|
|
|
|
return String(a).localeCompare(String(b), undefined, { numeric: true, sensitivity: 'base' });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function escapeRegex(text) {
|
|
|
|
|
return text.replace(/[.+^${}()|[\]\\]/g, '\\$&');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function wildcardToRegex(pattern) {
|
|
|
|
|
const escaped = escapeRegex(pattern).replace(/\*/g, '.*').replace(/\?/g, '.');
|
|
|
|
|
return new RegExp(`^${escaped}$`, 'i');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function parseComparator(expression) {
|
|
|
|
|
const match = String(expression).trim().match(/^(<=|>=|<|>|=)\s*(.+)$/);
|
|
|
|
|
if (!match) return null;
|
|
|
|
|
return { operator: match[1], value: match[2].trim() };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function compareByOperator(left, operator, right) {
|
|
|
|
|
if (left == null || right == null) return false;
|
|
|
|
|
switch (operator) {
|
|
|
|
|
case '<': return left < right;
|
|
|
|
|
case '<=': return left <= right;
|
|
|
|
|
case '>': return left > right;
|
|
|
|
|
case '>=': return left >= right;
|
|
|
|
|
case '=': return left === right;
|
|
|
|
|
default: return false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function matchesFilter(column, rawValue, expression) {
|
|
|
|
|
const filter = String(expression ?? '').trim();
|
|
|
|
|
if (!filter) return true;
|
|
|
|
|
const value = rawValue ?? '';
|
|
|
|
|
|
|
|
|
|
if (column === 'ltcg' && filter === '!X') {
|
|
|
|
|
return String(value).trim().toUpperCase() !== 'X';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (numericColumns.has(column)) {
|
|
|
|
|
const numericValue = asNumber(value);
|
|
|
|
|
const comparator = parseComparator(filter);
|
|
|
|
|
if (comparator) return compareByOperator(numericValue, comparator.operator, asNumber(comparator.value));
|
|
|
|
|
return numericValue === asNumber(filter);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (dateColumns.has(column)) {
|
|
|
|
|
const normalizedValue = String(value);
|
|
|
|
|
const comparator = parseComparator(filter);
|
|
|
|
|
if (comparator) return compareByOperator(normalizedValue, comparator.operator, comparator.value);
|
|
|
|
|
return wildcardToRegex(filter).test(normalizedValue);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return wildcardToRegex(filter).test(String(value));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function filterPositions(list) {
|
|
|
|
|
return list.filter((row) => Object.entries(filterState).every(([column, expression]) => {
|
|
|
|
|
const accessor = columnAccessors[column];
|
|
|
|
|
if (!accessor) return true;
|
|
|
|
|
return matchesFilter(column, accessor(row), expression);
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 22:38:04 -05:00
|
|
|
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());
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 21:44:21 -05:00
|
|
|
function sortPositions(list) {
|
|
|
|
|
if (!sortState.length) return [...list];
|
|
|
|
|
return [...list].sort((left, right) => {
|
|
|
|
|
for (const rule of sortState) {
|
|
|
|
|
const accessor = columnAccessors[rule.column];
|
|
|
|
|
if (!accessor) continue;
|
|
|
|
|
const leftValue = accessor(left);
|
|
|
|
|
const rightValue = accessor(right);
|
|
|
|
|
const result = numericColumns.has(rule.column)
|
|
|
|
|
? compareValues(asNumber(leftValue), asNumber(rightValue))
|
|
|
|
|
: compareValues(leftValue, rightValue);
|
|
|
|
|
if (result !== 0) return rule.direction === 'asc' ? result : -result;
|
|
|
|
|
}
|
|
|
|
|
return 0;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 22:38:04 -05:00
|
|
|
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'
|
|
|
|
|
? '<span class="ltcg-icon good" title="Long-term capital gains eligible">✓</span>'
|
|
|
|
|
: '<span class="ltcg-icon bad" title="Less than one year old">✗</span>';
|
|
|
|
|
case 'buy_time':
|
|
|
|
|
return escapeHtml(row.buy_time ?? '');
|
|
|
|
|
default:
|
|
|
|
|
return '';
|
2026-04-01 21:44:21 -05:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function renderPositions(list) {
|
2026-04-01 22:38:04 -05:00
|
|
|
const focusState = captureFocusState();
|
|
|
|
|
renderHeader();
|
2026-04-01 21:44:21 -05:00
|
|
|
ptbody.innerHTML = '';
|
2026-04-01 22:38:04 -05:00
|
|
|
const activeColumns = getActiveColumns();
|
|
|
|
|
for (const rowData of sortPositions(filterPositions(aggregateLots(list)))) {
|
2026-04-01 21:44:21 -05:00
|
|
|
const row = document.createElement('tr');
|
2026-04-01 22:38:04 -05:00
|
|
|
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);
|
|
|
|
|
}
|
2026-04-01 21:44:21 -05:00
|
|
|
ptbody.appendChild(row);
|
|
|
|
|
}
|
2026-04-01 22:38:04 -05:00
|
|
|
restoreFocusState(focusState);
|
2026-04-01 21:44:21 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function cycleSort(column, multi) {
|
|
|
|
|
const existingIndex = sortState.findIndex((rule) => rule.column === column);
|
|
|
|
|
if (!multi) {
|
|
|
|
|
if (existingIndex === -1) {
|
|
|
|
|
sortState = [{ column, direction: 'asc' }];
|
|
|
|
|
} else {
|
|
|
|
|
const current = sortState[existingIndex];
|
|
|
|
|
if (current.direction === 'asc') {
|
|
|
|
|
sortState = [{ column, direction: 'desc' }];
|
|
|
|
|
} else {
|
|
|
|
|
sortState = [];
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
renderPositions(latestPositions);
|
2026-04-01 22:38:04 -05:00
|
|
|
saveTableState();
|
2026-04-01 21:44:21 -05:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (existingIndex === -1) {
|
|
|
|
|
sortState.push({ column, direction: 'asc' });
|
|
|
|
|
} else {
|
|
|
|
|
const current = sortState[existingIndex];
|
|
|
|
|
if (current.direction === 'asc') {
|
|
|
|
|
sortState[existingIndex] = { column, direction: 'desc' };
|
|
|
|
|
} else {
|
|
|
|
|
sortState.splice(existingIndex, 1);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
renderPositions(latestPositions);
|
2026-04-01 22:38:04 -05:00
|
|
|
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');
|
2026-04-01 21:44:21 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function pollPositions() {
|
2026-04-01 22:38:04 -05:00
|
|
|
if (isRefreshing) return;
|
|
|
|
|
isRefreshing = true;
|
|
|
|
|
if (refreshButton) refreshButton.disabled = true;
|
2026-04-01 21:44:21 -05:00
|
|
|
try {
|
|
|
|
|
const positions = await fetchJson('/api/lots');
|
|
|
|
|
latestPositions = Array.isArray(positions) ? positions : [];
|
|
|
|
|
renderPositions(latestPositions);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.warn('Positions polling error', e);
|
2026-04-01 22:38:04 -05:00
|
|
|
} finally {
|
|
|
|
|
isRefreshing = false;
|
|
|
|
|
if (refreshButton) refreshButton.disabled = false;
|
2026-04-01 21:44:21 -05:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 22:38:04 -05:00
|
|
|
loadTableState();
|
|
|
|
|
if (!columnOrder.length) {
|
|
|
|
|
columnOrder = [...defaultColumnOrder];
|
2026-04-01 21:44:21 -05:00
|
|
|
}
|
2026-04-01 22:38:04 -05:00
|
|
|
if (aggregateCheckbox) {
|
|
|
|
|
aggregateCheckbox.checked = aggregateSameDay;
|
|
|
|
|
aggregateCheckbox.addEventListener('change', () => {
|
|
|
|
|
aggregateSameDay = aggregateCheckbox.checked;
|
|
|
|
|
saveTableState();
|
2026-04-01 21:44:21 -05:00
|
|
|
renderPositions(latestPositions);
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-04-01 22:38:04 -05:00
|
|
|
if (refreshButton) {
|
|
|
|
|
refreshButton.addEventListener('click', () => {
|
|
|
|
|
pollPositions();
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-04-01 21:44:21 -05:00
|
|
|
|
|
|
|
|
pollPositions();
|