Generally quite nicely working already

This commit is contained in:
2026-04-01 21:44:21 -05:00
parent 28afa137cb
commit 1835fb89d8
13 changed files with 1686 additions and 198 deletions

View File

@@ -1,32 +1,255 @@
// 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');
async function fetchJson(path) {
const res = await fetch(path);
if (!res.ok) throw new Error('Network response not ok');
return res.json();
}
function renderPositions(list) {
ptbody.innerHTML = '';
for (const p of list) {
const row = document.createElement('tr');
const avg = p.averageCost ?? p.average_cost ?? '';
row.innerHTML = `<td>${p.symbol}</td><td>${p.position}</td><td>${avg}</td><td>${p.account ?? ''}</td>`;
ptbody.appendChild(row);
}
}
async function pollPositions() {
try {
const positions = await fetchJson('/api/positions');
renderPositions(positions);
} catch (e) {
console.warn('Positions polling error', e);
}
}
// start polling every 2 seconds
pollPositions();
setInterval(pollPositions, 2000);
// 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'));
let latestPositions = [];
let sortState = [];
let filterState = {};
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']);
const columnAccessors = {
security_type: (p) => p.security_type ?? '',
display_symbol: (p) => p.display_symbol ?? p.symbol ?? '',
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 ?? '',
};
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)}%`;
}
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);
}));
}
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;
});
}
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 renderPositions(list) {
ptbody.innerHTML = '';
for (const p of sortPositions(filterPositions(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
? '<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);
}
updateHeaderIndicators();
}
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);
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);
}
async function pollPositions() {
try {
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();
if (value) {
filterState[input.dataset.column] = value;
} else {
delete filterState[input.dataset.column];
}
renderPositions(latestPositions);
});
}
// start polling every 15 seconds
pollPositions();
setInterval(pollPositions, 15000);

View File

@@ -4,25 +4,198 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>IB Dashboard</title>
<style>
body { font-family: system-ui, -apple-system, "Segoe UI", Roboto, Arial; margin: 20px; }
input, button { padding: 6px 8px; margin-right: 6px }
table { border-collapse: collapse; width: 100%; margin-top: 12px }
th, td { border: 1px solid #ddd; padding: 8px; text-align: left }
th { background: #f4f4f4 }
</style>
</head>
<body>
<h1>Interactive Brokers Dashboard</h1>
<section>
<h2>Positions</h2>
<table id="positions-table">
<thead><tr><th>Symbol</th><th>Position</th><th>Average Cost</th><th>Account</th></tr></thead>
<tbody></tbody>
</table>
</section>
<script src="/static/app.js"></script>
</body>
</html>
<style>
:root { --sort-row-height: 43px; --filter-row-height: 38px; }
body { font-family: system-ui, -apple-system, "Segoe UI", Roboto, Arial; margin: 20px; }
input, button { padding: 6px 8px; margin-right: 6px }
.table-wrap {
position: relative;
max-height: calc(100vh - 140px);
overflow: auto;
border: 1px solid #ddd;
margin-top: 12px;
background: #fff;
}
table { border-collapse: separate; border-spacing: 0; width: 100%; margin-top: 0; background: #fff }
th, td {
padding: 8px;
text-align: left;
border-right: 1px solid #ddd;
border-bottom: 1px solid #ddd;
}
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 }
.help-badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 999px;
background: #1f9d3a;
color: #fff;
font-size: 22px;
font-weight: 700;
line-height: 1;
cursor: help;
}
.help-pane {
display: none;
position: absolute;
top: 50%;
left: calc(100% + 12px);
right: auto;
transform: translateY(-50%);
width: 340px;
padding: 12px 14px;
border: 1px solid #cfd8cf;
background: #fbfffb;
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.12);
z-index: 5;
font-size: 13px;
line-height: 1.45;
}
.help-wrap:hover .help-pane { display: block }
.help-pane p { margin: 0 0 8px 0 }
.help-pane p:last-child { margin-bottom: 0 }
th {
background: #f4f4f4;
position: sticky;
top: 0;
z-index: 4;
cursor: pointer;
user-select: none;
white-space: nowrap;
}
thead tr.sort-row th {
top: 0;
height: var(--sort-row-height);
min-height: var(--sort-row-height);
box-sizing: border-box;
box-shadow: inset 0 -1px 0 #d9d9d9;
}
th:hover { background: #e9e9e9 }
th .sort-meta { color: #666; font-size: 12px; margin-left: 6px }
thead { position: relative; z-index: 2 }
thead tr.filter-row th {
top: var(--sort-row-height);
background: #fafafa;
cursor: default;
z-index: 3;
padding: 6px;
height: var(--filter-row-height);
min-height: var(--filter-row-height);
box-sizing: border-box;
box-shadow: inset 0 -1px 0 #d9d9d9;
}
thead tr.filter-row th:hover { background: #fafafa }
.filter-input {
width: 100%;
min-width: 82px;
margin: 0;
padding: 5px 6px;
box-sizing: border-box;
font-size: 12px;
border: 1px solid #cfd3d7;
background: #fff;
}
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 }
td.icon-cell { text-align: center }
.ltcg-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: 4px;
font-size: 14px;
font-weight: 700;
line-height: 1;
}
.ltcg-icon.good {
background: #e5f7e9;
color: #188038;
border: 1px solid #b7e0c1;
}
.ltcg-icon.bad {
background: #fdeaea;
color: #c5221f;
border: 1px solid #efb7b7;
}
</style>
</head>
<body>
<h1>Interactive Brokers Dashboard</h1>
<section>
<div class="section-head">
<h2>Open Lots</h2>
<div class="help-wrap">
<span class="help-badge">?</span>
<div class="help-pane">
<p><strong>Sorting:</strong> click a column header to sort ascending, click again for descending, click a third time to remove it. Hold <code>Shift</code> while clicking to add second and third sort levels.</p>
<p><strong>Text filters:</strong> use wildcards with <code>*</code> and <code>?</code>. Example: <code>AM*</code> or <code>*2027*</code>.</p>
<p><strong>Numeric filters:</strong> use values like <code>&gt;100</code>, <code>&lt;=0</code>, <code>50</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 class="table-wrap">
<table id="positions-table">
<thead>
<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 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>
</thead>
<tbody></tbody>
</table>
</div>
</section>
<script src="/static/app.js"></script>
</body>
</html>