/** * CASH FLOW OPTIONS — Trade Tools (single-file install) * Container-bound Apps Script for the Cash Flow Options Income Tracker. * * VERSION 1.8 (2026-08-17) * 1.8 NEW: "Roll a Trade…" — extend an existing position instead of letting * it expire or get called away. Pick any OPEN put or covered call; the * form auto-detects which. You enter only the NET CREDIT your broker's * roll screen shows (Public: Trade > More > Roll Position; Schwab has the * same), plus the new expiration and strike. Behind the scenes the tool * writes what actually happened: a row closing the old contract and a row * opening the new one, on the SAME cycle — a roll is a continuation of * the position, not a new one, so cycle ROI keeps accruing in one place. * The net credit rides on the new leg, so recorded cash flow matches the * account to the penny. Live panel shows the credit, the extra days, and * the annualized return on those INCREMENTAL days (the number that tells * you whether the roll beats redeploying the cash). * New: getRollableCycles(), rollPosition(), rollHtml_(), showRollForm(). * ALSO 1.8: the Income by Month block now formats its yield columns as * percentages across the whole rolling 12-month window, not just the * rows that happen to hold numbers today. A month landing in a row that * had never been formatted used to render 0.0108 instead of 1.08%. * New: applyOwnedFormats_(), run from Apply Latest Updates. * 1.7 Annualized yield on capital (Dashboard) is now a single BLENDED * figure — cumulative return annualized over the days since the first * trade — instead of an average of each month's day-adjusted rate. A * short, exceptional trade no longer over-inflates the headline * (e.g. ~245% -> ~114% after a hot first week). Owned formula, so * Apply Latest Updates heals existing copies. * 1.6 Sell a Put now guarantees a UNIQUE Cycle ID — two trades with the * same ticker/expiration/strike no longer collide (the first stays * clean, duplicates get -2, -3, …), so the Position Tracker keeps them * as separate positions. New: uniqueCycleId_(). Forward-looking: it * does NOT renumber Cycle IDs already in the log. * 1.5 Close a Position now lists OPEN COVERED CALLS too, not just puts. * Close a call as Bought-to-close, Expired (shares kept), or Called * away (shares sold). Trade/close date is always editable so you can * backfill past closes. New: getCloseableCycles(), closeCallPosition_(), * callBtcType_(). * SELF-UPDATE: new "⚙️ Apply Latest Updates" menu item + hidden _CFO * version stamp + on-open nudge. Members update by pasting new code and * clicking it; it re-applies owned formulas (declarative) and runs * one-time migrations, never touching data. Bump CODE_VERSION each * release; put formula changes in applyOwnedFormulas_, structural * changes in MIGRATIONS. * 1.4.1 Fix: trade-type lookup now checks the Lists tab (reference tab was * renamed from Trade Types) and falls back to the standard names. * 1.4 Settle Expired Options covers covered CALLs too: called away vs * call-expired, with trade-type names read live from the Trade * Types tab. Menu item renamed from "Settle expired puts". * 1.3 Settle Expired Puts works ON expiration Friday after the 4:05pm ET * bell (live quote + 1% safety band; too-close-to-call waits for the * official close next day). Added getLivePrice_(). * 1.2 (2026-07-08) Withdraw-balance lookup by label (row-shift-proof); * in-dialog confirm/error panels on all 4 forms. * 1.1 (2026-06) Full 5-item menu: Sell a Put, Sell a Covered Call, * Close a Position, Record a Cash Move, Settle Expired Puts. * 1.0 (2026-06) Initial: Sell a Put + Close a Position. * * @OnlyCurrentDoc * The line above limits this script to THIS spreadsheet only (a non-sensitive * scope), so users get a normal "Allow" prompt instead of broad access. * * Install once on the MASTER sheet: Extensions > Apps Script > paste this whole * file over Code.gs > Save > reload the sheet. Students who COPY the sheet get * the script automatically — they only click "Approve" once. No pasting. * * Menu: Cash Flow Tools * Sell a Put… -> opens a wheel cycle (Short Put Sold) * Sell a Covered Call… -> rebound leg on an assigned cycle (Covered Call Sold) * Roll a Trade… -> extend an OPEN put or covered call to a later expiration for a net credit * Close a Position… -> pick an OPEN put OR covered call; Expired / Assigned / Called away / Bought-to-close * Record a Cash Move… -> Add/Withdraw Capital, Reinvest, Withdraw Cash Flow * Settle expired puts -> auto-checks expiration-day close vs strike and posts it * ⚙️ Apply Latest Updates -> re-applies owned formulas + structural migrations to match this code */ var TL = 'Trade Log'; var PT = 'Position Tracker'; var LEDGER = 'Cash Additions/Withdrawals'; // Trade Log columns (1-based) var C = { date:1, cycle:2, ticker:3, type:4, exp:5, strike:6, qty:7, prem:8, fees:9, cash:10, realized:11, wd:12, batch:13, dte:14, note:15, roi:16, ann:17 }; var LASTCOL = C.ann; var FORMULA_COLS = [C.cash, C.realized, C.dte, C.roi, C.ann]; // Position Tracker columns (1-based) var P = { cycle:1, ticker:2, status:3, qty:4, price:5, strike:6 }; var OPEN_PUT = 'Open Put'; var OPEN_CALL = 'Open Call'; var ASSIGNED = 'Assigned Shares'; // Ledger (Cash Additions/Withdrawals) columns + live-balance cells var L = { date:1, type:2, amount:3, batch:4, cycle:5, note:6 }; var BAL = { cashFlow:'I2', basis:'I3', toTrade:'I4' }; // --- Self-update framework ------------------------------------------------- var CODE_VERSION = '1.8'; // bump every release (even a one-cell formula change) var META = '_CFO'; // hidden tab that stores this sheet's installed version var DASH = 'Dashboard'; function onOpen() { SpreadsheetApp.getUi() .createMenu('⚡ Cash Flow Tools') .addItem('Sell a Put…', 'showSellPutForm') .addItem('Sell a Covered Call…', 'showCoveredCallForm') .addItem('Roll a Trade…', 'showRollForm') .addItem('Close a Position…', 'showCloseForm') .addSeparator() .addItem('Record a Cash Move…', 'showCashMoveForm') .addSeparator() .addItem('Settle expired options (check now)', 'checkExpirations') .addSeparator() .addItem('\u2699\ufe0f Apply Latest Updates', 'applyUpdates') .addItem('About (v1.8 \u2014 2026-08-17)', 'showVersion') .addToUi(); // Gentle, non-blocking nudge if this sheet is behind the pasted code. try { if (getInstalledVersion_() !== CODE_VERSION) SpreadsheetApp.getActive().toast( 'An update is ready. Click \u26a1 Cash Flow Tools \u25b8 Apply Latest Updates.', 'Cash Flow Tools', 8); } catch (e) {} } function showVersion() { SpreadsheetApp.getUi().alert('Cash Flow Tools', 'Version 1.8 (2026-08-17)\nNEW: Roll a Trade. Extend an open put or covered call to a later expiration instead of letting it expire or get called away. Enter only the net credit your broker shows you; the tool records the close and the re-open for you, on the same cycle, and shows the annualized return on the extra days.', SpreadsheetApp.getUi().ButtonSet.OK); } /* =================================================================== * SELF-UPDATE FRAMEWORK * "Apply Latest Updates" brings a member's sheet up to match whatever * code is currently pasted in. It only writes formulas / structure the * tool OWNS — it never touches Trade Log or ledger DATA rows. It has no * internet access; it just applies what this code already contains. * Safe to run any time; running it twice does nothing the second time. * * To ship a formula change: edit its line in applyOwnedFormulas_ and * bump CODE_VERSION. For a structural change (new column, seed a list * value, etc.): append a step to MIGRATIONS and bump CODE_VERSION. * ------------------------------------------------------------------- */ // Hidden tab that remembers which version this sheet was last updated to. function metaSheet_() { var ss = SpreadsheetApp.getActive(); var sh = ss.getSheetByName(META); if (!sh) { sh = ss.insertSheet(META); sh.getRange('A1').setValue('installedVersion'); try { sh.hideSheet(); } catch (e) {} } return sh; } // Read-only — safe to call from onOpen (never creates a tab). function getInstalledVersion_() { var sh = SpreadsheetApp.getActive().getSheetByName(META); return sh ? String(sh.getRange('B1').getValue() || '') : ''; } function setInstalledVersion_(v) { metaSheet_().getRange('B1').setValue(v); } // true if version a is older than version b ("1.4.1" < "1.5") function verLt_(a, b) { a = String(a || '0').split('.').map(Number); b = String(b || '0').split('.').map(Number); for (var i = 0; i < Math.max(a.length, b.length); i++) { var x = a[i] || 0, y = b[i] || 0; if (x < y) return true; if (x > y) return false; } return false; } // Menu action: heal owned formulas + run pending migrations, then stamp version. function applyUpdates() { var ui = SpreadsheetApp.getUi(); var from = getInstalledVersion_(); var changes = []; try { changes = changes.concat(applyOwnedFormulas_()); changes = changes.concat(applyOwnedFormats_()); changes = changes.concat(runMigrations_()); setInstalledVersion_(CODE_VERSION); } catch (e) { ui.alert('Apply Latest Updates', 'Could not finish: ' + e.message + '\n\nNothing in your data was changed.', ui.ButtonSet.OK); return; } if (from === CODE_VERSION && !changes.length) { ui.alert('Cash Flow Tools', 'You are already on the latest version (v' + CODE_VERSION + '). Nothing to update.', ui.ButtonSet.OK); } else { ui.alert('Cash Flow Tools', 'Updated to v' + CODE_VERSION + '.\n\nWhat changed:\n• ' + (changes.length ? changes.join('\n• ') : 'Everything was already current.') + '\n\nYour trades, notes, and cash moves were not touched.', ui.ButtonSet.OK); } } // Declarative: every formula the tool owns. Add a line here when one changes. function applyOwnedFormulas_() { var out = []; var dash = SpreadsheetApp.getActive().getSheetByName(DASH); if (!dash) throw new Error('Could not find the "Dashboard" tab.'); // Available to Withdraw Now = Total Realized - Already Withdrawn - Reinvested - Cushion. // Anchored by row label so it survives inserted rows; standard layout as fallback. var rTotal = dashRowByLabel_(dash, 'Total Realized Income') || 5; var rWithdr = dashRowByLabel_(dash, 'Already Withdrawn') || 6; var rCushion = dashRowByLabel_(dash, 'Cash Reserve Cushion') || 7; var rAvail = dashRowByLabel_(dash, 'Available to Withdraw Now') || 8; var fAvail = '=B' + rTotal + '-B' + rWithdr + '-SUMIF(\'' + LEDGER + '\'!$B:$B,"Reinvest",\'' + LEDGER + '\'!$C:$C)' + '-B' + rCushion; if (setFormulaIfDifferent_(dash.getRange(rAvail, 2), fAvail)) out.push('Available to Withdraw now subtracts Reinvested cash (Reinvest fix).'); // Annualized yield on capital (column D of the Total Realized Income row): // one blended figure = cumulative return (col C) annualized over the days // since the first trade, so a short, exceptional trade no longer over-inflates // the headline the way an average-of-monthly-rates did. var fAnn = '=IF(Settings!B10="","",C' + rTotal + '*365/MAX(1,TODAY()-Settings!B10))'; if (setFormulaIfDifferent_(dash.getRange(rTotal, 4), fAnn)) out.push('Annualized yield is now one blended figure — a short, exceptional trade no longer inflates it.'); return out; } /* Declarative number formats the tool owns. * * The "Income by Month" block is a rolling 12-month window: each new month is * written into the next row down and the oldest scrolls off the top. Rows that * had never carried a number were left unformatted, so the first time real * figures landed in one, the yield columns rendered raw — 0.0108 where you * expect 1.08%. Formatting the whole block (not just the rows with data today) * fixes it for every month that lands there from now on. * * Columns are located by their header text rather than by letter, so this still * works if the layout ever shifts. Re-applied on every update, so it heals * itself; it only reports a change when it actually changed something. */ function applyOwnedFormats_() { var out = []; var dash = SpreadsheetApp.getActive().getSheetByName(DASH); if (!dash) return out; var hdr = dashRowByLabel_(dash, 'Income by Month'); if (!hdr) return out; // Which columns in that header row are yields? (Monthly Yield, Monthly Yield // on Deployed, Ann. Yield (day-adj) — all of them say "Yield".) var lastCol = Math.min(dash.getLastColumn(), 26); if (lastCol < 1) return out; var head = dash.getRange(hdr, 1, 1, lastCol).getValues()[0]; var cols = []; for (var c = 0; c < head.length; c++) if (/yield/i.test(String(head[c] || ''))) cols.push(c + 1); if (!cols.length) return out; // How deep does the block run? Walk down while column A still names a month. // Never fewer than 12 rows — that is the size of the rolling window, so the // rows that are empty today are the ones next month's numbers land in. var room = dash.getLastRow() - hdr; if (room < 1) return out; var scan = Math.min(room, 60); var labels = dash.getRange(hdr + 1, 1, scan, 1).getValues(); var n = 0; while (n < scan && String(labels[n][0] || '').trim() !== '') n++; n = Math.max(n, Math.min(12, scan)); if (n < 1) return out; var want = '0.00%', touched = false; for (var i = 0; i < cols.length; i++) { var rng = dash.getRange(hdr + 1, cols[i], n, 1); var fmts = rng.getNumberFormats(), needs = false; for (var r = 0; r < fmts.length; r++) if (fmts[r][0] !== want) { needs = true; break; } if (needs) { rng.setNumberFormat(want); touched = true; } } if (touched) out.push('Income by Month now shows its yield columns as percentages (1.08%) instead of decimals (0.0108) — including the rows future months will land in.'); return out; } function dashRowByLabel_(sh, label) { var n = Math.min(sh.getLastRow(), 80); if (n < 1) return 0; var col = sh.getRange(1, 1, n, 1).getValues(); var want = String(label).toLowerCase().trim(); for (var i = 0; i < col.length; i++) if (String(col[i][0] || '').toLowerCase().trim() === want) return i + 1; return 0; } function setFormulaIfDifferent_(range, formula) { if (range.getFormula() === formula) return false; range.setFormula(formula); return true; } // Ordered one-time migrations for STRUCTURAL changes. Append; never edit history. var MIGRATIONS = [ { version: '1.5', run: function () { // Ensure the reference tab lists the covered-call buy-to-close type. var ss = SpreadsheetApp.getActive(); var sh = ss.getSheetByName('Lists') || ss.getSheetByName('Trade Types'); if (!sh) return ''; var last = sh.getLastRow(); var vals = last ? sh.getRange(1, 1, last, 1).getValues() : []; var has = vals.some(function (r) { return String(r[0] || '').trim().toLowerCase() === 'covered call btc'; }); if (has) return ''; sh.getRange(last + 1, 1).setValue('Covered Call BTC'); return 'Added "Covered Call BTC" to your Lists tab (used when you close a covered call early).'; } }, { version: '1.8', run: function () { // Roll a Trade writes a buy-to-close row for the leg being replaced, so // both BTC type names must exist on the reference tab. var ss = SpreadsheetApp.getActive(); var sh = ss.getSheetByName('Lists') || ss.getSheetByName('Trade Types'); if (!sh) return ''; var added = []; ['Short Put BTC', 'Covered Call BTC'].forEach(function (want) { var last = sh.getLastRow(); var vals = last ? sh.getRange(1, 1, last, 1).getValues() : []; var has = vals.some(function (r) { return String(r[0] || '').trim().toLowerCase() === want.toLowerCase(); }); if (!has) { sh.getRange(last + 1, 1).setValue(want); added.push(want); } }); return added.length ? 'Added ' + added.map(function (a) { return '"' + a + '"'; }).join(' and ') + ' to your Lists tab (used by Roll a Trade).' : ''; } } ]; function runMigrations_() { var installed = getInstalledVersion_(); var out = []; MIGRATIONS.forEach(function (m) { if (verLt_(installed, m.version)) { var did = m.run(); if (did) out.push(did); } }); return out; } /* ----------------------------------------------------------------- UI openers */ function showSellPutForm() { open_(sellPutHtml_(), 410, 560, 'Sell a Put — new trade'); } function showCoveredCallForm(){ open_(coveredCallHtml_(),420, 560, 'Sell a Covered Call'); } function showRollForm() { open_(rollHtml_(), 440, 640, 'Roll a Trade'); } function showCloseForm() { open_(closeHtml_(), 440, 560, 'Close a Position'); } function showCashMoveForm() { open_(cashMoveHtml_(), 410, 470, 'Record a Cash Move'); } function open_(html, w, h, title) { SpreadsheetApp.getUi().showModalDialog( HtmlService.createHtmlOutput(html).setWidth(w).setHeight(h), title); } /* ------------------------------------------------------------ shared helpers */ function lastRowIn_(sh, col) { var n = sh.getMaxRows() - 1; var vals = sh.getRange(2, col, n, 1).getValues(); var last = 1; for (var i = 0; i < vals.length; i++) if (vals[i][0] !== '' && vals[i][0] !== null) last = i + 2; return last; } function lastTradeRow_(sh) { return lastRowIn_(sh, C.type); } /** Append a Trade Log row; copies per-row formats + formulas so it calculates. */ function appendTradeRow_(fields) { var sh = SpreadsheetApp.getActive().getSheetByName(TL); var src = Math.max(2, lastTradeRow_(sh)); var target = lastTradeRow_(sh) + 1; sh.getRange(src, 1, 1, LASTCOL).copyTo(sh.getRange(target, 1, 1, LASTCOL), {formatOnly:true}); FORMULA_COLS.forEach(function (c) { sh.getRange(src, c).copyTo(sh.getRange(target, c)); }); Object.keys(fields).forEach(function (k) { if (fields[k] !== undefined && fields[k] !== null && fields[k] !== '') sh.getRange(target, C[k]).setValue(fields[k]); }); SpreadsheetApp.flush(); return target; } /** Append a row to the cash ledger (no per-row formulas there). */ function appendLedgerRow_(fields) { var sh = SpreadsheetApp.getActive().getSheetByName(LEDGER); var src = Math.max(2, lastRowIn_(sh, L.type)); var target = lastRowIn_(sh, L.type) + 1; sh.getRange(src, 1, 1, L.note).copyTo(sh.getRange(target, 1, 1, L.note), {formatOnly:true}); Object.keys(fields).forEach(function (k) { if (fields[k] !== undefined && fields[k] !== null && fields[k] !== '') sh.getRange(target, L[k]).setValue(fields[k]); }); SpreadsheetApp.flush(); return target; } /** Look up ticker/expiration/strike/qty for a cycle from its Short Put Sold row. */ function cycleInfo_(cycleId) { var sh = SpreadsheetApp.getActive().getSheetByName(TL); var n = lastTradeRow_(sh); if (n < 2) return {}; var rows = sh.getRange(2, 1, n - 1, LASTCOL).getValues(); for (var i = 0; i < rows.length; i++) { if (rows[i][C.cycle-1] === cycleId && rows[i][C.type-1] === 'Short Put Sold') return { ticker: rows[i][C.ticker-1], exp: rows[i][C.exp-1], strike: rows[i][C.strike-1], qty: rows[i][C.qty-1] }; } return {}; } /** GOOGLEFINANCE closing price for a ticker on a given date, via a scratch cell. */ function getCloseOnDate_(ticker, d) { var sh = SpreadsheetApp.getActive().getSheetByName(PT); var cell = sh.getRange(1, 30); // far-right scratch cell (AD1), cleared after var f = '=IFERROR(INDEX(GOOGLEFINANCE("' + ticker + '","close",DATE(' + d.getFullYear() + ',' + (d.getMonth()+1) + ',' + d.getDate() + ')),2,2),"")'; cell.setFormula(f); SpreadsheetApp.flush(); var v = cell.getValue(); for (var t = 0; t < 6 && (v === '' || v === null); t++) { Utilities.sleep(700); SpreadsheetApp.flush(); v = cell.getValue(); } cell.clearContent(); return (typeof v === 'number' && v > 0) ? v : null; } // Live/delayed quote via GOOGLEFINANCE — used to settle at the bell on // expiration day itself, when the official daily close isn't posted yet. function getLivePrice_(ticker) { var sh = SpreadsheetApp.getActive().getSheetByName(PT); var cell = sh.getRange(1, 30); // same AD1 scratch cell, cleared after cell.setFormula('=IFERROR(GOOGLEFINANCE("' + ticker + '","price"),"")'); SpreadsheetApp.flush(); var v = cell.getValue(); for (var t = 0; t < 6 && (v === '' || v === null); t++) { Utilities.sleep(700); SpreadsheetApp.flush(); v = cell.getValue(); } cell.clearContent(); return (typeof v === 'number' && v > 0) ? v : null; } /* ----------------------------------------------------------- list functions */ // Latest covered-call leg of a cycle (the working Rebound CALL). function callInfo_(cycleId) { var sh = SpreadsheetApp.getActive().getSheetByName(TL); var n = lastTradeRow_(sh); if (n < 2) return {}; var rows = sh.getRange(2, 1, n - 1, LASTCOL).getValues(); var hit = {}; for (var i = 0; i < rows.length; i++) { if (rows[i][C.cycle-1] === cycleId && rows[i][C.type-1] === 'Covered Call Sold') hit = { ticker: rows[i][C.ticker-1], exp: rows[i][C.exp-1], strike: rows[i][C.strike-1], qty: rows[i][C.qty-1] }; } return hit; } // Find a trade-type name from the sheet's own Trade Types tab, so the rows we // write always use the exact terminology this spreadsheet's formulas expect. function findTradeType_(pred) { var names = ['Trade Types', 'Lists']; // reference tab may carry either name for (var i = 0; i < names.length; i++) { var sh = SpreadsheetApp.getActive().getSheetByName(names[i]); if (!sh || sh.getLastRow() < 1) continue; var vals = sh.getRange(1, 1, sh.getLastRow(), Math.min(sh.getLastColumn(), 3)).getValues(); for (var r = 0; r < vals.length; r++) for (var c = 0; c < vals[r].length; c++) { var v = String(vals[r][c] || '').trim(); if (v && pred(v)) return v; } } return null; } function calledAwayType_() { return findTradeType_(function (v) { return /called\s*away/i.test(v); }) || 'Stock Sell / Called Away'; } function callExpiredType_() { return findTradeType_(function (v) { return /call/i.test(v) && /expir/i.test(v) && !/put/i.test(v); }) || 'Covered Call Expired'; } // Record the end of a covered-call leg. function closeCallLeg_(cycleId, outcome, note) { var info = callInfo_(cycleId); if (!info.ticker) throw new Error('No covered call found for ' + cycleId + '.'); if (outcome === 'called_away') { var t = calledAwayType_(); if (!t) throw new Error('No "Called Away" trade type found on the Trade Types tab.'); appendTradeRow_({ date: info.exp || new Date(), cycle: cycleId, ticker: info.ticker, type: t, exp: info.exp, strike: info.strike, qty: info.qty, note: note || 'Shares called away at $' + info.strike }); return t; } if (outcome === 'call_expired') { var t2 = callExpiredType_(); if (!t2) throw new Error('No "Covered Call Expired" trade type found on the Trade Types tab.'); appendTradeRow_({ date: info.exp || new Date(), cycle: cycleId, ticker: info.ticker, type: t2, exp: info.exp, note: note || 'Call expired worthless - shares kept, premium kept, sell the next call' }); return t2; } throw new Error('Unknown call outcome.'); } function ptRowsByStatus_(status) { var sh = SpreadsheetApp.getActive().getSheetByName(PT); var n = sh.getLastRow(); if (n < 2) return []; var rows = sh.getRange(2, 1, n - 1, P.strike).getValues(); var out = []; for (var i = 0; i < rows.length; i++) { if (rows[i][P.cycle-1] === '' || rows[i][P.cycle-1] === null) continue; if (rows[i][P.status-1] !== status) continue; out.push(rows[i]); } return out; } // Open puts (for Close a Position) function getOpenCycles() { var tz = SpreadsheetApp.getActive().getSpreadsheetTimeZone(); return ptRowsByStatus_(OPEN_PUT).map(function (r) { var info = cycleInfo_(r[P.cycle-1]); var e = (info.exp instanceof Date) ? Utilities.formatDate(info.exp, tz, 'M/d/yy') : ''; return { cycle: r[P.cycle-1], label: r[P.ticker-1] + ' · ' + r[P.qty-1] + '× $' + r[P.strike-1] + ' put' + (e ? ' · exp ' + e : '') + ' (' + r[P.status-1] + ')' }; }); } // Open puts AND open covered calls (for Close a Position). Each carries a // `kind` so the form and closePosition know which leg is being closed. function getCloseableCycles() { var tz = SpreadsheetApp.getActive().getSpreadsheetTimeZone(); var out = ptRowsByStatus_(OPEN_PUT).map(function (r) { var info = cycleInfo_(r[P.cycle-1]); var e = (info.exp instanceof Date) ? Utilities.formatDate(info.exp, tz, 'M/d/yy') : ''; return { cycle: r[P.cycle-1], kind: 'put', label: r[P.ticker-1] + ' · ' + r[P.qty-1] + '× $' + r[P.strike-1] + ' put' + (e ? ' · exp ' + e : '') + ' (Open Put)' }; }); ptRowsByStatus_(OPEN_CALL).forEach(function (r) { var info = callInfo_(r[P.cycle-1]); var strike = info.strike || r[P.strike-1]; var qty = info.qty || r[P.qty-1]; var e = (info.exp instanceof Date) ? Utilities.formatDate(info.exp, tz, 'M/d/yy') : ''; out.push({ cycle: r[P.cycle-1], kind: 'call', label: r[P.ticker-1] + ' · ' + qty + '× $' + strike + ' call' + (e ? ' · exp ' + e : '') + ' (Open Call)' }); }); return out; } // Assigned cycles (for Sell a Covered Call) function getAssignedCycles() { return ptRowsByStatus_(ASSIGNED).map(function (r) { return { cycle: r[P.cycle-1], ticker: r[P.ticker-1], qty: r[P.qty-1], strike: r[P.strike-1], label: r[P.ticker-1] + ' · ' + (r[P.qty-1]*100) + ' shares · cost basis $' + r[P.strike-1] }; }); } // Live balances (for Record a Cash Move). // Finds each number by its LABEL in column H of the ledger tab, so the lookup // survives rows being inserted/moved. Falls back to the fixed cells (I2:I4). function getLiveBalances() { var sh = SpreadsheetApp.getActive().getSheetByName(LEDGER); var out = { cashFlow: null, basis: null, toTrade: null }; var vals = sh.getRange(1, 8, 25, 2).getValues(); // H1:I25 — label | value for (var i = 0; i < vals.length; i++) { var label = String(vals[i][0] || '').toLowerCase(); var v = vals[i][1]; if (!label) continue; if (out.cashFlow === null && label.indexOf('withdraw') > -1 && label.indexOf('cash') > -1) out.cashFlow = Number(v) || 0; else if (out.basis === null && label.indexOf('basis') > -1) out.basis = Number(v) || 0; else if (out.toTrade === null && label.indexOf('trade') > -1) out.toTrade = Number(v) || 0; } if (out.cashFlow === null) out.cashFlow = Number(sh.getRange(BAL.cashFlow).getValue()) || 0; if (out.basis === null) out.basis = Number(sh.getRange(BAL.basis).getValue()) || 0; if (out.toTrade === null) out.toTrade = Number(sh.getRange(BAL.toTrade).getValue()) || 0; return out; } /* --------------------------------------------------------------- form actions */ // Make a Cycle ID unique within the Trade Log. The first of a given // ticker/expiration/strike keeps the clean base; later duplicates get // -2, -3, … appended so same-setup trades track as separate positions. // (Forward-looking only — it never renames rows already in the log.) function uniqueCycleId_(base) { base = String(base || '').trim(); if (!base) return base; var sh = SpreadsheetApp.getActive().getSheetByName(TL); var n = lastTradeRow_(sh); var seen = {}; if (n >= 2) { var ids = sh.getRange(2, C.cycle, n - 1, 1).getValues(); for (var i = 0; i < ids.length; i++) { var v = String(ids[i][0] || '').trim(); if (v) seen[v] = true; } } if (!seen[base]) return base; var k = 2; while (seen[base + '-' + k]) k++; return base + '-' + k; } // Sell a Put function addSellPut(f) { var ticker = String(f.ticker||'').trim().toUpperCase(); var strike = Number(f.strike), qty = Math.round(Number(f.contracts)), prem = Number(f.premium), fees = Number(f.fees||0); if (!ticker) throw new Error('Ticker is required.'); if (!(strike > 0)) throw new Error('Strike must be positive.'); if (!(qty > 0)) throw new Error('Contracts must be at least 1.'); if (!(prem > 0)) throw new Error('Premium per share must be positive.'); if (!f.expiration) throw new Error('Expiration date is required.'); var cycleId = uniqueCycleId_(String(f.cycleId||'').trim() || (ticker + '-' + String(f.expiration) + '-' + strike)); var row = appendTradeRow_({ date: f.tradeDate ? new Date(f.tradeDate + 'T00:00:00') : new Date(), cycle: cycleId, ticker: ticker, type: 'Short Put Sold', exp: new Date(f.expiration + 'T00:00:00'), strike: strike, qty: qty, prem: prem, fees: fees, wd: 'Yes', note: f.note ? String(f.note) : '' }); return { ok:true, cycleId:cycleId, credit: qty*100*prem - fees, row: row }; } // Sell a Covered Call (on an assigned cycle) function addCoveredCall(f) { if (!f.cycle) throw new Error('Pick an assigned position.'); var info = cycleInfo_(f.cycle); var strike = Number(f.strike), qty = Math.round(Number(f.contracts)), prem = Number(f.premium), fees = Number(f.fees||0); if (!(strike > 0)) throw new Error('Call strike must be positive.'); if (!(qty > 0)) throw new Error('Contracts must be at least 1.'); if (!(prem > 0)) throw new Error('Premium per share must be positive.'); if (!f.expiration) throw new Error('Expiration date is required.'); appendTradeRow_({ date: f.tradeDate ? new Date(f.tradeDate + 'T00:00:00') : new Date(), cycle: f.cycle, ticker: info.ticker, type: 'Covered Call Sold', exp: new Date(f.expiration + 'T00:00:00'), strike: strike, qty: qty, prem: prem, fees: fees, wd: 'Yes', note: f.note ? String(f.note) : '' }); return { ok:true, credit: qty*100*prem - fees, ticker: info.ticker }; } // Close a Position. // PUT p = { cycle, kind:'put' (or omitted), outcome:'expired'|'assigned'|'btc', premium?, fees?, tradeDate?, note? } // CALL p = { cycle, kind:'call', outcome:'call_expired'|'called_away'|'call_btc', premium?, fees?, tradeDate?, note? } // tradeDate (YYYY-MM-DD) overrides the default date; leave blank to use the // option's expiration (expired/assigned/called-away) or today (bought-to-close). function closePosition(p) { if (!p.cycle) throw new Error('Pick a position to close.'); if (p.kind === 'call') return closeCallPosition_(p); var info = cycleInfo_(p.cycle); var today = new Date(); var override = p.tradeDate ? new Date(p.tradeDate + 'T00:00:00') : null; var common = { cycle: p.cycle, ticker: info.ticker }; if (p.outcome === 'expired') { appendTradeRow_(Object.assign({}, common, { date: override || info.exp || today, type: 'Short Put Expired', exp: info.exp, note: p.note || 'Expired worthless' })); return { ok:true, msg: info.ticker + ' ' + p.cycle + ' marked EXPIRED — collateral freed, premium kept.' }; } if (p.outcome === 'assigned') { appendTradeRow_(Object.assign({}, common, { date: override || info.exp || today, type: 'Short Put Assigned', exp: info.exp, strike: info.strike, qty: info.qty, note: p.note || 'Assigned — now hold shares; sell covered calls next' })); return { ok:true, msg: info.ticker + ' ' + p.cycle + ' marked ASSIGNED — status becomes "Assigned Shares".' }; } if (p.outcome === 'btc') { var prem = Number(p.premium), fees = Number(p.fees||0); if (!(prem >= 0)) throw new Error('Enter the buy-to-close debit (premium per share).'); appendTradeRow_(Object.assign({}, common, { date: override || today, type: 'Short Put BTC', strike: info.strike, qty: info.qty, prem: prem, fees: fees, wd: 'Yes', note: p.note || 'Bought to close (early exit)' })); return { ok:true, msg: info.ticker + ' ' + p.cycle + ' bought to close.' }; } throw new Error('Unknown outcome.'); } // Trade-type name this sheet uses for a covered call bought back early. function callBtcType_() { return findTradeType_(function (v) { return /covered\s*call/i.test(v) && /(btc|bought)/i.test(v); }) || 'Covered Call BTC'; } // Close an OPEN COVERED CALL from the Close a Position form. function closeCallPosition_(p) { var info = callInfo_(p.cycle); if (!info.ticker) throw new Error('No open covered call found for ' + p.cycle + '.'); var today = new Date(); var override = p.tradeDate ? new Date(p.tradeDate + 'T00:00:00') : null; var common = { cycle: p.cycle, ticker: info.ticker }; if (p.outcome === 'call_expired') { appendTradeRow_(Object.assign({}, common, { date: override || info.exp || today, type: callExpiredType_(), exp: info.exp, note: p.note || 'Call expired worthless — shares kept, premium kept, sell the next call' })); return { ok:true, msg: info.ticker + ' ' + p.cycle + ' covered call EXPIRED — shares kept, back to Assigned Shares.' }; } if (p.outcome === 'called_away') { appendTradeRow_(Object.assign({}, common, { date: override || info.exp || today, type: calledAwayType_(), exp: info.exp, strike: info.strike, qty: info.qty, note: p.note || ('Shares called away at $' + info.strike) })); return { ok:true, msg: info.ticker + ' ' + p.cycle + ' CALLED AWAY at $' + info.strike + ' — cycle closed, shares sold.' }; } if (p.outcome === 'call_btc') { var prem = Number(p.premium), fees = Number(p.fees||0); if (!(prem >= 0)) throw new Error('Enter the buy-to-close debit (premium per share).'); appendTradeRow_(Object.assign({}, common, { date: override || today, type: callBtcType_(), exp: info.exp, strike: info.strike, qty: info.qty, prem: prem, fees: fees, wd: 'Yes', note: p.note || 'Covered call bought to close (early exit)' })); return { ok:true, msg: info.ticker + ' ' + p.cycle + ' covered call bought to close — back to Assigned Shares.' }; } throw new Error('Unknown call outcome.'); } /* ======================================================================== * ROLL A TRADE (v1.8) * A roll extends a position instead of letting it expire or get called away. * The broker (Public: Trade > More > Roll Position; Schwab: Roll) executes it * as ONE spread order and shows ONE net credit — so that is the only number we * ask for. Underneath, what really happened is a close and a re-open, so that * is what we write to the log. * * Both rows carry the SAME Cycle ID. A roll is a continuation of the position * you already own, not a new one, so the cycle's cash flow keeps accruing in * one place. The closing row carries no premium; the whole net credit rides on * the new leg, which makes recorded cash flow match the brokerage account * exactly. * ---------------------------------------------------------------------- */ function fmtDate_(d) { if (!(d instanceof Date)) return ''; var tz = SpreadsheetApp.getActive().getSpreadsheetTimeZone(); return Utilities.formatDate(d, tz, 'M/d/yy'); } function daysBetween_(a, b) { if (!(a instanceof Date) || !(b instanceof Date)) return 0; return Math.round((b - a) / 864e5); } // Every OPEN put and covered call, with the detail the roll form needs to // prefill itself and work out the extra days. function getRollableCycles() { var out = []; ptRowsByStatus_(OPEN_PUT).forEach(function (r) { var info = cycleInfo_(r[P.cycle-1]); var e = fmtDate_(info.exp); out.push({ cycle: r[P.cycle-1], kind: 'put', ticker: r[P.ticker-1], strike: info.strike || r[P.strike-1], qty: info.qty || r[P.qty-1], exp: (info.exp instanceof Date) ? Utilities.formatDate(info.exp, SpreadsheetApp.getActive().getSpreadsheetTimeZone(), 'yyyy-MM-dd') : '', label: r[P.ticker-1] + ' · ' + (info.qty || r[P.qty-1]) + '× $' + (info.strike || r[P.strike-1]) + ' PUT' + (e ? ' · exp ' + e : '') }); }); ptRowsByStatus_(OPEN_CALL).forEach(function (r) { var info = callInfo_(r[P.cycle-1]); var e = fmtDate_(info.exp); out.push({ cycle: r[P.cycle-1], kind: 'call', ticker: r[P.ticker-1], strike: info.strike || r[P.strike-1], qty: info.qty || r[P.qty-1], exp: (info.exp instanceof Date) ? Utilities.formatDate(info.exp, SpreadsheetApp.getActive().getSpreadsheetTimeZone(), 'yyyy-MM-dd') : '', label: r[P.ticker-1] + ' · ' + (info.qty || r[P.qty-1]) + '× $' + (info.strike || r[P.strike-1]) + ' CALL' + (e ? ' · exp ' + e : '') }); }); return out; } // f = { cycle, kind:'put'|'call', expiration, strike, contracts, credit, fees?, tradeDate?, note? } function rollPosition(f) { if (!f.cycle) throw new Error('Pick a position to roll.'); var kind = (f.kind === 'call') ? 'call' : 'put'; var info = (kind === 'call') ? callInfo_(f.cycle) : cycleInfo_(f.cycle); if (!info.ticker) throw new Error('Could not find an open ' + (kind === 'call' ? 'covered CALL' : 'PUT') + ' for ' + f.cycle + '.'); var newStrike = Number(f.strike), qty = Math.round(Number(f.contracts)), credit = Number(f.credit), fees = Number(f.fees || 0); if (!(newStrike > 0)) throw new Error('New strike must be positive.'); if (!(qty > 0)) throw new Error('Contracts must be at least 1.'); if (!(credit > 0)) throw new Error('Enter the net credit per share from your broker’s roll screen. A roll should always pay you.'); if (!f.expiration) throw new Error('New expiration is required.'); var newExp = new Date(f.expiration + 'T00:00:00'); if (info.exp instanceof Date && newExp <= info.exp) throw new Error('The new expiration must be AFTER the current one (' + fmtDate_(info.exp) + '). A roll always moves further out in time.'); var when = f.tradeDate ? new Date(f.tradeDate + 'T00:00:00') : new Date(); var oldLabel = '$' + info.strike + ' exp ' + fmtDate_(info.exp); var newLabel = '$' + newStrike + ' exp ' + fmtDate_(newExp); var oldQty = info.qty || qty; // Leg 1 — close the contract being replaced. No premium here on purpose: the // broker quoted one net figure for the whole roll and it rides on leg 2, so // the log totals match the cash that actually hit the account. appendTradeRow_({ date: when, cycle: f.cycle, ticker: info.ticker, type: (kind === 'call') ? callBtcType_() : 'Short Put BTC', exp: info.exp, strike: info.strike, qty: oldQty, prem: 0, fees: 0, wd: 'Yes', note: 'ROLL — closed ' + oldLabel + ', rolled to ' + newLabel + ' (net credit recorded on the new leg)' }); // Leg 2 — open the replacement on the SAME cycle. Carries the net credit. appendTradeRow_({ date: when, cycle: f.cycle, ticker: info.ticker, type: (kind === 'call') ? 'Covered Call Sold' : 'Short Put Sold', exp: newExp, strike: newStrike, qty: qty, prem: credit, fees: fees, wd: 'Yes', note: f.note || ('ROLL — rolled from ' + oldLabel + ' for a net credit') }); var cash = qty * 100 * credit - fees; var days = daysBetween_(info.exp, newExp); var ann = (days > 0 && newStrike > 0) ? (cash / (newStrike * 100 * qty)) * 365 / days * 100 : 0; return { ok: true, ticker: info.ticker, kind: kind, credit: cash, days: days, ann: ann, msg: info.ticker + ' rolled ' + oldLabel + ' → ' + newLabel + ' for $' + cash.toFixed(2) + ' net credit (' + days + ' extra days, about ' + ann.toFixed(0) + '% annualized on those days).' }; } // Record a Cash Move: f = { type, amount, note } function addCashMove(f) { var type = String(f.type||'').trim(); var amount = Number(f.amount); var valid = ['Withdraw Cash Flow','Withdraw Capital','Add Capital','Reinvest']; if (valid.indexOf(type) < 0) throw new Error('Pick a type.'); if (!(amount > 0)) throw new Error('Enter an amount greater than zero.'); var bal = getLiveBalances(); if ((type === 'Withdraw Cash Flow' || type === 'Reinvest') && amount > bal.cashFlow + 0.005) throw new Error('Only $' + bal.cashFlow.toFixed(2) + ' of cash flow is available to ' + (type === 'Reinvest' ? 'reinvest' : 'withdraw') + '.'); if (type === 'Withdraw Capital' && amount > bal.toTrade + 0.005) throw new Error('Only $' + bal.toTrade.toFixed(2) + ' of un-committed capital is available to withdraw.'); appendLedgerRow_({ date: f.tradeDate ? new Date(f.tradeDate + 'T00:00:00') : new Date(), type: type, amount: amount, note: f.note ? String(f.note) : '' }); return { ok:true, type: type, amount: amount, bal: getLiveBalances() }; } // Auto-settle EXPIRED OPTIONS - both legs of the wheel. Runs on expiration day // itself once past the 4:05pm ET bell (live quote, 1% clearance band; anything // closer waits for the official close next day), and on any later day off the // official close. Open Puts: expired vs assigned. Open Calls: call expired vs // called away. function checkExpirations() { var now = new Date(); var today = new Date(); today.setHours(0,0,0,0); var etMinutes = Number(Utilities.formatDate(now, 'America/New_York', 'HH')) * 60 + Number(Utilities.formatDate(now, 'America/New_York', 'mm')); var afterBell = etMinutes >= (16 * 60 + 5); // 4:05pm ET, a small buffer past the close var BAND = 0.01; // 1% clearance required for same-day settles var done = []; function settle(kind, cyc, info) { // kind: 'put' | 'call' if (!(info.exp instanceof Date)) return; var exp = new Date(info.exp); exp.setHours(0,0,0,0); if (exp.getTime() > today.getTime()) return; // not expired yet var sameDay = exp.getTime() === today.getTime(); if (sameDay && !afterBell) { done.push(info.ticker + ': expires today - check back after the 4pm ET bell'); return; } var px = sameDay ? getLivePrice_(info.ticker) : getCloseOnDate_(info.ticker, info.exp); if (px === null) { done.push(info.ticker + ': price unavailable (left for you)'); return; } var hi = sameDay ? info.strike * (1 + BAND) : info.strike; var lo = sameDay ? info.strike * (1 - BAND) : info.strike; var tag = sameDay ? ' at the bell' : ''; var noteTag = sameDay ? 'Auto (at the bell): ' : 'Auto: '; if (px >= hi) { if (kind === 'put') { closePosition({ cycle: cyc, outcome: 'expired', note: noteTag + '$' + px.toFixed(2) + ' above strike $' + info.strike + ' -> expired' }); done.push(info.ticker + ' PUT EXPIRED' + tag + ' ($' + px.toFixed(2) + ') - collateral freed, premium kept'); } else { closeCallLeg_(cyc, 'called_away', noteTag + '$' + px.toFixed(2) + ' above strike $' + info.strike + ' -> called away'); done.push(info.ticker + ' CALLED AWAY' + tag + ' ($' + px.toFixed(2) + ') - shares sold at $' + info.strike); } } else if (px <= lo) { if (kind === 'put') { closePosition({ cycle: cyc, outcome: 'assigned', note: noteTag + '$' + px.toFixed(2) + ' below strike $' + info.strike + ' -> assigned' }); done.push(info.ticker + ' ASSIGNED' + tag + ' ($' + px.toFixed(2) + ') - you own shares at $' + info.strike); } else { closeCallLeg_(cyc, 'call_expired', noteTag + '$' + px.toFixed(2) + ' below strike $' + info.strike + ' -> call expired'); done.push(info.ticker + ' CALL EXPIRED' + tag + ' ($' + px.toFixed(2) + ') - shares kept, sell the next call'); } } else { done.push(info.ticker + ': $' + px.toFixed(2) + ' is within 1% of the $' + info.strike + ' strike - too close to call at the bell. Run this again tomorrow for the official close.'); } } var puts = ptRowsByStatus_(OPEN_PUT); for (var i = 0; i < puts.length; i++) { var cycP = puts[i][P.cycle-1]; try { settle('put', cycP, cycleInfo_(cycP)); } catch (e) { done.push(cycP + ': ' + e.message); } } var calls = ptRowsByStatus_(OPEN_CALL); for (var j = 0; j < calls.length; j++) { var cycC = calls[j][P.cycle-1]; try { settle('call', cycC, callInfo_(cycC)); } catch (e) { done.push(cycC + ': ' + e.message); } } var ui = SpreadsheetApp.getUi(); ui.alert('Expiration check', done.length ? done.join('\n') : 'No expired options to settle right now.', ui.ButtonSet.OK); } /* --------------------------------------------------------------------- HTML */ function baseCss_() { return ''; } /** Full-screen confirm/error panel shared by all dialogs. */ function ovlHtml_(yesLabel) { return '
' + '
' + '
' + '
'; } function ovlJs_() { return 'function done(t,d){var T=document.getElementById("ovlT");T.className="big ok";T.textContent=t;' + 'document.getElementById("ovlD").textContent=d;document.getElementById("ovlYN").style.display="flex";' + 'document.getElementById("ovlOK").style.display="none";document.getElementById("ovl").className="ovl show";}' + 'function oops(d){var T=document.getElementById("ovlT");T.className="big bad";T.textContent="\\u26a0 That didn\\u2019t go through";' + 'document.getElementById("ovlD").textContent=d;document.getElementById("ovlYN").style.display="none";' + 'document.getElementById("ovlOK").style.display="flex";document.getElementById("ovl").className="ovl show";}'; } function sellPutHtml_() { return '' + baseCss_() + '' + '

Sell a Put

Opens a new wheel cycle. The Cycle ID is created for you.
' + '
' + '
' + '
' + '
' + '
' + '
' + '
Defaults to today. Change it if you are logging a trade from an earlier day.
' + '
Auto = TICKER-EXPIRATION-STRIKE. Edit only if you want.
' + '' + '
Estimated credit: $0.00
' + '
' + ovlHtml_('Yes — log another') + '