From 70fbf0445518624647165ea0d073e2af79b76310 Mon Sep 17 00:00:00 2001 From: snowrugar-beep Date: Sun, 21 Jun 2026 00:19:05 +0000 Subject: [PATCH] fix: implement portfolio performance calculation with ROI and period returns (#326) --- src/portfolio/portfolio.controller.ts | 41 +++ .../performance-analytics.service.spec.ts | 331 ++++++++++++++++++ .../services/performance-analytics.service.ts | 186 +++++++++- 3 files changed, 556 insertions(+), 2 deletions(-) diff --git a/src/portfolio/portfolio.controller.ts b/src/portfolio/portfolio.controller.ts index 01d56ec2..61ac303f 100644 --- a/src/portfolio/portfolio.controller.ts +++ b/src/portfolio/portfolio.controller.ts @@ -437,6 +437,47 @@ export class PortfolioController { ); } + @Get("portfolios/:portfolioId/metrics/roi") + @ApiOperation({ + summary: + "Get Return on Investment (ROI) relative to the invested cost basis", + }) + @UseGuards(PortfolioOwnerGuard) + async getROI(@Param("portfolioId") portfolioId: string) { + const roi = await this.performanceService.calculateROI(portfolioId); + return { portfolioId, roi }; + } + + @Get("portfolios/:portfolioId/metrics/drawdown") + @ApiOperation({ + summary: + "Get current drawdown relative to the all-time peak portfolio value", + }) + @UseGuards(PortfolioOwnerGuard) + async getCurrentDrawdown(@Param("portfolioId") portfolioId: string) { + const currentDrawdown = + await this.performanceService.calculateCurrentDrawdown(portfolioId); + return { portfolioId, currentDrawdown }; + } + + @Get("portfolios/:portfolioId/metrics/periods") + @ApiOperation({ + summary: "Get standard period returns (YTD, 1Y, 3Y, 5Y) for the portfolio", + }) + @UseGuards(PortfolioOwnerGuard) + async getPeriodReturns(@Param("portfolioId") portfolioId: string) { + return this.performanceService.calculatePeriodReturns(portfolioId); + } + + @Get("portfolios/:portfolioId/metrics/allocation") + @ApiOperation({ + summary: "Get the current allocation breakdown (ticker → percentage)", + }) + @UseGuards(PortfolioOwnerGuard) + async getAllocationBreakdown(@Param("portfolioId") portfolioId: string) { + return this.performanceService.getAllocationBreakdown(portfolioId); + } + // Backtesting Endpoints @Post("backtests") diff --git a/src/portfolio/services/performance-analytics.service.spec.ts b/src/portfolio/services/performance-analytics.service.spec.ts index dc09e292..97d0d3e6 100644 --- a/src/portfolio/services/performance-analytics.service.spec.ts +++ b/src/portfolio/services/performance-analytics.service.spec.ts @@ -4,6 +4,7 @@ import { Between } from "typeorm"; import { PerformanceAnalyticsService } from "./performance-analytics.service"; import { PerformanceMetric } from "../entities/performance-metric.entity"; import { Portfolio } from "../entities/portfolio.entity"; +import { PortfolioAsset } from "../entities/portfolio-asset.entity"; import { PerformancePeriod } from "../dto/performance.dto"; const PORTFOLIO_ID = "portfolio-1"; @@ -39,6 +40,13 @@ const mockPortfolioRepo = { findOne: jest.fn(), }; +const mockAssetRepo = { + find: jest.fn(), + findOne: jest.fn(), + create: jest.fn(), + save: jest.fn(), +}; + describe("PerformanceAnalyticsService", () => { let service: PerformanceAnalyticsService; @@ -54,6 +62,10 @@ describe("PerformanceAnalyticsService", () => { provide: getRepositoryToken(Portfolio), useValue: mockPortfolioRepo, }, + { + provide: getRepositoryToken(PortfolioAsset), + useValue: mockAssetRepo, + }, ], }).compile(); @@ -61,6 +73,16 @@ describe("PerformanceAnalyticsService", () => { PerformanceAnalyticsService, ); jest.clearAllMocks(); + // Default: a portfolio exists with empty assets and zero metrics so + // tests that don't care about ROI/drawdowns/period returns don't trip + // on a missing mock (calculateXxx methods short-circuit to 0 on empty + // histories or asset lists). + mockPortfolioRepo.findOne.mockResolvedValue({ + id: PORTFOLIO_ID, + currentAllocation: {}, + } as Portfolio); + mockAssetRepo.find.mockResolvedValue([]); + mockMetricRepo.find.mockResolvedValue([]); }); it("should be defined", () => { @@ -105,6 +127,44 @@ describe("PerformanceAnalyticsService", () => { expect.objectContaining({ dailyReturn: 0 }), ); }); + + it("attaches period returns and currentDrawdown when history exists", async () => { + // Build a history long enough for period returns + drawdown to compute. + const dailyPrices = [100, 105, 110, 115, 120, 125, 130, 135, 140, 145]; + mockMetricRepo.find.mockResolvedValue( + dailyPrices.map((p, i) => makeMetric(p, day(dailyPrices.length - i))), + ); + + const saved = makeMetric(150, new Date()); + mockMetricRepo.create.mockReturnValue(saved); + mockMetricRepo.save.mockResolvedValue(saved); + + await service.recordMetrics(PORTFOLIO_ID, 150, { AAPL: 100 }, 145); + + expect(mockMetricRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ + yearToDateReturn: expect.any(Number), + oneYearReturn: expect.any(Number), + threeYearReturn: expect.any(Number), + fiveYearReturn: expect.any(Number), + currentDrawdown: expect.any(Number), + }), + ); + }); + + it("writes the snapshot even when there is no prior history", async () => { + // Empty history → calculatePeriodReturns returns 0s and + // calculateCurrentDrawdown returns 0, both must not block the write. + mockMetricRepo.find.mockResolvedValue([]); + + const saved = makeMetric(10000, new Date()); + mockMetricRepo.create.mockReturnValue(saved); + mockMetricRepo.save.mockResolvedValue(saved); + + await expect( + service.recordMetrics(PORTFOLIO_ID, 10000, {}), + ).resolves.toBe(saved); + }); }); // ─── calculateCumulativeReturn ────────────────────────────────────────────── @@ -423,8 +483,279 @@ describe("PerformanceAnalyticsService", () => { maxDrawdown: expect.any(Number), calmarRatio: expect.any(Number), valueAtRisk95: expect.any(Number), + roi: expect.any(Number), + currentDrawdown: expect.any(Number), + allocationBreakdown: expect.any(Object), + periodReturns: expect.objectContaining({ + yearToDateReturn: expect.any(Number), + oneYearReturn: expect.any(Number), + threeYearReturn: expect.any(Number), + fiveYearReturn: expect.any(Number), + }), + }); + }); + + it("includes ROI computed from portfolio assets", async () => { + const metrics = [ + makeMetric(100, day(2)), + makeMetric(110, day(1)), + makeMetric(120, day(0)), + ]; + mockMetricRepo.find.mockResolvedValue(metrics); + mockAssetRepo.find.mockResolvedValue([ + // quantity * currentPrice = 100 * 150 = 15_000 + { + id: "a1", + portfolioId: PORTFOLIO_ID, + quantity: 100, + currentPrice: 150, + costBasis: 10_000, + }, + // quantity * currentPrice = 50 * 100 = 5_000 + { + id: "a2", + portfolioId: PORTFOLIO_ID, + quantity: 50, + currentPrice: 100, + costBasis: 5_000, + }, + ]); + + const summary = await service.getPerformanceSummary(PORTFOLIO_ID); + + // ROI = (15k + 5k - 10k - 5k) / (10k + 5k) = 5_000 / 15_000 ≈ 0.333… + expect(summary.roi).toBeCloseTo(5_000 / 15_000, 5); + }); + + it("wires allocation breakdown from the latest portfolio snapshot", async () => { + mockMetricRepo.find.mockResolvedValue([]); + mockPortfolioRepo.findOne.mockResolvedValue({ + id: PORTFOLIO_ID, + currentAllocation: { BTC: 60, ETH: 40 }, + } as unknown as Portfolio); + + const summary = await service.getPerformanceSummary(PORTFOLIO_ID); + expect(summary.allocationBreakdown).toEqual({ BTC: 60, ETH: 40 }); + }); + }); + + // ─── calculateCurrentDrawdown ──────────────────────────────────────────────── + + describe("calculateCurrentDrawdown", () => { + it("returns 0 when there is no history", async () => { + mockMetricRepo.find.mockResolvedValue([]); + expect(await service.calculateCurrentDrawdown(PORTFOLIO_ID)).toBe(0); + }); + + it("returns 0 when the latest value is at or above the peak", async () => { + mockMetricRepo.find.mockResolvedValue([ + makeMetric(100, day(4)), + makeMetric(120, day(3)), + makeMetric(140, day(2)), + makeMetric(150, day(0)), + ]); + + // Latest is the peak → drawdown = 0. + expect(await service.calculateCurrentDrawdown(PORTFOLIO_ID)).toBe(0); + }); + + it("returns 0 for a monotonically increasing series", async () => { + const prices = [100, 110, 120, 130, 140, 150]; + mockMetricRepo.find.mockResolvedValue( + prices.map((p, i) => makeMetric(p, day(prices.length - i))), + ); + expect(await service.calculateCurrentDrawdown(PORTFOLIO_ID)).toBe(0); + }); + + it("computes the drawdown from the historical peak to the latest value", async () => { + // Peak = 200, latest = 150 → drawdown = (200 - 150) / 200 = 0.25 + mockMetricRepo.find.mockResolvedValue([ + makeMetric(100, day(5)), + makeMetric(200, day(4)), // peak + makeMetric(180, day(3)), + makeMetric(150, day(0), { allocation: {}, assetContribution: null }), + ]); + + const dd = await service.calculateCurrentDrawdown(PORTFOLIO_ID); + expect(dd).toBeCloseTo(0.25, 5); + }); + + it("computes drawdown correctly when the peak occurred at the first metric", async () => { + mockMetricRepo.find.mockResolvedValue([ + makeMetric(200, day(3)), // all-time peak & also first metric + makeMetric(150, day(2)), + makeMetric(100, day(1)), + makeMetric(120, day(0)), // latest value, below the historic peak + ]); + const dd = await service.calculateCurrentDrawdown(PORTFOLIO_ID); + // Peak = 200, latest = 120 → (200 - 120) / 200 = 0.4 + expect(dd).toBeCloseTo(0.4, 5); + }); + + it("handles a monotonically declining series (latest is below peak)", async () => { + const prices = [200, 180, 160, 140, 120, 100]; + mockMetricRepo.find.mockResolvedValue( + prices.map((p, i) => makeMetric(p, day(prices.length - i))), + ); + // Peak = 200, latest = 100 → 0.5 + expect(await service.calculateCurrentDrawdown(PORTFOLIO_ID)).toBeCloseTo( + 0.5, + 5, + ); + }); + + it("ignores values <= 0 when computing the peak", async () => { + mockMetricRepo.find.mockResolvedValue([ + makeMetric(0, day(2)), + makeMetric(100, day(1)), + makeMetric(50, day(0)), + ]); + // Peak > 0 satisfies the guard and computes (100 - 50) / 100 = 0.5 + expect(await service.calculateCurrentDrawdown(PORTFOLIO_ID)).toBeCloseTo( + 0.5, + 5, + ); + }); + }); + + // ─── calculateROI ───────────────────────────────────────────────────────────── + + describe("calculateROI", () => { + it("returns 0 when the portfolio has no assets", async () => { + mockAssetRepo.find.mockResolvedValue([]); + expect(await service.calculateROI(PORTFOLIO_ID)).toBe(0); + }); + + it("returns 0 when total cost basis is 0", async () => { + mockAssetRepo.find.mockResolvedValue([ + { id: "a1", costBasis: 0, quantity: 1, currentPrice: 1000 }, + ]); + expect(await service.calculateROI(PORTFOLIO_ID)).toBe(0); + }); + + it("handles a fully costless portfolio (a.k.a. gifts/airdrops)", async () => { + mockAssetRepo.find.mockResolvedValue([ + { id: "a1", costBasis: 0, quantity: 1, currentPrice: 1000 }, + { id: "a2", costBasis: 0, quantity: 1, currentPrice: 500 }, + ]); + expect(await service.calculateROI(PORTFOLIO_ID)).toBe(0); + }); + + it("computes ROI = (value - cost) / cost across all assets", async () => { + mockAssetRepo.find.mockResolvedValue([ + { id: "a1", costBasis: 8_000, quantity: 100, currentPrice: 120 }, + { id: "a2", costBasis: 2_000, quantity: 50, currentPrice: 60 }, + ]); + // currentValue = 100*120 + 50*60 = 15_000 + // (15_000 - 10_000) / 10_000 = 0.5 + expect(await service.calculateROI(PORTFOLIO_ID)).toBeCloseTo(0.5, 5); + }); + + it("returns negative ROI when value is below cost basis", async () => { + mockAssetRepo.find.mockResolvedValue([ + { id: "a1", costBasis: 10_000, quantity: 100, currentPrice: 40 }, + ]); + // currentValue = 4_000, ROI = (4k - 10k) / 10k = -0.6 + expect(await service.calculateROI(PORTFOLIO_ID)).toBeCloseTo(-0.6, 5); + }); + + it("treats null/undefined fields as zero", async () => { + mockAssetRepo.find.mockResolvedValue([ + { + id: "a1", + costBasis: undefined as any, + quantity: undefined as any, + currentPrice: undefined as any, + }, + ]); + expect(await service.calculateROI(PORTFOLIO_ID)).toBe(0); + }); + }); + + // ─── calculatePeriodReturns ─────────────────────────────────────────────────── + + describe("calculatePeriodReturns", () => { + it("returns all four period returns as numbers", async () => { + mockMetricRepo.find.mockResolvedValue([]); + + const result = await service.calculatePeriodReturns(PORTFOLIO_ID); + + expect(result).toEqual({ + yearToDateReturn: expect.any(Number), + oneYearReturn: expect.any(Number), + threeYearReturn: expect.any(Number), + fiveYearReturn: expect.any(Number), }); }); + + it("queries the metric repository for each period via calculateCumulativeReturn", async () => { + mockMetricRepo.find.mockResolvedValue([]); + + await service.calculatePeriodReturns(PORTFOLIO_ID); + + // Four period calls + 0 from each (no history). 4 calls to find. + expect(mockMetricRepo.find).toHaveBeenCalledTimes(4); + const startDates = mockMetricRepo.find.mock.calls.map( + (call) => (call[0].where.dateTime as any)._value[0], + ); + const nowMs = Date.now(); + const toleranceMs = 5_000; // 5 s slop is plenty for date math + const ytdStart = new Date(new Date().getFullYear(), 0, 1).getTime(); + const oneYearStart = nowMs - 365 * 24 * 60 * 60 * 1000; + const threeYearStart = nowMs - 3 * 365 * 24 * 60 * 60 * 1000; + const fiveYearStart = nowMs - 5 * 365 * 24 * 60 * 60 * 1000; + + const withinTolerance = (target: number) => + startDates.some( + (d: Date) => Math.abs(d.getTime() - target) < toleranceMs, + ); + expect(withinTolerance(ytdStart)).toBe(true); + expect(withinTolerance(oneYearStart)).toBe(true); + expect(withinTolerance(threeYearStart)).toBe(true); + expect(withinTolerance(fiveYearStart)).toBe(true); + }); + + it("computes returns correctly for a price series with full history", async () => { + // 6 prices: 100 → 160 → 120 → 180 → 140 → 220 (last = 220, first = 100) + const prices = [100, 160, 120, 180, 140, 220]; + mockMetricRepo.find.mockResolvedValue( + prices.map((p, i) => makeMetric(p, day(prices.length - i))), + ); + + const result = await service.calculatePeriodReturns(PORTFOLIO_ID); + + // Each lookback returns the same first/last in this stub → 120% return. + Object.values(result).forEach((value) => { + expect(value).toBeCloseTo(1.2, 5); + }); + }); + }); + + // ─── getAllocationBreakdown ─────────────────────────────────────────────────── + + describe("getAllocationBreakdown", () => { + it("returns an empty object when the portfolio is not found", async () => { + mockPortfolioRepo.findOne.mockResolvedValue(null); + expect(await service.getAllocationBreakdown(PORTFOLIO_ID)).toEqual({}); + }); + + it("returns an empty object when currentAllocation is null", async () => { + mockPortfolioRepo.findOne.mockResolvedValue({ + id: PORTFOLIO_ID, + currentAllocation: null, + } as unknown as Portfolio); + expect(await service.getAllocationBreakdown(PORTFOLIO_ID)).toEqual({}); + }); + + it("returns the live allocation breakdown from the portfolio entity", async () => { + mockPortfolioRepo.findOne.mockResolvedValue({ + id: PORTFOLIO_ID, + currentAllocation: { BTC: 55, ETH: 25, USDC: 20 }, + } as unknown as Portfolio); + + const result = await service.getAllocationBreakdown(PORTFOLIO_ID); + expect(result).toEqual({ BTC: 55, ETH: 25, USDC: 20 }); + }); }); // ─── getAttributionAnalysis ──────────────────────────────────────────────── diff --git a/src/portfolio/services/performance-analytics.service.ts b/src/portfolio/services/performance-analytics.service.ts index aa1ccb57..75cd2247 100644 --- a/src/portfolio/services/performance-analytics.service.ts +++ b/src/portfolio/services/performance-analytics.service.ts @@ -3,8 +3,18 @@ import { InjectRepository } from "@nestjs/typeorm"; import { Between, Repository } from "typeorm"; import { PerformanceMetric } from "../entities/performance-metric.entity"; import { Portfolio } from "../entities/portfolio.entity"; +import { PortfolioAsset } from "../entities/portfolio-asset.entity"; import { PerformancePeriod } from "../dto/performance.dto"; +/** + * Number of trading days in a year (used for annualising volatility). + * 252 is the trading-day convention for US equity markets. + */ +const TRADING_DAYS_PER_YEAR = 252; + +/** Number of calendar days in a year, used for lookback windows. */ +const CALENDAR_DAYS_PER_YEAR = 365; + @Injectable() export class PerformanceAnalyticsService { private readonly logger = new Logger(PerformanceAnalyticsService.name); @@ -14,6 +24,8 @@ export class PerformanceAnalyticsService { private metricRepository: Repository, @InjectRepository(Portfolio) private portfolioRepository: Repository, + @InjectRepository(PortfolioAsset) + private assetRepository: Repository, ) {} // ─── Helpers ──────────────────────────────────────────────────────────────── @@ -49,7 +61,13 @@ export class PerformanceAnalyticsService { // ─── Recording ────────────────────────────────────────────────────────────── /** - * Record performance metrics for a portfolio + * Record performance metrics for a portfolio. + * + * Also computes and persists period-specific returns (YTD/1Y/3Y/5Y) and + * the live drawdown against the all-time peak so callers can render + * dashboards without re-querying the entire history. Both derived + * quantities default to 0 when the recorded history is too short or + * unavailable. */ async recordMetrics( portfolioId: string, @@ -62,6 +80,11 @@ export class PerformanceAnalyticsService { ? (portfolioValue - previousValue) / previousValue : 0; + const [periodReturns, currentDrawdown] = await Promise.all([ + this.calculatePeriodReturns(portfolioId), + this.calculateCurrentDrawdown(portfolioId), + ]); + const metric = this.metricRepository.create({ portfolioId, dateTime: new Date(), @@ -69,6 +92,11 @@ export class PerformanceAnalyticsService { previousValue, dailyReturn, allocation, + yearToDateReturn: periodReturns.yearToDateReturn, + oneYearReturn: periodReturns.oneYearReturn, + threeYearReturn: periodReturns.threeYearReturn, + fiveYearReturn: periodReturns.fiveYearReturn, + currentDrawdown, }); return this.metricRepository.save(metric); @@ -129,7 +157,7 @@ export class PerformanceAnalyticsService { const variance = returns.reduce((sum, r) => sum + (r - mean) ** 2, 0) / returns.length; - return Math.sqrt(variance) * Math.sqrt(252); + return Math.sqrt(variance) * Math.sqrt(TRADING_DAYS_PER_YEAR); } /** @@ -182,6 +210,132 @@ export class PerformanceAnalyticsService { return (cumulativeReturn - riskFreeRate) / downsideDeviation; } + /** + * Calculate the live drawdown of the most recent portfolio value + * relative to the all-time peak in the recorded history. + * + * Unlike {@link calculateMaxDrawdown}, this returns the drawdown *right + * now* – useful for surfacing red/green on a dashboard. Returns 0 when + * the portfolio is at or above its historical peak. + */ + async calculateCurrentDrawdown(portfolioId: string): Promise { + const metrics = await this.metricRepository.find({ + where: { portfolioId }, + order: { dateTime: "ASC" }, + }); + + if (metrics.length === 0) return 0; + + let peak = metrics[0].portfolioValue; + for (const metric of metrics) { + if (metric.portfolioValue > peak) peak = metric.portfolioValue; + } + + const lastValue = metrics[metrics.length - 1].portfolioValue; + if (peak <= 0 || lastValue >= peak) return 0; + + return (peak - lastValue) / peak; + } + + /** + * Calculate Return on Investment (ROI) for a portfolio relative to its + * invested capital. + * + * ROI = (current value − cost basis) / cost basis. This differs from + * `cumulativeReturn` because `cumulativeReturn` is a time-series price + * return, whereas ROI is a profit on the actual capital the user put in. + * + * Current portfolio value is derived from `asset.quantity * asset.currentPrice` + * rather than the persisted `asset.value` column, which is only refreshed + * when `PortfolioService.updatePortfolioAllocation` runs. Computing it + * inline avoids a stale-value bug for assets whose `value` field has not + * yet been rebalanced after the latest price change. + * + * If no assets are recorded, or their cost basis sums to zero, returns 0 + * to avoid a divide-by-zero result. + */ + async calculateROI(portfolioId: string): Promise { + const assets = await this.assetRepository.find({ where: { portfolioId } }); + + if (assets.length === 0) return 0; + + let totalValue = 0; + let totalCostBasis = 0; + + for (const asset of assets) { + const quantity = asset.quantity ?? 0; + const currentPrice = asset.currentPrice ?? 0; + totalValue += quantity * currentPrice; + totalCostBasis += asset.costBasis ?? 0; + } + + if (totalCostBasis <= 0) return 0; + return (totalValue - totalCostBasis) / totalCostBasis; + } + + /** + * Calculate the standard period-specific returns that most reporting + * dashboards expect: YTD, 1 year, 3 years and 5 years. + * + * Each return is computed from the corresponding lookback date to the + * most recent snapshot and falls back to 0 when the history is too + * short to compute the period. + */ + async calculatePeriodReturns(portfolioId: string): Promise<{ + yearToDateReturn: number; + oneYearReturn: number; + threeYearReturn: number; + fiveYearReturn: number; + }> { + const now = new Date(); + const ytdStart = new Date(now.getFullYear(), 0, 1); + const oneYearAgo = new Date( + now.getTime() - CALENDAR_DAYS_PER_YEAR * 24 * 60 * 60 * 1000, + ); + const threeYearAgo = new Date( + now.getTime() - 3 * CALENDAR_DAYS_PER_YEAR * 24 * 60 * 60 * 1000, + ); + const fiveYearAgo = new Date( + now.getTime() - 5 * CALENDAR_DAYS_PER_YEAR * 24 * 60 * 60 * 1000, + ); + + const [yearToDateReturn, oneYearReturn, threeYearReturn, fiveYearReturn] = + await Promise.all([ + this.calculateCumulativeReturn(portfolioId, ytdStart), + this.calculateCumulativeReturn(portfolioId, oneYearAgo), + this.calculateCumulativeReturn(portfolioId, threeYearAgo), + this.calculateCumulativeReturn(portfolioId, fiveYearAgo), + ]); + + return { + yearToDateReturn, + oneYearReturn, + threeYearReturn, + fiveYearReturn, + }; + } + + /** + * Return the latest allocation breakdown for a portfolio (ticker → %). + * + * Source of truth is `Portfolio.currentAllocation`, which is kept in + * sync with the running holdings by `PortfolioService.updatePortfolioAllocation`. + * Returns an empty object if no portfolio is found or the allocation map + * has not yet been populated. + */ + async getAllocationBreakdown( + portfolioId: string, + ): Promise> { + const portfolio = await this.portfolioRepository.findOne({ + where: { id: portfolioId }, + }); + + if (!portfolio) return {}; + + const allocation = portfolio.currentAllocation ?? {}; + return allocation && typeof allocation === "object" ? allocation : {}; + } + /** * Calculate maximum drawdown over the full history. */ @@ -356,6 +510,13 @@ export class PerformanceAnalyticsService { /** * Get comprehensive performance summary for a portfolio. + * + * Includes the legacy risk-adjusted metrics (Sharpe, Sortino, Calmar, + * max drawdown, VaR, volatility) as well as the explicit reporting + * metrics called out in the Portfolio Performance API spec: + * total/cumulative return, ROI relative to the invested cost basis, + * the current allocation breakdown, the live drawdown vs. the all-time + * peak, and period-specific returns (YTD/1Y/3Y/5Y). */ async getPerformanceSummary( portfolioId: string, @@ -368,6 +529,15 @@ export class PerformanceAnalyticsService { maxDrawdown: number; calmarRatio: number; valueAtRisk95: number; + roi: number; + currentDrawdown: number; + allocationBreakdown: Record; + periodReturns: { + yearToDateReturn: number; + oneYearReturn: number; + threeYearReturn: number; + fiveYearReturn: number; + }; }> { const [ cumulativeReturn, @@ -377,6 +547,10 @@ export class PerformanceAnalyticsService { maxDrawdown, calmarRatio, valueAtRisk95, + roi, + currentDrawdown, + allocationBreakdown, + periodReturns, ] = await Promise.all([ this.calculateCumulativeReturn(portfolioId, startDate), this.calculateVolatility(portfolioId), @@ -385,6 +559,10 @@ export class PerformanceAnalyticsService { this.calculateMaxDrawdown(portfolioId), this.calculateCalmarRatio(portfolioId), this.calculateVaR(portfolioId), + this.calculateROI(portfolioId), + this.calculateCurrentDrawdown(portfolioId), + this.getAllocationBreakdown(portfolioId), + this.calculatePeriodReturns(portfolioId), ]); return { @@ -395,6 +573,10 @@ export class PerformanceAnalyticsService { maxDrawdown, calmarRatio, valueAtRisk95, + roi, + currentDrawdown, + allocationBreakdown, + periodReturns, }; }