diff --git a/api/quote.js b/api/quote.js index 0ec56f8..0e1862c 100644 --- a/api/quote.js +++ b/api/quote.js @@ -35,12 +35,32 @@ module.exports = async function handler(req, res) { // ── TWELVE DATA (fallback + market-open state) — batched ───── if (provider === 'twelvedata') { + const key = process.env.TWELVEDATA_KEY; + if (!key) return res.status(500).json({ error: 'TWELVEDATA_KEY not configured' }); + + // Daily close history for one symbol (benchmark comparison — #145). + if (req.query.type === 'timeseries') { + const symbol = req.query.symbol || ''; + const start = req.query.start_date || ''; + const end = req.query.end_date || ''; + if (!/^[A-Za-z0-9.\-]{1,12}$/.test(symbol)) return res.status(400).json({ error: 'Invalid symbol' }); + if (!/^\d{4}-\d{2}-\d{2}$/.test(start)) return res.status(400).json({ error: 'Invalid start_date' }); + if (end && !/^\d{4}-\d{2}-\d{2}$/.test(end)) return res.status(400).json({ error: 'Invalid end_date' }); + try { + let url = `https://api.twelvedata.com/time_series?symbol=${encodeURIComponent(symbol)}&interval=1day&start_date=${start}&order=ASC&apikey=${key}`; + if (end) url += `&end_date=${end}`; + const upstream = await fetch(url); + const data = await upstream.json(); + return res.status(upstream.status).json(data); + } catch (e) { + return res.status(502).json({ error: 'Twelve Data upstream failed: ' + e.message }); + } + } + const symbols = req.query.symbols || ''; if (!/^[A-Za-z0-9.,\-]{1,120}$/.test(symbols)) { return res.status(400).json({ error: 'Invalid symbols' }); } - const key = process.env.TWELVEDATA_KEY; - if (!key) return res.status(500).json({ error: 'TWELVEDATA_KEY not configured' }); try { const upstream = await fetch(`https://api.twelvedata.com/quote?symbol=${encodeURIComponent(symbols)}&apikey=${key}`); const data = await upstream.json(); diff --git a/src/css/styles.css b/src/css/styles.css index f92a700..49beac7 100644 --- a/src/css/styles.css +++ b/src/css/styles.css @@ -104,6 +104,24 @@ body{font-family:var(--mono);background:var(--bg);color:var(--text);font-size:15 .expiry-section{background:var(--surface);border-left:2px solid var(--green);padding:14px 16px 0;margin-bottom:0} body[data-app="tradfi"] .expiry-section{display:none} +/* ── Monthly Summary (Wheeler only) ──────────────────────── */ +body[data-app="crypto"] .msum-sec{display:none} +.msum-grid{display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px} +.msum-tile{background:var(--s2);border:1px solid var(--bd);border-radius:8px;padding:14px} +.msum-lbl{font-size:.54rem;font-weight:700;text-transform:uppercase;letter-spacing:1.2px;color:var(--mu);margin-bottom:6px;font-family:var(--mono)} +.msum-val{font-size:1.5rem;font-weight:700;font-family:var(--mono);line-height:1.1} +.msum-val.pos{color:var(--green)} +.msum-val.neg{color:var(--red)} +.msum-val.zero{color:var(--text)} +.msum-sub{font-size:.6rem;color:var(--text2);margin-top:5px;font-family:var(--mono)} +.msum-brow{display:flex;align-items:center;justify-content:space-between;font-family:var(--mono);font-size:.72rem;padding:3px 0} +.msum-blbl{color:var(--text2)} +.msum-bval{font-weight:700} +.msum-bval.pos{color:var(--green)} +.msum-bval.neg{color:var(--red)} +.msum-bval.zero{color:var(--mu)} +@media(max-width:600px){.msum-grid{grid-template-columns:1fr}} + /* ── P&L Calendar (Wheeler only) ─────────────────────────── */ body[data-app="crypto"] .pnl-cal-sec{display:none} .cal-hd{display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:10px;margin-bottom:12px} diff --git a/src/html/body.html b/src/html/body.html index 26d69b5..f957f9b 100644 --- a/src/html/body.html +++ b/src/html/body.html @@ -80,6 +80,9 @@
+ +
+
diff --git a/src/js/core/01-state.js b/src/js/core/01-state.js index 559409e..df00a40 100644 --- a/src/js/core/01-state.js +++ b/src/js/core/01-state.js @@ -23,3 +23,7 @@ let sHistTo = ''; // P&L Calendar displayed month (Wheeler only), 'YYYY-MM'. Empty → current month // (lazily set on first render in 07b-render-pnl-calendar.js). let sCalMonth = ''; + +// Monthly Summary displayed month (Wheeler only), 'YYYY-MM'. Empty → current +// month (lazily set on first render in 07c-render-monthly-summary.js). +let sSumMonth = ''; diff --git a/src/js/core/07c-render-monthly-summary.js b/src/js/core/07c-render-monthly-summary.js new file mode 100644 index 0000000..24b97c3 --- /dev/null +++ b/src/js/core/07c-render-monthly-summary.js @@ -0,0 +1,176 @@ +// ── MONTHLY SUMMARY + BENCHMARK (Wheeler) ───────────────── +// A month-scoped snapshot card: Premium Collected (+ trade count), Net P/L +// (realised for the month), and a Benchmark card showing SPY / QQQ price +// return over the same month for context alongside the portfolio's net dollars. +// Gated to Wheeler (tradfi) — see rMonthlySummary (crypto section CSS-hidden). +// +// Zero bookkeeping by design: the portfolio figure is absolute dollars, never a +// %-return (that would need an account NAV Wheeler doesn't track). The benchmark +// %s are context, not a head-to-head. SPY/QQQ stand in for S&P 500 / Nasdaq 100. +// +// monthlySummary() is pure and dual-exported for Node tests. Premium + trade +// count are bucketed by realisation date (close date for buy-to-close, else +// expiry) — the same rule computePnl uses — matching "premiums by close date". + +function monthlySummary(trades, assetFilter, ym) { + const cp = (typeof computePnl !== 'undefined') + ? computePnl + : require('./05b-pnl.js').computePnl; + const { realisedByMonth } = cp(trades, assetFilter, {}); + + const filtered = (assetFilter && assetFilter !== 'ALL') + ? trades.filter(t => t.asset === assetFilter) + : trades; + + const realDate = t => (t.outcome === 'CLOSED' && t.closeDate) + ? t.closeDate : (t.expiry || t.date); + + let premium = 0, tradeCount = 0; + filtered.forEach(t => { + if (t.type === 'HOLDING' || t.outcome === 'OPEN') return; + const d = realDate(t); + if (!d || d.slice(0, 7) !== ym) return; + premium += (t.premium || 0); + tradeCount++; + }); + + return { ym, premium, tradeCount, netPnl: realisedByMonth[ym] || 0 }; +} + +// ── benchmark cache (localStorage) ──────────────────────── +// { "SPY:2026-08": { pct, asof: "YYYY-MM-DD" } }. Past months are fixed once +// fetched; the current month is refetched when asof != today. +const MSUM_BENCH_KEY = 'wheeler_bench'; +const MSUM_BENCHMARKS = [ + { sym: 'SPY', label: 'S&P 500' }, + { sym: 'QQQ', label: 'Nasdaq 100' }, +]; + +function _benchCache() { + try { return JSON.parse(localStorage.getItem(MSUM_BENCH_KEY) || '{}'); } + catch (e) { return {}; } +} +function _benchGet(sym, ym) { + const hit = _benchCache()[sym + ':' + ym]; + if (!hit) return undefined; + const isCurrent = ym === today().slice(0, 7); + if (isCurrent && hit.asof !== today()) return undefined; // stale current month + return hit.pct; +} +function _benchSet(sym, ym, pct) { + const c = _benchCache(); + c[sym + ':' + ym] = { pct, asof: today() }; + try { localStorage.setItem(MSUM_BENCH_KEY, JSON.stringify(c)); } catch (e) {} +} + +// Month price return = (last close in month − last close before it) / baseline. +// Requests a short lead-in so the prior close is available as the baseline. +async function _fetchBenchPct(sym, ym) { + const [y, m] = ym.split('-').map(Number); + const monthStart = ym + '-01'; + const lead = new Date(Date.UTC(y, m - 1, 1)); + lead.setUTCDate(lead.getUTCDate() - 7); + const startReq = lead.toISOString().slice(0, 10); + const isCurrent = ym === today().slice(0, 7); + const endReq = isCurrent ? today() : new Date(Date.UTC(y, m, 0)).toISOString().slice(0, 10); + + const r = await fetch('/api/quote?provider=twelvedata&type=timeseries&symbol=' + + encodeURIComponent(sym) + '&start_date=' + startReq + '&end_date=' + endReq); + if (!r.ok) throw new Error('timeseries ' + r.status); + const d = await r.json(); + if (!d || d.status === 'error' || !Array.isArray(d.values) || !d.values.length) { + throw new Error('no timeseries for ' + sym); + } + // API returns ASC (we requested order=ASC); each row { datetime, close }. + const rows = d.values.filter(v => v && v.close).map(v => ({ dt: v.datetime, c: parseFloat(v.close) })); + const before = rows.filter(v => v.dt < monthStart); + const inMonth = rows.filter(v => v.dt >= monthStart); + if (!inMonth.length) throw new Error('no in-month bars for ' + sym); + const baseline = before.length ? before[before.length - 1].c : inMonth[0].c; + const final = inMonth[inMonth.length - 1].c; + if (!baseline) throw new Error('no baseline for ' + sym); + return ((final - baseline) / baseline) * 100; +} + +let _benchFetching = ''; +async function _loadBenchmarks(ym) { + if (_benchFetching === ym) return; // in flight for this month + _benchFetching = ym; + let any = false; + for (const b of MSUM_BENCHMARKS) { + if (_benchGet(b.sym, ym) !== undefined) continue; + try { _benchSet(b.sym, ym, await _fetchBenchPct(b.sym, ym)); any = true; } + catch (e) { /* leave uncached; card shows — for this one */ } + } + _benchFetching = ''; + if (any) rMonthlySummary(); +} + +// Signed percent, e.g. +1.2% / −6.6%. +function _benchPct(n) { + return (n >= 0 ? '+' : '−') + Math.abs(n).toFixed(1) + '%'; +} + +function rMonthlySummary() { + const host = document.getElementById('msum-sec'); + if (!host) return; + if (!_isTradfi()) { host.innerHTML = ''; return; } + if (!sSumMonth) sSumMonth = today().slice(0, 7); + + const { ym, premium, tradeCount, netPnl } = monthlySummary(trades, sFilter, sSumMonth); + const [y, m] = ym.split('-').map(Number); + const isCurrent = ym === today().slice(0, 7); + const rangeLbl = isCurrent + ? 'Month to Date' + : CAL_MONTHS[m - 1] + ' ' + y; + const netCls = netPnl > 0 ? 'pos' : netPnl < 0 ? 'neg' : 'zero'; + + let bench = ''; + MSUM_BENCHMARKS.forEach(b => { + const pct = _benchGet(b.sym, ym); + const cls = pct === undefined ? 'zero' : pct > 0 ? 'pos' : pct < 0 ? 'neg' : 'zero'; + const val = pct === undefined ? '—' : _benchPct(pct); + bench += '
' + b.label + '' + + '' + val + '
'; + }); + + host.innerHTML = + '
' + + '
Monthly Summary
' + + '
' + + '' + + '' + rangeLbl + '' + + '' + + '
' + + '
' + + '
' + + '
' + + '
Premium Collected
' + + '
$' + sk(Math.round(premium)) + '
' + + '
' + tradeCount + ' trade' + (tradeCount === 1 ? '' : 's') + '
' + + '
' + + '
' + + '
Net P&L
' + + '
' + _calMoney(netPnl) + '
' + + '
realised this month
' + + '
' + + '
' + + '
Benchmark (price return)
' + + bench + + '
' + + '
'; + + _loadBenchmarks(ym); +} + +// Shift the displayed month by `delta` months and re-render the summary. +function setSumMonth(delta) { + if (!sSumMonth) sSumMonth = today().slice(0, 7); + const [y, m] = sSumMonth.split('-').map(Number); + sSumMonth = new Date(Date.UTC(y, m - 1 + delta, 1)).toISOString().slice(0, 7); + rMonthlySummary(); +} + +if (typeof module !== 'undefined' && module.exports) { + module.exports = { monthlySummary }; +} diff --git a/src/js/core/08-render.js b/src/js/core/08-render.js index ac9fed0..48341dd 100644 --- a/src/js/core/08-render.js +++ b/src/js/core/08-render.js @@ -9,5 +9,6 @@ function render() { rTable(displayRows, streams, lots); rOutcomeChart(); rCharts(displayRows, lots); + rMonthlySummary(); // Wheeler only — self-gates on tradfi rPnlCalendar(); // Wheeler only — self-gates on tradfi } diff --git a/test/unit/monthly-summary.test.js b/test/unit/monthly-summary.test.js new file mode 100644 index 0000000..7814b6f --- /dev/null +++ b/test/unit/monthly-summary.test.js @@ -0,0 +1,45 @@ +const test = require('node:test'); +const assert = require('node:assert'); +const { monthlySummary } = require('../../src/js/core/07c-render-monthly-summary.js'); +const { computePnl } = require('../../src/js/core/05b-pnl.js'); +// monthlySummary reaches for a global computePnl first; provide it for Node. +global.computePnl = computePnl; +global.lotEngine = require('../../src/js/core/04b-lot-engine.js').lotEngine; + +test('sums premium + counts settled trades bucketed by realisation month', () => { + const trades = [ + // settled in Aug — counted + { id: 1, asset: 'IBIT', type: 'PUT', date: '2026-08-03', expiry: '2026-08-14', + strike: 60, size: 100, premium: 226, outcome: 'EXPIRED', closeCost: 0, closeDate: '' }, + // CLOSED-early — buckets on closeDate (Aug), premium is gross (not net of closeCost) + { id: 2, asset: 'IBIT', type: 'CALL', date: '2026-08-01', expiry: '2026-09-05', + strike: 65, size: 100, premium: 100, outcome: 'CLOSED', closeCost: 15, closeDate: '2026-08-11' }, + // OPEN — excluded + { id: 3, asset: 'IBIT', type: 'PUT', date: '2026-08-20', expiry: '2026-09-18', + strike: 58, size: 100, premium: 300, outcome: 'OPEN', closeCost: 0, closeDate: '' }, + // HOLDING — excluded + { id: 4, asset: 'IBIT', type: 'HOLDING', date: '2026-08-14', + strike: 60, size: 100, premium: 0, outcome: 'OPEN', closeCost: 0, closeDate: '' }, + ]; + const s = monthlySummary(trades, 'ALL', '2026-08'); + assert.strictEqual(s.premium, 326, 'gross premium of the two settled options'); + assert.strictEqual(s.tradeCount, 2); +}); + +test('netPnl matches computePnl realisedByMonth for the month', () => { + const trades = [ + { id: 1, asset: 'IBIT', type: 'PUT', date: '2026-07-03', expiry: '2026-07-14', + strike: 60, size: 100, premium: 200, outcome: 'EXPIRED', closeCost: 0, closeDate: '' }, + { id: 2, asset: 'IBIT', type: 'CALL', date: '2026-08-01', expiry: '2026-08-28', + strike: 65, size: 100, premium: 100, outcome: 'CLOSED', closeCost: 15, closeDate: '2026-08-11' }, + ]; + const { realisedByMonth } = computePnl(trades, 'ALL', {}); + assert.strictEqual(monthlySummary(trades, 'ALL', '2026-08').netPnl, realisedByMonth['2026-08']); + assert.strictEqual(monthlySummary(trades, 'ALL', '2026-08').netPnl, 85, '100 − 15 close cost'); + assert.strictEqual(monthlySummary(trades, 'ALL', '2026-07').netPnl, 200); +}); + +test('empty month yields zeros', () => { + const s = monthlySummary([], 'ALL', '2026-08'); + assert.deepStrictEqual(s, { ym: '2026-08', premium: 0, tradeCount: 0, netPnl: 0 }); +});