diff --git a/src/core/guards/auth.guard.ts b/src/core/guards/auth.guard.ts index b22d71f..020028e 100644 --- a/src/core/guards/auth.guard.ts +++ b/src/core/guards/auth.guard.ts @@ -162,12 +162,14 @@ export const requireAdmin = requireRoles(UserRoleEnum.ADMIN, UserRoleEnum.SUPER_ /** * Require instructor role + * @deprecated Role 'instructor' does not exist in DDL (auth.user_role) + * Use requireAdmin or create a custom permission-based guard instead */ -export const requireInstructor = requireRoles( - UserRoleEnum.INSTRUCTOR, - UserRoleEnum.ADMIN, - UserRoleEnum.SUPER_ADMIN -); +// export const requireInstructor = requireRoles( +// UserRoleEnum.INSTRUCTOR, +// UserRoleEnum.ADMIN, +// UserRoleEnum.SUPER_ADMIN +// ); /** * Resource ownership guard diff --git a/src/modules/auth/types/auth.types.ts b/src/modules/auth/types/auth.types.ts index 648e357..ef802bb 100644 --- a/src/modules/auth/types/auth.types.ts +++ b/src/modules/auth/types/auth.types.ts @@ -11,15 +11,15 @@ export type AuthProvider = | 'apple' | 'github'; -export type UserRole = 'investor' | 'trader' | 'student' | 'instructor' | 'admin' | 'superadmin'; +// Alineado con auth.user_role (DDL: apps/database/ddl/schemas/auth/01-enums.sql) +export type UserRole = 'user' | 'trader' | 'analyst' | 'admin' | 'super_admin'; export enum UserRoleEnum { - INVESTOR = 'investor', + USER = 'user', TRADER = 'trader', - STUDENT = 'student', - INSTRUCTOR = 'instructor', + ANALYST = 'analyst', ADMIN = 'admin', - SUPER_ADMIN = 'superadmin', + SUPER_ADMIN = 'super_admin', } export type UserStatus = 'pending' | 'active' | 'suspended' | 'banned'; diff --git a/src/modules/investment/controllers/investment.controller.ts b/src/modules/investment/controllers/investment.controller.ts index 7f68e12..068edd7 100644 --- a/src/modules/investment/controllers/investment.controller.ts +++ b/src/modules/investment/controllers/investment.controller.ts @@ -4,8 +4,9 @@ */ import { Request, Response, NextFunction } from 'express'; -import { productService, RiskProfile } from '../services/product.service'; +import { productService } from '../services/product.service'; import { accountService, CreateAccountInput } from '../services/account.service'; +import type { RiskProfile } from '../types/investment.types'; import { transactionService, TransactionType, diff --git a/src/modules/investment/jobs/distribution.job.ts b/src/modules/investment/jobs/distribution.job.ts new file mode 100644 index 0000000..baf6f04 --- /dev/null +++ b/src/modules/investment/jobs/distribution.job.ts @@ -0,0 +1,462 @@ +/** + * Distribution Job + * Daily calculation and distribution of investment returns + * Runs at 00:00 UTC + */ + +import { db } from '../../../shared/database'; +import { logger } from '../../../shared/utils/logger'; +import { notificationService } from '../../notifications'; + +// ============================================================================ +// Types +// ============================================================================ + +interface InvestmentAccount { + id: string; + userId: string; + productId: string; + productCode: string; + productName: string; + accountNumber: string; + currentBalance: number; + status: string; +} + +interface Product { + id: string; + code: string; + name: string; + targetReturnMin: number; + targetReturnMax: number; + performanceFee: number; +} + +interface DistributionResult { + accountId: string; + userId: string; + productName: string; + accountNumber: string; + grossReturn: number; + performanceFee: number; + netReturn: number; + previousBalance: number; + newBalance: number; +} + +interface DistributionSummary { + totalAccounts: number; + successfulDistributions: number; + failedDistributions: number; + totalGrossReturns: number; + totalFees: number; + totalNetReturns: number; + startTime: Date; + endTime: Date; + duration: number; +} + +// ============================================================================ +// Distribution Job Class +// ============================================================================ + +class DistributionJob { + private isRunning = false; + private lastRunAt: Date | null = null; + private cronInterval: NodeJS.Timeout | null = null; + + /** + * Start the distribution job scheduler + */ + start(): void { + if (this.cronInterval) { + logger.warn('[DistributionJob] Job already running'); + return; + } + + // Calculate time until next midnight UTC + const now = new Date(); + const nextMidnight = new Date(Date.UTC( + now.getUTCFullYear(), + now.getUTCMonth(), + now.getUTCDate() + 1, + 0, 0, 0, 0 + )); + const msUntilMidnight = nextMidnight.getTime() - now.getTime(); + + // Schedule first run at next midnight, then every 24 hours + setTimeout(() => { + this.run(); + this.cronInterval = setInterval(() => this.run(), 24 * 60 * 60 * 1000); + }, msUntilMidnight); + + logger.info('[DistributionJob] Scheduled to run at 00:00 UTC', { + nextRun: nextMidnight.toISOString(), + msUntilRun: msUntilMidnight, + }); + } + + /** + * Stop the distribution job scheduler + */ + stop(): void { + if (this.cronInterval) { + clearInterval(this.cronInterval); + this.cronInterval = null; + } + logger.info('[DistributionJob] Stopped'); + } + + /** + * Execute the distribution job + */ + async run(): Promise { + if (this.isRunning) { + logger.warn('[DistributionJob] Distribution already in progress, skipping'); + throw new Error('Distribution already in progress'); + } + + this.isRunning = true; + const startTime = new Date(); + + const summary: DistributionSummary = { + totalAccounts: 0, + successfulDistributions: 0, + failedDistributions: 0, + totalGrossReturns: 0, + totalFees: 0, + totalNetReturns: 0, + startTime, + endTime: startTime, + duration: 0, + }; + + try { + logger.info('[DistributionJob] Starting daily distribution', { + date: startTime.toISOString().split('T')[0], + }); + + // Get all active investment accounts + const accounts = await this.getActiveAccounts(); + summary.totalAccounts = accounts.length; + + if (accounts.length === 0) { + logger.info('[DistributionJob] No active accounts to process'); + return summary; + } + + // Get products with their return rates + const products = await this.getProducts(); + const productMap = new Map(products.map(p => [p.id, p])); + + // Process each account + for (const account of accounts) { + try { + const product = productMap.get(account.productId); + if (!product) { + logger.warn('[DistributionJob] Product not found for account', { + accountId: account.id, + productId: account.productId, + }); + summary.failedDistributions++; + continue; + } + + // Calculate and distribute returns + const result = await this.distributeReturns(account, product); + + if (result) { + summary.successfulDistributions++; + summary.totalGrossReturns += result.grossReturn; + summary.totalFees += result.performanceFee; + summary.totalNetReturns += result.netReturn; + + // Send notification to user + await this.notifyUser(result); + } + } catch (error) { + logger.error('[DistributionJob] Failed to process account', { + accountId: account.id, + error: (error as Error).message, + }); + summary.failedDistributions++; + } + } + + const endTime = new Date(); + summary.endTime = endTime; + summary.duration = endTime.getTime() - startTime.getTime(); + + // Log summary + await this.logDistributionRun(summary); + + logger.info('[DistributionJob] Distribution completed', { + processed: summary.successfulDistributions, + failed: summary.failedDistributions, + totalNetReturns: summary.totalNetReturns.toFixed(2), + duration: `${summary.duration}ms`, + }); + + this.lastRunAt = endTime; + return summary; + } finally { + this.isRunning = false; + } + } + + /** + * Get all active investment accounts + */ + private async getActiveAccounts(): Promise { + const result = await db.query<{ + id: string; + user_id: string; + product_id: string; + product_code: string; + product_name: string; + account_number: string; + current_balance: string; + status: string; + }>( + `SELECT + a.id, + a.user_id, + a.product_id, + p.code as product_code, + p.name as product_name, + a.account_number, + a.current_balance, + a.status + FROM investment.accounts a + JOIN investment.products p ON p.id = a.product_id + WHERE a.status = 'active' + AND a.current_balance > 0 + ORDER BY a.created_at` + ); + + return result.rows.map(row => ({ + id: row.id, + userId: row.user_id, + productId: row.product_id, + productCode: row.product_code, + productName: row.product_name, + accountNumber: row.account_number, + currentBalance: parseFloat(row.current_balance), + status: row.status, + })); + } + + /** + * Get all active products + */ + private async getProducts(): Promise { + const result = await db.query<{ + id: string; + code: string; + name: string; + target_return_min: string; + target_return_max: string; + performance_fee: string; + }>( + `SELECT id, code, name, target_return_min, target_return_max, performance_fee + FROM investment.products + WHERE is_active = TRUE` + ); + + return result.rows.map(row => ({ + id: row.id, + code: row.code, + name: row.name, + targetReturnMin: parseFloat(row.target_return_min), + targetReturnMax: parseFloat(row.target_return_max), + performanceFee: parseFloat(row.performance_fee), + })); + } + + /** + * Calculate and distribute returns for an account + */ + private async distributeReturns( + account: InvestmentAccount, + product: Product + ): Promise { + // Calculate daily return rate + // Monthly return range is targetReturnMin to targetReturnMax + // Daily rate = monthly rate / 30 (approximation) + // We use a random value within the range to simulate market variation + + const monthlyReturnMin = product.targetReturnMin / 100; + const monthlyReturnMax = product.targetReturnMax / 100; + + // Add some daily variance (can be slightly negative on bad days) + const variance = (Math.random() - 0.3) * 0.5; // -0.15 to +0.35 + const dailyReturnRate = ((monthlyReturnMin + monthlyReturnMax) / 2 / 30) * (1 + variance); + + // Calculate gross return + const grossReturn = account.currentBalance * dailyReturnRate; + + // Only distribute if positive (skip on negative days) + if (grossReturn <= 0) { + logger.debug('[DistributionJob] Skipping negative return day', { + accountId: account.id, + grossReturn: grossReturn.toFixed(4), + }); + return null; + } + + // Calculate performance fee (only on positive returns) + const performanceFeeRate = product.performanceFee / 100; + const performanceFee = grossReturn * performanceFeeRate; + const netReturn = grossReturn - performanceFee; + + // Round to 2 decimal places + const roundedNetReturn = Math.round(netReturn * 100) / 100; + + if (roundedNetReturn <= 0) { + return null; + } + + // Execute distribution in a transaction + return await db.transaction(async (client) => { + // Lock account row + const lockResult = await client.query<{ current_balance: string }>( + 'SELECT current_balance FROM investment.accounts WHERE id = $1 FOR UPDATE', + [account.id] + ); + + if (lockResult.rows.length === 0) { + throw new Error('Account not found'); + } + + const previousBalance = parseFloat(lockResult.rows[0].current_balance); + const newBalance = previousBalance + roundedNetReturn; + + // Update account balance + await client.query( + `UPDATE investment.accounts + SET current_balance = $1, + total_earnings = total_earnings + $2, + updated_at = NOW() + WHERE id = $3`, + [newBalance, roundedNetReturn, account.id] + ); + + // Record distribution transaction + await client.query( + `INSERT INTO investment.transactions ( + account_id, type, amount, fee_amount, description, status, processed_at + ) VALUES ($1, 'distribution', $2, $3, $4, 'completed', NOW())`, + [ + account.id, + roundedNetReturn, + Math.round(performanceFee * 100) / 100, + `Daily distribution from ${product.name}`, + ] + ); + + // Record in distribution history + await client.query( + `INSERT INTO investment.distribution_history ( + account_id, product_id, distribution_date, gross_amount, fee_amount, net_amount, balance_before, balance_after + ) VALUES ($1, $2, CURRENT_DATE, $3, $4, $5, $6, $7)`, + [ + account.id, + account.productId, + Math.round(grossReturn * 100) / 100, + Math.round(performanceFee * 100) / 100, + roundedNetReturn, + previousBalance, + newBalance, + ] + ); + + return { + accountId: account.id, + userId: account.userId, + productName: account.productName, + accountNumber: account.accountNumber, + grossReturn: Math.round(grossReturn * 100) / 100, + performanceFee: Math.round(performanceFee * 100) / 100, + netReturn: roundedNetReturn, + previousBalance, + newBalance, + }; + }); + } + + /** + * Send distribution notification to user + */ + private async notifyUser(result: DistributionResult): Promise { + try { + await notificationService.sendDistributionNotification(result.userId, { + productName: result.productName, + amount: result.netReturn, + accountNumber: result.accountNumber, + newBalance: result.newBalance, + }); + } catch (error) { + logger.error('[DistributionJob] Failed to send notification', { + userId: result.userId, + error: (error as Error).message, + }); + } + } + + /** + * Log distribution run to database + */ + private async logDistributionRun(summary: DistributionSummary): Promise { + try { + await db.query( + `INSERT INTO investment.distribution_runs ( + run_date, total_accounts, successful_count, failed_count, + total_gross_amount, total_fee_amount, total_net_amount, + started_at, completed_at, duration_ms + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`, + [ + summary.startTime.toISOString().split('T')[0], + summary.totalAccounts, + summary.successfulDistributions, + summary.failedDistributions, + Math.round(summary.totalGrossReturns * 100) / 100, + Math.round(summary.totalFees * 100) / 100, + Math.round(summary.totalNetReturns * 100) / 100, + summary.startTime, + summary.endTime, + summary.duration, + ] + ); + } catch (error) { + logger.error('[DistributionJob] Failed to log distribution run', { + error: (error as Error).message, + }); + } + } + + /** + * Get job status + */ + getStatus(): { + isRunning: boolean; + lastRunAt: Date | null; + isScheduled: boolean; + } { + return { + isRunning: this.isRunning, + lastRunAt: this.lastRunAt, + isScheduled: this.cronInterval !== null, + }; + } + + /** + * Manually trigger distribution (for testing/admin) + */ + async triggerManually(): Promise { + logger.info('[DistributionJob] Manual trigger requested'); + return this.run(); + } +} + +// Export singleton instance +export const distributionJob = new DistributionJob(); diff --git a/src/modules/investment/jobs/index.ts b/src/modules/investment/jobs/index.ts new file mode 100644 index 0000000..a619fd7 --- /dev/null +++ b/src/modules/investment/jobs/index.ts @@ -0,0 +1,6 @@ +/** + * Investment Jobs + * Background jobs for the investment module + */ + +export * from './distribution.job'; diff --git a/src/modules/investment/repositories/account.repository.ts b/src/modules/investment/repositories/account.repository.ts index 5f06bc6..247f330 100644 --- a/src/modules/investment/repositories/account.repository.ts +++ b/src/modules/investment/repositories/account.repository.ts @@ -4,14 +4,16 @@ */ import { db } from '../../../shared/database'; +import type { + AccountStatus, + RiskProfile, + TradingAgent, +} from '../types/investment.types'; // ============================================================================ // Types // ============================================================================ -export type AccountStatus = 'pending_kyc' | 'active' | 'suspended' | 'closed'; -export type RiskProfile = 'conservative' | 'moderate' | 'aggressive'; - export interface AccountRow { id: string; user_id: string; diff --git a/src/modules/investment/services/account.service.ts b/src/modules/investment/services/account.service.ts index add6474..6d6ac80 100644 --- a/src/modules/investment/services/account.service.ts +++ b/src/modules/investment/services/account.service.ts @@ -9,8 +9,8 @@ import { productService, InvestmentProduct } from './product.service'; import { accountRepository, InvestmentAccount as RepoAccount, - RiskProfile, } from '../repositories/account.repository'; +import type { RiskProfile, AccountStatus as DbAccountStatus } from '../types/investment.types'; // ============================================================================ // Types diff --git a/src/modules/investment/services/product.service.ts b/src/modules/investment/services/product.service.ts index f9ac004..e87d9dd 100644 --- a/src/modules/investment/services/product.service.ts +++ b/src/modules/investment/services/product.service.ts @@ -10,13 +10,13 @@ import { productRepository, InvestmentProduct as RepoProduct, } from '../repositories/product.repository'; -import { RiskProfile as RepoRiskProfile } from '../repositories/account.repository'; +import type { RiskProfile } from '../types/investment.types'; // ============================================================================ // Types // ============================================================================ -export type RiskProfile = 'conservative' | 'moderate' | 'aggressive'; +// RiskProfile is imported from ../types/investment.types export interface InvestmentProduct { id: string; @@ -219,7 +219,7 @@ class ProductService { if (this.useDatabase) { try { const repoProducts = await productRepository.findByRiskProfile( - riskProfile as RepoRiskProfile + riskProfile as RiskProfile ); if (repoProducts.length > 0) { return repoProducts.map(mapRepoToService); @@ -248,7 +248,7 @@ class ProductService { description: input.description, shortDescription: input.strategy, productType: 'variable_return', - riskProfile: input.riskProfile as RepoRiskProfile, + riskProfile: input.riskProfile as RiskProfile, targetMonthlyReturn: input.targetReturnMin, maxDrawdown: input.maxDrawdown, managementFeePercent: input.managementFee, diff --git a/src/modules/investment/types/investment.types.ts b/src/modules/investment/types/investment.types.ts new file mode 100644 index 0000000..79ac10a --- /dev/null +++ b/src/modules/investment/types/investment.types.ts @@ -0,0 +1,121 @@ +/** + * Investment Module Types + * Type definitions for PAMM accounts, trading agents, and profit distribution + * Aligned with investment schema DDL (00-enums.sql) + */ + +// ============================================================================ +// Investment Enums (Alineado con investment.* DDL) +// ============================================================================ + +// Alineado con investment.trading_agent (DDL) +export type TradingAgent = 'atlas' | 'orion' | 'nova'; + +export enum TradingAgentEnum { + ATLAS = 'atlas', + ORION = 'orion', + NOVA = 'nova', +} + +// Alineado con investment.risk_profile (DDL) +export type RiskProfile = 'conservative' | 'moderate' | 'aggressive'; + +export enum RiskProfileEnum { + CONSERVATIVE = 'conservative', + MODERATE = 'moderate', + AGGRESSIVE = 'aggressive', +} + +// Alineado con investment.account_status (DDL) +export type AccountStatus = 'pending_kyc' | 'active' | 'suspended' | 'closed'; + +export enum AccountStatusEnum { + PENDING_KYC = 'pending_kyc', + ACTIVE = 'active', + SUSPENDED = 'suspended', + CLOSED = 'closed', +} + +// Alineado con investment.distribution_frequency (DDL) +export type DistributionFrequency = 'monthly' | 'quarterly'; + +export enum DistributionFrequencyEnum { + MONTHLY = 'monthly', + QUARTERLY = 'quarterly', +} + +// Alineado con investment.transaction_type (DDL) +export type TransactionType = 'deposit' | 'withdrawal' | 'distribution'; + +export enum TransactionTypeEnum { + DEPOSIT = 'deposit', + WITHDRAWAL = 'withdrawal', + DISTRIBUTION = 'distribution', +} + +// Alineado con investment.transaction_status (DDL) +export type TransactionStatus = + | 'pending' + | 'processing' + | 'completed' + | 'failed' + | 'cancelled'; + +export enum TransactionStatusEnum { + PENDING = 'pending', + PROCESSING = 'processing', + COMPLETED = 'completed', + FAILED = 'failed', + CANCELLED = 'cancelled', +} + +// ============================================================================ +// Interfaces +// ============================================================================ + +export interface InvestmentAccount { + id: string; + user_id: string; + trading_agent: TradingAgent; + initial_capital: number; + current_balance: number; + accumulated_profit: number; + accumulated_loss: number; + status: AccountStatus; + risk_profile: RiskProfile; + created_at: Date; + updated_at: Date; + closed_at: Date | null; +} + +export interface InvestmentTransaction { + id: string; + account_id: string; + type: TransactionType; + amount: number; + status: TransactionStatus; + created_at: Date; + completed_at: Date | null; + notes: string | null; +} + +export interface Distribution { + id: string; + account_id: string; + distribution_date: Date; + profit_amount: number; + performance_fee: number; + net_profit: number; + balance_before: number; + balance_after: number; +} + +export interface AgentPerformance { + trading_agent: TradingAgent; + total_accounts: number; + total_capital: number; + avg_monthly_return: number; + max_drawdown: number; + sharpe_ratio: number; + win_rate: number; +} diff --git a/src/modules/trading/controllers/export.controller.ts b/src/modules/trading/controllers/export.controller.ts new file mode 100644 index 0000000..0acbaa8 --- /dev/null +++ b/src/modules/trading/controllers/export.controller.ts @@ -0,0 +1,137 @@ +/** + * Export Controller + * Handles trading history export endpoints + */ + +import { Request, Response, NextFunction } from 'express'; +import { exportService, ExportFilters } from '../services/export.service'; + +// ============================================================================ +// Helper Functions +// ============================================================================ + +function parseFilters(query: Request['query']): ExportFilters { + const filters: ExportFilters = {}; + + if (query.startDate && typeof query.startDate === 'string') { + filters.startDate = new Date(query.startDate); + } + + if (query.endDate && typeof query.endDate === 'string') { + filters.endDate = new Date(query.endDate); + } + + if (query.symbols) { + if (typeof query.symbols === 'string') { + filters.symbols = query.symbols.split(',').map((s) => s.trim().toUpperCase()); + } else if (Array.isArray(query.symbols)) { + filters.symbols = query.symbols.map((s) => String(s).trim().toUpperCase()); + } + } + + if (query.status && typeof query.status === 'string') { + if (['open', 'closed', 'all'].includes(query.status)) { + filters.status = query.status as 'open' | 'closed' | 'all'; + } + } + + if (query.direction && typeof query.direction === 'string') { + if (['long', 'short', 'all'].includes(query.direction)) { + filters.direction = query.direction as 'long' | 'short' | 'all'; + } + } + + return filters; +} + +// ============================================================================ +// Export Controllers +// ============================================================================ + +/** + * Export trading history to CSV + */ +export async function exportCSV( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const userId = req.user!.id; + const filters = parseFilters(req.query); + + const result = await exportService.exportToCSV(userId, filters); + + res.setHeader('Content-Type', result.mimeType); + res.setHeader('Content-Disposition', `attachment; filename="${result.filename}"`); + res.send(result.data); + } catch (error) { + next(error); + } +} + +/** + * Export trading history to Excel + */ +export async function exportExcel( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const userId = req.user!.id; + const filters = parseFilters(req.query); + + const result = await exportService.exportToExcel(userId, filters); + + res.setHeader('Content-Type', result.mimeType); + res.setHeader('Content-Disposition', `attachment; filename="${result.filename}"`); + res.send(result.data); + } catch (error) { + next(error); + } +} + +/** + * Export trading history to PDF + */ +export async function exportPDF( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const userId = req.user!.id; + const filters = parseFilters(req.query); + + const result = await exportService.exportToPDF(userId, filters); + + res.setHeader('Content-Type', result.mimeType); + res.setHeader('Content-Disposition', `attachment; filename="${result.filename}"`); + res.send(result.data); + } catch (error) { + next(error); + } +} + +/** + * Export trading history to JSON + */ +export async function exportJSON( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const userId = req.user!.id; + const filters = parseFilters(req.query); + + const result = await exportService.exportToJSON(userId, filters); + + res.setHeader('Content-Type', result.mimeType); + res.setHeader('Content-Disposition', `attachment; filename="${result.filename}"`); + res.send(result.data); + } catch (error) { + next(error); + } +} diff --git a/src/modules/trading/services/alerts.service.ts b/src/modules/trading/services/alerts.service.ts index 05bca10..29c546d 100644 --- a/src/modules/trading/services/alerts.service.ts +++ b/src/modules/trading/services/alerts.service.ts @@ -5,6 +5,7 @@ import { db } from '../../../shared/database'; import { logger } from '../../../shared/utils/logger'; +import { notificationService } from '../../notifications'; // ============================================================================ // Types (matching trading.price_alerts schema) @@ -277,8 +278,21 @@ class AlertsService { logger.info('[AlertsService] Alert triggered:', { alertId: id, currentPrice }); - // TODO: Send notifications based on notify_email and notify_push - // This would integrate with email and push notification services + // Send notifications based on user preferences + try { + await notificationService.sendAlertNotification(alert.userId, { + symbol: alert.symbol, + condition: alert.condition, + targetPrice: alert.price, + currentPrice, + note: alert.note, + }); + } catch (error) { + logger.error('[AlertsService] Failed to send alert notification:', { + alertId: id, + error: (error as Error).message, + }); + } } // ========================================================================== diff --git a/src/modules/trading/services/export.service.ts b/src/modules/trading/services/export.service.ts new file mode 100644 index 0000000..47a815f --- /dev/null +++ b/src/modules/trading/services/export.service.ts @@ -0,0 +1,536 @@ +/** + * Export Service + * Handles exporting trading history to CSV, Excel, and PDF formats + */ + +import { db } from '../../../shared/database'; +import { logger } from '../../../shared/utils/logger'; +import { format } from 'date-fns'; + +// ============================================================================ +// Types +// ============================================================================ + +export interface ExportFilters { + startDate?: Date; + endDate?: Date; + symbols?: string[]; + status?: 'open' | 'closed' | 'all'; + direction?: 'long' | 'short' | 'all'; +} + +export interface TradeRecord { + id: string; + symbol: string; + direction: 'long' | 'short'; + lotSize: number; + entryPrice: number; + exitPrice: number | null; + stopLoss: number | null; + takeProfit: number | null; + status: string; + openedAt: Date; + closedAt: Date | null; + realizedPnl: number | null; + realizedPnlPercent: number | null; + closeReason: string | null; +} + +export interface ExportResult { + data: Buffer; + filename: string; + mimeType: string; +} + +// ============================================================================ +// Helper Functions +// ============================================================================ + +function formatDate(date: Date | null): string { + if (!date) return ''; + return format(date, 'yyyy-MM-dd HH:mm:ss'); +} + +function formatCurrency(value: number | null): string { + if (value === null) return ''; + return value.toFixed(2); +} + +function formatPercent(value: number | null): string { + if (value === null) return ''; + return `${value.toFixed(2)}%`; +} + +function calculatePnlPercent(trade: TradeRecord): number | null { + if (trade.realizedPnl === null || trade.entryPrice === 0) return null; + const investment = trade.entryPrice * trade.lotSize; + return (trade.realizedPnl / investment) * 100; +} + +// ============================================================================ +// Export Service Class +// ============================================================================ + +class ExportService { + /** + * Fetch trades from database + */ + private async fetchTrades(userId: string, filters: ExportFilters): Promise { + const conditions: string[] = ['p.user_id = $1']; + const values: (string | Date)[] = [userId]; + let paramIndex = 2; + + if (filters.startDate) { + conditions.push(`p.opened_at >= $${paramIndex++}`); + values.push(filters.startDate); + } + + if (filters.endDate) { + conditions.push(`p.opened_at <= $${paramIndex++}`); + values.push(filters.endDate); + } + + if (filters.symbols && filters.symbols.length > 0) { + conditions.push(`p.symbol = ANY($${paramIndex++})`); + values.push(filters.symbols as unknown as string); + } + + if (filters.status && filters.status !== 'all') { + conditions.push(`p.status = $${paramIndex++}`); + values.push(filters.status); + } + + if (filters.direction && filters.direction !== 'all') { + conditions.push(`p.direction = $${paramIndex++}`); + values.push(filters.direction); + } + + const query = ` + SELECT + p.id, + p.symbol, + p.direction, + p.lot_size, + p.entry_price, + p.exit_price, + p.stop_loss, + p.take_profit, + p.status, + p.opened_at, + p.closed_at, + p.realized_pnl, + p.close_reason + FROM trading.paper_trading_positions p + WHERE ${conditions.join(' AND ')} + ORDER BY p.opened_at DESC + `; + + const result = await db.query<{ + id: string; + symbol: string; + direction: 'long' | 'short'; + lot_size: string; + entry_price: string; + exit_price: string | null; + stop_loss: string | null; + take_profit: string | null; + status: string; + opened_at: Date; + closed_at: Date | null; + realized_pnl: string | null; + close_reason: string | null; + }>(query, values); + + return result.rows.map((row) => { + const trade: TradeRecord = { + id: row.id, + symbol: row.symbol, + direction: row.direction, + lotSize: parseFloat(row.lot_size), + entryPrice: parseFloat(row.entry_price), + exitPrice: row.exit_price ? parseFloat(row.exit_price) : null, + stopLoss: row.stop_loss ? parseFloat(row.stop_loss) : null, + takeProfit: row.take_profit ? parseFloat(row.take_profit) : null, + status: row.status, + openedAt: row.opened_at, + closedAt: row.closed_at, + realizedPnl: row.realized_pnl ? parseFloat(row.realized_pnl) : null, + realizedPnlPercent: null, + closeReason: row.close_reason, + }; + trade.realizedPnlPercent = calculatePnlPercent(trade); + return trade; + }); + } + + /** + * Export trades to CSV format + */ + async exportToCSV(userId: string, filters: ExportFilters = {}): Promise { + logger.info('Exporting trades to CSV', { userId, filters }); + + const trades = await this.fetchTrades(userId, filters); + + // CSV header + const headers = [ + 'Trade ID', + 'Symbol', + 'Direction', + 'Lot Size', + 'Entry Price', + 'Exit Price', + 'Stop Loss', + 'Take Profit', + 'Status', + 'Opened At', + 'Closed At', + 'Realized P&L', + 'P&L %', + 'Close Reason', + ]; + + // CSV rows + const rows = trades.map((trade) => [ + trade.id, + trade.symbol, + trade.direction.toUpperCase(), + trade.lotSize.toString(), + formatCurrency(trade.entryPrice), + formatCurrency(trade.exitPrice), + formatCurrency(trade.stopLoss), + formatCurrency(trade.takeProfit), + trade.status.toUpperCase(), + formatDate(trade.openedAt), + formatDate(trade.closedAt), + formatCurrency(trade.realizedPnl), + formatPercent(trade.realizedPnlPercent), + trade.closeReason || '', + ]); + + // Build CSV string + const escapeCsvField = (field: string): string => { + if (field.includes(',') || field.includes('"') || field.includes('\n')) { + return `"${field.replace(/"/g, '""')}"`; + } + return field; + }; + + const csvLines = [ + headers.map(escapeCsvField).join(','), + ...rows.map((row) => row.map(escapeCsvField).join(',')), + ]; + + const csvContent = csvLines.join('\n'); + const filename = `trading-history-${format(new Date(), 'yyyy-MM-dd-HHmmss')}.csv`; + + return { + data: Buffer.from(csvContent, 'utf-8'), + filename, + mimeType: 'text/csv', + }; + } + + /** + * Export trades to Excel format + */ + async exportToExcel(userId: string, filters: ExportFilters = {}): Promise { + logger.info('Exporting trades to Excel', { userId, filters }); + + // Dynamic import to avoid loading exceljs if not needed + const ExcelJS = await import('exceljs'); + const workbook = new ExcelJS.default.Workbook(); + + workbook.creator = 'Trading Platform'; + workbook.created = new Date(); + + const trades = await this.fetchTrades(userId, filters); + + // Trades sheet + const worksheet = workbook.addWorksheet('Trading History', { + properties: { tabColor: { argb: '4F46E5' } }, + }); + + // Define columns + worksheet.columns = [ + { header: 'Trade ID', key: 'id', width: 20 }, + { header: 'Symbol', key: 'symbol', width: 12 }, + { header: 'Direction', key: 'direction', width: 10 }, + { header: 'Lot Size', key: 'lotSize', width: 12 }, + { header: 'Entry Price', key: 'entryPrice', width: 14 }, + { header: 'Exit Price', key: 'exitPrice', width: 14 }, + { header: 'Stop Loss', key: 'stopLoss', width: 14 }, + { header: 'Take Profit', key: 'takeProfit', width: 14 }, + { header: 'Status', key: 'status', width: 10 }, + { header: 'Opened At', key: 'openedAt', width: 20 }, + { header: 'Closed At', key: 'closedAt', width: 20 }, + { header: 'Realized P&L', key: 'realizedPnl', width: 14 }, + { header: 'P&L %', key: 'pnlPercent', width: 10 }, + { header: 'Close Reason', key: 'closeReason', width: 15 }, + ]; + + // Style header row + const headerRow = worksheet.getRow(1); + headerRow.font = { bold: true, color: { argb: 'FFFFFF' } }; + headerRow.fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: '4F46E5' }, + }; + headerRow.alignment = { horizontal: 'center' }; + + // Add data rows + trades.forEach((trade) => { + const row = worksheet.addRow({ + id: trade.id, + symbol: trade.symbol, + direction: trade.direction.toUpperCase(), + lotSize: trade.lotSize, + entryPrice: trade.entryPrice, + exitPrice: trade.exitPrice, + stopLoss: trade.stopLoss, + takeProfit: trade.takeProfit, + status: trade.status.toUpperCase(), + openedAt: trade.openedAt, + closedAt: trade.closedAt, + realizedPnl: trade.realizedPnl, + pnlPercent: trade.realizedPnlPercent ? trade.realizedPnlPercent / 100 : null, + closeReason: trade.closeReason, + }); + + // Color P&L cells + const pnlCell = row.getCell('realizedPnl'); + if (trade.realizedPnl !== null) { + pnlCell.fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: trade.realizedPnl >= 0 ? 'D1FAE5' : 'FEE2E2' }, + }; + pnlCell.font = { + color: { argb: trade.realizedPnl >= 0 ? '059669' : 'DC2626' }, + }; + } + + // Format P&L percent + const pnlPercentCell = row.getCell('pnlPercent'); + pnlPercentCell.numFmt = '0.00%'; + }); + + // Add summary sheet + const summarySheet = workbook.addWorksheet('Summary', { + properties: { tabColor: { argb: '10B981' } }, + }); + + const totalTrades = trades.length; + const closedTrades = trades.filter((t) => t.status === 'closed'); + const winningTrades = closedTrades.filter((t) => (t.realizedPnl ?? 0) > 0); + const totalPnl = closedTrades.reduce((sum, t) => sum + (t.realizedPnl ?? 0), 0); + + summarySheet.columns = [ + { header: 'Metric', key: 'metric', width: 25 }, + { header: 'Value', key: 'value', width: 20 }, + ]; + + const summaryHeaderRow = summarySheet.getRow(1); + summaryHeaderRow.font = { bold: true, color: { argb: 'FFFFFF' } }; + summaryHeaderRow.fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: '10B981' }, + }; + + summarySheet.addRows([ + { metric: 'Total Trades', value: totalTrades }, + { metric: 'Closed Trades', value: closedTrades.length }, + { metric: 'Winning Trades', value: winningTrades.length }, + { metric: 'Losing Trades', value: closedTrades.length - winningTrades.length }, + { metric: 'Win Rate', value: closedTrades.length > 0 ? `${((winningTrades.length / closedTrades.length) * 100).toFixed(1)}%` : 'N/A' }, + { metric: 'Total P&L', value: `$${totalPnl.toFixed(2)}` }, + { metric: 'Export Date', value: format(new Date(), 'yyyy-MM-dd HH:mm:ss') }, + ]); + + // Generate buffer + const buffer = await workbook.xlsx.writeBuffer(); + const filename = `trading-history-${format(new Date(), 'yyyy-MM-dd-HHmmss')}.xlsx`; + + return { + data: Buffer.from(buffer), + filename, + mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + }; + } + + /** + * Export trades to PDF format + */ + async exportToPDF(userId: string, filters: ExportFilters = {}): Promise { + logger.info('Exporting trades to PDF', { userId, filters }); + + // Dynamic import to avoid loading pdfkit if not needed + const PDFDocument = (await import('pdfkit')).default; + + const trades = await this.fetchTrades(userId, filters); + + // Create PDF document + const doc = new PDFDocument({ + size: 'A4', + layout: 'landscape', + margin: 30, + }); + + const buffers: Buffer[] = []; + doc.on('data', (chunk: Buffer) => buffers.push(chunk)); + + // Title + doc.fontSize(20).font('Helvetica-Bold').text('Trading History Report', { align: 'center' }); + doc.moveDown(0.5); + doc.fontSize(10).font('Helvetica').text(`Generated: ${format(new Date(), 'yyyy-MM-dd HH:mm:ss')}`, { align: 'center' }); + doc.moveDown(1); + + // Summary section + const closedTrades = trades.filter((t) => t.status === 'closed'); + const winningTrades = closedTrades.filter((t) => (t.realizedPnl ?? 0) > 0); + const totalPnl = closedTrades.reduce((sum, t) => sum + (t.realizedPnl ?? 0), 0); + + doc.fontSize(12).font('Helvetica-Bold').text('Summary'); + doc.moveDown(0.3); + doc.fontSize(10).font('Helvetica'); + doc.text(`Total Trades: ${trades.length}`); + doc.text(`Closed Trades: ${closedTrades.length}`); + doc.text(`Win Rate: ${closedTrades.length > 0 ? ((winningTrades.length / closedTrades.length) * 100).toFixed(1) : 0}%`); + doc.text(`Total P&L: $${totalPnl.toFixed(2)}`, { + continued: false, + }); + doc.moveDown(1); + + // Table header + const tableTop = doc.y; + const columns = [ + { header: 'Symbol', width: 70 }, + { header: 'Dir', width: 40 }, + { header: 'Size', width: 50 }, + { header: 'Entry', width: 70 }, + { header: 'Exit', width: 70 }, + { header: 'Status', width: 60 }, + { header: 'Opened', width: 100 }, + { header: 'Closed', width: 100 }, + { header: 'P&L', width: 70 }, + ]; + + let x = 30; + doc.font('Helvetica-Bold').fontSize(8); + + // Draw header background + doc.fillColor('#4F46E5').rect(30, tableTop - 5, 730, 18).fill(); + doc.fillColor('#FFFFFF'); + + columns.forEach((col) => { + doc.text(col.header, x, tableTop, { width: col.width, align: 'center' }); + x += col.width; + }); + + // Draw table rows + doc.font('Helvetica').fontSize(8).fillColor('#000000'); + let rowY = tableTop + 18; + + trades.slice(0, 30).forEach((trade, index) => { + // Alternate row background + if (index % 2 === 0) { + doc.fillColor('#F3F4F6').rect(30, rowY - 3, 730, 14).fill(); + } + doc.fillColor('#000000'); + + x = 30; + const values = [ + trade.symbol, + trade.direction.toUpperCase(), + trade.lotSize.toFixed(2), + `$${trade.entryPrice.toFixed(2)}`, + trade.exitPrice ? `$${trade.exitPrice.toFixed(2)}` : '-', + trade.status.toUpperCase(), + format(trade.openedAt, 'MM/dd/yy HH:mm'), + trade.closedAt ? format(trade.closedAt, 'MM/dd/yy HH:mm') : '-', + trade.realizedPnl !== null ? `$${trade.realizedPnl.toFixed(2)}` : '-', + ]; + + values.forEach((value, colIndex) => { + // Color P&L + if (colIndex === 8 && trade.realizedPnl !== null) { + doc.fillColor(trade.realizedPnl >= 0 ? '#059669' : '#DC2626'); + } + doc.text(value, x, rowY, { width: columns[colIndex].width, align: 'center' }); + doc.fillColor('#000000'); + x += columns[colIndex].width; + }); + + rowY += 14; + + // Check if we need a new page + if (rowY > 550) { + doc.addPage(); + rowY = 30; + } + }); + + if (trades.length > 30) { + doc.moveDown(2); + doc.text(`... and ${trades.length - 30} more trades. Export to Excel for complete data.`, { align: 'center' }); + } + + // Footer + doc.fontSize(8).text('Trading Platform - Confidential', 30, 570, { align: 'center' }); + + // Finalize + doc.end(); + + // Wait for PDF generation to complete + await new Promise((resolve) => doc.on('end', resolve)); + + const pdfBuffer = Buffer.concat(buffers); + const filename = `trading-history-${format(new Date(), 'yyyy-MM-dd-HHmmss')}.pdf`; + + return { + data: pdfBuffer, + filename, + mimeType: 'application/pdf', + }; + } + + /** + * Export trades to JSON format (for API consistency) + */ + async exportToJSON(userId: string, filters: ExportFilters = {}): Promise { + logger.info('Exporting trades to JSON', { userId, filters }); + + const trades = await this.fetchTrades(userId, filters); + + const closedTrades = trades.filter((t) => t.status === 'closed'); + const winningTrades = closedTrades.filter((t) => (t.realizedPnl ?? 0) > 0); + const totalPnl = closedTrades.reduce((sum, t) => sum + (t.realizedPnl ?? 0), 0); + + const exportData = { + exportedAt: new Date().toISOString(), + filters, + summary: { + totalTrades: trades.length, + closedTrades: closedTrades.length, + winningTrades: winningTrades.length, + losingTrades: closedTrades.length - winningTrades.length, + winRate: closedTrades.length > 0 ? (winningTrades.length / closedTrades.length) * 100 : 0, + totalPnl, + }, + trades, + }; + + const jsonContent = JSON.stringify(exportData, null, 2); + const filename = `trading-history-${format(new Date(), 'yyyy-MM-dd-HHmmss')}.json`; + + return { + data: Buffer.from(jsonContent, 'utf-8'), + filename, + mimeType: 'application/json', + }; + } +} + +// Export singleton instance +export const exportService = new ExportService(); diff --git a/src/modules/trading/trading.routes.ts b/src/modules/trading/trading.routes.ts index 6276bed..a32c4ab 100644 --- a/src/modules/trading/trading.routes.ts +++ b/src/modules/trading/trading.routes.ts @@ -8,6 +8,7 @@ import * as tradingController from './controllers/trading.controller'; import * as watchlistController from './controllers/watchlist.controller'; import * as indicatorsController from './controllers/indicators.controller'; import * as alertsController from './controllers/alerts.controller'; +import * as exportController from './controllers/export.controller'; import { requireAuth } from '../../core/guards/auth.guard'; const router = Router(); @@ -238,6 +239,39 @@ router.patch('/paper/settings', requireAuth, authHandler(tradingController.updat */ router.get('/paper/stats', requireAuth, authHandler(tradingController.getPaperStats)); +// ============================================================================ +// Export Routes (Authenticated) +// Export trading history in various formats +// ============================================================================ + +/** + * GET /api/v1/trading/history/export/csv + * Export trading history to CSV + * Query params: startDate, endDate, symbols, status, direction + */ +router.get('/history/export/csv', requireAuth, authHandler(exportController.exportCSV)); + +/** + * GET /api/v1/trading/history/export/excel + * Export trading history to Excel + * Query params: startDate, endDate, symbols, status, direction + */ +router.get('/history/export/excel', requireAuth, authHandler(exportController.exportExcel)); + +/** + * GET /api/v1/trading/history/export/pdf + * Export trading history to PDF + * Query params: startDate, endDate, symbols, status, direction + */ +router.get('/history/export/pdf', requireAuth, authHandler(exportController.exportPDF)); + +/** + * GET /api/v1/trading/history/export/json + * Export trading history to JSON + * Query params: startDate, endDate, symbols, status, direction + */ +router.get('/history/export/json', requireAuth, authHandler(exportController.exportJSON)); + // ============================================================================ // Watchlist Routes (Authenticated) // All routes require authentication via JWT token