[OQI-008] feat: Add portfolio Phase 3 - WebSocket, snapshots, performance APIs
- Add portfolio.websocket.ts for real-time portfolio updates - Add snapshot.repository.ts for historical performance data - Add getPortfolioPerformance and getPerformanceStats endpoints - Update routes with /performance and /performance/stats Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
f40dfa8061
commit
32a088698e
@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
import { Request, Response, NextFunction } from 'express';
|
import { Request, Response, NextFunction } from 'express';
|
||||||
import { portfolioService, RiskProfile } from '../services/portfolio.service';
|
import { portfolioService, RiskProfile } from '../services/portfolio.service';
|
||||||
|
import { snapshotRepository } from '../repositories/snapshot.repository';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Types
|
// Types
|
||||||
@ -458,3 +459,108 @@ export async function deleteGoal(req: AuthRequest, res: Response, next: NextFunc
|
|||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Performance & Analytics
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get portfolio performance history for charts
|
||||||
|
*/
|
||||||
|
export async function getPortfolioPerformance(req: AuthRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const userId = req.user?.id;
|
||||||
|
if (!userId) {
|
||||||
|
res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
error: { message: 'Unauthorized', code: 'UNAUTHORIZED' },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { portfolioId } = req.params;
|
||||||
|
const period = (req.query.period as string) || 'month';
|
||||||
|
|
||||||
|
const validPeriods = ['week', 'month', '3months', 'year', 'all'];
|
||||||
|
if (!validPeriods.includes(period)) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: { message: 'Invalid period', code: 'VALIDATION_ERROR' },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const portfolio = await portfolioService.getPortfolio(portfolioId);
|
||||||
|
if (!portfolio) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: { message: 'Portfolio not found', code: 'NOT_FOUND' },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (portfolio.userId !== userId) {
|
||||||
|
res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
error: { message: 'Forbidden', code: 'FORBIDDEN' },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const performanceData = await snapshotRepository.getPerformanceData(
|
||||||
|
portfolioId,
|
||||||
|
period as 'week' | 'month' | '3months' | 'year' | 'all'
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: performanceData,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get detailed performance statistics
|
||||||
|
*/
|
||||||
|
export async function getPerformanceStats(req: AuthRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const userId = req.user?.id;
|
||||||
|
if (!userId) {
|
||||||
|
res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
error: { message: 'Unauthorized', code: 'UNAUTHORIZED' },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { portfolioId } = req.params;
|
||||||
|
|
||||||
|
const portfolio = await portfolioService.getPortfolio(portfolioId);
|
||||||
|
if (!portfolio) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: { message: 'Portfolio not found', code: 'NOT_FOUND' },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (portfolio.userId !== userId) {
|
||||||
|
res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
error: { message: 'Forbidden', code: 'FORBIDDEN' },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = await snapshotRepository.getPerformanceStats(portfolioId);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: stats,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -48,6 +48,19 @@ router.put('/:portfolioId/allocations', authHandler(portfolioController.updateAl
|
|||||||
*/
|
*/
|
||||||
router.get('/:portfolioId/stats', authHandler(portfolioController.getPortfolioStats));
|
router.get('/:portfolioId/stats', authHandler(portfolioController.getPortfolioStats));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/v1/portfolio/:portfolioId/performance
|
||||||
|
* Get portfolio performance history for charts
|
||||||
|
* Query: period = 'week' | 'month' | '3months' | 'year' | 'all'
|
||||||
|
*/
|
||||||
|
router.get('/:portfolioId/performance', authHandler(portfolioController.getPortfolioPerformance));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/v1/portfolio/:portfolioId/performance/stats
|
||||||
|
* Get detailed performance statistics
|
||||||
|
*/
|
||||||
|
router.get('/:portfolioId/performance/stats', authHandler(portfolioController.getPerformanceStats));
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Rebalancing (Authenticated)
|
// Rebalancing (Authenticated)
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@ -4,3 +4,4 @@
|
|||||||
|
|
||||||
export * from './portfolio.repository';
|
export * from './portfolio.repository';
|
||||||
export * from './goal.repository';
|
export * from './goal.repository';
|
||||||
|
export * from './snapshot.repository';
|
||||||
|
|||||||
338
src/modules/portfolio/repositories/snapshot.repository.ts
Normal file
338
src/modules/portfolio/repositories/snapshot.repository.ts
Normal file
@ -0,0 +1,338 @@
|
|||||||
|
/**
|
||||||
|
* Portfolio Snapshot Repository
|
||||||
|
* Handles database operations for portfolio snapshots (historical data)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { db } from '../../../shared/database';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface SnapshotRow {
|
||||||
|
id: string;
|
||||||
|
portfolio_id: string;
|
||||||
|
snapshot_date: Date;
|
||||||
|
total_value: string;
|
||||||
|
total_cost: string;
|
||||||
|
unrealized_pnl: string;
|
||||||
|
unrealized_pnl_percent: string;
|
||||||
|
day_change: string;
|
||||||
|
day_change_percent: string;
|
||||||
|
allocations: Record<string, unknown> | null;
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PortfolioSnapshot {
|
||||||
|
id: string;
|
||||||
|
portfolioId: string;
|
||||||
|
snapshotDate: Date;
|
||||||
|
totalValue: number;
|
||||||
|
totalCost: number;
|
||||||
|
unrealizedPnl: number;
|
||||||
|
unrealizedPnlPercent: number;
|
||||||
|
dayChange: number;
|
||||||
|
dayChangePercent: number;
|
||||||
|
allocations: Record<string, unknown> | null;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateSnapshotInput {
|
||||||
|
portfolioId: string;
|
||||||
|
snapshotDate: Date;
|
||||||
|
totalValue: number;
|
||||||
|
totalCost: number;
|
||||||
|
unrealizedPnl: number;
|
||||||
|
unrealizedPnlPercent: number;
|
||||||
|
dayChange?: number;
|
||||||
|
dayChangePercent?: number;
|
||||||
|
allocations?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PerformanceDataPoint {
|
||||||
|
date: string;
|
||||||
|
value: number;
|
||||||
|
pnl: number;
|
||||||
|
pnlPercent: number;
|
||||||
|
change: number;
|
||||||
|
changePercent: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Helper Functions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
function mapRowToSnapshot(row: SnapshotRow): PortfolioSnapshot {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
portfolioId: row.portfolio_id,
|
||||||
|
snapshotDate: row.snapshot_date,
|
||||||
|
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'),
|
||||||
|
dayChange: parseFloat(row.day_change || '0'),
|
||||||
|
dayChangePercent: parseFloat(row.day_change_percent || '0'),
|
||||||
|
allocations: row.allocations,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Repository Class
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
class SnapshotRepository {
|
||||||
|
/**
|
||||||
|
* Create a new snapshot
|
||||||
|
*/
|
||||||
|
async create(input: CreateSnapshotInput): Promise<PortfolioSnapshot> {
|
||||||
|
const result = await db.query<SnapshotRow>(
|
||||||
|
`INSERT INTO portfolio.portfolio_snapshots (
|
||||||
|
portfolio_id, snapshot_date, total_value, total_cost,
|
||||||
|
unrealized_pnl, unrealized_pnl_percent, day_change, day_change_percent, allocations
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||||
|
ON CONFLICT (portfolio_id, snapshot_date)
|
||||||
|
DO UPDATE SET
|
||||||
|
total_value = EXCLUDED.total_value,
|
||||||
|
total_cost = EXCLUDED.total_cost,
|
||||||
|
unrealized_pnl = EXCLUDED.unrealized_pnl,
|
||||||
|
unrealized_pnl_percent = EXCLUDED.unrealized_pnl_percent,
|
||||||
|
day_change = EXCLUDED.day_change,
|
||||||
|
day_change_percent = EXCLUDED.day_change_percent,
|
||||||
|
allocations = EXCLUDED.allocations
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
input.portfolioId,
|
||||||
|
input.snapshotDate,
|
||||||
|
input.totalValue,
|
||||||
|
input.totalCost,
|
||||||
|
input.unrealizedPnl,
|
||||||
|
input.unrealizedPnlPercent,
|
||||||
|
input.dayChange ?? 0,
|
||||||
|
input.dayChangePercent ?? 0,
|
||||||
|
input.allocations ? JSON.stringify(input.allocations) : null,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return mapRowToSnapshot(result.rows[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get snapshots for a portfolio within date range
|
||||||
|
*/
|
||||||
|
async findByPortfolioId(
|
||||||
|
portfolioId: string,
|
||||||
|
startDate?: Date,
|
||||||
|
endDate?: Date
|
||||||
|
): Promise<PortfolioSnapshot[]> {
|
||||||
|
let query = `SELECT * FROM portfolio.portfolio_snapshots WHERE portfolio_id = $1`;
|
||||||
|
const params: (string | Date)[] = [portfolioId];
|
||||||
|
|
||||||
|
if (startDate) {
|
||||||
|
query += ` AND snapshot_date >= $${params.length + 1}`;
|
||||||
|
params.push(startDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (endDate) {
|
||||||
|
query += ` AND snapshot_date <= $${params.length + 1}`;
|
||||||
|
params.push(endDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
query += ` ORDER BY snapshot_date ASC`;
|
||||||
|
|
||||||
|
const result = await db.query<SnapshotRow>(query, params);
|
||||||
|
return result.rows.map(mapRowToSnapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get latest snapshot for a portfolio
|
||||||
|
*/
|
||||||
|
async findLatest(portfolioId: string): Promise<PortfolioSnapshot | null> {
|
||||||
|
const result = await db.query<SnapshotRow>(
|
||||||
|
`SELECT * FROM portfolio.portfolio_snapshots
|
||||||
|
WHERE portfolio_id = $1
|
||||||
|
ORDER BY snapshot_date DESC
|
||||||
|
LIMIT 1`,
|
||||||
|
[portfolioId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) return null;
|
||||||
|
return mapRowToSnapshot(result.rows[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get performance data for charts
|
||||||
|
*/
|
||||||
|
async getPerformanceData(
|
||||||
|
portfolioId: string,
|
||||||
|
period: 'week' | 'month' | '3months' | 'year' | 'all'
|
||||||
|
): Promise<PerformanceDataPoint[]> {
|
||||||
|
let startDate: Date;
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
switch (period) {
|
||||||
|
case 'week':
|
||||||
|
startDate = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||||
|
break;
|
||||||
|
case 'month':
|
||||||
|
startDate = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||||
|
break;
|
||||||
|
case '3months':
|
||||||
|
startDate = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000);
|
||||||
|
break;
|
||||||
|
case 'year':
|
||||||
|
startDate = new Date(now.getTime() - 365 * 24 * 60 * 60 * 1000);
|
||||||
|
break;
|
||||||
|
case 'all':
|
||||||
|
default:
|
||||||
|
startDate = new Date(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await db.query<{
|
||||||
|
snapshot_date: Date;
|
||||||
|
total_value: string;
|
||||||
|
unrealized_pnl: string;
|
||||||
|
unrealized_pnl_percent: string;
|
||||||
|
day_change: string;
|
||||||
|
day_change_percent: string;
|
||||||
|
}>(
|
||||||
|
`SELECT snapshot_date, total_value, unrealized_pnl, unrealized_pnl_percent,
|
||||||
|
day_change, day_change_percent
|
||||||
|
FROM portfolio.portfolio_snapshots
|
||||||
|
WHERE portfolio_id = $1 AND snapshot_date >= $2
|
||||||
|
ORDER BY snapshot_date ASC`,
|
||||||
|
[portfolioId, startDate]
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows.map((row) => ({
|
||||||
|
date: row.snapshot_date.toISOString().split('T')[0],
|
||||||
|
value: parseFloat(row.total_value),
|
||||||
|
pnl: parseFloat(row.unrealized_pnl),
|
||||||
|
pnlPercent: parseFloat(row.unrealized_pnl_percent),
|
||||||
|
change: parseFloat(row.day_change),
|
||||||
|
changePercent: parseFloat(row.day_change_percent),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cumulative performance stats
|
||||||
|
*/
|
||||||
|
async getPerformanceStats(portfolioId: string): Promise<{
|
||||||
|
dayChange: number;
|
||||||
|
dayChangePercent: number;
|
||||||
|
weekChange: number;
|
||||||
|
weekChangePercent: number;
|
||||||
|
monthChange: number;
|
||||||
|
monthChangePercent: number;
|
||||||
|
yearChange: number;
|
||||||
|
yearChangePercent: number;
|
||||||
|
allTimeChange: number;
|
||||||
|
allTimeChangePercent: number;
|
||||||
|
}> {
|
||||||
|
const now = new Date();
|
||||||
|
const dayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
||||||
|
const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||||
|
const monthAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||||
|
const yearAgo = new Date(now.getTime() - 365 * 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
const result = await db.query<{
|
||||||
|
period: string;
|
||||||
|
start_value: string;
|
||||||
|
end_value: string;
|
||||||
|
}>(
|
||||||
|
`WITH periods AS (
|
||||||
|
SELECT 'day' as period, $2::date as start_date
|
||||||
|
UNION ALL SELECT 'week', $3::date
|
||||||
|
UNION ALL SELECT 'month', $4::date
|
||||||
|
UNION ALL SELECT 'year', $5::date
|
||||||
|
UNION ALL SELECT 'all', '1970-01-01'::date
|
||||||
|
),
|
||||||
|
latest AS (
|
||||||
|
SELECT total_value
|
||||||
|
FROM portfolio.portfolio_snapshots
|
||||||
|
WHERE portfolio_id = $1
|
||||||
|
ORDER BY snapshot_date DESC
|
||||||
|
LIMIT 1
|
||||||
|
),
|
||||||
|
period_starts AS (
|
||||||
|
SELECT p.period,
|
||||||
|
COALESCE(
|
||||||
|
(SELECT total_value FROM portfolio.portfolio_snapshots
|
||||||
|
WHERE portfolio_id = $1 AND snapshot_date <= p.start_date
|
||||||
|
ORDER BY snapshot_date DESC LIMIT 1),
|
||||||
|
(SELECT total_value FROM latest)
|
||||||
|
) as start_value
|
||||||
|
FROM periods p
|
||||||
|
)
|
||||||
|
SELECT ps.period, ps.start_value, l.total_value as end_value
|
||||||
|
FROM period_starts ps
|
||||||
|
CROSS JOIN latest l`,
|
||||||
|
[portfolioId, dayAgo, weekAgo, monthAgo, yearAgo]
|
||||||
|
);
|
||||||
|
|
||||||
|
const stats = {
|
||||||
|
dayChange: 0,
|
||||||
|
dayChangePercent: 0,
|
||||||
|
weekChange: 0,
|
||||||
|
weekChangePercent: 0,
|
||||||
|
monthChange: 0,
|
||||||
|
monthChangePercent: 0,
|
||||||
|
yearChange: 0,
|
||||||
|
yearChangePercent: 0,
|
||||||
|
allTimeChange: 0,
|
||||||
|
allTimeChangePercent: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
result.rows.forEach((row) => {
|
||||||
|
const startVal = parseFloat(row.start_value || '0');
|
||||||
|
const endVal = parseFloat(row.end_value || '0');
|
||||||
|
const change = endVal - startVal;
|
||||||
|
const changePercent = startVal > 0 ? (change / startVal) * 100 : 0;
|
||||||
|
|
||||||
|
switch (row.period) {
|
||||||
|
case 'day':
|
||||||
|
stats.dayChange = change;
|
||||||
|
stats.dayChangePercent = changePercent;
|
||||||
|
break;
|
||||||
|
case 'week':
|
||||||
|
stats.weekChange = change;
|
||||||
|
stats.weekChangePercent = changePercent;
|
||||||
|
break;
|
||||||
|
case 'month':
|
||||||
|
stats.monthChange = change;
|
||||||
|
stats.monthChangePercent = changePercent;
|
||||||
|
break;
|
||||||
|
case 'year':
|
||||||
|
stats.yearChange = change;
|
||||||
|
stats.yearChangePercent = changePercent;
|
||||||
|
break;
|
||||||
|
case 'all':
|
||||||
|
stats.allTimeChange = change;
|
||||||
|
stats.allTimeChangePercent = changePercent;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete old snapshots (cleanup)
|
||||||
|
*/
|
||||||
|
async deleteOldSnapshots(portfolioId: string, keepDays: number = 365): Promise<number> {
|
||||||
|
const cutoffDate = new Date();
|
||||||
|
cutoffDate.setDate(cutoffDate.getDate() - keepDays);
|
||||||
|
|
||||||
|
const result = await db.query(
|
||||||
|
`DELETE FROM portfolio.portfolio_snapshots
|
||||||
|
WHERE portfolio_id = $1 AND snapshot_date < $2`,
|
||||||
|
[portfolioId, cutoffDate]
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rowCount || 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export singleton instance
|
||||||
|
export const snapshotRepository = new SnapshotRepository();
|
||||||
288
src/modules/portfolio/websocket/portfolio.websocket.ts
Normal file
288
src/modules/portfolio/websocket/portfolio.websocket.ts
Normal file
@ -0,0 +1,288 @@
|
|||||||
|
/**
|
||||||
|
* Portfolio WebSocket Handler
|
||||||
|
* Real-time updates for portfolio data
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { wsManager, WSClient, WSMessage } from '../../../core/websocket/websocket.server';
|
||||||
|
import { portfolioService } from '../services/portfolio.service';
|
||||||
|
import { logger } from '../../../shared/utils/logger';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface PortfolioUpdateMessage {
|
||||||
|
portfolioId: string;
|
||||||
|
totalValue: number;
|
||||||
|
unrealizedPnl: number;
|
||||||
|
unrealizedPnlPercent: number;
|
||||||
|
allocations: {
|
||||||
|
asset: string;
|
||||||
|
value: number;
|
||||||
|
currentPercent: number;
|
||||||
|
pnl: number;
|
||||||
|
pnlPercent: number;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PriceUpdateMessage {
|
||||||
|
asset: string;
|
||||||
|
price: number;
|
||||||
|
change24h: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Portfolio WebSocket Service
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
class PortfolioWebSocketService {
|
||||||
|
private updateIntervals: Map<string, NodeJS.Timeout> = new Map();
|
||||||
|
private readonly UPDATE_INTERVAL = 10000; // 10 seconds
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize portfolio WebSocket handlers
|
||||||
|
*/
|
||||||
|
initialize(): void {
|
||||||
|
// Register message handlers
|
||||||
|
wsManager.registerHandler('portfolio:subscribe', this.handleSubscribe.bind(this));
|
||||||
|
wsManager.registerHandler('portfolio:unsubscribe', this.handleUnsubscribe.bind(this));
|
||||||
|
wsManager.registerHandler('portfolio:refresh', this.handleRefresh.bind(this));
|
||||||
|
|
||||||
|
// Listen for subscription events
|
||||||
|
wsManager.on('subscribe', (client: WSClient, channel: string) => {
|
||||||
|
if (channel.startsWith('portfolio:')) {
|
||||||
|
this.onPortfolioSubscribe(client, channel);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
wsManager.on('unsubscribe', (client: WSClient, channel: string) => {
|
||||||
|
if (channel.startsWith('portfolio:')) {
|
||||||
|
this.onPortfolioUnsubscribe(channel);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info('[Portfolio WS] Handlers initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle portfolio subscribe message
|
||||||
|
*/
|
||||||
|
private async handleSubscribe(client: WSClient, message: WSMessage): Promise<void> {
|
||||||
|
const portfolioId = (message.data as { portfolioId?: string })?.portfolioId;
|
||||||
|
|
||||||
|
if (!portfolioId) {
|
||||||
|
wsManager.send(client, {
|
||||||
|
type: 'error',
|
||||||
|
data: { message: 'portfolioId is required' },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!client.userId) {
|
||||||
|
wsManager.send(client, {
|
||||||
|
type: 'error',
|
||||||
|
data: { message: 'Authentication required' },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify user owns this portfolio
|
||||||
|
const portfolio = await portfolioService.getPortfolio(portfolioId);
|
||||||
|
if (!portfolio || portfolio.userId !== client.userId) {
|
||||||
|
wsManager.send(client, {
|
||||||
|
type: 'error',
|
||||||
|
data: { message: 'Portfolio not found or access denied' },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add subscription
|
||||||
|
const channel = `portfolio:${portfolioId}`;
|
||||||
|
client.subscriptions.add(channel);
|
||||||
|
|
||||||
|
// Send initial data
|
||||||
|
await this.sendPortfolioUpdate(portfolioId);
|
||||||
|
|
||||||
|
wsManager.send(client, {
|
||||||
|
type: 'portfolio:subscribed',
|
||||||
|
data: { portfolioId, channel },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle portfolio unsubscribe message
|
||||||
|
*/
|
||||||
|
private handleUnsubscribe(client: WSClient, message: WSMessage): void {
|
||||||
|
const portfolioId = (message.data as { portfolioId?: string })?.portfolioId;
|
||||||
|
|
||||||
|
if (portfolioId) {
|
||||||
|
const channel = `portfolio:${portfolioId}`;
|
||||||
|
client.subscriptions.delete(channel);
|
||||||
|
|
||||||
|
wsManager.send(client, {
|
||||||
|
type: 'portfolio:unsubscribed',
|
||||||
|
data: { portfolioId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle manual refresh request
|
||||||
|
*/
|
||||||
|
private async handleRefresh(client: WSClient, message: WSMessage): Promise<void> {
|
||||||
|
const portfolioId = (message.data as { portfolioId?: string })?.portfolioId;
|
||||||
|
|
||||||
|
if (!portfolioId || !client.userId) return;
|
||||||
|
|
||||||
|
const portfolio = await portfolioService.getPortfolio(portfolioId);
|
||||||
|
if (!portfolio || portfolio.userId !== client.userId) return;
|
||||||
|
|
||||||
|
// Send fresh data to requesting client only
|
||||||
|
const updateData = this.formatPortfolioUpdate(portfolio);
|
||||||
|
wsManager.send(client, {
|
||||||
|
type: 'portfolio:update',
|
||||||
|
data: updateData,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when a client subscribes to a portfolio channel
|
||||||
|
*/
|
||||||
|
private onPortfolioSubscribe(client: WSClient, channel: string): void {
|
||||||
|
const portfolioId = channel.replace('portfolio:', '');
|
||||||
|
|
||||||
|
// Start update interval if not already running
|
||||||
|
if (!this.updateIntervals.has(portfolioId)) {
|
||||||
|
const interval = setInterval(async () => {
|
||||||
|
await this.sendPortfolioUpdate(portfolioId);
|
||||||
|
}, this.UPDATE_INTERVAL);
|
||||||
|
|
||||||
|
this.updateIntervals.set(portfolioId, interval);
|
||||||
|
logger.debug('[Portfolio WS] Started updates for:', { portfolioId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when a client unsubscribes from a portfolio channel
|
||||||
|
*/
|
||||||
|
private onPortfolioUnsubscribe(channel: string): void {
|
||||||
|
const portfolioId = channel.replace('portfolio:', '');
|
||||||
|
|
||||||
|
// Check if anyone is still subscribed
|
||||||
|
const subscriberCount = wsManager.getChannelSubscriberCount(channel);
|
||||||
|
|
||||||
|
if (subscriberCount === 0) {
|
||||||
|
// Stop update interval
|
||||||
|
const interval = this.updateIntervals.get(portfolioId);
|
||||||
|
if (interval) {
|
||||||
|
clearInterval(interval);
|
||||||
|
this.updateIntervals.delete(portfolioId);
|
||||||
|
logger.debug('[Portfolio WS] Stopped updates for:', { portfolioId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send portfolio update to all subscribers
|
||||||
|
*/
|
||||||
|
async sendPortfolioUpdate(portfolioId: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const portfolio = await portfolioService.getPortfolio(portfolioId);
|
||||||
|
if (!portfolio) return;
|
||||||
|
|
||||||
|
const updateData = this.formatPortfolioUpdate(portfolio);
|
||||||
|
|
||||||
|
wsManager.broadcast(`portfolio:${portfolioId}`, {
|
||||||
|
type: 'portfolio:update',
|
||||||
|
data: updateData,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[Portfolio WS] Error sending update:', {
|
||||||
|
portfolioId,
|
||||||
|
error: (error as Error).message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format portfolio data for WebSocket message
|
||||||
|
*/
|
||||||
|
private formatPortfolioUpdate(portfolio: {
|
||||||
|
id: string;
|
||||||
|
totalValue: number;
|
||||||
|
unrealizedPnl: number;
|
||||||
|
unrealizedPnlPercent: number;
|
||||||
|
allocations: {
|
||||||
|
asset: string;
|
||||||
|
value: number;
|
||||||
|
currentPercent: number;
|
||||||
|
pnl: number;
|
||||||
|
pnlPercent: number;
|
||||||
|
}[];
|
||||||
|
}): PortfolioUpdateMessage {
|
||||||
|
return {
|
||||||
|
portfolioId: portfolio.id,
|
||||||
|
totalValue: portfolio.totalValue,
|
||||||
|
unrealizedPnl: portfolio.unrealizedPnl,
|
||||||
|
unrealizedPnlPercent: portfolio.unrealizedPnlPercent,
|
||||||
|
allocations: portfolio.allocations.map((a) => ({
|
||||||
|
asset: a.asset,
|
||||||
|
value: a.value,
|
||||||
|
currentPercent: a.currentPercent,
|
||||||
|
pnl: a.pnl,
|
||||||
|
pnlPercent: a.pnlPercent,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Broadcast price update to all portfolio channels that have this asset
|
||||||
|
*/
|
||||||
|
async broadcastPriceUpdate(asset: string, price: number, change24h: number): Promise<void> {
|
||||||
|
const message: PriceUpdateMessage = { asset, price, change24h };
|
||||||
|
|
||||||
|
// Get all active portfolio channels
|
||||||
|
const channels = wsManager.getActiveChannels().filter((c) => c.startsWith('portfolio:'));
|
||||||
|
|
||||||
|
// Broadcast to each channel
|
||||||
|
channels.forEach((channel) => {
|
||||||
|
wsManager.broadcast(channel, {
|
||||||
|
type: 'price:update',
|
||||||
|
data: message,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send notification to user about portfolio events
|
||||||
|
*/
|
||||||
|
sendUserNotification(
|
||||||
|
userId: string,
|
||||||
|
notification: {
|
||||||
|
type: 'rebalance_needed' | 'goal_achieved' | 'significant_change';
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
portfolioId?: string;
|
||||||
|
goalId?: string;
|
||||||
|
}
|
||||||
|
): void {
|
||||||
|
wsManager.sendToUser(userId, {
|
||||||
|
type: 'portfolio:notification',
|
||||||
|
data: notification,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleanup on shutdown
|
||||||
|
*/
|
||||||
|
shutdown(): void {
|
||||||
|
this.updateIntervals.forEach((interval) => {
|
||||||
|
clearInterval(interval);
|
||||||
|
});
|
||||||
|
this.updateIntervals.clear();
|
||||||
|
logger.info('[Portfolio WS] Service shut down');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export singleton instance
|
||||||
|
export const portfolioWebSocket = new PortfolioWebSocketService();
|
||||||
Loading…
Reference in New Issue
Block a user