From 3180f93a998b8db8f0049ac58a415427151a5ff7 Mon Sep 17 00:00:00 2001 From: heyitsStylez Date: Thu, 15 Oct 2026 20:30:49 +0800 Subject: [PATCH] Wheeler: roll an open PUT/CALL to a new strike/expiry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Roll ↻ quick-action on open option rows/cards opens a modal (buy-back cost, roll date, new expiry/strike/contracts/premium, live net credit/debit). Saves as the old leg CLOSED + a new OPEN leg carrying rolledFrom (and lotNum for calls), so lot engine and P&L are unchanged. Position History tags rolled closed legs with ↻ rolled. --- CLAUDE.md | 1 + src/css/styles.css | 2 + src/html/modals.html | 25 +++++++ src/js/core/06-render-table.js | 13 +++- src/js/tradfi/21-roll.js | 101 +++++++++++++++++++++++++++ test/integration/wheeler-app.test.js | 48 +++++++++++++ test/unit/roll.test.js | 35 ++++++++++ 7 files changed, 224 insertions(+), 1 deletion(-) create mode 100644 src/js/tradfi/21-roll.js create mode 100644 test/unit/roll.test.js diff --git a/CLAUDE.md b/CLAUDE.md index 736c266..5ff7a21 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -162,6 +162,7 @@ Tests mirror this: `test/helpers/assemble.js` does the fragment substitution and | `12-cloud-sync.js` | ~95 | **Wheeler cloud sync** (#110, ADR 0007, Design B): `authInit` (boot: GET `/api/wheeler-sync` → set gate UI, then **union-merge** local+remote via `mergeTradesById` and converge whichever store is behind — never wholesale-replaces, so a pull can't drop a local-only trade), `mergeTradesById(local, remote, preferRemote)` (pure, dual-exported: union by `id`, newer side wins shared ids; deletes don't propagate), `cloudPush`/`scheduleCloudPush` (debounced POST of the FULL trade array), `wheelerSignIn`/`wheelerSignOut`, `_setAuthUI`, `_setCloudStatus`. Auth state via session cookie; only pushes when signed in | | `17-boot.js` | ~23 | **Wheeler boot**: no wallet popup / chain. `trades = await loadTrades()`, `sAsset = ''` (free-text ticker), `setType(sType)`, `render()`, `fetchExpiryPrices()`, `authInit()` (fire-and-forget). Empty trades → rTable's "add your first trade" prompt | | `20-ditm-calc.js` | ~120 | **DITM call calculator** (Wheeler-only, header `[ DITM CALC ]` button → `openDitmCalc()`). What-if modal (`#ditm-overlay` in shared `modals.html`) modelling a long deep-ITM call as a stock substitute vs. buying 100 shares — mirrors sheet 1 of `ditm_call_tracker.xlsx`. Inputs price/strike/premium/expiry → break-even (`strike+prem`), debit/max-loss, cost-of-100-shares, DTE tiles + an auto-generated price-level outcomes matrix (`ditmLevels`). Pure scratchpad — **nothing is saved to `trades`**. Own Esc/click-outside handlers | +| `21-roll.js` | ~105 | **Roll a PUT/CALL** (Wheeler-only, `Roll ↻` quick-action `btn-qa-roll` on open option rows/cards → `openRollModal(id)`, `#roll-overlay` in shared `modals.html`). `rollTrade(trades, id, {closeCost, date, expiry, strike, size, premium})` (pure, dual-exported) marks the old leg CLOSED (`closeCost` = buy-back, `closeDate` = roll date) and appends a new OPEN leg of the same asset/type/platform/`lotNum` with `rolledFrom: `. Two ordinary trades, so lot engine / P&L are unchanged. Modal shows live net credit/debit; contracts round-trip via `contractsToShares`. Position History tags the closed leg `↻ rolled` (`_rolledTag` in `06-render-table.js`, derived from any trade with `rolledFrom === id`) | | `19-tradfi-form.js` | ~50 | `setTicker(v)` (mirrors free-text ticker → `sAsset`, uppercased) and `wheelerAddTrade()` — manual PUT/CALL/HOLDING entry (crypto's core `addTrade()` is HOLDING-only), routed through `save()`/`render()`. Trades carry `platform: 'MANUAL'`. Supports the CLOSED (buy-to-close) outcome: reads `f-closecost` and stores `closeCost` (netted off premium by the core cash-flow lens). **Options are entered in contracts and stored as shares** via `entryMultiplier(asset, isHolding)` — per-asset multiplier, so MES stores contracts×5. Equity/ETF holdings enter in raw shares (×1); futures holdings enter in contracts too (×multiplier), so a manual holding matches an assigned-put lot. `refreshSizeLabel` flips the field label Shares↔Contracts by ticker+type. `setSizeDisplay(unit)` flips the header contracts↔shares toggle and re-renders. Display of `size` routes through `fmtSize`/`nativeSize` in `06-render-table.js`; futures render as contracts in both toggle states (no share concept) (#91) | **Line numbers above are approximate** — they shift as the code evolves. Use them diff --git a/src/css/styles.css b/src/css/styles.css index 135b5b9..a6cb340 100644 --- a/src/css/styles.css +++ b/src/css/styles.css @@ -331,6 +331,7 @@ tr.lot-child.la-sol:hover > td{background:rgba(153,69,255,.08)} .hcard-hint{margin-top:6px;font-size:.6rem;color:var(--mu2);font-family:var(--mono);line-height:1.45} .lot-badge{font-size:.5rem;font-weight:700;letter-spacing:1.5px;text-transform:uppercase;padding:2px 6px;background:var(--s3);border:1px solid var(--bd2);color:var(--mu2);font-family:var(--mono)} .cap-badge{display:inline-block;margin-left:6px;font-size:.6rem;font-weight:700;font-family:var(--mono);letter-spacing:.3px} +.rolled-tag{display:inline-block;margin-left:6px;font-size:.6rem;font-weight:700;font-family:var(--mono);letter-spacing:.3px;color:var(--blue)} .hcard-spot-muted{color:var(--mu2)} /* Wide-card layout: when <=2 visible open lots and viewport >=720px, @@ -514,6 +515,7 @@ body::after { .btn-qa-cal { color: var(--orange); border-color: var(--ob); background: var(--od); } .btn-qa-asg { color: var(--red); border-color: var(--rb); background: var(--rd); } .btn-qa-cls { color: var(--purple); border-color: var(--pb); background: var(--pd); } +.btn-qa-roll { color: var(--blue); border-color: var(--bd2); background: var(--s2); } .btn-qa-edit { color: var(--mu2); border-color: var(--bd2); background: none; } .btn-qa:hover { filter: brightness(1.18); } /* Row action buttons — fade in on row hover */ diff --git a/src/html/modals.html b/src/html/modals.html index 2977c2a..4c1f549 100644 --- a/src/html/modals.html +++ b/src/html/modals.html @@ -25,6 +25,31 @@

Delete all trades?

+ +
+
+
+

Roll Option

+ +
+

+
+
+
+
+
+
+
+
+
+
+
+ + +
+
+
+
diff --git a/src/js/core/06-render-table.js b/src/js/core/06-render-table.js index 2117523..328605e 100644 --- a/src/js/core/06-render-table.js +++ b/src/js/core/06-render-table.js @@ -138,6 +138,7 @@ function _openRow(r) { // preset to CLOSED so the user can enter the buy-back cost. const closeBtn = (!isHolding && _isTradfi()) ? '' + + '' : ''; const actions = (isHolding ? '' : (r.type === 'CALL' ? '' @@ -162,6 +163,14 @@ function _openRow(r) { + ''; } +// "↻ rolled" tag on a CLOSED leg that was rolled into a new option (Wheeler +// roll: the replacement carries rolledFrom = this id). Derived, so deleting the +// replacement drops the tag. +function _rolledTag(r) { + if (r.outcome !== 'CLOSED' || !trades.some(t => t.rolledFrom === r.id)) return ''; + return '\u21bb rolled'; +} + // "% of premium captured" badge for buy-to-close (CLOSED) rows — how much of the // collected premium you kept by closing early. Hidden when there is no premium // to capture. Graded green/orange/red by how much was kept. @@ -207,6 +216,7 @@ function _histRow(r) { + '' + aprStr + '' + '' + outcomeLabel(r) + '' + _captureBadge(r) + + _rolledTag(r) + (r.outcome === 'CLOSED' && r.closeDate ? '
closed ' + r.closeDate + _daysEarlyTag(r) + '
' : '') + '' @@ -226,6 +236,7 @@ function _openCard(r) { + (r.annual !== null ? r.annual.toFixed(1) + '%' : '—') + ''; const closeBtn = _isTradfi() ? '' + + '' : ''; const actions = (r.type === 'CALL' ? '' @@ -254,7 +265,7 @@ function _openCard(r) { function _histCard(r) { const typeBadge = '' + r.type + ''; const platBadge = r.platform === 'HSFC' ? 'HSFC' : 'RYSK'; - const outBadge = '' + outcomeLabel(r) + '' + _captureBadge(r); + const outBadge = '' + outcomeLabel(r) + '' + _captureBadge(r) + _rolledTag(r); const premHtml = r.outcome === 'CLOSED' ? '+$' + fmt(r.premium - (r.closeCost || 0)) : '+$' + fmt(r.premium); diff --git a/src/js/tradfi/21-roll.js b/src/js/tradfi/21-roll.js new file mode 100644 index 0000000..64caaac --- /dev/null +++ b/src/js/tradfi/21-roll.js @@ -0,0 +1,101 @@ +// ── ROLL AN OPTION (Wheeler) ───────────────────────────────── +// Rolling = buy-to-close the open PUT/CALL and sell a replacement (usually a +// later expiry, maybe a new strike) in one step. Modelled as two ordinary +// trades so the lot engine / P&L need no changes: the old leg becomes CLOSED +// (closeCost = buy-back, closeDate = roll date) and a new OPEN leg of the same +// asset/type/platform/lotNum is added, carrying `rolledFrom: `. + +// Pure: returns a new trades array with the roll applied, or null if `id` is +// not an open option. `size` is stored units (shares), like trade.size. +function rollTrade(trades, id, p) { + const old = trades.find(t => t.id === id); + if (!old || old.type === 'HOLDING' || old.outcome !== 'OPEN') return null; + const newId = Math.max(Date.now(), ...trades.map(t => t.id + 1)); + const dte = Math.round((new Date(p.expiry) - new Date(p.date)) / 86400000); + const closed = { ...old, outcome: 'CLOSED', closeCost: p.closeCost, closeDate: p.date }; + const next = { + id: newId, asset: old.asset, type: old.type, date: p.date, expiry: p.expiry, + dte: dte > 0 ? dte : null, strike: p.strike, size: p.size, premium: p.premium, + outcome: 'OPEN', closeCost: 0, closeDate: '', platform: old.platform, + rolledFrom: old.id, + }; + if (old.lotNum != null) next.lotNum = old.lotNum; + return trades.map(t => (t.id === id ? closed : t)).concat(next); +} + +let _rollId = null; + +function openRollModal(id) { + const t = trades.find(t => t.id === id); + if (!t) return; + _rollId = id; + document.getElementById('roll-title').textContent = 'Roll ' + t.type + ' — ' + t.asset; + document.getElementById('roll-cur').textContent = + 'Current: $' + fmt(t.strike) + ' ' + t.type + ' exp ' + t.expiry + + ' · ' + fmt(sharesToContracts(t.size, t.asset)) + ' contracts · +$' + fmt(t.premium) + ' premium'; + const set = (k, v) => { document.getElementById('rf-' + k).value = v; }; + set('closecost', ''); + set('date', today()); + set('expiry', ''); + set('strike', t.strike); + set('size', sharesToContracts(t.size, t.asset)); + set('premium', ''); + document.getElementById('roll-err').textContent = ''; + rollRecalc(); + document.getElementById('roll-overlay').classList.add('open'); +} + +function closeRollModal() { + document.getElementById('roll-overlay').classList.remove('open'); + _rollId = null; +} + +// Live net credit/debit of the roll: new premium − buy-back cost. +function rollRecalc() { + const cc = parseFloat(document.getElementById('rf-closecost').value) || 0; + const pr = parseFloat(document.getElementById('rf-premium').value) || 0; + const net = pr - cc; + const el = document.getElementById('roll-net'); + el.textContent = (net >= 0 ? 'Net credit +$' : 'Net debit −$') + fmt(Math.abs(net)); + el.style.color = net >= 0 ? 'var(--green)' : 'var(--red)'; +} + +function confirmRoll() { + const t = trades.find(t => t.id === _rollId); + if (!t) { closeRollModal(); return; } + const err = document.getElementById('roll-err'); + const v = k => document.getElementById('rf-' + k).value.trim(); + const closeCost = parseFloat(v('closecost')); + const date = v('date'), expiry = v('expiry'); + const strike = parseFloat(v('strike')); + const size = parseFloat(v('size')); + const premium = parseFloat(v('premium')); + if (isNaN(closeCost) || closeCost < 0) { err.textContent = 'Buy-back cost required.'; return; } + if (!date) { err.textContent = 'Roll date required.'; return; } + if (!expiry || expiry <= date) { err.textContent = 'New expiry must be after the roll date.'; return; } + if (isNaN(strike) || strike <= 0) { err.textContent = 'Strike must be > 0.'; return; } + if (isNaN(size) || size <= 0) { err.textContent = 'Contracts must be > 0.'; return; } + if (isNaN(premium) || premium < 0) { err.textContent = 'New premium required.'; return; } + + const next = rollTrade(trades, t.id, + { closeCost, date, expiry, strike, size: contractsToShares(size, t.asset), premium }); + if (!next) { err.textContent = 'Only open options can be rolled.'; return; } + trades = next; + save(); render(); + fetchExpiryPrices(); + closeRollModal(); + toast(t.asset + ' ' + t.type.toLowerCase() + ' rolled to $' + fmt(strike) + ' ' + expiry); +} + +// Close on Esc / click outside (element ships to both apps, only wired on Wheeler). +(function () { + if (typeof document === 'undefined') return; + const ov = document.getElementById('roll-overlay'); + if (!ov) return; + ov.addEventListener('click', function (e) { if (e.target === ov) closeRollModal(); }); + document.addEventListener('keydown', function (e) { + if (e.key === 'Escape' && ov.classList.contains('open')) closeRollModal(); + }); +})(); + +if (typeof module !== 'undefined' && module.exports) module.exports = { rollTrade }; diff --git a/test/integration/wheeler-app.test.js b/test/integration/wheeler-app.test.js index bebf64a..f25be7d 100644 --- a/test/integration/wheeler-app.test.js +++ b/test/integration/wheeler-app.test.js @@ -469,3 +469,51 @@ test('filter tabs are derived from traded tickers and scope the view', async (t) assert.match(doc.getElementById('fb-ALL').className, /active/, 'filter falls back to ALL'); assert.match(doc.getElementById('ncbwrap').innerHTML, /IBIT/, 'remaining ticker still shows'); }); + +test('Roll button rolls an open PUT: old CLOSED, new OPEN linked', async (t) => { + const { window, teardown } = await setupJsdom({ app: 'tradfi' }); + t.after(teardown); + const doc = window.document; + const setVal = (id, v) => { doc.getElementById(id).value = v; }; + + window.setTicker('AAPL'); + window.setType('PUT'); + window.setOut('OPEN'); + setVal('f-date', '2026-10-01'); + setVal('f-expiry', '2026-10-17'); + setVal('f-strike', '200'); + setVal('f-size', '1'); + setVal('f-prem', '150'); + window.wheelerAddTrade(); + + const btn = doc.querySelector('#ttbody-open .btn-qa-roll'); + assert.ok(btn, 'open option row should have a Roll button'); + btn.click(); + assert.ok(doc.getElementById('roll-overlay').classList.contains('open')); + assert.strictEqual(doc.getElementById('rf-strike').value, '200'); + assert.strictEqual(doc.getElementById('rf-size').value, '1'); + + setVal('rf-closecost', '220'); + setVal('rf-date', '2026-10-15'); + setVal('rf-expiry', '2026-11-21'); + setVal('rf-strike', '195'); + setVal('rf-premium', '310'); + window.rollRecalc(); + assert.match(doc.getElementById('roll-net').textContent, /Net credit \+\$90/); + window.confirmRoll(); + + assert.ok(!doc.getElementById('roll-overlay').classList.contains('open')); + const stored = JSON.parse(window.localStorage.getItem('wheeler_trades')); + assert.strictEqual(stored.length, 2); + const [old, next] = stored; + assert.strictEqual(old.outcome, 'CLOSED'); + assert.strictEqual(old.closeCost, 220); + assert.strictEqual(next.outcome, 'OPEN'); + assert.strictEqual(next.strike, 195); + assert.strictEqual(next.size, 100); + assert.strictEqual(next.rolledFrom, old.id); + + // History shows the closed leg tagged as rolled; the open table has the new leg. + assert.match(doc.getElementById('ttbody-hist').innerHTML, /↻ rolled/); + assert.match(doc.getElementById('ttbody-open').innerHTML, /195/); +}); diff --git a/test/unit/roll.test.js b/test/unit/roll.test.js new file mode 100644 index 0000000..f334203 --- /dev/null +++ b/test/unit/roll.test.js @@ -0,0 +1,35 @@ +const { test } = require('node:test'); +const assert = require('node:assert'); +const { rollTrade } = require('../../src/js/tradfi/21-roll.js'); + +const put = { id: 1, asset: 'AAPL', type: 'PUT', date: '2026-10-01', expiry: '2026-10-17', dte: 16, + strike: 200, size: 100, premium: 150, outcome: 'OPEN', closeCost: 0, closeDate: '', platform: 'MANUAL' }; +const p = { closeCost: 220, date: '2026-10-15', expiry: '2026-11-21', strike: 195, size: 100, premium: 310 }; + +test('roll closes the old leg and opens a linked replacement', () => { + const out = rollTrade([put], 1, p); + assert.strictEqual(out.length, 2); + const [old, next] = out; + assert.deepStrictEqual([old.outcome, old.closeCost, old.closeDate], ['CLOSED', 220, '2026-10-15']); + assert.strictEqual(old.premium, 150); + assert.strictEqual(next.outcome, 'OPEN'); + assert.strictEqual(next.type, 'PUT'); + assert.strictEqual(next.platform, 'MANUAL'); + assert.strictEqual(next.rolledFrom, 1); + assert.strictEqual(next.dte, 37); + assert.deepStrictEqual([next.strike, next.size, next.premium, next.date], [195, 100, 310, '2026-10-15']); + assert.ok(next.id > old.id); + assert.strictEqual(put.outcome, 'OPEN', 'input not mutated'); +}); + +test('rolled CALL keeps its lotNum', () => { + const call = { ...put, type: 'CALL', lotNum: 2 }; + assert.strictEqual(rollTrade([call], 1, p)[1].lotNum, 2); + assert.ok(!('lotNum' in rollTrade([put], 1, p)[1])); +}); + +test('only open options roll', () => { + assert.strictEqual(rollTrade([{ ...put, outcome: 'EXPIRED' }], 1, p), null); + assert.strictEqual(rollTrade([{ ...put, type: 'HOLDING' }], 1, p), null); + assert.strictEqual(rollTrade([put], 99, p), null); +});