From 79898539d1b202f5f524b1c5549f57126547955c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sven=20G=C3=BCnther?= Date: Wed, 28 Jan 2026 15:32:09 +0100 Subject: [PATCH 1/6] Add calculation and display of annualized dividend yield based on dividends received in the last 12 months relative to the investment cost basis. Implementation: - Add annualizedDividendYield property to TimelinePosition, PortfolioSnapshot, and PortfolioSummary interfaces - Calculate individual position yield based on last 12 months dividends / investment with currency effects - Aggregate portfolio-wide yield in PortfolioSnapshot - Extract and include yield in PortfolioSummary via PortfolioService Tests: - Add comprehensive tests with single position (MSFT) - Add multi-position test (MSFT + IBM) to verify aggregation - Add PortfolioService integration tests - Add IBM mock data to CurrentRateService - Update cash test to include new property --- .../calculator/portfolio-calculator.ts | 29 ++ .../roai/portfolio-calculator-cash.spec.ts | 1 + ...-calculator-msft-buy-with-dividend.spec.ts | 348 +++++++++++++++++- .../calculator/roai/portfolio-calculator.ts | 15 + .../portfolio/current-rate.service.mock.ts | 9 + .../app/portfolio/portfolio.service.spec.ts | 77 ++++ .../src/app/portfolio/portfolio.service.ts | 8 +- .../interfaces/portfolio-summary.interface.ts | 1 + .../src/lib/models/portfolio-snapshot.ts | 1 + .../src/lib/models/timeline-position.ts | 1 + 10 files changed, 487 insertions(+), 3 deletions(-) create mode 100644 apps/api/src/app/portfolio/portfolio.service.spec.ts diff --git a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts index b3b1d3410..a281f9b97 100644 --- a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts @@ -48,6 +48,7 @@ import { eachYearOfInterval, endOfDay, endOfYear, + subYears, format, isAfter, isBefore, @@ -185,6 +186,7 @@ export abstract class PortfolioCalculator { if (!transactionPoints.length) { return { activitiesCount: 0, + annualizedDividendYield: 0, createdAt: new Date(), currentValueInBaseCurrency: new Big(0), errors: [], @@ -403,11 +405,38 @@ export abstract class PortfolioCalculator { }; } + // Calculate annualized dividend yield based on investment (cost basis) + const twelveMonthsAgo = subYears(this.endDate, 1); + const dividendsLast12Months = this.activities + .filter(({ SymbolProfile, type, date }) => { + return ( + SymbolProfile.symbol === item.symbol && + type === 'DIVIDEND' && + new Date(date) >= twelveMonthsAgo && + new Date(date) <= this.endDate + ); + }) + .reduce((sum, activity) => { + const exchangeRate = + exchangeRatesByCurrency[ + `${activity.SymbolProfile.currency}${this.currency}` + ]?.[format(new Date(activity.date), DATE_FORMAT)] ?? 1; + const dividendAmount = activity.quantity.mul(activity.unitPrice); + return sum.plus(dividendAmount.mul(exchangeRate)); + }, new Big(0)); + + const annualizedDividendYield = totalInvestmentWithCurrencyEffect.gt(0) + ? dividendsLast12Months + .div(totalInvestmentWithCurrencyEffect) + .toNumber() + : 0; + positions.push({ includeInTotalAssetValue, timeWeightedInvestment, timeWeightedInvestmentWithCurrencyEffect, activitiesCount: item.activitiesCount, + annualizedDividendYield, averagePrice: item.averagePrice, currency: item.currency, dataSource: item.dataSource, diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts index bbcaba294..b213ea6c6 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts @@ -231,6 +231,7 @@ describe('PortfolioCalculator', () => { */ expect(position).toMatchObject({ activitiesCount: 2, + annualizedDividendYield: 0, averagePrice: new Big(1), currency: 'USD', dataSource: DataSource.YAHOO, diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-with-dividend.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-with-dividend.spec.ts index 88895b8c6..051b75e9b 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-with-dividend.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-with-dividend.spec.ts @@ -79,7 +79,7 @@ describe('PortfolioCalculator', () => { }); describe('get current positions', () => { - it.only('with MSFT buy', async () => { + it('with MSFT buy', async () => { jest.useFakeTimers().setSystemTime(parseDate('2023-07-10').getTime()); const activities: Activity[] = [ @@ -180,5 +180,351 @@ describe('PortfolioCalculator', () => { }) ); }); + + it('with MSFT buy and four quarterly dividends to calculate annualized dividend yield', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2023-07-10').getTime()); + + const activities: Activity[] = [ + { + ...activityDummyData, + date: new Date('2021-09-16'), + feeInAssetProfileCurrency: 19, + feeInBaseCurrency: 19, + quantity: 1, + SymbolProfile: { + ...symbolProfileDummyData, + currency: 'USD', + dataSource: 'YAHOO', + name: 'Microsoft Inc.', + symbol: 'MSFT' + }, + type: 'BUY', + unitPriceInAssetProfileCurrency: 298.58 + }, + { + ...activityDummyData, + date: new Date('2022-08-16'), + feeInAssetProfileCurrency: 0, + feeInBaseCurrency: 0, + quantity: 1, + SymbolProfile: { + ...symbolProfileDummyData, + currency: 'USD', + dataSource: 'YAHOO', + name: 'Microsoft Inc.', + symbol: 'MSFT' + }, + type: 'DIVIDEND', + unitPriceInAssetProfileCurrency: 0.62 + }, + { + ...activityDummyData, + date: new Date('2022-11-16'), + feeInAssetProfileCurrency: 0, + feeInBaseCurrency: 0, + quantity: 1, + SymbolProfile: { + ...symbolProfileDummyData, + currency: 'USD', + dataSource: 'YAHOO', + name: 'Microsoft Inc.', + symbol: 'MSFT' + }, + type: 'DIVIDEND', + unitPriceInAssetProfileCurrency: 0.68 + }, + { + ...activityDummyData, + date: new Date('2023-02-16'), + feeInAssetProfileCurrency: 0, + feeInBaseCurrency: 0, + quantity: 1, + SymbolProfile: { + ...symbolProfileDummyData, + currency: 'USD', + dataSource: 'YAHOO', + name: 'Microsoft Inc.', + symbol: 'MSFT' + }, + type: 'DIVIDEND', + unitPriceInAssetProfileCurrency: 0.68 + }, + { + ...activityDummyData, + date: new Date('2023-05-16'), + feeInAssetProfileCurrency: 0, + feeInBaseCurrency: 0, + quantity: 1, + SymbolProfile: { + ...symbolProfileDummyData, + currency: 'USD', + dataSource: 'YAHOO', + name: 'Microsoft Inc.', + symbol: 'MSFT' + }, + type: 'DIVIDEND', + unitPriceInAssetProfileCurrency: 0.68 + } + ]; + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.ROAI, + currency: 'USD', + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); + + expect(portfolioSnapshot).toMatchObject({ + errors: [], + hasErrors: false, + positions: [ + { + activitiesCount: 5, + averagePrice: new Big('298.58'), + currency: 'USD', + dataSource: 'YAHOO', + dateOfFirstActivity: '2021-09-16', + dividend: new Big('2.66'), + dividendInBaseCurrency: new Big('2.66'), + fee: new Big('19'), + firstBuyDate: '2021-09-16', + quantity: new Big('1'), + symbol: 'MSFT', + tags: [], + transactionCount: 5 + } + ] + }); + + const position = portfolioSnapshot.positions[0]; + expect(position).toHaveProperty('annualizedDividendYield'); + expect(position.annualizedDividendYield).toBeGreaterThan(0); + + // Verify that the snapshot data is sufficient for portfolio summary calculation + // Portfolio summary annualized dividend yield = totalDividend / totalInvestment + const expectedPortfolioYield = new Big(position.dividendInBaseCurrency) + .div(position.investmentWithCurrencyEffect) + .toNumber(); + + expect(position.annualizedDividendYield).toBeCloseTo( + expectedPortfolioYield, + 10 + ); + expect(expectedPortfolioYield).toBeCloseTo(0.00891, 3); // ~0.89% yield on cost + }); + + it('with MSFT and IBM positions to verify portfolio-wide dividend yield aggregation', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2023-07-10').getTime()); + + const activities: Activity[] = [ + // MSFT: 1 share @ 300, 4 quarterly dividends = 2.60 total + { + ...activityDummyData, + date: new Date('2021-09-16'), + feeInAssetProfileCurrency: 0, + feeInBaseCurrency: 0, + quantity: 1, + SymbolProfile: { + ...symbolProfileDummyData, + currency: 'USD', + dataSource: 'YAHOO', + name: 'Microsoft Inc.', + symbol: 'MSFT' + }, + type: 'BUY', + unitPriceInAssetProfileCurrency: 300 + }, + { + ...activityDummyData, + date: new Date('2022-08-16'), + feeInAssetProfileCurrency: 0, + feeInBaseCurrency: 0, + quantity: 1, + SymbolProfile: { + ...symbolProfileDummyData, + currency: 'USD', + dataSource: 'YAHOO', + name: 'Microsoft Inc.', + symbol: 'MSFT' + }, + type: 'DIVIDEND', + unitPriceInAssetProfileCurrency: 0.65 + }, + { + ...activityDummyData, + date: new Date('2022-11-16'), + feeInAssetProfileCurrency: 0, + feeInBaseCurrency: 0, + quantity: 1, + SymbolProfile: { + ...symbolProfileDummyData, + currency: 'USD', + dataSource: 'YAHOO', + name: 'Microsoft Inc.', + symbol: 'MSFT' + }, + type: 'DIVIDEND', + unitPriceInAssetProfileCurrency: 0.65 + }, + { + ...activityDummyData, + date: new Date('2023-02-16'), + feeInAssetProfileCurrency: 0, + feeInBaseCurrency: 0, + quantity: 1, + SymbolProfile: { + ...symbolProfileDummyData, + currency: 'USD', + dataSource: 'YAHOO', + name: 'Microsoft Inc.', + symbol: 'MSFT' + }, + type: 'DIVIDEND', + unitPriceInAssetProfileCurrency: 0.65 + }, + { + ...activityDummyData, + date: new Date('2023-05-16'), + feeInAssetProfileCurrency: 0, + feeInBaseCurrency: 0, + quantity: 1, + SymbolProfile: { + ...symbolProfileDummyData, + currency: 'USD', + dataSource: 'YAHOO', + name: 'Microsoft Inc.', + symbol: 'MSFT' + }, + type: 'DIVIDEND', + unitPriceInAssetProfileCurrency: 0.65 + }, + // IBM: 1 share @ 200, 4 quarterly dividends = 6.60 total + { + ...activityDummyData, + date: new Date('2021-10-01'), + feeInAssetProfileCurrency: 0, + feeInBaseCurrency: 0, + quantity: 1, + SymbolProfile: { + ...symbolProfileDummyData, + currency: 'USD', + dataSource: 'YAHOO', + name: 'IBM', + symbol: 'IBM' + }, + type: 'BUY', + unitPriceInAssetProfileCurrency: 200 + }, + { + ...activityDummyData, + date: new Date('2022-09-01'), + feeInAssetProfileCurrency: 0, + feeInBaseCurrency: 0, + quantity: 1, + SymbolProfile: { + ...symbolProfileDummyData, + currency: 'USD', + dataSource: 'YAHOO', + name: 'IBM', + symbol: 'IBM' + }, + type: 'DIVIDEND', + unitPriceInAssetProfileCurrency: 1.65 + }, + { + ...activityDummyData, + date: new Date('2022-12-01'), + feeInAssetProfileCurrency: 0, + feeInBaseCurrency: 0, + quantity: 1, + SymbolProfile: { + ...symbolProfileDummyData, + currency: 'USD', + dataSource: 'YAHOO', + name: 'IBM', + symbol: 'IBM' + }, + type: 'DIVIDEND', + unitPriceInAssetProfileCurrency: 1.65 + }, + { + ...activityDummyData, + date: new Date('2023-03-01'), + feeInAssetProfileCurrency: 0, + feeInBaseCurrency: 0, + quantity: 1, + SymbolProfile: { + ...symbolProfileDummyData, + currency: 'USD', + dataSource: 'YAHOO', + name: 'IBM', + symbol: 'IBM' + }, + type: 'DIVIDEND', + unitPriceInAssetProfileCurrency: 1.65 + }, + { + ...activityDummyData, + date: new Date('2023-06-01'), + feeInAssetProfileCurrency: 0, + feeInBaseCurrency: 0, + quantity: 1, + SymbolProfile: { + ...symbolProfileDummyData, + currency: 'USD', + dataSource: 'YAHOO', + name: 'IBM', + symbol: 'IBM' + }, + type: 'DIVIDEND', + unitPriceInAssetProfileCurrency: 1.65 + } + ]; + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.ROAI, + currency: 'USD', + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); + + expect(portfolioSnapshot.positions).toHaveLength(2); + + const msftPosition = portfolioSnapshot.positions.find( + ({ symbol }) => symbol === 'MSFT' + ); + const ibmPosition = portfolioSnapshot.positions.find( + ({ symbol }) => symbol === 'IBM' + ); + + // MSFT: 2.60 dividends / 300 investment = 0.00867 (0.867%) + expect(msftPosition.dividendInBaseCurrency).toEqual(new Big('2.6')); + expect(msftPosition.investmentWithCurrencyEffect).toEqual(new Big('300')); + expect(msftPosition.annualizedDividendYield).toBeCloseTo(2.6 / 300, 5); + + // IBM: 6.60 dividends / 200 investment = 0.033 (3.3%) + expect(ibmPosition.dividendInBaseCurrency).toEqual(new Big('6.6')); + expect(ibmPosition.investmentWithCurrencyEffect).toEqual(new Big('200')); + expect(ibmPosition.annualizedDividendYield).toBeCloseTo(6.6 / 200, 5); + + // Portfolio-wide: (2.60 + 6.60) / (300 + 200) = 9.20 / 500 = 0.0184 (1.84%) + const totalDividends = new Big(msftPosition.dividendInBaseCurrency).plus( + ibmPosition.dividendInBaseCurrency + ); + const totalInvestment = new Big( + msftPosition.investmentWithCurrencyEffect + ).plus(ibmPosition.investmentWithCurrencyEffect); + + expect(totalDividends.toNumber()).toBe(9.2); + expect(totalInvestment.toNumber()).toBe(500); + + // Test that portfolioSnapshot has aggregated annualizedDividendYield + expect(portfolioSnapshot).toHaveProperty('annualizedDividendYield'); + expect(portfolioSnapshot.annualizedDividendYield).toBeCloseTo(0.0184, 4); + }); }); }); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts index fe912510a..8f29c618e 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts @@ -34,6 +34,7 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { let grossPerformanceWithCurrencyEffect = new Big(0); let hasErrors = false; let netPerformance = new Big(0); + let totalDividendsInBaseCurrency = new Big(0); let totalFeesWithCurrencyEffect = new Big(0); const totalInterestWithCurrencyEffect = new Big(0); let totalInvestment = new Big(0); @@ -46,6 +47,12 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { return includeInTotalAssetValue; } )) { + if (currentPosition.dividendInBaseCurrency) { + totalDividendsInBaseCurrency = totalDividendsInBaseCurrency.plus( + currentPosition.dividendInBaseCurrency + ); + } + if (currentPosition.feeInBaseCurrency) { totalFeesWithCurrencyEffect = totalFeesWithCurrencyEffect.plus( currentPosition.feeInBaseCurrency @@ -105,6 +112,13 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { } } + // Calculate annualized dividend yield for the entire portfolio + const annualizedDividendYield = totalInvestmentWithCurrencyEffect.gt(0) + ? totalDividendsInBaseCurrency + .div(totalInvestmentWithCurrencyEffect) + .toNumber() + : 0; + return { currentValueInBaseCurrency, hasErrors, @@ -116,6 +130,7 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { activitiesCount: this.activities.filter(({ type }) => { return ['BUY', 'SELL'].includes(type); }).length, + annualizedDividendYield, createdAt: new Date(), errors: [], historicalData: [], diff --git a/apps/api/src/app/portfolio/current-rate.service.mock.ts b/apps/api/src/app/portfolio/current-rate.service.mock.ts index 4b4b8f00e..f4e76a44d 100644 --- a/apps/api/src/app/portfolio/current-rate.service.mock.ts +++ b/apps/api/src/app/portfolio/current-rate.service.mock.ts @@ -64,6 +64,15 @@ function mockGetValue(symbol: string, date: Date) { return { marketPrice: 0 }; + case 'IBM': + if (isSameDay(parseDate('2021-10-01'), date)) { + return { marketPrice: 140.5 }; + } else if (isSameDay(parseDate('2023-07-10'), date)) { + return { marketPrice: 145.2 }; + } + + return { marketPrice: 0 }; + case 'MSFT': if (isSameDay(parseDate('2021-09-16'), date)) { return { marketPrice: 89.12 }; diff --git a/apps/api/src/app/portfolio/portfolio.service.spec.ts b/apps/api/src/app/portfolio/portfolio.service.spec.ts new file mode 100644 index 000000000..ae45cb931 --- /dev/null +++ b/apps/api/src/app/portfolio/portfolio.service.spec.ts @@ -0,0 +1,77 @@ +import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator'; + +describe('PortfolioService', () => { + describe('getSummary', () => { + it('should include annualizedDividendYield from calculator snapshot', async () => { + // This test verifies that getSummary() correctly extracts + // annualizedDividendYield from the calculator snapshot + // and includes it in the returned PortfolioSummary + + // Mock calculator with annualizedDividendYield in snapshot + const mockSnapshot = { + annualizedDividendYield: 0.0184, // 1.84% + currentValueInBaseCurrency: { toNumber: () => 500 }, + totalInvestment: { toNumber: () => 500 }, + totalInvestmentWithCurrencyEffect: { toNumber: () => 500 } + }; + + const mockCalculator = { + getSnapshot: jest.fn().mockResolvedValue(mockSnapshot) + } as unknown as PortfolioCalculator; + + // Verify that the snapshot has the annualizedDividendYield + const snapshot = await mockCalculator.getSnapshot(); + expect(snapshot).toHaveProperty('annualizedDividendYield'); + expect(snapshot.annualizedDividendYield).toBe(0.0184); + + // The actual PortfolioService.getSummary() implementation should: + // 1. Call portfolioCalculator.getSnapshot() + // 2. Extract annualizedDividendYield from the snapshot + // 3. Include it in the returned PortfolioSummary + // + // Implementation in portfolio.service.ts:1867-1869: + // const { annualizedDividendYield, ... } = await portfolioCalculator.getSnapshot(); + // + // And in the return statement at line 1965: + // return { annualizedDividendYield, ... } + }); + + it('should handle zero annualizedDividendYield for portfolios without dividends', async () => { + const mockSnapshot = { + annualizedDividendYield: 0, + currentValueInBaseCurrency: { toNumber: () => 1000 }, + totalInvestment: { toNumber: () => 1000 }, + totalInvestmentWithCurrencyEffect: { toNumber: () => 1000 } + }; + + const mockCalculator = { + getSnapshot: jest.fn().mockResolvedValue(mockSnapshot) + } as unknown as PortfolioCalculator; + + const snapshot = await mockCalculator.getSnapshot(); + expect(snapshot.annualizedDividendYield).toBe(0); + }); + + it('should verify the data flow from Calculator to Service', () => { + // This test documents the expected data flow: + // + // 1. Calculator Level (portfolio-calculator.ts): + // - Calculates annualizedDividendYield for each position + // - Aggregates to portfolio-wide annualizedDividendYield in snapshot + // + // 2. Service Level (portfolio.service.ts:getSummary): + // - Calls: const { annualizedDividendYield } = await portfolioCalculator.getSnapshot() + // - Returns: { annualizedDividendYield, ...otherFields } + // + // 3. API Response (PortfolioSummary interface): + // - Client receives annualizedDividendYield as part of the summary + // + // This flow is verified by: + // - Calculator tests: portfolio-calculator-msft-buy-with-dividend.spec.ts + // - This service test: verifies extraction from snapshot + // - Integration would be tested via E2E tests (if they existed) + + expect(true).toBe(true); // Documentation test + }); + }); +}); diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index 7db743a43..645d3f4b3 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -1862,8 +1862,11 @@ export class PortfolioService { } } - const { currentValueInBaseCurrency, totalInvestment } = - await portfolioCalculator.getSnapshot(); + const { + annualizedDividendYield, + currentValueInBaseCurrency, + totalInvestment + } = await portfolioCalculator.getSnapshot(); const { performance } = await this.getPerformance({ impersonationId, @@ -1961,6 +1964,7 @@ export class PortfolioService { })?.toNumber(); return { + annualizedDividendYield, annualizedPerformancePercent, annualizedPerformancePercentWithCurrencyEffect, cash, diff --git a/libs/common/src/lib/interfaces/portfolio-summary.interface.ts b/libs/common/src/lib/interfaces/portfolio-summary.interface.ts index ccf94dcf7..3db88761a 100644 --- a/libs/common/src/lib/interfaces/portfolio-summary.interface.ts +++ b/libs/common/src/lib/interfaces/portfolio-summary.interface.ts @@ -3,6 +3,7 @@ import { PortfolioPerformance } from './portfolio-performance.interface'; export interface PortfolioSummary extends PortfolioPerformance { activityCount: number; + annualizedDividendYield: number; annualizedPerformancePercent: number; annualizedPerformancePercentWithCurrencyEffect: number; cash: number; diff --git a/libs/common/src/lib/models/portfolio-snapshot.ts b/libs/common/src/lib/models/portfolio-snapshot.ts index 6b13ca048..f4cad3bdc 100644 --- a/libs/common/src/lib/models/portfolio-snapshot.ts +++ b/libs/common/src/lib/models/portfolio-snapshot.ts @@ -10,6 +10,7 @@ import { Transform, Type } from 'class-transformer'; export class PortfolioSnapshot { activitiesCount: number; + annualizedDividendYield: number; createdAt: Date; diff --git a/libs/common/src/lib/models/timeline-position.ts b/libs/common/src/lib/models/timeline-position.ts index 244d6595e..906a91cf3 100644 --- a/libs/common/src/lib/models/timeline-position.ts +++ b/libs/common/src/lib/models/timeline-position.ts @@ -10,6 +10,7 @@ import { Transform, Type } from 'class-transformer'; export class TimelinePosition { activitiesCount: number; + annualizedDividendYield: number; @Transform(transformToBig, { toClassOnly: true }) @Type(() => Big) From 156103b880271e7f1c17044c4f820d13cd73e2d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sven=20G=C3=BCnther?= Date: Wed, 28 Jan 2026 16:39:15 +0100 Subject: [PATCH 2/6] Feature: Add annualized dividend yield to portfolio summary UI - Display annualized dividend yield as indented entry below Dividend - Only visible when experimental features are enabled --- .../portfolio-summary.component.html | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html b/apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html index 46eb2845c..0ea84e84c 100644 --- a/apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html +++ b/apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html @@ -374,4 +374,21 @@ /> + @if (user?.settings?.isExperimentalFeatures) { +
+
+ Annualized Dividend Yield +
+
+ +
+
+ } From 55ea4bc08c1a6ef4ba35dbf6408970fe224b9444 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sven=20G=C3=BCnther?= Date: Wed, 28 Jan 2026 16:41:42 +0100 Subject: [PATCH 3/6] add i18n --- apps/client/src/locales/messages.ca.xlf | 8 ++++++++ apps/client/src/locales/messages.de.xlf | 8 ++++++++ apps/client/src/locales/messages.es.xlf | 8 ++++++++ apps/client/src/locales/messages.fr.xlf | 8 ++++++++ apps/client/src/locales/messages.it.xlf | 8 ++++++++ apps/client/src/locales/messages.ko.xlf | 8 ++++++++ apps/client/src/locales/messages.nl.xlf | 8 ++++++++ apps/client/src/locales/messages.pl.xlf | 8 ++++++++ apps/client/src/locales/messages.pt.xlf | 8 ++++++++ apps/client/src/locales/messages.tr.xlf | 8 ++++++++ apps/client/src/locales/messages.uk.xlf | 8 ++++++++ apps/client/src/locales/messages.xlf | 7 +++++++ apps/client/src/locales/messages.zh.xlf | 8 ++++++++ 13 files changed, 103 insertions(+) diff --git a/apps/client/src/locales/messages.ca.xlf b/apps/client/src/locales/messages.ca.xlf index a6bec7df3..45b6866a2 100644 --- a/apps/client/src/locales/messages.ca.xlf +++ b/apps/client/src/locales/messages.ca.xlf @@ -2274,6 +2274,14 @@ 216 + + Annualized Dividend Yield + Rendiment de dividends anualitzat + + apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html + 379 + + Professional Data Provider Proveïdor de dades professional diff --git a/apps/client/src/locales/messages.de.xlf b/apps/client/src/locales/messages.de.xlf index 44ee607c3..b7cf14920 100644 --- a/apps/client/src/locales/messages.de.xlf +++ b/apps/client/src/locales/messages.de.xlf @@ -3757,6 +3757,14 @@ 34 + + Annualized Dividend Yield + Annualisierte Dividendenrendite + + apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html + 379 + + Professional Data Provider Professioneller Datenanbieter diff --git a/apps/client/src/locales/messages.es.xlf b/apps/client/src/locales/messages.es.xlf index 121e9e949..afb8e288e 100644 --- a/apps/client/src/locales/messages.es.xlf +++ b/apps/client/src/locales/messages.es.xlf @@ -3734,6 +3734,14 @@ 32 + + Annualized Dividend Yield + Rendimiento de dividendos anualizado + + apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html + 379 + + Professional Data Provider Proveedor de datos profesional diff --git a/apps/client/src/locales/messages.fr.xlf b/apps/client/src/locales/messages.fr.xlf index 0da1c3f6a..d97abbcac 100644 --- a/apps/client/src/locales/messages.fr.xlf +++ b/apps/client/src/locales/messages.fr.xlf @@ -3733,6 +3733,14 @@ 32 + + Annualized Dividend Yield + Rendement de dividende annualisé + + apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html + 379 + + Professional Data Provider Fournisseur de Données Professionnel diff --git a/apps/client/src/locales/messages.it.xlf b/apps/client/src/locales/messages.it.xlf index 746e1fbd1..f78e2a3df 100644 --- a/apps/client/src/locales/messages.it.xlf +++ b/apps/client/src/locales/messages.it.xlf @@ -3734,6 +3734,14 @@ 32 + + Annualized Dividend Yield + Rendimento da dividendi annualizzato + + apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html + 379 + + Professional Data Provider Fornitore di dati professionale diff --git a/apps/client/src/locales/messages.ko.xlf b/apps/client/src/locales/messages.ko.xlf index 67443706b..fe0d92fd5 100644 --- a/apps/client/src/locales/messages.ko.xlf +++ b/apps/client/src/locales/messages.ko.xlf @@ -2035,6 +2035,14 @@ 216 + + Annualized Dividend Yield + 연간 배당 수익률 + + apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html + 379 + + Professional Data Provider 전문 데이터 제공자 diff --git a/apps/client/src/locales/messages.nl.xlf b/apps/client/src/locales/messages.nl.xlf index d7d0b71e8..f865c4707 100644 --- a/apps/client/src/locales/messages.nl.xlf +++ b/apps/client/src/locales/messages.nl.xlf @@ -3733,6 +3733,14 @@ 32 + + Annualized Dividend Yield + Geannualiseerd dividendrendement + + apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html + 379 + + Professional Data Provider Professionele gegevensleverancier diff --git a/apps/client/src/locales/messages.pl.xlf b/apps/client/src/locales/messages.pl.xlf index a94a76d2f..bf8992687 100644 --- a/apps/client/src/locales/messages.pl.xlf +++ b/apps/client/src/locales/messages.pl.xlf @@ -2002,6 +2002,14 @@ 216 + + Annualized Dividend Yield + Roczna stopa dywidendy + + apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html + 379 + + Professional Data Provider Profesjonalne źródło danych diff --git a/apps/client/src/locales/messages.pt.xlf b/apps/client/src/locales/messages.pt.xlf index 2bcd7c401..788a2aa7e 100644 --- a/apps/client/src/locales/messages.pt.xlf +++ b/apps/client/src/locales/messages.pt.xlf @@ -3733,6 +3733,14 @@ 32 + + Annualized Dividend Yield + Rendimento de dividendos anualizado + + apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html + 379 + + Professional Data Provider Provedor de Dados Profissionais diff --git a/apps/client/src/locales/messages.tr.xlf b/apps/client/src/locales/messages.tr.xlf index 421ef3855..cd515c861 100644 --- a/apps/client/src/locales/messages.tr.xlf +++ b/apps/client/src/locales/messages.tr.xlf @@ -1870,6 +1870,14 @@ 216 + + Annualized Dividend Yield + Yıllık Temettü Getirisi + + apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html + 379 + + Professional Data Provider Profesyonel Veri Sağlayıcı diff --git a/apps/client/src/locales/messages.uk.xlf b/apps/client/src/locales/messages.uk.xlf index 2fc389030..de461f76a 100644 --- a/apps/client/src/locales/messages.uk.xlf +++ b/apps/client/src/locales/messages.uk.xlf @@ -2478,6 +2478,14 @@ 216 + + Annualized Dividend Yield + Річна дивідендна дохідність + + apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html + 379 + + Professional Data Provider Професійний постачальник даних diff --git a/apps/client/src/locales/messages.xlf b/apps/client/src/locales/messages.xlf index a6907698d..64c91b108 100644 --- a/apps/client/src/locales/messages.xlf +++ b/apps/client/src/locales/messages.xlf @@ -1867,6 +1867,13 @@ 216 + + Annualized Dividend Yield + + apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html + 379 + + Professional Data Provider diff --git a/apps/client/src/locales/messages.zh.xlf b/apps/client/src/locales/messages.zh.xlf index 7ec9d85a0..cb60c8550 100644 --- a/apps/client/src/locales/messages.zh.xlf +++ b/apps/client/src/locales/messages.zh.xlf @@ -2011,6 +2011,14 @@ 216 + + Annualized Dividend Yield + 年化股息收益率 + + apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html + 379 + + Professional Data Provider 专业数据提供商 From a5b5166bef5615f68d0a94dada8fd7c438d9a67a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sven=20G=C3=BCnther?= Date: Wed, 28 Jan 2026 16:43:37 +0100 Subject: [PATCH 4/6] changelog update --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b0abeea63..b9b45c688 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Added + +- Added the annualized dividend yield to the portfolio summary (experimental) + ### Changed - Improved the usability of the create asset profile dialog in the market data section of the admin control panel From aaffd20a423c04fd91435507be44f30442f4ca74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sven=20G=C3=BCnther?= Date: Thu, 29 Jan 2026 08:48:59 +0100 Subject: [PATCH 5/6] fix yearly boundary --- ...-calculator-msft-buy-with-dividend.spec.ts | 163 ++++++++++++++++++ .../calculator/roai/portfolio-calculator.ts | 15 +- 2 files changed, 172 insertions(+), 6 deletions(-) diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-with-dividend.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-with-dividend.spec.ts index 051b75e9b..0f68c1358 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-with-dividend.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-with-dividend.spec.ts @@ -526,5 +526,168 @@ describe('PortfolioCalculator', () => { expect(portfolioSnapshot).toHaveProperty('annualizedDividendYield'); expect(portfolioSnapshot.annualizedDividendYield).toBeCloseTo(0.0184, 4); }); + + it('ignores dividends older than 12 months when aggregating portfolio yield', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2023-07-10').getTime()); + + const activities: Activity[] = [ + // MSFT: 1 share @ 300, 3 dividends total (one older than 12 months) + { + ...activityDummyData, + date: new Date('2021-09-16'), + feeInAssetProfileCurrency: 0, + feeInBaseCurrency: 0, + quantity: 1, + SymbolProfile: { + ...symbolProfileDummyData, + currency: 'USD', + dataSource: 'YAHOO', + name: 'Microsoft Inc.', + symbol: 'MSFT' + }, + type: 'BUY', + unitPriceInAssetProfileCurrency: 300 + }, + { + ...activityDummyData, + date: new Date('2021-11-16'), + feeInAssetProfileCurrency: 0, + feeInBaseCurrency: 0, + quantity: 1, + SymbolProfile: { + ...symbolProfileDummyData, + currency: 'USD', + dataSource: 'YAHOO', + name: 'Microsoft Inc.', + symbol: 'MSFT' + }, + type: 'DIVIDEND', + unitPriceInAssetProfileCurrency: 0.62 + }, + { + ...activityDummyData, + date: new Date('2022-08-16'), + feeInAssetProfileCurrency: 0, + feeInBaseCurrency: 0, + quantity: 1, + SymbolProfile: { + ...symbolProfileDummyData, + currency: 'USD', + dataSource: 'YAHOO', + name: 'Microsoft Inc.', + symbol: 'MSFT' + }, + type: 'DIVIDEND', + unitPriceInAssetProfileCurrency: 0.65 + }, + { + ...activityDummyData, + date: new Date('2023-05-16'), + feeInAssetProfileCurrency: 0, + feeInBaseCurrency: 0, + quantity: 1, + SymbolProfile: { + ...symbolProfileDummyData, + currency: 'USD', + dataSource: 'YAHOO', + name: 'Microsoft Inc.', + symbol: 'MSFT' + }, + type: 'DIVIDEND', + unitPriceInAssetProfileCurrency: 0.65 + }, + // IBM: 1 share @ 200, 2 dividends total (one older than 12 months) + { + ...activityDummyData, + date: new Date('2021-10-01'), + feeInAssetProfileCurrency: 0, + feeInBaseCurrency: 0, + quantity: 1, + SymbolProfile: { + ...symbolProfileDummyData, + currency: 'USD', + dataSource: 'YAHOO', + name: 'IBM', + symbol: 'IBM' + }, + type: 'BUY', + unitPriceInAssetProfileCurrency: 200 + }, + { + ...activityDummyData, + date: new Date('2022-06-01'), + feeInAssetProfileCurrency: 0, + feeInBaseCurrency: 0, + quantity: 1, + SymbolProfile: { + ...symbolProfileDummyData, + currency: 'USD', + dataSource: 'YAHOO', + name: 'IBM', + symbol: 'IBM' + }, + type: 'DIVIDEND', + unitPriceInAssetProfileCurrency: 1.65 + }, + { + ...activityDummyData, + date: new Date('2023-06-01'), + feeInAssetProfileCurrency: 0, + feeInBaseCurrency: 0, + quantity: 1, + SymbolProfile: { + ...symbolProfileDummyData, + currency: 'USD', + dataSource: 'YAHOO', + name: 'IBM', + symbol: 'IBM' + }, + type: 'DIVIDEND', + unitPriceInAssetProfileCurrency: 1.65 + } + ]; + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.ROAI, + currency: 'USD', + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); + + const msftPosition = portfolioSnapshot.positions.find( + ({ symbol }) => symbol === 'MSFT' + ); + const ibmPosition = portfolioSnapshot.positions.find( + ({ symbol }) => symbol === 'IBM' + ); + + expect(msftPosition.dividendInBaseCurrency).toEqual(new Big('1.92')); + expect(ibmPosition.dividendInBaseCurrency).toEqual(new Big('3.3')); + + const msftDividendLast12Months = new Big('1.3'); + const ibmDividendLast12Months = new Big('1.65'); + const totalInvestment = new Big('500'); + + expect(msftPosition.annualizedDividendYield).toBeCloseTo( + msftDividendLast12Months.div(new Big('300')).toNumber(), + 6 + ); + expect(ibmPosition.annualizedDividendYield).toBeCloseTo( + ibmDividendLast12Months.div(new Big('200')).toNumber(), + 6 + ); + + const expectedAnnualizedDividendYield = msftDividendLast12Months + .plus(ibmDividendLast12Months) + .div(totalInvestment) + .toNumber(); + + expect(portfolioSnapshot.annualizedDividendYield).toBeCloseTo( + expectedAnnualizedDividendYield, + 6 + ); + }); }); }); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts index 8f29c618e..a971d1c8e 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts @@ -34,7 +34,7 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { let grossPerformanceWithCurrencyEffect = new Big(0); let hasErrors = false; let netPerformance = new Big(0); - let totalDividendsInBaseCurrency = new Big(0); + let totalDividendsLast12MonthsInBaseCurrency = new Big(0); let totalFeesWithCurrencyEffect = new Big(0); const totalInterestWithCurrencyEffect = new Big(0); let totalInvestment = new Big(0); @@ -47,10 +47,13 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { return includeInTotalAssetValue; } )) { - if (currentPosition.dividendInBaseCurrency) { - totalDividendsInBaseCurrency = totalDividendsInBaseCurrency.plus( - currentPosition.dividendInBaseCurrency - ); + if (currentPosition.investmentWithCurrencyEffect) { + totalDividendsLast12MonthsInBaseCurrency = + totalDividendsLast12MonthsInBaseCurrency.plus( + new Big(currentPosition.annualizedDividendYield ?? 0).mul( + currentPosition.investmentWithCurrencyEffect + ) + ); } if (currentPosition.feeInBaseCurrency) { @@ -114,7 +117,7 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { // Calculate annualized dividend yield for the entire portfolio const annualizedDividendYield = totalInvestmentWithCurrencyEffect.gt(0) - ? totalDividendsInBaseCurrency + ? totalDividendsLast12MonthsInBaseCurrency .div(totalInvestmentWithCurrencyEffect) .toNumber() : 0; From e88e4d292a6c52928ce9331ecfad3fa081f25dbf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sven=20G=C3=BCnther?= Date: Thu, 29 Jan 2026 08:59:08 +0100 Subject: [PATCH 6/6] Replaced the placeholder spec with a real unit test that instantiates PortfolioService, stubs dependencies, calls getSummary, and asserts the returned summary fields (including annualizedDividendYield and computed totals). The new test lives in apps/api/src/app/portfolio/portfolio.service.spec.ts. --- .../app/portfolio/portfolio.service.spec.ts | 221 +++++++++++++----- 1 file changed, 160 insertions(+), 61 deletions(-) diff --git a/apps/api/src/app/portfolio/portfolio.service.spec.ts b/apps/api/src/app/portfolio/portfolio.service.spec.ts index ae45cb931..3fe6438dd 100644 --- a/apps/api/src/app/portfolio/portfolio.service.spec.ts +++ b/apps/api/src/app/portfolio/portfolio.service.spec.ts @@ -1,77 +1,176 @@ import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator'; +import { + activityDummyData, + symbolProfileDummyData, + userDummyData +} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; +import { DEFAULT_CURRENCY } from '@ghostfolio/common/config'; +import { Activity } from '@ghostfolio/common/interfaces'; +import { RequestWithUser } from '@ghostfolio/common/types'; + +import { Type as ActivityType } from '@prisma/client'; +import { Big } from 'big.js'; + +import { PortfolioService } from './portfolio.service'; describe('PortfolioService', () => { describe('getSummary', () => { - it('should include annualizedDividendYield from calculator snapshot', async () => { - // This test verifies that getSummary() correctly extracts - // annualizedDividendYield from the calculator snapshot - // and includes it in the returned PortfolioSummary - - // Mock calculator with annualizedDividendYield in snapshot - const mockSnapshot = { - annualizedDividendYield: 0.0184, // 1.84% - currentValueInBaseCurrency: { toNumber: () => 500 }, - totalInvestment: { toNumber: () => 500 }, - totalInvestmentWithCurrencyEffect: { toNumber: () => 500 } + beforeEach(() => { + jest.useFakeTimers().setSystemTime(new Date('2023-07-10')); + }); + + afterEach(() => { + jest.useRealTimers(); + jest.restoreAllMocks(); + }); + + it('returns annualizedDividendYield from the calculator snapshot', async () => { + const activities: Activity[] = [ + { + ...activityDummyData, + currency: 'USD', + date: new Date('2023-06-01'), + feeInAssetProfileCurrency: 0, + feeInBaseCurrency: 0, + quantity: 2, + SymbolProfile: { + ...symbolProfileDummyData, + currency: 'USD', + dataSource: 'YAHOO', + name: 'Microsoft Inc.', + symbol: 'MSFT' + }, + type: ActivityType.BUY, + unitPrice: 50, + unitPriceInAssetProfileCurrency: 50, + value: 100, + valueInBaseCurrency: 100 + }, + { + ...activityDummyData, + currency: 'USD', + date: new Date('2023-06-02'), + feeInAssetProfileCurrency: 0, + feeInBaseCurrency: 0, + quantity: 1, + SymbolProfile: { + ...symbolProfileDummyData, + currency: 'USD', + dataSource: 'YAHOO', + name: 'Microsoft Inc.', + symbol: 'MSFT' + }, + type: ActivityType.SELL, + unitPrice: 40, + unitPriceInAssetProfileCurrency: 40, + value: 40, + valueInBaseCurrency: 40 + } + ]; + + const exchangeRateDataService = { + toCurrency: jest.fn((value: number) => value) }; - const mockCalculator = { - getSnapshot: jest.fn().mockResolvedValue(mockSnapshot) - } as unknown as PortfolioCalculator; + const orderService = { + getOrders: jest.fn().mockResolvedValue({ activities }) + }; - // Verify that the snapshot has the annualizedDividendYield - const snapshot = await mockCalculator.getSnapshot(); - expect(snapshot).toHaveProperty('annualizedDividendYield'); - expect(snapshot.annualizedDividendYield).toBe(0.0184); - - // The actual PortfolioService.getSummary() implementation should: - // 1. Call portfolioCalculator.getSnapshot() - // 2. Extract annualizedDividendYield from the snapshot - // 3. Include it in the returned PortfolioSummary - // - // Implementation in portfolio.service.ts:1867-1869: - // const { annualizedDividendYield, ... } = await portfolioCalculator.getSnapshot(); - // - // And in the return statement at line 1965: - // return { annualizedDividendYield, ... } - }); + const userService = { + user: jest.fn().mockResolvedValue({ + id: userDummyData.id, + settings: { + settings: { + baseCurrency: DEFAULT_CURRENCY, + emergencyFund: 0 + } + } + }) + }; + + const accountService = { + getCashDetails: jest.fn().mockResolvedValue({ + balanceInBaseCurrency: 1000 + }) + }; - it('should handle zero annualizedDividendYield for portfolios without dividends', async () => { - const mockSnapshot = { - annualizedDividendYield: 0, - currentValueInBaseCurrency: { toNumber: () => 1000 }, - totalInvestment: { toNumber: () => 1000 }, - totalInvestmentWithCurrencyEffect: { toNumber: () => 1000 } + const impersonationService = { + validateImpersonationId: jest.fn().mockResolvedValue(undefined) }; - const mockCalculator = { - getSnapshot: jest.fn().mockResolvedValue(mockSnapshot) + const request = { + user: { + id: userDummyData.id, + settings: { settings: { baseCurrency: DEFAULT_CURRENCY } } + } + } as RequestWithUser; + + const portfolioCalculator = { + getDividendInBaseCurrency: jest.fn().mockResolvedValue(new Big(12)), + getFeesInBaseCurrency: jest.fn().mockResolvedValue(new Big(4)), + getInterestInBaseCurrency: jest.fn().mockResolvedValue(new Big(1)), + getLiabilitiesInBaseCurrency: jest.fn().mockResolvedValue(new Big(6)), + getSnapshot: jest.fn().mockResolvedValue({ + annualizedDividendYield: 0.0123, + currentValueInBaseCurrency: new Big(500), + totalInvestment: new Big(400) + }), + getStartDate: jest.fn().mockReturnValue(new Date('2023-01-01')) } as unknown as PortfolioCalculator; - const snapshot = await mockCalculator.getSnapshot(); - expect(snapshot.annualizedDividendYield).toBe(0); - }); + const service = new PortfolioService( + {} as any, + accountService as any, + {} as any, + {} as any, + {} as any, + exchangeRateDataService as any, + {} as any, + impersonationService as any, + orderService as any, + request, + {} as any, + {} as any, + userService as any + ); + + jest.spyOn(service, 'getPerformance').mockResolvedValue({ + performance: { + netPerformance: 20, + netPerformancePercentage: 0.05, + netPerformancePercentageWithCurrencyEffect: 0.05, + netPerformanceWithCurrencyEffect: 20 + } + } as any); + + const summary = await (service as any).getSummary({ + balanceInBaseCurrency: 1000, + emergencyFundHoldingsValueInBaseCurrency: 0, + filteredValueInBaseCurrency: new Big(200), + impersonationId: userDummyData.id, + portfolioCalculator, + userCurrency: DEFAULT_CURRENCY, + userId: userDummyData.id + }); - it('should verify the data flow from Calculator to Service', () => { - // This test documents the expected data flow: - // - // 1. Calculator Level (portfolio-calculator.ts): - // - Calculates annualizedDividendYield for each position - // - Aggregates to portfolio-wide annualizedDividendYield in snapshot - // - // 2. Service Level (portfolio.service.ts:getSummary): - // - Calls: const { annualizedDividendYield } = await portfolioCalculator.getSnapshot() - // - Returns: { annualizedDividendYield, ...otherFields } - // - // 3. API Response (PortfolioSummary interface): - // - Client receives annualizedDividendYield as part of the summary - // - // This flow is verified by: - // - Calculator tests: portfolio-calculator-msft-buy-with-dividend.spec.ts - // - This service test: verifies extraction from snapshot - // - Integration would be tested via E2E tests (if they existed) - - expect(true).toBe(true); // Documentation test + expect(portfolioCalculator.getSnapshot).toHaveBeenCalledTimes(1); + expect(summary).toMatchObject({ + annualizedDividendYield: 0.0123, + cash: 1000, + committedFunds: 60, + dividendInBaseCurrency: 12, + fees: 4, + grossPerformance: 24, + grossPerformanceWithCurrencyEffect: 24, + interestInBaseCurrency: 1, + liabilitiesInBaseCurrency: 6, + totalBuy: 100, + totalInvestment: 400, + totalSell: 40, + totalValueInBaseCurrency: 1494 + }); + expect(summary.activityCount).toBe(2); + expect(summary.dateOfFirstActivity).toEqual(new Date('2023-01-01')); }); }); });