[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:
parent
4322caf69a
commit
f40dfa8061
430
src/modules/portfolio/repositories/goal.repository.ts
Normal file
430
src/modules/portfolio/repositories/goal.repository.ts
Normal 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();
|
||||||
6
src/modules/portfolio/repositories/index.ts
Normal file
6
src/modules/portfolio/repositories/index.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* Portfolio Repositories - Index
|
||||||
|
*/
|
||||||
|
|
||||||
|
export * from './portfolio.repository';
|
||||||
|
export * from './goal.repository';
|
||||||
597
src/modules/portfolio/repositories/portfolio.repository.ts
Normal file
597
src/modules/portfolio/repositories/portfolio.repository.ts
Normal 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();
|
||||||
@ -1,10 +1,22 @@
|
|||||||
/**
|
/**
|
||||||
* Portfolio Service
|
* Portfolio Service
|
||||||
* Manages user portfolios, allocations, and rebalancing
|
* Manages user portfolios, allocations, and rebalancing
|
||||||
|
*
|
||||||
|
* Supports both PostgreSQL repository and in-memory fallback
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import { marketService } from '../../trading/services/market.service';
|
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
|
// 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();
|
const portfolios: Map<string, Portfolio> = new Map();
|
||||||
@ -120,6 +200,8 @@ const goals: Map<string, PortfolioGoal> = new Map();
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
class PortfolioService {
|
class PortfolioService {
|
||||||
|
private useDatabase = true; // Toggle for DB vs in-memory
|
||||||
|
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
// Portfolio Management
|
// Portfolio Management
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
@ -135,6 +217,35 @@ class PortfolioService {
|
|||||||
): Promise<Portfolio> {
|
): Promise<Portfolio> {
|
||||||
const defaultAllocations = DEFAULT_ALLOCATIONS[riskProfile];
|
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 = {
|
const portfolio: Portfolio = {
|
||||||
id: uuidv4(),
|
id: uuidv4(),
|
||||||
userId,
|
userId,
|
||||||
@ -176,6 +287,20 @@ class PortfolioService {
|
|||||||
* Get portfolio by ID
|
* Get portfolio by ID
|
||||||
*/
|
*/
|
||||||
async getPortfolio(portfolioId: string): Promise<Portfolio | null> {
|
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);
|
const portfolio = portfolios.get(portfolioId);
|
||||||
if (!portfolio) return null;
|
if (!portfolio) return null;
|
||||||
|
|
||||||
@ -189,6 +314,24 @@ class PortfolioService {
|
|||||||
* Get user portfolios
|
* Get user portfolios
|
||||||
*/
|
*/
|
||||||
async getUserPortfolios(userId: string): Promise<Portfolio[]> {
|
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())
|
const userPortfolios = Array.from(portfolios.values())
|
||||||
.filter((p) => p.userId === userId);
|
.filter((p) => p.userId === userId);
|
||||||
|
|
||||||
@ -205,17 +348,40 @@ class PortfolioService {
|
|||||||
portfolioId: string,
|
portfolioId: string,
|
||||||
allocations: { asset: string; targetPercent: number }[]
|
allocations: { asset: string; targetPercent: number }[]
|
||||||
): Promise<Portfolio> {
|
): Promise<Portfolio> {
|
||||||
const portfolio = portfolios.get(portfolioId);
|
|
||||||
if (!portfolio) {
|
|
||||||
throw new Error(`Portfolio not found: ${portfolioId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate total is 100%
|
// Validate total is 100%
|
||||||
const total = allocations.reduce((sum, a) => sum + a.targetPercent, 0);
|
const total = allocations.reduce((sum, a) => sum + a.targetPercent, 0);
|
||||||
if (Math.abs(total - 100) > 0.01) {
|
if (Math.abs(total - 100) > 0.01) {
|
||||||
throw new Error('Allocations must sum to 100%');
|
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
|
// Update allocations
|
||||||
portfolio.allocations = allocations.map((a) => {
|
portfolio.allocations = allocations.map((a) => {
|
||||||
const existing = portfolio.allocations.find((e) => e.asset === a.asset);
|
const existing = portfolio.allocations.find((e) => e.asset === a.asset);
|
||||||
@ -308,6 +474,26 @@ class PortfolioService {
|
|||||||
portfolio.lastRebalanced = new Date();
|
portfolio.lastRebalanced = new Date();
|
||||||
portfolio.updatedAt = 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;
|
return portfolio;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -365,6 +551,21 @@ class PortfolioService {
|
|||||||
targetDate: Date,
|
targetDate: Date,
|
||||||
monthlyContribution: number
|
monthlyContribution: number
|
||||||
): Promise<PortfolioGoal> {
|
): 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 = {
|
const goal: PortfolioGoal = {
|
||||||
id: uuidv4(),
|
id: uuidv4(),
|
||||||
userId,
|
userId,
|
||||||
@ -390,6 +591,17 @@ class PortfolioService {
|
|||||||
* Get user goals
|
* Get user goals
|
||||||
*/
|
*/
|
||||||
async getUserGoals(userId: string): Promise<PortfolioGoal[]> {
|
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())
|
return Array.from(goals.values())
|
||||||
.filter((g) => g.userId === userId)
|
.filter((g) => g.userId === userId)
|
||||||
.map((g) => {
|
.map((g) => {
|
||||||
@ -405,6 +617,17 @@ class PortfolioService {
|
|||||||
goalId: string,
|
goalId: string,
|
||||||
currentAmount: number
|
currentAmount: number
|
||||||
): Promise<PortfolioGoal> {
|
): 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);
|
const goal = goals.get(goalId);
|
||||||
if (!goal) {
|
if (!goal) {
|
||||||
throw new Error(`Goal not found: ${goalId}`);
|
throw new Error(`Goal not found: ${goalId}`);
|
||||||
@ -423,6 +646,17 @@ class PortfolioService {
|
|||||||
* Delete a goal
|
* Delete a goal
|
||||||
*/
|
*/
|
||||||
async deleteGoal(goalId: string): Promise<boolean> {
|
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);
|
return goals.delete(goalId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user