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
24 changes: 22 additions & 2 deletions api/quote.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
18 changes: 18 additions & 0 deletions src/css/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
3 changes: 3 additions & 0 deletions src/html/body.html
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,9 @@
<div id="ppnl-body"></div>
</div>

<!-- MONTHLY SUMMARY (Wheeler only; populated by rMonthlySummary) -->
<div class="sec msum-sec" id="msum-sec"></div>

<!-- P&L CALENDAR (Wheeler only; populated by rPnlCalendar) -->
<div class="sec pnl-cal-sec" id="pnl-cal-sec"></div>

Expand Down
4 changes: 4 additions & 0 deletions src/js/core/01-state.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '';
176 changes: 176 additions & 0 deletions src/js/core/07c-render-monthly-summary.js
Original file line number Diff line number Diff line change
@@ -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 += '<div class="msum-brow"><span class="msum-blbl">' + b.label + '</span>' +
'<span class="msum-bval ' + cls + '">' + val + '</span></div>';
});

host.innerHTML =
'<div class="cal-hd">' +
'<div class="cal-ttl"><span class="dot dg"></span>Monthly Summary</div>' +
'<div class="cal-nav">' +
'<button class="cal-navbtn" onclick="setSumMonth(-1)" title="Previous month">&#8249;</button>' +
'<span class="cal-month">' + rangeLbl + '</span>' +
'<button class="cal-navbtn" onclick="setSumMonth(1)" title="Next month">&#8250;</button>' +
'</div>' +
'</div>' +
'<div class="msum-grid">' +
'<div class="msum-tile">' +
'<div class="msum-lbl">Premium Collected</div>' +
'<div class="msum-val">$' + sk(Math.round(premium)) + '</div>' +
'<div class="msum-sub">' + tradeCount + ' trade' + (tradeCount === 1 ? '' : 's') + '</div>' +
'</div>' +
'<div class="msum-tile">' +
'<div class="msum-lbl">Net P&amp;L</div>' +
'<div class="msum-val ' + netCls + '">' + _calMoney(netPnl) + '</div>' +
'<div class="msum-sub">realised this month</div>' +
'</div>' +
'<div class="msum-tile msum-bench">' +
'<div class="msum-lbl">Benchmark (price return)</div>' +
bench +
'</div>' +
'</div>';

_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 };
}
1 change: 1 addition & 0 deletions src/js/core/08-render.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
45 changes: 45 additions & 0 deletions test/unit/monthly-summary.test.js
Original file line number Diff line number Diff line change
@@ -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 });
});
Loading