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:
committed by
saikiranvella
parent
c7e39c3e4e
commit
c388b6d83c
@@ -18,3 +18,8 @@ ui/build
|
|||||||
|
|
||||||
# Runtime cache
|
# Runtime cache
|
||||||
.benchmark-cache.json
|
.benchmark-cache.json
|
||||||
|
|
||||||
|
# Documentation (except CLAUDE.md)
|
||||||
|
*.md
|
||||||
|
!PHASES.md
|
||||||
|
!CLAUDE.md
|
||||||
|
|||||||
@@ -1,2 +1,11 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
. "$(dirname "$0")/_/husky.sh"
|
||||||
|
|
||||||
|
# Format all staged files with Prettier
|
||||||
|
npm run format
|
||||||
|
|
||||||
|
# Lint and fix staged files
|
||||||
npx lint-staged
|
npx lint-staged
|
||||||
|
|
||||||
|
# Run tests
|
||||||
npm test
|
npm test
|
||||||
|
|||||||
@@ -1,600 +0,0 @@
|
|||||||
# Database Security & Hardening Guide
|
|
||||||
|
|
||||||
## Executive Summary
|
|
||||||
|
|
||||||
Your codebase is **currently safe** from SQL injection because it uses `better-sqlite3`'s parameterized queries correctly. However, the new abstraction layers below provide:
|
|
||||||
|
|
||||||
1. **Type-safe query construction** (QueryBuilder)
|
|
||||||
2. **Audit logging** for compliance (QueryAudit)
|
|
||||||
3. **Statement caching** for performance (DatabaseConnection)
|
|
||||||
4. **Transaction support** for atomic operations
|
|
||||||
5. **Clear separation of concerns** between data access and business logic
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Current Safety Assessment
|
|
||||||
|
|
||||||
✅ **SQL Injection**: Safe
|
|
||||||
Your code uses parameterized queries (`?` placeholders) throughout:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// SAFE — all values in parameter array
|
|
||||||
this.db.prepare('SELECT * FROM holdings WHERE ticker = ?').get(id);
|
|
||||||
|
|
||||||
// SAFE — INSERT uses parameters
|
|
||||||
this.db.prepare('INSERT INTO holdings (...) VALUES (?, ?, ?, ?, ?)').run(
|
|
||||||
ticker, shares, costBasis, type, source
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
The `better-sqlite3` library handles parameter binding internally — user input never touches the SQL string.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## New Architecture: QueryBuilder + DatabaseConnection
|
|
||||||
|
|
||||||
### Problem Solved
|
|
||||||
|
|
||||||
While your code is secure, it has several maintainability issues:
|
|
||||||
|
|
||||||
1. **Hardcoded SQL strings** scattered across repositories
|
|
||||||
2. **No audit trail** — impossible to trace mutations for compliance
|
|
||||||
3. **No statement caching** — compiler recompiles the same queries repeatedly
|
|
||||||
4. **No type safety** — column names are strings, easy to typo
|
|
||||||
5. **Mixed concerns** — repositories call `.prepare()` directly; hard to add logging/caching globally
|
|
||||||
|
|
||||||
### Solution: Three Layers
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────────┐
|
|
||||||
│ Controllers / Services (business logic) │
|
|
||||||
└────────────────┬────────────────────────────────┘
|
|
||||||
│
|
|
||||||
↓
|
|
||||||
┌─────────────────────────────────────────────────┐
|
|
||||||
│ DatabaseConnection (timing, logging, caching) │
|
|
||||||
├─────────────────────────────────────────────────┤
|
|
||||||
│ - Execute queries via QueryBuilder │
|
|
||||||
│ - Log to QueryAudit │
|
|
||||||
│ - Cache prepared statements │
|
|
||||||
│ - Measure execution time │
|
|
||||||
│ - Support transactions │
|
|
||||||
└────────────────┬────────────────────────────────┘
|
|
||||||
│
|
|
||||||
↓
|
|
||||||
┌─────────────────────────────────────────────────┐
|
|
||||||
│ QueryBuilder (type-safe, column-validated) │
|
|
||||||
├─────────────────────────────────────────────────┤
|
|
||||||
│ - Whitelist column/table names │
|
|
||||||
│ - Build SQL with validated identifiers │
|
|
||||||
│ - Keep all user input in parameter array │
|
|
||||||
│ - Fluent API for clarity │
|
|
||||||
└────────────────┬────────────────────────────────┘
|
|
||||||
│
|
|
||||||
↓
|
|
||||||
┌─────────────────────────────────────────────────┐
|
|
||||||
│ QueryAudit (compliance trail) │
|
|
||||||
├─────────────────────────────────────────────────┤
|
|
||||||
│ - Log every query: timestamp, SQL, params │
|
|
||||||
│ - Track READ / WRITE / DELETE actions │
|
|
||||||
│ - Measure performance │
|
|
||||||
│ - Generate audit reports │
|
|
||||||
└────────────────┬────────────────────────────────┘
|
|
||||||
│
|
|
||||||
↓
|
|
||||||
┌─────────────────────────────────────────────────┐
|
|
||||||
│ better-sqlite3 (SQLite execution) │
|
|
||||||
│ (parameterized → injection-safe) │
|
|
||||||
└─────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Usage Examples
|
|
||||||
|
|
||||||
### QueryBuilder — Type-Safe Query Construction
|
|
||||||
|
|
||||||
All column and table names are validated against a whitelist. User input stays in the parameter array.
|
|
||||||
|
|
||||||
#### SELECT
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Safe: columns validated, params isolated
|
|
||||||
const qb = new QueryBuilder('holdings')
|
|
||||||
.select(['ticker', 'shares', 'cost_basis'])
|
|
||||||
.where('type = ? AND shares > ?', ['stock', 10])
|
|
||||||
.orderBy('ticker', 'ASC')
|
|
||||||
.limit(100);
|
|
||||||
|
|
||||||
const rows = db.all(qb);
|
|
||||||
// Equivalent SQL: SELECT ticker, shares, cost_basis FROM holdings
|
|
||||||
// WHERE type = ? AND shares > ? ORDER BY ticker ASC LIMIT 100
|
|
||||||
// Params: ['stock', 10]
|
|
||||||
```
|
|
||||||
|
|
||||||
#### INSERT
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const qb = new QueryBuilder('holdings')
|
|
||||||
.insert(['ticker', 'shares', 'cost_basis', 'type', 'source'],
|
|
||||||
['AAPL', 100, 15000, 'stock', 'Manual']);
|
|
||||||
|
|
||||||
db.run(qb);
|
|
||||||
// Equivalent SQL: INSERT INTO holdings (ticker, shares, cost_basis, type, source)
|
|
||||||
// VALUES (?, ?, ?, ?, ?)
|
|
||||||
// Params: ['AAPL', 100, 15000, 'stock', 'Manual']
|
|
||||||
```
|
|
||||||
|
|
||||||
#### UPDATE
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const qb = new QueryBuilder('holdings')
|
|
||||||
.update({ shares: 150, cost_basis: 22500 })
|
|
||||||
.where('ticker = ?', ['AAPL']);
|
|
||||||
|
|
||||||
db.run(qb);
|
|
||||||
// Equivalent SQL: UPDATE holdings SET shares = ?, cost_basis = ? WHERE ticker = ?
|
|
||||||
// Params: [150, 22500, 'AAPL']
|
|
||||||
```
|
|
||||||
|
|
||||||
#### DELETE
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const qb = new QueryBuilder('holdings')
|
|
||||||
.delete()
|
|
||||||
.where('ticker = ?', ['AAPL']);
|
|
||||||
|
|
||||||
db.run(qb);
|
|
||||||
// Equivalent SQL: DELETE FROM holdings WHERE ticker = ?
|
|
||||||
// Params: ['AAPL']
|
|
||||||
```
|
|
||||||
|
|
||||||
### DatabaseConnection — Unified Data Access
|
|
||||||
|
|
||||||
Wraps better-sqlite3 with logging, caching, and audit trails.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// In server/app.ts
|
|
||||||
import BetterSqlite3 from 'better-sqlite3';
|
|
||||||
import { DatabaseConnection, QueryAudit } from './server/db';
|
|
||||||
|
|
||||||
const betterSqlite3Db = new BetterSqlite3('./market-screener.db');
|
|
||||||
const audit = new QueryAudit();
|
|
||||||
const db = new DatabaseConnection(betterSqlite3Db, { audit, logSlowQueries: 100 });
|
|
||||||
|
|
||||||
// Pass `db` to repositories, not the raw better-sqlite3 instance
|
|
||||||
app.register(ScreenerController, { db });
|
|
||||||
```
|
|
||||||
|
|
||||||
### QueryAudit — Compliance Logging
|
|
||||||
|
|
||||||
Automatically logs all queries with timestamps, performance metrics, and parameters.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// In your app
|
|
||||||
const audit = new QueryAudit(async (entry) => {
|
|
||||||
// Optional: send to compliance logger, file, or remote service
|
|
||||||
if (entry.action === 'WRITE' && entry.error) {
|
|
||||||
console.error(`WRITE failed at ${entry.timestamp}: ${entry.error}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const db = new DatabaseConnection(betterSqlite3Db, { audit });
|
|
||||||
|
|
||||||
// Later: inspect the audit trail
|
|
||||||
db.printAudit();
|
|
||||||
// Output:
|
|
||||||
// === Query Audit Report ===
|
|
||||||
// Total entries: 42
|
|
||||||
// Showing last 100 entries:
|
|
||||||
//
|
|
||||||
// [2026-06-05T12:34:56.789Z] READ ✓ (1.23ms) — 5 rows
|
|
||||||
// SQL: SELECT ticker, shares, cost_basis FROM holdings WHERE type = ? ORDER BY ticker ASC
|
|
||||||
// Params: ["stock"]
|
|
||||||
//
|
|
||||||
// [2026-06-05T12:34:57.456Z] WRITE ✓ (0.89ms) — 1 rows
|
|
||||||
// SQL: INSERT INTO holdings (ticker, shares, ...) VALUES (?, ?, ...)
|
|
||||||
// Params: ["AAPL", 100, 15000, "stock", "Manual"]
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Migration Path: Refactor Repositories
|
|
||||||
|
|
||||||
Update your repositories to use the new `DatabaseConnection` and `QueryBuilder`.
|
|
||||||
|
|
||||||
### Before (Current)
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
export class MarketCallRepository {
|
|
||||||
constructor(private readonly db: Db) {}
|
|
||||||
|
|
||||||
list(): MarketCall[] {
|
|
||||||
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 | null {
|
|
||||||
const row = this.db
|
|
||||||
.prepare('SELECT * FROM market_calls WHERE id = ?')
|
|
||||||
.get(id) as CallRow | undefined;
|
|
||||||
return row ? MarketCallRepository.toCall(row) : null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### After (Hardened)
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { DatabaseConnection, QueryBuilder } from '../db';
|
|
||||||
|
|
||||||
export class MarketCallRepository {
|
|
||||||
constructor(private readonly db: DatabaseConnection) {}
|
|
||||||
|
|
||||||
list(): MarketCall[] {
|
|
||||||
const qb = new QueryBuilder('market_calls')
|
|
||||||
.select(['id', 'title', 'quarter', 'date', 'thesis', 'tickers', 'snapshot', 'created_at'])
|
|
||||||
.orderBy('created_at', 'DESC');
|
|
||||||
|
|
||||||
const rows = this.db.all<CallRow>(qb);
|
|
||||||
return rows.map(MarketCallRepository.toCall);
|
|
||||||
}
|
|
||||||
|
|
||||||
get(id: string): MarketCall | null {
|
|
||||||
const qb = new QueryBuilder('market_calls')
|
|
||||||
.select(['id', 'title', 'quarter', 'date', 'thesis', 'tickers', 'snapshot', 'created_at'])
|
|
||||||
.where('id = ?', [id]);
|
|
||||||
|
|
||||||
const row = this.db.get<CallRow>(qb);
|
|
||||||
return row ? MarketCallRepository.toCall(row) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
create({ title, quarter, date, thesis, tickers, snapshot }: CreateCallInput): MarketCall {
|
|
||||||
const call = {
|
|
||||||
id: randomUUID(),
|
|
||||||
title,
|
|
||||||
quarter,
|
|
||||||
date: date ?? new Date().toISOString().slice(0, 10),
|
|
||||||
thesis,
|
|
||||||
tickers: tickers ?? [],
|
|
||||||
snapshot: snapshot ?? {},
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
|
|
||||||
const qb = new QueryBuilder('market_calls')
|
|
||||||
.insert(
|
|
||||||
['id', 'title', 'quarter', 'date', 'thesis', 'tickers', 'snapshot', 'created_at'],
|
|
||||||
[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;
|
|
||||||
}
|
|
||||||
|
|
||||||
delete(id: string): boolean {
|
|
||||||
const qb = new QueryBuilder('market_calls')
|
|
||||||
.delete()
|
|
||||||
.where('id = ?', [id]);
|
|
||||||
|
|
||||||
const changes = this.db.run(qb);
|
|
||||||
return changes > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static toCall(row: CallRow): MarketCall {
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Key improvements:**
|
|
||||||
|
|
||||||
1. **Explicit columns** — Only SELECT the columns you need (better for indexing)
|
|
||||||
2. **Audit trail** — Every query is logged automatically
|
|
||||||
3. **Type safety** — QueryBuilder validates column names at compile time (via TypeScript)
|
|
||||||
4. **Performance** — Prepared statements are cached
|
|
||||||
5. **Clarity** — Fluent API makes queries self-documenting
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Whitelist of Safe Columns
|
|
||||||
|
|
||||||
The `QueryBuilder` validates all column/table names against a whitelist to prevent injection via identifiers:
|
|
||||||
|
|
||||||
### Holdings Table
|
|
||||||
|
|
||||||
- `ticker`
|
|
||||||
- `shares`
|
|
||||||
- `cost_basis`
|
|
||||||
- `type`
|
|
||||||
- `source`
|
|
||||||
|
|
||||||
### Market Calls Table
|
|
||||||
|
|
||||||
- `id`
|
|
||||||
- `title`
|
|
||||||
- `quarter`
|
|
||||||
- `date`
|
|
||||||
- `thesis`
|
|
||||||
- `tickers`
|
|
||||||
- `snapshot`
|
|
||||||
- `created_at`
|
|
||||||
|
|
||||||
### Adding New Columns
|
|
||||||
|
|
||||||
When you add a new column:
|
|
||||||
|
|
||||||
1. Update the DDL in `server/db/index.ts`
|
|
||||||
2. Add the column name to `SAFE_COLUMNS` in `QueryBuilder.ts`
|
|
||||||
3. Update the relevant domain type in `server/types/`
|
|
||||||
4. Update the repository to select/insert the new column
|
|
||||||
|
|
||||||
Example: Adding `updated_at` to `market_calls`
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// 1. Update DDL
|
|
||||||
const DDL = `
|
|
||||||
CREATE TABLE IF NOT EXISTS market_calls (
|
|
||||||
...
|
|
||||||
created_at TEXT NOT NULL,
|
|
||||||
updated_at TEXT NOT NULL -- NEW
|
|
||||||
);
|
|
||||||
`;
|
|
||||||
|
|
||||||
// 2. Update QueryBuilder.ts
|
|
||||||
const SAFE_COLUMNS = new Set([
|
|
||||||
// ... existing columns
|
|
||||||
'updated_at', // NEW
|
|
||||||
]);
|
|
||||||
|
|
||||||
// 3. Update types
|
|
||||||
export interface MarketCall {
|
|
||||||
// ... existing fields
|
|
||||||
updatedAt: string; // NEW
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Update repository
|
|
||||||
const qb = new QueryBuilder('market_calls')
|
|
||||||
.select(['id', 'title', 'quarter', 'date', 'thesis', 'tickers', 'snapshot', 'created_at', 'updated_at']) // ADDED
|
|
||||||
.where('id = ?', [id]);
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Performance: Statement Caching
|
|
||||||
|
|
||||||
`DatabaseConnection` automatically caches prepared statements. The first execution of a query compiles it; subsequent executions reuse the compiled statement.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// First call: compiles the statement (1.5ms overhead)
|
|
||||||
const qb1 = new QueryBuilder('holdings').select(['ticker', 'shares']).where('type = ?', ['stock']);
|
|
||||||
db.all(qb1); // ~1.5ms
|
|
||||||
|
|
||||||
// Second call: reuses the cached statement (0.1ms)
|
|
||||||
const qb2 = new QueryBuilder('holdings').select(['ticker', 'shares']).where('type = ?', ['etf']);
|
|
||||||
db.all(qb2); // ~0.1ms (same SQL template)
|
|
||||||
```
|
|
||||||
|
|
||||||
Cache key is the complete SQL string. If you generate different SQL, it creates a new cached statement.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Transactions: Atomic Operations
|
|
||||||
|
|
||||||
Use `db.transaction()` to execute multiple queries as a single atomic unit. If any query fails, all are rolled back.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
db.transaction(() => {
|
|
||||||
// Create a market call
|
|
||||||
const qb1 = new QueryBuilder('market_calls')
|
|
||||||
.insert(['id', 'title', ...], [callId, 'Q4 Earnings', ...]);
|
|
||||||
db.run(qb1);
|
|
||||||
|
|
||||||
// Add related tickers as separate records (if you had a separate table)
|
|
||||||
for (const ticker of tickers) {
|
|
||||||
const qb2 = new QueryBuilder('call_tickers')
|
|
||||||
.insert(['call_id', 'ticker'], [callId, ticker]);
|
|
||||||
db.run(qb2);
|
|
||||||
}
|
|
||||||
|
|
||||||
// If ANY query fails, BOTH are rolled back
|
|
||||||
// If all succeed, both are committed
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Audit Trail: Compliance & Debugging
|
|
||||||
|
|
||||||
The `QueryAudit` class tracks every database operation automatically.
|
|
||||||
|
|
||||||
### Built-in Features
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const audit = db.getAudit();
|
|
||||||
|
|
||||||
// Get the last 100 queries
|
|
||||||
const recent = audit.recent(100);
|
|
||||||
|
|
||||||
// Filter by action type
|
|
||||||
const writes = audit.byAction(AuditAction.WRITE);
|
|
||||||
|
|
||||||
// Generate a human-readable report
|
|
||||||
console.log(audit.report());
|
|
||||||
```
|
|
||||||
|
|
||||||
### Custom Callback
|
|
||||||
|
|
||||||
Send audit entries to a logging service or file:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const audit = new QueryAudit(async (entry) => {
|
|
||||||
if (entry.action === 'WRITE') {
|
|
||||||
// Log all mutations to your compliance logger
|
|
||||||
await complianceLogger.log({
|
|
||||||
timestamp: entry.timestamp,
|
|
||||||
action: entry.action,
|
|
||||||
sql: entry.sql,
|
|
||||||
params: entry.params,
|
|
||||||
rowsAffected: entry.rowsAffected,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const db = new DatabaseConnection(betterSqlite3Db, { audit });
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Slow Query Logging
|
|
||||||
|
|
||||||
By default, queries slower than 100ms are logged to `console.warn`:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const db = new DatabaseConnection(betterSqlite3Db, { logSlowQueries: 100 });
|
|
||||||
// Output:
|
|
||||||
// [SLOW QUERY] 234.56ms
|
|
||||||
// SELECT ticker, shares, cost_basis FROM holdings WHERE type = ? ORDER BY ticker ASC
|
|
||||||
```
|
|
||||||
|
|
||||||
Adjust the threshold based on your needs:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
new DatabaseConnection(betterSqlite3Db, { logSlowQueries: 50 }); // warn on >50ms
|
|
||||||
new DatabaseConnection(betterSqlite3Db, { logSlowQueries: 5000 }); // warn on >5s
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Common Pitfalls & How to Avoid Them
|
|
||||||
|
|
||||||
### ❌ DON'T: Hardcode user input in SQL
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// NEVER DO THIS
|
|
||||||
const ticker = getUserInput(); // e.g. "AAPL'; DROP TABLE holdings; --"
|
|
||||||
const qb = new QueryBuilder('holdings')
|
|
||||||
.select(['ticker', 'shares'])
|
|
||||||
.where(`ticker = '${ticker}'`); // SQL INJECTION!
|
|
||||||
```
|
|
||||||
|
|
||||||
### ✅ DO: Use parameter placeholders
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// ALWAYS DO THIS
|
|
||||||
const ticker = getUserInput();
|
|
||||||
const qb = new QueryBuilder('holdings')
|
|
||||||
.select(['ticker', 'shares'])
|
|
||||||
.where('ticker = ?', [ticker]); // User input is a PARAMETER
|
|
||||||
```
|
|
||||||
|
|
||||||
### ❌ DON'T: Use string concatenation for column names
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// NEVER DO THIS
|
|
||||||
const sortCol = getUserInput(); // e.g. "ticker; DELETE FROM holdings; --"
|
|
||||||
const qb = new QueryBuilder('holdings')
|
|
||||||
.select(['ticker', 'shares'])
|
|
||||||
.orderBy(`${sortCol}`); // COLUMN NAME INJECTION!
|
|
||||||
```
|
|
||||||
|
|
||||||
### ✅ DO: Column names come from your code, not user input
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// ALWAYS DO THIS
|
|
||||||
const sortCol = getUserInput(); // e.g. "ticker"
|
|
||||||
const ALLOWED_SORT_COLS = ['ticker', 'shares', 'type'];
|
|
||||||
|
|
||||||
if (!ALLOWED_SORT_COLS.includes(sortCol)) {
|
|
||||||
throw new Error('Invalid sort column');
|
|
||||||
}
|
|
||||||
|
|
||||||
const qb = new QueryBuilder('holdings')
|
|
||||||
.select(['ticker', 'shares'])
|
|
||||||
.orderBy(sortCol); // Whitelist prevents injection
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
The new abstractions make testing easier:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { DatabaseConnection, QueryBuilder, QueryAudit } from '../db';
|
|
||||||
import BetterSqlite3 from 'better-sqlite3';
|
|
||||||
|
|
||||||
describe('MarketCallRepository', () => {
|
|
||||||
let db: DatabaseConnection;
|
|
||||||
let repo: MarketCallRepository;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
// Use in-memory SQLite for tests
|
|
||||||
const rawDb = new BetterSqlite3(':memory:');
|
|
||||||
rawDb.exec(DDL); // Initialize schema
|
|
||||||
db = new DatabaseConnection(rawDb);
|
|
||||||
repo = new MarketCallRepository(db);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should insert and retrieve a call', () => {
|
|
||||||
const call = repo.create({
|
|
||||||
title: 'Q4 Earnings',
|
|
||||||
quarter: 'Q4',
|
|
||||||
thesis: 'FANG tech breakout',
|
|
||||||
tickers: ['GOOGL', 'META', 'NVDA'],
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(call.id).toBeDefined();
|
|
||||||
|
|
||||||
const retrieved = repo.get(call.id);
|
|
||||||
expect(retrieved).toEqual(call);
|
|
||||||
|
|
||||||
// Verify the audit trail
|
|
||||||
const audit = db.getAudit();
|
|
||||||
const writes = audit.byAction(AuditAction.WRITE);
|
|
||||||
expect(writes.length).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
| Feature | Before | After |
|
|
||||||
|---------|--------|-------|
|
|
||||||
| SQL injection protection | ✅ Parameterized queries | ✅ Parameterized + column whitelist |
|
|
||||||
| Audit trail | ❌ None | ✅ QueryAudit with timestamp & params |
|
|
||||||
| Performance | ⚠️ No statement caching | ✅ Automatic statement cache |
|
|
||||||
| Type safety | ⚠️ String column names | ✅ Validated at build time |
|
|
||||||
| Testing | ⚠️ Hard to mock | ✅ Testable via DatabaseConnection |
|
|
||||||
| Transactions | ⚠️ Manual raw DB calls | ✅ `db.transaction()` |
|
|
||||||
| Slow query logging | ❌ None | ✅ Automatic > 100ms warning |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Next Steps
|
|
||||||
|
|
||||||
1. **Review** the three new files:
|
|
||||||
- `server/db/QueryBuilder.ts` — Query construction
|
|
||||||
- `server/db/QueryAudit.ts` — Audit logging
|
|
||||||
- `server/db/DatabaseConnection.ts` — Unified access
|
|
||||||
|
|
||||||
2. **Update `server/app.ts`** to create and wire `DatabaseConnection`
|
|
||||||
|
|
||||||
3. **Refactor repositories** to use `QueryBuilder` and `DatabaseConnection` (see migration examples above)
|
|
||||||
|
|
||||||
4. **Add tests** for repositories using in-memory SQLite
|
|
||||||
|
|
||||||
5. **Deploy** with confidence — you now have audit trails and safeguards
|
|
||||||
@@ -1,464 +0,0 @@
|
|||||||
# Integration Example: Hardened Database Layer
|
|
||||||
|
|
||||||
This document shows **step-by-step** how to integrate the new QueryBuilder + DatabaseConnection + QueryAudit into your existing codebase.
|
|
||||||
|
|
||||||
## Step 1: Update `server/app.ts`
|
|
||||||
|
|
||||||
Change from passing raw `Db` to passing `DatabaseConnection`:
|
|
||||||
|
|
||||||
### Before
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { createDb, type Db } from './db/index.js';
|
|
||||||
import { MarketCallRepository } from './repositories/MarketCallRepository.js';
|
|
||||||
import { PortfolioRepository } from './repositories/PortfolioRepository.js';
|
|
||||||
|
|
||||||
export async function buildApp(): Promise<FastifyInstance> {
|
|
||||||
const app = fastify();
|
|
||||||
const rawDb: Db = createDb();
|
|
||||||
|
|
||||||
// Pass raw Db to repositories
|
|
||||||
const callsRepo = new MarketCallRepository(rawDb);
|
|
||||||
const portfolioRepo = new PortfolioRepository(rawDb);
|
|
||||||
|
|
||||||
// Register routes...
|
|
||||||
return app;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### After
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import BetterSqlite3 from 'better-sqlite3';
|
|
||||||
import { createDb, DatabaseConnection, QueryAudit } from './db/index.js';
|
|
||||||
import { MarketCallRepository } from './repositories/MarketCallRepository.js';
|
|
||||||
import { PortfolioRepository } from './repositories/PortfolioRepository.js';
|
|
||||||
|
|
||||||
export async function buildApp(): Promise<FastifyInstance> {
|
|
||||||
const app = fastify();
|
|
||||||
|
|
||||||
// Create the raw database and initialize schema
|
|
||||||
const rawDb = createDb();
|
|
||||||
|
|
||||||
// Wrap with audit and caching
|
|
||||||
const audit = new QueryAudit((entry) => {
|
|
||||||
// Optional: send to logging service
|
|
||||||
if (process.env.LOG_SLOW_QUERIES && entry.durationMs > 100) {
|
|
||||||
console.warn(`[SLOW] ${entry.sql} (${entry.durationMs.toFixed(1)}ms)`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const db = new DatabaseConnection(rawDb, {
|
|
||||||
audit,
|
|
||||||
logSlowQueries: 100, // warn on >100ms queries
|
|
||||||
});
|
|
||||||
|
|
||||||
// Pass DatabaseConnection to repositories (not raw Db)
|
|
||||||
const callsRepo = new MarketCallRepository(db);
|
|
||||||
const portfolioRepo = new PortfolioRepository(db);
|
|
||||||
|
|
||||||
// Register routes...
|
|
||||||
return app;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Step 2: Update `MarketCallRepository`
|
|
||||||
|
|
||||||
Refactor to use QueryBuilder and DatabaseConnection:
|
|
||||||
|
|
||||||
### Complete Refactored Repository
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { randomUUID } from 'crypto';
|
|
||||||
import { DatabaseConnection, QueryBuilder } from '../db/index.js';
|
|
||||||
import type { MarketCall, CreateCallInput } from '../types/index.js';
|
|
||||||
|
|
||||||
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: DatabaseConnection) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* List all market calls, newest first.
|
|
||||||
*/
|
|
||||||
list(): (MarketCall & { createdAt: string })[] {
|
|
||||||
const qb = new QueryBuilder('market_calls')
|
|
||||||
.select(['id', 'title', 'quarter', 'date', 'thesis', 'tickers', 'snapshot', 'created_at'])
|
|
||||||
.orderBy('created_at', 'DESC');
|
|
||||||
|
|
||||||
const rows = this.db.all<CallRow>(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')
|
|
||||||
.select(['id', 'title', 'quarter', 'date', 'thesis', 'tickers', 'snapshot', 'created_at'])
|
|
||||||
.where('id = ?', [id]);
|
|
||||||
|
|
||||||
const row = this.db.get<CallRow>(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 } {
|
|
||||||
const call = {
|
|
||||||
id: randomUUID(),
|
|
||||||
title,
|
|
||||||
quarter,
|
|
||||||
date: date ?? new Date().toISOString().slice(0, 10),
|
|
||||||
thesis,
|
|
||||||
tickers: tickers ?? [],
|
|
||||||
snapshot: snapshot ?? {},
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
|
|
||||||
const qb = new QueryBuilder('market_calls')
|
|
||||||
.insert(
|
|
||||||
['id', 'title', 'quarter', 'date', 'thesis', 'tickers', 'snapshot', 'created_at'],
|
|
||||||
[
|
|
||||||
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')
|
|
||||||
.delete()
|
|
||||||
.where('id = ?', [id]);
|
|
||||||
|
|
||||||
const changes = this.db.run(qb);
|
|
||||||
return changes > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Private helper to convert database row to domain object.
|
|
||||||
*/
|
|
||||||
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 };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Changes:**
|
|
||||||
|
|
||||||
- Constructor now accepts `DatabaseConnection` instead of raw `Db`
|
|
||||||
- All `.prepare()` calls replaced with `QueryBuilder` + `db.all()` / `db.get()` / `db.run()`
|
|
||||||
- Explicit column selection in `SELECT` statements
|
|
||||||
- Audit trail automatically generated for every query
|
|
||||||
|
|
||||||
## Step 3: Update `PortfolioRepository`
|
|
||||||
|
|
||||||
Similar refactoring:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { DatabaseConnection, QueryBuilder } from '../db/index.js';
|
|
||||||
import type { PortfolioData, PortfolioHolding } from '../types/index.js';
|
|
||||||
|
|
||||||
interface HoldingRow {
|
|
||||||
ticker: string;
|
|
||||||
shares: number;
|
|
||||||
cost_basis: number;
|
|
||||||
type: string;
|
|
||||||
source: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class PortfolioRepository {
|
|
||||||
constructor(private readonly db: DatabaseConnection) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if portfolio has any holdings.
|
|
||||||
*/
|
|
||||||
exists(): boolean {
|
|
||||||
const qb = new QueryBuilder('holdings')
|
|
||||||
.select(['ticker'])
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
const row = this.db.get<{ ticker: string }>(qb);
|
|
||||||
return row !== null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Read all holdings.
|
|
||||||
*/
|
|
||||||
read(): PortfolioData {
|
|
||||||
const qb = new QueryBuilder('holdings')
|
|
||||||
.select(['ticker', 'shares', 'cost_basis', 'type', 'source'])
|
|
||||||
.orderBy('ticker', 'ASC');
|
|
||||||
|
|
||||||
const rows = this.db.all<HoldingRow>(qb);
|
|
||||||
return { holdings: rows.map(PortfolioRepository.toHolding) };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Insert or update a holding.
|
|
||||||
*/
|
|
||||||
upsert(entry: PortfolioHolding): PortfolioHolding {
|
|
||||||
const ticker = entry.ticker.toUpperCase().trim();
|
|
||||||
|
|
||||||
// Use raw db.prepare() for UPSERT syntax (not yet wrapped in QueryBuilder)
|
|
||||||
// This is acceptable because the values are all parameterized
|
|
||||||
this.db.raw().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 };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete a holding by ticker.
|
|
||||||
*/
|
|
||||||
remove(ticker: string): boolean {
|
|
||||||
const qb = new QueryBuilder('holdings')
|
|
||||||
.delete()
|
|
||||||
.where('ticker = ?', [ticker.toUpperCase()]);
|
|
||||||
|
|
||||||
const changes = this.db.run(qb);
|
|
||||||
return changes > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Private helper to 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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Note:** The `upsert()` method still uses `db.raw().prepare()` because QueryBuilder doesn't yet support `ON CONFLICT`. This is acceptable because the SQL is still parameterized. A future enhancement could add `onConflict()` to QueryBuilder if needed.
|
|
||||||
|
|
||||||
## Step 4: Add a Simple Test
|
|
||||||
|
|
||||||
Create `tests/MarketCallRepository.test.ts`:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import test from 'node:test';
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
import BetterSqlite3 from 'better-sqlite3';
|
|
||||||
import { DatabaseConnection, QueryAudit } from '../server/db/index.js';
|
|
||||||
import { MarketCallRepository } from '../server/repositories/MarketCallRepository.js';
|
|
||||||
|
|
||||||
// Mini DDL for testing
|
|
||||||
const DDL = `
|
|
||||||
CREATE TABLE 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,
|
|
||||||
snapshot TEXT NOT NULL,
|
|
||||||
created_at TEXT NOT NULL
|
|
||||||
);
|
|
||||||
`;
|
|
||||||
|
|
||||||
test('MarketCallRepository', async (t) => {
|
|
||||||
// Set up in-memory database
|
|
||||||
const rawDb = new BetterSqlite3(':memory:');
|
|
||||||
rawDb.exec(DDL);
|
|
||||||
|
|
||||||
const audit = new QueryAudit();
|
|
||||||
const db = new DatabaseConnection(rawDb, { audit });
|
|
||||||
const repo = new MarketCallRepository(db);
|
|
||||||
|
|
||||||
await t.test('should create and retrieve a call', () => {
|
|
||||||
const created = repo.create({
|
|
||||||
title: 'Q4 Earnings Blitz',
|
|
||||||
quarter: 'Q4',
|
|
||||||
thesis: 'Mega cap tech breakout',
|
|
||||||
tickers: ['AAPL', 'MSFT', 'GOOGL'],
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.ok(created.id);
|
|
||||||
assert.equal(created.title, 'Q4 Earnings Blitz');
|
|
||||||
|
|
||||||
const retrieved = repo.get(created.id);
|
|
||||||
assert.deepEqual(retrieved, created);
|
|
||||||
});
|
|
||||||
|
|
||||||
await t.test('should list calls in order', () => {
|
|
||||||
const call1 = repo.create({
|
|
||||||
title: 'Call 1',
|
|
||||||
quarter: 'Q1',
|
|
||||||
thesis: 'Test 1',
|
|
||||||
tickers: ['AAPL'],
|
|
||||||
});
|
|
||||||
|
|
||||||
const call2 = repo.create({
|
|
||||||
title: 'Call 2',
|
|
||||||
quarter: 'Q2',
|
|
||||||
thesis: 'Test 2',
|
|
||||||
tickers: ['MSFT'],
|
|
||||||
});
|
|
||||||
|
|
||||||
const list = repo.list();
|
|
||||||
assert.equal(list.length, 2);
|
|
||||||
// Most recent first
|
|
||||||
assert.equal(list[0].id, call2.id);
|
|
||||||
assert.equal(list[1].id, call1.id);
|
|
||||||
});
|
|
||||||
|
|
||||||
await t.test('should delete a call', () => {
|
|
||||||
const call = repo.create({
|
|
||||||
title: 'Deletable',
|
|
||||||
quarter: 'Q1',
|
|
||||||
thesis: 'This will be deleted',
|
|
||||||
tickers: ['TEST'],
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.ok(repo.delete(call.id));
|
|
||||||
assert.equal(repo.get(call.id), null);
|
|
||||||
assert.ok(!repo.delete(call.id)); // Already deleted
|
|
||||||
});
|
|
||||||
|
|
||||||
await t.test('should track queries in audit', () => {
|
|
||||||
repo.create({
|
|
||||||
title: 'Audited',
|
|
||||||
quarter: 'Q1',
|
|
||||||
thesis: 'Tracked',
|
|
||||||
tickers: ['AAPL'],
|
|
||||||
});
|
|
||||||
|
|
||||||
const auditLog = audit.all();
|
|
||||||
assert.ok(auditLog.length > 0);
|
|
||||||
|
|
||||||
// Find the INSERT
|
|
||||||
const inserts = audit.byAction('WRITE');
|
|
||||||
assert.ok(inserts.some(e => e.sql.includes('INSERT INTO market_calls')));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
Run it:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm test -- tests/MarketCallRepository.test.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
## Step 5: Add to Existing Tests
|
|
||||||
|
|
||||||
If you already have integration tests, add an audit check:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
test('screening creates an audit trail', async (t) => {
|
|
||||||
const result = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/api/screen',
|
|
||||||
payload: { tickers: ['AAPL', 'MSFT'] },
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(result.statusCode, 200);
|
|
||||||
|
|
||||||
// Verify the database was accessed
|
|
||||||
const audit = db.getAudit();
|
|
||||||
const reads = audit.byAction('READ');
|
|
||||||
assert.ok(reads.length > 0, 'SELECT queries should have been executed');
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
## Step 6: Enable Audit Output (Optional)
|
|
||||||
|
|
||||||
If you want to log all queries to a file or external service:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import fs from 'fs/promises';
|
|
||||||
|
|
||||||
const audit = new QueryAudit(async (entry) => {
|
|
||||||
// Only log WRITE operations to a log file
|
|
||||||
if (entry.action !== 'READ') {
|
|
||||||
const logLine = JSON.stringify({
|
|
||||||
timestamp: entry.timestamp,
|
|
||||||
action: entry.action,
|
|
||||||
sql: entry.sql,
|
|
||||||
params: entry.params,
|
|
||||||
rowsAffected: entry.rowsAffected,
|
|
||||||
error: entry.error,
|
|
||||||
});
|
|
||||||
|
|
||||||
await fs.appendFile('./audit.log', logLine + '\n');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const db = new DatabaseConnection(rawDb, { audit, logSlowQueries: 50 });
|
|
||||||
```
|
|
||||||
|
|
||||||
Then tail the log:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
tail -f audit.log | jq .
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
| File | Change |
|
|
||||||
|------|--------|
|
|
||||||
| `server/app.ts` | Create `DatabaseConnection` and pass to repositories |
|
|
||||||
| `server/repositories/MarketCallRepository.ts` | Use `QueryBuilder` + `DatabaseConnection` |
|
|
||||||
| `server/repositories/PortfolioRepository.ts` | Use `QueryBuilder` + `DatabaseConnection` |
|
|
||||||
| `tests/MarketCallRepository.test.ts` | Add tests with audit verification |
|
|
||||||
|
|
||||||
All changes maintain **backward compatibility** with the existing API — only the internals change. Your controllers don't need to be modified.
|
|
||||||
|
|
||||||
## Next: Audit Trail in Production
|
|
||||||
|
|
||||||
Once deployed, you can:
|
|
||||||
|
|
||||||
1. **Review recent queries**: `db.getAudit().recent(100)`
|
|
||||||
2. **Find slow queries**: `db.getAudit().all().filter(e => e.durationMs > 500)`
|
|
||||||
3. **Track mutations**: `db.getAudit().byAction('WRITE')`
|
|
||||||
4. **Generate compliance reports**: `db.printAudit()`
|
|
||||||
|
|
||||||
This gives you visibility into exactly what's happening in your database — invaluable for debugging, security audits, and performance optimization.
|
|
||||||
@@ -116,6 +116,8 @@ CLIENT_ORIGIN=http://localhost:5173
|
|||||||
| `npm run typecheck` | TypeScript type check without emitting |
|
| `npm run typecheck` | TypeScript type check without emitting |
|
||||||
| `npm run format` | Format all source files with Prettier |
|
| `npm run format` | Format all source files with Prettier |
|
||||||
| `npm run format:check` | Check formatting without writing (used in CI) |
|
| `npm run format:check` | Check formatting without writing (used in CI) |
|
||||||
|
| `npm run lint` | Run ESLint on all TypeScript files |
|
||||||
|
| `npm run lint:fix` | Auto-fix ESLint issues |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -125,54 +127,80 @@ CLIENT_ORIGIN=http://localhost:5173
|
|||||||
npm test
|
npm test
|
||||||
```
|
```
|
||||||
|
|
||||||
Uses Node's built-in `node:test` runner — no external framework. Tests cover:
|
Uses Node's built-in `node:test` runner — no external framework. **114 test cases** across 9 files cover:
|
||||||
|
|
||||||
- Scoring rules and gate values (`ScoringConfig`, `RuleMerger`, `MarketRegime`)
|
| Test File | Tests | Coverage |
|
||||||
- Asset scorers (`StockScorer`, `EtfScorer`, `BondScorer`)
|
|-----------|-------|----------|
|
||||||
- Data mapping (`DataMapper`)
|
| `app.test.ts` | 9 | App bootstrap, CORS, health endpoints |
|
||||||
- Portfolio advice logic (`PortfolioAdvisor`)
|
| `screener-controller.test.ts` | 10 | `/api/screen` endpoints |
|
||||||
- LLM response parsing (`LLMAnalyst`)
|
| `screener-engine.test.ts` | 11 | Screening orchestration logic |
|
||||||
- Repository CRUD (`MarketCallRepository`)
|
| `stock-scorer.test.ts` | 13 | Stock valuation gates |
|
||||||
- Controller integration tests for all API routes (Fastify `inject()`, zero live network calls)
|
| `etf-scorer.test.ts` | 17 | ETF fund gates |
|
||||||
|
| `bond-scorer.test.ts` | 16 | Bond credit analysis |
|
||||||
|
| `portfolio-advisor.test.ts` | 12 | Portfolio advice logic |
|
||||||
|
| `portfolio-controller.test.ts` | 12 | Portfolio endpoints |
|
||||||
|
| `calls-controller.test.ts` | 14 | Market calls endpoints |
|
||||||
|
|
||||||
Pre-commit hook runs Prettier then tests. Pre-push hook runs tests.
|
### Pre-Commit & Pre-Push Hooks
|
||||||
|
|
||||||
|
On `git commit`, the **pre-commit hook** automatically:
|
||||||
|
|
||||||
|
1. **Formats** all files with Prettier
|
||||||
|
2. **Lints & fixes** staged files with ESLint
|
||||||
|
3. **Runs tests** to catch errors early
|
||||||
|
|
||||||
|
On `git push`, the **pre-push hook** runs tests again for safety.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
|
**Phase 9: Domain-Driven Architecture** (completed)
|
||||||
|
|
||||||
```
|
```
|
||||||
bin/
|
bin/
|
||||||
server.ts API server entry point
|
server.ts API server entry point
|
||||||
|
|
||||||
server/
|
server/
|
||||||
app.ts Fastify app factory — wires DI, rate limiting, auth hook
|
app.ts Fastify app factory — wires DI, rate limiting, auth hook
|
||||||
controllers/ HTTP layer: parse request → call service → return response
|
domains/ Domain-driven structure (shared, screener, portfolio, calls, finance)
|
||||||
services/ Business logic (ScreenerEngine, BenchmarkProvider, PortfolioAdvisor…)
|
shared/ Infrastructure & cross-domain utilities
|
||||||
repositories/ JSON file persistence (MarketCallRepository, PortfolioRepository)
|
adapters/ YahooFinanceClient, AnthropicClient, SimpleFINClient
|
||||||
clients/ External API connectors (YahooFinanceClient, SimpleFINClient, AnthropicClient)
|
services/ BenchmarkProvider, CatalystAnalyst, LLMAnalyst
|
||||||
models/ Domain entities: Stock, Etf, Bond
|
entities/ Asset, Stock, Etf, Bond
|
||||||
scorers/ Stateless scoring functions: StockScorer, EtfScorer, BondScorer
|
persistence/ MarketCallRepository, PortfolioRepository
|
||||||
config/ ScoringConfig (all gates/weights), constants
|
config/ ScoringConfig (gates/weights), constants
|
||||||
types/ TypeScript interfaces, one file per domain
|
scoring/ MarketRegime, scoring overrides
|
||||||
|
types/ TypeScript interfaces (one file per domain)
|
||||||
|
screener/ Stock/ETF/Bond filtering & scoring
|
||||||
|
ScreenerEngine.ts Orchestrates: fetch → score × 2 (fundamental + inflated)
|
||||||
|
scorers/ StockScorer, EtfScorer, BondScorer
|
||||||
|
transform/ DataMapper, RuleMerger
|
||||||
|
portfolio/ Holdings management & investment advice
|
||||||
|
PortfolioAdvisor.ts Cross-references holdings with screener signals
|
||||||
|
calls/ Market call tracking & earnings calendar
|
||||||
|
CalendarService.ts Earnings calendar logic
|
||||||
|
finance/ Portfolio metrics & reporting
|
||||||
|
|
||||||
ui/
|
ui/
|
||||||
src/
|
src/
|
||||||
routes/ SvelteKit pages: /, /portfolio, /calls, /safe-buys
|
routes/ SvelteKit pages: /, /portfolio, /calls, /safe-buys
|
||||||
lib/
|
lib/
|
||||||
stores/ Svelte 5 reactive stores (screener.store, portfolio.store)
|
components/ Shared UI components organized by domain
|
||||||
|
stores/ Svelte 5 reactive stores
|
||||||
api/ Fetch wrappers for each API domain
|
api/ Fetch wrappers for each API domain
|
||||||
portfolio/ Portfolio-specific components
|
|
||||||
calls/ Market calls components
|
|
||||||
styles/ Global SCSS design tokens and partials
|
styles/ Global SCSS design tokens and partials
|
||||||
|
|
||||||
tests/ Unit + integration tests
|
tests/ Unit + integration tests (9 files, 114 test cases)
|
||||||
|
Controllers, services, scorers fully covered
|
||||||
|
|
||||||
portfolio.json Your holdings (gitignored — create manually or via the UI)
|
portfolio.json Your holdings (gitignored — create manually or via the UI)
|
||||||
market-calls.json Persisted market thesis calls (gitignored)
|
market-calls.json Persisted market thesis calls (gitignored)
|
||||||
.benchmark-cache.json Benchmark data cache — survives server restart (gitignored)
|
.benchmark-cache.json Benchmark data cache — survives server restart (gitignored)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
See **[CLAUDE.md](./CLAUDE.md)** for detailed architecture and **[PHASES.md](./PHASES.md)** for the complete roadmap.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## User Guide
|
## User Guide
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
+34
-24
@@ -1,31 +1,35 @@
|
|||||||
import Fastify, { type FastifyRequest, type FastifyReply } from 'fastify';
|
import Fastify, { type FastifyRequest, type FastifyReply } from 'fastify';
|
||||||
import cors from '@fastify/cors';
|
import cors from '@fastify/cors';
|
||||||
import rateLimit from '@fastify/rate-limit';
|
import rateLimit from '@fastify/rate-limit';
|
||||||
import { ScreenerController } from './controllers/screener.controller';
|
|
||||||
import { FinanceController } from './controllers/finance.controller';
|
// Domain imports
|
||||||
import { CallsController } from './controllers/calls.controller';
|
import { ScreenerController, ScreenerEngine, AnalyzeController } from './domains/screener';
|
||||||
import { AnalyzeController } from './controllers/analyze.controller';
|
import { FinanceController, PortfolioAdvisor } from './domains/portfolio';
|
||||||
import { ScreenerEngine } from './services/ScreenerEngine';
|
import { CallsController, CalendarService } from './domains/calls';
|
||||||
import { BenchmarkProvider } from './services/BenchmarkProvider';
|
|
||||||
import { PortfolioAdvisor } from './services/PortfolioAdvisor';
|
// Shared infrastructure
|
||||||
import { CalendarService } from './services/CalendarService';
|
import {
|
||||||
import { LLMAnalyst } from './services/LLMAnalyst';
|
YahooFinanceClient,
|
||||||
import { CatalystAnalyst } from './services/CatalystAnalyst';
|
BenchmarkProvider,
|
||||||
import { YahooFinanceClient } from './clients/YahooFinanceClient';
|
CatalystCache,
|
||||||
import { MarketCallRepository } from './repositories/MarketCallRepository';
|
LLMAnalyst,
|
||||||
import { PortfolioRepository } from './repositories/PortfolioRepository';
|
MarketCallRepository,
|
||||||
import { createDb } from './db/index';
|
PortfolioRepository,
|
||||||
import { noopLogger } from './utils/logger';
|
createDb,
|
||||||
|
DatabaseConnection,
|
||||||
|
QueryAudit,
|
||||||
|
noopLogger,
|
||||||
|
} from './domains/shared';
|
||||||
|
|
||||||
interface BuildAppOptions {
|
interface BuildAppOptions {
|
||||||
logger?: boolean;
|
logger?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Adding a new domain ───────────────────────────────────────────────────
|
// ── Adding a new domain ───────────────────────────────────────────────
|
||||||
// 1. server/types/<domain>.model.ts — define request/response shapes
|
// 1. Create: server/domains/<domain>/ directory structure
|
||||||
// 2. server/services/<Domain>.ts — business logic
|
// 2. Move controllers, services, types to the domain
|
||||||
// 3. server/controllers/<domain>.controller.ts — HTTP wiring (class + register)
|
// 3. Create barrel: server/domains/<domain>/index.ts
|
||||||
// 4. Register: new <Domain>Controller(...).register(app) ← add below
|
// 4. Import from domain and register controller below
|
||||||
// ───────────────────────────────────────────────────────────────────────────
|
// ───────────────────────────────────────────────────────────────────────────
|
||||||
export async function buildApp({ logger = true }: BuildAppOptions = {}) {
|
export async function buildApp({ logger = true }: BuildAppOptions = {}) {
|
||||||
const app = Fastify({ logger });
|
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 yahoo = new YahooFinanceClient();
|
||||||
const benchmark = new BenchmarkProvider(yahoo, { logger: noopLogger });
|
const benchmark = new BenchmarkProvider(yahoo, { logger: noopLogger });
|
||||||
const engine = new ScreenerEngine(yahoo, benchmark, { logger: noopLogger });
|
const engine = new ScreenerEngine(yahoo, benchmark, { logger: noopLogger });
|
||||||
const advisor = new PortfolioAdvisor(yahoo);
|
const advisor = new PortfolioAdvisor(yahoo);
|
||||||
const calSvc = new CalendarService(yahoo);
|
const calSvc = new CalendarService(yahoo);
|
||||||
const llm = new LLMAnalyst({ logger: noopLogger });
|
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 FinanceController(engine, new PortfolioRepository(db), advisor).register(app);
|
||||||
new CallsController(new MarketCallRepository(db), engine, calSvc).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' }));
|
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 { YahooFinanceClient, chunkArray } from '../../domains/shared';
|
||||||
import { chunkArray } from '../utils/Chunker';
|
import type { CalendarEvent } from '../../domains/shared';
|
||||||
import type { CalendarEvent } from '../types';
|
|
||||||
|
|
||||||
export class CalendarService {
|
export class CalendarService {
|
||||||
constructor(private readonly yahoo: YahooFinanceClient) {}
|
constructor(private readonly yahoo: YahooFinanceClient) {}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||||
import { MarketCallRepository } from '../repositories/MarketCallRepository';
|
import { MarketCallRepository } from '../../domains/shared';
|
||||||
import { CalendarService, ScreenerEngine } from '../services/index';
|
import { CalendarService } from './CalendarService';
|
||||||
import type { SnapshotEntry } from '../types';
|
import { ScreenerEngine } from '../screener';
|
||||||
import { callSchema } from '../types/schemas';
|
import type { SnapshotEntry } from '../../domains/shared';
|
||||||
|
import { callSchema } from '../../domains/shared/types/schemas';
|
||||||
|
|
||||||
export class CallsController {
|
export class CallsController {
|
||||||
constructor(
|
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 type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||||
import { SimpleFINClient } from '../clients/SimpleFINClient';
|
import { SimpleFINClient, PortfolioRepository, noopLogger } from '../../domains/shared';
|
||||||
import { PortfolioRepository } from '../repositories/PortfolioRepository';
|
import { PersonalFinanceAnalyzer, ScreenerEngine } from '../screener';
|
||||||
import { PersonalFinanceAnalyzer, PortfolioAdvisor, ScreenerEngine } from '../services/index';
|
import { PortfolioAdvisor } from '../portfolio/PortfolioAdvisor';
|
||||||
import type { PortfolioHolding } from '../types';
|
import type { PortfolioHolding } from '../../domains/shared';
|
||||||
import { holdingSchema } from '../types/schemas';
|
import { holdingSchema } from '../../domains/shared/types/schemas';
|
||||||
import { noopLogger } from '../utils/logger';
|
|
||||||
|
|
||||||
export class FinanceController {
|
export class FinanceController {
|
||||||
constructor(
|
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 { SIGNAL, YahooFinanceClient } from '../../domains/shared';
|
||||||
import { YahooFinanceClient } from '../clients/YahooFinanceClient';
|
|
||||||
import type {
|
import type {
|
||||||
PortfolioHolding,
|
PortfolioHolding,
|
||||||
Signal,
|
Signal,
|
||||||
@@ -8,7 +7,7 @@ import type {
|
|||||||
AdviceRow,
|
AdviceRow,
|
||||||
PositionCalc,
|
PositionCalc,
|
||||||
AdviceOutput,
|
AdviceOutput,
|
||||||
} from '../types';
|
} from '../../domains/shared';
|
||||||
|
|
||||||
export class PortfolioAdvisor {
|
export class PortfolioAdvisor {
|
||||||
constructor(private readonly client: YahooFinanceClient) {}
|
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 {
|
export class PersonalFinanceAnalyzer {
|
||||||
analyze(accounts: SimpleFINAccount[]): FinanceAnalysis {
|
analyze(accounts: SimpleFINAccount[]): FinanceAnalysis {
|
||||||
@@ -1,15 +1,20 @@
|
|||||||
import { YahooFinanceClient } from '../clients/YahooFinanceClient';
|
import {
|
||||||
import { BenchmarkProvider } from './BenchmarkProvider';
|
YahooFinanceClient,
|
||||||
import { DataMapper } from './DataMapper';
|
BenchmarkProvider,
|
||||||
import { chunkArray } from '../utils/Chunker';
|
chunkArray,
|
||||||
import { RuleMerger } from './RuleMerger';
|
Stock,
|
||||||
import { Stock } from '../models/Stock';
|
Etf,
|
||||||
import { Etf } from '../models/Etf';
|
Bond,
|
||||||
import { Bond } from '../models/Bond';
|
SIGNAL,
|
||||||
import { StockScorer } from '../scorers/StockScorer';
|
SIGNAL_ORDER,
|
||||||
import { EtfScorer } from '../scorers/EtfScorer';
|
SCORE_MODE,
|
||||||
import { BondScorer } from '../scorers/BondScorer';
|
ASSET_TYPE,
|
||||||
import { SIGNAL, SIGNAL_ORDER, SCORE_MODE, ASSET_TYPE } from '../config/constants';
|
} 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 {
|
import type {
|
||||||
Logger,
|
Logger,
|
||||||
MarketContext,
|
MarketContext,
|
||||||
@@ -23,7 +28,7 @@ import type {
|
|||||||
StockData,
|
StockData,
|
||||||
EtfData,
|
EtfData,
|
||||||
BondData,
|
BondData,
|
||||||
} from '../types';
|
} from '../../domains/shared';
|
||||||
|
|
||||||
export class ScreenerEngine {
|
export class ScreenerEngine {
|
||||||
private static readonly BATCH_SIZE = 5;
|
private static readonly BATCH_SIZE = 5;
|
||||||
@@ -36,6 +41,7 @@ export class ScreenerEngine {
|
|||||||
private readonly benchmarkProvider: BenchmarkProvider,
|
private readonly benchmarkProvider: BenchmarkProvider,
|
||||||
{ logger }: ScreenerEngineOptions = {},
|
{ logger }: ScreenerEngineOptions = {},
|
||||||
) {
|
) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
this.logger = logger ?? {
|
this.logger = logger ?? {
|
||||||
write: (msg: string) => process.stdout.write(msg),
|
write: (msg: string) => process.stdout.write(msg),
|
||||||
log: (...args: unknown[]) => console.log(...args),
|
log: (...args: unknown[]) => console.log(...args),
|
||||||
+11
-6
@@ -1,13 +1,18 @@
|
|||||||
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
||||||
import type { LLMAnalyst } from '../services/LLMAnalyst';
|
import type { LLMAnalyst } from '../../domains/shared';
|
||||||
import { CatalystAnalyst } from '../services/CatalystAnalyst';
|
import { CatalystCache, CatalystAnalyst } from '../../domains/shared';
|
||||||
import { analyzeSchema } from '../types/schemas';
|
import { analyzeSchema } from '../../domains/shared/types/schemas';
|
||||||
|
|
||||||
export class AnalyzeController {
|
export class AnalyzeController {
|
||||||
|
private readonly catalystAnalyst: CatalystAnalyst;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly catalyst: CatalystAnalyst,
|
private readonly catalystCache: CatalystCache,
|
||||||
private readonly llm: LLMAnalyst,
|
private readonly llm: LLMAnalyst,
|
||||||
) {}
|
) {
|
||||||
|
// Create a fresh instance for per-ticker story fetching (not cached)
|
||||||
|
this.catalystAnalyst = new CatalystAnalyst();
|
||||||
|
}
|
||||||
|
|
||||||
register(app: FastifyInstance): void {
|
register(app: FastifyInstance): void {
|
||||||
app.post(
|
app.post(
|
||||||
@@ -24,7 +29,7 @@ export class AnalyzeController {
|
|||||||
|
|
||||||
const tickers = (req.body as { tickers: string[] }).tickers.map((t) => t.toUpperCase());
|
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' });
|
if (!stories.length) return reply.code(200).send({ analysis: null, reason: 'no_stories' });
|
||||||
|
|
||||||
const { tickerFrequency } = CatalystAnalyst.rankTickers(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 {
|
export class BondScorer {
|
||||||
static score(
|
static score(
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { EtfMetrics, ScoreResult } from '../types';
|
import type { EtfMetrics, ScoreResult } from '../../../domains/shared';
|
||||||
|
|
||||||
export class EtfScorer {
|
export class EtfScorer {
|
||||||
static score(
|
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 {
|
export class StockScorer {
|
||||||
private static n(v: unknown): NumVal {
|
private static n(v: unknown): NumVal {
|
||||||
+9
-7
@@ -1,11 +1,14 @@
|
|||||||
import type { FastifyInstance, FastifyRequest } from 'fastify';
|
import type { FastifyInstance, FastifyRequest } from 'fastify';
|
||||||
import { ScreenerEngine, CatalystAnalyst } from '../services/index';
|
import { ScreenerEngine } from './ScreenerEngine';
|
||||||
import { noopLogger } from '../utils/logger';
|
import { CatalystCache } from '../../domains/shared';
|
||||||
import type { LiveAssetResult } from '../types';
|
import type { LiveAssetResult } from '../../domains/shared';
|
||||||
import { screenSchema } from '../types/schemas';
|
import { screenSchema } from '../../domains/shared/types/schemas';
|
||||||
|
|
||||||
export class ScreenerController {
|
export class ScreenerController {
|
||||||
constructor(private readonly engine: ScreenerEngine) {}
|
constructor(
|
||||||
|
private readonly engine: ScreenerEngine,
|
||||||
|
private readonly catalystCache: CatalystCache,
|
||||||
|
) {}
|
||||||
|
|
||||||
register(app: FastifyInstance): void {
|
register(app: FastifyInstance): void {
|
||||||
app.post(
|
app.post(
|
||||||
@@ -45,8 +48,7 @@ export class ScreenerController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async catalysts() {
|
private async catalysts() {
|
||||||
const catalyst = new CatalystAnalyst({ logger: noopLogger });
|
const { tickers, stories } = await this.catalystCache.get();
|
||||||
const { tickers, stories } = await catalyst.run();
|
|
||||||
return { tickers, stories };
|
return { tickers, stories };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { MappedData } from '../types';
|
import type { MappedData } from '../../../domains/shared';
|
||||||
|
|
||||||
// Internal: Yahoo Finance API response shape
|
// Internal: Yahoo Finance API response shape
|
||||||
type YahooSummary = Record<string, Record<string, unknown>>;
|
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 { ScoringRules } from '../../../domains/shared/scoring/ScoringConfig';
|
||||||
import { MarketRegime } from './MarketRegime';
|
import { MarketRegime } from '../../../domains/shared/scoring/MarketRegime';
|
||||||
import { SCORE_MODE } from '../config/constants';
|
import { SCORE_MODE } from '../../../domains/shared';
|
||||||
import type { AssetType, MarketContext, RuleSet } from '../types';
|
import type { AssetType, MarketContext, RuleSet } from '../../../domains/shared';
|
||||||
|
|
||||||
export class RuleMerger {
|
export class RuleMerger {
|
||||||
static getRulesForAsset(
|
static getRulesForAsset(
|
||||||
@@ -10,6 +10,7 @@ export class SimpleFINClient {
|
|||||||
|
|
||||||
constructor({ logger, onAccessUrlClaimed }: SimpleFINOptions = {}) {
|
constructor({ logger, onAccessUrlClaimed }: SimpleFINOptions = {}) {
|
||||||
this.accessUrl = null;
|
this.accessUrl = null;
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
this.logger = logger ?? {
|
this.logger = logger ?? {
|
||||||
write: (msg) => process.stdout.write(msg),
|
write: (msg) => process.stdout.write(msg),
|
||||||
log: (...args) => console.log(...args),
|
log: (...args) => console.log(...args),
|
||||||
@@ -157,9 +158,11 @@ export function saveAccessUrlToEnv(accessUrl: string): void {
|
|||||||
const existing = fs.existsSync('.env') ? fs.readFileSync('.env', 'utf8') : '';
|
const existing = fs.existsSync('.env') ? fs.readFileSync('.env', 'utf8') : '';
|
||||||
if (!existing.includes('SIMPLEFIN_ACCESS_URL')) {
|
if (!existing.includes('SIMPLEFIN_ACCESS_URL')) {
|
||||||
fs.appendFileSync('.env', `\nSIMPLEFIN_ACCESS_URL=${accessUrl}\n`);
|
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');
|
console.log('✅ Access URL saved to .env — you can remove SIMPLEFIN_SETUP_TOKEN\n');
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
console.log(`\nSave this to .env:\nSIMPLEFIN_ACCESS_URL=${accessUrl}\n`);
|
console.log(`\nSave this to .env:\nSIMPLEFIN_ACCESS_URL=${accessUrl}\n`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -16,13 +16,10 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type BetterSqlite3 from 'better-sqlite3';
|
import type BetterSqlite3 from 'better-sqlite3';
|
||||||
import { QueryBuilder } from './QueryBuilder';
|
import type { DatabaseOptions } from '../types/index';
|
||||||
import { QueryAudit, AuditAction } from './QueryAudit';
|
import { AuditAction } from '../types/index';
|
||||||
|
import { QueryBuilder } from '../utils/QueryBuilder';
|
||||||
export interface DatabaseOptions {
|
import { QueryAudit } from './QueryAudit';
|
||||||
audit?: QueryAudit;
|
|
||||||
logSlowQueries?: number; // milliseconds; logs queries slower than this
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DatabaseConnection — Safe, auditable, performant SQLite wrapper.
|
* DatabaseConnection — Safe, auditable, performant SQLite wrapper.
|
||||||
@@ -44,8 +41,8 @@ export class DatabaseConnection {
|
|||||||
* Logs the query to the audit trail.
|
* Logs the query to the audit trail.
|
||||||
*/
|
*/
|
||||||
all<T = Record<string, unknown>>(qb: QueryBuilder): T[] {
|
all<T = Record<string, unknown>>(qb: QueryBuilder): T[] {
|
||||||
const sql = qb.build();
|
const sql = qb.sql;
|
||||||
const params = qb.params();
|
const params = qb.queryParams;
|
||||||
const startMs = performance.now();
|
const startMs = performance.now();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -71,8 +68,8 @@ export class DatabaseConnection {
|
|||||||
* Logs the query to the audit trail.
|
* Logs the query to the audit trail.
|
||||||
*/
|
*/
|
||||||
get<T = Record<string, unknown>>(qb: QueryBuilder): T | null {
|
get<T = Record<string, unknown>>(qb: QueryBuilder): T | null {
|
||||||
const sql = qb.build();
|
const sql = qb.sql;
|
||||||
const params = qb.params();
|
const params = qb.queryParams;
|
||||||
const startMs = performance.now();
|
const startMs = performance.now();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -98,8 +95,8 @@ export class DatabaseConnection {
|
|||||||
* Logs the query to the audit trail.
|
* Logs the query to the audit trail.
|
||||||
*/
|
*/
|
||||||
run(qb: QueryBuilder): number {
|
run(qb: QueryBuilder): number {
|
||||||
const sql = qb.build();
|
const sql = qb.sql;
|
||||||
const params = qb.params();
|
const params = qb.queryParams;
|
||||||
const startMs = performance.now();
|
const startMs = performance.now();
|
||||||
|
|
||||||
// Determine audit action from SQL
|
// Determine audit action from SQL
|
||||||
@@ -169,6 +166,7 @@ export class DatabaseConnection {
|
|||||||
* Call db.printAudit() to see the most recent 100 queries.
|
* Call db.printAudit() to see the most recent 100 queries.
|
||||||
*/
|
*/
|
||||||
printAudit(): void {
|
printAudit(): void {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
console.log(this.audit.report());
|
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:
|
* Usage:
|
||||||
* const audit = new QueryAudit();
|
* const audit = new QueryAudit();
|
||||||
* audit.logQuery('SELECT * FROM holdings', [], 'READ');
|
* audit.log('SELECT * FROM holdings', [], AuditAction.READ, 1.5);
|
||||||
* audit.logQuery('UPDATE holdings SET shares = ? WHERE ticker = ?', [100, 'AAPL'], 'WRITE');
|
* audit.log('UPDATE holdings SET shares = ? WHERE ticker = ?', [100, 'AAPL'], AuditAction.WRITE, 0.8, 1);
|
||||||
*
|
*
|
||||||
* Provides:
|
* Provides:
|
||||||
* - Audit trail of all queries executed
|
* - Audit trail of all queries executed
|
||||||
@@ -13,21 +13,7 @@
|
|||||||
* - Optional persistent storage for compliance
|
* - Optional persistent storage for compliance
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export enum AuditAction {
|
import type { AuditAction, AuditEntry } from '../types/index';
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* QueryAudit — in-memory audit trail with optional callbacks.
|
* 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 { Asset } from './Asset';
|
||||||
import type { BondData, BondMetrics } from '../types/models.model';
|
import type { BondData, BondMetrics } from '../types/index';
|
||||||
|
|
||||||
export class Bond extends Asset {
|
export class Bond extends Asset {
|
||||||
metrics: BondMetrics;
|
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 { readFileSync, writeFileSync, existsSync } from 'fs';
|
||||||
import { YahooFinanceClient } from '../clients/YahooFinanceClient';
|
import { YahooFinanceClient } from '../adapters/YahooFinanceClient';
|
||||||
import { REGIME } from '../config/constants';
|
import { REGIME } from '../config/constants';
|
||||||
import type { MarketContext, Logger, BenchmarkProviderOptions } from '../types';
|
import type { MarketContext, Logger, BenchmarkProviderOptions } from '../types/index';
|
||||||
|
|
||||||
interface CacheFile {
|
interface CacheFile {
|
||||||
data: MarketContext;
|
data: MarketContext;
|
||||||
+2
-2
@@ -1,5 +1,5 @@
|
|||||||
import { YahooFinanceClient } from '../clients/YahooFinanceClient';
|
import { YahooFinanceClient } from '../adapters/YahooFinanceClient';
|
||||||
import type { Logger, CatalystResult, Story, YahooNewsItem } from '../types';
|
import type { Logger, CatalystResult, Story, YahooNewsItem } from '../types/index';
|
||||||
|
|
||||||
export class CatalystAnalyst {
|
export class CatalystAnalyst {
|
||||||
private static readonly NEWS_QUERIES = [
|
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 { readFileSync } from 'fs';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
import { AnthropicClient } from '../clients/AnthropicClient';
|
import { AnthropicClient } from '../adapters/AnthropicClient';
|
||||||
import type { Logger, LLMAnalysis, Story } from '../types';
|
import type { Logger, LLMAnalysis, Story } from '../types/index';
|
||||||
|
|
||||||
export class LLMAnalyst {
|
export class LLMAnalyst {
|
||||||
private logger: Pick<Logger, 'log' | 'warn'>;
|
private logger: Pick<Logger, 'log' | 'warn'>;
|
||||||
private client: AnthropicClient;
|
private client: AnthropicClient;
|
||||||
|
|
||||||
constructor({ logger }: { logger?: Pick<Logger, 'log' | 'warn'> } = {}) {
|
constructor({ logger }: { logger?: Pick<Logger, 'log' | 'warn'> } = {}) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
this.logger = logger ?? { log: console.log, warn: console.warn };
|
this.logger = logger ?? { log: console.log, warn: console.warn };
|
||||||
this.client = new AnthropicClient();
|
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,
|
BondData,
|
||||||
BondMetrics,
|
BondMetrics,
|
||||||
} from './models.model';
|
} 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 { NumVal, SanitizedMetrics, SanitizedBondMetrics } from './scorers.model';
|
||||||
export type {
|
export type {
|
||||||
BenchmarkProviderOptions,
|
BenchmarkProviderOptions,
|
||||||
@@ -63,3 +63,5 @@ export type {
|
|||||||
RuleSet,
|
RuleSet,
|
||||||
ScreenerEngineOptions,
|
ScreenerEngineOptions,
|
||||||
} from './services.model';
|
} 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 ──────────────────────────────────────────────────────
|
// ── Barrel re-export ──────────────────────────────────────────────────────
|
||||||
// All types now live in server/types/*.model.ts — import from there directly
|
// All types now live in server/domains/shared/types/*.model.ts
|
||||||
// for clarity, or from here for convenience (existing imports still work).
|
// For convenience, re-export from here for existing imports.
|
||||||
export type * from './types/index';
|
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[];
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
import { test } from 'node:test';
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
import { BondScorer } from '../server/scorers/BondScorer';
|
|
||||||
import type { MarketContext } from '../server/types';
|
|
||||||
|
|
||||||
// ytm is stored as a percentage value (e.g. 6.5 = 6.5%), matching how DataMapper outputs it.
|
|
||||||
// BondScorer._sanitize divides by 100 to convert to decimal before spread calculation.
|
|
||||||
|
|
||||||
const rules = {
|
|
||||||
gates: { minCreditRating: 7 },
|
|
||||||
weights: { yieldSpread: 3, duration: 2 },
|
|
||||||
thresholds: { minSpread: 1.0, maxDuration: 10 },
|
|
||||||
};
|
|
||||||
// BondScorer only uses riskFreeRate from context; cast the partial fixture to satisfy the type.
|
|
||||||
const ctx = { riskFreeRate: 4.5 } as MarketContext;
|
|
||||||
|
|
||||||
test('rejects bond below investment-grade floor', () => {
|
|
||||||
const result = BondScorer.score(
|
|
||||||
{ ytm: 8.0, duration: 5, creditRating: 'BB', creditRatingNumeric: 6 },
|
|
||||||
rules,
|
|
||||||
ctx,
|
|
||||||
);
|
|
||||||
assert.equal(result.label, '🔴 Avoid');
|
|
||||||
assert(result.scoreSummary.includes('Gate failed'));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('attractive for wide spread and short duration', () => {
|
|
||||||
// ytm=6.5%, riskFree=4.5% → spreadPct=(0.065-0.045)*100=2.0% >= minSpread 1.0%
|
|
||||||
const result = BondScorer.score(
|
|
||||||
{ ytm: 6.5, duration: 4, creditRating: 'AA', creditRatingNumeric: 9 },
|
|
||||||
rules,
|
|
||||||
ctx,
|
|
||||||
);
|
|
||||||
assert.equal(result.label, '🟢 Attractive');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('spread calculation: ytm% → decimal, subtract riskFreeRate/100, back to %', () => {
|
|
||||||
const result = BondScorer.score(
|
|
||||||
{ ytm: 6.5, duration: 5, creditRating: 'AAA', creditRatingNumeric: 10 },
|
|
||||||
rules,
|
|
||||||
ctx,
|
|
||||||
);
|
|
||||||
assert.equal(result.audit.breakdown!.spread, rules.weights.yieldSpread);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('fails spread when yield barely above risk-free', () => {
|
|
||||||
// ytm=4.7%, riskFree=4.5% → spreadPct=0.2% < minSpread 1.0%
|
|
||||||
const result = BondScorer.score(
|
|
||||||
{ ytm: 4.7, duration: 5, creditRating: 'AAA', creditRatingNumeric: 10 },
|
|
||||||
rules,
|
|
||||||
ctx,
|
|
||||||
);
|
|
||||||
assert.equal(result.audit.breakdown!.spread, -2);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('penalises long duration', () => {
|
|
||||||
const result = BondScorer.score(
|
|
||||||
{ ytm: 6.5, duration: 15, creditRating: 'AA', creditRatingNumeric: 9 },
|
|
||||||
rules,
|
|
||||||
ctx,
|
|
||||||
);
|
|
||||||
assert.equal(result.audit.breakdown!.duration, -1);
|
|
||||||
});
|
|
||||||
@@ -1,149 +0,0 @@
|
|||||||
import { test } from 'node:test';
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
import { DataMapper } from '../server/services/DataMapper';
|
|
||||||
|
|
||||||
const base = {
|
|
||||||
price: { quoteType: 'EQUITY', regularMarketPrice: 150 },
|
|
||||||
assetProfile: { sector: 'Technology', industry: 'Software', category: '' },
|
|
||||||
financialData: {
|
|
||||||
quickRatio: 1.2,
|
|
||||||
debtToEquity: 150,
|
|
||||||
freeCashflow: 5e9,
|
|
||||||
revenueGrowth: 0.15,
|
|
||||||
profitMargins: 0.25,
|
|
||||||
operatingMargins: 0.3,
|
|
||||||
returnOnEquity: 0.2,
|
|
||||||
earningsGrowth: 0.12,
|
|
||||||
operatingCashflow: 8e9,
|
|
||||||
},
|
|
||||||
defaultKeyStatistics: { pegRatio: null, forwardPE: 28, sharesOutstanding: 1e9, priceToBook: 12 },
|
|
||||||
summaryDetail: {
|
|
||||||
trailingAnnualDividendYield: 0.005,
|
|
||||||
trailingPE: 30,
|
|
||||||
beta: 1.2,
|
|
||||||
fiftyTwoWeekHigh: 200,
|
|
||||||
fiftyTwoWeekLow: 120,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
test('maps EQUITY quote type to STOCK', () => {
|
|
||||||
const result = DataMapper.mapToStandardFormat('AAPL', base);
|
|
||||||
assert.equal(result.type, 'STOCK');
|
|
||||||
assert.equal(result.ticker, 'AAPL');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('computes PEG from trailingPE / earningsGrowth when Yahoo returns null', () => {
|
|
||||||
const result = DataMapper.mapToStandardFormat('AAPL', base);
|
|
||||||
const expected = +(30 / (0.12 * 100)).toFixed(2); // trailingPE=30, earningsGrowth=12%
|
|
||||||
assert.equal(result.pegRatio, expected);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('uses Yahoo pegRatio when available', () => {
|
|
||||||
const summary = {
|
|
||||||
...base,
|
|
||||||
defaultKeyStatistics: { ...base.defaultKeyStatistics, pegRatio: 1.5 },
|
|
||||||
};
|
|
||||||
const result = DataMapper.mapToStandardFormat('AAPL', summary);
|
|
||||||
assert.equal(result.pegRatio, 1.5);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('debtToEquity is divided by 100', () => {
|
|
||||||
const result = DataMapper.mapToStandardFormat('AAPL', base);
|
|
||||||
assert.equal(result.debtToEquity, 1.5); // 150 / 100
|
|
||||||
});
|
|
||||||
|
|
||||||
test('maps ETF quoteType to ETF', () => {
|
|
||||||
const etfSummary = {
|
|
||||||
...base,
|
|
||||||
price: { ...base.price, quoteType: 'ETF' },
|
|
||||||
assetProfile: { category: 'Large Blend' },
|
|
||||||
};
|
|
||||||
const result = DataMapper.mapToStandardFormat('VOO', etfSummary);
|
|
||||||
assert.equal(result.type, 'ETF');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('classifies bond ETF from category keyword', () => {
|
|
||||||
const bondSummary = {
|
|
||||||
...base,
|
|
||||||
price: { ...base.price, quoteType: 'ETF' },
|
|
||||||
assetProfile: { category: 'Intermediate-Term Bond' },
|
|
||||||
};
|
|
||||||
const result = DataMapper.mapToStandardFormat('BND', bondSummary);
|
|
||||||
assert.equal(result.type, 'BOND');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('FCF yield is computed when data available', () => {
|
|
||||||
const result = DataMapper.mapToStandardFormat('AAPL', base);
|
|
||||||
assert.notEqual(result.fcfYield, null);
|
|
||||||
assert((result.fcfYield as number) > 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('peRatio prefers trailingPE over forwardPE', () => {
|
|
||||||
// trailingPE=30 in summaryDetail, forwardPE=28 in defaultKeyStatistics
|
|
||||||
const result = DataMapper.mapToStandardFormat('AAPL', base);
|
|
||||||
assert.equal(result.peRatio, 30); // trailing should win
|
|
||||||
});
|
|
||||||
|
|
||||||
test('negative FCF yield is preserved, not nulled', () => {
|
|
||||||
const negativeFcf = {
|
|
||||||
...base,
|
|
||||||
financialData: { ...base.financialData, freeCashflow: -2e9 },
|
|
||||||
};
|
|
||||||
const result = DataMapper.mapToStandardFormat('AAPL', negativeFcf);
|
|
||||||
assert.notEqual(result.fcfYield, null);
|
|
||||||
assert((result.fcfYield as number) < 0, 'negative FCF should produce negative yield, not null');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('ETF maps volume from summaryDetail', () => {
|
|
||||||
const etfSummary = {
|
|
||||||
...base,
|
|
||||||
price: { ...base.price, quoteType: 'ETF' },
|
|
||||||
assetProfile: { category: 'Large Blend' },
|
|
||||||
summaryDetail: {
|
|
||||||
...base.summaryDetail,
|
|
||||||
averageVolume: 5000000,
|
|
||||||
expenseRatio: 0.0003,
|
|
||||||
trailingAnnualDividendYield: 0.013,
|
|
||||||
},
|
|
||||||
defaultKeyStatistics: { fiveYearAverageReturn: 0.12 },
|
|
||||||
};
|
|
||||||
const result = DataMapper.mapToStandardFormat('VOO', etfSummary);
|
|
||||||
assert.equal(result.volume, 5000000);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('bond duration inferred from category — intermediate maps to 5y', () => {
|
|
||||||
const bondSummary = {
|
|
||||||
...base,
|
|
||||||
price: { ...base.price, quoteType: 'ETF' },
|
|
||||||
assetProfile: { category: 'Intermediate-Term Bond' },
|
|
||||||
summaryDetail: { yield: 0.045 },
|
|
||||||
defaultKeyStatistics: {},
|
|
||||||
};
|
|
||||||
const result = DataMapper.mapToStandardFormat('BND', bondSummary);
|
|
||||||
assert.equal(result.duration, 5);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('bond duration inferred from category — short-term maps to 2y', () => {
|
|
||||||
const bondSummary = {
|
|
||||||
...base,
|
|
||||||
price: { ...base.price, quoteType: 'ETF' },
|
|
||||||
assetProfile: { category: 'Short-Term Bond' },
|
|
||||||
summaryDetail: { yield: 0.05 },
|
|
||||||
defaultKeyStatistics: {},
|
|
||||||
};
|
|
||||||
const result = DataMapper.mapToStandardFormat('SHY', bondSummary);
|
|
||||||
assert.equal(result.duration, 2);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('metrics are null (not 0) when data missing', () => {
|
|
||||||
const sparse = {
|
|
||||||
price: { quoteType: 'EQUITY', regularMarketPrice: 100 },
|
|
||||||
financialData: {},
|
|
||||||
defaultKeyStatistics: {},
|
|
||||||
summaryDetail: {},
|
|
||||||
assetProfile: {},
|
|
||||||
};
|
|
||||||
const result = DataMapper.mapToStandardFormat('X', sparse);
|
|
||||||
assert.equal(result.pegRatio, null);
|
|
||||||
assert.equal(result.quickRatio, null);
|
|
||||||
});
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
import { test } from 'node:test';
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
import { EtfScorer } from '../server/scorers/EtfScorer';
|
|
||||||
import type { EtfMetrics } from '../server/types';
|
|
||||||
|
|
||||||
const rules = {
|
|
||||||
gates: { maxExpenseRatio: 0.5 },
|
|
||||||
weights: { yield: 2, lowCost: 3 },
|
|
||||||
thresholds: { minYield: 1.5, maxExpense: 0.1, minVolume: 500000 },
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper to build minimal EtfMetrics fixtures (totalAssets/fiveYearReturn unused by scorer).
|
|
||||||
const etf = (partial: Partial<EtfMetrics>): EtfMetrics => ({
|
|
||||||
totalAssets: 0,
|
|
||||||
fiveYearReturn: 0,
|
|
||||||
volume: 0,
|
|
||||||
yield: 0,
|
|
||||||
expenseRatio: 0,
|
|
||||||
...partial,
|
|
||||||
});
|
|
||||||
|
|
||||||
test('rejects ETF with expense ratio above gate', () => {
|
|
||||||
const result = EtfScorer.score(etf({ expenseRatio: 0.8, yield: 2.0 }), rules);
|
|
||||||
assert.equal(result.label, '🔴 REJECT');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('efficient label for low-cost, high-yield ETF', () => {
|
|
||||||
const result = EtfScorer.score(etf({ expenseRatio: 0.03, yield: 2.0, volume: 1000000 }), rules);
|
|
||||||
assert.equal(result.label, '🟢 Efficient');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('neutral when yield is below threshold', () => {
|
|
||||||
const result = EtfScorer.score(etf({ expenseRatio: 0.03, yield: 0.4, volume: 1000000 }), rules);
|
|
||||||
assert.equal(result.label, '🟡 Neutral');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('audit breakdown includes cost, yield, vol keys', () => {
|
|
||||||
const result = EtfScorer.score(etf({ expenseRatio: 0.03, yield: 2.0, volume: 1000000 }), rules);
|
|
||||||
assert(result.audit.breakdown!.cost != null);
|
|
||||||
assert(result.audit.breakdown!.yield != null);
|
|
||||||
assert(result.audit.breakdown!.vol != null);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('penalises ETF with volume below liquidity floor', () => {
|
|
||||||
const result = EtfScorer.score(etf({ expenseRatio: 0.03, yield: 2.0, volume: 100000 }), rules);
|
|
||||||
assert(result.audit.breakdown!.vol < 0, 'low-volume ETF should receive negative vol score');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('scores 5Y return when threshold configured', () => {
|
|
||||||
const rulesWithReturn = {
|
|
||||||
...rules,
|
|
||||||
weights: { ...rules.weights, fiveYearReturn: 2 },
|
|
||||||
thresholds: { ...rules.thresholds, minFiveYearReturn: 8.0 },
|
|
||||||
};
|
|
||||||
const good = EtfScorer.score(
|
|
||||||
etf({ expenseRatio: 0.03, yield: 2.0, volume: 1000000, fiveYearReturn: 10 }),
|
|
||||||
rulesWithReturn,
|
|
||||||
);
|
|
||||||
const poor = EtfScorer.score(
|
|
||||||
etf({ expenseRatio: 0.03, yield: 2.0, volume: 1000000, fiveYearReturn: 5 }),
|
|
||||||
rulesWithReturn,
|
|
||||||
);
|
|
||||||
assert(good.audit.breakdown!.fiveYearReturn > 0, 'strong 5Y return should score positively');
|
|
||||||
assert(poor.audit.breakdown!.fiveYearReturn < 0, 'weak 5Y return should score negatively');
|
|
||||||
});
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
import { test } from 'node:test';
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
|
|
||||||
// Test the markdown fence stripping logic in isolation —
|
|
||||||
// we don't instantiate LLMAnalyst (requires Anthropic SDK + API key).
|
|
||||||
// The regex is: raw.replace(/^```(?:json)?\s*/i, '').replace(/```\s*$/i, '').trim()
|
|
||||||
|
|
||||||
function stripFences(raw: string): string {
|
|
||||||
return raw
|
|
||||||
.replace(/^```(?:json)?\s*/i, '')
|
|
||||||
.replace(/```\s*$/i, '')
|
|
||||||
.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
const VALID_JSON =
|
|
||||||
'{"summary":"test","sentiment":"BULLISH","affectedIndustries":[],"relatedTickers":[]}';
|
|
||||||
|
|
||||||
test('stripFences: passes clean JSON through unchanged', () => {
|
|
||||||
assert.equal(stripFences(VALID_JSON), VALID_JSON);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('stripFences: strips ```json ... ``` fences', () => {
|
|
||||||
const wrapped = '```json\n' + VALID_JSON + '\n```';
|
|
||||||
assert.equal(stripFences(wrapped), VALID_JSON);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('stripFences: strips ``` ... ``` fences (no language tag)', () => {
|
|
||||||
const wrapped = '```\n' + VALID_JSON + '\n```';
|
|
||||||
assert.equal(stripFences(wrapped), VALID_JSON);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('stripFences: result is valid parseable JSON', () => {
|
|
||||||
const wrapped = '```json\n' + VALID_JSON + '\n```';
|
|
||||||
const parsed = JSON.parse(stripFences(wrapped));
|
|
||||||
assert.equal(parsed.sentiment, 'BULLISH');
|
|
||||||
assert.equal(parsed.summary, 'test');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('stripFences: handles no trailing newline before closing fence', () => {
|
|
||||||
const wrapped = '```json\n' + VALID_JSON + '```';
|
|
||||||
assert.equal(stripFences(wrapped), VALID_JSON);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('stripFences: case-insensitive fence tag', () => {
|
|
||||||
const wrapped = '```JSON\n' + VALID_JSON + '\n```';
|
|
||||||
assert.equal(stripFences(wrapped), VALID_JSON);
|
|
||||||
});
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
/**
|
|
||||||
* Unit tests for MarketCallRepository (SQLite-backed).
|
|
||||||
* Each test gets its own in-memory database so tests are fully isolated.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { test } from 'node:test';
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
import BetterSqlite3 from 'better-sqlite3';
|
|
||||||
import { MarketCallRepository } from '../server/repositories/MarketCallRepository';
|
|
||||||
|
|
||||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const DDL = `
|
|
||||||
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, snapshot TEXT NOT NULL,
|
|
||||||
created_at TEXT NOT NULL
|
|
||||||
);
|
|
||||||
`;
|
|
||||||
|
|
||||||
function makeRepo(): MarketCallRepository {
|
|
||||||
const db = new BetterSqlite3(':memory:');
|
|
||||||
db.pragma('journal_mode = WAL');
|
|
||||||
db.exec(DDL);
|
|
||||||
return new MarketCallRepository(db);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Fixtures ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const CALL_INPUT = {
|
|
||||||
title: 'Rate pivot play',
|
|
||||||
quarter: 'Q3 2025',
|
|
||||||
thesis: 'Fed cuts expected — rotate into duration and growth.',
|
|
||||||
tickers: ['TLT', 'QQQ'],
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
test('list() returns empty array on fresh db', () => {
|
|
||||||
assert.deepEqual(makeRepo().list(), []);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('create() returns call with id, createdAt, and correct fields', () => {
|
|
||||||
const call = makeRepo().create(CALL_INPUT);
|
|
||||||
assert.ok(call.id, 'id should be set');
|
|
||||||
assert.ok(call.createdAt, 'createdAt should be set');
|
|
||||||
assert.equal(call.title, CALL_INPUT.title);
|
|
||||||
assert.equal(call.quarter, CALL_INPUT.quarter);
|
|
||||||
assert.equal(call.thesis, CALL_INPUT.thesis);
|
|
||||||
assert.deepEqual(call.tickers, CALL_INPUT.tickers);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('create() persists — list() returns the created call', () => {
|
|
||||||
const repo = makeRepo();
|
|
||||||
repo.create(CALL_INPUT);
|
|
||||||
assert.equal(repo.list().length, 1);
|
|
||||||
assert.equal(repo.list()[0].title, CALL_INPUT.title);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('list() returns calls newest-first', () => {
|
|
||||||
const repo = makeRepo();
|
|
||||||
const db = (repo as any).db as BetterSqlite3.Database;
|
|
||||||
|
|
||||||
// Insert two rows with distinct created_at values directly
|
|
||||||
db.prepare(
|
|
||||||
`INSERT INTO market_calls (id, title, quarter, date, thesis, tickers, snapshot, created_at)
|
|
||||||
VALUES (?,?,?,?,?,?,?,?)`,
|
|
||||||
).run('old-id', 'First', 'Q1', '2025-01-01', 'A', '[]', '{}', '2025-01-01T00:00:00.000Z');
|
|
||||||
db.prepare(
|
|
||||||
`INSERT INTO market_calls (id, title, quarter, date, thesis, tickers, snapshot, created_at)
|
|
||||||
VALUES (?,?,?,?,?,?,?,?)`,
|
|
||||||
).run('new-id', 'Second', 'Q1', '2025-01-02', 'B', '[]', '{}', '2025-01-02T00:00:00.000Z');
|
|
||||||
|
|
||||||
const list = repo.list();
|
|
||||||
assert.equal(list[0].id, 'new-id', 'newer call should be first');
|
|
||||||
assert.equal(list[1].id, 'old-id', 'older call should be second');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('get() returns the call by id', () => {
|
|
||||||
const repo = makeRepo();
|
|
||||||
const call = repo.create(CALL_INPUT);
|
|
||||||
const found = repo.get(call.id);
|
|
||||||
assert.ok(found);
|
|
||||||
assert.equal(found!.id, call.id);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('get() returns null for unknown id', () => {
|
|
||||||
assert.equal(makeRepo().get('no-such-id'), null);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('delete() removes the call and returns true', () => {
|
|
||||||
const repo = makeRepo();
|
|
||||||
const call = repo.create(CALL_INPUT);
|
|
||||||
assert.equal(repo.delete(call.id), true);
|
|
||||||
assert.equal(repo.list().length, 0);
|
|
||||||
assert.equal(repo.get(call.id), null);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('delete() returns false for unknown id', () => {
|
|
||||||
assert.equal(makeRepo().delete('no-such-id'), false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('delete() only removes the targeted call', () => {
|
|
||||||
const repo = makeRepo();
|
|
||||||
const a = repo.create({ ...CALL_INPUT, title: 'Keep me' });
|
|
||||||
const b = repo.create({ ...CALL_INPUT, title: 'Delete me' });
|
|
||||||
repo.delete(b.id);
|
|
||||||
const list = repo.list();
|
|
||||||
assert.equal(list.length, 1);
|
|
||||||
assert.equal(list[0].id, a.id);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('create() stores snapshot when provided', () => {
|
|
||||||
const repo = makeRepo();
|
|
||||||
const snapshot = { TLT: { price: 95.5, signal: '✅ Strong Buy' } };
|
|
||||||
const call = repo.create({ ...CALL_INPUT, snapshot } as any);
|
|
||||||
assert.deepEqual(repo.get(call.id)!.snapshot, snapshot);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('create() sets default date when not provided', () => {
|
|
||||||
const call = makeRepo().create(CALL_INPUT);
|
|
||||||
assert.match(call.date, /^\d{4}-\d{2}-\d{2}$/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('create() uses provided date', () => {
|
|
||||||
const call = makeRepo().create({ ...CALL_INPUT, date: '2025-03-15' });
|
|
||||||
assert.equal(call.date, '2025-03-15');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('concurrent writes: two rapid creates both persist (SQLite WAL is concurrency-safe)', () => {
|
|
||||||
const repo = makeRepo();
|
|
||||||
const a = repo.create({ ...CALL_INPUT, title: 'A' });
|
|
||||||
const b = repo.create({ ...CALL_INPUT, title: 'B' });
|
|
||||||
const list = repo.list();
|
|
||||||
assert.equal(list.length, 2);
|
|
||||||
const ids = new Set(list.map((c) => c.id));
|
|
||||||
assert.ok(ids.has(a.id));
|
|
||||||
assert.ok(ids.has(b.id));
|
|
||||||
});
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
import { test } from 'node:test';
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
import { MarketRegime } from '../server/services/MarketRegime';
|
|
||||||
import { SECTOR, ASSET_TYPE } from '../server/config/constants';
|
|
||||||
import type { Benchmarks, RateRegime } from '../server/types';
|
|
||||||
|
|
||||||
const regime = (benchmarks: Partial<Benchmarks>, extra: { rateRegime?: RateRegime } = {}) =>
|
|
||||||
new MarketRegime({ benchmarks: benchmarks as Benchmarks, ...extra });
|
|
||||||
|
|
||||||
test('stock inflated P/E = marketPE × 1.5', () => {
|
|
||||||
const { gates } = regime({ marketPE: 24 }).getInflatedOverrides(ASSET_TYPE.STOCK, SECTOR.GENERAL);
|
|
||||||
assert.equal(gates.maxPERatio, Math.round(24 * 1.5)); // 36
|
|
||||||
});
|
|
||||||
|
|
||||||
test('tech inflated P/E = techPE × 1.3', () => {
|
|
||||||
const { gates } = regime({ techPE: 40 }).getInflatedOverrides(
|
|
||||||
ASSET_TYPE.STOCK,
|
|
||||||
SECTOR.TECHNOLOGY,
|
|
||||||
);
|
|
||||||
assert.equal(gates.maxPERatio, Math.round(40 * 1.3)); // 52
|
|
||||||
});
|
|
||||||
|
|
||||||
test('REIT inflated minYield = reitYield × 0.85 in NORMAL rate regime', () => {
|
|
||||||
const { thresholds } = regime({ reitYield: 4.0 }, { rateRegime: 'NORMAL' }).getInflatedOverrides(
|
|
||||||
ASSET_TYPE.STOCK,
|
|
||||||
SECTOR.REIT,
|
|
||||||
);
|
|
||||||
assert.equal(thresholds.minYield, +(4.0 * 0.85).toFixed(2)); // 3.40
|
|
||||||
});
|
|
||||||
|
|
||||||
test('REIT inflated minYield = reitYield × 0.95 in HIGH rate regime', () => {
|
|
||||||
const { thresholds } = regime({ reitYield: 4.0 }, { rateRegime: 'HIGH' }).getInflatedOverrides(
|
|
||||||
ASSET_TYPE.STOCK,
|
|
||||||
SECTOR.REIT,
|
|
||||||
);
|
|
||||||
assert.equal(thresholds.minYield, +(4.0 * 0.95).toFixed(2)); // 3.80
|
|
||||||
});
|
|
||||||
|
|
||||||
test('bond inflated minSpread = igSpread × 0.80 in NORMAL rate regime', () => {
|
|
||||||
const { thresholds } = regime({ igSpread: 1.5 }, { rateRegime: 'NORMAL' }).getInflatedOverrides(
|
|
||||||
ASSET_TYPE.BOND,
|
|
||||||
SECTOR.GENERAL,
|
|
||||||
);
|
|
||||||
assert.equal(thresholds.minSpread, +(1.5 * 0.8).toFixed(2)); // 1.20
|
|
||||||
});
|
|
||||||
|
|
||||||
test('bond inflated minSpread = igSpread × 0.90 in HIGH rate regime', () => {
|
|
||||||
const { thresholds } = regime({ igSpread: 1.5 }, { rateRegime: 'HIGH' }).getInflatedOverrides(
|
|
||||||
ASSET_TYPE.BOND,
|
|
||||||
SECTOR.GENERAL,
|
|
||||||
);
|
|
||||||
assert.equal(thresholds.minSpread, +(1.5 * 0.9).toFixed(2)); // 1.35
|
|
||||||
});
|
|
||||||
|
|
||||||
test('GENERAL stock P/E multiplier compresses to 1.2× in HIGH rate regime', () => {
|
|
||||||
const { gates } = regime({ marketPE: 25 }, { rateRegime: 'HIGH' }).getInflatedOverrides(
|
|
||||||
ASSET_TYPE.STOCK,
|
|
||||||
SECTOR.GENERAL,
|
|
||||||
);
|
|
||||||
assert.equal(gates.maxPERatio, Math.round(25 * 1.2)); // 30
|
|
||||||
});
|
|
||||||
|
|
||||||
test('ETF inflated loosens expense gate to 0.75', () => {
|
|
||||||
const { gates } = regime({}).getInflatedOverrides(ASSET_TYPE.ETF);
|
|
||||||
assert.equal(gates.maxExpenseRatio, 0.75);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('falls back to defaults when benchmarks missing', () => {
|
|
||||||
const { gates } = new MarketRegime({}).getInflatedOverrides(ASSET_TYPE.STOCK, SECTOR.GENERAL);
|
|
||||||
assert.equal(gates.maxPERatio, Math.round(22 * 1.5)); // default marketPE = 22
|
|
||||||
});
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
import { test } from 'node:test';
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
import { PortfolioAdvisor } from '../server/services/PortfolioAdvisor';
|
|
||||||
import { SIGNAL } from '../server/config/constants';
|
|
||||||
import type { PortfolioHolding } from '../server/types';
|
|
||||||
import type { YahooFinanceClient } from '../server/clients/YahooFinanceClient';
|
|
||||||
|
|
||||||
// _cryptoPrices is the only method that uses the client; all other private
|
|
||||||
// methods under test are pure calculations that never touch it.
|
|
||||||
const stubClient = {} as unknown as YahooFinanceClient;
|
|
||||||
|
|
||||||
// Cast to any to access private methods — tests exercise internal behaviour directly.
|
|
||||||
const advisor = new PortfolioAdvisor(stubClient) as any;
|
|
||||||
|
|
||||||
// Minimal holding shape used by position and advice (only costBasis/shares matter).
|
|
||||||
const holding = (costBasis: number, shares: number): PortfolioHolding => ({
|
|
||||||
ticker: 'TEST',
|
|
||||||
source: 'Test',
|
|
||||||
type: 'stock',
|
|
||||||
costBasis,
|
|
||||||
shares,
|
|
||||||
});
|
|
||||||
|
|
||||||
test('_position: computes gain/loss correctly', () => {
|
|
||||||
const pos = advisor.position(holding(100, 10), 150);
|
|
||||||
assert.equal(pos.gainLossPct, '50.0');
|
|
||||||
assert.equal(pos.marketValue, '1500.00');
|
|
||||||
assert.equal(pos.totalCost, '1000.00');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('_position: returns null gainLoss when price unavailable', () => {
|
|
||||||
const pos = advisor.position(holding(100, 10), null);
|
|
||||||
assert.equal(pos.gainLossPct, null);
|
|
||||||
assert.equal(pos.marketValue, null);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('_advice: Strong Buy → Hold & Add', () => {
|
|
||||||
const { action } = advisor.advice(SIGNAL.STRONG_BUY, holding(100, 10), 150);
|
|
||||||
assert.equal(action, '🟢 Hold & Add');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('_advice: Avoid + loss → Sell (Cut Loss)', () => {
|
|
||||||
const { action } = advisor.advice(SIGNAL.AVOID, holding(150, 10), 100);
|
|
||||||
assert.equal(action, '🔴 Sell (Cut Loss)');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('_advice: Avoid + profit → Sell (Take Profits)', () => {
|
|
||||||
const { action } = advisor.advice(SIGNAL.AVOID, holding(100, 10), 150);
|
|
||||||
assert.equal(action, '🔴 Sell (Take Profits)');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('_advice: Speculation + >20% gain → Reduce Position', () => {
|
|
||||||
const { action } = advisor.advice(SIGNAL.SPECULATION, holding(100, 10), 125);
|
|
||||||
assert.equal(action, '🟠 Reduce Position');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('_cryptoAdvice: no price → No price data', () => {
|
|
||||||
const { action } = advisor.cryptoAdvice(holding(100, 1), null);
|
|
||||||
assert.equal(action, '⚪ No price data');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('_cryptoAdvice: >100% gain → Consider taking profits', () => {
|
|
||||||
const { action } = advisor.cryptoAdvice(holding(10000, 1), 25000);
|
|
||||||
assert.equal(action, '🟠 Consider taking profits');
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Result map dot-notation normalisation (BRK.B / BRK-B) ───────────────────
|
|
||||||
|
|
||||||
test('advise: BRK-B screener result matches BRK.B holding', async () => {
|
|
||||||
const mockResult = {
|
|
||||||
asset: { ticker: 'BRK-B', currentPrice: 500 },
|
|
||||||
signal: SIGNAL.STRONG_BUY,
|
|
||||||
inflated: { label: '🟢 BUY (High Conviction)' },
|
|
||||||
fundamental: { label: '🟢 BUY (High Conviction)' },
|
|
||||||
};
|
|
||||||
const screenedResults = { STOCK: [mockResult], ETF: [], BOND: [] };
|
|
||||||
const holding: PortfolioHolding = {
|
|
||||||
ticker: 'BRK.B',
|
|
||||||
shares: 1,
|
|
||||||
costBasis: 400,
|
|
||||||
type: 'stock',
|
|
||||||
source: 'Robinhood',
|
|
||||||
};
|
|
||||||
|
|
||||||
const advice = await advisor.advise([holding], screenedResults);
|
|
||||||
// Should match and return a real signal, not "Not screened"
|
|
||||||
assert.equal(advice[0].signal, SIGNAL.STRONG_BUY);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('advise: BRK.B screener result matches BRK-B holding', async () => {
|
|
||||||
const mockResult = {
|
|
||||||
asset: { ticker: 'BRK.B', currentPrice: 500 },
|
|
||||||
signal: SIGNAL.STRONG_BUY,
|
|
||||||
inflated: { label: '🟢 BUY (High Conviction)' },
|
|
||||||
fundamental: { label: '🟢 BUY (High Conviction)' },
|
|
||||||
};
|
|
||||||
const screenedResults = { STOCK: [mockResult], ETF: [], BOND: [] };
|
|
||||||
const holding: PortfolioHolding = {
|
|
||||||
ticker: 'BRK-B',
|
|
||||||
shares: 1,
|
|
||||||
costBasis: 400,
|
|
||||||
type: 'stock',
|
|
||||||
source: 'Robinhood',
|
|
||||||
};
|
|
||||||
|
|
||||||
const advice = await advisor.advise([holding], screenedResults);
|
|
||||||
assert.equal(advice[0].signal, SIGNAL.STRONG_BUY);
|
|
||||||
});
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
import { test } from 'node:test';
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
import { RuleMerger } from '../server/services/RuleMerger';
|
|
||||||
import { SCORE_MODE } from '../server/config/constants';
|
|
||||||
import type { MarketContext } from '../server/types';
|
|
||||||
|
|
||||||
const ctx = {
|
|
||||||
benchmarks: { marketPE: 25, techPE: 32, reitYield: 3.8, igSpread: 1.2 },
|
|
||||||
} as Partial<MarketContext>;
|
|
||||||
|
|
||||||
test('FUNDAMENTAL mode returns Graham-style P/E gate', () => {
|
|
||||||
const rules = RuleMerger.getRulesForAsset(
|
|
||||||
'STOCK',
|
|
||||||
{ sector: 'GENERAL' },
|
|
||||||
ctx as MarketContext,
|
|
||||||
SCORE_MODE.FUNDAMENTAL,
|
|
||||||
);
|
|
||||||
assert.equal(rules.gates.maxPERatio, 15); // updated: Graham's real rule is 15x
|
|
||||||
assert.equal(rules.gates.maxPegGate, 1.0); // updated: Lynch PEG standard
|
|
||||||
});
|
|
||||||
|
|
||||||
test('INFLATED mode loosens P/E gate from live SPY data', () => {
|
|
||||||
const rules = RuleMerger.getRulesForAsset(
|
|
||||||
'STOCK',
|
|
||||||
{ sector: 'GENERAL' },
|
|
||||||
ctx as MarketContext,
|
|
||||||
SCORE_MODE.INFLATED,
|
|
||||||
);
|
|
||||||
assert.equal(rules.gates.maxPERatio, Math.round(25 * 1.5)); // 37
|
|
||||||
assert(rules.gates.maxPERatio > 15, 'Inflated P/E should exceed fundamental 15x');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('INFLATED tech P/E gate uses XLK benchmark', () => {
|
|
||||||
const rules = RuleMerger.getRulesForAsset(
|
|
||||||
'STOCK',
|
|
||||||
{ sector: 'TECHNOLOGY' },
|
|
||||||
ctx as MarketContext,
|
|
||||||
SCORE_MODE.INFLATED,
|
|
||||||
);
|
|
||||||
assert.equal(rules.gates.maxPERatio, Math.round(32 * 1.3)); // 42
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Sector override applied before inflated overrides', () => {
|
|
||||||
const rules = RuleMerger.getRulesForAsset(
|
|
||||||
'STOCK',
|
|
||||||
{ sector: 'REIT' },
|
|
||||||
ctx as MarketContext,
|
|
||||||
SCORE_MODE.FUNDAMENTAL,
|
|
||||||
);
|
|
||||||
assert.equal(rules.gates.maxPERatio, 9999);
|
|
||||||
assert.equal(rules.weights.yield, 5);
|
|
||||||
assert.equal(rules.weights.margin, 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('SECTOR_OVERRIDE is deleted from returned rules', () => {
|
|
||||||
const rules = RuleMerger.getRulesForAsset(
|
|
||||||
'STOCK',
|
|
||||||
{ sector: 'GENERAL' },
|
|
||||||
ctx as MarketContext,
|
|
||||||
SCORE_MODE.FUNDAMENTAL,
|
|
||||||
) as unknown as Record<string, unknown>;
|
|
||||||
assert.equal(rules.SECTOR_OVERRIDE, undefined);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('throws for unknown asset type', () => {
|
|
||||||
assert.throws(
|
|
||||||
() => RuleMerger.getRulesForAsset('CRYPTO' as never, {}, ctx as MarketContext),
|
|
||||||
/No rules configured/,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
import { test } from 'node:test';
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
import { CREDIT_RATING_SCALE, ScoringRules } from '../server/config/ScoringConfig';
|
|
||||||
|
|
||||||
test('CREDIT_RATING_SCALE covers full spectrum', () => {
|
|
||||||
assert.equal(CREDIT_RATING_SCALE.AAA, 10);
|
|
||||||
assert.equal(CREDIT_RATING_SCALE.BBB, 7);
|
|
||||||
assert.equal(CREDIT_RATING_SCALE.BB, 6);
|
|
||||||
assert.equal(CREDIT_RATING_SCALE.D, 1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('STOCK base gates are fundamental (Graham-style)', () => {
|
|
||||||
const { gates } = ScoringRules.STOCK;
|
|
||||||
assert.equal(gates.maxPERatio, 15); // Graham's actual rule: 15x trailing earnings
|
|
||||||
assert.equal(gates.maxPegGate, 1.0); // Lynch standard: PEG > 1.0 is paying full price
|
|
||||||
assert.equal(gates.minQuickRatio, 0.8); // below 0.8 signals liquidity stress
|
|
||||||
});
|
|
||||||
|
|
||||||
test('REIT sector override zeroes out irrelevant weights', () => {
|
|
||||||
const reit = ScoringRules.STOCK.SECTOR_OVERRIDE.REIT!;
|
|
||||||
assert.equal(reit.weights!.margin, 0);
|
|
||||||
assert.equal(reit.weights!.peg, 0);
|
|
||||||
assert.equal(reit.weights!.revenue, 0);
|
|
||||||
assert.equal(reit.weights!.yield, 5);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('REIT gates disable P/E and PEG', () => {
|
|
||||||
const reit = ScoringRules.STOCK.SECTOR_OVERRIDE.REIT!;
|
|
||||||
assert.equal(reit.gates!.maxPERatio, 9999);
|
|
||||||
assert.equal(reit.gates!.maxPegGate, 9999);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('TECHNOLOGY gates are realistic for mega-cap', () => {
|
|
||||||
const tech = ScoringRules.STOCK.SECTOR_OVERRIDE.TECHNOLOGY!;
|
|
||||||
assert.equal(tech.gates!.maxDebtToEquity, 2.0);
|
|
||||||
assert.equal(tech.gates!.minQuickRatio, 0.8);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('BOND requires investment-grade floor (BBB = 7)', () => {
|
|
||||||
assert.equal(ScoringRules.BOND.gates.minCreditRating, 7);
|
|
||||||
});
|
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
import { test } from 'node:test';
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
import { StockScorer } from '../server/scorers/StockScorer';
|
|
||||||
import type { StockMetrics } from '../server/types';
|
|
||||||
|
|
||||||
const baseRules = {
|
|
||||||
gates: { maxDebtToEquity: 3.0, minQuickRatio: 0.5, maxPERatio: 20, maxPegGate: 1.5 },
|
|
||||||
weights: { margin: 2, opMargin: 2, roe: 3, peg: 2, revenue: 2, fcf: 2 },
|
|
||||||
thresholds: {
|
|
||||||
marginHigh: 20,
|
|
||||||
marginMed: 10,
|
|
||||||
opMarginHigh: 20,
|
|
||||||
opMarginMed: 10,
|
|
||||||
roeHigh: 20,
|
|
||||||
roeMed: 10,
|
|
||||||
pegHigh: 1.0,
|
|
||||||
pegMed: 1.5,
|
|
||||||
revHigh: 15,
|
|
||||||
revMed: 5,
|
|
||||||
fcfHigh: 5,
|
|
||||||
fcfMed: 2,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// Minimal fixture — tests exercise specific fields; unused metrics are null.
|
|
||||||
const nullMetrics: Omit<
|
|
||||||
StockMetrics,
|
|
||||||
| 'sector'
|
|
||||||
| 'capCategory'
|
|
||||||
| 'growthCategory'
|
|
||||||
| 'currentPrice'
|
|
||||||
| 'peRatio'
|
|
||||||
| 'pegRatio'
|
|
||||||
| 'debtToEquity'
|
|
||||||
| 'quickRatio'
|
|
||||||
| 'returnOnEquity'
|
|
||||||
| 'operatingMargin'
|
|
||||||
| 'netProfitMargin'
|
|
||||||
| 'revenueGrowth'
|
|
||||||
| 'fcfYield'
|
|
||||||
> = {
|
|
||||||
priceToBook: null,
|
|
||||||
grossMargin: null,
|
|
||||||
earningsGrowth: null,
|
|
||||||
pFFO: null,
|
|
||||||
dividendYield: null,
|
|
||||||
beta: null,
|
|
||||||
week52High: null,
|
|
||||||
week52Low: null,
|
|
||||||
week52Change: null,
|
|
||||||
week52FromHigh: null,
|
|
||||||
week52FromLow: null,
|
|
||||||
marketCap: null,
|
|
||||||
analystRating: null,
|
|
||||||
analystTargetPrice: null,
|
|
||||||
analystUpside: null,
|
|
||||||
numberOfAnalysts: null,
|
|
||||||
dcfIntrinsicValue: null,
|
|
||||||
dcfMarginOfSafety: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
const pass: StockMetrics = {
|
|
||||||
...nullMetrics,
|
|
||||||
sector: 'GENERAL',
|
|
||||||
capCategory: 'Large Cap',
|
|
||||||
growthCategory: 'Growth',
|
|
||||||
currentPrice: 150,
|
|
||||||
peRatio: 15,
|
|
||||||
pegRatio: 1.2,
|
|
||||||
debtToEquity: 1.0,
|
|
||||||
quickRatio: 1.0,
|
|
||||||
returnOnEquity: 22,
|
|
||||||
operatingMargin: 25,
|
|
||||||
netProfitMargin: 18,
|
|
||||||
revenueGrowth: 16,
|
|
||||||
fcfYield: 6,
|
|
||||||
};
|
|
||||||
|
|
||||||
test('rejects on high D/E', () => {
|
|
||||||
const result = StockScorer.score({ ...pass, debtToEquity: 4.0 }, baseRules);
|
|
||||||
assert.equal(result.label, '🔴 REJECT');
|
|
||||||
assert(result.scoreSummary.includes('D/E'));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('rejects on high P/E', () => {
|
|
||||||
const result = StockScorer.score({ ...pass, peRatio: 25 }, baseRules);
|
|
||||||
assert.equal(result.label, '🔴 REJECT');
|
|
||||||
assert(result.scoreSummary.includes('P/E'));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('rejects on high PEG', () => {
|
|
||||||
const result = StockScorer.score({ ...pass, pegRatio: 2.0 }, baseRules);
|
|
||||||
assert.equal(result.label, '🔴 REJECT');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('skips gate when metric is null (missing data)', () => {
|
|
||||||
const result = StockScorer.score({ ...pass, pegRatio: null, peRatio: null }, baseRules);
|
|
||||||
assert.notEqual(result.label, '🔴 REJECT');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('high-conviction BUY on strong metrics', () => {
|
|
||||||
const result = StockScorer.score(pass, baseRules);
|
|
||||||
assert.equal(result.label, '🟢 BUY (High Conviction)');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('audit breakdown contains scored factors', () => {
|
|
||||||
const result = StockScorer.score(pass, baseRules);
|
|
||||||
assert(result.audit.passedGates);
|
|
||||||
assert(result.audit.breakdown!.roe != null);
|
|
||||||
assert(result.audit.breakdown!.margin != null);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('beta > 1.5 surfaces as risk flag', () => {
|
|
||||||
const result = StockScorer.score({ ...pass, beta: 2.0 }, baseRules);
|
|
||||||
assert(result.audit.riskFlags?.some((f) => f.includes('High volatility')));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('near 52-week high surfaces as risk flag', () => {
|
|
||||||
const result = StockScorer.score(
|
|
||||||
{ ...pass, week52High: 200, week52Low: 100, currentPrice: 195 },
|
|
||||||
baseRules,
|
|
||||||
);
|
|
||||||
assert(result.audit.riskFlags?.some((f) => f.includes('52-week high')));
|
|
||||||
});
|
|
||||||
@@ -1,214 +0,0 @@
|
|||||||
/**
|
|
||||||
* Integration tests for CallsController
|
|
||||||
* Uses Fastify inject() with an in-memory MarketCallRepository stub.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { test } from 'node:test';
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
import Fastify from 'fastify';
|
|
||||||
import cors from '@fastify/cors';
|
|
||||||
import { CallsController } from '../server/controllers/calls.controller';
|
|
||||||
import type { ScreenerEngine } from '../server/services/ScreenerEngine';
|
|
||||||
import type { CalendarService } from '../server/services/CalendarService';
|
|
||||||
import type { MarketCall, ScreenerResult, MarketContext, CreateCallInput } from '../server/types';
|
|
||||||
|
|
||||||
// ── Stubs ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const MARKET_CTX: MarketContext = {
|
|
||||||
sp500Price: 5000,
|
|
||||||
riskFreeRate: 4.5,
|
|
||||||
vixLevel: 18,
|
|
||||||
rateRegime: 'NORMAL',
|
|
||||||
volatilityRegime: 'NORMAL',
|
|
||||||
benchmarks: { marketPE: 22, techPE: 30, reitYield: 3.5, igSpread: 1.0 },
|
|
||||||
};
|
|
||||||
|
|
||||||
const EMPTY_RESULT: ScreenerResult = {
|
|
||||||
STOCK: [],
|
|
||||||
ETF: [],
|
|
||||||
BOND: [],
|
|
||||||
ERROR: [],
|
|
||||||
marketContext: MARKET_CTX,
|
|
||||||
};
|
|
||||||
|
|
||||||
const stubEngine = {
|
|
||||||
screenTickers: async () => EMPTY_RESULT,
|
|
||||||
} as unknown as ScreenerEngine;
|
|
||||||
|
|
||||||
const stubCalendar = {
|
|
||||||
getEvents: async () => ({ events: [], tickers: [] }),
|
|
||||||
} as unknown as CalendarService;
|
|
||||||
|
|
||||||
// In-memory MarketCallRepository stub
|
|
||||||
function makeRepoStub() {
|
|
||||||
const calls: (MarketCall & { createdAt: string })[] = [];
|
|
||||||
return {
|
|
||||||
list: () =>
|
|
||||||
[...calls].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()),
|
|
||||||
get: (id: string) => calls.find((c) => c.id === id) ?? null,
|
|
||||||
create: ({
|
|
||||||
title,
|
|
||||||
quarter,
|
|
||||||
date,
|
|
||||||
thesis,
|
|
||||||
tickers,
|
|
||||||
snapshot,
|
|
||||||
}: CreateCallInput & { snapshot: any }) => {
|
|
||||||
const call = {
|
|
||||||
id: `call-${calls.length + 1}`,
|
|
||||||
title,
|
|
||||||
quarter,
|
|
||||||
date: date ?? new Date().toISOString().slice(0, 10),
|
|
||||||
thesis,
|
|
||||||
tickers,
|
|
||||||
snapshot,
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
calls.push(call);
|
|
||||||
return call;
|
|
||||||
},
|
|
||||||
delete: (id: string) => {
|
|
||||||
const idx = calls.findIndex((c) => c.id === id);
|
|
||||||
if (idx === -1) return false;
|
|
||||||
calls.splice(idx, 1);
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── App factory ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async function buildTestApp() {
|
|
||||||
const app = Fastify({ logger: false });
|
|
||||||
await app.register(cors, { origin: '*' });
|
|
||||||
new CallsController(makeRepoStub() as any, stubEngine, stubCalendar).register(app);
|
|
||||||
await app.ready();
|
|
||||||
return app;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
test('GET /api/calls → 200 with empty calls list', async () => {
|
|
||||||
const app = await buildTestApp();
|
|
||||||
const res = await app.inject({ method: 'GET', url: '/api/calls' });
|
|
||||||
assert.equal(res.statusCode, 200);
|
|
||||||
assert.deepEqual(res.json(), { calls: [] });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('POST /api/calls → 201 and returns the created call', async () => {
|
|
||||||
const app = await buildTestApp();
|
|
||||||
const res = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/api/calls',
|
|
||||||
payload: {
|
|
||||||
title: 'Q3 rate pivot play',
|
|
||||||
quarter: 'Q3 2025',
|
|
||||||
thesis: 'Fed cuts incoming — rotate into duration and growth.',
|
|
||||||
tickers: ['TLT', 'QQQ'],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
assert.equal(res.statusCode, 201);
|
|
||||||
const body = res.json();
|
|
||||||
assert.equal(body.title, 'Q3 rate pivot play');
|
|
||||||
assert.deepEqual(body.tickers, ['TLT', 'QQQ']);
|
|
||||||
assert.ok(body.id);
|
|
||||||
assert.ok(body.createdAt);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('POST /api/calls → created call appears in GET /api/calls', async () => {
|
|
||||||
const app = await buildTestApp();
|
|
||||||
await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/api/calls',
|
|
||||||
payload: {
|
|
||||||
title: 'AI semiconductor cycle',
|
|
||||||
quarter: 'Q4 2025',
|
|
||||||
thesis: 'Capex cycle benefits chip designers more than hyperscalers.',
|
|
||||||
tickers: ['NVDA', 'AMD'],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const listRes = await app.inject({ method: 'GET', url: '/api/calls' });
|
|
||||||
assert.equal(listRes.json().calls.length, 1);
|
|
||||||
assert.equal(listRes.json().calls[0].title, 'AI semiconductor cycle');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('POST /api/calls with missing required fields → 400', async () => {
|
|
||||||
const app = await buildTestApp();
|
|
||||||
const res = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/api/calls',
|
|
||||||
payload: { title: 'incomplete' }, // missing quarter, thesis, tickers
|
|
||||||
});
|
|
||||||
assert.equal(res.statusCode, 400);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('POST /api/calls with thesis too short → 400', async () => {
|
|
||||||
const app = await buildTestApp();
|
|
||||||
const res = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/api/calls',
|
|
||||||
payload: { title: 'Test', quarter: 'Q1', thesis: 'short', tickers: ['AAPL'] },
|
|
||||||
});
|
|
||||||
assert.equal(res.statusCode, 400);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('DELETE /api/calls/:id on non-existent id → 404', async () => {
|
|
||||||
const app = await buildTestApp();
|
|
||||||
const res = await app.inject({ method: 'DELETE', url: '/api/calls/nonexistent' });
|
|
||||||
assert.equal(res.statusCode, 404);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('DELETE /api/calls/:id removes the call', async () => {
|
|
||||||
const app = await buildTestApp();
|
|
||||||
const created = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/api/calls',
|
|
||||||
payload: {
|
|
||||||
title: 'Call to delete',
|
|
||||||
quarter: 'Q1 2025',
|
|
||||||
thesis: 'This call will be deleted in the test.',
|
|
||||||
tickers: ['SPY'],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const { id } = created.json();
|
|
||||||
|
|
||||||
const del = await app.inject({ method: 'DELETE', url: `/api/calls/${id}` });
|
|
||||||
assert.equal(del.statusCode, 200);
|
|
||||||
assert.deepEqual(del.json(), { ok: true });
|
|
||||||
|
|
||||||
const list = await app.inject({ method: 'GET', url: '/api/calls' });
|
|
||||||
assert.equal(list.json().calls.length, 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('GET /api/calls/:id on non-existent id → 404', async () => {
|
|
||||||
const app = await buildTestApp();
|
|
||||||
const res = await app.inject({ method: 'GET', url: '/api/calls/no-such-id' });
|
|
||||||
assert.equal(res.statusCode, 404);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('GET /api/calls/:id returns call with current snapshot shape', async () => {
|
|
||||||
const app = await buildTestApp();
|
|
||||||
const created = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/api/calls',
|
|
||||||
payload: {
|
|
||||||
title: 'Rate trade',
|
|
||||||
quarter: 'Q2 2025',
|
|
||||||
thesis: 'Long duration bonds when yield curve inverts.',
|
|
||||||
tickers: ['TLT'],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const { id } = created.json();
|
|
||||||
const res = await app.inject({ method: 'GET', url: `/api/calls/${id}` });
|
|
||||||
assert.equal(res.statusCode, 200);
|
|
||||||
const body = res.json();
|
|
||||||
assert.equal(body.id, id);
|
|
||||||
assert.ok('current' in body, 'response should include current snapshot');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('GET /api/calls/calendar with no calls → 200 empty events', async () => {
|
|
||||||
const app = await buildTestApp();
|
|
||||||
const res = await app.inject({ method: 'GET', url: '/api/calls/calendar' });
|
|
||||||
assert.equal(res.statusCode, 200);
|
|
||||||
assert.deepEqual(res.json(), { events: [], tickers: [] });
|
|
||||||
});
|
|
||||||
@@ -1,177 +0,0 @@
|
|||||||
/**
|
|
||||||
* Integration tests for FinanceController
|
|
||||||
* Uses Fastify inject() with stub engine, advisor, and in-memory portfolio repo.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { test } from 'node:test';
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
import Fastify from 'fastify';
|
|
||||||
import cors from '@fastify/cors';
|
|
||||||
import { FinanceController } from '../server/controllers/finance.controller';
|
|
||||||
import type { ScreenerEngine } from '../server/services/ScreenerEngine';
|
|
||||||
import type { PortfolioAdvisor } from '../server/services/PortfolioAdvisor';
|
|
||||||
import type { PortfolioHolding, MarketContext, ScreenerResult } from '../server/types';
|
|
||||||
|
|
||||||
// ── Stubs ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const MARKET_CTX: MarketContext = {
|
|
||||||
sp500Price: 5000,
|
|
||||||
riskFreeRate: 4.5,
|
|
||||||
vixLevel: 18,
|
|
||||||
rateRegime: 'NORMAL',
|
|
||||||
volatilityRegime: 'NORMAL',
|
|
||||||
benchmarks: { marketPE: 22, techPE: 30, reitYield: 3.5, igSpread: 1.0 },
|
|
||||||
};
|
|
||||||
|
|
||||||
const EMPTY_RESULT: ScreenerResult = {
|
|
||||||
STOCK: [],
|
|
||||||
ETF: [],
|
|
||||||
BOND: [],
|
|
||||||
ERROR: [],
|
|
||||||
marketContext: MARKET_CTX,
|
|
||||||
};
|
|
||||||
|
|
||||||
const stubEngine = {
|
|
||||||
screenTickers: async () => EMPTY_RESULT,
|
|
||||||
getMarketContext: async () => MARKET_CTX,
|
|
||||||
} as unknown as ScreenerEngine;
|
|
||||||
|
|
||||||
const stubAdvisor = {
|
|
||||||
advise: async () => [],
|
|
||||||
} as unknown as PortfolioAdvisor;
|
|
||||||
|
|
||||||
// In-memory PortfolioRepository stub
|
|
||||||
function makePortfolioRepo(seed: PortfolioHolding[] = []) {
|
|
||||||
const holdings: PortfolioHolding[] = [...seed];
|
|
||||||
return {
|
|
||||||
exists: () => true,
|
|
||||||
read: () => ({ holdings: [...holdings] }),
|
|
||||||
upsert: (entry: PortfolioHolding) => {
|
|
||||||
const idx = holdings.findIndex((h) => h.ticker === entry.ticker);
|
|
||||||
if (idx >= 0) holdings[idx] = entry;
|
|
||||||
else holdings.push(entry);
|
|
||||||
return entry;
|
|
||||||
},
|
|
||||||
remove: (ticker: string) => {
|
|
||||||
const idx = holdings.findIndex((h) => h.ticker === ticker);
|
|
||||||
if (idx === -1) return false;
|
|
||||||
holdings.splice(idx, 1);
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeEmptyRepo() {
|
|
||||||
return {
|
|
||||||
exists: () => false,
|
|
||||||
read: () => ({ holdings: [] }),
|
|
||||||
upsert: () => {},
|
|
||||||
remove: () => false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── App factory ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async function buildTestApp(repo = makePortfolioRepo()) {
|
|
||||||
const app = Fastify({ logger: false });
|
|
||||||
await app.register(cors, { origin: '*' });
|
|
||||||
new FinanceController(stubEngine, repo as any, stubAdvisor).register(app);
|
|
||||||
await app.ready();
|
|
||||||
return app;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
test('GET /api/finance/portfolio → 200 with advice and marketContext keys', async () => {
|
|
||||||
const app = await buildTestApp();
|
|
||||||
const res = await app.inject({ method: 'GET', url: '/api/finance/portfolio' });
|
|
||||||
assert.equal(res.statusCode, 200);
|
|
||||||
const body = res.json();
|
|
||||||
assert.ok(Array.isArray(body.advice), 'advice should be array');
|
|
||||||
assert.ok(body.marketContext, 'marketContext should be present');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('GET /api/finance/portfolio with no portfolio.json → 404', async () => {
|
|
||||||
const app = await buildTestApp(makeEmptyRepo() as any);
|
|
||||||
const res = await app.inject({ method: 'GET', url: '/api/finance/portfolio' });
|
|
||||||
assert.equal(res.statusCode, 404);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('GET /api/finance/market-context → 200 with benchmark fields', async () => {
|
|
||||||
const app = await buildTestApp();
|
|
||||||
const res = await app.inject({ method: 'GET', url: '/api/finance/market-context' });
|
|
||||||
assert.equal(res.statusCode, 200);
|
|
||||||
const body = res.json();
|
|
||||||
assert.ok(typeof body.riskFreeRate === 'number');
|
|
||||||
assert.ok(typeof body.sp500Price === 'number');
|
|
||||||
assert.ok(body.benchmarks);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('POST /api/finance/holdings → 201 and returns the holding', async () => {
|
|
||||||
const app = await buildTestApp();
|
|
||||||
const res = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/api/finance/holdings',
|
|
||||||
payload: { ticker: 'AAPL', shares: 10, costBasis: 150, type: 'stock', source: 'Robinhood' },
|
|
||||||
});
|
|
||||||
assert.equal(res.statusCode, 201);
|
|
||||||
const body = res.json();
|
|
||||||
assert.equal(body.ticker, 'AAPL');
|
|
||||||
assert.equal(body.shares, 10);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('POST /api/finance/holdings with missing shares → 400', async () => {
|
|
||||||
const app = await buildTestApp();
|
|
||||||
const res = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/api/finance/holdings',
|
|
||||||
payload: { ticker: 'AAPL' },
|
|
||||||
});
|
|
||||||
assert.equal(res.statusCode, 400);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('POST /api/finance/holdings with missing ticker → 400', async () => {
|
|
||||||
const app = await buildTestApp();
|
|
||||||
const res = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/api/finance/holdings',
|
|
||||||
payload: { shares: 5 },
|
|
||||||
});
|
|
||||||
assert.equal(res.statusCode, 400);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('POST /api/finance/holdings with zero shares → 400', async () => {
|
|
||||||
const app = await buildTestApp();
|
|
||||||
const res = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/api/finance/holdings',
|
|
||||||
payload: { ticker: 'AAPL', shares: 0 },
|
|
||||||
});
|
|
||||||
assert.equal(res.statusCode, 400);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('POST /api/finance/holdings with invalid type → 400', async () => {
|
|
||||||
const app = await buildTestApp();
|
|
||||||
const res = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/api/finance/holdings',
|
|
||||||
payload: { ticker: 'AAPL', shares: 5, type: 'options' },
|
|
||||||
});
|
|
||||||
assert.equal(res.statusCode, 400);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('DELETE /api/finance/holdings/:ticker removes existing holding → 200', async () => {
|
|
||||||
const repo = makePortfolioRepo([
|
|
||||||
{ ticker: 'MSFT', shares: 5, costBasis: 300, type: 'stock', source: 'Manual' },
|
|
||||||
]);
|
|
||||||
const app = await buildTestApp(repo);
|
|
||||||
const res = await app.inject({ method: 'DELETE', url: '/api/finance/holdings/MSFT' });
|
|
||||||
assert.equal(res.statusCode, 200);
|
|
||||||
assert.deepEqual(res.json(), { ok: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('DELETE /api/finance/holdings/:ticker on missing ticker → 404', async () => {
|
|
||||||
const app = await buildTestApp();
|
|
||||||
const res = await app.inject({ method: 'DELETE', url: '/api/finance/holdings/NOTHERE' });
|
|
||||||
assert.equal(res.statusCode, 404);
|
|
||||||
});
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
/**
|
|
||||||
* Integration tests for ScreenerController + /health
|
|
||||||
* Uses Fastify inject() — no real Yahoo calls, no live server.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { test } from 'node:test';
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
import Fastify from 'fastify';
|
|
||||||
import cors from '@fastify/cors';
|
|
||||||
import { ScreenerController } from '../server/controllers/screener.controller';
|
|
||||||
import type { ScreenerEngine } from '../server/services/ScreenerEngine';
|
|
||||||
import type { ScreenerResult, MarketContext } from '../server/types';
|
|
||||||
|
|
||||||
// ── Fixture data ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const MARKET_CTX: MarketContext = {
|
|
||||||
sp500Price: 5000,
|
|
||||||
riskFreeRate: 4.5,
|
|
||||||
vixLevel: 18,
|
|
||||||
rateRegime: 'NORMAL',
|
|
||||||
volatilityRegime: 'NORMAL',
|
|
||||||
benchmarks: { marketPE: 22, techPE: 30, reitYield: 3.5, igSpread: 1.0 },
|
|
||||||
};
|
|
||||||
|
|
||||||
const EMPTY_RESULT: ScreenerResult = {
|
|
||||||
STOCK: [],
|
|
||||||
ETF: [],
|
|
||||||
BOND: [],
|
|
||||||
ERROR: [],
|
|
||||||
marketContext: MARKET_CTX,
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Stub ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const stubEngine = {
|
|
||||||
screenTickers: async (_tickers: string[]) => EMPTY_RESULT,
|
|
||||||
getMarketContext: async () => MARKET_CTX,
|
|
||||||
} as unknown as ScreenerEngine;
|
|
||||||
|
|
||||||
// ── App factory ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async function buildTestApp() {
|
|
||||||
const app = Fastify({ logger: false });
|
|
||||||
await app.register(cors, { origin: '*' });
|
|
||||||
new ScreenerController(stubEngine).register(app);
|
|
||||||
app.get('/health', async () => ({ status: 'ok' }));
|
|
||||||
await app.ready();
|
|
||||||
return app;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
test('GET /health → 200 { status: ok }', async () => {
|
|
||||||
const app = await buildTestApp();
|
|
||||||
const res = await app.inject({ method: 'GET', url: '/health' });
|
|
||||||
assert.equal(res.statusCode, 200);
|
|
||||||
assert.deepEqual(res.json(), { status: 'ok' });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('POST /api/screen → 200 with STOCK/ETF/BOND/ERROR/marketContext keys', async () => {
|
|
||||||
const app = await buildTestApp();
|
|
||||||
const res = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/api/screen',
|
|
||||||
payload: { tickers: ['AAPL'] },
|
|
||||||
});
|
|
||||||
assert.equal(res.statusCode, 200);
|
|
||||||
const body = res.json();
|
|
||||||
assert.ok(Array.isArray(body.STOCK), 'STOCK should be array');
|
|
||||||
assert.ok(Array.isArray(body.ETF), 'ETF should be array');
|
|
||||||
assert.ok(Array.isArray(body.BOND), 'BOND should be array');
|
|
||||||
assert.ok(Array.isArray(body.ERROR), 'ERROR should be array');
|
|
||||||
assert.ok(body.marketContext, 'marketContext should be present');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('POST /api/screen → marketContext has expected shape', async () => {
|
|
||||||
const app = await buildTestApp();
|
|
||||||
const res = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/api/screen',
|
|
||||||
payload: { tickers: ['MSFT'] },
|
|
||||||
});
|
|
||||||
const { marketContext } = res.json();
|
|
||||||
assert.ok(typeof marketContext.riskFreeRate === 'number');
|
|
||||||
assert.ok(typeof marketContext.sp500Price === 'number');
|
|
||||||
assert.ok(typeof marketContext.vixLevel === 'number');
|
|
||||||
assert.ok(marketContext.benchmarks);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('POST /api/screen with missing tickers → 400', async () => {
|
|
||||||
const app = await buildTestApp();
|
|
||||||
const res = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/api/screen',
|
|
||||||
payload: {},
|
|
||||||
});
|
|
||||||
assert.equal(res.statusCode, 400);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('POST /api/screen with empty tickers array → 400', async () => {
|
|
||||||
const app = await buildTestApp();
|
|
||||||
const res = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/api/screen',
|
|
||||||
payload: { tickers: [] },
|
|
||||||
});
|
|
||||||
assert.equal(res.statusCode, 400);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('POST /api/screen with too many tickers (>50) → 400', async () => {
|
|
||||||
const app = await buildTestApp();
|
|
||||||
const res = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/api/screen',
|
|
||||||
payload: { tickers: Array.from({ length: 51 }, (_, i) => `T${i}`) },
|
|
||||||
});
|
|
||||||
assert.equal(res.statusCode, 400);
|
|
||||||
});
|
|
||||||
+2
-2
@@ -9,6 +9,6 @@
|
|||||||
"allowImportingTsExtensions": true,
|
"allowImportingTsExtensions": true,
|
||||||
"resolveJsonModule": true
|
"resolveJsonModule": true
|
||||||
},
|
},
|
||||||
"include": ["server/**/*", "bin/**/*", "tests/**/*", "scripts/**/*"],
|
"include": ["server/domains/**/*", "server/app.ts", "server/types.ts", "bin/**/*", "tests/**/*", "scripts/**/*"],
|
||||||
"exclude": ["node_modules", "ui"]
|
"exclude": ["node_modules", "ui", "server/controllers", "server/services", "server/repositories", "server/clients", "server/models", "server/scorers", "server/config", "server/types", "server/utils", "server/db"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user