From 6374ee8855a62d173b63be8004ed0040c70caa8c Mon Sep 17 00:00:00 2001 From: heyitsStylez Date: Thu, 7 May 2026 13:32:34 +0800 Subject: [PATCH] Fix: split DTE (live) vs Term (frozen) across position tables Open Positions DTE now shows a live countdown computed from expiry; column sorts by expiry. Position History header renamed to "Term"; value remains the stored original t.dte. Closes #45. Co-Authored-By: Claude Opus 4.7 --- src/js/06-render-table.js | 15 +++- test/integration/dte-term-split.test.js | 91 +++++++++++++++++++++++++ 2 files changed, 103 insertions(+), 3 deletions(-) create mode 100644 test/integration/dte-term-split.test.js diff --git a/src/js/06-render-table.js b/src/js/06-render-table.js index 8917aa6..028ade0 100644 --- a/src/js/06-render-table.js +++ b/src/js/06-render-table.js @@ -46,7 +46,7 @@ function _th(label, col, s, fn) { function _openHeaders() { const s = tSortOpen, fn = 'sortOpen'; return _th('Asset','asset',s,fn) + _th('Platform','platform',s,fn) + _th('Date','date',s,fn) - + _th('Expiry','expiry',s,fn) + _th('DTE','dte',s,fn) + _th('Type','type',s,fn) + + _th('Expiry','expiry',s,fn) + _th('DTE','expiry',s,fn) + _th('Type','type',s,fn) + _th('Strike','strike',s,fn) + _th('Size','size',s,fn) + _th('Premium','premium',s,fn) + _th('APR','annual',s,fn) + ''; } @@ -54,11 +54,20 @@ function _openHeaders() { function _histHeaders() { const s = tSortHist, fn = 'sortHist'; return _th('Asset','asset',s,fn) + _th('Platform','platform',s,fn) + _th('Date','date',s,fn) - + _th('Expiry','expiry',s,fn) + _th('DTE','dte',s,fn) + _th('Type','type',s,fn) + + _th('Expiry','expiry',s,fn) + _th('Term','dte',s,fn) + _th('Type','type',s,fn) + _th('Strike','strike',s,fn) + _th('Size','size',s,fn) + _th('Premium','premium',s,fn) + _th('APR','annual',s,fn) + _th('Outcome','outcome',s,fn) + ''; } +function _liveDte(expiry) { + if (!expiry) return '—'; + const today = new Date(); today.setHours(0, 0, 0, 0); + const exp = new Date(expiry + 'T00:00:00'); + const days = Math.round((exp - today) / 86400000); + if (days <= 0) return 'today'; + return days + 'd'; +} + function _openRow(r) { const assetCls = { BTC:'bbtc', ETH:'beth', HYPE:'bhype', SOL:'bsol' }[r.asset] || 'bbtc'; const isHolding = r.type === 'HOLDING'; @@ -81,7 +90,7 @@ function _openRow(r) { + '' + platBadge + '' + '' + r.date + '' + '' + (isHolding ? '—' : (r.expiry || '—')) + '' - + '' + (isHolding ? '—' : (r.dte || '—')) + '' + + '' + (isHolding ? '—' : _liveDte(r.expiry)) + '' + '' + typeBadge + '' + '$' + fmt(r.strike) + (isHolding ? '
cost basis' : '') + '' + '' + r.size + ' ' + r.asset + '' diff --git a/test/integration/dte-term-split.test.js b/test/integration/dte-term-split.test.js new file mode 100644 index 0000000..a17b711 --- /dev/null +++ b/test/integration/dte-term-split.test.js @@ -0,0 +1,91 @@ +const test = require('node:test'); +const assert = require('node:assert'); +const { setupJsdom } = require('../helpers/setupJsdom'); + +function isoDaysFromToday(days) { + // Local-date string (not UTC) so it round-trips with `new Date(s + 'T00:00:00')`. + 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').slice(0, 2); + return yyyy + '-' + mm + '-' + dd; +} + +// Find the labelled `label` in the given thead element and invoke its onclick. +function clickHeader(window, theadId, label) { + const ths = window.document.querySelectorAll('#' + theadId + ' th'); + for (const th of ths) { + if (th.textContent.trim().startsWith(label)) { + const m = /(\w+)\('([^']+)'\)/.exec(th.getAttribute('onclick') || ''); + if (m) window[m[1]](m[2]); + return; + } + } + throw new Error('header not found: ' + label); +} + +test('Open Positions DTE column shows live countdown, not stored t.dte', (t) => { + // Stored dte=99 is the original term at open; expiry is 3d away — live should win. + const openPut = { + id: 1, asset: 'BTC', type: 'PUT', + date: '2026-01-01', expiry: isoDaysFromToday(3), + dte: 99, strike: 50000, size: 0.05, premium: 100, + outcome: 'OPEN', closeCost: 0, platform: 'RYSK', + }; + const { window, teardown } = setupJsdom({ trades: [openPut] }); + t.after(teardown); + + const cells = window.document.querySelectorAll('#ttbody-open tr td'); + // Column index 4 is the DTE cell (Asset, Platform, Date, Expiry, DTE, ...). + const dteCellText = cells[4].textContent; + assert.ok(/\b3d\b|\b3\b/.test(dteCellText), 'expected live "3" countdown, got: ' + dteCellText); + assert.ok(!/99/.test(dteCellText), 'should not render stored term 99 in live DTE column'); +}); + +test('Position History column header reads "Term"; cell shows stored t.dte', (t) => { + const settled = { + id: 2, asset: 'BTC', type: 'PUT', + date: '2026-01-01', expiry: '2026-01-22', + dte: 21, strike: 50000, size: 0.05, premium: 100, + outcome: 'EXPIRED', closeCost: 0, platform: 'RYSK', + }; + const { window, teardown } = setupJsdom({ trades: [settled] }); + t.after(teardown); + + const histHdr = window.document.getElementById('hist-hdr'); + assert.ok(/Term/.test(histHdr.textContent), 'history header should contain "Term"'); + assert.ok(!/\bDTE\b/.test(histHdr.textContent), 'history header should not contain "DTE"'); + + const cells = window.document.querySelectorAll('#ttbody-hist tr td'); + // Same column position (4) — stored 21. + assert.strictEqual(cells[4].textContent.trim(), '21'); +}); + +test('Open Positions DTE column sorts by expiry', (t) => { + // Two opens: stored dte order (5, 10) is opposite to expiry order. + // After clicking DTE, ascending order should be by expiry (soonest first). + // All three trades share the same stored dte (20). Old behavior would tie on + // dte and fall back to insertion order — BTC first regardless of direction. + // New behavior sorts by expiry, so ETH (3d) or HYPE (20d) leads, never BTC. + const trades = [ + { id: 1, asset: 'BTC', type: 'PUT', date: '2026-01-01', expiry: isoDaysFromToday(10), + dte: 20, strike: 50000, size: 0.05, premium: 100, outcome: 'OPEN', closeCost: 0, platform: 'RYSK' }, + { id: 2, asset: 'ETH', type: 'PUT', date: '2026-01-01', expiry: isoDaysFromToday(3), + dte: 20, strike: 3000, size: 0.5, premium: 50, outcome: 'OPEN', closeCost: 0, platform: 'RYSK' }, + { id: 3, asset: 'HYPE', type: 'PUT', date: '2026-01-01', expiry: isoDaysFromToday(20), + dte: 20, strike: 20, size: 50, premium: 30, outcome: 'OPEN', closeCost: 0, platform: 'RYSK' }, + ]; + const { window, teardown } = setupJsdom({ trades }); + t.after(teardown); + + clickHeader(window, 'open-hdr', 'DTE'); + const firstA = window.document.querySelector('#ttbody-open tr td').textContent.trim(); + clickHeader(window, 'open-hdr', 'DTE'); + const firstB = window.document.querySelector('#ttbody-open tr td').textContent.trim(); + + const seen = new Set([firstA, firstB]); + assert.deepStrictEqual([...seen].sort(), ['ETH', 'HYPE'], + 'expected ETH (3d) and HYPE (20d) at the ends across two clicks; BTC must not lead'); +});