import { MarketCallStore } from '../../calls/MarketCallStore.js'; import { ScreenerEngine } from '../../screener/ScreenerEngine.js'; import { YahooClient } from '../../market/YahooClient.js'; import { chunkArray } from '../../screener/Chunker.js'; const noopLogger = { write: () => {}, log: () => {}, warn: () => {} }; const store = new MarketCallStore(); // Takes a screener result entry and flattens it to a snapshot record const toSnapshot = (r) => { if (!r) return null; const m = r.asset?.displayMetrics ?? r.asset?.getDisplayMetrics?.() ?? {}; return { price: r.asset?.currentPrice ?? null, signal: r.signal ?? null, inflatedVerdict: r.inflated?.label ?? null, fundamentalVerdict: r.fundamental?.label ?? null, pe: m['P/E'] ?? null, roe: m['ROE%'] ?? null, fcf: m['FCF Yld%'] ?? null, }; }; export default async function callsRoutes(app) { // GET /api/calls — list all market calls (newest first) app.get('/api/calls', async () => { return { calls: store.list() }; }); // GET /api/calls/:id — get one call + enrich with current prices for comparison app.get('/api/calls/:id', async (req, reply) => { const call = store.get(req.params.id); if (!call) return reply.code(404).send({ error: 'Call not found' }); // Re-screen the tickers to get current prices for comparison let current = {}; if (call.tickers.length > 0) { try { const engine = new ScreenerEngine({ logger: noopLogger }); const results = await engine.screenTickers(call.tickers); const all = [...results.STOCK, ...results.ETF, ...results.BOND]; for (const r of all) { current[r.asset.ticker] = toSnapshot(r); } } catch { // Non-fatal — return call without current prices } } return { ...call, current }; }); // POST /api/calls — create a new market call and snapshot current prices app.post('/api/calls', { schema: { body: { type: 'object', required: ['title', 'quarter', 'thesis', 'tickers'], properties: { title: { type: 'string', minLength: 3 }, quarter: { type: 'string', minLength: 2 }, date: { type: 'string' }, thesis: { type: 'string', minLength: 10 }, tickers: { type: 'array', items: { type: 'string' }, minItems: 1, maxItems: 30 }, }, }, }, handler: async (req, reply) => { const { title, quarter, date, thesis, tickers } = req.body; const upperTickers = tickers.map((t) => t.toUpperCase()); // Snapshot current screener data for each ticker let snapshot = {}; try { const engine = new ScreenerEngine({ logger: noopLogger }); const results = await engine.screenTickers(upperTickers); const all = [...results.STOCK, ...results.ETF, ...results.BOND]; for (const r of all) { snapshot[r.asset.ticker] = toSnapshot(r); } } catch (err) { app.log.warn('Could not snapshot prices for market call:', err.message); } const call = store.create({ title, quarter, date, thesis, tickers: upperTickers, snapshot }); return reply.code(201).send(call); }, }); // DELETE /api/calls/:id app.delete('/api/calls/:id', async (req, reply) => { const deleted = store.delete(req.params.id); if (!deleted) return reply.code(404).send({ error: 'Call not found' }); return { ok: true }; }); // GET /api/calls/calendar?tickers=AAPL,MSFT (or omit to use all call tickers) // Returns upcoming earnings dates, ex-dividend dates and dividend dates per ticker. // Fetched in parallel batches of 5 with rate-limit delay. app.get('/api/calls/calendar', async (req) => { const client = new YahooClient(); // Resolve tickers: from query param, or aggregate all unique tickers across all calls let tickers; if (req.query.tickers) { tickers = req.query.tickers .split(',') .map((t) => t.trim().toUpperCase()) .filter(Boolean); } else { const allCalls = store.list(); const set = new Set(allCalls.flatMap((c) => c.tickers)); tickers = [...set]; } if (tickers.length === 0) return { events: [] }; // Fetch calendarEvents in parallel batches const results = {}; for (const batch of chunkArray(tickers, 5)) { await Promise.all( batch.map(async (ticker) => { const cal = await client.fetchCalendarEvents(ticker); if (cal) results[ticker] = cal; }), ); await new Promise((r) => setTimeout(r, 500)); } // Flatten into a sorted event list const events = []; const now = Date.now(); for (const [ticker, cal] of Object.entries(results)) { // Upcoming earnings dates for (const dateVal of cal.earnings?.earningsDate ?? []) { const d = new Date(dateVal); events.push({ ticker, type: 'earnings', date: d.toISOString().slice(0, 10), label: 'Earnings', detail: cal.earnings.isEarningsDateEstimate ? 'Estimated' : 'Confirmed', epsEstimate: cal.earnings.earningsAverage ?? null, revEstimate: cal.earnings.revenueAverage ?? null, isPast: d.getTime() < now, }); } // Ex-dividend date if (cal.exDividendDate) { const d = new Date(cal.exDividendDate); events.push({ ticker, type: 'exdividend', date: d.toISOString().slice(0, 10), label: 'Ex-Dividend', detail: null, isPast: d.getTime() < now, }); } // Dividend payment date if (cal.dividendDate) { const d = new Date(cal.dividendDate); events.push({ ticker, type: 'dividend', date: d.toISOString().slice(0, 10), label: 'Dividend', detail: null, isPast: d.getTime() < now, }); } } // Sort: upcoming first, then past events.sort((a, b) => { if (a.isPast !== b.isPast) return a.isPast ? 1 : -1; return a.isPast ? new Date(b.date) - new Date(a.date) // most recent past first : new Date(a.date) - new Date(b.date); // soonest upcoming first }); return { events, tickers }; }); }