fix bruno collection
This commit is contained in:
@@ -1,71 +0,0 @@
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { SimpleFINClient, PortfolioRepository, noopLogger } from '../../domains/shared';
|
||||
import { PersonalFinanceAnalyzer, ScreenerEngine } from '../screener';
|
||||
import { PortfolioAdvisor } from './PortfolioAdvisor';
|
||||
import type { PortfolioHolding } from '../../domains/shared';
|
||||
import { holdingSchema } from '../../domains/shared/types/schemas';
|
||||
|
||||
export class FinanceController {
|
||||
constructor(
|
||||
private readonly engine: ScreenerEngine,
|
||||
private readonly repo: PortfolioRepository,
|
||||
private readonly advisor: PortfolioAdvisor,
|
||||
) {}
|
||||
|
||||
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) => h.ticker.toUpperCase());
|
||||
|
||||
const results =
|
||||
screenable.length > 0
|
||||
? await this.engine.screenTickers(screenable)
|
||||
: { STOCK: [], ETF: [], BOND: [], ERROR: [], marketContext: {} as any };
|
||||
|
||||
const advice = await this.advisor.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();
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,2 @@
|
||||
// Portfolio domain — holdings management and advice
|
||||
export { FinanceController } from './finance.controller';
|
||||
export { PortfolioAdvisor } from './PortfolioAdvisor';
|
||||
|
||||
@@ -44,7 +44,9 @@ export class ScreenerEngine {
|
||||
// eslint-disable-next-line no-console
|
||||
this.logger = logger ?? {
|
||||
write: (msg: string) => process.stdout.write(msg),
|
||||
// eslint-disable-next-line no-console
|
||||
log: (...args: unknown[]) => console.log(...args),
|
||||
// eslint-disable-next-line no-console
|
||||
warn: (...args: unknown[]) => console.warn(...args),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,15 +4,10 @@ import { CatalystCache, CatalystAnalyst } from '../../domains/shared';
|
||||
import { analyzeSchema } from '../../domains/shared/types/schemas';
|
||||
|
||||
export class AnalyzeController {
|
||||
private readonly catalystAnalyst: CatalystAnalyst;
|
||||
|
||||
constructor(
|
||||
private readonly catalystCache: CatalystCache,
|
||||
private readonly llm: LLMAnalyst,
|
||||
) {
|
||||
// Create a fresh instance for per-ticker story fetching (not cached)
|
||||
this.catalystAnalyst = new CatalystAnalyst();
|
||||
}
|
||||
) {}
|
||||
|
||||
register(app: FastifyInstance): void {
|
||||
app.post(
|
||||
@@ -27,13 +22,22 @@ export class AnalyzeController {
|
||||
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 requestedTickers = (req.body as { tickers: string[] }).tickers.map((t) =>
|
||||
t.toUpperCase(),
|
||||
);
|
||||
|
||||
// Use cached catalyst data (refreshed every 15 minutes)
|
||||
const { stories: allStories } = await this.catalystCache.get();
|
||||
|
||||
// Filter stories to only those matching requested tickers
|
||||
const stories = allStories.filter((story) =>
|
||||
story.tickers.some((t) => requestedTickers.includes(t)),
|
||||
);
|
||||
|
||||
const stories = await this.catalystAnalyst.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);
|
||||
const analysis = await this.llm.analyze(stories, requestedTickers, tickerFrequency);
|
||||
return { analysis };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,9 +21,14 @@ export class BondScorer {
|
||||
|
||||
if (metrics.creditRatingNumeric < gates.minCreditRating) {
|
||||
return {
|
||||
label: '🔴 Avoid',
|
||||
scoreSummary: `Gate failed: ${metrics.creditRating} (${metrics.creditRatingNumeric}) < ${gates.minCreditRating}`,
|
||||
audit: { passedGates: false },
|
||||
label: '🔴 REJECT',
|
||||
scoreSummary: `Credit rating gate failed: ${metrics.creditRating} (${metrics.creditRatingNumeric}) < ${gates.minCreditRating}`,
|
||||
audit: {
|
||||
passedGates: false,
|
||||
failures: [
|
||||
`creditRating: ${metrics.creditRating} (${metrics.creditRatingNumeric}) < ${gates.minCreditRating}`,
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -17,11 +17,24 @@ export class EtfScorer {
|
||||
fiveYearReturn: parseFloat(String(m.fiveYearReturn)) || 0,
|
||||
};
|
||||
|
||||
const failures: string[] = [];
|
||||
if (metrics.expenseRatio > gates.maxExpenseRatio) {
|
||||
failures.push(`Expense ratio: ${metrics.expenseRatio} > ${gates.maxExpenseRatio}`);
|
||||
}
|
||||
if (
|
||||
thresholds.minFiveYearReturn != null &&
|
||||
metrics.fiveYearReturn < thresholds.minFiveYearReturn
|
||||
) {
|
||||
failures.push(`5-year return: ${metrics.fiveYearReturn}% < ${thresholds.minFiveYearReturn}%`);
|
||||
}
|
||||
if (thresholds.minVolume != null && metrics.volume < thresholds.minVolume) {
|
||||
failures.push(`Volume: ${metrics.volume} < ${thresholds.minVolume}`);
|
||||
}
|
||||
if (failures.length > 0) {
|
||||
return {
|
||||
label: '🔴 REJECT',
|
||||
scoreSummary: 'Gate failed: High Expense Ratio',
|
||||
audit: { passedGates: false },
|
||||
scoreSummary: `Gate failed: ${failures.map((f) => f.split(':')[0]).join(', ')}`,
|
||||
audit: { passedGates: false, failures },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,9 @@ export class SimpleFINClient {
|
||||
// eslint-disable-next-line no-console
|
||||
this.logger = logger ?? {
|
||||
write: (msg) => process.stdout.write(msg),
|
||||
// eslint-disable-next-line no-console
|
||||
log: (...args) => console.log(...args),
|
||||
// eslint-disable-next-line no-console
|
||||
warn: (...args) => console.warn(...args),
|
||||
};
|
||||
this.onAccessUrlClaimed = onAccessUrlClaimed ?? null;
|
||||
|
||||
@@ -193,3 +193,24 @@ export const ScoringRules: ScoringRulesShape = {
|
||||
thresholds: { minSpread: 1.5, maxDuration: 7 },
|
||||
},
|
||||
};
|
||||
|
||||
// Alias used by tests — shape: ScoringConfig.base.gates.STOCK etc.
|
||||
export const ScoringConfig = {
|
||||
base: {
|
||||
gates: {
|
||||
STOCK: ScoringRules.STOCK.gates,
|
||||
ETF: ScoringRules.ETF.gates,
|
||||
BOND: ScoringRules.BOND.gates,
|
||||
},
|
||||
weights: {
|
||||
STOCK: ScoringRules.STOCK.weights,
|
||||
ETF: ScoringRules.ETF.weights,
|
||||
BOND: ScoringRules.BOND.weights,
|
||||
},
|
||||
thresholds: {
|
||||
STOCK: ScoringRules.STOCK.thresholds,
|
||||
ETF: ScoringRules.ETF.thresholds,
|
||||
BOND: ScoringRules.BOND.thresholds,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user