@spfunctions/cli 1.2.1 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,293 @@
1
+ "use strict";
2
+ /**
3
+ * sf liquidity — Market liquidity scanner by topic and horizon
4
+ *
5
+ * Scans known series, fetches public orderbooks, and displays
6
+ * spread/depth/slippage data grouped by topic and horizon.
7
+ */
8
+ Object.defineProperty(exports, "__esModule", { value: true });
9
+ exports.liquidityCommand = liquidityCommand;
10
+ const client_js_1 = require("../client.js");
11
+ const kalshi_js_1 = require("../kalshi.js");
12
+ const topics_js_1 = require("../topics.js");
13
+ const utils_js_1 = require("../utils.js");
14
+ // ── Horizon classification ───────────────────────────────────────────────────
15
+ function classifyHorizon(closeTime) {
16
+ const now = Date.now();
17
+ const close = new Date(closeTime).getTime();
18
+ const daysAway = (close - now) / (1000 * 60 * 60 * 24);
19
+ if (daysAway < 7)
20
+ return 'weekly';
21
+ if (daysAway <= 35)
22
+ return 'monthly';
23
+ return 'long-term';
24
+ }
25
+ function horizonLabel(h) {
26
+ switch (h) {
27
+ case 'weekly': return 'weekly (<7d)';
28
+ case 'monthly': return 'monthly (7-35d)';
29
+ case 'long-term': return 'long-term (>35d)';
30
+ }
31
+ }
32
+ // ── Slippage calculation ─────────────────────────────────────────────────────
33
+ /**
34
+ * Calculate weighted average price to buy `qty` YES contracts
35
+ * by eating NO bids (selling NO = buying YES).
36
+ *
37
+ * no_dollars are sorted low→high by price. The best NO bid
38
+ * (highest price) is the cheapest YES ask.
39
+ */
40
+ function calcSlippage100(noDollars, qty) {
41
+ // Sort descending by price (highest no bid = cheapest yes ask)
42
+ const levels = noDollars
43
+ .map(([price, amount]) => ({
44
+ noPrice: parseFloat(price),
45
+ yesAsk: 1.0 - parseFloat(price),
46
+ qty: parseFloat(amount),
47
+ }))
48
+ .filter(l => l.noPrice > 0 && l.qty > 0)
49
+ .sort((a, b) => b.noPrice - a.noPrice); // highest no price first = lowest yes ask
50
+ let remaining = qty;
51
+ let totalCost = 0;
52
+ for (const level of levels) {
53
+ if (remaining <= 0)
54
+ break;
55
+ const fill = Math.min(remaining, level.qty);
56
+ totalCost += fill * level.yesAsk;
57
+ remaining -= fill;
58
+ }
59
+ if (remaining > 0)
60
+ return '∞';
61
+ const avgPrice = totalCost / qty;
62
+ return (avgPrice * 100).toFixed(1) + '¢';
63
+ }
64
+ // ── Batch concurrency helper ─────────────────────────────────────────────────
65
+ async function batchProcess(items, fn, batchSize, delayMs) {
66
+ const results = [];
67
+ for (let i = 0; i < items.length; i += batchSize) {
68
+ const batch = items.slice(i, i + batchSize);
69
+ const settled = await Promise.allSettled(batch.map(fn));
70
+ for (const s of settled) {
71
+ results.push(s.status === 'fulfilled' ? s.value : null);
72
+ }
73
+ if (i + batchSize < items.length) {
74
+ await new Promise(resolve => setTimeout(resolve, delayMs));
75
+ }
76
+ }
77
+ return results;
78
+ }
79
+ // ── Main command ─────────────────────────────────────────────────────────────
80
+ async function liquidityCommand(opts) {
81
+ // Determine which topics to scan
82
+ const allTopics = Object.keys(topics_js_1.TOPIC_SERIES);
83
+ const topics = opts.topic
84
+ ? allTopics.filter(t => t.toLowerCase() === opts.topic.toLowerCase())
85
+ : allTopics;
86
+ if (topics.length === 0) {
87
+ const valid = allTopics.join(', ');
88
+ console.error(`Unknown topic: ${opts.topic}. Valid topics: ${valid}`);
89
+ process.exit(1);
90
+ }
91
+ // Fetch held positions if Kalshi is configured
92
+ let heldTickers = new Set();
93
+ if ((0, kalshi_js_1.isKalshiConfigured)()) {
94
+ try {
95
+ const positions = await (0, kalshi_js_1.getPositions)();
96
+ if (positions) {
97
+ heldTickers = new Set(positions.map(p => p.ticker));
98
+ }
99
+ }
100
+ catch {
101
+ // ignore — positions are optional decoration
102
+ }
103
+ }
104
+ // Collect all markets per topic
105
+ const topicMarkets = {};
106
+ for (const topic of topics) {
107
+ const series = topics_js_1.TOPIC_SERIES[topic];
108
+ const markets = [];
109
+ for (const seriesTicker of series) {
110
+ try {
111
+ const m = await (0, client_js_1.kalshiFetchMarketsBySeries)(seriesTicker);
112
+ markets.push(...m);
113
+ }
114
+ catch {
115
+ // skip failed series
116
+ }
117
+ }
118
+ if (markets.length > 0) {
119
+ topicMarkets[topic] = markets;
120
+ }
121
+ }
122
+ // Filter by horizon if specified, classify all markets
123
+ const horizonFilter = opts.horizon;
124
+ const marketInfos = [];
125
+ for (const [topic, markets] of Object.entries(topicMarkets)) {
126
+ for (const m of markets) {
127
+ const closeTime = m.close_time || m.expiration_time || '';
128
+ if (!closeTime)
129
+ continue;
130
+ const horizon = classifyHorizon(closeTime);
131
+ if (horizonFilter && horizon !== horizonFilter)
132
+ continue;
133
+ marketInfos.push({ ticker: m.ticker, closeTime, topic, horizon });
134
+ }
135
+ }
136
+ if (marketInfos.length === 0) {
137
+ console.log('No markets found matching filters.');
138
+ return;
139
+ }
140
+ // Fetch orderbooks in batches of 5, 100ms between batches
141
+ const orderbooks = await batchProcess(marketInfos, async (info) => {
142
+ const ob = await (0, kalshi_js_1.getPublicOrderbook)(info.ticker);
143
+ return { info, ob };
144
+ }, 5, 100);
145
+ // Build liquidity rows
146
+ const rows = [];
147
+ for (const result of orderbooks) {
148
+ if (!result || !result.ob)
149
+ continue;
150
+ const { info, ob } = result;
151
+ const yesDollars = ob.yes_dollars.map(([p, q]) => ({
152
+ price: Math.round(parseFloat(p) * 100),
153
+ qty: parseFloat(q),
154
+ })).filter(l => l.price > 0);
155
+ const noDollars = ob.no_dollars.map(([p, q]) => ({
156
+ price: Math.round(parseFloat(p) * 100),
157
+ qty: parseFloat(q),
158
+ })).filter(l => l.price > 0);
159
+ // Sort descending
160
+ yesDollars.sort((a, b) => b.price - a.price);
161
+ noDollars.sort((a, b) => b.price - a.price);
162
+ const bestBid = yesDollars.length > 0 ? yesDollars[0].price : 0;
163
+ const bestAsk = noDollars.length > 0 ? (100 - noDollars[0].price) : 100;
164
+ const spread = bestAsk - bestBid;
165
+ const bidDepth = yesDollars.reduce((sum, l) => sum + l.qty, 0);
166
+ const askDepth = noDollars.reduce((sum, l) => sum + l.qty, 0);
167
+ const slippage100 = calcSlippage100(ob.no_dollars, 100);
168
+ // Filter by minDepth
169
+ if (opts.minDepth && (bidDepth + askDepth) < opts.minDepth)
170
+ continue;
171
+ rows.push({
172
+ ticker: info.ticker,
173
+ shortTicker: info.ticker, // will abbreviate per-group below
174
+ horizon: info.horizon,
175
+ closeTime: info.closeTime,
176
+ bestBid,
177
+ bestAsk,
178
+ spread,
179
+ bidDepth,
180
+ askDepth,
181
+ slippage100,
182
+ held: heldTickers.has(info.ticker),
183
+ });
184
+ }
185
+ // ── JSON output ────────────────────────────────────────────────────────────
186
+ if (opts.json) {
187
+ console.log(JSON.stringify(rows, null, 2));
188
+ return;
189
+ }
190
+ // ── Formatted output ───────────────────────────────────────────────────────
191
+ const now = new Date().toISOString().slice(0, 10);
192
+ console.log();
193
+ console.log(`${utils_js_1.c.bold}Liquidity Scanner${utils_js_1.c.reset} ${utils_js_1.c.dim}(${now} UTC)${utils_js_1.c.reset}`);
194
+ console.log(utils_js_1.c.dim + '─'.repeat(68) + utils_js_1.c.reset);
195
+ // Group rows by topic → horizon
196
+ const grouped = {};
197
+ for (const row of rows) {
198
+ // find which topic this ticker belongs to
199
+ let topic = 'OTHER';
200
+ for (const [t, series] of Object.entries(topics_js_1.TOPIC_SERIES)) {
201
+ for (const s of series) {
202
+ if (row.ticker.toUpperCase().startsWith(s)) {
203
+ topic = t.toUpperCase();
204
+ break;
205
+ }
206
+ }
207
+ if (topic !== 'OTHER')
208
+ break;
209
+ }
210
+ if (!grouped[topic])
211
+ grouped[topic] = {};
212
+ if (!grouped[topic][row.horizon])
213
+ grouped[topic][row.horizon] = [];
214
+ grouped[topic][row.horizon].push(row);
215
+ }
216
+ let totalMarkets = 0;
217
+ let thinMarkets = 0;
218
+ let heldCount = 0;
219
+ const horizonOrder = ['weekly', 'monthly', 'long-term'];
220
+ for (const [topic, horizons] of Object.entries(grouped)) {
221
+ for (const h of horizonOrder) {
222
+ const marketRows = horizons[h];
223
+ if (!marketRows || marketRows.length === 0)
224
+ continue;
225
+ // Find common prefix for abbreviation within this group
226
+ const commonPrefix = findCommonPrefix(marketRows.map(r => r.ticker));
227
+ // Abbreviate tickers
228
+ for (const row of marketRows) {
229
+ row.shortTicker = commonPrefix.length > 0
230
+ ? row.ticker.slice(commonPrefix.length).replace(/^-/, '')
231
+ : row.ticker;
232
+ if (row.shortTicker.length === 0)
233
+ row.shortTicker = row.ticker;
234
+ }
235
+ // Sort by ticker
236
+ marketRows.sort((a, b) => a.ticker.localeCompare(b.ticker));
237
+ console.log();
238
+ console.log(`${utils_js_1.c.bold}${utils_js_1.c.cyan}${topic}${utils_js_1.c.reset} ${utils_js_1.c.dim}— ${horizonLabel(h)}${utils_js_1.c.reset}`);
239
+ console.log(`${utils_js_1.c.dim}${(0, utils_js_1.pad)('Ticker', 20)} ${(0, utils_js_1.rpad)('Bid¢', 5)} ${(0, utils_js_1.rpad)('Ask¢', 5)} ${(0, utils_js_1.rpad)('Spread', 6)} ${(0, utils_js_1.rpad)('BidDep', 6)} ${(0, utils_js_1.rpad)('AskDep', 6)} ${(0, utils_js_1.rpad)('Slip100', 7)}${utils_js_1.c.reset}`);
240
+ for (const row of marketRows) {
241
+ totalMarkets++;
242
+ if (row.held)
243
+ heldCount++;
244
+ const thin = row.spread > 5;
245
+ if (thin)
246
+ thinMarkets++;
247
+ // Color spread
248
+ let spreadStr;
249
+ if (row.spread <= 2) {
250
+ spreadStr = `${utils_js_1.c.green}${row.spread}¢${utils_js_1.c.reset}`;
251
+ }
252
+ else if (row.spread <= 5) {
253
+ spreadStr = `${utils_js_1.c.yellow}${row.spread}¢${utils_js_1.c.reset}`;
254
+ }
255
+ else {
256
+ spreadStr = `${utils_js_1.c.red}${row.spread}¢${utils_js_1.c.reset}`;
257
+ }
258
+ const thinMark = thin ? ' \u26A0\uFE0F' : '';
259
+ const heldMark = row.held ? ` ${utils_js_1.c.magenta}\u2190 held${utils_js_1.c.reset}` : '';
260
+ // Pad spread field accounting for ANSI codes
261
+ const spreadPadded = (0, utils_js_1.rpad)(`${row.spread}¢`, 6);
262
+ const spreadColored = row.spread <= 2
263
+ ? `${utils_js_1.c.green}${spreadPadded}${utils_js_1.c.reset}`
264
+ : row.spread <= 5
265
+ ? `${utils_js_1.c.yellow}${spreadPadded}${utils_js_1.c.reset}`
266
+ : `${utils_js_1.c.red}${spreadPadded}${utils_js_1.c.reset}`;
267
+ console.log(`${(0, utils_js_1.pad)(row.shortTicker, 20)} ${(0, utils_js_1.rpad)(String(row.bestBid), 5)} ${(0, utils_js_1.rpad)(String(row.bestAsk), 5)} ${spreadColored} ${(0, utils_js_1.rpad)(String(Math.round(row.bidDepth)), 6)} ${(0, utils_js_1.rpad)(String(Math.round(row.askDepth)), 6)} ${(0, utils_js_1.rpad)(row.slippage100, 7)}${thinMark}${heldMark}`);
268
+ }
269
+ }
270
+ }
271
+ // Summary
272
+ console.log();
273
+ console.log(`${utils_js_1.c.dim}Summary: ${totalMarkets} markets | ${thinMarkets} thin (spread>5¢) | ${heldCount} held${utils_js_1.c.reset}`);
274
+ console.log();
275
+ }
276
+ // ── Helpers ──────────────────────────────────────────────────────────────────
277
+ function findCommonPrefix(strings) {
278
+ if (strings.length === 0)
279
+ return '';
280
+ if (strings.length === 1)
281
+ return '';
282
+ let prefix = strings[0];
283
+ for (let i = 1; i < strings.length; i++) {
284
+ while (!strings[i].startsWith(prefix)) {
285
+ prefix = prefix.slice(0, -1);
286
+ if (prefix.length === 0)
287
+ return '';
288
+ }
289
+ }
290
+ // Don't strip if it would leave nothing for some tickers
291
+ // Also strip trailing hyphen from prefix for cleaner display
292
+ return prefix;
293
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * sf performance — Portfolio P&L over time with thesis event annotations
3
+ */
4
+ export declare function performanceCommand(opts: {
5
+ ticker?: string;
6
+ since?: string;
7
+ json?: boolean;
8
+ }): Promise<void>;
@@ -0,0 +1,261 @@
1
+ "use strict";
2
+ /**
3
+ * sf performance — Portfolio P&L over time with thesis event annotations
4
+ */
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.performanceCommand = performanceCommand;
7
+ const client_js_1 = require("../client.js");
8
+ const kalshi_js_1 = require("../kalshi.js");
9
+ const config_js_1 = require("../config.js");
10
+ const utils_js_1 = require("../utils.js");
11
+ /** Abbreviate ticker: KXWTIMAX-26DEC31-T135 -> T135 */
12
+ function abbrevTicker(ticker) {
13
+ const parts = ticker.split('-');
14
+ return parts[parts.length - 1] || ticker;
15
+ }
16
+ /** Format date as "Mar 01" */
17
+ function fmtDate(d) {
18
+ const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
19
+ return `${months[d.getMonth()]} ${String(d.getDate()).padStart(2, '0')}`;
20
+ }
21
+ /** Format dollar amount: positive -> +$12.30, negative -> -$12.30 */
22
+ function fmtDollar(cents) {
23
+ const abs = Math.abs(cents / 100);
24
+ if (cents >= 0)
25
+ return `+$${abs.toFixed(cents === 0 ? 2 : abs >= 100 ? 1 : 2)}`;
26
+ return `-$${abs.toFixed(abs >= 100 ? 1 : 2)}`;
27
+ }
28
+ /** Date string key YYYY-MM-DD from a Date */
29
+ function dateKey(d) {
30
+ return d.toISOString().slice(0, 10);
31
+ }
32
+ async function performanceCommand(opts) {
33
+ if (!(0, kalshi_js_1.isKalshiConfigured)()) {
34
+ console.log(`${utils_js_1.c.yellow}Kalshi not configured.${utils_js_1.c.reset} Run ${utils_js_1.c.cyan}sf setup --kalshi${utils_js_1.c.reset} first.`);
35
+ return;
36
+ }
37
+ // 1. Fetch fills
38
+ const fillsResult = await (0, kalshi_js_1.getFills)({ limit: 500 });
39
+ if (!fillsResult || fillsResult.fills.length === 0) {
40
+ console.log(`${utils_js_1.c.dim}No fills found.${utils_js_1.c.reset}`);
41
+ return;
42
+ }
43
+ const fills = fillsResult.fills;
44
+ const tickerMap = new Map();
45
+ for (const fill of fills) {
46
+ const ticker = fill.ticker || fill.market_ticker || '';
47
+ if (!ticker)
48
+ continue;
49
+ const side = fill.side || 'yes';
50
+ const action = fill.action || 'buy';
51
+ const count = fill.count || 0;
52
+ const yesPrice = fill.yes_price || 0; // cents int
53
+ // Determine direction: buy yes = +count, sell yes = -count
54
+ let delta = count;
55
+ if (action === 'sell')
56
+ delta = -count;
57
+ const info = tickerMap.get(ticker) || {
58
+ ticker,
59
+ netQty: 0,
60
+ totalCostCents: 0,
61
+ totalContracts: 0,
62
+ earliestFillTs: Infinity,
63
+ };
64
+ info.netQty += delta;
65
+ if (delta > 0) {
66
+ // Buying: accumulate cost
67
+ info.totalCostCents += yesPrice * count;
68
+ info.totalContracts += count;
69
+ }
70
+ // Track earliest fill
71
+ const fillTime = fill.created_time || fill.ts || fill.created_at;
72
+ if (fillTime) {
73
+ const ts = Math.floor(new Date(fillTime).getTime() / 1000);
74
+ if (ts < info.earliestFillTs)
75
+ info.earliestFillTs = ts;
76
+ }
77
+ tickerMap.set(ticker, info);
78
+ }
79
+ // 3. Filter out fully closed positions (net qty = 0)
80
+ let tickers = [...tickerMap.values()].filter(t => t.netQty !== 0);
81
+ // 4. Apply --ticker fuzzy filter
82
+ if (opts.ticker) {
83
+ const needle = opts.ticker.toLowerCase();
84
+ tickers = tickers.filter(t => t.ticker.toLowerCase().includes(needle));
85
+ }
86
+ if (tickers.length === 0) {
87
+ console.log(`${utils_js_1.c.dim}No open positions found${opts.ticker ? ` matching "${opts.ticker}"` : ''}.${utils_js_1.c.reset}`);
88
+ return;
89
+ }
90
+ // Determine date range
91
+ const sinceTs = opts.since
92
+ ? Math.floor(new Date(opts.since).getTime() / 1000)
93
+ : Math.min(...tickers.map(t => t.earliestFillTs === Infinity ? Math.floor(Date.now() / 1000) - 30 * 86400 : t.earliestFillTs));
94
+ const nowTs = Math.floor(Date.now() / 1000);
95
+ // 5. Fetch candlesticks
96
+ const candleData = await (0, kalshi_js_1.getBatchCandlesticks)({
97
+ tickers: tickers.map(t => t.ticker),
98
+ startTs: sinceTs,
99
+ endTs: nowTs,
100
+ periodInterval: 1440,
101
+ });
102
+ // Build candlestick lookup: ticker -> dateKey -> close price (cents)
103
+ const candleMap = new Map();
104
+ for (const mc of candleData) {
105
+ const priceByDate = new Map();
106
+ for (const candle of (mc.candlesticks || [])) {
107
+ // close_dollars is a string like "0.4800"
108
+ const closeDollars = parseFloat(candle.close_dollars || candle.close || '0');
109
+ const closeCents = Math.round(closeDollars * 100);
110
+ const ts = candle.end_period_ts || candle.period_end_ts || candle.ts;
111
+ if (ts) {
112
+ const d = new Date(ts * 1000);
113
+ priceByDate.set(dateKey(d), closeCents);
114
+ }
115
+ }
116
+ candleMap.set(mc.market_ticker, priceByDate);
117
+ }
118
+ // 6. Build daily P&L matrix
119
+ // Collect all unique dates across all tickers
120
+ const allDates = new Set();
121
+ for (const [, priceByDate] of candleMap) {
122
+ for (const dk of priceByDate.keys()) {
123
+ allDates.add(dk);
124
+ }
125
+ }
126
+ const sortedDates = [...allDates].sort();
127
+ // For each ticker compute entry price
128
+ const entryPrices = new Map();
129
+ for (const t of tickers) {
130
+ const avgEntry = t.totalContracts > 0 ? Math.round(t.totalCostCents / t.totalContracts) : 0;
131
+ entryPrices.set(t.ticker, avgEntry);
132
+ }
133
+ const dailyRows = [];
134
+ for (const dk of sortedDates) {
135
+ const pnlByTicker = new Map();
136
+ let total = 0;
137
+ for (const t of tickers) {
138
+ const prices = candleMap.get(t.ticker);
139
+ const closePrice = prices?.get(dk);
140
+ if (closePrice !== undefined) {
141
+ const entry = entryPrices.get(t.ticker) || 0;
142
+ const pnl = (closePrice - entry) * t.netQty;
143
+ pnlByTicker.set(t.ticker, pnl);
144
+ total += pnl;
145
+ }
146
+ }
147
+ dailyRows.push({ date: dk, pnlByTicker, total });
148
+ }
149
+ const events = [];
150
+ try {
151
+ const config = (0, config_js_1.loadConfig)();
152
+ const client = new client_js_1.SFClient(config.apiKey, config.apiUrl);
153
+ const feedData = await client.getFeed(720);
154
+ const feedItems = feedData?.items || feedData?.events || feedData || [];
155
+ if (Array.isArray(feedItems)) {
156
+ for (const item of feedItems) {
157
+ const confDelta = item.confidenceDelta ?? item.confidence_delta ?? 0;
158
+ if (Math.abs(confDelta) > 0.03) {
159
+ const itemDate = item.createdAt || item.created_at || item.timestamp || '';
160
+ if (itemDate) {
161
+ events.push({
162
+ date: dateKey(new Date(itemDate)),
163
+ direction: confDelta > 0 ? 'up' : 'down',
164
+ deltaPct: Math.round(confDelta * 100),
165
+ summary: item.summary || item.title || item.description || '',
166
+ });
167
+ }
168
+ }
169
+ }
170
+ }
171
+ }
172
+ catch {
173
+ // Feed unavailable — continue without events
174
+ }
175
+ // Compute summary
176
+ const totalCostCents = tickers.reduce((sum, t) => sum + t.totalCostCents, 0);
177
+ const lastRow = dailyRows.length > 0 ? dailyRows[dailyRows.length - 1] : null;
178
+ const currentPnlCents = lastRow?.total ?? 0;
179
+ const currentValueCents = totalCostCents + currentPnlCents;
180
+ const pnlPct = totalCostCents > 0 ? (currentPnlCents / totalCostCents) * 100 : 0;
181
+ // 8. Output
182
+ if (opts.json) {
183
+ console.log(JSON.stringify({
184
+ daily: dailyRows.map(row => {
185
+ const tickerPnl = {};
186
+ for (const [tk, pnl] of row.pnlByTicker) {
187
+ tickerPnl[tk] = pnl;
188
+ }
189
+ return { date: row.date, tickers: tickerPnl, total: row.total };
190
+ }),
191
+ events,
192
+ summary: {
193
+ cost: totalCostCents,
194
+ current: currentValueCents,
195
+ pnl: currentPnlCents,
196
+ pnlPct: Math.round(pnlPct * 10) / 10,
197
+ },
198
+ }, null, 2));
199
+ return;
200
+ }
201
+ // Formatted output
202
+ const startDate = sortedDates.length > 0 ? fmtDate(new Date(sortedDates[0])) : '?';
203
+ const endDate = fmtDate(new Date());
204
+ console.log();
205
+ console.log(` ${utils_js_1.c.bold}Portfolio Performance${utils_js_1.c.reset} ${utils_js_1.c.dim}(${startDate} -> ${endDate})${utils_js_1.c.reset}`);
206
+ console.log(` ${utils_js_1.c.dim}${'─'.repeat(50)}${utils_js_1.c.reset}`);
207
+ console.log();
208
+ // Column headers
209
+ const abbrevs = tickers.map(t => abbrevTicker(t.ticker));
210
+ const colWidth = 9;
211
+ const dateCol = 'Date'.padEnd(12);
212
+ const headerCols = abbrevs.map(a => (0, utils_js_1.rpad)(a, colWidth)).join('');
213
+ const totalCol = (0, utils_js_1.rpad)('Total', colWidth);
214
+ console.log(` ${utils_js_1.c.dim}${dateCol}${headerCols}${totalCol}${utils_js_1.c.reset}`);
215
+ // Rows — show at most ~30 rows, sample if more
216
+ const maxRows = 30;
217
+ let rowsToShow = dailyRows;
218
+ if (dailyRows.length > maxRows) {
219
+ // Sample evenly + always include first and last
220
+ const step = Math.ceil(dailyRows.length / maxRows);
221
+ rowsToShow = dailyRows.filter((_, i) => i === 0 || i === dailyRows.length - 1 || i % step === 0);
222
+ }
223
+ for (const row of rowsToShow) {
224
+ const d = new Date(row.date);
225
+ const dateStr = fmtDate(d).padEnd(12);
226
+ const cols = tickers.map(t => {
227
+ const pnl = row.pnlByTicker.get(t.ticker);
228
+ if (pnl === undefined)
229
+ return (0, utils_js_1.rpad)('--', colWidth);
230
+ const str = fmtDollar(pnl);
231
+ const color = pnl > 0 ? utils_js_1.c.green : pnl < 0 ? utils_js_1.c.red : utils_js_1.c.dim;
232
+ return color + (0, utils_js_1.rpad)(str, colWidth) + utils_js_1.c.reset;
233
+ }).join('');
234
+ const totalStr = fmtDollar(row.total);
235
+ const totalColor = row.total > 0 ? utils_js_1.c.green : row.total < 0 ? utils_js_1.c.red : utils_js_1.c.dim;
236
+ console.log(` ${dateStr}${cols}${totalColor}${(0, utils_js_1.rpad)(totalStr, colWidth)}${utils_js_1.c.reset}`);
237
+ }
238
+ // Events
239
+ if (events.length > 0) {
240
+ console.log();
241
+ // Match events to date range
242
+ const dateSet = new Set(sortedDates);
243
+ const relevantEvents = events.filter(e => dateSet.has(e.date));
244
+ for (const ev of relevantEvents.slice(0, 10)) {
245
+ const arrow = ev.direction === 'up' ? `${utils_js_1.c.green}▲${utils_js_1.c.reset}` : `${utils_js_1.c.red}▼${utils_js_1.c.reset}`;
246
+ const dateFmt = fmtDate(new Date(ev.date));
247
+ const sign = ev.deltaPct > 0 ? '+' : '';
248
+ const summary = ev.summary.length > 60 ? ev.summary.slice(0, 59) + '...' : ev.summary;
249
+ console.log(` ${arrow} ${utils_js_1.c.dim}${dateFmt}${utils_js_1.c.reset} ${sign}${ev.deltaPct}% -> ${summary}`);
250
+ }
251
+ }
252
+ // Summary line
253
+ console.log();
254
+ const costStr = `$${(totalCostCents / 100).toFixed(0)}`;
255
+ const currentStr = `$${(currentValueCents / 100).toFixed(0)}`;
256
+ const pnlStr = fmtDollar(currentPnlCents);
257
+ const pnlPctStr = `${pnlPct >= 0 ? '+' : ''}${pnlPct.toFixed(1)}%`;
258
+ const pnlColor = currentPnlCents >= 0 ? utils_js_1.c.green : utils_js_1.c.red;
259
+ console.log(` ${utils_js_1.c.dim}Cost:${utils_js_1.c.reset} ${costStr} ${utils_js_1.c.dim}| Current:${utils_js_1.c.reset} ${currentStr} ${utils_js_1.c.dim}| P&L:${utils_js_1.c.reset} ${pnlColor}${pnlStr} (${pnlPctStr})${utils_js_1.c.reset}`);
260
+ console.log();
261
+ }