Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion doc/dev/plans/ui-ux-overhaul/00-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions doc/dev/plans/ui-ux-overhaul/03-overview-groups.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
180 changes: 156 additions & 24 deletions trading_bot/interfaces/ui/templates/overview.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,18 @@
{% block content %}
<h1>Overview</h1>

<!-- Summary strip — running/total strategies, open orders, total realised PnL
(the server's exact Decimal total — never computed client-side) and the
next scheduled evaluation tick. Refreshed alongside the rest of the page. -->
<section class="card" id="summary-card">
<div class="row" id="summary-strip">
<span class="chip"><b id="sum-running">—</b>/<b id="sum-total">—</b> strategies running</span>
<span class="chip"><b id="sum-open-orders">—</b> open orders</span>
<span class="chip" title="Total realised PnL across every running strategy">total PnL <b id="sum-pnl">—</b></span>
<span class="chip">next check <b id="sum-next-tick">—</b></span>
</div>
</section>

<!-- KPI strip — a level toggle (strategy / exchange / total) over the merged
PnL / fees / ratios. The buttons refetch /api/kpi?level=. -->
<section class="card" id="kpi-card">
Expand Down Expand Up @@ -34,21 +46,25 @@ <h2 style="margin:0;">Performance</h2>
</div>
</section>

<!-- Positions — a group-by control (none / crypto / exchange). -->
<!-- Positions — a group-by control (none / strategy / crypto / exchange), by
strategy by default so the page answers "how is each strategy doing?" at a
glance. Grouping by strategy drops the now-redundant Strategy column from
both the header and the row cells (the group header already names it); the
<thead> is rebuilt in JS per grouping mode to keep colspans consistent. -->
<section class="card" id="positions-card">
<div class="row" style="justify-content:space-between;">
<h2 style="margin:0;">Positions</h2>
<div class="row" id="pos-groups" role="tablist" aria-label="Group positions by">
<button type="button" class="btn pos-group is-active" data-group="">Flat</button>
<button type="button" class="btn pos-group" data-group="">Flat</button>
<button type="button" class="btn pos-group is-active" data-group="strategy">By strategy</button>
<button type="button" class="btn pos-group" data-group="crypto">By crypto</button>
<button type="button" class="btn pos-group" data-group="exchange">By exchange</button>
</div>
</div>
<div class="table-wrap">
<table id="positions-table">
<thead>
<thead id="positions-thead">
<tr>
<th>Strategy</th>
<th>Exchange</th>
<th>Instrument</th>
<th class="num">Net qty</th>
Expand All @@ -58,7 +74,7 @@ <h2 style="margin:0;">Positions</h2>
</tr>
</thead>
<tbody id="positions-body">
<tr class="empty"><td colspan="7">Loading…</td></tr>
<tr class="empty"><td colspan="6">Loading…</td></tr>
</tbody>
</table>
</div>
Expand Down Expand Up @@ -97,9 +113,30 @@ <h2>Open orders</h2>
// 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) {
Expand All @@ -110,6 +147,69 @@ <h2>Open orders</h2>
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');
Expand Down Expand Up @@ -144,10 +244,24 @@ <h2>Open orders</h2>
}

// --- 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 <thead> rebuilt per grouping mode so colspans stay consistent.
function positionsTheadHtml(includeStrategy) {
return '<tr>' +
(includeStrategy ? '<th>Strategy</th>' : '') +
'<th>Exchange</th>' +
'<th>Instrument</th>' +
'<th class="num">Net qty</th>' +
'<th class="num">Avg entry</th>' +
'<th class="num">Realised PnL</th>' +
'<th class="num">Fees</th>' +
'</tr>';
}
function positionRowHtml(r, includeStrategy) {
const unit = tbFmt.splitInstrument(r.instrument); // {base, quote}
return '<tr>' +
'<td>' + escapeHtml(r.strategy) + '</td>' +
(includeStrategy ? '<td>' + escapeHtml(r.strategy) + '</td>' : '') +
'<td>' + escapeHtml(r.exchange) + '</td>' +
'<td>' + escapeHtml(r.instrument) + '</td>' +
'<td class="num">' + tbFmt.qtyCell(r.net_qty, { base: unit.base }) + '</td>' +
Expand All @@ -163,6 +277,9 @@ <h2>Open orders</h2>
'<span class="group-count">' + count + '</span></td></tr>';
}
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)
Expand All @@ -171,27 +288,27 @@ <h2>Open orders</h2>
data = await api('GET', url);
} catch (e) {
document.getElementById('positions-body').innerHTML =
'<tr class="empty"><td colspan="7">' + escapeHtml(e.message) + '</td></tr>';
'<tr class="empty"><td colspan="' + colspan + '">' + escapeHtml(e.message) + '</td></tr>';
return;
}
const body = document.getElementById('positions-body');
let html = '';
if (POS_GROUP) {
// Grouped: [{group, rows}] — a header row per bucket.
if (!data.length) {
body.innerHTML = '<tr class="empty"><td colspan="7">No open positions.</td></tr>';
body.innerHTML = '<tr class="empty"><td colspan="' + colspan + '">No open positions.</td></tr>';
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 = '<tr class="empty"><td colspan="7">No open positions.</td></tr>';
body.innerHTML = '<tr class="empty"><td colspan="' + colspan + '">No open positions.</td></tr>';
return;
}
html = data.map(positionRowHtml).join('');
html = data.map(function (r) { return positionRowHtml(r, includeStrategy); }).join('');
}
body.innerHTML = html;
}
Expand All @@ -204,8 +321,11 @@ <h2>Open orders</h2>
} catch (e) {
document.getElementById('orders-body').innerHTML =
'<tr class="empty"><td colspan="8">' + escapeHtml(e.message) + '</td></tr>';
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 = '<tr class="empty"><td colspan="8">No open orders.</td></tr>';
Expand All @@ -228,31 +348,43 @@ <h2>Open orders</h2>
}

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();
});
});
Expand Down
13 changes: 13 additions & 0 deletions trading_bot/tests/interfaces/test_dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading