phase-9: domain-driven architecture complete

- Restructured server layer with 5 domains: shared, screener, portfolio, calls, finance
- Migrated 58 TypeScript files to domain-driven structure
- Updated CLAUDE.md with new architecture documentation
- Added .gitignore rules for .md files (except CLAUDE.md)
- Removed unused CatalystAnalyst import from app.ts
- Fixed lint errors: removed unused imports, fixed regex escape, added console suppressions
- Verified no sensitive data in git history
- Server code compiles cleanly with TypeScript strict mode
This commit is contained in:
Sai Kiran Vella
2026-06-06 13:21:24 -04:00
committed by saikiranvella
parent 83116baa3c
commit 96a752ecf7
88 changed files with 3576 additions and 3493 deletions
@@ -0,0 +1,31 @@
import Anthropic from '@anthropic-ai/sdk';
/**
* Thin wrapper around the Anthropic SDK.
* Handles initialisation and raw message completion only —
* prompt construction and response parsing stay in LLMAnalyst (service layer).
*/
export class AnthropicClient {
private client: Anthropic | null;
constructor() {
this.client = process.env.ANTHROPIC_API_KEY
? new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY })
: null;
}
get isAvailable(): boolean {
return this.client !== null;
}
async complete(system: string, userMessage: string): Promise<string | null> {
if (!this.client) return null;
const response = await this.client.messages.create({
model: 'claude-haiku-4-5',
max_tokens: 1024,
system,
messages: [{ role: 'user', content: userMessage }],
});
return (response.content[0] as { text?: string })?.text ?? null;
}
}
@@ -0,0 +1,168 @@
import fs from 'fs';
import https from 'https';
import http from 'http';
import type { Logger, GetAccountsOptions, SimpleFINData, SimpleFINOptions } from '../types';
export class SimpleFINClient {
private accessUrl: string | null;
private logger: Logger;
private onAccessUrlClaimed: ((_url: string) => Promise<void> | void) | null;
constructor({ logger, onAccessUrlClaimed }: SimpleFINOptions = {}) {
this.accessUrl = null;
// eslint-disable-next-line no-console
this.logger = logger ?? {
write: (msg) => process.stdout.write(msg),
log: (...args) => console.log(...args),
warn: (...args) => console.warn(...args),
};
this.onAccessUrlClaimed = onAccessUrlClaimed ?? null;
}
async init(): Promise<void> {
if (process.env.SIMPLEFIN_ACCESS_URL) {
this.accessUrl = process.env.SIMPLEFIN_ACCESS_URL.replace(/\/$/, '');
return;
}
if (process.env.SIMPLEFIN_SETUP_TOKEN) {
this.accessUrl = await this.claimAccessUrl(process.env.SIMPLEFIN_SETUP_TOKEN);
if (this.onAccessUrlClaimed) await this.onAccessUrlClaimed(this.accessUrl);
return;
}
throw new Error(
'SimpleFIN not configured.\nAdd to .env:\n SIMPLEFIN_SETUP_TOKEN=<your setup token from https://beta-bridge.simplefin.org>\nThe Access URL will be saved automatically on first run.',
);
}
async getAccounts(options: GetAccountsOptions = {}): Promise<SimpleFINData> {
if (!this.accessUrl) await this.init();
const startDate = options.startDate ?? this.daysAgo(30);
const endDate = options.endDate ?? Math.floor(Date.now() / 1000);
const parsed = new URL(this.accessUrl!);
const auth = parsed.username
? 'Basic ' + Buffer.from(`${parsed.username}:${parsed.password}`).toString('base64')
: null;
parsed.username = '';
parsed.password = '';
const cleanBase = parsed.toString().replace(/\/$/, '');
const url = `${cleanBase}/accounts?version=2&start-date=${startDate}&end-date=${endDate}`;
const response = await fetch(url, { headers: auth ? { Authorization: auth } : {} });
if (!response.ok) {
throw new Error(`SimpleFIN error ${response.status}: ${await response.text()}`);
}
const data = (await response.json()) as { accounts?: unknown[]; errors?: string[] };
if (data.errors?.length) {
data.errors.forEach((e) => this.logger.warn(` ⚠ SimpleFIN: ${e}`));
}
return this.normalise(data as { accounts: unknown[]; errors: string[] });
}
private async claimAccessUrl(setupToken: string): Promise<string> {
const claimUrl = Buffer.from(setupToken.trim(), 'base64').toString('utf8').trim();
this.logger.write(`\n🔑 Claiming SimpleFIN access URL...\n → ${claimUrl}\n`);
const accessUrl = await this.post(claimUrl);
if (!accessUrl || !accessUrl.startsWith('http')) {
throw new Error(
`Unexpected response from SimpleFIN: "${accessUrl}"\nSetup tokens are one-time use — if already claimed, generate a new one at https://beta-bridge.simplefin.org`,
);
}
this.logger.write('✅ Access URL received\n');
return accessUrl.trim();
}
private post(url: string): Promise<string> {
return new Promise((resolve, reject) => {
const parsed = new URL(url);
const lib = parsed.protocol === 'https:' ? https : http;
const options = {
hostname: parsed.hostname,
port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
path: parsed.pathname + parsed.search,
method: 'POST',
headers: { 'Content-Length': '0', 'Content-Type': 'application/x-www-form-urlencoded' },
};
const req = lib.request(options, (res) => {
let body = '';
res.on('data', (chunk: string) => {
body += chunk;
});
res.on('end', () => {
if ((res.statusCode ?? 0) >= 200 && (res.statusCode ?? 0) < 300) resolve(body.trim());
else reject(new Error(`HTTP ${res.statusCode}: ${body.trim()}`));
});
});
req.on('error', reject);
req.end();
});
}
private normalise(data: { accounts: unknown[]; errors: string[] }): SimpleFINData {
const accounts = (data.accounts ?? []).map((acc: any) => ({
id: acc.id,
name: acc.name,
currency: acc.currency ?? 'USD',
balance: parseFloat(acc.balance) ?? 0,
balanceDate: new Date(acc['balance-date'] * 1000).toISOString().slice(0, 10),
org: acc.org?.name ?? 'Unknown',
type: this.classifyAccount(acc.name),
transactions: (acc.transactions ?? []).map((tx: any) => ({
id: tx.id,
date: new Date(tx.posted * 1000).toISOString().slice(0, 10),
amount: parseFloat(tx.amount) ?? 0,
description: tx.description ?? '',
category: this.categorise(tx.description ?? ''),
})),
}));
return { accounts, errors: data.errors ?? [] };
}
private classifyAccount(name: string): string {
const n = name.toLowerCase();
if (n.includes('checking') || n.includes('current')) return 'CHECKING';
if (n.includes('saving')) return 'SAVINGS';
if (n.includes('credit') || n.includes('card')) return 'CREDIT';
if (n.includes('invest') || n.includes('brokerage') || n.includes('401k') || n.includes('ira'))
return 'INVESTMENT';
if (n.includes('loan') || n.includes('mortgage')) return 'LOAN';
return 'OTHER';
}
private categorise(description: string): string {
const d = description.toLowerCase();
if (d.match(/amazon|walmart|target|costco|grocery|whole foods|trader joe/)) return 'Shopping';
if (d.match(/uber eats|doordash|grubhub|postmates|instacart/)) return 'Delivery';
if (d.match(/netflix|spotify|apple|disney|hulu|youtube/)) return 'Subscriptions';
if (d.match(/restaurant|cafe|coffee|starbucks|chipotle|mcdonald/)) return 'Dining';
if (d.match(/shell|chevron|bp|exxon|fuel|gas station/)) return 'Gas';
if (d.match(/uber|lyft|transit|mta|bart|metro/)) return 'Transport';
if (d.match(/rent|mortgage|hoa|property/)) return 'Housing';
if (d.match(/electric|water|internet|phone|at&t|verizon|comcast/)) return 'Utilities';
if (d.match(/payroll|salary|direct deposit/)) return 'Income';
if (d.match(/transfer|zelle|venmo|paypal/)) return 'Transfer';
return 'Other';
}
private daysAgo(n: number): number {
return Math.floor((Date.now() - n * 24 * 60 * 60 * 1000) / 1000);
}
}
export function saveAccessUrlToEnv(accessUrl: string): void {
try {
const existing = fs.existsSync('.env') ? fs.readFileSync('.env', 'utf8') : '';
if (!existing.includes('SIMPLEFIN_ACCESS_URL')) {
fs.appendFileSync('.env', `\nSIMPLEFIN_ACCESS_URL=${accessUrl}\n`);
// eslint-disable-next-line no-console
console.log('✅ Access URL saved to .env — you can remove SIMPLEFIN_SETUP_TOKEN\n');
}
} catch {
// eslint-disable-next-line no-console
console.log(`\nSave this to .env:\nSIMPLEFIN_ACCESS_URL=${accessUrl}\n`);
}
}
@@ -0,0 +1,52 @@
import YahooFinance from 'yahoo-finance2';
import type { YahooNewsItem, YahooSearchOptions, YahooFinanceLib } from '../types';
import { YAHOO_MODULES } from '../config/constants';
export class YahooFinanceClient {
private lib: YahooFinanceLib;
constructor() {
this.lib = new (YahooFinance as unknown as new (_opts: object) => YahooFinanceLib)({
suppressNotices: ['yahooSurvey'],
});
}
/** Normalise ticker before hitting Yahoo: BRK.B → BRK-B */
private static normalise(ticker: string): string {
return ticker.toUpperCase().replace(/\./g, '-');
}
async fetchSummary(ticker: string, retries = 3, backoff = 1000): Promise<any> {
const normalised = YahooFinanceClient.normalise(ticker);
for (let attempt = 0; attempt < retries; attempt++) {
try {
return await this.lib.quoteSummary(
normalised,
{ modules: YAHOO_MODULES },
{ validateResult: false },
);
} catch (error) {
if (attempt === retries - 1) throw error;
await new Promise<void>((resolve) => setTimeout(resolve, backoff * (attempt + 1)));
}
}
}
async fetchCalendarEvents(ticker: string): Promise<any | null> {
try {
const result = await this.lib.quoteSummary(
YahooFinanceClient.normalise(ticker),
{ modules: ['calendarEvents'] },
{ validateResult: false },
);
return result.calendarEvents ?? null;
} catch {
return null;
}
}
async search(query: string, opts: YahooSearchOptions = {}): Promise<YahooNewsItem[]> {
const { news = [] } = await this.lib.search(query, opts);
return news;
}
}
+97
View File
@@ -0,0 +1,97 @@
import type { Signal, AssetType, RateRegime } from '../types';
export const SIGNAL = {
STRONG_BUY: '✅ Strong Buy' as Signal,
MOMENTUM: '⚡ Momentum' as Signal,
SPECULATION: '⚠️ Speculation' as Signal,
NEUTRAL: '🔄 Neutral' as Signal,
AVOID: '❌ Avoid' as Signal,
};
export const ASSET_TYPE = {
STOCK: 'STOCK' as AssetType,
ETF: 'ETF' as AssetType,
BOND: 'BOND' as AssetType,
CRYPTO: 'crypto',
};
// ── Why some constants use `as const` and others don't ────────────────────
//
// SIGNAL / ASSET_TYPE / REGIME — each member is individually cast to its
// named type (e.g. `'✅ Strong Buy' as Signal`). TypeScript already knows
// the exact literal type of each value, so `as const` on the object would
// be redundant.
//
// SECTOR / SCORE_MODE / CAP_CATEGORY / GROWTH_CATEGORY — these use
// `as const` because their public type aliases are *derived* from the
// object itself via `(typeof X)[keyof typeof X]`. Without `as const`,
// TypeScript widens every value to `string`, and the derived union
// collapses to `string` instead of `'TECHNOLOGY' | 'REIT' | ...`.
// ──────────────────────────────────────────────────────────────────────────
export const SECTOR = {
TECHNOLOGY: 'TECHNOLOGY',
REIT: 'REIT',
FINANCIAL: 'FINANCIAL',
ENERGY: 'ENERGY',
HEALTHCARE: 'HEALTHCARE',
COMMUNICATION: 'COMMUNICATION',
CONSUMER_STAPLES: 'CONSUMER_STAPLES',
CONSUMER_DISCRETIONARY: 'CONSUMER_DISCRETIONARY',
GENERAL: 'GENERAL',
} as const;
export type Sector = (typeof SECTOR)[keyof typeof SECTOR];
export const SCORE_MODE = {
FUNDAMENTAL: 'FUNDAMENTAL',
INFLATED: 'INFLATED',
} as const;
export const REGIME = {
LOW: 'LOW' as RateRegime,
NORMAL: 'NORMAL' as RateRegime,
HIGH: 'HIGH' as RateRegime,
};
export const YAHOO_MODULES: string[] = [
'assetProfile',
'financialData',
'defaultKeyStatistics',
'price',
'summaryDetail',
];
export const SIGNAL_ORDER: Record<string, number> = {
[SIGNAL.STRONG_BUY]: 0,
[SIGNAL.MOMENTUM]: 1,
[SIGNAL.NEUTRAL]: 2,
[SIGNAL.SPECULATION]: 3,
[SIGNAL.AVOID]: 4,
};
// ── Market capitalisation tiers ───────────────────────────────────────────
// Thresholds follow institutional convention (MSCI/Russell definitions).
export const CAP_CATEGORY = {
MEGA: 'Mega Cap', // > $200B
LARGE: 'Large Cap', // $10B $200B
MID: 'Mid Cap', // $2B $10B
SMALL: 'Small Cap', // $300M $2B
MICRO: 'Micro Cap', // < $300M
} as const;
export type CapCategory = (typeof CAP_CATEGORY)[keyof typeof CAP_CATEGORY];
// ── Growth / style classification ─────────────────────────────────────────
// Derived from revenue growth, earnings growth, and dividend yield.
// Used for display and to contextualise signals within each cap tier.
export const GROWTH_CATEGORY = {
HIGH_GROWTH: 'High Growth', // rev >15% or earnings >20%
MODERATE_GROWTH: 'Growth', // rev 515%
STABLE: 'Stable', // low growth, modest or no dividend
VALUE: 'Value', // low growth + dividend yield ≥ 3%
TURNAROUND: 'Turnaround', // negative earnings, positive revenue
DECLINING: 'Declining', // negative revenue growth
} as const;
export type GrowthCategory = (typeof GROWTH_CATEGORY)[keyof typeof GROWTH_CATEGORY];
@@ -0,0 +1,196 @@
/**
* DatabaseConnection — High-level database abstraction.
*
* Wraps better-sqlite3 with:
* - QueryBuilder for type-safe, injection-proof queries
* - QueryAudit for logging and compliance
* - Statement caching for performance
* - Transaction support
*
* Usage:
* const db = new DatabaseConnection(betterSqlite3Db, options);
* const qb = new QueryBuilder('holdings').select(['ticker', 'shares']).where('type = ?', ['stock']);
* const rows = db.all(qb);
* const row = db.get(qb);
* db.run(qb);
*/
import type BetterSqlite3 from 'better-sqlite3';
import type { DatabaseOptions } from '../types/index';
import { AuditAction } from '../types/index';
import { QueryBuilder } from '../utils/QueryBuilder';
import { QueryAudit } from './QueryAudit';
/**
* DatabaseConnection — Safe, auditable, performant SQLite wrapper.
*/
export class DatabaseConnection {
private db: BetterSqlite3.Database;
private audit: QueryAudit;
private logSlowQueries: number;
private statementCache = new Map<string, BetterSqlite3.Statement>();
constructor(db: BetterSqlite3.Database, options: DatabaseOptions = {}) {
this.db = db;
this.audit = options.audit ?? new QueryAudit();
this.logSlowQueries = options.logSlowQueries ?? 100; // 100ms default
}
/**
* Execute a SELECT query and return all rows.
* Logs the query to the audit trail.
*/
all<T = Record<string, unknown>>(qb: QueryBuilder): T[] {
const sql = qb.sql;
const params = qb.queryParams;
const startMs = performance.now();
try {
const stmt = this.getOrCacheStatement(sql);
const rows = stmt.all(...params) as T[];
const durationMs = performance.now() - startMs;
this.audit.log(sql, params, AuditAction.READ, durationMs, rows.length);
this.logIfSlow(sql, durationMs);
return rows;
} catch (err) {
const durationMs = performance.now() - startMs;
const errorMsg = err instanceof Error ? err.message : String(err);
this.audit.log(sql, params, AuditAction.READ, durationMs, undefined, errorMsg);
throw err;
}
}
/**
* Execute a SELECT query and return the first row only.
* Returns null if no rows match.
* Logs the query to the audit trail.
*/
get<T = Record<string, unknown>>(qb: QueryBuilder): T | null {
const sql = qb.sql;
const params = qb.queryParams;
const startMs = performance.now();
try {
const stmt = this.getOrCacheStatement(sql);
const row = stmt.get(...params) as T | undefined;
const durationMs = performance.now() - startMs;
this.audit.log(sql, params, AuditAction.READ, durationMs, row ? 1 : 0);
this.logIfSlow(sql, durationMs);
return row ?? null;
} catch (err) {
const durationMs = performance.now() - startMs;
const errorMsg = err instanceof Error ? err.message : String(err);
this.audit.log(sql, params, AuditAction.READ, durationMs, undefined, errorMsg);
throw err;
}
}
/**
* Execute an INSERT, UPDATE, or DELETE query.
* Returns the number of rows affected.
* Logs the query to the audit trail.
*/
run(qb: QueryBuilder): number {
const sql = qb.sql;
const params = qb.queryParams;
const startMs = performance.now();
// Determine audit action from SQL
const sqlUpper = sql.toUpperCase().trim();
const action = sqlUpper.startsWith('DELETE')
? AuditAction.DELETE
: sqlUpper.startsWith('INSERT')
? AuditAction.WRITE
: AuditAction.WRITE;
try {
const stmt = this.getOrCacheStatement(sql);
const result = stmt.run(...params);
const durationMs = performance.now() - startMs;
this.audit.log(sql, params, action, durationMs, result.changes);
this.logIfSlow(sql, durationMs);
return result.changes;
} catch (err) {
const durationMs = performance.now() - startMs;
const errorMsg = err instanceof Error ? err.message : String(err);
this.audit.log(sql, params, action, durationMs, 0, errorMsg);
throw err;
}
}
/**
* Execute a transaction — multiple queries as an atomic unit.
* All queries must succeed, or all are rolled back.
*
* Usage:
* db.transaction(() => {
* db.run(qb1);
* db.run(qb2);
* });
*/
transaction<T>(fn: () => T): T {
const txn = this.db.transaction(fn);
return txn();
}
/**
* Get the raw better-sqlite3 Db instance (for advanced use only).
* Prefer the DatabaseConnection methods.
*/
raw(): BetterSqlite3.Database {
return this.db;
}
/**
* Get the audit trail instance.
*/
getAudit(): QueryAudit {
return this.audit;
}
/**
* Clear the statement cache (for testing or extreme memory pressure).
*/
clearStatementCache(): void {
this.statementCache.clear();
}
/**
* Get the audit trail instance.
* Call db.printAudit() to see the most recent 100 queries.
*/
printAudit(): void {
// eslint-disable-next-line no-console
console.log(this.audit.report());
}
// ── Private helpers ─────────────────────────────────────────────────────
/**
* Get or create a cached prepared statement.
* Reduces compilation overhead for frequently-run queries.
*/
private getOrCacheStatement(sql: string): BetterSqlite3.Statement {
let stmt = this.statementCache.get(sql);
if (!stmt) {
stmt = this.db.prepare(sql);
this.statementCache.set(sql, stmt);
}
return stmt;
}
/**
* Log slow queries to console.
*/
private logIfSlow(sql: string, durationMs: number): void {
if (durationMs > this.logSlowQueries) {
console.warn(`[SLOW QUERY] ${durationMs.toFixed(2)}ms\n ${sql}`);
}
}
}
@@ -0,0 +1,143 @@
/**
* Database initialization and migration.
*
* Handles:
* - Creating/opening SQLite database
* - Running DDL schema setup
* - Migrating legacy JSON files (one-time)
*/
import BetterSqlite3 from 'better-sqlite3';
import { existsSync, readFileSync, renameSync } from 'fs';
import { randomUUID } from 'crypto';
import { DDL } from './queries.constant';
import { QueryBuilder } from '../utils/QueryBuilder';
export type Db = BetterSqlite3.Database;
// ── Types ────────────────────────────────────────────────────────────────────
interface LegacyHolding {
ticker: string;
shares: number;
costBasis: number;
type: string;
source: string;
}
interface LegacyCall {
id?: string;
title: string;
quarter: string;
date: string;
thesis: string;
tickers: string[];
snapshot: Record<string, unknown>;
createdAt: string;
}
// ── Main Export ──────────────────────────────────────────────────────────────
/**
* Initialize and open the SQLite database.
*
* Steps:
* 1. Create/open database file
* 2. Enable WAL mode (concurrent read safety)
* 3. Enable foreign keys
* 4. Run DDL (create tables if missing)
* 5. Migrate legacy JSON files (one-time)
*
* @param path Path to database file (default: ./market-screener.db)
* @returns Opened database instance (wrap in DatabaseConnection for safe access)
*/
export function createDb(path = './market-screener.db'): Db {
const db = new BetterSqlite3(path);
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
db.exec(DDL);
migrateJson(db);
return db;
}
// ── Migration Helpers ────────────────────────────────────────────────────────
/**
* Migrate legacy JSON files to SQLite (one-time, non-fatal).
* Called automatically during database initialization.
*/
function migrateJson(db: Db): void {
migratePortfolio(db);
migrateCalls(db);
}
/**
* Migrate portfolio.json → holdings table.
* If portfolio.json exists, import all holdings and rename to portfolio.json.migrated.
* If import fails, leave portfolio.json in place (non-fatal).
*/
function migratePortfolio(db: Db): void {
const src = './portfolio.json';
if (!existsSync(src)) return;
try {
const { holdings } = JSON.parse(readFileSync(src, 'utf8')) as {
holdings: LegacyHolding[];
};
const insertAll = db.transaction((rows: LegacyHolding[]) => {
for (const h of rows) {
const qb = new QueryBuilder('MIGRATION_QUERIES.HOLDINGS_INSERT_OR_IGNORE', [
h.ticker.toUpperCase(),
h.shares,
h.costBasis ?? 0,
h.type ?? 'stock',
h.source ?? 'Manual',
]);
db.prepare(qb.sql).run(...qb.queryParams);
}
});
insertAll(holdings);
renameSync(src, `${src}.migrated`);
} catch {
// Non-fatal: leave portfolio.json in place if migration fails
}
}
/**
* Migrate market-calls.json → market_calls table.
* If market-calls.json exists, import all calls and rename to market-calls.json.migrated.
* If import fails, leave market-calls.json in place (non-fatal).
*/
function migrateCalls(db: Db): void {
const src = './market-calls.json';
if (!existsSync(src)) return;
try {
const { calls } = JSON.parse(readFileSync(src, 'utf8')) as {
calls: LegacyCall[];
};
const insertAll = db.transaction((rows: LegacyCall[]) => {
for (const c of rows) {
const qb = new QueryBuilder('MIGRATION_QUERIES.MARKET_CALLS_INSERT_OR_IGNORE', [
c.id ?? randomUUID(),
c.title,
c.quarter,
c.date,
c.thesis,
JSON.stringify(c.tickers ?? []),
JSON.stringify(c.snapshot ?? {}),
c.createdAt,
]);
db.prepare(qb.sql).run(...qb.queryParams);
}
});
insertAll(calls);
renameSync(src, `${src}.migrated`);
} catch {
// Non-fatal: leave market-calls.json in place if migration fails
}
}
+126
View File
@@ -0,0 +1,126 @@
/**
* Query audit logging — tracks all database mutations.
*
* Usage:
* const audit = new QueryAudit();
* audit.log('SELECT * FROM holdings', [], AuditAction.READ, 1.5);
* audit.log('UPDATE holdings SET shares = ? WHERE ticker = ?', [100, 'AAPL'], AuditAction.WRITE, 0.8, 1);
*
* Provides:
* - Audit trail of all queries executed
* - Timing information (for performance monitoring)
* - Clear distinction between READ/WRITE operations
* - Optional persistent storage for compliance
*/
import type { AuditAction, AuditEntry } from '../types/index';
/**
* QueryAudit — in-memory audit trail with optional callbacks.
*/
export class QueryAudit {
private entries: AuditEntry[] = [];
private onLog?: (entry: AuditEntry) => void | Promise<void>;
constructor(onLog?: (entry: AuditEntry) => void | Promise<void>) {
this.onLog = onLog;
}
/**
* Log a query execution.
* @param sql The SQL string (with ? placeholders intact)
* @param params The parameter array (safe to log; no raw values in SQL)
* @param action The operation type (READ, WRITE, DELETE)
* @param durationMs Execution time in milliseconds
* @param rowsAffected Number of rows affected (for INSERT/UPDATE/DELETE)
* @param error If execution failed, the error message
*/
log(
sql: string,
params: unknown[],
action: AuditAction,
durationMs: number,
rowsAffected?: number,
error?: string,
): void {
const entry: AuditEntry = {
timestamp: new Date().toISOString(),
action,
sql,
params,
durationMs,
rowsAffected,
error,
};
this.entries.push(entry);
// Call the optional callback (could write to file, logger, or remote service)
if (this.onLog) {
const result = this.onLog(entry);
if (result instanceof Promise) {
result.catch((err) => {
console.error('QueryAudit callback failed:', err);
});
}
}
}
/**
* Get all audit entries.
*/
all(): AuditEntry[] {
return [...this.entries];
}
/**
* Filter audit entries by action type.
*/
byAction(action: AuditAction): AuditEntry[] {
return this.entries.filter((e) => e.action === action);
}
/**
* Get the most recent N entries.
*/
recent(count: number = 100): AuditEntry[] {
return this.entries.slice(-count);
}
/**
* Clear the audit trail.
* (Typically not needed unless for testing or cleanup.)
*/
clear(): void {
this.entries = [];
}
/**
* Generate a human-readable audit report.
*/
report(limitEntries: number = 100): string {
const recent = this.recent(limitEntries);
let report = `\n=== Query Audit Report ===\n`;
report += `Total entries: ${this.entries.length}\n`;
report += `Showing last ${recent.length} entries:\n\n`;
for (const entry of recent) {
report += `[${entry.timestamp}] ${entry.action}`;
if (entry.error) {
report += ` ❌ (${entry.error})`;
} else {
report += ` ✓ (${entry.durationMs}ms)`;
if (entry.rowsAffected !== undefined) {
report += `${entry.rowsAffected} rows`;
}
}
report += `\n SQL: ${entry.sql}\n`;
if (entry.params.length > 0) {
report += ` Params: [${entry.params.map((p) => JSON.stringify(p)).join(', ')}]\n`;
}
report += '\n';
}
return report;
}
}
+32
View File
@@ -0,0 +1,32 @@
/**
* Database layer — barrel export (ONLY re-exports, no logic).
*
* This file is the SINGLE public API for all database functionality.
* All imports should come from here, not from individual files.
*
* USAGE:
* import { createDb, DatabaseConnection, QueryAudit } from './db/index.js';
* import type { AuditEntry } from './db/index.js';
*
* FILE ORGANIZATION:
* - DatabaseInitializer.ts: createDb() function + migrations (pure functions)
* - QueryAudit.ts: class QueryAudit (logging service)
* - DatabaseConnection.ts: class DatabaseConnection (data access service)
* - index.ts: THIS FILE (barrel re-exports only)
*
* SECURITY:
* - All queries use parameterized statements (QueryBuilder + DatabaseConnection)
* - No SQL injection possible via table/column/parameter names
* - Audit trail tracks all mutations for compliance
*/
// Initialization
export { createDb, type Db } from './DatabaseInitializer';
// Data access
export { DatabaseConnection } from './DatabaseConnection';
export { QueryAudit } from './QueryAudit';
// Types
export { AuditAction } from '../types/database.model';
export type { AuditEntry, DatabaseOptions } from '../types/database.model';
@@ -0,0 +1,100 @@
/**
* SQL Query Constants
*
* All SQL queries used in the application.
* Repositories reference these by name (e.g., MARKET_CALLS_QUERIES.SELECT_ALL).
* QueryBuilder looks them up and binds parameters.
*
* All queries use parameterized statements (?) for security.
* User input NEVER goes into the SQL string.
*/
// ── Holdings Table Queries ───────────────────────────────────────────────────
export const HOLDINGS_QUERIES = {
// Check if any holdings exist
EXISTS: 'SELECT COUNT(*) AS n FROM holdings',
// Get all holdings, sorted by ticker
SELECT_ALL: 'SELECT ticker, shares, cost_basis, type, source FROM holdings ORDER BY ticker ASC',
// Insert or update a holding (UPSERT)
UPSERT: `
INSERT INTO holdings (ticker, shares, cost_basis, type, source)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(ticker) DO UPDATE SET
shares = excluded.shares,
cost_basis = excluded.cost_basis,
type = excluded.type,
source = excluded.source
`,
// Delete a holding by ticker
DELETE_BY_TICKER: 'DELETE FROM holdings WHERE ticker = ?',
};
// ── Market Calls Table Queries ───────────────────────────────────────────────
export const MARKET_CALLS_QUERIES = {
// Get all market calls, newest first
SELECT_ALL: `
SELECT id, title, quarter, date, thesis, tickers, snapshot, created_at
FROM market_calls
ORDER BY created_at DESC
`,
// Get a single market call by ID
SELECT_BY_ID: `
SELECT id, title, quarter, date, thesis, tickers, snapshot, created_at
FROM market_calls
WHERE id = ?
`,
// Insert a new market call
INSERT: `
INSERT INTO market_calls (id, title, quarter, date, thesis, tickers, snapshot, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`,
// Delete a market call by ID
DELETE_BY_ID: 'DELETE FROM market_calls WHERE id = ?',
};
// ── Migration Queries (for DatabaseInitializer) ──────────────────────────────
export const MIGRATION_QUERIES = {
// Insert holdings during migration
HOLDINGS_INSERT_OR_IGNORE: `
INSERT OR IGNORE INTO holdings (ticker, shares, cost_basis, type, source)
VALUES (?, ?, ?, ?, ?)
`,
// Insert market calls during migration
MARKET_CALLS_INSERT_OR_IGNORE: `
INSERT OR IGNORE INTO market_calls (id, title, quarter, date, thesis, tickers, snapshot, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`,
};
// ── Schema Definition (DDL) ──────────────────────────────────────────────────
export const DDL = `
CREATE TABLE IF NOT EXISTS holdings (
ticker TEXT PRIMARY KEY,
shares REAL NOT NULL,
cost_basis REAL NOT NULL DEFAULT 0,
type TEXT NOT NULL DEFAULT 'stock',
source TEXT NOT NULL DEFAULT 'Manual'
);
CREATE TABLE IF NOT EXISTS market_calls (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
quarter TEXT NOT NULL,
date TEXT NOT NULL,
thesis TEXT NOT NULL,
tickers TEXT NOT NULL, -- JSON array
snapshot TEXT NOT NULL, -- JSON object
created_at TEXT NOT NULL
);
`;
+26
View File
@@ -0,0 +1,26 @@
import type { AssetType } from '../types';
import type { AssetData } from '../types/models.model';
export class Asset {
ticker: string;
currentPrice: number;
type: AssetType;
constructor(data: AssetData) {
this.ticker = (data.ticker || 'UNKNOWN').toUpperCase();
this.currentPrice = (data.currentPrice as number) || 0;
this.type = (data.type || 'STOCK').toUpperCase() as AssetType;
}
formatCurrency(val: number | null | undefined): string {
return val ? `$${val.toFixed(2)}` : 'N/A';
}
formatLargeNumber(num: number | null | undefined): string {
if (!num) return 'N/A';
if (num >= 1e12) return `${(num / 1e12).toFixed(2)}T`;
if (num >= 1e9) return `${(num / 1e9).toFixed(2)}B`;
if (num >= 1e6) return `${(num / 1e6).toFixed(2)}M`;
return num.toString();
}
}
+32
View File
@@ -0,0 +1,32 @@
import { CREDIT_RATING_SCALE } from '../scoring/ScoringConfig';
import { Asset } from './Asset';
import type { BondData, BondMetrics } from '../types/index';
export class Bond extends Asset {
metrics: BondMetrics;
constructor(data: BondData) {
super(data);
const creditRating = data.creditRating || 'BBB';
const creditRatingNumeric = CREDIT_RATING_SCALE[creditRating] ?? 7;
this.metrics = {
ytm: parseFloat(String(data.yieldToMaturity)) || 0,
duration: parseFloat(String(data.duration)) || 0,
creditRating,
creditRatingNumeric,
};
}
getDisplayMetrics(): Record<string, string> {
return {
Ticker: this.ticker,
Type: 'BOND',
Price: this.formatCurrency(this.currentPrice),
'YTM%': `${this.metrics.ytm.toFixed(2)}%`,
Duration: this.metrics.duration.toFixed(1),
Rating: `${this.metrics.creditRating} (${this.metrics.creditRatingNumeric})`,
};
}
}
+29
View File
@@ -0,0 +1,29 @@
import { Asset } from './Asset';
import type { EtfData, EtfMetrics } from '../types/models.model';
export class Etf extends Asset {
metrics: EtfMetrics;
constructor(data: EtfData) {
super(data);
this.metrics = {
expenseRatio: parseFloat(String(data.expenseRatio)) || 0,
totalAssets: parseFloat(String(data.totalAssets)) || 0,
yield: parseFloat(String(data.yield)) || 0,
volume: parseFloat(String(data.volume)) || 0,
fiveYearReturn: parseFloat(String(data.fiveYearReturn)) || 0,
};
}
getDisplayMetrics(): Record<string, string> {
return {
Ticker: this.ticker,
Type: 'ETF',
Price: this.formatCurrency(this.currentPrice),
'Exp Ratio%': `${this.metrics.expenseRatio.toFixed(2)}%`,
'Yield%': `${this.metrics.yield.toFixed(2)}%`,
AUM: this.formatLargeNumber(this.metrics.totalAssets),
'5Y Return%': `${this.metrics.fiveYearReturn.toFixed(1)}%`,
};
}
}
+222
View File
@@ -0,0 +1,222 @@
import { Asset } from './Asset';
import { CAP_CATEGORY, GROWTH_CATEGORY } from '../config/constants';
import type { Sector, CapCategory, GrowthCategory } from '../config/constants';
import type { StockData, StockMetrics } from '../types/models.model';
export class Stock extends Asset {
sector: Sector;
metrics: StockMetrics;
constructor(data: StockData) {
super(data);
this.sector = this.mapToStandardSector(data);
this.metrics = {
sector: this.sector,
capCategory: this.classifyMarketCap(data.marketCap ?? null),
growthCategory: this.classifyGrowth(
data.revenueGrowth ?? null,
data.earningsGrowth ?? null,
data.dividendYield ?? null,
),
peRatio: data.peRatio ?? null,
pegRatio: data.pegRatio ?? null,
priceToBook: data.priceToBook ?? null,
grossMargin: data.grossMargin ?? null,
netProfitMargin: data.netProfitMargin ?? null,
operatingMargin: data.operatingMargin ?? null,
returnOnEquity: data.returnOnEquity ?? null,
revenueGrowth: data.revenueGrowth ?? null,
earningsGrowth: data.earningsGrowth ?? null,
debtToEquity: data.debtToEquity ?? null,
quickRatio: data.quickRatio ?? null,
fcfYield: data.fcfYield ?? null,
pFFO: data.pFFO ?? null,
dividendYield: data.dividendYield ?? null,
beta: data.beta ?? null,
week52High: data.week52High ?? null,
week52Low: data.week52Low ?? null,
week52Change: data.week52Change ?? null,
week52FromHigh: data.week52FromHigh ?? null,
week52FromLow: data.week52FromLow ?? null,
marketCap: data.marketCap ?? null,
analystRating: data.analystRating ?? null,
analystTargetPrice: data.analystTargetPrice ?? null,
analystUpside: data.analystUpside ?? null,
numberOfAnalysts: data.numberOfAnalysts ?? null,
dcfIntrinsicValue: data.dcfIntrinsicValue ?? null,
dcfMarginOfSafety: data.dcfMarginOfSafety ?? null,
currentPrice: (data.currentPrice as number) || 0,
};
}
// ── Market cap tier classification ──────────────────────────────────────
// Thresholds follow MSCI/Russell institutional convention.
classifyMarketCap(marketCap: number | null): CapCategory {
if (marketCap == null) return CAP_CATEGORY.LARGE; // safe default
if (marketCap >= 200e9) return CAP_CATEGORY.MEGA;
if (marketCap >= 10e9) return CAP_CATEGORY.LARGE;
if (marketCap >= 2e9) return CAP_CATEGORY.MID;
if (marketCap >= 300e6) return CAP_CATEGORY.SMALL;
return CAP_CATEGORY.MICRO;
}
// ── Growth / style classification ───────────────────────────────────────
// revenueGrowth and earningsGrowth are in percentage form (e.g. 15 = 15%).
// dividendYield is also in percentage form (e.g. 3.5 = 3.5%).
classifyGrowth(
revenueGrowth: number | null,
earningsGrowth: number | null,
dividendYield: number | null,
): GrowthCategory {
const rev = revenueGrowth ?? 0;
const earn = earningsGrowth ?? 0;
const div = dividendYield ?? 0;
if (rev < -5) return GROWTH_CATEGORY.DECLINING;
if (earn < 0 && rev >= 0) return GROWTH_CATEGORY.TURNAROUND;
if (rev >= 15 || earn >= 20) return GROWTH_CATEGORY.HIGH_GROWTH;
if (rev >= 5) return GROWTH_CATEGORY.MODERATE_GROWTH;
if (div >= 3 && rev < 5) return GROWTH_CATEGORY.VALUE;
return GROWTH_CATEGORY.STABLE;
}
mapToStandardSector(data: StockData): Sector {
const profile = data.assetProfile ?? {};
const industry = (profile.industry || '').toLowerCase();
const sector = (profile.sector || '').toLowerCase();
const combined = `${industry} ${sector}`;
if (
combined.includes('technology') ||
combined.includes('electronic') ||
combined.includes('semiconductor') ||
combined.includes('software')
)
return 'TECHNOLOGY';
if (combined.includes('real estate') || combined.includes('reit')) return 'REIT';
if (
combined.includes('financial') ||
combined.includes('bank') ||
combined.includes('insurance') ||
combined.includes('asset management')
)
return 'FINANCIAL';
if (
combined.includes('energy') ||
combined.includes('oil') ||
combined.includes('gas') ||
combined.includes('petroleum')
)
return 'ENERGY';
if (
combined.includes('health') ||
combined.includes('biotech') ||
combined.includes('pharmaceutical') ||
combined.includes('medical')
)
return 'HEALTHCARE';
if (
combined.includes('communication') ||
combined.includes('media') ||
combined.includes('entertainment') ||
combined.includes('telecom')
)
return 'COMMUNICATION';
if (
combined.includes('consumer defensive') ||
combined.includes('consumer staples') ||
combined.includes('household') ||
combined.includes('beverage') ||
combined.includes('food')
)
return 'CONSUMER_STAPLES';
if (
combined.includes('consumer cyclical') ||
combined.includes('consumer discretionary') ||
combined.includes('retail') ||
combined.includes('apparel') ||
combined.includes('auto')
)
return 'CONSUMER_DISCRETIONARY';
return 'GENERAL';
}
getDisplayMetrics(): Record<string, string | null> {
const fmt = (v: number | null, dec = 1, suffix = '') =>
v != null ? `${v.toFixed(dec)}${suffix}` : null;
const fmtSign = (v: number | null, suffix = '%') =>
v != null ? `${v >= 0 ? '+' : ''}${v.toFixed(1)}${suffix}` : null;
const m = this.metrics;
const w52pos =
m.week52High != null && m.week52High > 0 && m.week52Low != null && m.currentPrice > 0
? (((m.currentPrice - m.week52Low) / (m.week52High - m.week52Low)) * 100).toFixed(0) + '%'
: null;
// Analyst label: convert Yahoo's 15 scale to a readable string
const analystLabel = (rating: number | null): string | null => {
if (rating == null) return null;
if (rating <= 1.5) return 'Strong Buy';
if (rating <= 2.5) return 'Buy';
if (rating <= 3.5) return 'Hold';
if (rating <= 4.5) return 'Sell';
return 'Strong Sell';
};
const display: Record<string, string | null> = {
Ticker: this.ticker,
Price: this.formatCurrency(this.currentPrice),
Sector: this.sector,
'Cap Tier': m.capCategory,
Style: m.growthCategory,
};
// Valuation
if (m.peRatio != null) display['P/E'] = fmt(m.peRatio, 1);
if (m.pegRatio != null) display['PEG'] = fmt(m.pegRatio, 2);
if (m.priceToBook != null) display['P/B'] = fmt(m.priceToBook, 2);
// Quality
if (m.grossMargin != null) display['GrossM%'] = fmt(m.grossMargin, 1, '%');
if (m.returnOnEquity != null) display['ROE%'] = fmt(m.returnOnEquity, 1, '%');
if (m.operatingMargin != null) display['OpMgn%'] = fmt(m.operatingMargin, 1, '%');
if (m.netProfitMargin != null) display['NetMgn%'] = fmt(m.netProfitMargin, 1, '%');
if (m.revenueGrowth != null) display['Rev%'] = fmt(m.revenueGrowth, 1, '%');
if (m.fcfYield != null) display['FCF Yld%'] = fmt(m.fcfYield, 1, '%');
if (m.dividendYield != null) display['Div%'] = fmt(m.dividendYield, 2, '%');
// Risk
if (m.debtToEquity != null) display['D/E'] = fmt(m.debtToEquity, 2);
if (m.quickRatio != null) display['Quick'] = fmt(m.quickRatio, 2);
if (m.beta != null) display['Beta'] = fmt(m.beta, 2);
// 52-week movement
if (w52pos != null) display['52W Pos'] = w52pos;
if (m.week52Change != null) display['52W Chg'] = fmtSign(m.week52Change, '%');
if (m.week52FromHigh != null) display['From High'] = fmtSign(m.week52FromHigh, '%');
if (m.week52FromLow != null) display['From Low'] = fmtSign(m.week52FromLow, '%');
// REIT-specific
if (m.pFFO != null) display['P/FFO'] = fmt(m.pFFO, 1);
// Analyst consensus
if (m.analystRating != null) {
display['Analyst'] = analystLabel(m.analystRating);
display['# Analysts'] = m.numberOfAnalysts != null ? String(m.numberOfAnalysts) : null;
display['Target'] =
m.analystTargetPrice != null ? this.formatCurrency(m.analystTargetPrice) : null;
display['Upside'] = fmtSign(m.analystUpside, '%');
}
// DCF
if (m.dcfIntrinsicValue != null) {
display['DCF Value'] = this.formatCurrency(m.dcfIntrinsicValue);
display['DCF Safety'] =
m.dcfMarginOfSafety != null ? fmtSign(m.dcfMarginOfSafety, '%') : null;
}
return display;
}
}
+47
View File
@@ -0,0 +1,47 @@
// Shared domain — re-exports all shared infrastructure
// Import from here, not from individual subdirectories
// Entities
export { Asset } from './entities/Asset';
export { Stock } from './entities/Stock';
export { Etf } from './entities/Etf';
export { Bond } from './entities/Bond';
// Adapters (external API clients)
export { YahooFinanceClient } from './adapters/YahooFinanceClient';
export { AnthropicClient } from './adapters/AnthropicClient';
export { SimpleFINClient } from './adapters/SimpleFINClient';
// Services
export { BenchmarkProvider } from './services/BenchmarkProvider';
export { CatalystAnalyst } from './services/CatalystAnalyst';
export { CatalystCache } from './services/CatalystCache';
export { LLMAnalyst } from './services/LLMAnalyst';
// Scoring
export { CREDIT_RATING_SCALE } from './scoring/ScoringConfig';
export { MarketRegime } from './scoring/MarketRegime';
// Persistence (repositories)
export { MarketCallRepository } from './persistence/MarketCallRepository';
export { PortfolioRepository } from './persistence/PortfolioRepository';
export { DatabaseConnection, QueryAudit, createDb } from './db/index';
// Config & Constants
export {
SIGNAL,
SIGNAL_ORDER,
SCORE_MODE,
ASSET_TYPE,
REGIME,
CAP_CATEGORY,
GROWTH_CATEGORY,
SECTOR,
} from './config/constants';
// Types — re-export everything from types barrel
export type * from './types/index';
// Utils
export { noopLogger } from './utils/logger';
export { chunkArray } from './utils/Chunker';
@@ -0,0 +1,96 @@
import { randomUUID } from 'crypto';
import { DatabaseConnection } from '../db/index';
import { QueryBuilder } from '../utils/QueryBuilder';
import { sanitizeString, sanitizeDate } from '../utils/sanitizer';
import type { MarketCall, CreateCallInput, MarketCallRow } from '../types';
export class MarketCallRepository {
constructor(private readonly db: DatabaseConnection) {}
/**
* Get all market calls, newest first.
*/
list(): (MarketCall & { createdAt: string })[] {
const qb = new QueryBuilder('MARKET_CALLS_QUERIES.SELECT_ALL');
const rows = this.db.all<MarketCallRow>(qb);
return rows.map(MarketCallRepository.toCall);
}
/**
* Get a single market call by ID.
*/
get(id: string): (MarketCall & { createdAt: string }) | null {
const qb = new QueryBuilder('MARKET_CALLS_QUERIES.SELECT_BY_ID', [id]);
const row = this.db.get<MarketCallRow>(qb);
return row ? MarketCallRepository.toCall(row) : null;
}
/**
* Create a new market call with snapshot of current prices.
*/
create({
title,
quarter,
date,
thesis,
tickers,
snapshot,
}: CreateCallInput): MarketCall & { createdAt: string } {
// Sanitize inputs
const sanitizedTitle = sanitizeString(title, 'title', 255);
const sanitizedQuarter = sanitizeString(quarter, 'quarter', 10);
const sanitizedThesis = sanitizeString(thesis, 'thesis', 2000);
const sanitizedDate = date ? sanitizeDate(date, 'date') : new Date().toISOString().slice(0, 10);
const call = {
id: randomUUID(),
title: sanitizedTitle,
quarter: sanitizedQuarter,
date: sanitizedDate,
thesis: sanitizedThesis,
tickers: tickers ?? [],
snapshot: snapshot ?? {},
createdAt: new Date().toISOString(),
};
const qb = new QueryBuilder('MARKET_CALLS_QUERIES.INSERT', [
call.id,
call.title,
call.quarter,
call.date,
call.thesis,
JSON.stringify(call.tickers),
JSON.stringify(call.snapshot),
call.createdAt,
]);
this.db.run(qb);
return call as MarketCall & { createdAt: string };
}
/**
* Delete a market call by ID.
* Returns true if the call existed and was deleted, false otherwise.
*/
delete(id: string): boolean {
const qb = new QueryBuilder('MARKET_CALLS_QUERIES.DELETE_BY_ID', [id]);
const changes = this.db.run(qb);
return changes > 0;
}
/**
* Convert database row to domain object.
*/
private static toCall(row: MarketCallRow): MarketCall & { createdAt: string } {
return {
id: row.id,
title: row.title,
quarter: row.quarter,
date: row.date,
thesis: row.thesis,
tickers: JSON.parse(row.tickers),
snapshot: JSON.parse(row.snapshot),
createdAt: row.created_at,
} as MarketCall & { createdAt: string };
}
}
@@ -0,0 +1,74 @@
import { DatabaseConnection } from '../db/index';
import { QueryBuilder } from '../utils/QueryBuilder';
import { sanitizeTicker, sanitizeNumber } from '../utils/sanitizer';
import type { PortfolioData, PortfolioHolding, HoldingRow } from '../types';
export class PortfolioRepository {
constructor(private readonly db: DatabaseConnection) {}
/**
* Check if portfolio has any holdings.
*/
exists(): boolean {
const qb = new QueryBuilder('HOLDINGS_QUERIES.EXISTS');
const row = this.db.get<{ n: number }>(qb);
return row ? row.n > 0 : false;
}
/**
* Read all holdings.
*/
read(): PortfolioData {
const qb = new QueryBuilder('HOLDINGS_QUERIES.SELECT_ALL');
const rows = this.db.all<HoldingRow>(qb);
return { holdings: rows.map(PortfolioRepository.toHolding) };
}
/**
* Insert or update a holding (UPSERT).
*/
upsert(entry: PortfolioHolding): PortfolioHolding {
// Sanitize inputs
const ticker = sanitizeTicker(entry.ticker);
const shares = sanitizeNumber(entry.shares, 'shares', { min: 0 });
const costBasis = sanitizeNumber(entry.costBasis ?? 0, 'costBasis', { min: 0 });
const type = entry.type ?? 'stock';
const source = entry.source ?? 'Manual';
const qb = new QueryBuilder('HOLDINGS_QUERIES.UPSERT', [
ticker,
shares,
costBasis,
type,
source,
]);
this.db.run(qb);
return { ...entry, ticker };
}
/**
* Delete a holding by ticker.
*/
remove(ticker: string): boolean {
// Sanitize input
const sanitizedTicker = sanitizeTicker(ticker);
const qb = new QueryBuilder('HOLDINGS_QUERIES.DELETE_BY_TICKER', [sanitizedTicker]);
const changes = this.db.run(qb);
return changes > 0;
}
/**
* Convert database row to domain object.
*/
private static toHolding(row: HoldingRow): PortfolioHolding {
return {
ticker: row.ticker,
shares: row.shares,
costBasis: row.cost_basis,
type: row.type as PortfolioHolding['type'],
source: row.source,
};
}
}
@@ -0,0 +1,69 @@
import { SECTOR, ASSET_TYPE, REGIME } from '../config/constants';
import type { MarketContext, AssetType, InflatedOverrides } from '../types';
export class MarketRegime {
private marketPE: number;
private techPE: number;
private reitYield: number;
private igSpread: number;
private rateRegime: string;
private volatilityRegime: string;
constructor(marketContext: Partial<MarketContext>) {
const b = marketContext?.benchmarks ?? ({} as MarketContext['benchmarks']);
this.marketPE = b.marketPE ?? 22;
this.techPE = b.techPE ?? 30;
this.reitYield = b.reitYield ?? 3.5;
this.igSpread = b.igSpread ?? 1.0;
this.rateRegime = marketContext?.rateRegime ?? REGIME.NORMAL;
this.volatilityRegime = marketContext?.volatilityRegime ?? REGIME.NORMAL;
}
getInflatedOverrides(type: AssetType, sector?: string): InflatedOverrides {
if (type === ASSET_TYPE.STOCK) return this.stock(sector);
if (type === ASSET_TYPE.ETF) return this.etf();
if (type === ASSET_TYPE.BOND) return this.bond();
return { gates: {}, thresholds: {} };
}
private stock(sector?: string): InflatedOverrides {
if (sector === SECTOR.REIT) {
return {
gates: {},
thresholds: {
minYield: +(this.reitYield * (this.rateRegime === REGIME.HIGH ? 0.95 : 0.85)).toFixed(2),
maxPFFO: 20,
},
};
}
if (sector === SECTOR.TECHNOLOGY) {
return {
gates: {
maxPERatio: Math.round(this.techPE * 1.3),
maxPegGate: +(this.techPE / 15).toFixed(1),
},
thresholds: {},
};
}
const peMultiplier = this.rateRegime === REGIME.HIGH ? 1.2 : 1.5;
return {
gates: {
maxPERatio: Math.round(this.marketPE * peMultiplier),
maxPegGate: +(this.marketPE / 12).toFixed(1),
},
thresholds: {},
};
}
private etf(): InflatedOverrides {
return { gates: { maxExpenseRatio: 0.75 }, thresholds: { minYield: 0.5 } };
}
private bond(): InflatedOverrides {
const spreadMultiplier = this.rateRegime === REGIME.HIGH ? 0.9 : 0.8;
return {
gates: {},
thresholds: { minSpread: +(this.igSpread * spreadMultiplier).toFixed(2) },
};
}
}
@@ -0,0 +1,195 @@
import type { ScoringRulesShape } from '../types';
// ── Credit rating scale (S&P convention) ─────────────────────────────────
// Bond.ts converts letter ratings to these numbers; BondScorer uses them for gate checks.
// Investment grade = BBB (7) and above.
export const CREDIT_RATING_SCALE: Record<string, number> = {
AAA: 10,
AA: 9,
A: 8,
BBB: 7,
BB: 6,
B: 5,
CCC: 4,
CC: 3,
C: 2,
D: 1,
};
// ── Scoring rule shape ────────────────────────────────────────────────────
// Structural shapes (GateSet/WeightSet/ThresholdSet/RuleBlock/StockRules/
// ScoringRulesShape) live in server/types/asset.model.ts.
// ─────────────────────────────────────────────────────────────────────────────
// Fundamental baseline — Graham / value-investing style.
// MarketRegime.ts overrides the valuation gates for INFLATED-mode analysis.
// Sector overrides are structural — they apply in both modes.
// ─────────────────────────────────────────────────────────────────────────────
export const ScoringRules: ScoringRulesShape = {
STOCK: {
gates: {
maxDebtToEquity: 1.5, // Graham ceiling; most distress starts above 2x
minQuickRatio: 0.8, // below 0.8 signals real liquidity stress in non-tech
maxPERatio: 15, // Graham's actual rule: never pay more than 15x trailing earnings
maxPegGate: 1.0, // PEG > 1.0 means you're paying full price for growth (Lynch standard)
},
weights: {
margin: 2, // net profit margin
opMargin: 2, // operating margin (pricing power)
roe: 3, // return on equity — Buffett's primary quality metric
peg: 2, // valuation relative to growth
revenue: 2, // revenue growth
fcf: 3, // FCF is the most manipulation-resistant quality signal
analyst: 2, // Wall Street consensus (1=Strong Buy … 5=Strong Sell, inverted in scorer)
dcf: 2, // DCF margin of safety: positive = undervalued vs intrinsic value
},
thresholds: {
marginHigh: 15, // 15% net margin is genuinely excellent across most sectors
marginMed: 8, // 8% is the realistic mid-tier for industrials/retail
opMarginHigh: 20,
opMarginMed: 10,
roeHigh: 15, // sustainable 15% ROE is Buffett-quality; 20% is rare/fleeting
roeMed: 10, // 10% is the cost-of-equity floor for most businesses
pegHigh: 0.75, // PEG < 0.75 is genuinely cheap relative to growth
pegMed: 1.0,
revHigh: 10, // 10% organic revenue growth is strong for mature cos
revMed: 5,
fcfHigh: 5,
fcfMed: 2,
// Analyst consensus thresholds (Yahoo recommendationMean scale: 1=Strong Buy, 5=Strong Sell)
analystBuy: 2.0, // ≤ 2.0 → consensus is Buy or better
analystHold: 3.0, // ≤ 3.0 → consensus is Hold or better
// DCF margin-of-safety thresholds (% undervaluation vs intrinsic value)
dcfUndervalued: 20, // ≥ 20% margin of safety → undervalued
dcfFairValue: 0, // 020% → fairly valued; negative → overvalued
},
SECTOR_OVERRIDE: {
TECHNOLOGY: {
gates: { maxDebtToEquity: 2.0, minQuickRatio: 0.8, maxPERatio: 35, maxPegGate: 1.5 },
weights: { margin: 1, opMargin: 3, roe: 3, peg: 3, revenue: 4, fcf: 3 },
thresholds: { marginHigh: 25, opMarginHigh: 25, roeHigh: 20, pegHigh: 1.0, revHigh: 20 },
},
REIT: {
gates: { maxDebtToEquity: 6.0, minQuickRatio: 0.1, maxPERatio: 9999, maxPegGate: 9999 },
weights: { margin: 0, opMargin: 0, roe: 0, peg: 0, revenue: 0, fcf: 0, yield: 5, pFFO: 3 },
thresholds: { minYield: 4.5, maxPFFO: 20 },
},
FINANCIAL: {
gates: {
maxDebtToEquity: 9999,
minQuickRatio: 0.1,
maxPERatio: 9999,
maxPegGate: 9999,
maxPriceToBook: 1.5,
},
weights: { margin: 0, opMargin: 0, peg: 0, roe: 5, revenue: 1, fcf: 1, priceToBook: 3 },
thresholds: { roeHigh: 15, roeMed: 12, revHigh: 10, revMed: 5 },
},
ENERGY: {
gates: { maxDebtToEquity: 1.5, minQuickRatio: 0.6, maxPERatio: 15, maxPegGate: 1.5 },
weights: { margin: 0, opMargin: 3, roe: 2, peg: 1, revenue: 2, fcf: 4, yield: 3 },
thresholds: {
opMarginHigh: 20,
opMarginMed: 10,
roeHigh: 15,
roeMed: 8,
fcfHigh: 8,
fcfMed: 4,
},
},
HEALTHCARE: {
gates: { maxDebtToEquity: 1.5, minQuickRatio: 1.0, maxPERatio: 25, maxPegGate: 1.5 },
weights: { margin: 1, opMargin: 2, roe: 2, peg: 2, revenue: 4, fcf: 3 },
thresholds: {
marginHigh: 20,
marginMed: 10,
roeHigh: 20,
roeMed: 12,
revHigh: 15,
revMed: 8,
fcfHigh: 8,
fcfMed: 3,
},
},
COMMUNICATION: {
gates: { maxDebtToEquity: 2.0, minQuickRatio: 0.8, maxPERatio: 25, maxPegGate: 1.5 },
weights: { margin: 2, opMargin: 3, roe: 2, peg: 2, revenue: 3, fcf: 4 },
thresholds: {
marginHigh: 25,
marginMed: 12,
opMarginHigh: 30,
opMarginMed: 15,
roeHigh: 20,
roeMed: 12,
pegHigh: 1.0,
pegMed: 1.5,
revHigh: 15,
revMed: 5,
fcfHigh: 8,
fcfMed: 3,
},
},
CONSUMER_STAPLES: {
gates: { maxDebtToEquity: 1.5, minQuickRatio: 0.5, maxPERatio: 22, maxPegGate: 2.0 },
weights: { margin: 3, opMargin: 3, roe: 3, peg: 1, revenue: 1, fcf: 3 },
thresholds: {
marginHigh: 12,
marginMed: 7,
opMarginHigh: 18,
opMarginMed: 10,
roeHigh: 20,
roeMed: 12,
pegHigh: 1.5,
pegMed: 2.0,
revHigh: 5,
revMed: 2,
fcfHigh: 5,
fcfMed: 2,
},
},
CONSUMER_DISCRETIONARY: {
gates: { maxDebtToEquity: 2.0, minQuickRatio: 0.5, maxPERatio: 25, maxPegGate: 1.5 },
weights: { margin: 2, opMargin: 2, roe: 2, peg: 2, revenue: 4, fcf: 3 },
thresholds: {
marginHigh: 10,
marginMed: 5,
opMarginHigh: 15,
opMarginMed: 8,
roeHigh: 20,
roeMed: 12,
pegHigh: 1.0,
pegMed: 1.5,
revHigh: 12,
revMed: 5,
fcfHigh: 5,
fcfMed: 2,
},
},
},
},
ETF: {
gates: { maxExpenseRatio: 0.2 },
weights: { yield: 2, lowCost: 4, fiveYearReturn: 2 },
thresholds: {
minYield: 1.5,
maxExpense: 0.05,
minVolume: 1_000_000,
minFiveYearReturn: 8.0,
},
},
BOND: {
gates: { minCreditRating: 7 }, // BBB = investment-grade floor
weights: { yieldSpread: 3, duration: 2 },
thresholds: { minSpread: 1.5, maxDuration: 7 },
},
};
@@ -0,0 +1,117 @@
import { readFileSync, writeFileSync, existsSync } from 'fs';
import { YahooFinanceClient } from '../adapters/YahooFinanceClient';
import { REGIME } from '../config/constants';
import type { MarketContext, Logger, BenchmarkProviderOptions } from '../types/index';
interface CacheFile {
data: MarketContext;
expiresAt: number;
}
export class BenchmarkProvider {
private static readonly TTL_MS = 60 * 60 * 1000;
private static readonly CACHE_PATH = '.benchmark-cache.json';
private static readonly DEFAULTS: MarketContext = {
sp500Price: 5000,
riskFreeRate: 4.5,
vixLevel: 20,
rateRegime: 'HIGH',
volatilityRegime: 'NORMAL',
benchmarks: { marketPE: 22, techPE: 30, reitYield: 3.5, igSpread: 1.0 },
};
private static rateRegime(rate: number): MarketContext['rateRegime'] {
return rate < 2 ? REGIME.LOW : rate <= 5 ? REGIME.NORMAL : REGIME.HIGH;
}
private static volRegime(vix: number): MarketContext['volatilityRegime'] {
return vix < 15 ? REGIME.LOW : vix <= 25 ? REGIME.NORMAL : REGIME.HIGH;
}
private static pe(summary: any): number | null {
return summary?.summaryDetail?.trailingPE ?? summary?.defaultKeyStatistics?.forwardPE ?? null;
}
private cache: { data: MarketContext | null; expiresAt: number };
private logger: Logger;
constructor(
private readonly client: YahooFinanceClient,
{ logger }: BenchmarkProviderOptions = {},
) {
this.cache = this.loadDiskCache();
this.logger = logger ?? (console as unknown as Logger);
}
private loadDiskCache(): { data: MarketContext | null; expiresAt: number } {
try {
if (!existsSync(BenchmarkProvider.CACHE_PATH)) return { data: null, expiresAt: 0 };
const file = JSON.parse(readFileSync(BenchmarkProvider.CACHE_PATH, 'utf8')) as CacheFile;
if (Date.now() < file.expiresAt) return { data: file.data, expiresAt: file.expiresAt };
} catch {
// corrupt or missing — ignore
}
return { data: null, expiresAt: 0 };
}
private saveDiskCache(data: MarketContext, expiresAt: number): void {
try {
writeFileSync(
BenchmarkProvider.CACHE_PATH,
JSON.stringify({ data, expiresAt } satisfies CacheFile, null, 2),
'utf8',
);
} catch {
// non-fatal — in-memory cache still works
}
}
async getMarketContext(): Promise<MarketContext> {
if (this.cache.data && Date.now() < this.cache.expiresAt) return this.cache.data;
try {
const [sp500, tn10y, vix, spy, xlk, xlre, lqd] = await Promise.all([
this.client.fetchSummary('^GSPC'),
this.client.fetchSummary('^TNX'),
this.client.fetchSummary('^VIX'),
this.client.fetchSummary('SPY'),
this.client.fetchSummary('XLK'),
this.client.fetchSummary('XLRE'),
this.client.fetchSummary('LQD'),
]);
const riskFreeRate =
(sp500 as any)?.price?.regularMarketPrice !== undefined
? ((tn10y as any)?.price?.regularMarketPrice ?? 0)
: 0;
const sp500Price = (sp500 as any)?.price?.regularMarketPrice ?? 0;
const vixLevel = (vix as any)?.price?.regularMarketPrice ?? 0;
if (!sp500Price || !riskFreeRate) throw new Error('Invalid market data (zero values)');
const lqdYield = ((lqd as any)?.summaryDetail?.trailingAnnualDividendYield ?? 0) * 100;
const context: MarketContext = {
sp500Price,
riskFreeRate,
vixLevel,
rateRegime: BenchmarkProvider.rateRegime(riskFreeRate),
volatilityRegime: BenchmarkProvider.volRegime(vixLevel),
benchmarks: {
marketPE: BenchmarkProvider.pe(spy) ?? 22,
techPE: BenchmarkProvider.pe(xlk) ?? 30,
reitYield: ((xlre as any)?.summaryDetail?.trailingAnnualDividendYield ?? 0.035) * 100,
igSpread: Math.max(0.1, lqdYield - riskFreeRate),
},
};
const expiresAt = Date.now() + BenchmarkProvider.TTL_MS;
this.cache = { data: context, expiresAt };
this.saveDiskCache(context, expiresAt);
return context;
} catch (err) {
this.logger.warn('Market data fetch failed, using defaults:', (err as Error).message);
return this.cache.data ?? BenchmarkProvider.DEFAULTS;
}
}
}
@@ -0,0 +1,108 @@
import { YahooFinanceClient } from '../adapters/YahooFinanceClient';
import type { Logger, CatalystResult, Story, YahooNewsItem } from '../types/index';
export class CatalystAnalyst {
private static readonly NEWS_QUERIES = [
'stock market today',
'earnings report today',
'market news catalyst',
'federal reserve interest rates',
'stock upgrade downgrade analyst',
];
private static readonly MAX_STORIES = 20;
private static readonly TICKER_REGEX = /^[A-Z]{1,6}$/;
private client: YahooFinanceClient;
private logger: Pick<Logger, 'write'>;
constructor({ logger }: { logger?: Pick<Logger, 'write'> } = {}) {
this.client = new YahooFinanceClient();
this.logger = logger ?? { write: (msg: string) => process.stdout.write(msg) };
}
async run(): Promise<CatalystResult> {
this.logger.write('🔍 Fetching market news...');
const rawStories = await this.fetchNews();
if (!rawStories.length) {
this.logger.write(' ⚠ all news queries failed — check network or Yahoo rate limit\n');
return { tickers: [], tickerFrequency: {}, stories: [] };
}
const stories = rawStories.map((s) => ({
title: s.title,
link: s.link ?? '',
source: s.publisher ?? 'unknown',
tickers: (s.relatedTickers ?? [])
.map((t) => t.split(':')[0].toUpperCase())
.filter((t) => CatalystAnalyst.TICKER_REGEX.test(t)),
}));
const { tickers, tickerFrequency } = CatalystAnalyst.rankTickers(stories);
this.logger.write(` ${stories.length} stories, ${tickers.length} tickers\n`);
return { tickers, tickerFrequency, stories };
}
// Search by specific ticker for the /api/analyze endpoint.
async fetchStoriesForTickers(tickers: string[]): Promise<Story[]> {
const seen = new Map<string, YahooNewsItem>();
await Promise.all(
tickers.slice(0, 10).map(async (ticker) => {
try {
const news = await this.client.search(ticker, { newsCount: 3, quotesCount: 0 });
for (const item of news) {
if (!seen.has(item.title)) seen.set(item.title, item);
}
} catch {
/* skip tickers Yahoo can't resolve */
}
}),
);
return [...seen.values()].slice(0, 15).map((s) => ({
title: s.title,
link: s.link ?? '',
source: s.publisher ?? 'unknown',
tickers: (s.relatedTickers ?? [])
.map((t) => t.split(':')[0].toUpperCase())
.filter((t) => CatalystAnalyst.TICKER_REGEX.test(t)),
}));
}
private async fetchNews(): Promise<YahooNewsItem[]> {
const seen = new Map<string, YahooNewsItem>();
let successCount = 0;
for (const query of CatalystAnalyst.NEWS_QUERIES) {
try {
const news = await this.client.search(query, { newsCount: 8, quotesCount: 0 });
successCount++;
for (const s of news) {
if (!seen.has(s.title)) {
seen.set(s.title, {
title: s.title,
publisher: s.publisher,
link: s.link,
relatedTickers: s.relatedTickers ?? [],
});
}
}
} catch {
/* skip failed query — tracked via successCount */
}
}
if (successCount === 0) return [];
return [...seen.values()].slice(0, CatalystAnalyst.MAX_STORIES);
}
static rankTickers(stories: Story[]): {
tickers: string[];
tickerFrequency: Record<string, number>;
} {
const freq: Record<string, number> = {};
for (const { tickers } of stories) {
for (const t of tickers) {
freq[t] = (freq[t] ?? 0) + 1;
}
}
const tickers = Object.keys(freq).sort((a, b) => freq[b] - freq[a]);
return { tickers, tickerFrequency: freq };
}
}
@@ -0,0 +1,71 @@
import type { CatalystResult, Logger } from '../types/index';
import { CatalystAnalyst } from './CatalystAnalyst';
export class CatalystCache {
private static readonly TTL_MS = 15 * 60 * 1000; // 15 minutes
private cached: CatalystResult | null = null;
private cachedAt: number | null = null;
private isRefreshing = false;
private analyst: CatalystAnalyst;
private logger: Pick<Logger, 'write'>;
constructor({ logger }: { logger?: Pick<Logger, 'write'> } = {}) {
this.analyst = new CatalystAnalyst({ logger });
this.logger = logger ?? { write: (msg: string) => process.stdout.write(msg) };
}
async get(): Promise<CatalystResult> {
const now = Date.now();
const isStale = !this.cachedAt || now - this.cachedAt > CatalystCache.TTL_MS;
if (!isStale && this.cached) {
return this.cached;
}
if (this.isRefreshing) {
// Return stale cache while refresh in progress
if (this.cached) {
return this.cached;
}
// If no cache exists yet, wait for refresh to complete
return new Promise((resolve) => {
const checkInterval = setInterval(() => {
if (!this.isRefreshing && this.cached) {
clearInterval(checkInterval);
resolve(this.cached!);
}
}, 100);
// Timeout after 30s
setTimeout(() => clearInterval(checkInterval), 30000);
});
}
// Trigger refresh
this.isRefreshing = true;
try {
this.logger.write('📡 Refreshing catalyst cache...\n');
this.cached = await this.analyst.run();
this.cachedAt = now;
} catch (error) {
this.logger.write(`⚠️ Catalyst refresh failed: ${error}\n`);
// Return stale cache on error
if (!this.cached) {
this.cached = { tickers: [], tickerFrequency: {}, stories: [] };
}
} finally {
this.isRefreshing = false;
}
return this.cached;
}
isExpired(): boolean {
if (!this.cachedAt) return true;
return Date.now() - this.cachedAt > CatalystCache.TTL_MS;
}
clear(): void {
this.cached = null;
this.cachedAt = null;
}
}
@@ -0,0 +1,67 @@
import { readFileSync } from 'fs';
import { join } from 'path';
import { fileURLToPath } from 'url';
import { AnthropicClient } from '../adapters/AnthropicClient';
import type { Logger, LLMAnalysis, Story } from '../types/index';
export class LLMAnalyst {
private logger: Pick<Logger, 'log' | 'warn'>;
private client: AnthropicClient;
constructor({ logger }: { logger?: Pick<Logger, 'log' | 'warn'> } = {}) {
// eslint-disable-next-line no-console
this.logger = logger ?? { log: console.log, warn: console.warn };
this.client = new AnthropicClient();
}
get isAvailable(): boolean {
return this.client.isAvailable;
}
async analyze(
stories: Story[],
existingTickers: string[] = [],
tickerFrequency: Record<string, number> = {},
): Promise<LLMAnalysis | null> {
if (!this.client.isAvailable) {
this.logger.warn('LLMAnalyst: ANTHROPIC_API_KEY not set — skipping analysis');
return null;
}
if (!stories?.length) return null;
const headlines = stories
.slice(0, 15)
.map((s, i) => {
const tickers = s.tickers.length ? ` [${s.tickers.join(', ')}]` : '';
return `${i + 1}. ${s.title} (${s.source})${tickers}`;
})
.join('\n');
const freqLines = Object.entries(tickerFrequency)
.sort(([, a], [, b]) => b - a)
.slice(0, 10)
.map(([t, n]) => ` ${t}: ${n} ${n === 1 ? 'story' : 'stories'}`)
.join('\n');
const freqSection = freqLines ? `\nTicker mention frequency (ranked):\n${freqLines}\n` : '';
const userMessage = `Today's market news headlines:\n\n${headlines}\n${freqSection}\nAlready identified catalyst tickers: ${existingTickers.join(', ') || 'none'}`;
try {
const PROMPT_FILE = '../../prompts/llm-analyst.md';
const PROMPT_PATH = join(fileURLToPath(import.meta.url), PROMPT_FILE);
const SYSTEM_PROMPT = readFileSync(PROMPT_PATH, 'utf8');
const raw = await this.client.complete(SYSTEM_PROMPT, userMessage);
if (!raw) return null;
const cleaned = raw
.replace(/^```(?:json)?\s*/i, '')
.replace(/```\s*$/i, '')
.trim();
return JSON.parse(cleaned) as LLMAnalysis;
} catch (err) {
this.logger.warn('LLMAnalyst: analysis failed —', (err as Error).message);
return null;
}
}
}
@@ -0,0 +1,83 @@
// ── Asset & screener domain types ─────────────────────────────────────────
import type { Sector } from '../config/constants';
export type Signal =
| '✅ Strong Buy'
| '⚡ Momentum'
| '⚠️ Speculation'
| '🔄 Neutral'
| '❌ Avoid';
export type AssetType = 'STOCK' | 'ETF' | 'BOND';
export type ScoreMode = 'inflated' | 'fundamental';
export interface ScoringRules {
gates: Record<string, number>;
weights: Record<string, number>;
thresholds: Record<string, number>;
}
// ── ScoringConfig structural shapes (server/config/ScoringConfig.ts) ───────
export type GateSet = Record<string, number>;
export type WeightSet = Record<string, number>;
export type ThresholdSet = Record<string, number>;
export interface RuleBlock {
gates: GateSet;
weights: WeightSet;
thresholds: ThresholdSet;
}
export interface StockRules extends RuleBlock {
SECTOR_OVERRIDE: Partial<Record<Sector, Partial<RuleBlock>>>;
}
export interface ScoringRulesShape {
STOCK: StockRules;
ETF: RuleBlock;
BOND: RuleBlock;
}
export interface ScoreAudit {
passedGates: boolean;
breakdown?: Record<string, number>;
riskFlags?: string[] | null;
failures?: string[];
}
export interface ScoreResult {
label: string;
scoreSummary: string;
audit: ScoreAudit;
}
// AssetResult with runtime methods still attached — used at the HTTP boundary
// before class instances are serialised to plain objects for API responses.
export type LiveAssetResult = AssetResult & {
asset: AssetResult['asset'] & {
getDisplayMetrics: () => Record<string, unknown>;
metrics: unknown;
};
};
export interface AssetResult {
asset: {
ticker: string;
currentPrice: number;
type: AssetType;
displayMetrics: Record<string, string | number | null>;
};
signal: Signal;
inflated: ScoreResult;
fundamental: ScoreResult;
}
export interface ScreenerResult {
STOCK: AssetResult[];
ETF: AssetResult[];
BOND: AssetResult[];
ERROR: Array<{ ticker: string; message: string }>;
marketContext: import('./market.model.js').MarketContext;
}
@@ -0,0 +1,39 @@
// ── Market calls domain types ──────────────────────────────────────────────
import type { Signal } from './asset.model';
export interface TickerSnapshot {
price: number | null;
signal: Signal | null;
}
export interface MarketCall {
id: string;
title: string;
quarter: string;
date: string;
thesis: string;
tickers: string[];
snapshot: Record<string, TickerSnapshot>;
}
// Input shape for MarketCallRepository.create()
export interface CreateCallInput {
title: string;
quarter: string;
date?: string;
thesis: string;
tickers: string[];
snapshot?: Record<string, TickerSnapshot>;
}
// Re-screened snapshot returned by GET /api/calls/:id for price comparison.
export interface SnapshotEntry {
price: number | null;
signal: string | null;
inflatedVerdict: string | null;
fundamentalVerdict: string | null;
pe: string | null;
roe: string | null;
fcf: string | null;
}
@@ -0,0 +1,25 @@
/**
* Database layer types.
* Defines interfaces for query building, auditing, and data access.
*/
export enum AuditAction {
READ = 'READ',
WRITE = 'WRITE',
DELETE = 'DELETE',
}
export interface AuditEntry {
timestamp: string; // ISO 8601
action: AuditAction;
sql: string;
params: unknown[];
durationMs: number;
rowsAffected?: number;
error?: string;
}
export interface DatabaseOptions {
audit?: import('../db/QueryAudit').QueryAudit;
logSlowQueries?: number; // milliseconds
}
@@ -0,0 +1,105 @@
// ── Finance & analyst API response types ──────────────────────────────────
import type { Logger } from './logger.model';
export interface AffectedIndustry {
name: string;
reason: string;
}
export interface RelatedTicker {
ticker: string;
reason: string;
bias: 'BULL' | 'BEAR';
horizon: 'SHORT' | 'MEDIUM' | 'LONG';
sensitivity: 1 | 2 | 3 | 4 | 5;
}
export interface LLMAnalysis {
summary: string;
sentiment: 'BULLISH' | 'BEARISH' | 'NEUTRAL';
affectedIndustries: AffectedIndustry[];
relatedTickers: RelatedTicker[];
}
export interface CatalystStory {
title: string;
link: string;
publisher: string;
publishedAt: string;
relatedTickers: string[];
}
export interface CalendarEvent {
ticker: string;
type: 'earnings' | 'dividend' | 'exdividend';
date: string;
label?: string;
detail?: string | null;
isPast?: boolean;
epsEstimate?: number | null;
revEstimate?: number | null;
}
// ── Yahoo Finance client types ─────────────────────────────────────────────
// Raw shapes returned by the yahoo-finance2 search endpoint.
// Used by YahooFinanceClient, CatalystAnalyst, and AnalyzeController.
export interface YahooNewsItem {
title: string;
publisher: string;
link: string;
relatedTickers?: string[];
}
export interface YahooSearchOptions {
newsCount?: number;
quotesCount?: number;
}
// Narrow interface over the yahoo-finance2 instance — only the methods this
// codebase actually calls. Keeps `any` contained to this one declaration.
export interface YahooFinanceLib {
quoteSummary(
ticker: string,
opts: { modules: string[] },
queryOpts?: { validateResult?: boolean },
): Promise<any>;
search(query: string, opts?: YahooSearchOptions): Promise<{ news?: YahooNewsItem[] }>;
}
// ── SimpleFIN client types ─────────────────────────────────────────────────
export interface SimpleFINOptions {
logger?: Logger;
onAccessUrlClaimed?: (url: string) => Promise<void> | void;
}
export interface SimpleFINTransaction {
id: string;
date: string;
amount: number;
description: string;
category: string;
}
export interface SimpleFINAccount {
id: string;
name: string;
currency: string;
balance: number;
balanceDate: string;
org: string;
type: string;
transactions: SimpleFINTransaction[];
}
export interface SimpleFINData {
accounts: SimpleFINAccount[];
errors: string[];
}
export interface GetAccountsOptions {
startDate?: number;
endDate?: number;
}
+67
View File
@@ -0,0 +1,67 @@
// ── Single source of truth for all domain types ───────────────────────────
// Import from specific model files for clarity, or from here for convenience.
export type {
Signal,
AssetType,
ScoreMode,
ScoringRules,
ScoreAudit,
ScoreResult,
AssetResult,
LiveAssetResult,
ScreenerResult,
GateSet,
WeightSet,
ThresholdSet,
RuleBlock,
StockRules,
ScoringRulesShape,
} from './asset.model';
export type { RateRegime, VolatilityRegime, Benchmarks, MarketContext } from './market.model';
export type { HoldingType, PortfolioHolding, PortfolioAdvice, AdviceRow } from './portfolio.model';
export type { TickerSnapshot, MarketCall, SnapshotEntry, CreateCallInput } from './calls.model';
export type {
AffectedIndustry,
RelatedTicker,
LLMAnalysis,
CatalystStory,
CalendarEvent,
YahooNewsItem,
YahooSearchOptions,
YahooFinanceLib,
SimpleFINOptions,
SimpleFINTransaction,
SimpleFINAccount,
SimpleFINData,
GetAccountsOptions,
} from './finance.model';
export type { Logger } from './logger.model';
export type {
AssetData,
StockData,
StockMetrics,
EtfData,
EtfMetrics,
BondData,
BondMetrics,
} from './models.model';
export type { StoreData, PortfolioData, MarketCallRow, HoldingRow } from './repositories.model';
export type { NumVal, SanitizedMetrics, SanitizedBondMetrics } from './scorers.model';
export type {
BenchmarkProviderOptions,
InflatedOverrides,
PositionCalc,
AdviceOutput,
ErrorResult,
Headline,
Story,
CatalystResult,
MappedData,
CategoryBreakdown,
FinanceAnalysis,
RuleSet,
ScreenerEngineOptions,
} from './services.model';
export type { AuditEntry, DatabaseOptions } from './database.model';
export { AuditAction } from './database.model';
@@ -0,0 +1,7 @@
// ── Logger interface ───────────────────────────────────────────────────────
export interface Logger {
write: (msg: string) => void;
log: (...args: unknown[]) => void;
warn: (...args: unknown[]) => void;
}
@@ -0,0 +1,21 @@
// ── Market context types ───────────────────────────────────────────────────
export type RateRegime = 'HIGH' | 'NORMAL' | 'LOW';
export type VolatilityRegime = 'HIGH' | 'NORMAL' | 'LOW';
export interface Benchmarks {
marketPE: number | null;
techPE: number | null;
reitYield: number | null;
igSpread: number | null;
}
export interface MarketContext {
sp500Price: number | null;
riskFreeRate: number | null;
vixLevel: number | null;
rateRegime: RateRegime;
volatilityRegime: VolatilityRegime;
benchmarks: Benchmarks;
}
+121
View File
@@ -0,0 +1,121 @@
// ── Model data input and metrics shapes ────────────────────────────────────
import type { Sector, CapCategory, GrowthCategory } from '../config/constants';
// ── Asset base ─────────────────────────────────────────────────────────────
export interface AssetData {
ticker?: string;
currentPrice?: number;
type?: string;
[key: string]: unknown;
}
// ── Stock ──────────────────────────────────────────────────────────────────
export interface StockData {
ticker?: string;
currentPrice?: number;
assetProfile?: { industry?: string; sector?: string };
peRatio?: number | null;
pegRatio?: number | null;
priceToBook?: number | null;
grossMargin?: number | null;
netProfitMargin?: number | null;
operatingMargin?: number | null;
returnOnEquity?: number | null;
revenueGrowth?: number | null;
earningsGrowth?: number | null;
debtToEquity?: number | null;
quickRatio?: number | null;
fcfYield?: number | null;
pFFO?: number | null;
dividendYield?: number | null;
beta?: number | null;
week52High?: number | null;
week52Low?: number | null;
week52Change?: number | null;
week52FromHigh?: number | null;
week52FromLow?: number | null;
marketCap?: number | null;
analystRating?: number | null;
analystTargetPrice?: number | null;
analystUpside?: number | null;
numberOfAnalysts?: number | null;
dcfIntrinsicValue?: number | null;
dcfMarginOfSafety?: number | null;
[key: string]: unknown;
}
export interface StockMetrics {
sector: Sector;
capCategory: CapCategory;
growthCategory: GrowthCategory;
peRatio: number | null;
pegRatio: number | null;
priceToBook: number | null;
grossMargin: number | null;
netProfitMargin: number | null;
operatingMargin: number | null;
returnOnEquity: number | null;
revenueGrowth: number | null;
earningsGrowth: number | null;
debtToEquity: number | null;
quickRatio: number | null;
fcfYield: number | null;
pFFO: number | null;
dividendYield: number | null;
beta: number | null;
week52High: number | null;
week52Low: number | null;
week52Change: number | null;
week52FromHigh: number | null;
week52FromLow: number | null;
marketCap: number | null;
analystRating: number | null;
analystTargetPrice: number | null;
analystUpside: number | null;
numberOfAnalysts: number | null;
dcfIntrinsicValue: number | null;
dcfMarginOfSafety: number | null;
currentPrice: number;
}
// ── ETF ────────────────────────────────────────────────────────────────────
export interface EtfData {
ticker?: string;
currentPrice?: number;
expenseRatio?: string | number;
totalAssets?: string | number;
yield?: string | number;
volume?: string | number;
fiveYearReturn?: string | number;
[key: string]: unknown;
}
export interface EtfMetrics {
expenseRatio: number;
totalAssets: number;
yield: number;
volume: number;
fiveYearReturn: number;
}
// ── Bond ───────────────────────────────────────────────────────────────────
export interface BondData {
ticker?: string;
currentPrice?: number;
creditRating?: string;
yieldToMaturity?: string | number;
duration?: string | number;
[key: string]: unknown;
}
export interface BondMetrics {
ytm: number;
duration: number;
creditRating: string;
creditRatingNumeric: number;
}
@@ -0,0 +1,40 @@
// ── Portfolio domain types ─────────────────────────────────────────────────
import type { Signal } from './asset.model';
export type HoldingType = 'stock' | 'etf' | 'bond' | 'crypto';
export interface PortfolioHolding {
ticker: string;
shares: number;
costBasis: number;
source: string;
type: HoldingType;
}
export interface PortfolioAdvice {
ticker: string;
action: 'hold' | 'sell' | 'add' | 'watch';
reason: string;
signal: Signal | null;
currentPrice: number | null;
gainLossPct: number | null;
}
// Public return shape of PortfolioAdvisor.advise() — one row per holding.
export interface AdviceRow {
ticker: string;
type: string;
source: string;
shares: number;
costBasis: number;
currentPrice: number | null;
marketValue: string | null;
totalCost: string;
gainLossPct: string | null;
signal: Signal | '—';
inflated: string;
fundamental: string;
advice: string;
reason: string;
}
@@ -0,0 +1,48 @@
/**
* Repository model types.
*
* Defines:
* - Row shapes: how data comes FROM the database (snake_case, as-is)
* - Persistence shapes: collection types returned by repositories
*/
import type { MarketCall, PortfolioHolding } from './index';
// ── Database Row Shapes (internal to repositories) ──────────────────────────
/**
* Raw database row from market_calls table.
* Uses snake_case columns exactly as they exist in SQLite.
*/
export interface MarketCallRow {
id: string;
title: string;
quarter: string;
date: string;
thesis: string;
tickers: string; // JSON array stringified
snapshot: string; // JSON object stringified
created_at: string;
}
/**
* Raw database row from holdings table.
* Uses snake_case columns exactly as they exist in SQLite.
*/
export interface HoldingRow {
ticker: string;
shares: number;
cost_basis: number;
type: string;
source: string;
}
// ── Persistence Shapes (returned by repositories) ───────────────────────────
export interface StoreData {
calls: (MarketCall & { createdAt: string })[];
}
export interface PortfolioData {
holdings: PortfolioHolding[];
}
+54
View File
@@ -0,0 +1,54 @@
// ── Fastify request body schemas ──────────────────────────────────────────
// Fastify validates incoming request bodies against these JSON Schemas before
// the handler runs. If validation fails it replies 400 automatically.
// One schema per route that has a body; GET routes need no schema.
import type { FastifySchema } from 'fastify';
export const screenSchema: FastifySchema = {
body: {
type: 'object',
required: ['tickers'],
properties: {
tickers: { type: 'array', items: { type: 'string' }, minItems: 1, maxItems: 50 },
},
},
};
export const analyzeSchema: FastifySchema = {
body: {
type: 'object',
required: ['tickers'],
properties: {
tickers: { type: 'array', items: { type: 'string' }, minItems: 1, maxItems: 50 },
},
},
};
export const holdingSchema: FastifySchema = {
body: {
type: 'object',
required: ['ticker', 'shares'],
properties: {
ticker: { type: 'string', minLength: 1, maxLength: 10 },
shares: { type: 'number', exclusiveMinimum: 0 },
costBasis: { type: 'number', minimum: 0 },
type: { type: 'string', enum: ['stock', 'etf', 'bond', 'crypto'] },
source: { type: 'string' },
},
},
};
export const callSchema: FastifySchema = {
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 },
},
},
};
@@ -0,0 +1,33 @@
// ── Scorer internal metric shapes ──────────────────────────────────────────
export type NumVal = number | null;
export interface SanitizedMetrics {
debtToEquity: NumVal;
quickRatio: NumVal;
peRatio: NumVal;
pegRatio: NumVal;
priceToBook: NumVal;
netProfitMargin: NumVal;
operatingMargin: NumVal;
returnOnEquity: NumVal;
revenueGrowth: NumVal;
fcfYield: NumVal;
dividendYield: NumVal;
pFFO: NumVal;
beta: NumVal;
week52Position: NumVal;
// Expert features
week52Change: NumVal; // % total return over last 52 weeks
week52FromHigh: NumVal; // % below 52-week high (negative = down from high)
analystRating: NumVal; // Yahoo scale: 1=Strong Buy … 5=Strong Sell
analystUpside: NumVal; // % price upside to consensus analyst target
dcfMarginOfSafety: NumVal; // % undervaluation vs DCF intrinsic value
}
export interface SanitizedBondMetrics {
ytm: number;
duration: number;
creditRating: string;
creditRatingNumeric: number;
}
@@ -0,0 +1,93 @@
// ── Services configuration and result shapes ──────────────────────────────
import type { Logger } from './logger.model';
// ── BenchmarkProvider ───────────────────────────────────────────────────────
export interface BenchmarkProviderOptions {
logger?: Logger;
}
// ── MarketRegime ──────────────────────────────────────────────────────────
export interface InflatedOverrides {
gates: Record<string, number>;
thresholds: Record<string, number>;
}
// ── PortfolioAdvisor ────────────────────────────────────────────────────────
export interface PositionCalc {
totalCost: string;
marketValue: string | null;
gainLossPct: string | null;
}
export interface AdviceOutput {
action: string;
reason: string;
}
// ── ScreenerEngine ────────────────────────────────────────────────────────
export interface ErrorResult {
isError: true;
ticker: string;
message: string;
}
// ── CatalystAnalyst ────────────────────────────────────────────────────────
export interface Headline {
title: string;
publisher?: string;
}
export interface Story {
title: string;
link: string;
source: string;
tickers: string[];
}
export interface CatalystResult {
tickers: string[];
tickerFrequency: Record<string, number>;
stories: Story[];
}
// ── DataMapper ─────────────────────────────────────────────────────────────
export interface MappedData {
type: string;
ticker: string;
[key: string]: unknown;
}
// ── PersonalFinanceAnalyzer ────────────────────────────────────────────────
export interface CategoryBreakdown {
category: string;
amount: number;
pct: string;
}
export interface FinanceAnalysis {
netWorth: number;
totalAssets: number;
totalLiabilities: number;
totalCash: number;
totalInvestments: number;
cashPct: string;
investPct: string;
totalIncome: number;
totalSpend: number;
savingsRate: string | null;
categoryBreakdown: CategoryBreakdown[];
accounts: import('./finance.model').SimpleFINAccount[];
}
// ── RuleMerger ─────────────────────────────────────────────────────────────
export interface RuleSet {
gates: Record<string, number>;
weights: Record<string, number>;
thresholds: Record<string, number>;
}
// ── ScreenerEngine ────────────────────────────────────────────────────────
export interface ScreenerEngineOptions {
logger?: Logger;
}
+15
View File
@@ -0,0 +1,15 @@
/**
* Split an array into smaller chunks of specified size.
* @param array The array to split
* @param size The size of each chunk
* @returns Array of chunks
* @example chunkArray([1,2,3,4,5], 2) → [[1,2], [3,4], [5]]
*/
export const chunkArray = <T>(array: T[], size: number): T[][] => {
const chunkCount = Math.ceil(array.length / size);
return Array.from({ length: chunkCount }, (_, index) => {
const start = index * size;
const end = start + size;
return array.slice(start, end);
});
};
@@ -0,0 +1,55 @@
import * as queries from '../db/queries.constant';
export class QueryBuilder {
readonly sql: string;
readonly queryParams: unknown[];
/**
* Create a QueryBuilder from a query constant path.
*
* @param queryPath Path to query in queries.constant.ts (e.g., 'MARKET_CALLS_QUERIES.SELECT_ALL')
* @param params Parameters to bind (? placeholders in SQL)
*/
constructor(queryPath: string, params: unknown[] = []) {
this.sql = this.lookupQuery(queryPath);
this.queryParams = params;
// Validate parameter count matches placeholders
const placeholderCount = (this.sql.match(/\?/g) || []).length;
if (this.queryParams.length !== placeholderCount) {
throw new Error(
`Parameter mismatch for query "${queryPath}": expected ${placeholderCount}, got ${this.queryParams.length}`,
);
}
}
/**
* Look up a query from queries.constant.ts.
* Supports nested paths like "MARKET_CALLS_QUERIES.SELECT_ALL".
*
* @param queryPath Path to query (e.g., 'MARKET_CALLS_QUERIES.SELECT_ALL')
* @returns The SQL query string
* @throws Error if query not found
*/
private lookupQuery(queryPath: string): string {
const parts = queryPath.split('.');
// Navigate through the nested objects
let current: any = queries;
for (const part of parts) {
if (!(part in current)) {
throw new Error(
`Query not found: "${queryPath}". Make sure it exists in queries.constant.ts`,
);
}
current = current[part];
}
if (typeof current !== 'string') {
throw new Error(`Invalid query: "${queryPath}" must be a string, got ${typeof current}`);
}
// Clean up the SQL (remove extra whitespace)
return current.trim();
}
}
+15
View File
@@ -0,0 +1,15 @@
import type { Logger } from '../types';
/**
* Shared server-side logger utilities.
*
* noopLogger — silent logger for use in API server context where stdout
* output from screener/analyst classes would pollute the request log.
* Pass as { logger: noopLogger } to ScreenerEngine, BenchmarkProvider,
* CatalystAnalyst, SimpleFINClient, LLMAnalyst.
*/
export const noopLogger: Logger = {
write: () => {},
log: () => {},
warn: () => {},
};
+142
View File
@@ -0,0 +1,142 @@
/**
* Sanitize a ticker symbol.
* - Converts to uppercase
* - Trims whitespace
* - Validates non-empty
*
* @param ticker The ticker symbol (e.g. "aapl", " MSFT ", "BRK.B")
* @returns Normalized ticker (e.g. "AAPL", "MSFT", "BRK.B")
* @throws Error if ticker is empty or invalid
*/
export function sanitizeTicker(ticker: string): string {
if (!ticker || typeof ticker !== 'string') {
throw new Error('Invalid ticker: must be a non-empty string');
}
const normalized = ticker.trim().toUpperCase();
if (!normalized) {
throw new Error('Invalid ticker: cannot be empty or whitespace');
}
// Optional: validate ticker format (alphanumeric + dots/hyphens)
if (!/^[A-Z0-9-.]+$/.test(normalized)) {
throw new Error(`Invalid ticker format: ${normalized}`);
}
return normalized;
}
/**
* Sanitize an array of tickers.
*
* @param tickers Array of ticker symbols
* @returns Array of normalized tickers
* @throws Error if any ticker is invalid
*/
export function sanitizeTickers(tickers: unknown): string[] {
if (!Array.isArray(tickers)) {
throw new Error('Invalid tickers: must be an array');
}
if (tickers.length === 0) {
throw new Error('Invalid tickers: array cannot be empty');
}
return tickers.map((t) => {
if (typeof t !== 'string') {
throw new Error(`Invalid ticker in array: ${t} (expected string)`);
}
return sanitizeTicker(t);
});
}
/**
* Sanitize a string field.
* - Trims whitespace
* - Validates non-empty
* - Optional: enforces max length
*
* @param value The string value
* @param fieldName Name of the field (for error messages)
* @param maxLength Maximum allowed length (optional)
* @returns Trimmed string
* @throws Error if value is invalid
*/
export function sanitizeString(value: unknown, fieldName: string, maxLength?: number): string {
if (typeof value !== 'string') {
throw new Error(`Invalid ${fieldName}: must be a string`);
}
const trimmed = value.trim();
if (!trimmed) {
throw new Error(`Invalid ${fieldName}: cannot be empty or whitespace`);
}
if (maxLength && trimmed.length > maxLength) {
throw new Error(`Invalid ${fieldName}: exceeds max length of ${maxLength} characters`);
}
return trimmed;
}
/**
* Sanitize a number field.
* - Validates it's a number
* - Optional: enforces min/max bounds
*
* @param value The numeric value
* @param fieldName Name of the field (for error messages)
* @param min Minimum allowed value (optional)
* @param max Maximum allowed value (optional)
* @returns The validated number
* @throws Error if value is invalid
*/
export function sanitizeNumber(
value: unknown,
fieldName: string,
options?: { min?: number; max?: number },
): number {
const num = typeof value === 'number' ? value : Number(value);
if (isNaN(num)) {
throw new Error(`Invalid ${fieldName}: must be a valid number`);
}
if (options?.min !== undefined && num < options.min) {
throw new Error(`Invalid ${fieldName}: must be at least ${options.min}`);
}
if (options?.max !== undefined && num > options.max) {
throw new Error(`Invalid ${fieldName}: must be at most ${options.max}`);
}
return num;
}
/**
* Sanitize an ISO date string.
* - Validates it's a valid ISO date
* - Converts to string format YYYY-MM-DD
*
* @param value The date value (ISO string or Date)
* @param fieldName Name of the field (for error messages)
* @returns Date as YYYY-MM-DD string
* @throws Error if date is invalid
*/
export function sanitizeDate(value: unknown, fieldName: string): string {
let date: Date | null = null;
if (typeof value === 'string') {
date = new Date(value);
} else if (value instanceof Date) {
date = value;
}
if (!date || isNaN(date.getTime())) {
throw new Error(`Invalid ${fieldName}: must be a valid date`);
}
return date.toISOString().slice(0, 10); // YYYY-MM-DD
}