Browse Source

Bugfix/carry forward of market prices between chart dates (#8009)

* Carry forward of market prices between chart dates

* Update changelog
pull/7550/merge
Thomas Kaul 5 days ago
committed by GitHub
parent
commit
beb8f9de78
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 4
      CHANGELOG.md
  2. 33
      apps/api/src/app/portfolio/calculator/portfolio-calculator.ts
  3. 170
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-manual-buy-with-market-price-between-chart-dates.spec.ts
  4. 14
      apps/api/src/app/portfolio/current-rate.service.mock.ts

4
CHANGELOG.md

@ -13,6 +13,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Upgraded `ai` from version `7.0.37` to `7.0.114` - Upgraded `ai` from version `7.0.37` to `7.0.114`
- Upgraded `dotenv` from version `17.4.2` to `18.0.3` - Upgraded `dotenv` from version `17.4.2` to `18.0.3`
### Fixed
- Fixed the portfolio calculation for holdings with historical market prices between the chart dates
## 3.77.0 - 2026-10-02 ## 3.77.0 - 2026-10-02
### Added ### Added

33
apps/api/src/app/portfolio/calculator/portfolio-calculator.ts

@ -791,6 +791,16 @@ export abstract class PortfolioCalculator {
activitiesByDate[activity.date].push(activity); activitiesByDate[activity.date].push(activity);
} }
// Carry forward the market prices of all dates, not only of the chart
// dates, as sparse market data (e.g. MANUAL) can be between two chart dates
const marketPriceDates = Object.keys(marketSymbolMap)
.filter((date) => {
return !!marketSymbolMap[date][assetProfileIdentifier];
})
.sort();
let indexOfMarketPriceDate = 0;
for (const dateString of chartDates) { for (const dateString of chartDates) {
if (dateString < startDateString) { if (dateString < startDateString) {
continue; continue;
@ -798,6 +808,18 @@ export abstract class PortfolioCalculator {
break; break;
} }
while (
indexOfMarketPriceDate < marketPriceDates.length &&
marketPriceDates[indexOfMarketPriceDate] <= dateString
) {
lastMarketPrice =
marketSymbolMap[marketPriceDates[indexOfMarketPriceDate]][
assetProfileIdentifier
];
indexOfMarketPriceDate += 1;
}
const activitiesOfDate = activitiesByDate[dateString]; const activitiesOfDate = activitiesByDate[dateString];
if (!lastMarketPrice && activitiesOfDate?.length > 0) { if (!lastMarketPrice && activitiesOfDate?.length > 0) {
@ -808,13 +830,8 @@ export abstract class PortfolioCalculator {
} }
} }
const marketPrice = marketSymbolMap[dateString]?.[assetProfileIdentifier];
const unitPrice = const unitPrice =
marketPrice ?? lastMarketPrice ?? lastActivityUnitPrice ?? unitPriceAtEndDate;
lastMarketPrice ??
lastActivityUnitPrice ??
unitPriceAtEndDate;
if (activitiesOfDate?.length > 0) { if (activitiesOfDate?.length > 0) {
for (const activity of activitiesOfDate) { for (const activity of activitiesOfDate) {
@ -832,10 +849,6 @@ export abstract class PortfolioCalculator {
unitPriceFromMarketData: unitPrice unitPriceFromMarketData: unitPrice
}); });
} }
if (marketPrice) {
lastMarketPrice = marketPrice;
}
} }
// Sort the activities so that the start and end placeholder activities // Sort the activities so that the start and end placeholder activities

170
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-manual-buy-with-market-price-between-chart-dates.spec.ts

@ -0,0 +1,170 @@
import {
activityDummyData,
assetProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper';
import { Activity } from '@ghostfolio/common/interfaces';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Big } from 'big.js';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
};
});
jest.mock(
'@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service',
() => {
return {
PortfolioSnapshotService: jest.fn().mockImplementation(() => {
return PortfolioSnapshotServiceMock;
})
};
}
);
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock;
})
};
});
describe('PortfolioCalculator', () => {
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
let portfolioCalculatorFactory: PortfolioCalculatorFactory;
let portfolioSnapshotService: PortfolioSnapshotService;
let redisCacheService: RedisCacheService;
beforeEach(() => {
PortfolioSnapshotServiceMock.reset();
RedisCacheServiceMock.reset();
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
null,
null,
null,
null
);
portfolioSnapshotService = new PortfolioSnapshotService(null, null);
redisCacheService = new RedisCacheService(null, null);
portfolioCalculatorFactory = new PortfolioCalculatorFactory(
configurationService,
currentRateService,
exchangeRateDataService,
portfolioSnapshotService,
redisCacheService
);
});
describe('get current positions', () => {
it('with MANUAL buy and market price between two chart dates', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2024-01-31').getTime());
const activities: Activity[] = [
{
...activityDummyData,
assetProfile: {
...assetProfileDummyData,
currency: 'USD',
dataSource: 'MANUAL',
name: 'Private Investment',
symbol: '6c0c5cee-0208-4975-b473-03baf2518497'
},
date: parseDate('2021-01-04'),
feeInAssetProfileCurrency: 0,
feeInBaseCurrency: 0,
quantity: 10,
type: 'BUY',
unitPriceInAssetProfileCurrency: 50
}
];
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.ROAI,
currency: 'USD',
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
const historicalDataByDate = Object.fromEntries(
portfolioSnapshot.historicalData.map((historicalDataItem) => {
return [historicalDataItem.date, historicalDataItem];
})
);
/**
* The only historical market prices are on 2023-06-14 and 2023-06-15,
* which are not chart dates (every third day from 2021-01-03)
*/
expect(historicalDataByDate['2023-06-14']).toBeUndefined();
expect(historicalDataByDate['2023-06-15']).toBeUndefined();
/**
* The chart dates before the market prices use the unit price of the
* activity: 50
*/
expect(historicalDataByDate['2023-06-13']).toMatchObject({
netPerformance: 0, // 10 * (50 - 50) = 0
totalInvestment: 500,
value: 500 // 10 * 50 = 500
});
/**
* The chart dates after the market prices carry the latest one forward
* (100), not the first one (80)
*/
expect(historicalDataByDate['2023-06-16']).toMatchObject({
netPerformance: 500, // 10 * (100 - 50) = 500
totalInvestment: 500,
value: 1000 // 10 * 100 = 1000
});
expect(historicalDataByDate['2024-01-30']).toMatchObject({
netPerformance: 500, // 10 * (100 - 50) = 500
totalInvestment: 500,
value: 1000 // 10 * 100 = 1000
});
/**
* The market price is unchanged since 2023-06-15, hence there is no
* performance today
*/
expect(portfolioSnapshot.positions[0]).toMatchObject({
netPerformancePercentageWithCurrencyEffectMap: {
'1d': new Big(0)
},
netPerformanceWithCurrencyEffectMap: {
'1d': new Big(0),
max: new Big(500) // 10 * (100 - 50) = 500
}
});
});
});
});

14
apps/api/src/app/portfolio/current-rate.service.mock.ts

@ -30,6 +30,20 @@ function mockGetValue({
return { marketPrice: 0 }; return { marketPrice: 0 };
case '6c0c5cee-0208-4975-b473-03baf2518497':
// Sparse market data with the quote of today, which falls back to the
// latest market price
if (isSameDay(parseDate('2023-06-14'), date)) {
return { marketPrice: 80 };
} else if (
isSameDay(parseDate('2023-06-15'), date) ||
isSameDay(parseDate('2024-01-31'), date)
) {
return { marketPrice: 100 };
}
return { marketPrice: 0 };
case 'BALN.SW': case 'BALN.SW':
if (isSameDay(parseDate('2021-11-12'), date)) { if (isSameDay(parseDate('2021-11-12'), date)) {
return { marketPrice: 146 }; return { marketPrice: 146 };

Loading…
Cancel
Save