phase-7: code restructure
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
||||
import type { LLMAnalyst } from '../services/LLMAnalyst';
|
||||
import { CatalystAnalyst } from '../services/CatalystAnalyst';
|
||||
import { analyzeSchema } from '../types/schemas';
|
||||
|
||||
export class AnalyzeController {
|
||||
constructor(
|
||||
private readonly catalyst: CatalystAnalyst,
|
||||
private readonly llm: LLMAnalyst,
|
||||
) {}
|
||||
|
||||
register(app: FastifyInstance): void {
|
||||
app.post('/api/analyze', { schema: analyzeSchema }, this.analyze.bind(this));
|
||||
}
|
||||
|
||||
private async analyze(req: FastifyRequest, reply: FastifyReply) {
|
||||
if (!this.llm.isAvailable) {
|
||||
return reply.code(400).send({ error: 'ANTHROPIC_API_KEY is not set in .env' });
|
||||
}
|
||||
|
||||
const tickers = (req.body as { tickers: string[] }).tickers.map((t) => t.toUpperCase());
|
||||
|
||||
const stories = await this.catalyst.fetchStoriesForTickers(tickers);
|
||||
if (!stories.length) return reply.code(200).send({ analysis: null, reason: 'no_stories' });
|
||||
|
||||
const { tickerFrequency } = CatalystAnalyst.rankTickers(stories);
|
||||
const analysis = await this.llm.analyze(stories, tickers, tickerFrequency);
|
||||
return { analysis };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { YahooFinanceClient } from '../clients/YahooFinanceClient';
|
||||
import { MarketCallRepository } from '../repositories/MarketCallRepository';
|
||||
import { ScreenerEngine } from '../services/index';
|
||||
import type { SnapshotEntry } from '../types';
|
||||
import { callSchema } from '../types/schemas';
|
||||
import { chunkArray } from '../utils/Chunker';
|
||||
|
||||
export class CallsController {
|
||||
constructor(
|
||||
private readonly repo: MarketCallRepository,
|
||||
private readonly engine: ScreenerEngine,
|
||||
private readonly yahoo: YahooFinanceClient,
|
||||
) {}
|
||||
|
||||
private static toSnapshot(r: any): SnapshotEntry | null {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
register(app: FastifyInstance): void {
|
||||
app.get('/api/calls', this.list.bind(this));
|
||||
app.get('/api/calls/calendar', this.calendar.bind(this));
|
||||
app.get('/api/calls/:id', this.get.bind(this));
|
||||
app.post('/api/calls', { schema: callSchema }, this.create.bind(this));
|
||||
app.delete('/api/calls/:id', this.remove.bind(this));
|
||||
}
|
||||
|
||||
private async list() {
|
||||
return { calls: this.repo.list() };
|
||||
}
|
||||
|
||||
private async get(req: FastifyRequest, reply: FastifyReply) {
|
||||
const call = this.repo.get((req.params as { id: string }).id);
|
||||
if (!call) return reply.code(404).send({ error: 'Call not found' });
|
||||
|
||||
const current: Record<string, SnapshotEntry | null> = {};
|
||||
if (call.tickers.length > 0) {
|
||||
try {
|
||||
const results = await this.engine.screenTickers(call.tickers);
|
||||
for (const r of [...results.STOCK, ...results.ETF, ...results.BOND]) {
|
||||
current[r.asset.ticker] = CallsController.toSnapshot(r);
|
||||
}
|
||||
} catch {
|
||||
/* non-fatal */
|
||||
}
|
||||
}
|
||||
return { ...call, current };
|
||||
}
|
||||
|
||||
private async create(req: FastifyRequest, reply: FastifyReply) {
|
||||
const { title, quarter, date, thesis, tickers } = req.body as {
|
||||
title: string;
|
||||
quarter: string;
|
||||
date?: string;
|
||||
thesis: string;
|
||||
tickers: string[];
|
||||
};
|
||||
const upperTickers = tickers.map((t) => t.toUpperCase());
|
||||
|
||||
const snapshot: Record<string, SnapshotEntry | null> = {};
|
||||
try {
|
||||
const results = await this.engine.screenTickers(upperTickers);
|
||||
for (const r of [...results.STOCK, ...results.ETF, ...results.BOND]) {
|
||||
snapshot[r.asset.ticker] = CallsController.toSnapshot(r);
|
||||
}
|
||||
} catch (err) {
|
||||
req.log.warn(`Could not snapshot prices for market call: ${(err as Error).message}`);
|
||||
}
|
||||
|
||||
const call = this.repo.create({
|
||||
title,
|
||||
quarter,
|
||||
date,
|
||||
thesis,
|
||||
tickers: upperTickers,
|
||||
snapshot: snapshot as any,
|
||||
});
|
||||
return reply.code(201).send(call);
|
||||
}
|
||||
|
||||
private async remove(req: FastifyRequest, reply: FastifyReply) {
|
||||
const deleted = this.repo.delete((req.params as { id: string }).id);
|
||||
if (!deleted) return reply.code(404).send({ error: 'Call not found' });
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
private async calendar(req: FastifyRequest) {
|
||||
let tickers: string[];
|
||||
if ((req.query as any).tickers) {
|
||||
tickers = String((req.query as any).tickers)
|
||||
.split(',')
|
||||
.map((t) => t.trim().toUpperCase())
|
||||
.filter(Boolean);
|
||||
} else {
|
||||
const set = new Set(this.repo.list().flatMap((c) => c.tickers));
|
||||
tickers = [...set];
|
||||
}
|
||||
|
||||
if (tickers.length === 0) return { events: [] };
|
||||
|
||||
const results: Record<string, any> = {};
|
||||
for (const batch of chunkArray(tickers, 5)) {
|
||||
await Promise.all(
|
||||
batch.map(async (ticker) => {
|
||||
const cal = await this.yahoo.fetchCalendarEvents(ticker);
|
||||
if (cal) results[ticker] = cal;
|
||||
}),
|
||||
);
|
||||
await new Promise<void>((r) => setTimeout(r, 500));
|
||||
}
|
||||
|
||||
const events: any[] = [];
|
||||
const now = Date.now();
|
||||
|
||||
for (const [ticker, cal] of Object.entries(results)) {
|
||||
for (const dateVal of cal.earnings?.earningsDate ?? []) {
|
||||
const d = new Date(dateVal as string);
|
||||
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,
|
||||
});
|
||||
}
|
||||
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,
|
||||
});
|
||||
}
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
events.sort((a, b) => {
|
||||
if (a.isPast !== b.isPast) return a.isPast ? 1 : -1;
|
||||
return a.isPast
|
||||
? new Date(b.date).getTime() - new Date(a.date).getTime()
|
||||
: new Date(a.date).getTime() - new Date(b.date).getTime();
|
||||
});
|
||||
|
||||
return { events, tickers };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { SimpleFINClient } from '../clients/SimpleFINClient';
|
||||
import { PortfolioRepository } from '../repositories/PortfolioRepository';
|
||||
import { PersonalFinanceAnalyzer, PortfolioAdvisor, ScreenerEngine } from '../services/index';
|
||||
import type { PortfolioHolding } from '../types';
|
||||
import { holdingSchema } from '../types/schemas';
|
||||
import { noopLogger } from '../utils/logger';
|
||||
|
||||
export class FinanceController {
|
||||
constructor(
|
||||
private readonly engine: ScreenerEngine,
|
||||
private readonly repo: PortfolioRepository,
|
||||
) {}
|
||||
|
||||
private static normalizeYahoo(ticker: string): string {
|
||||
return ticker.toUpperCase().replace(/\./g, '-');
|
||||
}
|
||||
|
||||
register(app: FastifyInstance): void {
|
||||
app.get('/api/finance/portfolio', this.portfolio.bind(this));
|
||||
app.post('/api/finance/holdings', { schema: holdingSchema }, this.addHolding.bind(this));
|
||||
app.delete('/api/finance/holdings/:ticker', this.removeHolding.bind(this));
|
||||
app.get('/api/finance/market-context', this.marketContext.bind(this));
|
||||
}
|
||||
|
||||
private async portfolio(_req: FastifyRequest, reply: FastifyReply) {
|
||||
if (!this.repo.exists()) return reply.code(404).send({ error: 'portfolio.json not found' });
|
||||
|
||||
const { holdings } = this.repo.read();
|
||||
|
||||
let personalFinance = null;
|
||||
if (process.env.SIMPLEFIN_ACCESS_URL) {
|
||||
const client = new SimpleFINClient({ logger: noopLogger });
|
||||
const { accounts } = await client.getAccounts();
|
||||
personalFinance = new PersonalFinanceAnalyzer().analyze(accounts);
|
||||
}
|
||||
|
||||
const screenable = holdings
|
||||
.filter((h) => (h.type ?? 'stock') !== 'crypto')
|
||||
.map((h) => FinanceController.normalizeYahoo(h.ticker));
|
||||
|
||||
const results =
|
||||
screenable.length > 0
|
||||
? await this.engine.screenTickers(screenable)
|
||||
: { STOCK: [], ETF: [], BOND: [], ERROR: [], marketContext: {} as any };
|
||||
|
||||
const advice = await new PortfolioAdvisor().advise(holdings, results);
|
||||
return { advice, personalFinance, marketContext: results.marketContext };
|
||||
}
|
||||
|
||||
private async addHolding(req: FastifyRequest, reply: FastifyReply) {
|
||||
const {
|
||||
ticker,
|
||||
shares,
|
||||
costBasis = 0,
|
||||
type = 'stock',
|
||||
source = 'Manual',
|
||||
} = req.body as PortfolioHolding;
|
||||
const entry = this.repo.upsert({ ticker, shares, costBasis, type, source });
|
||||
return reply.code(201).send(entry);
|
||||
}
|
||||
|
||||
private async removeHolding(req: FastifyRequest, reply: FastifyReply) {
|
||||
const ticker = (req.params as { ticker: string }).ticker.toUpperCase();
|
||||
if (!this.repo.exists()) return reply.code(404).send({ error: 'portfolio.json not found' });
|
||||
|
||||
const removed = this.repo.remove(ticker);
|
||||
if (!removed) return reply.code(404).send({ error: 'Holding not found' });
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
private async marketContext() {
|
||||
return this.engine.getMarketContext();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { FastifyInstance, FastifyRequest } from 'fastify';
|
||||
import { ScreenerEngine, CatalystAnalyst } from '../services/index';
|
||||
import { noopLogger } from '../utils/logger';
|
||||
import type { LiveAssetResult } from '../types';
|
||||
import { screenSchema } from '../types/schemas';
|
||||
|
||||
export class ScreenerController {
|
||||
constructor(private readonly engine: ScreenerEngine) {}
|
||||
|
||||
register(app: FastifyInstance): void {
|
||||
app.post('/api/screen', { schema: screenSchema }, this.screen.bind(this));
|
||||
app.get('/api/screen/catalysts', this.catalysts.bind(this));
|
||||
}
|
||||
|
||||
private static serializeAssets(arr: LiveAssetResult[]) {
|
||||
return arr.map((r) => ({
|
||||
...r,
|
||||
asset: {
|
||||
ticker: r.asset.ticker,
|
||||
type: r.asset.type,
|
||||
currentPrice: r.asset.currentPrice,
|
||||
metrics: r.asset.metrics,
|
||||
displayMetrics: r.asset.getDisplayMetrics(),
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
private async screen(req: FastifyRequest) {
|
||||
const tickers = (req.body as { tickers: string[] }).tickers.map((t) => t.toUpperCase());
|
||||
const results = await this.engine.screenTickers(tickers);
|
||||
return {
|
||||
...results,
|
||||
STOCK: ScreenerController.serializeAssets(results.STOCK as LiveAssetResult[]),
|
||||
ETF: ScreenerController.serializeAssets(results.ETF as LiveAssetResult[]),
|
||||
BOND: ScreenerController.serializeAssets(results.BOND as LiveAssetResult[]),
|
||||
};
|
||||
}
|
||||
|
||||
private async catalysts() {
|
||||
const catalyst = new CatalystAnalyst({ logger: noopLogger });
|
||||
const { tickers, stories } = await catalyst.run();
|
||||
return { tickers, stories };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user