From f40dfa806179983c4e7d0c8cd41aed6a4552f657 Mon Sep 17 00:00:00 2001 From: Adrian Flores Cortes Date: Sun, 25 Jan 2026 08:21:08 -0600 Subject: [PATCH] [OQI-008] feat: Add PostgreSQL repositories for portfolio module - Created portfolio.repository.ts with CRUD operations for portfolios and allocations - Created goal.repository.ts with CRUD operations for portfolio goals - Updated portfolio.service.ts to use repositories with in-memory fallback - Migrated createPortfolio, getPortfolio, getUserPortfolios methods - Migrated updateAllocations, executeRebalance methods - Migrated createGoal, getUserGoals, updateGoalProgress, deleteGoal methods - Added helper functions for mapping between repo and service types Co-Authored-By: Claude Opus 4.5 --- .../portfolio/repositories/goal.repository.ts | 430 +++++++++++++ src/modules/portfolio/repositories/index.ts | 6 + .../repositories/portfolio.repository.ts | 597 ++++++++++++++++++ .../portfolio/services/portfolio.service.ts | 246 +++++++- 4 files changed, 1273 insertions(+), 6 deletions(-) create mode 100644 src/modules/portfolio/repositories/goal.repository.ts create mode 100644 src/modules/portfolio/repositories/index.ts create mode 100644 src/modules/portfolio/repositories/portfolio.repository.ts diff --git a/src/modules/portfolio/repositories/goal.repository.ts b/src/modules/portfolio/repositories/goal.repository.ts new file mode 100644 index 0000000..9c1f12d --- /dev/null +++ b/src/modules/portfolio/repositories/goal.repository.ts @@ -0,0 +1,430 @@ +/** + * Portfolio Goal Repository + * Handles database operations for portfolio goals + */ + +import { db } from '../../../shared/database'; + +// ============================================================================ +// Types +// ============================================================================ + +export type GoalStatus = 'active' | 'completed' | 'abandoned'; + +export interface GoalRow { + id: string; + user_id: string; + portfolio_id: string | null; + name: string; + description: string | null; + target_amount: string; + current_amount: string; + target_date: Date; + monthly_contribution: string; + progress: string; + projected_completion_date: Date | null; + months_remaining: number | null; + required_monthly_contribution: string | null; + status: GoalStatus; + completed_at: Date | null; + created_at: Date; + updated_at: Date; +} + +export interface PortfolioGoal { + id: string; + userId: string; + portfolioId: string | null; + name: string; + description: string | null; + targetAmount: number; + currentAmount: number; + targetDate: Date; + monthlyContribution: number; + progress: number; + projectedCompletionDate: Date | null; + monthsRemaining: number | null; + requiredMonthlyContribution: number | null; + status: GoalStatus; + completedAt: Date | null; + createdAt: Date; + updatedAt: Date; +} + +export interface CreateGoalInput { + userId: string; + portfolioId?: string; + name: string; + description?: string; + targetAmount: number; + targetDate: Date; + monthlyContribution: number; + currentAmount?: number; +} + +export interface UpdateGoalInput { + name?: string; + description?: string; + targetAmount?: number; + currentAmount?: number; + targetDate?: Date; + monthlyContribution?: number; + portfolioId?: string | null; + status?: GoalStatus; +} + +export interface GoalFilters { + userId?: string; + portfolioId?: string; + status?: GoalStatus; + limit?: number; + offset?: number; +} + +// ============================================================================ +// Helper Functions +// ============================================================================ + +function mapRowToGoal(row: GoalRow): PortfolioGoal { + return { + id: row.id, + userId: row.user_id, + portfolioId: row.portfolio_id, + name: row.name, + description: row.description, + targetAmount: parseFloat(row.target_amount), + currentAmount: parseFloat(row.current_amount || '0'), + targetDate: row.target_date, + monthlyContribution: parseFloat(row.monthly_contribution || '0'), + progress: parseFloat(row.progress || '0'), + projectedCompletionDate: row.projected_completion_date, + monthsRemaining: row.months_remaining, + requiredMonthlyContribution: row.required_monthly_contribution + ? parseFloat(row.required_monthly_contribution) + : null, + status: row.status, + completedAt: row.completed_at, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + +// ============================================================================ +// Repository Class +// ============================================================================ + +class GoalRepository { + /** + * Create a new goal + */ + async create(input: CreateGoalInput): Promise { + const result = await db.query( + `INSERT INTO portfolio.portfolio_goals ( + user_id, portfolio_id, name, description, target_amount, + current_amount, target_date, monthly_contribution + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING *`, + [ + input.userId, + input.portfolioId || null, + input.name, + input.description || null, + input.targetAmount, + input.currentAmount ?? 0, + input.targetDate, + input.monthlyContribution, + ] + ); + + return mapRowToGoal(result.rows[0]); + } + + /** + * Find goal by ID + */ + async findById(id: string): Promise { + const result = await db.query( + 'SELECT * FROM portfolio.portfolio_goals WHERE id = $1', + [id] + ); + + if (result.rows.length === 0) { + return null; + } + + return mapRowToGoal(result.rows[0]); + } + + /** + * Find goals by user ID + */ + async findByUserId(userId: string): Promise { + const result = await db.query( + `SELECT * FROM portfolio.portfolio_goals + WHERE user_id = $1 + ORDER BY target_date ASC`, + [userId] + ); + + return result.rows.map(mapRowToGoal); + } + + /** + * Find active goals by user ID + */ + async findActiveByUserId(userId: string): Promise { + const result = await db.query( + `SELECT * FROM portfolio.portfolio_goals + WHERE user_id = $1 AND status = 'active' + ORDER BY target_date ASC`, + [userId] + ); + + return result.rows.map(mapRowToGoal); + } + + /** + * Find goals by portfolio ID + */ + async findByPortfolioId(portfolioId: string): Promise { + const result = await db.query( + `SELECT * FROM portfolio.portfolio_goals + WHERE portfolio_id = $1 AND status = 'active' + ORDER BY target_date ASC`, + [portfolioId] + ); + + return result.rows.map(mapRowToGoal); + } + + /** + * Find all goals with filters + */ + async findAll(filters: GoalFilters = {}): Promise<{ + goals: PortfolioGoal[]; + total: number; + }> { + const conditions: string[] = []; + const values: (string | GoalStatus | number)[] = []; + let paramIndex = 1; + + if (filters.userId) { + conditions.push(`user_id = $${paramIndex++}`); + values.push(filters.userId); + } + + if (filters.portfolioId) { + conditions.push(`portfolio_id = $${paramIndex++}`); + values.push(filters.portfolioId); + } + + if (filters.status) { + conditions.push(`status = $${paramIndex++}`); + values.push(filters.status); + } + + const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; + + // Get total count + const countResult = await db.query<{ count: string }>( + `SELECT COUNT(*) as count FROM portfolio.portfolio_goals ${whereClause}`, + values + ); + const total = parseInt(countResult.rows[0].count); + + // Get goals + let query = ` + SELECT * FROM portfolio.portfolio_goals + ${whereClause} + ORDER BY target_date ASC + `; + + if (filters.limit) { + query += ` LIMIT $${paramIndex++}`; + values.push(filters.limit); + } + + if (filters.offset) { + query += ` OFFSET $${paramIndex++}`; + values.push(filters.offset); + } + + const result = await db.query(query, values); + + return { + goals: result.rows.map(mapRowToGoal), + total, + }; + } + + /** + * Update goal + */ + async update(id: string, input: UpdateGoalInput): Promise { + const updates: string[] = []; + const values: (string | number | Date | null)[] = []; + let paramIndex = 1; + + if (input.name !== undefined) { + updates.push(`name = $${paramIndex++}`); + values.push(input.name); + } + + if (input.description !== undefined) { + updates.push(`description = $${paramIndex++}`); + values.push(input.description); + } + + if (input.targetAmount !== undefined) { + updates.push(`target_amount = $${paramIndex++}`); + values.push(input.targetAmount); + } + + if (input.currentAmount !== undefined) { + updates.push(`current_amount = $${paramIndex++}`); + values.push(input.currentAmount); + } + + if (input.targetDate !== undefined) { + updates.push(`target_date = $${paramIndex++}`); + values.push(input.targetDate); + } + + if (input.monthlyContribution !== undefined) { + updates.push(`monthly_contribution = $${paramIndex++}`); + values.push(input.monthlyContribution); + } + + if (input.portfolioId !== undefined) { + updates.push(`portfolio_id = $${paramIndex++}`); + values.push(input.portfolioId); + } + + if (input.status !== undefined) { + updates.push(`status = $${paramIndex++}`); + values.push(input.status); + } + + if (updates.length === 0) { + return this.findById(id); + } + + values.push(id); + + const result = await db.query( + `UPDATE portfolio.portfolio_goals + SET ${updates.join(', ')} + WHERE id = $${paramIndex} + RETURNING *`, + values + ); + + if (result.rows.length === 0) { + return null; + } + + return mapRowToGoal(result.rows[0]); + } + + /** + * Update goal progress + */ + async updateProgress(id: string, currentAmount: number): Promise { + return this.update(id, { currentAmount }); + } + + /** + * Mark goal as completed + */ + async complete(id: string): Promise { + const result = await db.query( + `UPDATE portfolio.portfolio_goals + SET status = 'completed', completed_at = NOW() + WHERE id = $1 AND status = 'active' + RETURNING *`, + [id] + ); + + if (result.rows.length === 0) { + return null; + } + + return mapRowToGoal(result.rows[0]); + } + + /** + * Mark goal as abandoned + */ + async abandon(id: string): Promise { + const result = await db.query( + `UPDATE portfolio.portfolio_goals + SET status = 'abandoned' + WHERE id = $1 AND status = 'active' + RETURNING *`, + [id] + ); + + if (result.rows.length === 0) { + return null; + } + + return mapRowToGoal(result.rows[0]); + } + + /** + * Delete goal + */ + async delete(id: string): Promise { + const result = await db.query( + 'DELETE FROM portfolio.portfolio_goals WHERE id = $1', + [id] + ); + return (result.rowCount || 0) > 0; + } + + /** + * Get goal statistics for user + */ + async getStatsByUserId(userId: string): Promise<{ + totalGoals: number; + activeGoals: number; + completedGoals: number; + totalTargetAmount: number; + totalCurrentAmount: number; + overallProgress: number; + }> { + const result = await db.query<{ + total_goals: string; + active_goals: string; + completed_goals: string; + total_target: string; + total_current: string; + }>( + `SELECT + COUNT(*) as total_goals, + COUNT(*) FILTER (WHERE status = 'active') as active_goals, + COUNT(*) FILTER (WHERE status = 'completed') as completed_goals, + COALESCE(SUM(target_amount), 0) as total_target, + COALESCE(SUM(current_amount), 0) as total_current + FROM portfolio.portfolio_goals + WHERE user_id = $1`, + [userId] + ); + + const row = result.rows[0]; + const totalTarget = parseFloat(row.total_target); + const totalCurrent = parseFloat(row.total_current); + + return { + totalGoals: parseInt(row.total_goals), + activeGoals: parseInt(row.active_goals), + completedGoals: parseInt(row.completed_goals), + totalTargetAmount: totalTarget, + totalCurrentAmount: totalCurrent, + overallProgress: totalTarget > 0 ? (totalCurrent / totalTarget) * 100 : 0, + }; + } +} + +// Export singleton instance +export const goalRepository = new GoalRepository(); diff --git a/src/modules/portfolio/repositories/index.ts b/src/modules/portfolio/repositories/index.ts new file mode 100644 index 0000000..0c7f14b --- /dev/null +++ b/src/modules/portfolio/repositories/index.ts @@ -0,0 +1,6 @@ +/** + * Portfolio Repositories - Index + */ + +export * from './portfolio.repository'; +export * from './goal.repository'; diff --git a/src/modules/portfolio/repositories/portfolio.repository.ts b/src/modules/portfolio/repositories/portfolio.repository.ts new file mode 100644 index 0000000..478f5c2 --- /dev/null +++ b/src/modules/portfolio/repositories/portfolio.repository.ts @@ -0,0 +1,597 @@ +/** + * Portfolio Repository + * Handles database operations for portfolios and allocations + */ + +import { db } from '../../../shared/database'; + +// ============================================================================ +// Types +// ============================================================================ + +export type RiskProfile = 'conservative' | 'moderate' | 'aggressive'; +export type AllocationStatus = 'active' | 'inactive' | 'rebalancing'; + +export interface PortfolioRow { + id: string; + user_id: string; + name: string; + description: string | null; + risk_profile: RiskProfile; + total_value: string; + total_cost: string; + unrealized_pnl: string; + unrealized_pnl_percent: string; + day_change_percent: string | null; + week_change_percent: string | null; + month_change_percent: string | null; + all_time_change_percent: string | null; + is_active: boolean; + is_primary: boolean; + last_rebalanced_at: Date | null; + created_at: Date; + updated_at: Date; +} + +export interface Portfolio { + id: string; + userId: string; + name: string; + description: string | null; + riskProfile: RiskProfile; + totalValue: number; + totalCost: number; + unrealizedPnl: number; + unrealizedPnlPercent: number; + dayChangePercent: number; + weekChangePercent: number; + monthChangePercent: number; + allTimeChangePercent: number; + isActive: boolean; + isPrimary: boolean; + lastRebalancedAt: Date | null; + createdAt: Date; + updatedAt: Date; +} + +export interface AllocationRow { + id: string; + portfolio_id: string; + asset: string; + target_percent: string; + current_percent: string; + deviation: string; + quantity: string; + avg_cost: string; + current_price: string; + value: string; + cost: string; + pnl: string; + pnl_percent: string; + status: AllocationStatus; + last_updated_at: Date; + created_at: Date; +} + +export interface PortfolioAllocation { + id: string; + portfolioId: string; + asset: string; + targetPercent: number; + currentPercent: number; + deviation: number; + quantity: number; + avgCost: number; + currentPrice: number; + value: number; + cost: number; + pnl: number; + pnlPercent: number; + status: AllocationStatus; + lastUpdatedAt: Date; + createdAt: Date; +} + +export interface CreatePortfolioInput { + userId: string; + name: string; + description?: string; + riskProfile: RiskProfile; + totalValue?: number; + totalCost?: number; + isPrimary?: boolean; +} + +export interface UpdatePortfolioInput { + name?: string; + description?: string; + riskProfile?: RiskProfile; + totalValue?: number; + totalCost?: number; + unrealizedPnl?: number; + unrealizedPnlPercent?: number; + dayChangePercent?: number; + weekChangePercent?: number; + monthChangePercent?: number; + allTimeChangePercent?: number; + isActive?: boolean; + isPrimary?: boolean; + lastRebalancedAt?: Date; +} + +export interface CreateAllocationInput { + portfolioId: string; + asset: string; + targetPercent: number; + currentPercent?: number; + quantity?: number; + avgCost?: number; + currentPrice?: number; + value?: number; + cost?: number; +} + +export interface UpdateAllocationInput { + targetPercent?: number; + currentPercent?: number; + deviation?: number; + quantity?: number; + avgCost?: number; + currentPrice?: number; + value?: number; + cost?: number; + pnl?: number; + pnlPercent?: number; + status?: AllocationStatus; +} + +// ============================================================================ +// Helper Functions +// ============================================================================ + +function mapRowToPortfolio(row: PortfolioRow): Portfolio { + return { + id: row.id, + userId: row.user_id, + name: row.name, + description: row.description, + riskProfile: row.risk_profile, + totalValue: parseFloat(row.total_value || '0'), + totalCost: parseFloat(row.total_cost || '0'), + unrealizedPnl: parseFloat(row.unrealized_pnl || '0'), + unrealizedPnlPercent: parseFloat(row.unrealized_pnl_percent || '0'), + dayChangePercent: parseFloat(row.day_change_percent || '0'), + weekChangePercent: parseFloat(row.week_change_percent || '0'), + monthChangePercent: parseFloat(row.month_change_percent || '0'), + allTimeChangePercent: parseFloat(row.all_time_change_percent || '0'), + isActive: row.is_active, + isPrimary: row.is_primary, + lastRebalancedAt: row.last_rebalanced_at, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + +function mapRowToAllocation(row: AllocationRow): PortfolioAllocation { + return { + id: row.id, + portfolioId: row.portfolio_id, + asset: row.asset, + targetPercent: parseFloat(row.target_percent || '0'), + currentPercent: parseFloat(row.current_percent || '0'), + deviation: parseFloat(row.deviation || '0'), + quantity: parseFloat(row.quantity || '0'), + avgCost: parseFloat(row.avg_cost || '0'), + currentPrice: parseFloat(row.current_price || '0'), + value: parseFloat(row.value || '0'), + cost: parseFloat(row.cost || '0'), + pnl: parseFloat(row.pnl || '0'), + pnlPercent: parseFloat(row.pnl_percent || '0'), + status: row.status, + lastUpdatedAt: row.last_updated_at, + createdAt: row.created_at, + }; +} + +// ============================================================================ +// Repository Class +// ============================================================================ + +class PortfolioRepository { + // ========================================================================== + // Portfolio CRUD + // ========================================================================== + + /** + * Create a new portfolio + */ + async create(input: CreatePortfolioInput): Promise { + const result = await db.query( + `INSERT INTO portfolio.portfolios ( + user_id, name, description, risk_profile, total_value, total_cost, is_primary + ) VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING *`, + [ + input.userId, + input.name, + input.description || null, + input.riskProfile, + input.totalValue ?? 0, + input.totalCost ?? 0, + input.isPrimary ?? false, + ] + ); + + return mapRowToPortfolio(result.rows[0]); + } + + /** + * Find portfolio by ID + */ + async findById(id: string): Promise { + const result = await db.query( + 'SELECT * FROM portfolio.portfolios WHERE id = $1', + [id] + ); + + if (result.rows.length === 0) { + return null; + } + + return mapRowToPortfolio(result.rows[0]); + } + + /** + * Find portfolios by user ID + */ + async findByUserId(userId: string): Promise { + const result = await db.query( + `SELECT * FROM portfolio.portfolios + WHERE user_id = $1 AND is_active = true + ORDER BY is_primary DESC, created_at DESC`, + [userId] + ); + + return result.rows.map(mapRowToPortfolio); + } + + /** + * Find primary portfolio for user + */ + async findPrimaryByUserId(userId: string): Promise { + const result = await db.query( + `SELECT * FROM portfolio.portfolios + WHERE user_id = $1 AND is_primary = true AND is_active = true`, + [userId] + ); + + if (result.rows.length === 0) { + return null; + } + + return mapRowToPortfolio(result.rows[0]); + } + + /** + * Update portfolio + */ + async update(id: string, input: UpdatePortfolioInput): Promise { + const updates: string[] = []; + const values: (string | number | boolean | Date | null)[] = []; + let paramIndex = 1; + + if (input.name !== undefined) { + updates.push(`name = $${paramIndex++}`); + values.push(input.name); + } + + if (input.description !== undefined) { + updates.push(`description = $${paramIndex++}`); + values.push(input.description); + } + + if (input.riskProfile !== undefined) { + updates.push(`risk_profile = $${paramIndex++}`); + values.push(input.riskProfile); + } + + if (input.totalValue !== undefined) { + updates.push(`total_value = $${paramIndex++}`); + values.push(input.totalValue); + } + + if (input.totalCost !== undefined) { + updates.push(`total_cost = $${paramIndex++}`); + values.push(input.totalCost); + } + + if (input.unrealizedPnl !== undefined) { + updates.push(`unrealized_pnl = $${paramIndex++}`); + values.push(input.unrealizedPnl); + } + + if (input.unrealizedPnlPercent !== undefined) { + updates.push(`unrealized_pnl_percent = $${paramIndex++}`); + values.push(input.unrealizedPnlPercent); + } + + if (input.dayChangePercent !== undefined) { + updates.push(`day_change_percent = $${paramIndex++}`); + values.push(input.dayChangePercent); + } + + if (input.weekChangePercent !== undefined) { + updates.push(`week_change_percent = $${paramIndex++}`); + values.push(input.weekChangePercent); + } + + if (input.monthChangePercent !== undefined) { + updates.push(`month_change_percent = $${paramIndex++}`); + values.push(input.monthChangePercent); + } + + if (input.allTimeChangePercent !== undefined) { + updates.push(`all_time_change_percent = $${paramIndex++}`); + values.push(input.allTimeChangePercent); + } + + if (input.isActive !== undefined) { + updates.push(`is_active = $${paramIndex++}`); + values.push(input.isActive); + } + + if (input.isPrimary !== undefined) { + updates.push(`is_primary = $${paramIndex++}`); + values.push(input.isPrimary); + } + + if (input.lastRebalancedAt !== undefined) { + updates.push(`last_rebalanced_at = $${paramIndex++}`); + values.push(input.lastRebalancedAt); + } + + if (updates.length === 0) { + return this.findById(id); + } + + values.push(id); + + const result = await db.query( + `UPDATE portfolio.portfolios + SET ${updates.join(', ')} + WHERE id = $${paramIndex} + RETURNING *`, + values + ); + + if (result.rows.length === 0) { + return null; + } + + return mapRowToPortfolio(result.rows[0]); + } + + /** + * Deactivate portfolio + */ + async deactivate(id: string): Promise { + return this.update(id, { isActive: false }); + } + + // ========================================================================== + // Allocation CRUD + // ========================================================================== + + /** + * Create allocation + */ + async createAllocation(input: CreateAllocationInput): Promise { + const result = await db.query( + `INSERT INTO portfolio.portfolio_allocations ( + portfolio_id, asset, target_percent, current_percent, quantity, + avg_cost, current_price, value, cost + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + RETURNING *`, + [ + input.portfolioId, + input.asset, + input.targetPercent, + input.currentPercent ?? 0, + input.quantity ?? 0, + input.avgCost ?? 0, + input.currentPrice ?? 0, + input.value ?? 0, + input.cost ?? 0, + ] + ); + + return mapRowToAllocation(result.rows[0]); + } + + /** + * Create multiple allocations + */ + async createAllocations(allocations: CreateAllocationInput[]): Promise { + if (allocations.length === 0) return []; + + const values: (string | number)[] = []; + const placeholders: string[] = []; + let paramIndex = 1; + + for (const input of allocations) { + placeholders.push( + `($${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++})` + ); + values.push( + input.portfolioId, + input.asset, + input.targetPercent, + input.currentPercent ?? 0, + input.quantity ?? 0, + input.avgCost ?? 0, + input.currentPrice ?? 0, + input.value ?? 0, + input.cost ?? 0 + ); + } + + const result = await db.query( + `INSERT INTO portfolio.portfolio_allocations ( + portfolio_id, asset, target_percent, current_percent, quantity, + avg_cost, current_price, value, cost + ) VALUES ${placeholders.join(', ')} + RETURNING *`, + values + ); + + return result.rows.map(mapRowToAllocation); + } + + /** + * Find allocations by portfolio ID + */ + async findAllocationsByPortfolioId(portfolioId: string): Promise { + const result = await db.query( + `SELECT * FROM portfolio.portfolio_allocations + WHERE portfolio_id = $1 AND status = 'active' + ORDER BY target_percent DESC`, + [portfolioId] + ); + + return result.rows.map(mapRowToAllocation); + } + + /** + * Find allocation by ID + */ + async findAllocationById(id: string): Promise { + const result = await db.query( + 'SELECT * FROM portfolio.portfolio_allocations WHERE id = $1', + [id] + ); + + if (result.rows.length === 0) { + return null; + } + + return mapRowToAllocation(result.rows[0]); + } + + /** + * Update allocation + */ + async updateAllocation(id: string, input: UpdateAllocationInput): Promise { + const updates: string[] = []; + const values: (string | number | null)[] = []; + let paramIndex = 1; + + if (input.targetPercent !== undefined) { + updates.push(`target_percent = $${paramIndex++}`); + values.push(input.targetPercent); + } + + if (input.currentPercent !== undefined) { + updates.push(`current_percent = $${paramIndex++}`); + values.push(input.currentPercent); + } + + if (input.deviation !== undefined) { + updates.push(`deviation = $${paramIndex++}`); + values.push(input.deviation); + } + + if (input.quantity !== undefined) { + updates.push(`quantity = $${paramIndex++}`); + values.push(input.quantity); + } + + if (input.avgCost !== undefined) { + updates.push(`avg_cost = $${paramIndex++}`); + values.push(input.avgCost); + } + + if (input.currentPrice !== undefined) { + updates.push(`current_price = $${paramIndex++}`); + values.push(input.currentPrice); + } + + if (input.value !== undefined) { + updates.push(`value = $${paramIndex++}`); + values.push(input.value); + } + + if (input.cost !== undefined) { + updates.push(`cost = $${paramIndex++}`); + values.push(input.cost); + } + + if (input.pnl !== undefined) { + updates.push(`pnl = $${paramIndex++}`); + values.push(input.pnl); + } + + if (input.pnlPercent !== undefined) { + updates.push(`pnl_percent = $${paramIndex++}`); + values.push(input.pnlPercent); + } + + if (input.status !== undefined) { + updates.push(`status = $${paramIndex++}`); + values.push(input.status); + } + + if (updates.length === 0) { + return this.findAllocationById(id); + } + + updates.push(`last_updated_at = NOW()`); + values.push(id); + + const result = await db.query( + `UPDATE portfolio.portfolio_allocations + SET ${updates.join(', ')} + WHERE id = $${paramIndex} + RETURNING *`, + values + ); + + if (result.rows.length === 0) { + return null; + } + + return mapRowToAllocation(result.rows[0]); + } + + /** + * Delete allocations by portfolio ID + */ + async deleteAllocationsByPortfolioId(portfolioId: string): Promise { + const result = await db.query( + 'DELETE FROM portfolio.portfolio_allocations WHERE portfolio_id = $1', + [portfolioId] + ); + return result.rowCount || 0; + } + + /** + * Update allocations to inactive and create new ones + */ + async replaceAllocations( + portfolioId: string, + allocations: CreateAllocationInput[] + ): Promise { + // Mark existing as inactive + await db.query( + `UPDATE portfolio.portfolio_allocations + SET status = 'inactive' + WHERE portfolio_id = $1 AND status = 'active'`, + [portfolioId] + ); + + // Create new allocations + return this.createAllocations(allocations); + } +} + +// Export singleton instance +export const portfolioRepository = new PortfolioRepository(); diff --git a/src/modules/portfolio/services/portfolio.service.ts b/src/modules/portfolio/services/portfolio.service.ts index 14c73e5..198d57b 100644 --- a/src/modules/portfolio/services/portfolio.service.ts +++ b/src/modules/portfolio/services/portfolio.service.ts @@ -1,10 +1,22 @@ /** * Portfolio Service * Manages user portfolios, allocations, and rebalancing + * + * Supports both PostgreSQL repository and in-memory fallback */ import { v4 as uuidv4 } from 'uuid'; import { marketService } from '../../trading/services/market.service'; +import { + portfolioRepository, + Portfolio as RepoPortfolio, + PortfolioAllocation as RepoAllocation, + RiskProfile as RepoRiskProfile, +} from '../repositories/portfolio.repository'; +import { + goalRepository, + PortfolioGoal as RepoGoal, +} from '../repositories/goal.repository'; // ============================================================================ // Types @@ -109,7 +121,75 @@ const DEFAULT_ALLOCATIONS: Record = new Map(); @@ -120,6 +200,8 @@ const goals: Map = new Map(); // ============================================================================ class PortfolioService { + private useDatabase = true; // Toggle for DB vs in-memory + // ========================================================================== // Portfolio Management // ========================================================================== @@ -135,6 +217,35 @@ class PortfolioService { ): Promise { const defaultAllocations = DEFAULT_ALLOCATIONS[riskProfile]; + if (this.useDatabase) { + try { + // Create portfolio in DB + const repoPortfolio = await portfolioRepository.create({ + userId, + name, + riskProfile: riskProfile as RepoRiskProfile, + totalValue: initialValue, + totalCost: initialValue, + }); + + // Create allocations + const repoAllocations = await portfolioRepository.createAllocations( + defaultAllocations.map((a) => ({ + portfolioId: repoPortfolio.id, + asset: a.asset, + targetPercent: a.percent, + currentPercent: a.percent, + value: (initialValue * a.percent) / 100, + cost: (initialValue * a.percent) / 100, + })) + ); + + return mapRepoPortfolioToService(repoPortfolio, repoAllocations); + } catch { + // Fall back to in-memory on DB error + } + } + const portfolio: Portfolio = { id: uuidv4(), userId, @@ -176,6 +287,20 @@ class PortfolioService { * Get portfolio by ID */ async getPortfolio(portfolioId: string): Promise { + if (this.useDatabase) { + try { + const repoPortfolio = await portfolioRepository.findById(portfolioId); + if (repoPortfolio) { + const repoAllocations = await portfolioRepository.findAllocationsByPortfolioId(portfolioId); + const portfolio = mapRepoPortfolioToService(repoPortfolio, repoAllocations); + await this.updatePortfolioValues(portfolio); + return portfolio; + } + } catch { + // Fall back to in-memory on DB error + } + } + const portfolio = portfolios.get(portfolioId); if (!portfolio) return null; @@ -189,6 +314,24 @@ class PortfolioService { * Get user portfolios */ async getUserPortfolios(userId: string): Promise { + if (this.useDatabase) { + try { + const repoPortfolios = await portfolioRepository.findByUserId(userId); + if (repoPortfolios.length > 0) { + const portfolioList: Portfolio[] = []; + for (const repo of repoPortfolios) { + const allocations = await portfolioRepository.findAllocationsByPortfolioId(repo.id); + const portfolio = mapRepoPortfolioToService(repo, allocations); + await this.updatePortfolioValues(portfolio); + portfolioList.push(portfolio); + } + return portfolioList; + } + } catch { + // Fall back to in-memory on DB error + } + } + const userPortfolios = Array.from(portfolios.values()) .filter((p) => p.userId === userId); @@ -205,17 +348,40 @@ class PortfolioService { portfolioId: string, allocations: { asset: string; targetPercent: number }[] ): Promise { - const portfolio = portfolios.get(portfolioId); - if (!portfolio) { - throw new Error(`Portfolio not found: ${portfolioId}`); - } - // Validate total is 100% const total = allocations.reduce((sum, a) => sum + a.targetPercent, 0); if (Math.abs(total - 100) > 0.01) { throw new Error('Allocations must sum to 100%'); } + if (this.useDatabase) { + try { + const repoPortfolio = await portfolioRepository.findById(portfolioId); + if (repoPortfolio) { + // Replace allocations in DB + const repoAllocations = await portfolioRepository.replaceAllocations( + portfolioId, + allocations.map((a) => ({ + portfolioId, + asset: a.asset, + targetPercent: a.targetPercent, + })) + ); + + const portfolio = mapRepoPortfolioToService(repoPortfolio, repoAllocations); + await this.updatePortfolioValues(portfolio); + return portfolio; + } + } catch { + // Fall back to in-memory on DB error + } + } + + const portfolio = portfolios.get(portfolioId); + if (!portfolio) { + throw new Error(`Portfolio not found: ${portfolioId}`); + } + // Update allocations portfolio.allocations = allocations.map((a) => { const existing = portfolio.allocations.find((e) => e.asset === a.asset); @@ -308,6 +474,26 @@ class PortfolioService { portfolio.lastRebalanced = new Date(); portfolio.updatedAt = new Date(); + if (this.useDatabase) { + try { + // Update portfolio last rebalanced + await portfolioRepository.update(portfolioId, { + lastRebalancedAt: new Date(), + }); + + // Update allocations to match targets + const repoAllocations = await portfolioRepository.findAllocationsByPortfolioId(portfolioId); + for (const alloc of repoAllocations) { + await portfolioRepository.updateAllocation(alloc.id, { + currentPercent: alloc.targetPercent, + deviation: 0, + }); + } + } catch { + // Continue with in-memory state + } + } + return portfolio; } @@ -365,6 +551,21 @@ class PortfolioService { targetDate: Date, monthlyContribution: number ): Promise { + if (this.useDatabase) { + try { + const repoGoal = await goalRepository.create({ + userId, + name, + targetAmount, + targetDate, + monthlyContribution, + }); + return mapRepoGoalToService(repoGoal); + } catch { + // Fall back to in-memory on DB error + } + } + const goal: PortfolioGoal = { id: uuidv4(), userId, @@ -390,6 +591,17 @@ class PortfolioService { * Get user goals */ async getUserGoals(userId: string): Promise { + if (this.useDatabase) { + try { + const repoGoals = await goalRepository.findByUserId(userId); + if (repoGoals.length > 0) { + return repoGoals.map(mapRepoGoalToService); + } + } catch { + // Fall back to in-memory on DB error + } + } + return Array.from(goals.values()) .filter((g) => g.userId === userId) .map((g) => { @@ -405,6 +617,17 @@ class PortfolioService { goalId: string, currentAmount: number ): Promise { + if (this.useDatabase) { + try { + const repoGoal = await goalRepository.updateProgress(goalId, currentAmount); + if (repoGoal) { + return mapRepoGoalToService(repoGoal); + } + } catch { + // Fall back to in-memory on DB error + } + } + const goal = goals.get(goalId); if (!goal) { throw new Error(`Goal not found: ${goalId}`); @@ -423,6 +646,17 @@ class PortfolioService { * Delete a goal */ async deleteGoal(goalId: string): Promise { + if (this.useDatabase) { + try { + const deleted = await goalRepository.delete(goalId); + if (deleted) { + return true; + } + } catch { + // Fall back to in-memory on DB error + } + } + return goals.delete(goalId); }