import { getLogger } from '@stock-bot/logger'; import type { QueryResult, TimeSeriesQuery, AggregationQuery, TimeRange, TableNames } from './types'; // Interface to avoid circular dependency interface QuestDBClientInterface { query(sql: string, params?: any[]): Promise>; } /** * QuestDB Query Builder * * Provides a fluent interface for building optimized time-series queries * with support for QuestDB-specific functions and optimizations. */ export class QuestDBQueryBuilder { private readonly logger: ReturnType; private query!: { select: string[]; from: string; where: string[]; groupBy: string[]; orderBy: string[]; limit?: number; sampleBy?: string; latestBy?: string[]; timeRange?: TimeRange; }; constructor(private readonly client: QuestDBClientInterface) { this.logger = getLogger('questdb-query-builder'); this.reset(); } /** * Reset the query builder */ private reset(): QuestDBQueryBuilder { this.query = { select: [], from: '', where: [], groupBy: [], orderBy: [], sampleBy: undefined, latestBy: undefined, timeRange: undefined }; return this; } /** * Start a new query */ public static create(client: QuestDBClientInterface): QuestDBQueryBuilder { return new QuestDBQueryBuilder(client); } /** * Select columns */ public select(...columns: string[]): QuestDBQueryBuilder { this.query.select.push(...columns); return this; } /** * Select with aggregation functions */ public selectAgg(aggregations: Record): QuestDBQueryBuilder { Object.entries(aggregations).forEach(([alias, expression]) => { this.query.select.push(`${expression} as ${alias}`); }); return this; } /** * From table */ public from(table: TableNames | string): QuestDBQueryBuilder { this.query.from = table; return this; } /** * Where condition */ public where(condition: string): QuestDBQueryBuilder { this.query.where.push(condition); return this; } /** * Where symbol equals */ public whereSymbol(symbol: string): QuestDBQueryBuilder { this.query.where.push(`symbol = '${symbol}'`); return this; } /** * Where symbols in list */ public whereSymbolIn(symbols: string[]): QuestDBQueryBuilder { const symbolList = symbols.map(s => `'${s}'`).join(', '); this.query.where.push(`symbol IN (${symbolList})`); return this; } /** * Where exchange equals */ public whereExchange(exchange: string): QuestDBQueryBuilder { this.query.where.push(`exchange = '${exchange}'`); return this; } /** * Time range filter */ public whereTimeRange(startTime: Date, endTime: Date): QuestDBQueryBuilder { this.query.timeRange = { startTime, endTime }; this.query.where.push( `timestamp >= '${startTime.toISOString()}' AND timestamp <= '${endTime.toISOString()}'` ); return this; } /** * Last N hours */ public whereLastHours(hours: number): QuestDBQueryBuilder { this.query.where.push(`timestamp > dateadd('h', -${hours}, now())`); return this; } /** * Last N days */ public whereLastDays(days: number): QuestDBQueryBuilder { this.query.where.push(`timestamp > dateadd('d', -${days}, now())`); return this; } /** * Group by columns */ public groupBy(...columns: string[]): QuestDBQueryBuilder { this.query.groupBy.push(...columns); return this; } /** * Order by column */ public orderBy(column: string, direction: 'ASC' | 'DESC' = 'ASC'): QuestDBQueryBuilder { this.query.orderBy.push(`${column} ${direction}`); return this; } /** * Order by timestamp descending (most recent first) */ public orderByTimeDesc(): QuestDBQueryBuilder { this.query.orderBy.push('timestamp DESC'); return this; } /** * Limit results */ public limit(count: number): QuestDBQueryBuilder { this.query.limit = count; return this; } /** * Sample by time interval (QuestDB specific) */ public sampleBy(interval: string): QuestDBQueryBuilder { this.query.sampleBy = interval; return this; } /** * Latest by columns (QuestDB specific) */ public latestBy(...columns: string[]): QuestDBQueryBuilder { this.query.latestBy = columns; return this; } /** * Build and execute the query */ public async execute(): Promise> { const sql = this.build(); this.logger.debug('Executing query', { sql }); try { const result = await this.client.query(sql); this.reset(); // Reset for next query return result; } catch (error) { this.logger.error('Query execution failed', { sql, error }); this.reset(); // Reset even on error throw error; } } /** * Build the SQL query string */ public build(): string { if (!this.query.from) { throw new Error('FROM clause is required'); } if (this.query.select.length === 0) { this.query.select.push('*'); } let sql = `SELECT ${this.query.select.join(', ')} FROM ${this.query.from}`; // Add WHERE clause if (this.query.where.length > 0) { sql += ` WHERE ${this.query.where.join(' AND ')}`; } // Add LATEST BY (QuestDB specific - must come before GROUP BY) if (this.query.latestBy && this.query.latestBy.length > 0) { sql += ` LATEST BY ${this.query.latestBy.join(', ')}`; } // Add SAMPLE BY (QuestDB specific) if (this.query.sampleBy) { sql += ` SAMPLE BY ${this.query.sampleBy}`; } // Add GROUP BY if (this.query.groupBy.length > 0) { sql += ` GROUP BY ${this.query.groupBy.join(', ')}`; } // Add ORDER BY if (this.query.orderBy.length > 0) { sql += ` ORDER BY ${this.query.orderBy.join(', ')}`; } // Add LIMIT if (this.query.limit) { sql += ` LIMIT ${this.query.limit}`; } return sql; } /** * Get the built query without executing */ public toSQL(): string { return this.build(); } // Predefined query methods for common use cases /** * Get latest OHLCV data for symbols */ public static latestOHLCV( client: QuestDBClientInterface, symbols: string[], exchange?: string ): QuestDBQueryBuilder { const builder = QuestDBQueryBuilder.create(client) .select('symbol', 'timestamp', 'open', 'high', 'low', 'close', 'volume') .from('ohlcv_data') .whereSymbolIn(symbols) .latestBy('symbol') .orderByTimeDesc(); if (exchange) { builder.whereExchange(exchange); } return builder; } /** * Get OHLCV data with time sampling */ public static ohlcvTimeSeries( client: QuestDBClientInterface, symbol: string, interval: string, hours: number = 24 ): QuestDBQueryBuilder { return QuestDBQueryBuilder.create(client) .selectAgg({ 'first_open': 'first(open)', 'max_high': 'max(high)', 'min_low': 'min(low)', 'last_close': 'last(close)', 'sum_volume': 'sum(volume)' }) .from('ohlcv_data') .whereSymbol(symbol) .whereLastHours(hours) .sampleBy(interval) .orderByTimeDesc(); } /** * Get market analytics data */ public static marketAnalytics( client: QuestDBClientInterface, symbols: string[], hours: number = 1 ): QuestDBQueryBuilder { return QuestDBQueryBuilder.create(client) .select('symbol', 'timestamp', 'rsi', 'macd', 'bollinger_upper', 'bollinger_lower', 'volume_sma') .from('market_analytics') .whereSymbolIn(symbols) .whereLastHours(hours) .orderBy('symbol') .orderByTimeDesc(); } /** * Get performance metrics for a time range */ public static performanceMetrics( client: QuestDBClientInterface, startTime: Date, endTime: Date ): QuestDBQueryBuilder { return QuestDBQueryBuilder.create(client) .selectAgg({ 'total_trades': 'count(*)', 'avg_response_time': 'avg(response_time)', 'max_response_time': 'max(response_time)', 'error_rate': 'sum(case when success = false then 1 else 0 end) * 100.0 / count(*)' }) .from('performance_metrics') .whereTimeRange(startTime, endTime) .sampleBy('1m'); } /** * Get trade execution data */ public static tradeExecutions( client: QuestDBClientInterface, symbol?: string, hours: number = 24 ): QuestDBQueryBuilder { const builder = QuestDBQueryBuilder.create(client) .select('symbol', 'timestamp', 'side', 'quantity', 'price', 'execution_time') .from('trade_executions') .whereLastHours(hours) .orderByTimeDesc(); if (symbol) { builder.whereSymbol(symbol); } return builder; } }