[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 <noreply@anthropic.com>
This commit is contained in:
Adrian Flores Cortes 2026-01-25 08:21:08 -06:00
parent 4322caf69a
commit f40dfa8061
4 changed files with 1273 additions and 6 deletions

View File

@ -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<PortfolioGoal> {
const result = await db.query<GoalRow>(
`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<PortfolioGoal | null> {
const result = await db.query<GoalRow>(
'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<PortfolioGoal[]> {
const result = await db.query<GoalRow>(
`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<PortfolioGoal[]> {
const result = await db.query<GoalRow>(
`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<PortfolioGoal[]> {
const result = await db.query<GoalRow>(
`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<GoalRow>(query, values);
return {
goals: result.rows.map(mapRowToGoal),
total,
};
}
/**
* Update goal
*/
async update(id: string, input: UpdateGoalInput): Promise<PortfolioGoal | null> {
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<GoalRow>(
`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<PortfolioGoal | null> {
return this.update(id, { currentAmount });
}
/**
* Mark goal as completed
*/
async complete(id: string): Promise<PortfolioGoal | null> {
const result = await db.query<GoalRow>(
`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<PortfolioGoal | null> {
const result = await db.query<GoalRow>(
`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<boolean> {
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();

View File

@ -0,0 +1,6 @@
/**
* Portfolio Repositories - Index
*/
export * from './portfolio.repository';
export * from './goal.repository';

View File

@ -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<Portfolio> {
const result = await db.query<PortfolioRow>(
`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<Portfolio | null> {
const result = await db.query<PortfolioRow>(
'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<Portfolio[]> {
const result = await db.query<PortfolioRow>(
`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<Portfolio | null> {
const result = await db.query<PortfolioRow>(
`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<Portfolio | null> {
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<PortfolioRow>(
`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<Portfolio | null> {
return this.update(id, { isActive: false });
}
// ==========================================================================
// Allocation CRUD
// ==========================================================================
/**
* Create allocation
*/
async createAllocation(input: CreateAllocationInput): Promise<PortfolioAllocation> {
const result = await db.query<AllocationRow>(
`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<PortfolioAllocation[]> {
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<AllocationRow>(
`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<PortfolioAllocation[]> {
const result = await db.query<AllocationRow>(
`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<PortfolioAllocation | null> {
const result = await db.query<AllocationRow>(
'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<PortfolioAllocation | null> {
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<AllocationRow>(
`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<number> {
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<PortfolioAllocation[]> {
// 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();

View File

@ -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<RiskProfile, { asset: string; percent: number
};
// ============================================================================
// In-Memory Storage
// Helper Functions for Repository Mapping
// ============================================================================
function mapRepoPortfolioToService(
repo: RepoPortfolio,
allocations: RepoAllocation[]
): Portfolio {
return {
id: repo.id,
userId: repo.userId,
name: repo.name,
riskProfile: repo.riskProfile as RiskProfile,
allocations: allocations.map(mapRepoAllocationToService),
totalValue: repo.totalValue,
totalCost: repo.totalCost,
unrealizedPnl: repo.unrealizedPnl,
unrealizedPnlPercent: repo.unrealizedPnlPercent,
realizedPnl: 0, // Not stored in DB yet
lastRebalanced: repo.lastRebalancedAt,
createdAt: repo.createdAt,
updatedAt: repo.updatedAt,
};
}
function mapRepoAllocationToService(repo: RepoAllocation): PortfolioAllocation {
return {
id: repo.id,
portfolioId: repo.portfolioId,
asset: repo.asset,
targetPercent: repo.targetPercent,
currentPercent: repo.currentPercent,
quantity: repo.quantity,
value: repo.value,
cost: repo.cost,
pnl: repo.pnl,
pnlPercent: repo.pnlPercent,
deviation: repo.deviation,
};
}
function mapRepoGoalToService(repo: RepoGoal): PortfolioGoal {
// Map DB status to service status
let status: 'on_track' | 'at_risk' | 'behind' = 'on_track';
if (repo.status === 'completed') {
status = 'on_track';
} else if (repo.progress < 50 && repo.monthsRemaining && repo.monthsRemaining < 6) {
status = 'behind';
} else if (repo.progress < 75 && repo.monthsRemaining && repo.monthsRemaining < 3) {
status = 'at_risk';
}
return {
id: repo.id,
userId: repo.userId,
name: repo.name,
targetAmount: repo.targetAmount,
currentAmount: repo.currentAmount,
targetDate: repo.targetDate,
monthlyContribution: repo.monthlyContribution,
progress: repo.progress,
projectedCompletion: repo.projectedCompletionDate,
status,
createdAt: repo.createdAt,
updatedAt: repo.updatedAt,
};
}
// ============================================================================
// In-Memory Storage (fallback)
// ============================================================================
const portfolios: Map<string, Portfolio> = new Map();
@ -120,6 +200,8 @@ const goals: Map<string, PortfolioGoal> = new Map();
// ============================================================================
class PortfolioService {
private useDatabase = true; // Toggle for DB vs in-memory
// ==========================================================================
// Portfolio Management
// ==========================================================================
@ -135,6 +217,35 @@ class PortfolioService {
): Promise<Portfolio> {
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<Portfolio | null> {
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<Portfolio[]> {
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<Portfolio> {
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<PortfolioGoal> {
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<PortfolioGoal[]> {
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<PortfolioGoal> {
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<boolean> {
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);
}