188 lines
6.2 KiB
JavaScript
188 lines
6.2 KiB
JavaScript
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 };
|
|
});
|
|
}
|