diff --git a/src/css/styles.css b/src/css/styles.css
index 303c6a1..386124b 100644
--- a/src/css/styles.css
+++ b/src/css/styles.css
@@ -583,6 +583,52 @@ td.td-act { width:1px; white-space:nowrap; padding-right:12px; }
}
.cpnl-hero-change .chg-pos { color: var(--green); font-weight: 600; }
.cpnl-hero-change .chg-neg { color: var(--red); font-weight: 600; }
+.cpnl-hero-tile {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-end;
+ gap: 4px;
+ margin-left: auto;
+ padding: 0 8px;
+ border-left: 1px solid var(--bd);
+ padding-left: 16px;
+}
+.cpnl-tile-row {
+ display: flex;
+ align-items: baseline;
+ gap: 8px;
+ font-family: var(--mono);
+}
+.cpnl-tile-lbl {
+ font-size: .6rem;
+ letter-spacing: 2px;
+ color: var(--mu);
+ text-transform: uppercase;
+}
+.cpnl-tile-unrealised {
+ font-size: 1rem;
+ font-weight: 600;
+ color: var(--text);
+ letter-spacing: -.5px;
+}
+.cpnl-tile-unrealised .pos { color: var(--green); }
+.cpnl-tile-unrealised .neg { color: var(--red); }
+.cpnl-tile-total {
+ font-size: 2rem;
+ font-weight: 700;
+ line-height: 1;
+ letter-spacing: -1px;
+ color: var(--text);
+}
+.cpnl-tile-total .pos { color: var(--green); }
+.cpnl-tile-total .neg { color: var(--red); }
+.cpnl-tile-sub {
+ font-family: var(--mono);
+ font-size: .65rem;
+ color: var(--mu);
+ margin-top: 2px;
+}
+.cpnl-tile-sub:empty { display: none; }
.cpnl-period-btns {
display: flex;
gap: 4px;
diff --git a/src/html/body.html b/src/html/body.html
index 7408aa8..76d33a5 100644
--- a/src/html/body.html
+++ b/src/html/body.html
@@ -41,6 +41,17 @@
—
+
+
+ Unrealised
+ —
+
+
+ Total
+ —
+
+
+
diff --git a/src/js/07-render-charts.js b/src/js/07-render-charts.js
index 79e6660..91c2503 100644
--- a/src/js/07-render-charts.js
+++ b/src/js/07-render-charts.js
@@ -8,7 +8,48 @@ function setCpnlPeriod(p) {
rCpnlChart();
}
+function rHeroTile() {
+ const tEl = document.getElementById('cpnl-tile-total');
+ const uEl = document.getElementById('cpnl-tile-unrealised');
+ const sEl = document.getElementById('cpnl-tile-sub');
+ if (!tEl || !uEl || !sEl) return;
+
+ const { realised, unrealised, total, missingSpotAssets } = computePnl(trades, sFilter, livePrices);
+ const { lots } = compute(sFilter);
+
+ const openAssets = new Set();
+ Object.keys(lots).forEach(a => {
+ if (sFilter !== 'ALL' && a !== sFilter) return;
+ lots[a].forEach(l => { if (!l.endDate && l.size > 0) openAssets.add(a); });
+ });
+ const openLotsCount = openAssets.size;
+ const allMissing = openLotsCount > 0 && missingSpotAssets.length === openLotsCount;
+
+ function signed(v) {
+ const cls = v >= 0 ? 'pos' : 'neg';
+ return '' + (v >= 0 ? '+$' : '-$') + fmt(Math.abs(v)) + '';
+ }
+
+ if (openLotsCount === 0) {
+ uEl.innerHTML = '—';
+ tEl.innerHTML = signed(realised);
+ sEl.textContent = '';
+ } else if (allMissing) {
+ uEl.innerHTML = '—';
+ tEl.innerHTML = '—';
+ sEl.textContent = 'spot unavailable: ' + missingSpotAssets.join(', ');
+ } else {
+ uEl.innerHTML = signed(unrealised);
+ tEl.innerHTML = signed(total);
+ sEl.textContent = missingSpotAssets.length
+ ? 'spot unavailable: ' + missingSpotAssets.join(', ')
+ : '';
+ }
+}
+
function rCpnlChart() {
+ rHeroTile();
+
const area = document.getElementById('cpnl-chart-area');
if (!area) return;
diff --git a/test/integration/hero-total-tile.test.js b/test/integration/hero-total-tile.test.js
new file mode 100644
index 0000000..0f5818c
--- /dev/null
+++ b/test/integration/hero-total-tile.test.js
@@ -0,0 +1,97 @@
+const test = require('node:test');
+const assert = require('node:assert');
+const { setupJsdom } = require('../helpers/setupJsdom');
+
+const HOLDING_ETH = {
+ id: 1, asset: 'ETH', type: 'HOLDING', date: '2026-01-01', expiry: '',
+ dte: null, strike: 3000, size: 1, premium: 0, outcome: 'OPEN',
+ closeCost: 0, platform: 'SPOT',
+};
+const PUT_ETH_EXPIRED = {
+ id: 2, asset: 'ETH', type: 'PUT', date: '2026-01-01', expiry: '2026-01-15',
+ dte: 14, strike: 2800, size: 1, premium: 100, outcome: 'EXPIRED',
+ closeCost: 0, platform: 'RYSK',
+};
+const HOLDING_BTC = {
+ id: 3, asset: 'BTC', type: 'HOLDING', date: '2026-01-01', expiry: '',
+ dte: null, strike: 50000, size: 0.1, premium: 0, outcome: 'OPEN',
+ closeCost: 0, platform: 'SPOT',
+};
+
+test('hero band contains a Total P&L tile in the header row', (t) => {
+ const { window, teardown } = setupJsdom({ trades: [HOLDING_ETH] });
+ t.after(teardown);
+
+ const tile = window.document.getElementById('cpnl-tile');
+ assert.ok(tile, '#cpnl-tile should exist in the hero band');
+
+ const hero = window.document.getElementById('cpnl-hero');
+ assert.ok(hero.contains(tile), 'tile must live inside the hero band');
+});
+
+test('hero tile Total = Realised + Unrealised', (t) => {
+ // PUT EXPIRED netPrem 100 + HOLDING ETH 3000 size 1 spot 3500 → total = 600.
+ const { window, teardown } = setupJsdom({ trades: [PUT_ETH_EXPIRED, HOLDING_ETH] });
+ t.after(teardown);
+
+ window.livePrices = { ETH: 3500 };
+ window.render();
+
+ const total = window.document.getElementById('cpnl-tile-total').textContent;
+ assert.match(total, /\+\$600/, `expected +$600 in hero Total, got "${total}"`);
+
+ const unrealised = window.document.getElementById('cpnl-tile-unrealised').textContent;
+ assert.match(unrealised, /\+\$500/, `expected +$500 Unrealised, got "${unrealised}"`);
+});
+
+test('hero tile respects asset filter', (t) => {
+ const { window, teardown } = setupJsdom({ trades: [HOLDING_BTC, HOLDING_ETH] });
+ t.after(teardown);
+
+ window.livePrices = { BTC: 52000, ETH: 3500 };
+ window.setFilter('BTC');
+
+ // BTC: (52000-50000)*0.1 = 200
+ const total = window.document.getElementById('cpnl-tile-total').textContent;
+ const unrealised = window.document.getElementById('cpnl-tile-unrealised').textContent;
+ assert.match(unrealised, /\+\$200/, `BTC-only Unrealised should be +$200, got "${unrealised}"`);
+ assert.match(total, /\+\$200/, `BTC-only Total should be +$200, got "${total}"`);
+});
+
+test('hero tile partial missing-spot: renders partial Total + sub-line, no asterisk', (t) => {
+ const { window, teardown } = setupJsdom({ trades: [HOLDING_ETH, HOLDING_BTC] });
+ t.after(teardown);
+
+ window.livePrices = { ETH: 3500 }; // BTC missing
+ window.render();
+
+ const total = window.document.getElementById('cpnl-tile-total').textContent;
+ const sub = window.document.getElementById('cpnl-tile-sub').textContent;
+ // Partial = ETH unrealised only = 500
+ assert.match(total, /\+\$500/, `expected +$500 partial Total, got "${total}"`);
+ assert.match(sub, /BTC/, `sub-line should call out BTC, got "${sub}"`);
+ const tile = window.document.getElementById('cpnl-tile');
+ assert.ok(!tile.textContent.includes('*'),
+ `tile must not contain an asterisk under partial state, got "${tile.textContent}"`);
+});
+
+test('hero tile full missing-spot: dashes + sub-line; Realised stays visible', (t) => {
+ const { window, teardown } = setupJsdom({ trades: [HOLDING_ETH, PUT_ETH_EXPIRED] });
+ t.after(teardown);
+
+ // livePrices stays {} → ETH spot missing for the only open lot.
+ const total = window.document.getElementById('cpnl-tile-total').textContent;
+ const unrealised = window.document.getElementById('cpnl-tile-unrealised').textContent;
+ const sub = window.document.getElementById('cpnl-tile-sub').textContent;
+ assert.match(unrealised, /^—|^-$/, `expected dash Unrealised, got "${unrealised}"`);
+ assert.match(total, /^—|^-$/, `expected dash Total, got "${total}"`);
+ assert.match(sub, /spot unavailable/i, `sub-line should say spot unavailable, got "${sub}"`);
+
+ // Realised line header (#cpnl-val) still shows the +$100 from the EXPIRED PUT.
+ const heroVal = window.document.getElementById('cpnl-val').textContent;
+ assert.match(heroVal, /\+\$100/, `Realised hero number should still render, got "${heroVal}"`);
+
+ const tile = window.document.getElementById('cpnl-tile');
+ assert.ok(!tile.textContent.includes('*'),
+ `tile must not contain an asterisk under full-miss state, got "${tile.textContent}"`);
+});