diff --git a/src/index.ts b/src/index.ts index 6384440..f2a5f0d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -38,6 +38,8 @@ import { portfolioRouter } from './modules/portfolio/portfolio.routes.js'; import { agentsRouter } from './modules/agents/agents.routes.js'; import { notificationRouter } from './modules/notifications/notification.routes.js'; import { marketDataRouter } from './modules/market-data/index.js'; +import { currencyRouter } from './modules/currency/index.js'; +import { auditRouter } from './modules/audit/index.js'; // Service clients for health checks import { tradingAgentsClient, mlEngineClient, llmAgentClient } from './shared/clients/index.js'; @@ -156,6 +158,8 @@ apiRouter.use('/portfolio', portfolioRouter); apiRouter.use('/agents', agentsRouter); apiRouter.use('/notifications', notificationRouter); apiRouter.use('/market-data', marketDataRouter); +apiRouter.use('/currency', currencyRouter); +apiRouter.use('/audit', auditRouter); // Mount API router app.use('/api/v1', apiRouter); diff --git a/src/modules/currency/controllers/currency.controller.ts b/src/modules/currency/controllers/currency.controller.ts new file mode 100644 index 0000000..b885762 --- /dev/null +++ b/src/modules/currency/controllers/currency.controller.ts @@ -0,0 +1,156 @@ +/** + * Currency Exchange Controller + * Handles currency exchange endpoints + */ + +import { Request, Response, NextFunction } from 'express'; +import { currencyService } from '../services/currency.service'; +import { ConversionRequest, UpdateRateRequest } from '../types/currency.types'; + +type AuthRequest = Request; + +export async function getExchangeRate(req: Request, res: Response, next: NextFunction): Promise { + try { + const { from, to } = req.params; + + if (!from || !to) { + res.status(400).json({ + success: false, + error: { message: 'From and to currencies are required', code: 'VALIDATION_ERROR' }, + }); + return; + } + + const rate = await currencyService.getExchangeRate(from, to); + + if (!rate) { + res.status(404).json({ + success: false, + error: { message: 'Exchange rate not found', code: 'NOT_FOUND' }, + }); + return; + } + + res.json({ + success: true, + data: rate, + }); + } catch (error) { + next(error); + } +} + +export async function getAllRates(req: Request, res: Response, next: NextFunction): Promise { + try { + const { baseCurrency } = req.params; + + if (!baseCurrency) { + res.status(400).json({ + success: false, + error: { message: 'Base currency is required', code: 'VALIDATION_ERROR' }, + }); + return; + } + + const rates = await currencyService.getAllRates(baseCurrency); + + res.json({ + success: true, + data: rates, + }); + } catch (error) { + next(error); + } +} + +export async function convertCurrency(req: Request, res: Response, next: NextFunction): Promise { + try { + const { amount, fromCurrency, toCurrency } = req.body as ConversionRequest; + + if (!amount || !fromCurrency || !toCurrency) { + res.status(400).json({ + success: false, + error: { + message: 'Amount, fromCurrency, and toCurrency are required', + code: 'VALIDATION_ERROR', + }, + }); + return; + } + + if (typeof amount !== 'number' || amount <= 0) { + res.status(400).json({ + success: false, + error: { message: 'Amount must be a positive number', code: 'VALIDATION_ERROR' }, + }); + return; + } + + const result = await currencyService.convert({ amount, fromCurrency, toCurrency }); + + if (!result) { + res.status(404).json({ + success: false, + error: { message: 'Exchange rate not found for conversion', code: 'NOT_FOUND' }, + }); + return; + } + + res.json({ + success: true, + data: result, + }); + } catch (error) { + next(error); + } +} + +export async function updateExchangeRate(req: AuthRequest, res: Response, next: NextFunction): Promise { + try { + const userId = req.user?.id; + if (!userId) { + res.status(401).json({ + success: false, + error: { message: 'Unauthorized', code: 'UNAUTHORIZED' }, + }); + return; + } + + const { fromCurrency, toCurrency, rate, source, provider, metadata } = req.body as UpdateRateRequest; + + if (!fromCurrency || !toCurrency || !rate) { + res.status(400).json({ + success: false, + error: { + message: 'fromCurrency, toCurrency, and rate are required', + code: 'VALIDATION_ERROR', + }, + }); + return; + } + + if (typeof rate !== 'number' || rate <= 0) { + res.status(400).json({ + success: false, + error: { message: 'Rate must be a positive number', code: 'VALIDATION_ERROR' }, + }); + return; + } + + const updatedRate = await currencyService.updateRate({ + fromCurrency, + toCurrency, + rate, + source, + provider, + metadata, + }); + + res.json({ + success: true, + data: updatedRate, + }); + } catch (error) { + next(error); + } +} diff --git a/src/modules/currency/currency.routes.ts b/src/modules/currency/currency.routes.ts new file mode 100644 index 0000000..55fa53b --- /dev/null +++ b/src/modules/currency/currency.routes.ts @@ -0,0 +1,41 @@ +/** + * Currency Routes + * Currency exchange endpoints + */ + +import { Router, RequestHandler } from 'express'; +import * as currencyController from './controllers/currency.controller'; + +const router = Router(); + +const authHandler = (fn: Function): RequestHandler => fn as RequestHandler; + +/** + * GET /api/v1/currency/rates/:from/:to + * Get exchange rate between two currencies + * Path params: from (currency code), to (currency code) + */ +router.get('/rates/:from/:to', currencyController.getExchangeRate); + +/** + * GET /api/v1/currency/rates/:baseCurrency + * Get all exchange rates for a base currency + * Path params: baseCurrency (currency code) + */ +router.get('/rates/:baseCurrency', currencyController.getAllRates); + +/** + * POST /api/v1/currency/convert + * Convert amount between currencies + * Body: { amount: number, fromCurrency: string, toCurrency: string } + */ +router.post('/convert', currencyController.convertCurrency); + +/** + * PUT /api/v1/currency/rates + * Update exchange rate (admin only) + * Body: { fromCurrency: string, toCurrency: string, rate: number, source?: string, provider?: string, metadata?: object } + */ +router.put('/rates', authHandler(currencyController.updateExchangeRate)); + +export { router as currencyRouter }; diff --git a/src/modules/currency/index.ts b/src/modules/currency/index.ts new file mode 100644 index 0000000..b7bf871 --- /dev/null +++ b/src/modules/currency/index.ts @@ -0,0 +1,12 @@ +/** + * Currency Module - Public Exports + * + * Main entry point for the Currency Exchange module. + * Provides access to currency exchange services, types, and routes. + */ + +export { currencyService } from './services/currency.service'; + +export * from './types/currency.types'; + +export { currencyRouter } from './currency.routes'; diff --git a/src/modules/currency/services/currency.service.ts b/src/modules/currency/services/currency.service.ts new file mode 100644 index 0000000..b7199b3 --- /dev/null +++ b/src/modules/currency/services/currency.service.ts @@ -0,0 +1,320 @@ +/** + * Currency Exchange Service + * Handles currency exchange operations with Redis caching + */ + +import { db } from '../../../shared/database'; +import { redis } from '../../../shared/redis'; +import { logger } from '../../../shared/utils/logger'; +import { + ExchangeRate, + ConversionRequest, + ConversionResult, + ExchangeRateRecord, + UpdateRateRequest, +} from '../types/currency.types'; + +class CurrencyService { + private readonly CACHE_TTL = 300; + private readonly CACHE_PREFIX = 'currency:rate'; + + async getExchangeRate(from: string, to: string): Promise { + const fromUpper = from.toUpperCase(); + const toUpper = to.toUpperCase(); + const cacheKey = `${this.CACHE_PREFIX}:${fromUpper}:${toUpper}`; + + try { + const redisClient = await redis.getClient(); + const cached = await redisClient.get(cacheKey); + + if (cached) { + logger.debug('Exchange rate cache hit', { from: fromUpper, to: toUpper }); + const parsed = JSON.parse(cached); + return { + ...parsed, + updatedAt: new Date(parsed.updatedAt), + }; + } + } catch (error) { + logger.warn('Redis get failed, proceeding without cache', { + error: (error as Error).message, + }); + } + + if (fromUpper === toUpper) { + return { + fromCurrency: fromUpper, + toCurrency: toUpper, + rate: 1.0, + source: 'system', + updatedAt: new Date(), + }; + } + + const rate = await this.fetchRateFromDB(fromUpper, toUpper); + + if (rate) { + try { + const redisClient = await redis.getClient(); + await redisClient.setex(cacheKey, this.CACHE_TTL, JSON.stringify(rate)); + } catch (error) { + logger.warn('Redis set failed', { error: (error as Error).message }); + } + } + + return rate; + } + + async getAllRates(baseCurrency: string): Promise { + const baseUpper = baseCurrency.toUpperCase(); + const cacheKey = `${this.CACHE_PREFIX}:all:${baseUpper}`; + + try { + const redisClient = await redis.getClient(); + const cached = await redisClient.get(cacheKey); + + if (cached) { + logger.debug('All rates cache hit', { baseCurrency: baseUpper }); + const parsed = JSON.parse(cached); + return parsed.map((rate: ExchangeRate) => ({ + ...rate, + updatedAt: new Date(rate.updatedAt), + })); + } + } catch (error) { + logger.warn('Redis get failed, proceeding without cache', { + error: (error as Error).message, + }); + } + + const rates = await this.fetchAllRatesFromDB(baseUpper); + + if (rates.length > 0) { + try { + const redisClient = await redis.getClient(); + await redisClient.setex(cacheKey, this.CACHE_TTL, JSON.stringify(rates)); + } catch (error) { + logger.warn('Redis set failed', { error: (error as Error).message }); + } + } + + return rates; + } + + async convert(request: ConversionRequest): Promise { + const { amount, fromCurrency, toCurrency } = request; + + if (amount <= 0) { + throw new Error('Amount must be positive'); + } + + const rate = await this.getExchangeRate(fromCurrency, toCurrency); + + if (!rate) { + return null; + } + + const convertedAmount = amount * rate.rate; + + return { + originalAmount: amount, + convertedAmount, + rate: rate.rate, + fromCurrency: rate.fromCurrency, + toCurrency: rate.toCurrency, + }; + } + + async updateRate(request: UpdateRateRequest): Promise { + const { + fromCurrency, + toCurrency, + rate, + source = 'manual', + provider, + metadata = {}, + } = request; + + const fromUpper = fromCurrency.toUpperCase(); + const toUpper = toCurrency.toUpperCase(); + + if (fromUpper === toUpper) { + throw new Error('Cannot update rate for same currency'); + } + + if (rate <= 0) { + throw new Error('Rate must be positive'); + } + + await db.query( + `UPDATE financial.currency_exchange_rates + SET valid_to = NOW() + WHERE from_currency = $1 + AND to_currency = $2 + AND valid_to IS NULL`, + [fromUpper, toUpper] + ); + + const result = await db.query( + `INSERT INTO financial.currency_exchange_rates ( + from_currency, to_currency, rate, source, provider, metadata, valid_from + ) VALUES ($1, $2, $3, $4, $5, $6, NOW()) + RETURNING *`, + [fromUpper, toUpper, rate, source, provider || null, JSON.stringify(metadata)] + ); + + const record = result.rows[0]; + + await this.invalidateCache(fromUpper, toUpper); + + logger.info('Exchange rate updated', { fromCurrency: fromUpper, toCurrency: toUpper, rate, source }); + + return { + fromCurrency: record.from_currency, + toCurrency: record.to_currency, + rate: parseFloat(record.rate), + source: record.source, + updatedAt: record.valid_from, + }; + } + + private async fetchRateFromDB(from: string, to: string): Promise { + const query = ` + SELECT + from_currency, + to_currency, + rate, + source, + valid_from as updated_at + FROM financial.currency_exchange_rates + WHERE from_currency = $1 + AND to_currency = $2 + AND valid_from <= NOW() + AND (valid_to IS NULL OR valid_to > NOW()) + ORDER BY valid_from DESC + LIMIT 1 + `; + + const result = await db.query<{ + from_currency: string; + to_currency: string; + rate: string; + source: string; + updated_at: Date; + }>(query, [from, to]); + + if (result.rows.length === 0) { + const inverseQuery = ` + SELECT + to_currency as from_currency, + from_currency as to_currency, + (1.0 / rate) as rate, + source, + valid_from as updated_at + FROM financial.currency_exchange_rates + WHERE from_currency = $2 + AND to_currency = $1 + AND valid_from <= NOW() + AND (valid_to IS NULL OR valid_to > NOW()) + ORDER BY valid_from DESC + LIMIT 1 + `; + + const inverseResult = await db.query<{ + from_currency: string; + to_currency: string; + rate: string; + source: string; + updated_at: Date; + }>(inverseQuery, [from, to]); + + if (inverseResult.rows.length === 0) { + return null; + } + + const row = inverseResult.rows[0]; + return { + fromCurrency: row.from_currency, + toCurrency: row.to_currency, + rate: parseFloat(row.rate), + source: row.source, + updatedAt: row.updated_at, + }; + } + + const row = result.rows[0]; + return { + fromCurrency: row.from_currency, + toCurrency: row.to_currency, + rate: parseFloat(row.rate), + source: row.source, + updatedAt: row.updated_at, + }; + } + + private async fetchAllRatesFromDB(baseCurrency: string): Promise { + const query = ` + SELECT + from_currency, + to_currency, + rate, + source, + valid_from as updated_at + FROM financial.currency_exchange_rates + WHERE (from_currency = $1 OR to_currency = $1) + AND valid_from <= NOW() + AND (valid_to IS NULL OR valid_to > NOW()) + ORDER BY to_currency ASC + `; + + const result = await db.query<{ + from_currency: string; + to_currency: string; + rate: string; + source: string; + updated_at: Date; + }>(query, [baseCurrency]); + + const rates: ExchangeRate[] = []; + + for (const row of result.rows) { + if (row.from_currency === baseCurrency) { + rates.push({ + fromCurrency: row.from_currency, + toCurrency: row.to_currency, + rate: parseFloat(row.rate), + source: row.source, + updatedAt: row.updated_at, + }); + } else { + rates.push({ + fromCurrency: baseCurrency, + toCurrency: row.from_currency, + rate: 1.0 / parseFloat(row.rate), + source: row.source, + updatedAt: row.updated_at, + }); + } + } + + return rates; + } + + private async invalidateCache(from: string, to: string): Promise { + try { + const redisClient = await redis.getClient(); + const keys = [ + `${this.CACHE_PREFIX}:${from}:${to}`, + `${this.CACHE_PREFIX}:${to}:${from}`, + `${this.CACHE_PREFIX}:all:${from}`, + `${this.CACHE_PREFIX}:all:${to}`, + ]; + await redisClient.del(...keys); + logger.debug('Currency cache invalidated', { from, to }); + } catch (error) { + logger.warn('Cache invalidation failed', { error: (error as Error).message }); + } + } +} + +export const currencyService = new CurrencyService(); diff --git a/src/modules/currency/types/currency.types.ts b/src/modules/currency/types/currency.types.ts new file mode 100644 index 0000000..6505d6d --- /dev/null +++ b/src/modules/currency/types/currency.types.ts @@ -0,0 +1,49 @@ +/** + * Currency Exchange Types + * Types and interfaces for currency exchange operations + */ + +export interface ExchangeRate { + fromCurrency: string; + toCurrency: string; + rate: number; + source: string; + updatedAt: Date; +} + +export interface ConversionRequest { + amount: number; + fromCurrency: string; + toCurrency: string; +} + +export interface ConversionResult { + originalAmount: number; + convertedAmount: number; + rate: number; + fromCurrency: string; + toCurrency: string; +} + +export interface ExchangeRateRecord { + id: string; + from_currency: string; + to_currency: string; + rate: string; + source: string; + provider: string | null; + valid_from: Date; + valid_to: Date | null; + metadata: Record; + created_at: Date; + updated_at: Date; +} + +export interface UpdateRateRequest { + fromCurrency: string; + toCurrency: string; + rate: number; + source?: string; + provider?: string; + metadata?: Record; +}