phase-8g: add sqllite.
This commit is contained in:
+6
-2
@@ -8,11 +8,13 @@ import { AnalyzeController } from './controllers/analyze.controller';
|
||||
import { ScreenerEngine } from './services/ScreenerEngine';
|
||||
import { BenchmarkProvider } from './services/BenchmarkProvider';
|
||||
import { PortfolioAdvisor } from './services/PortfolioAdvisor';
|
||||
import { CalendarService } from './services/CalendarService';
|
||||
import { LLMAnalyst } from './services/LLMAnalyst';
|
||||
import { CatalystAnalyst } from './services/CatalystAnalyst';
|
||||
import { YahooFinanceClient } from './clients/YahooFinanceClient';
|
||||
import { MarketCallRepository } from './repositories/MarketCallRepository';
|
||||
import { PortfolioRepository } from './repositories/PortfolioRepository';
|
||||
import { createDb } from './db/index';
|
||||
import { noopLogger } from './utils/logger';
|
||||
|
||||
interface BuildAppOptions {
|
||||
@@ -52,16 +54,18 @@ export async function buildApp({ logger = true }: BuildAppOptions = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
const db = createDb();
|
||||
const yahoo = new YahooFinanceClient();
|
||||
const benchmark = new BenchmarkProvider(yahoo, { logger: noopLogger });
|
||||
const engine = new ScreenerEngine(yahoo, benchmark, { logger: noopLogger });
|
||||
const advisor = new PortfolioAdvisor(yahoo);
|
||||
const calSvc = new CalendarService(yahoo);
|
||||
const llm = new LLMAnalyst({ logger: noopLogger });
|
||||
const catalyst = new CatalystAnalyst({ logger: noopLogger });
|
||||
|
||||
new ScreenerController(engine).register(app);
|
||||
new FinanceController(engine, new PortfolioRepository(), advisor).register(app);
|
||||
new CallsController(new MarketCallRepository(), engine, yahoo).register(app);
|
||||
new FinanceController(engine, new PortfolioRepository(db), advisor).register(app);
|
||||
new CallsController(new MarketCallRepository(db), engine, calSvc).register(app);
|
||||
new AnalyzeController(catalyst, llm).register(app);
|
||||
|
||||
app.get('/health', async () => ({ status: 'ok' }));
|
||||
|
||||
@@ -20,7 +20,11 @@ export class YahooFinanceClient {
|
||||
const normalised = YahooFinanceClient.normalise(ticker);
|
||||
for (let attempt = 0; attempt < retries; attempt++) {
|
||||
try {
|
||||
return await this.lib.quoteSummary(normalised, { modules: YAHOO_MODULES });
|
||||
return await this.lib.quoteSummary(
|
||||
normalised,
|
||||
{ modules: YAHOO_MODULES },
|
||||
{ validateResult: false },
|
||||
);
|
||||
} catch (error) {
|
||||
if (attempt === retries - 1) throw error;
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, backoff * (attempt + 1)));
|
||||
@@ -30,9 +34,11 @@ export class YahooFinanceClient {
|
||||
|
||||
async fetchCalendarEvents(ticker: string): Promise<any | null> {
|
||||
try {
|
||||
const result = await this.lib.quoteSummary(YahooFinanceClient.normalise(ticker), {
|
||||
modules: ['calendarEvents'],
|
||||
});
|
||||
const result = await this.lib.quoteSummary(
|
||||
YahooFinanceClient.normalise(ticker),
|
||||
{ modules: ['calendarEvents'] },
|
||||
{ validateResult: false },
|
||||
);
|
||||
return result.calendarEvents ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { YahooFinanceClient } from '../clients/YahooFinanceClient';
|
||||
import { MarketCallRepository } from '../repositories/MarketCallRepository';
|
||||
import { ScreenerEngine } from '../services/index';
|
||||
import { CalendarService, ScreenerEngine } from '../services/index';
|
||||
import type { SnapshotEntry } from '../types';
|
||||
import { callSchema } from '../types/schemas';
|
||||
import { chunkArray } from '../utils/Chunker';
|
||||
|
||||
export class CallsController {
|
||||
constructor(
|
||||
private readonly repo: MarketCallRepository,
|
||||
private readonly engine: ScreenerEngine,
|
||||
private readonly yahoo: YahooFinanceClient,
|
||||
private readonly calendar: CalendarService,
|
||||
) {}
|
||||
|
||||
private static toSnapshot(r: any): SnapshotEntry | null {
|
||||
@@ -29,7 +27,7 @@ export class CallsController {
|
||||
|
||||
register(app: FastifyInstance): void {
|
||||
app.get('/api/calls', this.list.bind(this));
|
||||
app.get('/api/calls/calendar', this.calendar.bind(this));
|
||||
app.get('/api/calls/calendar', this.handleCalendar.bind(this));
|
||||
app.get('/api/calls/:id', this.get.bind(this));
|
||||
app.post('/api/calls', { schema: callSchema }, this.create.bind(this));
|
||||
app.delete('/api/calls/:id', this.remove.bind(this));
|
||||
@@ -94,7 +92,7 @@ export class CallsController {
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
private async calendar(req: FastifyRequest) {
|
||||
private async handleCalendar(req: FastifyRequest) {
|
||||
let tickers: string[];
|
||||
if ((req.query as any).tickers) {
|
||||
tickers = String((req.query as any).tickers)
|
||||
@@ -102,71 +100,9 @@ export class CallsController {
|
||||
.map((t) => t.trim().toUpperCase())
|
||||
.filter(Boolean);
|
||||
} else {
|
||||
const set = new Set(this.repo.list().flatMap((c) => c.tickers));
|
||||
tickers = [...set];
|
||||
tickers = [...new Set(this.repo.list().flatMap((c) => c.tickers))];
|
||||
}
|
||||
|
||||
if (tickers.length === 0) return { events: [] };
|
||||
|
||||
const results: Record<string, any> = {};
|
||||
for (const batch of chunkArray(tickers, 5)) {
|
||||
await Promise.all(
|
||||
batch.map(async (ticker) => {
|
||||
const cal = await this.yahoo.fetchCalendarEvents(ticker);
|
||||
if (cal) results[ticker] = cal;
|
||||
}),
|
||||
);
|
||||
await new Promise<void>((r) => setTimeout(r, 500));
|
||||
}
|
||||
|
||||
const events: any[] = [];
|
||||
const now = Date.now();
|
||||
|
||||
for (const [ticker, cal] of Object.entries(results)) {
|
||||
for (const dateVal of cal.earnings?.earningsDate ?? []) {
|
||||
const d = new Date(dateVal as string);
|
||||
events.push({
|
||||
ticker,
|
||||
type: 'earnings',
|
||||
date: d.toISOString().slice(0, 10),
|
||||
label: 'Earnings',
|
||||
detail: cal.earnings.isEarningsDateEstimate ? 'Estimated' : 'Confirmed',
|
||||
epsEstimate: cal.earnings.earningsAverage ?? null,
|
||||
revEstimate: cal.earnings.revenueAverage ?? null,
|
||||
isPast: d.getTime() < now,
|
||||
});
|
||||
}
|
||||
if (cal.exDividendDate) {
|
||||
const d = new Date(cal.exDividendDate);
|
||||
events.push({
|
||||
ticker,
|
||||
type: 'exdividend',
|
||||
date: d.toISOString().slice(0, 10),
|
||||
label: 'Ex-Dividend',
|
||||
detail: null,
|
||||
isPast: d.getTime() < now,
|
||||
});
|
||||
}
|
||||
if (cal.dividendDate) {
|
||||
const d = new Date(cal.dividendDate);
|
||||
events.push({
|
||||
ticker,
|
||||
type: 'dividend',
|
||||
date: d.toISOString().slice(0, 10),
|
||||
label: 'Dividend',
|
||||
detail: null,
|
||||
isPast: d.getTime() < now,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
events.sort((a, b) => {
|
||||
if (a.isPast !== b.isPast) return a.isPast ? 1 : -1;
|
||||
return a.isPast
|
||||
? new Date(b.date).getTime() - new Date(a.date).getTime()
|
||||
: new Date(a.date).getTime() - new Date(b.date).getTime();
|
||||
});
|
||||
|
||||
return { events, tickers };
|
||||
return this.calendar.getEvents(tickers);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* DatabaseConnection — High-level database abstraction.
|
||||
*
|
||||
* Wraps better-sqlite3 with:
|
||||
* - QueryBuilder for type-safe, injection-proof queries
|
||||
* - QueryAudit for logging and compliance
|
||||
* - Statement caching for performance
|
||||
* - Transaction support
|
||||
*
|
||||
* Usage:
|
||||
* const db = new DatabaseConnection(betterSqlite3Db, options);
|
||||
* const qb = new QueryBuilder('holdings').select(['ticker', 'shares']).where('type = ?', ['stock']);
|
||||
* const rows = db.all(qb);
|
||||
* const row = db.get(qb);
|
||||
* db.run(qb);
|
||||
*/
|
||||
|
||||
import type BetterSqlite3 from 'better-sqlite3';
|
||||
import { QueryBuilder } from './QueryBuilder';
|
||||
import { QueryAudit, AuditAction } from './QueryAudit';
|
||||
|
||||
export interface DatabaseOptions {
|
||||
audit?: QueryAudit;
|
||||
logSlowQueries?: number; // milliseconds; logs queries slower than this
|
||||
}
|
||||
|
||||
/**
|
||||
* DatabaseConnection — Safe, auditable, performant SQLite wrapper.
|
||||
*/
|
||||
export class DatabaseConnection {
|
||||
private db: BetterSqlite3.Database;
|
||||
private audit: QueryAudit;
|
||||
private logSlowQueries: number;
|
||||
private statementCache = new Map<string, BetterSqlite3.Statement>();
|
||||
|
||||
constructor(db: BetterSqlite3.Database, options: DatabaseOptions = {}) {
|
||||
this.db = db;
|
||||
this.audit = options.audit ?? new QueryAudit();
|
||||
this.logSlowQueries = options.logSlowQueries ?? 100; // 100ms default
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a SELECT query and return all rows.
|
||||
* Logs the query to the audit trail.
|
||||
*/
|
||||
all<T = Record<string, unknown>>(qb: QueryBuilder): T[] {
|
||||
const sql = qb.build();
|
||||
const params = qb.params();
|
||||
const startMs = performance.now();
|
||||
|
||||
try {
|
||||
const stmt = this.getOrCacheStatement(sql);
|
||||
const rows = stmt.all(...params) as T[];
|
||||
|
||||
const durationMs = performance.now() - startMs;
|
||||
this.audit.log(sql, params, AuditAction.READ, durationMs, rows.length);
|
||||
this.logIfSlow(sql, durationMs);
|
||||
|
||||
return rows;
|
||||
} catch (err) {
|
||||
const durationMs = performance.now() - startMs;
|
||||
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||
this.audit.log(sql, params, AuditAction.READ, durationMs, undefined, errorMsg);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a SELECT query and return the first row only.
|
||||
* Returns null if no rows match.
|
||||
* Logs the query to the audit trail.
|
||||
*/
|
||||
get<T = Record<string, unknown>>(qb: QueryBuilder): T | null {
|
||||
const sql = qb.build();
|
||||
const params = qb.params();
|
||||
const startMs = performance.now();
|
||||
|
||||
try {
|
||||
const stmt = this.getOrCacheStatement(sql);
|
||||
const row = stmt.get(...params) as T | undefined;
|
||||
|
||||
const durationMs = performance.now() - startMs;
|
||||
this.audit.log(sql, params, AuditAction.READ, durationMs, row ? 1 : 0);
|
||||
this.logIfSlow(sql, durationMs);
|
||||
|
||||
return row ?? null;
|
||||
} catch (err) {
|
||||
const durationMs = performance.now() - startMs;
|
||||
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||
this.audit.log(sql, params, AuditAction.READ, durationMs, undefined, errorMsg);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute an INSERT, UPDATE, or DELETE query.
|
||||
* Returns the number of rows affected.
|
||||
* Logs the query to the audit trail.
|
||||
*/
|
||||
run(qb: QueryBuilder): number {
|
||||
const sql = qb.build();
|
||||
const params = qb.params();
|
||||
const startMs = performance.now();
|
||||
|
||||
// Determine audit action from SQL
|
||||
const sqlUpper = sql.toUpperCase().trim();
|
||||
const action = sqlUpper.startsWith('DELETE')
|
||||
? AuditAction.DELETE
|
||||
: sqlUpper.startsWith('INSERT')
|
||||
? AuditAction.WRITE
|
||||
: AuditAction.WRITE;
|
||||
|
||||
try {
|
||||
const stmt = this.getOrCacheStatement(sql);
|
||||
const result = stmt.run(...params);
|
||||
|
||||
const durationMs = performance.now() - startMs;
|
||||
this.audit.log(sql, params, action, durationMs, result.changes);
|
||||
this.logIfSlow(sql, durationMs);
|
||||
|
||||
return result.changes;
|
||||
} catch (err) {
|
||||
const durationMs = performance.now() - startMs;
|
||||
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||
this.audit.log(sql, params, action, durationMs, 0, errorMsg);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a transaction — multiple queries as an atomic unit.
|
||||
* All queries must succeed, or all are rolled back.
|
||||
*
|
||||
* Usage:
|
||||
* db.transaction(() => {
|
||||
* db.run(qb1);
|
||||
* db.run(qb2);
|
||||
* });
|
||||
*/
|
||||
transaction<T>(fn: () => T): T {
|
||||
const txn = this.db.transaction(fn);
|
||||
return txn();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the raw better-sqlite3 Db instance (for advanced use only).
|
||||
* Prefer the DatabaseConnection methods.
|
||||
*/
|
||||
raw(): BetterSqlite3.Database {
|
||||
return this.db;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the audit trail instance.
|
||||
*/
|
||||
getAudit(): QueryAudit {
|
||||
return this.audit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the statement cache (for testing or extreme memory pressure).
|
||||
*/
|
||||
clearStatementCache(): void {
|
||||
this.statementCache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the audit trail instance.
|
||||
* Call db.printAudit() to see the most recent 100 queries.
|
||||
*/
|
||||
printAudit(): void {
|
||||
console.log(this.audit.report());
|
||||
}
|
||||
|
||||
// ── Private helpers ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Get or create a cached prepared statement.
|
||||
* Reduces compilation overhead for frequently-run queries.
|
||||
*/
|
||||
private getOrCacheStatement(sql: string): BetterSqlite3.Statement {
|
||||
let stmt = this.statementCache.get(sql);
|
||||
if (!stmt) {
|
||||
stmt = this.db.prepare(sql);
|
||||
this.statementCache.set(sql, stmt);
|
||||
}
|
||||
return stmt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log slow queries to console.
|
||||
*/
|
||||
private logIfSlow(sql: string, durationMs: number): void {
|
||||
if (durationMs > this.logSlowQueries) {
|
||||
console.warn(`[SLOW QUERY] ${durationMs.toFixed(2)}ms\n ${sql}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Query audit logging — tracks all database mutations.
|
||||
*
|
||||
* Usage:
|
||||
* const audit = new QueryAudit();
|
||||
* audit.logQuery('SELECT * FROM holdings', [], 'READ');
|
||||
* audit.logQuery('UPDATE holdings SET shares = ? WHERE ticker = ?', [100, 'AAPL'], 'WRITE');
|
||||
*
|
||||
* Provides:
|
||||
* - Audit trail of all queries executed
|
||||
* - Timing information (for performance monitoring)
|
||||
* - Clear distinction between READ/WRITE operations
|
||||
* - Optional persistent storage for compliance
|
||||
*/
|
||||
|
||||
export enum AuditAction {
|
||||
READ = 'READ',
|
||||
WRITE = 'WRITE',
|
||||
DELETE = 'DELETE',
|
||||
}
|
||||
|
||||
export interface AuditEntry {
|
||||
timestamp: string; // ISO 8601
|
||||
action: AuditAction;
|
||||
sql: string;
|
||||
params: unknown[];
|
||||
durationMs: number;
|
||||
rowsAffected?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* QueryAudit — in-memory audit trail with optional callbacks.
|
||||
*/
|
||||
export class QueryAudit {
|
||||
private entries: AuditEntry[] = [];
|
||||
private onLog?: (entry: AuditEntry) => void | Promise<void>;
|
||||
|
||||
constructor(onLog?: (entry: AuditEntry) => void | Promise<void>) {
|
||||
this.onLog = onLog;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a query execution.
|
||||
* @param sql The SQL string (with ? placeholders intact)
|
||||
* @param params The parameter array (safe to log; no raw values in SQL)
|
||||
* @param action The operation type (READ, WRITE, DELETE)
|
||||
* @param durationMs Execution time in milliseconds
|
||||
* @param rowsAffected Number of rows affected (for INSERT/UPDATE/DELETE)
|
||||
* @param error If execution failed, the error message
|
||||
*/
|
||||
log(
|
||||
sql: string,
|
||||
params: unknown[],
|
||||
action: AuditAction,
|
||||
durationMs: number,
|
||||
rowsAffected?: number,
|
||||
error?: string,
|
||||
): void {
|
||||
const entry: AuditEntry = {
|
||||
timestamp: new Date().toISOString(),
|
||||
action,
|
||||
sql,
|
||||
params,
|
||||
durationMs,
|
||||
rowsAffected,
|
||||
error,
|
||||
};
|
||||
|
||||
this.entries.push(entry);
|
||||
|
||||
// Call the optional callback (could write to file, logger, or remote service)
|
||||
if (this.onLog) {
|
||||
const result = this.onLog(entry);
|
||||
if (result instanceof Promise) {
|
||||
result.catch((err) => {
|
||||
console.error('QueryAudit callback failed:', err);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all audit entries.
|
||||
*/
|
||||
all(): AuditEntry[] {
|
||||
return [...this.entries];
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter audit entries by action type.
|
||||
*/
|
||||
byAction(action: AuditAction): AuditEntry[] {
|
||||
return this.entries.filter((e) => e.action === action);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the most recent N entries.
|
||||
*/
|
||||
recent(count: number = 100): AuditEntry[] {
|
||||
return this.entries.slice(-count);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the audit trail.
|
||||
* (Typically not needed unless for testing or cleanup.)
|
||||
*/
|
||||
clear(): void {
|
||||
this.entries = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a human-readable audit report.
|
||||
*/
|
||||
report(limitEntries: number = 100): string {
|
||||
const recent = this.recent(limitEntries);
|
||||
let report = `\n=== Query Audit Report ===\n`;
|
||||
report += `Total entries: ${this.entries.length}\n`;
|
||||
report += `Showing last ${recent.length} entries:\n\n`;
|
||||
|
||||
for (const entry of recent) {
|
||||
report += `[${entry.timestamp}] ${entry.action}`;
|
||||
if (entry.error) {
|
||||
report += ` ❌ (${entry.error})`;
|
||||
} else {
|
||||
report += ` ✓ (${entry.durationMs}ms)`;
|
||||
if (entry.rowsAffected !== undefined) {
|
||||
report += ` — ${entry.rowsAffected} rows`;
|
||||
}
|
||||
}
|
||||
report += `\n SQL: ${entry.sql}\n`;
|
||||
if (entry.params.length > 0) {
|
||||
report += ` Params: [${entry.params.map((p) => JSON.stringify(p)).join(', ')}]\n`;
|
||||
}
|
||||
report += '\n';
|
||||
}
|
||||
|
||||
return report;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* 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,37 +1,33 @@
|
||||
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
||||
import { randomUUID } from 'crypto';
|
||||
import type { MarketCall, CreateCallInput, StoreData } from '../types';
|
||||
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 {
|
||||
private static readonly DEFAULT_PATH = './market-calls.json';
|
||||
|
||||
private readonly storePath: string;
|
||||
|
||||
constructor(storePath?: string) {
|
||||
this.storePath = storePath ?? MarketCallRepository.DEFAULT_PATH;
|
||||
}
|
||||
|
||||
private load(): StoreData {
|
||||
if (!existsSync(this.storePath)) return { calls: [] };
|
||||
try {
|
||||
return JSON.parse(readFileSync(this.storePath, 'utf8')) as StoreData;
|
||||
} catch {
|
||||
return { calls: [] };
|
||||
}
|
||||
}
|
||||
|
||||
private save(data: StoreData): void {
|
||||
writeFileSync(this.storePath, JSON.stringify(data, null, 2), 'utf8');
|
||||
}
|
||||
constructor(private readonly db: Db) {}
|
||||
|
||||
list(): (MarketCall & { createdAt: string })[] {
|
||||
return this.load().calls.sort(
|
||||
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
|
||||
);
|
||||
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 {
|
||||
return this.load().calls.find((c) => c.id === id) ?? null;
|
||||
const row = this.db.prepare('SELECT * FROM market_calls WHERE id = ?').get(id) as
|
||||
| CallRow
|
||||
| undefined;
|
||||
return row ? MarketCallRepository.toCall(row) : null;
|
||||
}
|
||||
|
||||
create({
|
||||
@@ -42,28 +38,49 @@ export class MarketCallRepository {
|
||||
tickers,
|
||||
snapshot,
|
||||
}: CreateCallInput): MarketCall & { createdAt: string } {
|
||||
const data = this.load();
|
||||
const call = {
|
||||
id: randomUUID(),
|
||||
title,
|
||||
quarter,
|
||||
date: date ?? new Date().toISOString().slice(0, 10),
|
||||
thesis,
|
||||
tickers,
|
||||
tickers: tickers ?? [],
|
||||
snapshot: snapshot ?? {},
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
data.calls.push(call);
|
||||
this.save(data);
|
||||
return call;
|
||||
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 data = this.load();
|
||||
const before = data.calls.length;
|
||||
data.calls = data.calls.filter((c) => c.id !== id);
|
||||
if (data.calls.length === before) return false;
|
||||
this.save(data);
|
||||
return true;
|
||||
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,39 +1,63 @@
|
||||
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
||||
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 {
|
||||
private static readonly PORTFOLIO_PATH = './portfolio.json';
|
||||
constructor(private readonly db: Db) {}
|
||||
|
||||
exists(): boolean {
|
||||
return existsSync(PortfolioRepository.PORTFOLIO_PATH);
|
||||
const row = this.db.prepare('SELECT COUNT(*) AS n FROM holdings').get() as { n: number };
|
||||
return row.n > 0;
|
||||
}
|
||||
|
||||
read(): PortfolioData {
|
||||
if (!this.exists()) return { holdings: [] };
|
||||
return JSON.parse(readFileSync(PortfolioRepository.PORTFOLIO_PATH, 'utf8')) as PortfolioData;
|
||||
}
|
||||
|
||||
write(data: PortfolioData): void {
|
||||
writeFileSync(PortfolioRepository.PORTFOLIO_PATH, JSON.stringify(data, null, 2), 'utf8');
|
||||
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 data = this.read();
|
||||
const normalized = entry.ticker.toUpperCase().trim();
|
||||
const idx = data.holdings.findIndex((h) => h.ticker.toUpperCase() === normalized);
|
||||
const record: PortfolioHolding = { ...entry, ticker: normalized };
|
||||
if (idx >= 0) data.holdings[idx] = record;
|
||||
else data.holdings.push(record);
|
||||
this.write(data);
|
||||
return record;
|
||||
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 data = this.read();
|
||||
const before = data.holdings.length;
|
||||
data.holdings = data.holdings.filter((h) => h.ticker.toUpperCase() !== ticker.toUpperCase());
|
||||
if (data.holdings.length === before) return false;
|
||||
this.write(data);
|
||||
return true;
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { YahooFinanceClient } from '../clients/YahooFinanceClient';
|
||||
import { chunkArray } from '../utils/Chunker';
|
||||
import type { CalendarEvent } from '../types';
|
||||
|
||||
export class CalendarService {
|
||||
constructor(private readonly yahoo: YahooFinanceClient) {}
|
||||
|
||||
async getEvents(tickers: string[]): Promise<{ events: CalendarEvent[]; tickers: string[] }> {
|
||||
if (tickers.length === 0) return { events: [], tickers: [] };
|
||||
|
||||
const raw: Record<string, any> = {};
|
||||
for (const batch of chunkArray(tickers, 5)) {
|
||||
await Promise.all(
|
||||
batch.map(async (ticker) => {
|
||||
const cal = await this.yahoo.fetchCalendarEvents(ticker);
|
||||
if (cal) raw[ticker] = cal;
|
||||
}),
|
||||
);
|
||||
await new Promise<void>((r) => setTimeout(r, 500));
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const events = CalendarService.buildEvents(raw, now);
|
||||
CalendarService.sortEvents(events);
|
||||
|
||||
return { events, tickers };
|
||||
}
|
||||
|
||||
private static buildEvents(raw: Record<string, any>, now: number): CalendarEvent[] {
|
||||
const events: CalendarEvent[] = [];
|
||||
|
||||
for (const [ticker, cal] of Object.entries(raw)) {
|
||||
for (const dateVal of cal.earnings?.earningsDate ?? []) {
|
||||
const d = new Date(dateVal as string);
|
||||
events.push({
|
||||
ticker,
|
||||
type: 'earnings',
|
||||
date: d.toISOString().slice(0, 10),
|
||||
label: 'Earnings',
|
||||
detail: cal.earnings.isEarningsDateEstimate ? 'Estimated' : 'Confirmed',
|
||||
epsEstimate: cal.earnings.earningsAverage ?? null,
|
||||
revEstimate: cal.earnings.revenueAverage ?? null,
|
||||
isPast: d.getTime() < now,
|
||||
});
|
||||
}
|
||||
|
||||
if (cal.exDividendDate) {
|
||||
const d = new Date(cal.exDividendDate);
|
||||
events.push({
|
||||
ticker,
|
||||
type: 'exdividend',
|
||||
date: d.toISOString().slice(0, 10),
|
||||
label: 'Ex-Dividend',
|
||||
detail: null,
|
||||
isPast: d.getTime() < now,
|
||||
});
|
||||
}
|
||||
|
||||
if (cal.dividendDate) {
|
||||
const d = new Date(cal.dividendDate);
|
||||
events.push({
|
||||
ticker,
|
||||
type: 'dividend',
|
||||
date: d.toISOString().slice(0, 10),
|
||||
label: 'Dividend',
|
||||
detail: null,
|
||||
isPast: d.getTime() < now,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
private static sortEvents(events: CalendarEvent[]): void {
|
||||
events.sort((a, b) => {
|
||||
if (a.isPast !== b.isPast) return a.isPast ? 1 : -1;
|
||||
return a.isPast
|
||||
? new Date(b.date).getTime() - new Date(a.date).getTime()
|
||||
: new Date(a.date).getTime() - new Date(b.date).getTime();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
// 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';
|
||||
|
||||
@@ -60,7 +60,11 @@ export interface YahooSearchOptions {
|
||||
// Narrow interface over the yahoo-finance2 instance — only the methods this
|
||||
// codebase actually calls. Keeps `any` contained to this one declaration.
|
||||
export interface YahooFinanceLib {
|
||||
quoteSummary(ticker: string, opts: { modules: string[] }): Promise<any>;
|
||||
quoteSummary(
|
||||
ticker: string,
|
||||
opts: { modules: string[] },
|
||||
queryOpts?: { validateResult?: boolean },
|
||||
): Promise<any>;
|
||||
search(query: string, opts?: YahooSearchOptions): Promise<{ news?: YahooNewsItem[] }>;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user