diff --git a/CHANGELOG.md b/CHANGELOG.md index ad0a8a66..81e2276e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 always survives in a `title` tooltip; applied across Overview / Strategies / Orders / PnL / Logs and the legacy single-engine dashboard (ui-ux-overhaul leaf 02). +- **Overview: positions by strategy + summary strip.** Positions now group by + strategy by default (dropping the redundant Strategy column from both header + and rows in that view), the group-by and KPI-level choices persist across + reloads (`localStorage`), and a summary strip above the KPI card shows + running/total strategies, open orders, total realised PnL (server-exact, no + client-side math) and the next scheduled tick (ui-ux-overhaul leaf 03). ### Fixed diff --git a/doc/dev/plans/ui-ux-overhaul/00-plan.md b/doc/dev/plans/ui-ux-overhaul/00-plan.md index 8ce88a14..ec5137ed 100644 --- a/doc/dev/plans/ui-ux-overhaul/00-plan.md +++ b/doc/dev/plans/ui-ux-overhaul/00-plan.md @@ -55,7 +55,7 @@ ever parsed back into a computation. - [x] 01 api-read-model — feat/ui-api-read-model — medium - [x] 02 formatters — feat/ui-formatters — medium -- [ ] 03 overview-groups — feat/ui-overview-groups — low +- [x] 03 overview-groups — feat/ui-overview-groups — low - [ ] 04 cadence — feat/ui-cadence — medium - [ ] 05 tables-polish — feat/ui-tables-polish — medium diff --git a/doc/dev/plans/ui-ux-overhaul/03-overview-groups.md b/doc/dev/plans/ui-ux-overhaul/03-overview-groups.md index 33b11f04..40fe42a2 100644 --- a/doc/dev/plans/ui-ux-overhaul/03-overview-groups.md +++ b/doc/dev/plans/ui-ux-overhaul/03-overview-groups.md @@ -1,12 +1,12 @@ --- plan: ui-ux-overhaul/03-overview-groups kind: leaf -status: planned +status: done complexity: low depends: [02] parallel: false branch: feat/ui-overview-groups -pr: "" +pr: "https://github.com/ArthurBernard/Trading_Bot/pull/161" --- # Leaf 03 — Overview: positions by strategy + summary strip diff --git a/trading_bot/interfaces/ui/templates/overview.html b/trading_bot/interfaces/ui/templates/overview.html index a53cb818..58770ab4 100644 --- a/trading_bot/interfaces/ui/templates/overview.html +++ b/trading_bot/interfaces/ui/templates/overview.html @@ -3,6 +3,18 @@ {% block content %}

Overview

+ +
+
+ / strategies running + open orders + total PnL + next check +
+
+
@@ -34,21 +46,25 @@

Performance

- +

Positions

- + +
- + - @@ -58,7 +74,7 @@

Positions

- +
Strategy Exchange Instrument Net qty
Loading…
Loading…
@@ -97,9 +113,30 @@

Open orders

// and format.js's tbFmt.*Cell() helpers (rounded, unit-labelled, exact value // on hover — quote/base derived client-side from each row's instrument). - // Current selections (mirrored on the toggle buttons). - let KPI_LEVEL = 'strategy'; - let POS_GROUP = ''; // '' = flat, else 'crypto' | 'exchange' + // Current selections (mirrored on the toggle buttons), persisted across + // reloads in localStorage — restored on load, tolerating an absent or + // no-longer-valid stored value (falls back to the default below). + const POS_GROUP_KEY = 'tb.overview.posGroup'; + const KPI_LEVEL_KEY = 'tb.overview.kpiLevel'; + const POS_GROUPS = ['', 'strategy', 'crypto', 'exchange']; + const KPI_LEVELS = ['strategy', 'exchange', 'total']; + + function loadPref(key, allowed, fallback) { + try { + const v = localStorage.getItem(key); + return allowed.indexOf(v) !== -1 ? v : fallback; + } catch (e) { + return fallback; // storage disabled (e.g. private browsing) — use the default + } + } + function savePref(key, value) { + try { localStorage.setItem(key, value); } catch (e) { /* ignore — in-memory state still works */ } + } + + // 'strategy' by default — "how is each strategy doing?" is the page's + // headline question, so positions open grouped by strategy. + let KPI_LEVEL = loadPref(KPI_LEVEL_KEY, KPI_LEVELS, 'strategy'); + let POS_GROUP = loadPref(POS_GROUP_KEY, POS_GROUPS, 'strategy'); // '' = flat // A signed-money cell class: green for a gain, red for a loss. function pnlClass(v) { @@ -110,6 +147,69 @@

Open orders

return ''; } + // --- Summary strip ------------------------------------------------------ // + // Running/total strategies, open orders (reusing loadOrders()'s already- + // fetched payload — see below), total realised PnL, and the next tick. + + async function loadSummaryStrategies() { + const totalEl = document.getElementById('sum-total'); + const runningEl = document.getElementById('sum-running'); + try { + const list = await api('GET', '/api/strategies'); + totalEl.textContent = list.length; + runningEl.textContent = list.filter(function (s) { return s.running; }).length; + } catch (e) { + totalEl.textContent = '—'; + runningEl.textContent = '—'; + } + } + + // Total realised PnL from /api/kpi?level=total — always exactly one row (a + // zero total even with nothing running), rendered via tbFmt.moneyCell with + // its quote (null quote -> no suffix + "mixed quote currencies" tooltip). + // No client-side money arithmetic — the server's exact Decimal total is the + // only source. + async function loadSummaryPnl() { + const pnlEl = document.getElementById('sum-pnl'); + try { + const rows = await api('GET', '/api/kpi?level=total'); + const total = rows[0]; + if (total) { + pnlEl.className = pnlClass(total.realised_pnl); + pnlEl.innerHTML = tbFmt.moneyCell(total.realised_pnl, { quote: total.quote }); + } else { + pnlEl.className = ''; + pnlEl.textContent = '—'; + } + } catch (e) { + pnlEl.className = ''; + pnlEl.textContent = '—'; + } + } + + // A compact "in Xs/Xm/Xh" label from /api/health's next_tick_ts (epoch ms), + // recomputed at each refresh — a static render only; the live 1-second + // countdown is leaf 04's job. + function nextTickLabel(nextTickTs) { + if (nextTickTs == null) return '—'; + const deltaS = Math.round((nextTickTs - Date.now()) / 1000); + if (deltaS <= 0) return 'due now'; + if (deltaS < 60) return 'in ' + deltaS + 's'; + const deltaM = Math.round(deltaS / 60); + if (deltaM < 60) return 'in ' + deltaM + 'm'; + return 'in ' + Math.round(deltaM / 60) + 'h'; + } + + async function loadSummaryNextTick() { + const el = document.getElementById('sum-next-tick'); + try { + const h = await api('GET', '/api/health'); + el.textContent = nextTickLabel(h.next_tick_ts); + } catch (e) { + el.textContent = '—'; + } + } + // --- KPI strip --------------------------------------------------------- // async function loadKpi() { const head = document.getElementById('kpi-key-head'); @@ -144,10 +244,24 @@

Open orders

} // --- Positions --------------------------------------------------------- // - function positionRowHtml(r) { + // The Strategy column is redundant when grouping by strategy (the group + // header already names it) — dropped from both the header and the row cells, + // with the rebuilt per grouping mode so colspans stay consistent. + function positionsTheadHtml(includeStrategy) { + return '' + + (includeStrategy ? 'Strategy' : '') + + 'Exchange' + + 'Instrument' + + 'Net qty' + + 'Avg entry' + + 'Realised PnL' + + 'Fees' + + ''; + } + function positionRowHtml(r, includeStrategy) { const unit = tbFmt.splitInstrument(r.instrument); // {base, quote} return '' + - '' + escapeHtml(r.strategy) + '' + + (includeStrategy ? '' + escapeHtml(r.strategy) + '' : '') + '' + escapeHtml(r.exchange) + '' + '' + escapeHtml(r.instrument) + '' + '' + tbFmt.qtyCell(r.net_qty, { base: unit.base }) + '' + @@ -163,6 +277,9 @@

Open orders

'' + count + ''; } async function loadPositions() { + const includeStrategy = POS_GROUP !== 'strategy'; + const colspan = includeStrategy ? 7 : 6; + document.getElementById('positions-thead').innerHTML = positionsTheadHtml(includeStrategy); let data; const url = POS_GROUP ? '/api/positions?group_by=' + encodeURIComponent(POS_GROUP) @@ -171,7 +288,7 @@

Open orders

data = await api('GET', url); } catch (e) { document.getElementById('positions-body').innerHTML = - '' + escapeHtml(e.message) + ''; + '' + escapeHtml(e.message) + ''; return; } const body = document.getElementById('positions-body'); @@ -179,19 +296,19 @@

Open orders

if (POS_GROUP) { // Grouped: [{group, rows}] — a header row per bucket. if (!data.length) { - body.innerHTML = 'No open positions.'; + body.innerHTML = 'No open positions.'; return; } data.forEach(function (g) { - html += groupHeaderHtml(g.group, g.rows.length, 7); - html += g.rows.map(positionRowHtml).join(''); + html += groupHeaderHtml(g.group, g.rows.length, colspan); + html += g.rows.map(function (r) { return positionRowHtml(r, includeStrategy); }).join(''); }); } else { if (!data.length) { - body.innerHTML = 'No open positions.'; + body.innerHTML = 'No open positions.'; return; } - html = data.map(positionRowHtml).join(''); + html = data.map(function (r) { return positionRowHtml(r, includeStrategy); }).join(''); } body.innerHTML = html; } @@ -204,8 +321,11 @@

Open orders

} catch (e) { document.getElementById('orders-body').innerHTML = '' + escapeHtml(e.message) + ''; + document.getElementById('sum-open-orders').textContent = '—'; return; } + // Feeds the summary strip's open-orders count — no separate fetch for it. + document.getElementById('sum-open-orders').textContent = rows.length; const body = document.getElementById('orders-body'); if (!rows.length) { body.innerHTML = 'No open orders.'; @@ -228,31 +348,43 @@

Open orders

} function refreshAll() { + loadSummaryStrategies(); + loadSummaryPnl(); + loadSummaryNextTick(); loadKpi(); loadPositions(); loadOrders(); } + // Reflect KPI_LEVEL / POS_GROUP (restored from localStorage, or the default) + // onto the toggle buttons' is-active state. + function syncToggleButtons() { + document.querySelectorAll('.kpi-level').forEach(function (b) { + b.classList.toggle('is-active', b.dataset.level === KPI_LEVEL); + }); + document.querySelectorAll('.pos-group').forEach(function (b) { + b.classList.toggle('is-active', b.dataset.group === POS_GROUP); + }); + } + document.addEventListener('DOMContentLoaded', function () { + syncToggleButtons(); // apply the restored (or default) preferences + // KPI level toggle. document.querySelectorAll('.kpi-level').forEach(function (btn) { btn.addEventListener('click', function () { - document.querySelectorAll('.kpi-level').forEach(function (b) { - b.classList.remove('is-active'); - }); - btn.classList.add('is-active'); KPI_LEVEL = btn.dataset.level; + savePref(KPI_LEVEL_KEY, KPI_LEVEL); + syncToggleButtons(); loadKpi(); }); }); // Positions group-by control. document.querySelectorAll('.pos-group').forEach(function (btn) { btn.addEventListener('click', function () { - document.querySelectorAll('.pos-group').forEach(function (b) { - b.classList.remove('is-active'); - }); - btn.classList.add('is-active'); POS_GROUP = btn.dataset.group; + savePref(POS_GROUP_KEY, POS_GROUP); + syncToggleButtons(); loadPositions(); }); }); diff --git a/trading_bot/tests/interfaces/test_dashboard.py b/trading_bot/tests/interfaces/test_dashboard.py index 8cb35536..f5fbc096 100644 --- a/trading_bot/tests/interfaces/test_dashboard.py +++ b/trading_bot/tests/interfaces/test_dashboard.py @@ -865,7 +865,20 @@ def test_overview_page_has_kpi_strip_and_tables() -> None: assert 'id="positions-table"' in html assert "pos-group" in html # the group-by control assert 'data-group="crypto"' in html and 'data-group="exchange"' in html + # The "By strategy" grouping is present and the default (ui-ux leaf 03). + assert 'data-group="strategy"' in html + assert 'class="btn pos-group is-active" data-group="strategy"' in html assert 'id="orders-table"' in html + # The summary strip (running/total strategies, open orders, total PnL, next + # tick) sits above the KPI card. + assert 'id="summary-strip"' in html + assert 'id="sum-running"' in html and 'id="sum-total"' in html + assert 'id="sum-open-orders"' in html + assert 'id="sum-pnl"' in html + assert 'id="sum-next-tick"' in html + # View preferences persist across reloads. + assert "tb.overview.posGroup" in html + assert "tb.overview.kpiLevel" in html # It wires the merged SSE stream + polling fallback. assert "/api/events" in html assert "/api/positions" in html