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:
+34
-24
@@ -1,31 +1,35 @@
|
||||
import Fastify, { type FastifyRequest, type FastifyReply } from 'fastify';
|
||||
import cors from '@fastify/cors';
|
||||
import rateLimit from '@fastify/rate-limit';
|
||||
import { ScreenerController } from './controllers/screener.controller';
|
||||
import { FinanceController } from './controllers/finance.controller';
|
||||
import { CallsController } from './controllers/calls.controller';
|
||||
import { AnalyzeController } from './controllers/analyze.controller';
|
||||
import { ScreenerEngine } from './services/ScreenerEngine';
|
||||
import { BenchmarkProvider } from './services/BenchmarkProvider';
|
||||
import { PortfolioAdvisor } from './services/PortfolioAdvisor';
|
||||
import { CalendarService } from './services/CalendarService';
|
||||
import { LLMAnalyst } from './services/LLMAnalyst';
|
||||
import { CatalystAnalyst } from './services/CatalystAnalyst';
|
||||
import { YahooFinanceClient } from './clients/YahooFinanceClient';
|
||||
import { MarketCallRepository } from './repositories/MarketCallRepository';
|
||||
import { PortfolioRepository } from './repositories/PortfolioRepository';
|
||||
import { createDb } from './db/index';
|
||||
import { noopLogger } from './utils/logger';
|
||||
|
||||
// Domain imports
|
||||
import { ScreenerController, ScreenerEngine, AnalyzeController } from './domains/screener';
|
||||
import { FinanceController, PortfolioAdvisor } from './domains/portfolio';
|
||||
import { CallsController, CalendarService } from './domains/calls';
|
||||
|
||||
// Shared infrastructure
|
||||
import {
|
||||
YahooFinanceClient,
|
||||
BenchmarkProvider,
|
||||
CatalystCache,
|
||||
LLMAnalyst,
|
||||
MarketCallRepository,
|
||||
PortfolioRepository,
|
||||
createDb,
|
||||
DatabaseConnection,
|
||||
QueryAudit,
|
||||
noopLogger,
|
||||
} from './domains/shared';
|
||||
|
||||
interface BuildAppOptions {
|
||||
logger?: boolean;
|
||||
}
|
||||
|
||||
// ── Adding a new domain ───────────────────────────────────────────────────
|
||||
// 1. server/types/<domain>.model.ts — define request/response shapes
|
||||
// 2. server/services/<Domain>.ts — business logic
|
||||
// 3. server/controllers/<domain>.controller.ts — HTTP wiring (class + register)
|
||||
// 4. Register: new <Domain>Controller(...).register(app) ← add below
|
||||
// ── Adding a new domain ───────────────────────────────────────────────
|
||||
// 1. Create: server/domains/<domain>/ directory structure
|
||||
// 2. Move controllers, services, types to the domain
|
||||
// 3. Create barrel: server/domains/<domain>/index.ts
|
||||
// 4. Import from domain and register controller below
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
export async function buildApp({ logger = true }: BuildAppOptions = {}) {
|
||||
const app = Fastify({ logger });
|
||||
@@ -54,19 +58,25 @@ export async function buildApp({ logger = true }: BuildAppOptions = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
const db = createDb();
|
||||
// Database setup
|
||||
const rawDb = createDb();
|
||||
const audit = new QueryAudit();
|
||||
const db = new DatabaseConnection(rawDb, { audit, logSlowQueries: 100 });
|
||||
|
||||
// Services and clients
|
||||
const yahoo = new YahooFinanceClient();
|
||||
const benchmark = new BenchmarkProvider(yahoo, { logger: noopLogger });
|
||||
const engine = new ScreenerEngine(yahoo, benchmark, { logger: noopLogger });
|
||||
const advisor = new PortfolioAdvisor(yahoo);
|
||||
const calSvc = new CalendarService(yahoo);
|
||||
const llm = new LLMAnalyst({ logger: noopLogger });
|
||||
const catalyst = new CatalystAnalyst({ logger: noopLogger });
|
||||
const catalystCache = new CatalystCache({ logger: noopLogger }); // Singleton, cached for 15m
|
||||
|
||||
new ScreenerController(engine).register(app);
|
||||
// Register controllers
|
||||
new ScreenerController(engine, catalystCache).register(app);
|
||||
new FinanceController(engine, new PortfolioRepository(db), advisor).register(app);
|
||||
new CallsController(new MarketCallRepository(db), engine, calSvc).register(app);
|
||||
new AnalyzeController(catalyst, llm).register(app);
|
||||
new AnalyzeController(catalystCache, llm).register(app);
|
||||
|
||||
app.get('/health', async () => ({ status: 'ok' }));
|
||||
|
||||
|
||||
@@ -1,262 +0,0 @@
|
||||
/**
|
||||
* Type-safe query builder for SQLite.
|
||||
*
|
||||
* Prevents SQL injection by:
|
||||
* 1. Enforcing parameterized queries (? placeholders)
|
||||
* 2. Building SQL dynamically only for schema-safe values (table/column names are validated against a whitelist)
|
||||
* 3. Keeping all user input in parameter arrays, never in the SQL string
|
||||
*
|
||||
* Usage:
|
||||
* const qb = new QueryBuilder('holdings');
|
||||
* qb.select(['ticker', 'shares']).where('type = ?', ['stock']).orderBy('ticker');
|
||||
* const stmt = db.prepare(qb.build());
|
||||
* stmt.all(...qb.params());
|
||||
*/
|
||||
|
||||
type QueryType = 'SELECT' | 'INSERT' | 'UPDATE' | 'DELETE';
|
||||
|
||||
interface WhereClause {
|
||||
expression: string;
|
||||
params: unknown[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Whitelist of safe column and table names.
|
||||
* Prevents injection via column/table names.
|
||||
*/
|
||||
const SAFE_COLUMNS = new Set([
|
||||
// holdings table
|
||||
'ticker',
|
||||
'shares',
|
||||
'cost_basis',
|
||||
'type',
|
||||
'source',
|
||||
// market_calls table
|
||||
'id',
|
||||
'title',
|
||||
'quarter',
|
||||
'date',
|
||||
'thesis',
|
||||
'tickers',
|
||||
'snapshot',
|
||||
'created_at',
|
||||
]);
|
||||
|
||||
const SAFE_TABLES = new Set(['holdings', 'market_calls']);
|
||||
|
||||
/**
|
||||
* Validates a column name against the whitelist.
|
||||
* Throws if not in whitelist to prevent column name injection.
|
||||
*/
|
||||
function validateColumn(col: string): void {
|
||||
if (!SAFE_COLUMNS.has(col.toLowerCase())) {
|
||||
throw new Error(`Unsafe column name: ${col}. Only whitelisted columns allowed.`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a table name against the whitelist.
|
||||
* Throws if not in whitelist to prevent table name injection.
|
||||
*/
|
||||
function validateTable(table: string): void {
|
||||
if (!SAFE_TABLES.has(table.toLowerCase())) {
|
||||
throw new Error(`Unsafe table name: ${table}. Only whitelisted tables allowed.`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* QueryBuilder — type-safe, injectable-resistant query construction.
|
||||
*/
|
||||
export class QueryBuilder {
|
||||
private type: QueryType | null = null;
|
||||
private table: string;
|
||||
private selectCols: string[] = [];
|
||||
private whereClausesList: WhereClause[] = [];
|
||||
private orderByCols: { col: string; direction: 'ASC' | 'DESC' }[] = [];
|
||||
private limitVal: number | null = null;
|
||||
private offsetVal: number | null = null;
|
||||
|
||||
// For INSERT
|
||||
private insertCols: string[] = [];
|
||||
private insertParamCount = 0;
|
||||
|
||||
// For UPDATE
|
||||
private updateAssignments: { col: string; paramIndex: number }[] = [];
|
||||
|
||||
private allParams: unknown[] = [];
|
||||
|
||||
constructor(table: string) {
|
||||
validateTable(table);
|
||||
this.table = table;
|
||||
}
|
||||
|
||||
/**
|
||||
* SELECT query builder.
|
||||
* Columns are validated against whitelist.
|
||||
*/
|
||||
select(columns: string[]): this {
|
||||
if (this.type !== null) throw new Error('Query type already set');
|
||||
this.type = 'SELECT';
|
||||
for (const col of columns) {
|
||||
validateColumn(col);
|
||||
this.selectCols.push(col);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* INSERT query builder.
|
||||
* Columns are validated; values go into parameter array.
|
||||
*/
|
||||
insert(columns: string[], values: unknown[]): this {
|
||||
if (this.type !== null) throw new Error('Query type already set');
|
||||
if (columns.length !== values.length) {
|
||||
throw new Error('Column/value count mismatch');
|
||||
}
|
||||
this.type = 'INSERT';
|
||||
for (const col of columns) {
|
||||
validateColumn(col);
|
||||
this.insertCols.push(col);
|
||||
}
|
||||
this.insertParamCount = values.length;
|
||||
this.allParams.push(...values);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* UPDATE query builder.
|
||||
* Column names validated; values go into parameter array.
|
||||
*/
|
||||
update(updates: Record<string, unknown>): this {
|
||||
if (this.type !== null) throw new Error('Query type already set');
|
||||
this.type = 'UPDATE';
|
||||
let paramIndex = 0;
|
||||
for (const [col, value] of Object.entries(updates)) {
|
||||
validateColumn(col);
|
||||
this.updateAssignments.push({ col, paramIndex });
|
||||
this.allParams.push(value);
|
||||
paramIndex++;
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE query builder.
|
||||
*/
|
||||
delete(): this {
|
||||
if (this.type !== null) throw new Error('Query type already set');
|
||||
this.type = 'DELETE';
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* WHERE clause(s).
|
||||
* Expression is NOT validated (it should be safe from app logic);
|
||||
* params are added to the parameter array.
|
||||
*
|
||||
* Example: .where('type = ? AND shares > ?', ['stock', 10])
|
||||
*/
|
||||
where(expression: string, params: unknown[] = []): this {
|
||||
this.whereClausesList.push({ expression, params });
|
||||
this.allParams.push(...params);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* ORDER BY clause.
|
||||
* Column names are validated.
|
||||
*/
|
||||
orderBy(column: string, direction: 'ASC' | 'DESC' = 'ASC'): this {
|
||||
validateColumn(column);
|
||||
this.orderByCols.push({ col: column, direction });
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* LIMIT clause.
|
||||
*/
|
||||
limit(count: number): this {
|
||||
if (count < 0) throw new Error('LIMIT must be non-negative');
|
||||
this.limitVal = count;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* OFFSET clause.
|
||||
*/
|
||||
offset(count: number): this {
|
||||
if (count < 0) throw new Error('OFFSET must be non-negative');
|
||||
this.offsetVal = count;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the final SQL string.
|
||||
* The query is built dynamically but with no injection points:
|
||||
* - Table/column names from whitelist only
|
||||
* - All user input in the parameter array
|
||||
*/
|
||||
build(): string {
|
||||
if (this.type === null) throw new Error('Query type not set');
|
||||
|
||||
let sql = '';
|
||||
|
||||
switch (this.type) {
|
||||
case 'SELECT': {
|
||||
const cols = this.selectCols.length > 0 ? this.selectCols.join(', ') : '*';
|
||||
sql = `SELECT ${cols} FROM ${this.table}`;
|
||||
break;
|
||||
}
|
||||
|
||||
case 'INSERT': {
|
||||
const cols = this.insertCols.join(', ');
|
||||
const placeholders = Array(this.insertParamCount).fill('?').join(', ');
|
||||
sql = `INSERT INTO ${this.table} (${cols}) VALUES (${placeholders})`;
|
||||
break;
|
||||
}
|
||||
|
||||
case 'UPDATE': {
|
||||
const assignments = this.updateAssignments.map((a) => `${a.col} = ?`).join(', ');
|
||||
sql = `UPDATE ${this.table} SET ${assignments}`;
|
||||
break;
|
||||
}
|
||||
|
||||
case 'DELETE': {
|
||||
sql = `DELETE FROM ${this.table}`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Add WHERE clause(s)
|
||||
if (this.whereClausesList.length > 0) {
|
||||
const whereExpressions = this.whereClausesList.map((w) => `(${w.expression})`).join(' AND ');
|
||||
sql += ` WHERE ${whereExpressions}`;
|
||||
}
|
||||
|
||||
// Add ORDER BY
|
||||
if (this.orderByCols.length > 0) {
|
||||
const orderExpressions = this.orderByCols.map((o) => `${o.col} ${o.direction}`).join(', ');
|
||||
sql += ` ORDER BY ${orderExpressions}`;
|
||||
}
|
||||
|
||||
// Add LIMIT
|
||||
if (this.limitVal !== null) {
|
||||
sql += ` LIMIT ${this.limitVal}`;
|
||||
}
|
||||
|
||||
// Add OFFSET
|
||||
if (this.offsetVal !== null) {
|
||||
sql += ` OFFSET ${this.offsetVal}`;
|
||||
}
|
||||
|
||||
return sql;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the accumulated parameter array.
|
||||
* This is what gets passed to db.prepare(...).run(...params).
|
||||
*/
|
||||
params(): unknown[] {
|
||||
return this.allParams;
|
||||
}
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
/**
|
||||
* SQLite database initialisation.
|
||||
*
|
||||
* Call createDb() once in server/app.ts and pass the instance to repositories.
|
||||
* Uses WAL journal mode for safe concurrent reads alongside the single writer.
|
||||
*
|
||||
* Migration: if the legacy JSON files (portfolio.json / market-calls.json) exist
|
||||
* they are imported once into SQLite, then renamed to *.json.migrated so the
|
||||
* import never runs again.
|
||||
*
|
||||
* SECURITY:
|
||||
* - All queries use parameterized statements (QueryBuilder + DatabaseConnection)
|
||||
* - No SQL injection possible via table/column/parameter names
|
||||
* - Audit trail tracks all mutations for compliance
|
||||
* - Statement caching improves performance
|
||||
*/
|
||||
|
||||
import BetterSqlite3 from 'better-sqlite3';
|
||||
import { existsSync, readFileSync, renameSync } from 'fs';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { DatabaseConnection } from './DatabaseConnection.js';
|
||||
import { QueryBuilder } from './QueryBuilder.js';
|
||||
import { QueryAudit } from './QueryAudit.js';
|
||||
|
||||
export type Db = BetterSqlite3.Database;
|
||||
export { DatabaseConnection, QueryBuilder, QueryAudit };
|
||||
|
||||
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
|
||||
);
|
||||
`;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// ── One-time JSON → SQLite migration ─────────────────────────────────────────
|
||||
|
||||
function migrateJson(db: Db): void {
|
||||
migratePortfolio(db);
|
||||
migrateCalls(db);
|
||||
}
|
||||
|
||||
function migratePortfolio(db: Db): void {
|
||||
const src = './portfolio.json';
|
||||
if (!existsSync(src)) return;
|
||||
try {
|
||||
const { holdings } = JSON.parse(readFileSync(src, 'utf8')) as {
|
||||
holdings: Array<{
|
||||
ticker: string;
|
||||
shares: number;
|
||||
costBasis: number;
|
||||
type: string;
|
||||
source: string;
|
||||
}>;
|
||||
};
|
||||
const insert = db.prepare(
|
||||
'INSERT OR IGNORE INTO holdings (ticker, shares, cost_basis, type, source) VALUES (?,?,?,?,?)',
|
||||
);
|
||||
const insertAll = db.transaction((rows: typeof holdings) => {
|
||||
for (const h of rows) {
|
||||
insert.run(
|
||||
h.ticker.toUpperCase(),
|
||||
h.shares,
|
||||
h.costBasis ?? 0,
|
||||
h.type ?? 'stock',
|
||||
h.source ?? 'Manual',
|
||||
);
|
||||
}
|
||||
});
|
||||
insertAll(holdings);
|
||||
renameSync(src, src + '.migrated');
|
||||
} catch {
|
||||
// non-fatal — leave file in place if migration fails
|
||||
}
|
||||
}
|
||||
|
||||
function migrateCalls(db: Db): void {
|
||||
const src = './market-calls.json';
|
||||
if (!existsSync(src)) return;
|
||||
try {
|
||||
const { calls } = JSON.parse(readFileSync(src, 'utf8')) as {
|
||||
calls: Array<{
|
||||
id?: string;
|
||||
title: string;
|
||||
quarter: string;
|
||||
date: string;
|
||||
thesis: string;
|
||||
tickers: string[];
|
||||
snapshot: Record<string, unknown>;
|
||||
createdAt: string;
|
||||
}>;
|
||||
};
|
||||
const insert = db.prepare(
|
||||
'INSERT OR IGNORE INTO market_calls (id, title, quarter, date, thesis, tickers, snapshot, created_at) VALUES (?,?,?,?,?,?,?,?)',
|
||||
);
|
||||
const insertAll = db.transaction((rows: typeof calls) => {
|
||||
for (const c of rows) {
|
||||
insert.run(
|
||||
c.id ?? randomUUID(),
|
||||
c.title,
|
||||
c.quarter,
|
||||
c.date,
|
||||
c.thesis,
|
||||
JSON.stringify(c.tickers ?? []),
|
||||
JSON.stringify(c.snapshot ?? {}),
|
||||
c.createdAt,
|
||||
);
|
||||
}
|
||||
});
|
||||
insertAll(calls);
|
||||
renameSync(src, src + '.migrated');
|
||||
} catch {
|
||||
// non-fatal
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { YahooFinanceClient } from '../clients/YahooFinanceClient';
|
||||
import { chunkArray } from '../utils/Chunker';
|
||||
import type { CalendarEvent } from '../types';
|
||||
import { YahooFinanceClient, chunkArray } from '../../domains/shared';
|
||||
import type { CalendarEvent } from '../../domains/shared';
|
||||
|
||||
export class CalendarService {
|
||||
constructor(private readonly yahoo: YahooFinanceClient) {}
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { MarketCallRepository } from '../repositories/MarketCallRepository';
|
||||
import { CalendarService, ScreenerEngine } from '../services/index';
|
||||
import type { SnapshotEntry } from '../types';
|
||||
import { callSchema } from '../types/schemas';
|
||||
import { MarketCallRepository } from '../../domains/shared';
|
||||
import { CalendarService } from './CalendarService';
|
||||
import { ScreenerEngine } from '../screener';
|
||||
import type { SnapshotEntry } from '../../domains/shared';
|
||||
import { callSchema } from '../../domains/shared/types/schemas';
|
||||
|
||||
export class CallsController {
|
||||
constructor(
|
||||
@@ -0,0 +1,3 @@
|
||||
// Calls domain — market call tracking and calendar
|
||||
export { CallsController } from './calls.controller';
|
||||
export { CalendarService } from './CalendarService';
|
||||
+5
-6
@@ -1,10 +1,9 @@
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { SimpleFINClient } from '../clients/SimpleFINClient';
|
||||
import { PortfolioRepository } from '../repositories/PortfolioRepository';
|
||||
import { PersonalFinanceAnalyzer, PortfolioAdvisor, ScreenerEngine } from '../services/index';
|
||||
import type { PortfolioHolding } from '../types';
|
||||
import { holdingSchema } from '../types/schemas';
|
||||
import { noopLogger } from '../utils/logger';
|
||||
import { SimpleFINClient, PortfolioRepository, noopLogger } from '../../domains/shared';
|
||||
import { PersonalFinanceAnalyzer, ScreenerEngine } from '../screener';
|
||||
import { PortfolioAdvisor } from '../portfolio/PortfolioAdvisor';
|
||||
import type { PortfolioHolding } from '../../domains/shared';
|
||||
import { holdingSchema } from '../../domains/shared/types/schemas';
|
||||
|
||||
export class FinanceController {
|
||||
constructor(
|
||||
@@ -0,0 +1,2 @@
|
||||
// Finance domain — portfolio metrics and reporting
|
||||
export { FinanceController } from './finance.controller';
|
||||
@@ -1,5 +1,4 @@
|
||||
import { SIGNAL } from '../config/constants';
|
||||
import { YahooFinanceClient } from '../clients/YahooFinanceClient';
|
||||
import { SIGNAL, YahooFinanceClient } from '../../domains/shared';
|
||||
import type {
|
||||
PortfolioHolding,
|
||||
Signal,
|
||||
@@ -8,7 +7,7 @@ import type {
|
||||
AdviceRow,
|
||||
PositionCalc,
|
||||
AdviceOutput,
|
||||
} from '../types';
|
||||
} from '../../domains/shared';
|
||||
|
||||
export class PortfolioAdvisor {
|
||||
constructor(private readonly client: YahooFinanceClient) {}
|
||||
@@ -0,0 +1,71 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
// Portfolio domain — holdings management and advice
|
||||
export { FinanceController } from './finance.controller';
|
||||
export { PortfolioAdvisor } from './PortfolioAdvisor';
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import type { CategoryBreakdown, FinanceAnalysis, SimpleFINAccount } from '../types';
|
||||
import type { CategoryBreakdown, FinanceAnalysis, SimpleFINAccount } from '../../domains/shared';
|
||||
|
||||
export class PersonalFinanceAnalyzer {
|
||||
analyze(accounts: SimpleFINAccount[]): FinanceAnalysis {
|
||||
@@ -1,15 +1,20 @@
|
||||
import { YahooFinanceClient } from '../clients/YahooFinanceClient';
|
||||
import { BenchmarkProvider } from './BenchmarkProvider';
|
||||
import { DataMapper } from './DataMapper';
|
||||
import { chunkArray } from '../utils/Chunker';
|
||||
import { RuleMerger } from './RuleMerger';
|
||||
import { Stock } from '../models/Stock';
|
||||
import { Etf } from '../models/Etf';
|
||||
import { Bond } from '../models/Bond';
|
||||
import { StockScorer } from '../scorers/StockScorer';
|
||||
import { EtfScorer } from '../scorers/EtfScorer';
|
||||
import { BondScorer } from '../scorers/BondScorer';
|
||||
import { SIGNAL, SIGNAL_ORDER, SCORE_MODE, ASSET_TYPE } from '../config/constants';
|
||||
import {
|
||||
YahooFinanceClient,
|
||||
BenchmarkProvider,
|
||||
chunkArray,
|
||||
Stock,
|
||||
Etf,
|
||||
Bond,
|
||||
SIGNAL,
|
||||
SIGNAL_ORDER,
|
||||
SCORE_MODE,
|
||||
ASSET_TYPE,
|
||||
} from '../../domains/shared';
|
||||
import { DataMapper } from './transform/DataMapper';
|
||||
import { RuleMerger } from './transform/RuleMerger';
|
||||
import { StockScorer } from './scorers/StockScorer';
|
||||
import { EtfScorer } from './scorers/EtfScorer';
|
||||
import { BondScorer } from './scorers/BondScorer';
|
||||
import type {
|
||||
Logger,
|
||||
MarketContext,
|
||||
@@ -23,7 +28,7 @@ import type {
|
||||
StockData,
|
||||
EtfData,
|
||||
BondData,
|
||||
} from '../types';
|
||||
} from '../../domains/shared';
|
||||
|
||||
export class ScreenerEngine {
|
||||
private static readonly BATCH_SIZE = 5;
|
||||
@@ -36,6 +41,7 @@ export class ScreenerEngine {
|
||||
private readonly benchmarkProvider: BenchmarkProvider,
|
||||
{ logger }: ScreenerEngineOptions = {},
|
||||
) {
|
||||
// eslint-disable-next-line no-console
|
||||
this.logger = logger ?? {
|
||||
write: (msg: string) => process.stdout.write(msg),
|
||||
log: (...args: unknown[]) => console.log(...args),
|
||||
+11
-6
@@ -1,13 +1,18 @@
|
||||
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
||||
import type { LLMAnalyst } from '../services/LLMAnalyst';
|
||||
import { CatalystAnalyst } from '../services/CatalystAnalyst';
|
||||
import { analyzeSchema } from '../types/schemas';
|
||||
import type { LLMAnalyst } from '../../domains/shared';
|
||||
import { CatalystCache, CatalystAnalyst } from '../../domains/shared';
|
||||
import { analyzeSchema } from '../../domains/shared/types/schemas';
|
||||
|
||||
export class AnalyzeController {
|
||||
private readonly catalystAnalyst: CatalystAnalyst;
|
||||
|
||||
constructor(
|
||||
private readonly catalyst: CatalystAnalyst,
|
||||
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(
|
||||
@@ -24,7 +29,7 @@ export class AnalyzeController {
|
||||
|
||||
const tickers = (req.body as { tickers: string[] }).tickers.map((t) => t.toUpperCase());
|
||||
|
||||
const stories = await this.catalyst.fetchStoriesForTickers(tickers);
|
||||
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);
|
||||
@@ -0,0 +1,18 @@
|
||||
// Screener domain — stock/ETF/bond filtering and scoring
|
||||
|
||||
// Controllers
|
||||
export { ScreenerController } from './screener.controller';
|
||||
export { AnalyzeController } from './analyze.controller';
|
||||
|
||||
// Services
|
||||
export { ScreenerEngine } from './ScreenerEngine';
|
||||
export { PersonalFinanceAnalyzer } from './PersonalFinanceAnalyzer';
|
||||
|
||||
// Scorers
|
||||
export { StockScorer } from './scorers/StockScorer';
|
||||
export { EtfScorer } from './scorers/EtfScorer';
|
||||
export { BondScorer } from './scorers/BondScorer';
|
||||
|
||||
// Transform utilities
|
||||
export { DataMapper } from './transform/DataMapper';
|
||||
export { RuleMerger } from './transform/RuleMerger';
|
||||
@@ -1,4 +1,9 @@
|
||||
import type { BondMetrics, MarketContext, ScoreResult, SanitizedBondMetrics } from '../types';
|
||||
import type {
|
||||
BondMetrics,
|
||||
MarketContext,
|
||||
ScoreResult,
|
||||
SanitizedBondMetrics,
|
||||
} from '../../../domains/shared';
|
||||
|
||||
export class BondScorer {
|
||||
static score(
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { EtfMetrics, ScoreResult } from '../types';
|
||||
import type { EtfMetrics, ScoreResult } from '../../../domains/shared';
|
||||
|
||||
export class EtfScorer {
|
||||
static score(
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { NumVal, SanitizedMetrics, ScoreResult, StockMetrics } from '../types';
|
||||
import type { NumVal, SanitizedMetrics, ScoreResult, StockMetrics } from '../../../domains/shared';
|
||||
|
||||
export class StockScorer {
|
||||
private static n(v: unknown): NumVal {
|
||||
+9
-7
@@ -1,11 +1,14 @@
|
||||
import type { FastifyInstance, FastifyRequest } from 'fastify';
|
||||
import { ScreenerEngine, CatalystAnalyst } from '../services/index';
|
||||
import { noopLogger } from '../utils/logger';
|
||||
import type { LiveAssetResult } from '../types';
|
||||
import { screenSchema } from '../types/schemas';
|
||||
import { ScreenerEngine } from './ScreenerEngine';
|
||||
import { CatalystCache } from '../../domains/shared';
|
||||
import type { LiveAssetResult } from '../../domains/shared';
|
||||
import { screenSchema } from '../../domains/shared/types/schemas';
|
||||
|
||||
export class ScreenerController {
|
||||
constructor(private readonly engine: ScreenerEngine) {}
|
||||
constructor(
|
||||
private readonly engine: ScreenerEngine,
|
||||
private readonly catalystCache: CatalystCache,
|
||||
) {}
|
||||
|
||||
register(app: FastifyInstance): void {
|
||||
app.post(
|
||||
@@ -45,8 +48,7 @@ export class ScreenerController {
|
||||
}
|
||||
|
||||
private async catalysts() {
|
||||
const catalyst = new CatalystAnalyst({ logger: noopLogger });
|
||||
const { tickers, stories } = await catalyst.run();
|
||||
const { tickers, stories } = await this.catalystCache.get();
|
||||
return { tickers, stories };
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { MappedData } from '../types';
|
||||
import type { MappedData } from '../../../domains/shared';
|
||||
|
||||
// Internal: Yahoo Finance API response shape
|
||||
type YahooSummary = Record<string, Record<string, unknown>>;
|
||||
@@ -0,0 +1,69 @@
|
||||
import { ASSET_TYPE, REGIME, SECTOR } from '../../shared';
|
||||
import type { MarketContext, AssetType, InflatedOverrides } from '../../shared';
|
||||
|
||||
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) },
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ScoringRules } from '../config/ScoringConfig';
|
||||
import { MarketRegime } from './MarketRegime';
|
||||
import { SCORE_MODE } from '../config/constants';
|
||||
import type { AssetType, MarketContext, RuleSet } from '../types';
|
||||
import { ScoringRules } from '../../../domains/shared/scoring/ScoringConfig';
|
||||
import { MarketRegime } from '../../../domains/shared/scoring/MarketRegime';
|
||||
import { SCORE_MODE } from '../../../domains/shared';
|
||||
import type { AssetType, MarketContext, RuleSet } from '../../../domains/shared';
|
||||
|
||||
export class RuleMerger {
|
||||
static getRulesForAsset(
|
||||
@@ -10,6 +10,7 @@ export class SimpleFINClient {
|
||||
|
||||
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),
|
||||
@@ -157,9 +158,11 @@ export function saveAccessUrlToEnv(accessUrl: string): void {
|
||||
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`);
|
||||
}
|
||||
}
|
||||
@@ -16,13 +16,10 @@
|
||||
*/
|
||||
|
||||
import type BetterSqlite3 from 'better-sqlite3';
|
||||
import { QueryBuilder } from './QueryBuilder';
|
||||
import { QueryAudit, AuditAction } from './QueryAudit';
|
||||
|
||||
export interface DatabaseOptions {
|
||||
audit?: QueryAudit;
|
||||
logSlowQueries?: number; // milliseconds; logs queries slower than this
|
||||
}
|
||||
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.
|
||||
@@ -44,8 +41,8 @@ export class DatabaseConnection {
|
||||
* Logs the query to the audit trail.
|
||||
*/
|
||||
all<T = Record<string, unknown>>(qb: QueryBuilder): T[] {
|
||||
const sql = qb.build();
|
||||
const params = qb.params();
|
||||
const sql = qb.sql;
|
||||
const params = qb.queryParams;
|
||||
const startMs = performance.now();
|
||||
|
||||
try {
|
||||
@@ -71,8 +68,8 @@ export class DatabaseConnection {
|
||||
* Logs the query to the audit trail.
|
||||
*/
|
||||
get<T = Record<string, unknown>>(qb: QueryBuilder): T | null {
|
||||
const sql = qb.build();
|
||||
const params = qb.params();
|
||||
const sql = qb.sql;
|
||||
const params = qb.queryParams;
|
||||
const startMs = performance.now();
|
||||
|
||||
try {
|
||||
@@ -98,8 +95,8 @@ export class DatabaseConnection {
|
||||
* Logs the query to the audit trail.
|
||||
*/
|
||||
run(qb: QueryBuilder): number {
|
||||
const sql = qb.build();
|
||||
const params = qb.params();
|
||||
const sql = qb.sql;
|
||||
const params = qb.queryParams;
|
||||
const startMs = performance.now();
|
||||
|
||||
// Determine audit action from SQL
|
||||
@@ -169,6 +166,7 @@ export class DatabaseConnection {
|
||||
* Call db.printAudit() to see the most recent 100 queries.
|
||||
*/
|
||||
printAudit(): void {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(this.audit.report());
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,8 @@
|
||||
*
|
||||
* Usage:
|
||||
* const audit = new QueryAudit();
|
||||
* audit.logQuery('SELECT * FROM holdings', [], 'READ');
|
||||
* audit.logQuery('UPDATE holdings SET shares = ? WHERE ticker = ?', [100, 'AAPL'], 'WRITE');
|
||||
* 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
|
||||
@@ -13,21 +13,7 @@
|
||||
* - Optional persistent storage for compliance
|
||||
*/
|
||||
|
||||
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;
|
||||
}
|
||||
import type { AuditAction, AuditEntry } from '../types/index';
|
||||
|
||||
/**
|
||||
* QueryAudit — in-memory audit trail with optional callbacks.
|
||||
@@ -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
|
||||
);
|
||||
`;
|
||||
@@ -1,6 +1,6 @@
|
||||
import { CREDIT_RATING_SCALE } from '../config/ScoringConfig';
|
||||
import { CREDIT_RATING_SCALE } from '../scoring/ScoringConfig';
|
||||
import { Asset } from './Asset';
|
||||
import type { BondData, BondMetrics } from '../types/models.model';
|
||||
import type { BondData, BondMetrics } from '../types/index';
|
||||
|
||||
export class Bond extends Asset {
|
||||
metrics: BondMetrics;
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
||||
import { YahooFinanceClient } from '../clients/YahooFinanceClient';
|
||||
import { YahooFinanceClient } from '../adapters/YahooFinanceClient';
|
||||
import { REGIME } from '../config/constants';
|
||||
import type { MarketContext, Logger, BenchmarkProviderOptions } from '../types';
|
||||
import type { MarketContext, Logger, BenchmarkProviderOptions } from '../types/index';
|
||||
|
||||
interface CacheFile {
|
||||
data: MarketContext;
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
import { YahooFinanceClient } from '../clients/YahooFinanceClient';
|
||||
import type { Logger, CatalystResult, Story, YahooNewsItem } from '../types';
|
||||
import { YahooFinanceClient } from '../adapters/YahooFinanceClient';
|
||||
import type { Logger, CatalystResult, Story, YahooNewsItem } from '../types/index';
|
||||
|
||||
export class CatalystAnalyst {
|
||||
private static readonly NEWS_QUERIES = [
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,15 @@
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { AnthropicClient } from '../clients/AnthropicClient';
|
||||
import type { Logger, LLMAnalysis, Story } from '../types';
|
||||
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();
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -46,7 +46,7 @@ export type {
|
||||
BondData,
|
||||
BondMetrics,
|
||||
} from './models.model';
|
||||
export type { StoreData, PortfolioData } from './repositories.model';
|
||||
export type { StoreData, PortfolioData, MarketCallRow, HoldingRow } from './repositories.model';
|
||||
export type { NumVal, SanitizedMetrics, SanitizedBondMetrics } from './scorers.model';
|
||||
export type {
|
||||
BenchmarkProviderOptions,
|
||||
@@ -63,3 +63,5 @@ export type {
|
||||
RuleSet,
|
||||
ScreenerEngineOptions,
|
||||
} from './services.model';
|
||||
export type { AuditEntry, DatabaseOptions } from './database.model';
|
||||
export { AuditAction } from './database.model';
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
import type { Db } from '../db/index';
|
||||
import type { MarketCall, CreateCallInput } from '../types';
|
||||
|
||||
interface CallRow {
|
||||
id: string;
|
||||
title: string;
|
||||
quarter: string;
|
||||
date: string;
|
||||
thesis: string;
|
||||
tickers: string; // JSON
|
||||
snapshot: string; // JSON
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export class MarketCallRepository {
|
||||
constructor(private readonly db: Db) {}
|
||||
|
||||
list(): (MarketCall & { createdAt: string })[] {
|
||||
const rows = this.db
|
||||
.prepare('SELECT * FROM market_calls ORDER BY created_at DESC')
|
||||
.all() as CallRow[];
|
||||
return rows.map(MarketCallRepository.toCall);
|
||||
}
|
||||
|
||||
get(id: string): (MarketCall & { createdAt: string }) | null {
|
||||
const row = this.db.prepare('SELECT * FROM market_calls WHERE id = ?').get(id) as
|
||||
| CallRow
|
||||
| undefined;
|
||||
return row ? MarketCallRepository.toCall(row) : null;
|
||||
}
|
||||
|
||||
create({
|
||||
title,
|
||||
quarter,
|
||||
date,
|
||||
thesis,
|
||||
tickers,
|
||||
snapshot,
|
||||
}: CreateCallInput): MarketCall & { createdAt: string } {
|
||||
const call = {
|
||||
id: randomUUID(),
|
||||
title,
|
||||
quarter,
|
||||
date: date ?? new Date().toISOString().slice(0, 10),
|
||||
thesis,
|
||||
tickers: tickers ?? [],
|
||||
snapshot: snapshot ?? {},
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO market_calls (id, title, quarter, date, thesis, tickers, snapshot, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
call.id,
|
||||
call.title,
|
||||
call.quarter,
|
||||
call.date,
|
||||
call.thesis,
|
||||
JSON.stringify(call.tickers),
|
||||
JSON.stringify(call.snapshot),
|
||||
call.createdAt,
|
||||
);
|
||||
return call as MarketCall & { createdAt: string };
|
||||
}
|
||||
|
||||
delete(id: string): boolean {
|
||||
const result = this.db.prepare('DELETE FROM market_calls WHERE id = ?').run(id);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
private static toCall(row: CallRow): 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 };
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import type { Db } from '../db/index';
|
||||
import type { PortfolioData, PortfolioHolding } from '../types';
|
||||
|
||||
interface HoldingRow {
|
||||
ticker: string;
|
||||
shares: number;
|
||||
cost_basis: number;
|
||||
type: string;
|
||||
source: string;
|
||||
}
|
||||
|
||||
export class PortfolioRepository {
|
||||
constructor(private readonly db: Db) {}
|
||||
|
||||
exists(): boolean {
|
||||
const row = this.db.prepare('SELECT COUNT(*) AS n FROM holdings').get() as { n: number };
|
||||
return row.n > 0;
|
||||
}
|
||||
|
||||
read(): PortfolioData {
|
||||
const rows = this.db.prepare('SELECT * FROM holdings ORDER BY ticker').all() as HoldingRow[];
|
||||
return { holdings: rows.map(PortfolioRepository.toHolding) };
|
||||
}
|
||||
|
||||
upsert(entry: PortfolioHolding): PortfolioHolding {
|
||||
const ticker = entry.ticker.toUpperCase().trim();
|
||||
this.db
|
||||
.prepare(
|
||||
`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`,
|
||||
)
|
||||
.run(
|
||||
ticker,
|
||||
entry.shares,
|
||||
entry.costBasis ?? 0,
|
||||
entry.type ?? 'stock',
|
||||
entry.source ?? 'Manual',
|
||||
);
|
||||
return { ...entry, ticker };
|
||||
}
|
||||
|
||||
remove(ticker: string): boolean {
|
||||
const result = this.db
|
||||
.prepare('DELETE FROM holdings WHERE ticker = ?')
|
||||
.run(ticker.toUpperCase());
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
// Barrel — re-exports every service so callers import from one path.
|
||||
export * from './BenchmarkProvider';
|
||||
export * from './CalendarService';
|
||||
export * from './CatalystAnalyst';
|
||||
export * from './DataMapper';
|
||||
export * from './LLMAnalyst';
|
||||
export * from './MarketRegime';
|
||||
export * from './PersonalFinanceAnalyzer';
|
||||
export * from './PortfolioAdvisor';
|
||||
export * from './RuleMerger';
|
||||
export * from './ScreenerEngine';
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["domains/**/*", "app.ts", "types.ts"],
|
||||
"exclude": ["node_modules", "../ui", "controllers", "services", "repositories", "clients", "models", "scorers", "config", "types", "utils", "db"]
|
||||
}
|
||||
+3
-3
@@ -1,4 +1,4 @@
|
||||
// ── Barrel re-export ──────────────────────────────────────────────────────
|
||||
// All types now live in server/types/*.model.ts — import from there directly
|
||||
// for clarity, or from here for convenience (existing imports still work).
|
||||
export type * from './types/index';
|
||||
// All types now live in server/domains/shared/types/*.model.ts
|
||||
// For convenience, re-export from here for existing imports.
|
||||
export type * from './domains/shared/types/index';
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
// ── Repository persistence shapes ────────────────────────────────────────
|
||||
|
||||
import type { MarketCall, PortfolioHolding } from './index';
|
||||
|
||||
export interface StoreData {
|
||||
calls: (MarketCall & { createdAt: string })[];
|
||||
}
|
||||
|
||||
export interface PortfolioData {
|
||||
holdings: PortfolioHolding[];
|
||||
}
|
||||
Reference in New Issue
Block a user