From e5baa1acd5286f0d74f17a4321ad4470d85bfcfc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcin=20Szyma=C5=84ski?= Date: Thu, 4 Sep 2025 19:00:09 +0100 Subject: [PATCH] Bugfix/average price calculation for buy and sell activities of short positions (#5416) * Fix average price calculation for buy and sell activities of short positions * Update changelog --- CHANGELOG.md | 1 + .../calculator/portfolio-calculator.ts | 30 ++-- .../portfolio-calculator-btcusd-short.spec.ts | 132 ++++++++++++++++++ test/import/ok/btcusd-short.json | 42 ++++++ 4 files changed, 196 insertions(+), 9 deletions(-) create mode 100644 apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-short.spec.ts create mode 100644 test/import/ok/btcusd-short.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 46c447792..20250acba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Fixed an issue in the average price calculation for buy and sell activities of short positions - Fixed the number of attempts in the queue jobs view of the admin control panel ## 2.195.0 - 2025-08-29 diff --git a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts index 78323a332..e4d9cdfe8 100644 --- a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts @@ -927,13 +927,25 @@ export abstract class PortfolioCalculator { .plus(oldAccumulatedSymbol.quantity); if (type === 'BUY') { - investment = oldAccumulatedSymbol.investment.plus( - quantity.mul(unitPrice) - ); + if (oldAccumulatedSymbol.investment.gte(0)) { + investment = oldAccumulatedSymbol.investment.plus( + quantity.mul(unitPrice) + ); + } else { + investment = oldAccumulatedSymbol.investment.plus( + quantity.mul(oldAccumulatedSymbol.averagePrice) + ); + } } else if (type === 'SELL') { - investment = oldAccumulatedSymbol.investment.minus( - quantity.mul(oldAccumulatedSymbol.averagePrice) - ); + if (oldAccumulatedSymbol.investment.gt(0)) { + investment = oldAccumulatedSymbol.investment.minus( + quantity.mul(oldAccumulatedSymbol.averagePrice) + ); + } else { + investment = oldAccumulatedSymbol.investment.minus( + quantity.mul(unitPrice) + ); + } } currentTransactionPointItem = { @@ -942,9 +954,9 @@ export abstract class PortfolioCalculator { investment, skipErrors, symbol, - averagePrice: newQuantity.gt(0) - ? investment.div(newQuantity) - : new Big(0), + averagePrice: newQuantity.eq(0) + ? new Big(0) + : investment.div(newQuantity).abs(), dividend: new Big(0), fee: oldAccumulatedSymbol.fee.plus(fee), firstBuyDate: oldAccumulatedSymbol.firstBuyDate, diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-short.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-short.spec.ts new file mode 100644 index 000000000..ce1cf3681 --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-short.spec.ts @@ -0,0 +1,132 @@ +import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto'; +import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; +import { + activityDummyData, + loadActivityExportFile, + symbolProfileDummyData, + 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 { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; + +import { Tag } from '@prisma/client'; +import { Big } from 'big.js'; +import { join } from 'path'; + +jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + CurrentRateService: jest.fn().mockImplementation(() => { + return CurrentRateServiceMock; + }) + }; +}); + +jest.mock( + '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service', + () => { + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + PortfolioSnapshotService: jest.fn().mockImplementation(() => { + return PortfolioSnapshotServiceMock; + }) + }; + } +); + +jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + RedisCacheService: jest.fn().mockImplementation(() => { + return RedisCacheServiceMock; + }) + }; +}); + +describe('PortfolioCalculator', () => { + let activityDtos: CreateOrderDto[]; + + let configurationService: ConfigurationService; + let currentRateService: CurrentRateService; + let exchangeRateDataService: ExchangeRateDataService; + let portfolioCalculatorFactory: PortfolioCalculatorFactory; + let portfolioSnapshotService: PortfolioSnapshotService; + let redisCacheService: RedisCacheService; + + beforeAll(() => { + activityDtos = loadActivityExportFile( + join(__dirname, '../../../../../../../test/import/ok/btcusd-short.json') + ); + }); + + beforeEach(() => { + configurationService = new ConfigurationService(); + + currentRateService = new CurrentRateService(null, null, null, null); + + exchangeRateDataService = new ExchangeRateDataService( + null, + null, + null, + null + ); + + portfolioSnapshotService = new PortfolioSnapshotService(null); + + redisCacheService = new RedisCacheService(null, null); + + portfolioCalculatorFactory = new PortfolioCalculatorFactory( + configurationService, + currentRateService, + exchangeRateDataService, + portfolioSnapshotService, + redisCacheService + ); + }); + + describe('get current positions', () => { + it.only('with BTCUSD short sell (in USD)', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2022-01-14').getTime()); + + const activities: Activity[] = activityDtos.map((activity) => ({ + ...activityDummyData, + ...activity, + date: parseDate(activity.date), + feeInAssetProfileCurrency: activity.fee, + SymbolProfile: { + ...symbolProfileDummyData, + currency: 'USD', + dataSource: activity.dataSource, + name: 'Bitcoin', + symbol: activity.symbol + }, + tags: activity.tags?.map((id) => { + return { id } as Tag; + }), + unitPriceInAssetProfileCurrency: activity.unitPrice + })); + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.ROAI, + currency: 'USD', + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); + + expect(portfolioSnapshot.positions[0].averagePrice).toEqual( + Big(45647.95) + ); + }); + }); +}); diff --git a/test/import/ok/btcusd-short.json b/test/import/ok/btcusd-short.json new file mode 100644 index 000000000..bc4152de9 --- /dev/null +++ b/test/import/ok/btcusd-short.json @@ -0,0 +1,42 @@ +{ + "meta": { + "date": "2021-12-12T00:00:00.000Z", + "version": "dev" + }, + "accounts": [], + "platforms": [], + "tags": [], + "activities": [ + { + "accountId": null, + "comment": null, + "fee": 4.46, + "quantity": 1, + "type": "SELL", + "unitPrice": 44558.42, + "currency": "USD", + "dataSource": "YAHOO", + "date": "2021-12-12T00:00:00.000Z", + "symbol": "BTCUSD", + "tags": [] + }, + { + "accountId": null, + "comment": null, + "fee": 4.46, + "quantity": 1, + "type": "SELL", + "unitPrice": 46737.48, + "currency": "USD", + "dataSource": "YAHOO", + "date": "2021-12-13T00:00:00.000Z", + "symbol": "BTCUSD", + "tags": [] + } + ], + "user": { + "settings": { + "currency": "USD" + } + } +}