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
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ globals, and 17-boot.js runs an IIFE last to bootstrap the app.
| `05a-merge-open-lots.js` | 113 | `mergeOpenLots(trades, asset)` → `trades'`. Pure helper that merges all open lots for one asset (size-weighted `costBasis`, summed `lotPremiums`, earliest opener kept, CALL `lotNum` cleared). Prefers `lotEngine`, falls back to `compute` or a HOLDING/ASSIGNED heuristic for Node tests |
| `05b-pnl.js` | 90 | `computePnl(trades, assetFilter, livePrices)` → `{ realised, unrealised, total, missingSpotAssets, realisedSeries, realisedByMonth }`. Cash-flow-lens P&L calculator. Realised: `Σ settled netPrem + Σ (strike − costBasis) × calledSize` (open contributions are zero). Unrealised: `Σ over open lots of (spot − costBasis) × size`, marked against raw `costBasis` (never `netCost`); assets missing spot are excluded from the sum and reported in `missingSpotAssets`. Total = Realised + Unrealised. HOLDING- and ASSIGNED-originated lots are treated symmetrically in both paths. Pure; dual-exported. ADR: `docs/adr/0003-pnl-cash-flow-lens.md` |
| `05c-outcome-distribution.js` | 32 | `outcomeDistribution(trades, assetFilter)` → `[{outcome, count, premium}]`. Pure helper for the Position History outcome treemap. Excludes OPEN, orders EXPIRED/ASSIGNED/CALLED/CLOSED, nets `closeCost` from CLOSED premium (cash-flow lens). Dual-exported |
| `06-render-table.js` | 452 | `sortOpen/sortHist`, `renderExpiryTable` (today badge + mobile cards), `fetchExpiryPrices` (CoinGecko, calls full `render()` on success), `rTable` (holdings cards, open & history tables, history filter application), `rStats` (just delegates to `renderExpiryTable`), `exportHistoryCSV` (downloads filtered history as CSV) |
| `06-render-table.js` | 452 | `sortOpen/sortHist`, `renderExpiryTable(allRows)` (today badge + mobile cards — reads enriched rows from `compute`, uses `r.annual` and `_liveDte`; no longer reads raw `trades[]`), `fetchExpiryPrices` (CoinGecko, calls `render()` on success), `rTable` (holdings cards, open & history tables, history filter application), `rStats(streams, lots, allRows, displayRows)` (forwards `allRows` to `renderExpiryTable`), `exportHistoryCSV` (downloads filtered history as CSV) |
| `06a-render-outcome-chart.js` | 75 | `rOutcomeChart()` — renders a horizontal treemap of `outcomeDistribution` into `#hist-outchart` when ≥10 settled trades; otherwise hides itself and shows `#hist-pills`. Each cell click toggles `setHistOutcome`. Cells coloured via CSS vars (EXPIRED/ASSIGNED/CALLED/CLOSED → green/red/orange/blue) |
| `07-render-charts.js` | 640 | `setCpnlPeriod` (1M/3M/ALL), `rCpnlChart` (cumulative Realised P&L hero — sources `realisedSeries` from `computePnl` — plus secondary Realised sparkline), `rCharts` (Premium P&L total/monthly tabs — Total tab consumes `computePnl` for the Realised tile), `cOpts` (Chart.js options factory) |
| `08-render.js` | 8 | `render()` — orchestrator: `compute → rStats → rTable → rOutcomeChart → rCharts` |
Expand Down
2 changes: 1 addition & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ flowchart TD
LS -->|load on boot| Trades
Trades -->|per-asset slice| LE
LE -->|lots + accounting| CO
CO -->|displayRows| RT
CO -->|allRows + displayRows| RT
CO -->|streams| RC
Trades -->|all trades| CP
CP -->|realisedSeries\nrealised + unrealised| RC
Expand Down
58 changes: 26 additions & 32 deletions src/js/06-render-table.js
Original file line number Diff line number Diff line change
Expand Up @@ -133,11 +133,11 @@ function _histRow(r) {
+ '</tr>';
}

function rStats(streams, lots, displayRows) {
renderExpiryTable();
function rStats(streams, lots, allRows, displayRows) {
renderExpiryTable(allRows);
}

function renderExpiryTable() {
function renderExpiryTable(allRows) {
const wrap = document.getElementById('expiry-table-wrap');
if (!wrap) return;

Expand All @@ -146,12 +146,12 @@ function renderExpiryTable() {
const weekOut = new Date(today);
weekOut.setDate(weekOut.getDate() + 7);

const expiring = trades.filter(t => {
if (t.outcome !== 'OPEN') return false;
if (t.type !== 'PUT' && t.type !== 'CALL') return false;
if (sFilter !== 'ALL' && t.asset !== sFilter) return false;
if (!t.expiry) return false;
const exp = new Date(t.expiry + 'T00:00:00');
const expiring = (allRows || []).filter(r => {
if (r.outcome !== 'OPEN') return false;
if (r.type !== 'PUT' && r.type !== 'CALL') return false;
if (sFilter !== 'ALL' && r.asset !== sFilter) return false;
if (!r.expiry) return false;
const exp = new Date(r.expiry + 'T00:00:00');
return exp <= weekOut;
}).sort((a, b) => new Date(a.expiry) - new Date(b.expiry) || a.asset.localeCompare(b.asset));

Expand All @@ -177,38 +177,32 @@ function renderExpiryTable() {

const assetCol = { BTC: 'btc', ETH: 'eth', HYPE: 'hype', SOL: 'sol' };

const enriched = expiring.map(t => {
const exp = new Date(t.expiry + 'T00:00:00');
const daysLeft = Math.round((exp - today) / (1000 * 60 * 60 * 24));
const dteLabel = daysLeft <= 0
? '<span style="color:var(--red);font-weight:700">today</span>'
: daysLeft + 'd';
let aprHtml = '—';
if (t.dte > 0 && t.strike > 0 && t.size > 0) {
const ann = (t.premium / (t.strike * t.size)) * (365 / t.dte) * 100;
aprHtml = ann.toFixed(1) + '%';
}
const enriched = expiring.map(r => {
const exp = new Date(r.expiry + 'T00:00:00');
const daysLeft = Math.round((exp - today) / 86400000);
const dteLabel = _liveDte(r.expiry);
const aprHtml = r.annual != null ? r.annual.toFixed(1) + '%' : '—';
let statusHtml = '<span style="color:var(--mu)">—</span>';
const spot = livePrices[t.asset];
const spot = livePrices[r.asset];
if (spot) {
const isPut = t.type === 'PUT';
const isOTM = isPut ? spot > t.strike : spot < t.strike;
const pct = Math.abs((spot - t.strike) / spot * 100).toFixed(1);
const isPut = r.type === 'PUT';
const isOTM = isPut ? spot > r.strike : spot < r.strike;
const pct = Math.abs((spot - r.strike) / spot * 100).toFixed(1);
statusHtml = isOTM
? '<span class="exp-otm">OTM ' + pct + '%</span>'
: '<span class="exp-itm">ITM ' + pct + '%</span>';
}
const col = assetCol[t.asset] || 'mu2';
const platBadge = (t.platform === 'HSFC')
const col = assetCol[r.asset] || 'mu2';
const platBadge = (r.platform === 'HSFC')
? '<span class="bplat bplat-hsfc">HSFC</span>'
: '<span class="bplat bplat-rysk">RYSK</span>';
const actionsHtml = '<div class="row-actions">'
+ '<button class="btn-qa btn-qa-exp" onclick="quickOutcome(' + t.id + ',\'EXPIRED\')" title="Mark expired">Exp \u2713</button>'
+ (t.type === 'CALL'
? '<button class="btn-qa btn-qa-cal" onclick="quickOutcome(' + t.id + ',\'CALLED\')" title="Mark called away">Called \u2191</button>'
: '<button class="btn-qa btn-qa-asg" onclick="quickOutcome(' + t.id + ',\'ASSIGNED\')" title="Mark assigned">Asgn \u2193</button>')
+ '<button class="btn-qa btn-qa-exp" onclick="quickOutcome(' + r.id + ',\'EXPIRED\')" title="Mark expired">Exp </button>'
+ (r.type === 'CALL'
? '<button class="btn-qa btn-qa-cal" onclick="quickOutcome(' + r.id + ',\'CALLED\')" title="Mark called away">Called </button>'
: '<button class="btn-qa btn-qa-asg" onclick="quickOutcome(' + r.id + ',\'ASSIGNED\')" title="Mark assigned">Asgn </button>')
+ '</div>';
return { t, col, dteLabel, aprHtml, statusHtml, platBadge, actionsHtml, daysLeft };
return { t: r, col, dteLabel, aprHtml, statusHtml, platBadge, actionsHtml, daysLeft };
});

const rows = enriched.map(e => {
Expand Down Expand Up @@ -267,7 +261,7 @@ function fetchExpiryPrices() {
const el = document.getElementById('expiry-last-refreshed');
if (el) { const n = new Date(); el.textContent = 'refreshed ' + String(n.getUTCHours()).padStart(2,'0') + ':' + String(n.getUTCMinutes()).padStart(2,'0') + ' UTC'; }
// Re-render whole page so holdings cards pick up live spot too
if (typeof render === 'function') render(); else renderExpiryTable();
render();
})
.catch(() => { /* silently fail — table shows — for status */ });
}
Expand Down
2 changes: 1 addition & 1 deletion src/js/08-render.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// ── RENDER
function render() {
const { streams, lots, allRows, displayRows } = compute(sFilter);
rStats(streams, lots, displayRows);
rStats(streams, lots, allRows, displayRows);
rTable(displayRows, streams, lots);
rOutcomeChart();
rCharts(displayRows, lots);
Expand Down
63 changes: 63 additions & 0 deletions test/integration/expiry-table.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
const test = require('node:test');
const assert = require('node:assert');
const { setupJsdom } = require('../helpers/setupJsdom');

function isoDaysFromToday(days) {
const d = new Date();
d.setHours(0, 0, 0, 0);
d.setDate(d.getDate() + days);
const yyyy = d.getFullYear();
const mm = String(d.getMonth() + 1).padStart(2, '0');
const dd = String(d.getDate()).padStart(2, '0');
return yyyy + '-' + mm + '-' + dd;
}

test('Expiring This Week APR matches Open Positions APR for the same trade', (t) => {
// dte=21, premium=150, strike=50000, size=0.05
// annual = (150 / (50000 * 0.05)) * (365 / 21) * 100 ≈ 104.8%
const openPut = {
id: 1, asset: 'BTC', type: 'PUT',
date: '2026-01-01', expiry: isoDaysFromToday(3),
dte: 21, strike: 50000, size: 0.05, premium: 150,
outcome: 'OPEN', closeCost: 0, platform: 'RYSK',
};
const { window, teardown } = setupJsdom({ trades: [openPut] });
t.after(teardown);

// Get APR from Open Positions table (column index 9 = APR: Asset,Platform,Date,Expiry,DTE,Type,Strike,Size,Premium,APR)
const openCells = window.document.querySelectorAll('#ttbody-open tr td');
const openApr = openCells[9].textContent.trim();

// Get APR from Expiring This Week table (column index 7 = APR)
const expRows = window.document.querySelectorAll('.expiry-tbl tbody tr td');
const expiryApr = expRows[7].textContent.trim();

assert.ok(openApr.length > 0, 'open positions APR should not be empty');
assert.strictEqual(expiryApr, openApr, 'Expiring This Week APR must match Open Positions APR');
});

test('Expiring This Week respects asset filter', (t) => {
const btcPut = {
id: 1, asset: 'BTC', type: 'PUT',
date: '2026-01-01', expiry: isoDaysFromToday(3),
dte: 21, strike: 50000, size: 0.05, premium: 100,
outcome: 'OPEN', closeCost: 0, platform: 'RYSK',
};
const ethPut = {
id: 2, asset: 'ETH', type: 'PUT',
date: '2026-01-01', expiry: isoDaysFromToday(4),
dte: 21, strike: 3000, size: 0.5, premium: 50,
outcome: 'OPEN', closeCost: 0, platform: 'RYSK',
};
const { window, teardown } = setupJsdom({ trades: [btcPut, ethPut] });
t.after(teardown);

// Apply ETH filter
window.setFilter('ETH');
window.render();

const rows = window.document.querySelectorAll('.expiry-tbl tbody tr');
assert.strictEqual(rows.length, 1, 'only 1 row should appear with ETH filter');
const assetBadge = rows[0].querySelector('.badge').textContent.trim();
assert.strictEqual(assetBadge, 'ETH', 'the remaining row should be ETH');
});
Loading