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
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: <old id>`. 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
Expand Down
2 changes: 2 additions & 0 deletions src/css/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 */
Expand Down
25 changes: 25 additions & 0 deletions src/html/modals.html
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,31 @@ <h3>Delete all trades?</h3>
</div>
</div>

<!-- ROLL OPTION MODAL (Wheeler) -->
<div class="merge-overlay" id="roll-overlay">
<div class="merge-modal" style="max-width:min(480px, calc(100vw - 24px))">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:4px">
<h3 id="roll-title" style="font-size:.95rem">Roll Option</h3>
<button onclick="closeRollModal()" style="background:transparent;border:none;color:var(--mu2);font-size:1.2rem;cursor:pointer;padding:2px 6px">&#10005;</button>
</div>
<p id="roll-cur" style="font-size:.7rem;color:var(--mu2);margin-bottom:14px"></p>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
<div class="field"><label>Buy-back Cost ($)</label><input id="rf-closecost" type="number" step="0.01" min="0" oninput="rollRecalc()"></div>
<div class="field"><label>Roll Date</label><input id="rf-date" type="date"></div>
<div class="field"><label>New Expiry</label><input id="rf-expiry" type="date"></div>
<div class="field"><label>New Strike ($)</label><input id="rf-strike" type="number" step="0.01" min="0"></div>
<div class="field"><label>Contracts</label><input id="rf-size" type="number" step="1" min="0"></div>
<div class="field"><label>New Premium ($)</label><input id="rf-premium" type="number" step="0.01" min="0" oninput="rollRecalc()"></div>
</div>
<div id="roll-net" style="font-size:.8rem;font-weight:700;font-family:var(--mono);margin-top:12px"></div>
<div id="roll-err" style="font-size:.72rem;color:var(--red);margin-top:10px;min-height:1.2em"></div>
<div style="display:flex;gap:8px;justify-content:flex-end;margin-top:16px">
<button class="btn btn-g" onclick="closeRollModal()">Cancel</button>
<button class="btn" style="background:var(--green);color:var(--bg);border:none;font-weight:700" onclick="confirmRoll()">Roll</button>
</div>
</div>
</div>

<!-- DITM CALL CALCULATOR (Wheeler) -->
<div class="merge-overlay" id="ditm-overlay">
<div class="merge-modal dc-modal" style="width:min(720px, calc(100vw - 24px));max-width:none">
Expand Down
13 changes: 12 additions & 1 deletion src/js/core/06-render-table.js
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ function _openRow(r) {
// preset to CLOSED so the user can enter the buy-back cost.
const closeBtn = (!isHolding && _isTradfi())
? '<button class="btn-qa btn-qa-cls" onclick="openEditModal(' + r.id + ',\'CLOSED\')" title="Buy to close">Close \u2297</button>'
+ '<button class="btn-qa btn-qa-roll" onclick="openRollModal(' + r.id + ')" title="Roll to a new strike/expiry">Roll \u21bb</button>'
: '';
const actions = (isHolding ? '' : (r.type === 'CALL'
? '<button class="btn-qa btn-qa-exp" onclick="quickOutcome(' + r.id + ',\'EXPIRED\')" title="Mark expired">Exp \u2713</button>'
Expand All @@ -162,6 +163,14 @@ function _openRow(r) {
+ '</tr>';
}

// "↻ 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 '<span class="rolled-tag" title="Rolled into a new position">\u21bb rolled</span>';
}

// "% 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.
Expand Down Expand Up @@ -207,6 +216,7 @@ function _histRow(r) {
+ '<td>' + aprStr + '</td>'
+ '<td><span class="badge ' + outcomeBadge(r.outcome) + '">' + outcomeLabel(r) + '</span>'
+ _captureBadge(r)
+ _rolledTag(r)
+ (r.outcome === 'CLOSED' && r.closeDate
? '<div class="mu" style="font-size:.6rem;margin-top:2px">closed ' + r.closeDate + _daysEarlyTag(r) + '</div>' : '')
+ '</td>'
Expand All @@ -226,6 +236,7 @@ function _openCard(r) {
+ (r.annual !== null ? r.annual.toFixed(1) + '%' : '&mdash;') + '</span>';
const closeBtn = _isTradfi()
? '<button class="btn-qa btn-qa-cls" onclick="openEditModal(' + r.id + ',\'CLOSED\')" title="Buy to close">Close ⊗</button>'
+ '<button class="btn-qa btn-qa-roll" onclick="openRollModal(' + r.id + ')" title="Roll to a new strike/expiry">Roll ↻</button>'
: '';
const actions = (r.type === 'CALL'
? '<button class="btn-qa btn-qa-exp" onclick="quickOutcome(' + r.id + ',\'EXPIRED\')">Exp ✓</button>'
Expand Down Expand Up @@ -254,7 +265,7 @@ function _openCard(r) {
function _histCard(r) {
const typeBadge = '<span class="badge b' + r.type.toLowerCase() + '">' + r.type + '</span>';
const platBadge = r.platform === 'HSFC' ? '<span class="bplat bplat-hsfc">HSFC</span>' : '<span class="bplat bplat-rysk">RYSK</span>';
const outBadge = '<span class="badge ' + outcomeBadge(r.outcome) + '">' + outcomeLabel(r) + '</span>' + _captureBadge(r);
const outBadge = '<span class="badge ' + outcomeBadge(r.outcome) + '">' + outcomeLabel(r) + '</span>' + _captureBadge(r) + _rolledTag(r);
const premHtml = r.outcome === 'CLOSED'
? '+$' + fmt(r.premium - (r.closeCost || 0))
: '+$' + fmt(r.premium);
Expand Down
101 changes: 101 additions & 0 deletions src/js/tradfi/21-roll.js
Original file line number Diff line number Diff line change
@@ -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: <old id>`.

// 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 };
48 changes: 48 additions & 0 deletions test/integration/wheeler-app.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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/);
});
35 changes: 35 additions & 0 deletions test/unit/roll.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
Loading