Thomas Kaul 1 day ago
committed by GitHub
parent
commit
c1c39aaf7f
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 1
      CHANGELOG.md
  2. 18
      apps/api/src/app/activities/activities.service.ts
  3. 7
      apps/api/src/app/endpoints/public/public.controller.ts
  4. 12
      apps/api/src/app/export/export.service.ts
  5. 38
      apps/api/src/app/import/import.service.ts
  6. 2
      apps/api/src/app/portfolio/calculator/portfolio-calculator-test-utils.ts
  7. 4
      apps/api/src/app/portfolio/calculator/portfolio-calculator.ts
  8. 26
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-buy.spec.ts
  9. 462
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell-in-two-activities.spec.ts
  10. 26
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell.spec.ts
  11. 38
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy.spec.ts
  12. 14
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btceur-in-base-currency-eur.spec.ts
  13. 14
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btceur.spec.ts
  14. 26
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts
  15. 12
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-short.spec.ts
  16. 12
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd.spec.ts
  17. 14
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-fee.spec.ts
  18. 14
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-googl-buy.spec.ts
  19. 380
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-jnug-buy-and-sell-and-buy-and-sell.spec.ts
  20. 14
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-liability.spec.ts
  21. 38
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-and-sell.spec.ts
  22. 26
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-with-dividend.spec.ts
  23. 12
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell-partially.spec.ts
  24. 528
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell.spec.ts
  25. 14
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-valuable.spec.ts
  26. 2
      apps/api/src/app/portfolio/interfaces/portfolio-order.interface.ts
  27. 6
      apps/api/src/app/portfolio/portfolio.service.spec.ts
  28. 18
      apps/api/src/app/portfolio/portfolio.service.ts
  29. 7
      apps/api/src/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor.ts
  30. 2
      apps/client/src/app/pages/portfolio/activities/activities-page.component.ts
  31. 30
      apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts
  32. 4
      apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/interfaces/interfaces.ts
  33. 8
      apps/client/src/app/services/import-activities.service.ts
  34. 12
      libs/common/src/lib/config.ts
  35. 8
      libs/common/src/lib/interfaces/activities.interface.ts
  36. 6
      libs/common/src/lib/interfaces/responses/public-portfolio-response.interface.ts
  37. 16
      libs/ui/src/lib/activities-table/activities-table.component.html
  38. 10
      libs/ui/src/lib/activities-table/activities-table.component.stories.ts
  39. 4
      libs/ui/src/lib/activities-table/activities-table.component.ts

1
CHANGELOG.md

@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- Set the change detection strategy to `OnPush` in the _X-ray_ page
- Deprecated `SymbolProfile` in favor of `assetProfile` in the activity interface
## 3.25.0 - 2026-07-12

18
apps/api/src/app/activities/activities.service.ts

@ -455,6 +455,23 @@ export class ActivitiesService {
userId,
accountId: account.id,
accountUserId: account.userId,
assetProfile: {
activitiesCount: 0,
assetClass: AssetClass.LIQUIDITY,
assetSubClass: AssetSubClass.CASH,
countries: [],
createdAt: new Date(balanceItem.date),
currency: account.currency,
dataSource:
this.dataProviderService.getDataSourceForExchangeRates(),
holdings: [],
id: account.currency,
isActive: true,
name: account.currency,
sectors: [],
symbol: account.currency,
updatedAt: new Date(balanceItem.date)
},
comment: account.name,
createdAt: new Date(balanceItem.date),
currency: account.currency,
@ -823,6 +840,7 @@ export class ActivitiesService {
return {
...order,
assetProfile,
feeInAssetProfileCurrency,
feeInBaseCurrency,
unitPriceInAssetProfileCurrency,

7
apps/api/src/app/endpoints/public/public.controller.ts

@ -110,26 +110,27 @@ export class PublicController {
? []
: activities.map(
({
assetProfile,
currency,
date,
fee,
quantity,
SymbolProfile,
type,
unitPrice,
value,
valueInBaseCurrency
}) => {
return {
assetProfile,
currency,
date,
fee,
quantity,
SymbolProfile,
type,
unitPrice,
value,
valueInBaseCurrency
valueInBaseCurrency,
SymbolProfile: assetProfile
};
}
);

12
apps/api/src/app/export/export.service.ts

@ -124,8 +124,8 @@ export class ExportService {
const customAssetProfiles = uniqBy(
activities
.map(({ SymbolProfile }) => {
return SymbolProfile;
.map(({ assetProfile }) => {
return assetProfile;
})
.filter(({ userId: assetProfileUserId }) => {
return assetProfileUserId === userId;
@ -224,13 +224,13 @@ export class ExportService {
activities: activities.map(
({
accountId,
assetProfile,
comment,
currency,
date,
fee,
id,
quantity,
SymbolProfile,
tags: currentTags,
type,
unitPrice
@ -243,10 +243,10 @@ export class ExportService {
quantity,
type,
unitPrice,
currency: currency ?? SymbolProfile.currency,
dataSource: SymbolProfile.dataSource,
currency: currency ?? assetProfile.currency,
dataSource: assetProfile.dataSource,
date: date.toISOString(),
symbol: SymbolProfile.symbol,
symbol: assetProfile.symbol,
tags: currentTags.map(({ id: tagId }) => {
return tagId;
})

38
apps/api/src/app/import/import.service.ts

@ -122,11 +122,11 @@ export class ImportService {
const isDuplicate = activities.some((activity) => {
return (
activity.accountId === account?.id &&
activity.SymbolProfile.currency === assetProfile.currency &&
activity.SymbolProfile.dataSource === assetProfile.dataSource &&
activity.assetProfile.currency === assetProfile.currency &&
activity.assetProfile.dataSource === assetProfile.dataSource &&
isSameSecond(activity.date, date) &&
activity.quantity === quantity &&
activity.SymbolProfile.symbol === assetProfile.symbol &&
activity.assetProfile.symbol === assetProfile.symbol &&
activity.type === 'DIVIDEND' &&
activity.unitPrice === marketPrice
);
@ -138,6 +138,7 @@ export class ImportService {
return {
account,
assetProfile,
date,
error,
quantity,
@ -463,19 +464,18 @@ export class ImportService {
const error = activity.error;
const fee = activity.fee;
const quantity = activity.quantity;
const SymbolProfile = activity.SymbolProfile;
const tagIds = activity.tagIds ?? [];
const type = activity.type;
const unitPrice = activity.unitPrice;
const assetProfile = assetProfiles[
getAssetProfileIdentifier({
dataSource: SymbolProfile.dataSource,
symbol: SymbolProfile.symbol
dataSource: activity.assetProfile.dataSource,
symbol: activity.assetProfile.symbol
})
] ?? {
dataSource: SymbolProfile.dataSource,
symbol: SymbolProfile.symbol
dataSource: activity.assetProfile.dataSource,
symbol: activity.assetProfile.symbol
};
const {
assetClass,
@ -619,6 +619,8 @@ export class ImportService {
activities.push({
...order,
// @ts-ignore
assetProfile,
error,
value,
valueInBaseCurrency,
@ -633,19 +635,19 @@ export class ImportService {
if (!isDryRun) {
// Gather symbol data in the background, if not dry run
const uniqueActivities = uniqBy(activities, ({ SymbolProfile }) => {
const uniqueActivities = uniqBy(activities, ({ assetProfile }) => {
return getAssetProfileIdentifier({
dataSource: SymbolProfile.dataSource,
symbol: SymbolProfile.symbol
dataSource: assetProfile.dataSource,
symbol: assetProfile.symbol
});
});
this.dataGatheringService.gatherSymbols({
dataGatheringItems: uniqueActivities.map(({ date, SymbolProfile }) => {
dataGatheringItems: uniqueActivities.map(({ assetProfile, date }) => {
return {
date,
dataSource: SymbolProfile.dataSource,
symbol: SymbolProfile.symbol
dataSource: assetProfile.dataSource,
symbol: assetProfile.symbol
};
}),
priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH
@ -692,12 +694,12 @@ export class ImportService {
activity.accountId === accountId &&
activity.comment === comment &&
(activity.currency === currency ||
activity.SymbolProfile.currency === currency) &&
activity.SymbolProfile.dataSource === dataSource &&
activity.assetProfile.currency === currency) &&
activity.assetProfile.dataSource === dataSource &&
isSameSecond(activity.date, date) &&
activity.fee === fee &&
activity.quantity === quantity &&
activity.SymbolProfile.symbol === symbol &&
activity.assetProfile.symbol === symbol &&
activity.type === type &&
activity.unitPrice === unitPrice
);
@ -717,7 +719,7 @@ export class ImportService {
quantity,
type,
unitPrice,
SymbolProfile: {
assetProfile: {
dataSource,
symbol,
activitiesCount: undefined,

2
apps/api/src/app/portfolio/calculator/portfolio-calculator-test-utils.ts

@ -22,7 +22,7 @@ export const activityDummyData = {
valueInBaseCurrency: undefined
};
export const symbolProfileDummyData = {
export const assetProfileDummyData = {
activitiesCount: undefined,
assetClass: undefined,
assetSubClass: undefined,

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

@ -121,11 +121,11 @@ export abstract class PortfolioCalculator {
this.activities = activities
.map(
({
assetProfile,
date,
feeInAssetProfileCurrency,
feeInBaseCurrency,
quantity,
SymbolProfile,
tags = [],
type,
unitPriceInAssetProfileCurrency
@ -141,9 +141,9 @@ export abstract class PortfolioCalculator {
}
return {
assetProfile,
tags,
type,
assetProfile: SymbolProfile,
date: format(date, DATE_FORMAT),
fee: new Big(feeInAssetProfileCurrency),
feeInBaseCurrency: new Big(feeInBaseCurrency),

26
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-buy.spec.ts

@ -1,6 +1,6 @@
import {
activityDummyData,
symbolProfileDummyData,
assetProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
@ -85,33 +85,33 @@ describe('PortfolioCalculator', () => {
const activities: Activity[] = [
{
...activityDummyData,
date: new Date('2021-11-22'),
feeInAssetProfileCurrency: 1.55,
feeInBaseCurrency: 1.55,
quantity: 2,
SymbolProfile: {
...symbolProfileDummyData,
assetProfile: {
...assetProfileDummyData,
currency: 'CHF',
dataSource: 'YAHOO',
name: 'Bâloise Holding AG',
symbol: 'BALN.SW'
},
date: new Date('2021-11-22'),
feeInAssetProfileCurrency: 1.55,
feeInBaseCurrency: 1.55,
quantity: 2,
type: 'BUY',
unitPriceInAssetProfileCurrency: 142.9
},
{
...activityDummyData,
date: new Date('2021-11-30'),
feeInAssetProfileCurrency: 1.65,
feeInBaseCurrency: 1.65,
quantity: 2,
SymbolProfile: {
...symbolProfileDummyData,
assetProfile: {
...assetProfileDummyData,
currency: 'CHF',
dataSource: 'YAHOO',
name: 'Bâloise Holding AG',
symbol: 'BALN.SW'
},
date: new Date('2021-11-30'),
feeInAssetProfileCurrency: 1.65,
feeInBaseCurrency: 1.65,
quantity: 2,
type: 'BUY',
unitPriceInAssetProfileCurrency: 136.6
}

462
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell-in-two-activities.spec.ts

@ -1,231 +1,231 @@
import {
activityDummyData,
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 { 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(() => {
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, 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.only('with BALN.SW buy and sell in two activities', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2021-12-18').getTime());
const activities: Activity[] = [
{
...activityDummyData,
date: new Date('2021-11-22'),
feeInAssetProfileCurrency: 1.55,
feeInBaseCurrency: 1.55,
quantity: 2,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'CHF',
dataSource: 'YAHOO',
name: 'Bâloise Holding AG',
symbol: 'BALN.SW'
},
type: 'BUY',
unitPriceInAssetProfileCurrency: 142.9
},
{
...activityDummyData,
date: new Date('2021-11-30'),
feeInAssetProfileCurrency: 1.65,
feeInBaseCurrency: 1.65,
quantity: 1,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'CHF',
dataSource: 'YAHOO',
name: 'Bâloise Holding AG',
symbol: 'BALN.SW'
},
type: 'SELL',
unitPriceInAssetProfileCurrency: 136.6
},
{
...activityDummyData,
date: new Date('2021-11-30'),
feeInAssetProfileCurrency: 0,
feeInBaseCurrency: 0,
quantity: 1,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'CHF',
dataSource: 'YAHOO',
name: 'Bâloise Holding AG',
symbol: 'BALN.SW'
},
type: 'SELL',
unitPriceInAssetProfileCurrency: 136.6
}
];
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.ROAI,
currency: 'CHF',
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
data: portfolioSnapshot.historicalData,
groupBy: 'month'
});
const investmentsByYear = portfolioCalculator.getInvestmentsByGroup({
data: portfolioSnapshot.historicalData,
groupBy: 'year'
});
expect(portfolioSnapshot).toMatchObject({
currentValueInBaseCurrency: new Big('0'),
errors: [],
hasErrors: false,
positions: [
{
activitiesCount: 3,
averagePrice: new Big('0'),
currency: 'CHF',
dataSource: 'YAHOO',
dateOfFirstActivity: '2021-11-22',
dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'),
fee: new Big('3.2'),
feeInBaseCurrency: new Big('3.2'),
grossPerformance: new Big('-12.6'),
grossPerformancePercentage: new Big('-0.04408677396780965649'),
grossPerformancePercentageWithCurrencyEffect: new Big(
'-0.04408677396780965649'
),
grossPerformanceWithCurrencyEffect: new Big('-12.6'),
investment: new Big('0'),
investmentWithCurrencyEffect: new Big('0'),
netPerformancePercentageWithCurrencyEffectMap: {
max: new Big('-0.0552834149755073478')
},
netPerformanceWithCurrencyEffectMap: {
max: new Big('-15.8')
},
marketPrice: 148.9,
marketPriceInBaseCurrency: 148.9,
quantity: new Big('0'),
symbol: 'BALN.SW',
tags: [],
timeWeightedInvestment: new Big('285.80000000000000396627'),
timeWeightedInvestmentWithCurrencyEffect: new Big(
'285.80000000000000396627'
),
valueInBaseCurrency: new Big('0')
}
],
totalFeesWithCurrencyEffect: new Big('3.2'),
totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: new Big('0'),
totalInvestmentWithCurrencyEffect: new Big('0'),
totalLiabilitiesWithCurrencyEffect: new Big('0')
});
expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject(
expect.objectContaining({
netPerformance: -15.8,
netPerformanceInPercentage: -0.05528341497550734703,
netPerformanceInPercentageWithCurrencyEffect: -0.05528341497550734703,
netPerformanceWithCurrencyEffect: -15.8,
totalInvestment: 0,
totalInvestmentValueWithCurrencyEffect: 0
})
);
expect(investments).toEqual([
{ date: '2021-11-22', investment: new Big('285.8') },
{ date: '2021-11-30', investment: new Big('0') }
]);
expect(investmentsByMonth).toEqual([
{ date: '2021-11-01', investment: 0 },
{ date: '2021-12-01', investment: 0 }
]);
expect(investmentsByYear).toEqual([
{ date: '2021-01-01', investment: 0 }
]);
});
});
});
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(() => {
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, 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.only('with BALN.SW buy and sell in two activities', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2021-12-18').getTime());
const activities: Activity[] = [
{
...activityDummyData,
assetProfile: {
...assetProfileDummyData,
currency: 'CHF',
dataSource: 'YAHOO',
name: 'Bâloise Holding AG',
symbol: 'BALN.SW'
},
date: new Date('2021-11-22'),
feeInAssetProfileCurrency: 1.55,
feeInBaseCurrency: 1.55,
quantity: 2,
type: 'BUY',
unitPriceInAssetProfileCurrency: 142.9
},
{
...activityDummyData,
assetProfile: {
...assetProfileDummyData,
currency: 'CHF',
dataSource: 'YAHOO',
name: 'Bâloise Holding AG',
symbol: 'BALN.SW'
},
date: new Date('2021-11-30'),
feeInAssetProfileCurrency: 1.65,
feeInBaseCurrency: 1.65,
quantity: 1,
type: 'SELL',
unitPriceInAssetProfileCurrency: 136.6
},
{
...activityDummyData,
assetProfile: {
...assetProfileDummyData,
currency: 'CHF',
dataSource: 'YAHOO',
name: 'Bâloise Holding AG',
symbol: 'BALN.SW'
},
date: new Date('2021-11-30'),
feeInAssetProfileCurrency: 0,
feeInBaseCurrency: 0,
quantity: 1,
type: 'SELL',
unitPriceInAssetProfileCurrency: 136.6
}
];
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.ROAI,
currency: 'CHF',
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
data: portfolioSnapshot.historicalData,
groupBy: 'month'
});
const investmentsByYear = portfolioCalculator.getInvestmentsByGroup({
data: portfolioSnapshot.historicalData,
groupBy: 'year'
});
expect(portfolioSnapshot).toMatchObject({
currentValueInBaseCurrency: new Big('0'),
errors: [],
hasErrors: false,
positions: [
{
activitiesCount: 3,
averagePrice: new Big('0'),
currency: 'CHF',
dataSource: 'YAHOO',
dateOfFirstActivity: '2021-11-22',
dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'),
fee: new Big('3.2'),
feeInBaseCurrency: new Big('3.2'),
grossPerformance: new Big('-12.6'),
grossPerformancePercentage: new Big('-0.04408677396780965649'),
grossPerformancePercentageWithCurrencyEffect: new Big(
'-0.04408677396780965649'
),
grossPerformanceWithCurrencyEffect: new Big('-12.6'),
investment: new Big('0'),
investmentWithCurrencyEffect: new Big('0'),
netPerformancePercentageWithCurrencyEffectMap: {
max: new Big('-0.0552834149755073478')
},
netPerformanceWithCurrencyEffectMap: {
max: new Big('-15.8')
},
marketPrice: 148.9,
marketPriceInBaseCurrency: 148.9,
quantity: new Big('0'),
symbol: 'BALN.SW',
tags: [],
timeWeightedInvestment: new Big('285.80000000000000396627'),
timeWeightedInvestmentWithCurrencyEffect: new Big(
'285.80000000000000396627'
),
valueInBaseCurrency: new Big('0')
}
],
totalFeesWithCurrencyEffect: new Big('3.2'),
totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: new Big('0'),
totalInvestmentWithCurrencyEffect: new Big('0'),
totalLiabilitiesWithCurrencyEffect: new Big('0')
});
expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject(
expect.objectContaining({
netPerformance: -15.8,
netPerformanceInPercentage: -0.05528341497550734703,
netPerformanceInPercentageWithCurrencyEffect: -0.05528341497550734703,
netPerformanceWithCurrencyEffect: -15.8,
totalInvestment: 0,
totalInvestmentValueWithCurrencyEffect: 0
})
);
expect(investments).toEqual([
{ date: '2021-11-22', investment: new Big('285.8') },
{ date: '2021-11-30', investment: new Big('0') }
]);
expect(investmentsByMonth).toEqual([
{ date: '2021-11-01', investment: 0 },
{ date: '2021-12-01', investment: 0 }
]);
expect(investmentsByYear).toEqual([
{ date: '2021-01-01', investment: 0 }
]);
});
});
});

26
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell.spec.ts

@ -1,6 +1,6 @@
import {
activityDummyData,
symbolProfileDummyData,
assetProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
@ -85,33 +85,33 @@ describe('PortfolioCalculator', () => {
const activities: Activity[] = [
{
...activityDummyData,
date: new Date('2021-11-22'),
feeInAssetProfileCurrency: 1.55,
feeInBaseCurrency: 1.55,
quantity: 2,
SymbolProfile: {
...symbolProfileDummyData,
assetProfile: {
...assetProfileDummyData,
currency: 'CHF',
dataSource: 'YAHOO',
name: 'Bâloise Holding AG',
symbol: 'BALN.SW'
},
date: new Date('2021-11-22'),
feeInAssetProfileCurrency: 1.55,
feeInBaseCurrency: 1.55,
quantity: 2,
type: 'BUY',
unitPriceInAssetProfileCurrency: 142.9
},
{
...activityDummyData,
date: new Date('2021-11-30'),
feeInAssetProfileCurrency: 1.65,
feeInBaseCurrency: 1.65,
quantity: 2,
SymbolProfile: {
...symbolProfileDummyData,
assetProfile: {
...assetProfileDummyData,
currency: 'CHF',
dataSource: 'YAHOO',
name: 'Bâloise Holding AG',
symbol: 'BALN.SW'
},
date: new Date('2021-11-30'),
feeInAssetProfileCurrency: 1.65,
feeInBaseCurrency: 1.65,
quantity: 2,
type: 'SELL',
unitPriceInAssetProfileCurrency: 136.6
}

38
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy.spec.ts

@ -1,6 +1,6 @@
import {
activityDummyData,
symbolProfileDummyData,
assetProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
@ -85,17 +85,17 @@ describe('PortfolioCalculator', () => {
const activities: Activity[] = [
{
...activityDummyData,
date: new Date('2021-11-30'),
feeInAssetProfileCurrency: 1.55,
feeInBaseCurrency: 1.55,
quantity: 2,
SymbolProfile: {
...symbolProfileDummyData,
assetProfile: {
...assetProfileDummyData,
currency: 'CHF',
dataSource: 'YAHOO',
name: 'Bâloise Holding AG',
symbol: 'BALN.SW'
},
date: new Date('2021-11-30'),
feeInAssetProfileCurrency: 1.55,
feeInBaseCurrency: 1.55,
quantity: 2,
type: 'BUY',
unitPriceInAssetProfileCurrency: 136.6
}
@ -217,17 +217,17 @@ describe('PortfolioCalculator', () => {
const activities: Activity[] = [
{
...activityDummyData,
date: new Date('2021-11-30'),
feeInAssetProfileCurrency: 1.55,
feeInBaseCurrency: 1.55,
quantity: 2,
SymbolProfile: {
...symbolProfileDummyData,
assetProfile: {
...assetProfileDummyData,
currency: 'CHF',
dataSource: 'YAHOO',
name: 'Bâloise Holding AG',
symbol: 'BALN.SW'
},
date: new Date('2021-11-30'),
feeInAssetProfileCurrency: 1.55,
feeInBaseCurrency: 1.55,
quantity: 2,
type: 'BUY',
unitPriceInAssetProfileCurrency: 135.0
}
@ -257,17 +257,17 @@ describe('PortfolioCalculator', () => {
const activities: Activity[] = [
{
...activityDummyData,
date: new Date('2021-11-30'),
feeInAssetProfileCurrency: 1.55,
feeInBaseCurrency: 1.55,
quantity: 2,
SymbolProfile: {
...symbolProfileDummyData,
assetProfile: {
...assetProfileDummyData,
currency: 'CHF',
dataSource: 'YAHOO',
name: 'Bâloise Holding AG',
symbol: 'BALN.SW'
},
date: new Date('2021-11-30'),
feeInAssetProfileCurrency: 1.55,
feeInBaseCurrency: 1.55,
quantity: 2,
type: 'BUY',
unitPriceInAssetProfileCurrency: 135.0
}

14
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btceur-in-base-currency-eur.spec.ts

@ -1,7 +1,7 @@
import {
activityDummyData,
assetProfileDummyData,
loadExportFile,
symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
@ -107,17 +107,17 @@ describe('PortfolioCalculator', () => {
const activities: Activity[] = exportResponse.activities.map(
(activity) => ({
...activityDummyData,
...activity,
date: parseDate(activity.date),
feeInAssetProfileCurrency: 4.46,
feeInBaseCurrency: 3.94,
SymbolProfile: {
...symbolProfileDummyData,
assetProfile: {
...assetProfileDummyData,
currency: 'USD',
dataSource: activity.dataSource,
name: 'Bitcoin',
symbol: activity.symbol
},
...activity,
date: parseDate(activity.date),
feeInAssetProfileCurrency: 4.46,
feeInBaseCurrency: 3.94,
unitPriceInAssetProfileCurrency: 44558.42
})
);

14
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btceur.spec.ts

@ -1,7 +1,7 @@
import {
activityDummyData,
assetProfileDummyData,
loadExportFile,
symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
@ -95,17 +95,17 @@ describe('PortfolioCalculator', () => {
const activities: Activity[] = exportResponse.activities.map(
(activity) => ({
...activityDummyData,
...activity,
date: parseDate(activity.date),
feeInAssetProfileCurrency: 4.46,
feeInBaseCurrency: 4.46,
SymbolProfile: {
...symbolProfileDummyData,
assetProfile: {
...assetProfileDummyData,
currency: 'USD',
dataSource: activity.dataSource,
name: 'Bitcoin',
symbol: activity.symbol
},
...activity,
date: parseDate(activity.date),
feeInAssetProfileCurrency: 4.46,
feeInBaseCurrency: 4.46,
unitPriceInAssetProfileCurrency: 44558.42
})
);

26
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts

@ -1,6 +1,6 @@
import {
activityDummyData,
symbolProfileDummyData,
assetProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
@ -98,33 +98,33 @@ describe('PortfolioCalculator', () => {
const activities: Activity[] = [
{
...activityDummyData,
date: new Date('2015-01-01'),
feeInAssetProfileCurrency: 0,
feeInBaseCurrency: 0,
quantity: 2,
SymbolProfile: {
...symbolProfileDummyData,
assetProfile: {
...assetProfileDummyData,
currency: 'USD',
dataSource: 'YAHOO',
name: 'Bitcoin USD',
symbol: 'BTCUSD'
},
date: new Date('2015-01-01'),
feeInAssetProfileCurrency: 0,
feeInBaseCurrency: 0,
quantity: 2,
type: 'BUY',
unitPriceInAssetProfileCurrency: 320.43
},
{
...activityDummyData,
date: new Date('2017-12-31'),
feeInAssetProfileCurrency: 0,
feeInBaseCurrency: 0,
quantity: 1,
SymbolProfile: {
...symbolProfileDummyData,
assetProfile: {
...assetProfileDummyData,
currency: 'USD',
dataSource: 'YAHOO',
name: 'Bitcoin USD',
symbol: 'BTCUSD'
},
date: new Date('2017-12-31'),
feeInAssetProfileCurrency: 0,
feeInBaseCurrency: 0,
quantity: 1,
type: 'SELL',
unitPriceInAssetProfileCurrency: 14156.4
}

12
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-short.spec.ts

@ -1,7 +1,7 @@
import {
activityDummyData,
assetProfileDummyData,
loadExportFile,
symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
@ -96,16 +96,16 @@ describe('PortfolioCalculator', () => {
(activity) => ({
...activityDummyData,
...activity,
date: parseDate(activity.date),
feeInAssetProfileCurrency: activity.fee,
feeInBaseCurrency: activity.fee,
SymbolProfile: {
...symbolProfileDummyData,
assetProfile: {
...assetProfileDummyData,
currency: 'USD',
dataSource: activity.dataSource,
name: 'Bitcoin',
symbol: activity.symbol
},
date: parseDate(activity.date),
feeInAssetProfileCurrency: activity.fee,
feeInBaseCurrency: activity.fee,
unitPriceInAssetProfileCurrency: activity.unitPrice
})
);

12
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd.spec.ts

@ -1,7 +1,7 @@
import {
activityDummyData,
assetProfileDummyData,
loadExportFile,
symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
@ -96,16 +96,16 @@ describe('PortfolioCalculator', () => {
(activity) => ({
...activityDummyData,
...activity,
date: parseDate(activity.date),
feeInAssetProfileCurrency: 4.46,
feeInBaseCurrency: 4.46,
SymbolProfile: {
...symbolProfileDummyData,
assetProfile: {
...assetProfileDummyData,
currency: 'USD',
dataSource: activity.dataSource,
name: 'Bitcoin',
symbol: activity.symbol
},
date: parseDate(activity.date),
feeInAssetProfileCurrency: 4.46,
feeInBaseCurrency: 4.46,
unitPriceInAssetProfileCurrency: 44558.42
})
);

14
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-fee.spec.ts

@ -1,6 +1,6 @@
import {
activityDummyData,
symbolProfileDummyData,
assetProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
@ -85,17 +85,17 @@ describe('PortfolioCalculator', () => {
const activities: Activity[] = [
{
...activityDummyData,
date: new Date('2021-09-01'),
feeInAssetProfileCurrency: 49,
feeInBaseCurrency: 49,
quantity: 0,
SymbolProfile: {
...symbolProfileDummyData,
assetProfile: {
...assetProfileDummyData,
currency: 'USD',
dataSource: 'MANUAL',
name: 'Account Opening Fee',
symbol: '2c463fb3-af07-486e-adb0-8301b3d72141'
},
date: new Date('2021-09-01'),
feeInAssetProfileCurrency: 49,
feeInBaseCurrency: 49,
quantity: 0,
type: 'FEE',
unitPriceInAssetProfileCurrency: 0
}

14
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-googl-buy.spec.ts

@ -1,6 +1,6 @@
import {
activityDummyData,
symbolProfileDummyData,
assetProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
@ -97,17 +97,17 @@ describe('PortfolioCalculator', () => {
const activities: Activity[] = [
{
...activityDummyData,
date: new Date('2023-01-03'),
feeInAssetProfileCurrency: 1,
feeInBaseCurrency: 0.9238,
quantity: 1,
SymbolProfile: {
...symbolProfileDummyData,
assetProfile: {
...assetProfileDummyData,
currency: 'USD',
dataSource: 'YAHOO',
name: 'Alphabet Inc.',
symbol: 'GOOGL'
},
date: new Date('2023-01-03'),
feeInAssetProfileCurrency: 1,
feeInBaseCurrency: 0.9238,
quantity: 1,
type: 'BUY',
unitPriceInAssetProfileCurrency: 89.12
}

380
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-jnug-buy-and-sell-and-buy-and-sell.spec.ts

@ -1,190 +1,190 @@
import {
activityDummyData,
loadExportFile,
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 { Activity, ExportResponse } from '@ghostfolio/common/interfaces';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Big } from 'big.js';
import { join } from 'node:path';
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 exportResponse: ExportResponse;
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
let portfolioCalculatorFactory: PortfolioCalculatorFactory;
let portfolioSnapshotService: PortfolioSnapshotService;
let redisCacheService: RedisCacheService;
beforeAll(() => {
exportResponse = loadExportFile(
join(
__dirname,
'../../../../../../../test/import/ok/jnug-buy-and-sell-and-buy-and-sell.json'
)
);
});
beforeEach(() => {
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, 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.only('with JNUG buy and sell', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2025-12-28').getTime());
const activities: Activity[] = exportResponse.activities.map(
(activity) => ({
...activityDummyData,
...activity,
date: parseDate(activity.date),
feeInAssetProfileCurrency: activity.fee,
feeInBaseCurrency: activity.fee,
SymbolProfile: {
...symbolProfileDummyData,
currency: activity.currency,
dataSource: activity.dataSource,
name: 'Direxion Daily Junior Gold Miners Index Bull 2X Shares',
symbol: activity.symbol
},
unitPriceInAssetProfileCurrency: activity.unitPrice
})
);
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.ROAI,
currency: exportResponse.user.settings.currency,
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
data: portfolioSnapshot.historicalData,
groupBy: 'month'
});
const investmentsByYear = portfolioCalculator.getInvestmentsByGroup({
data: portfolioSnapshot.historicalData,
groupBy: 'year'
});
expect(portfolioSnapshot).toMatchObject({
currentValueInBaseCurrency: new Big('0'),
errors: [],
hasErrors: false,
positions: [
{
activitiesCount: 4,
averagePrice: new Big('0'),
currency: 'USD',
dataSource: 'YAHOO',
dateOfFirstActivity: '2025-12-11',
dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'),
fee: new Big('4'),
feeInBaseCurrency: new Big('4'),
grossPerformance: new Big('43.95'), // (1890.00 - 1885.05) + (2080.10 - 2041.10)
grossPerformanceWithCurrencyEffect: new Big('43.95'), // (1890.00 - 1885.05) + (2080.10 - 2041.10)
investment: new Big('0'),
investmentWithCurrencyEffect: new Big('0'),
netPerformance: new Big('39.95'), // (1890.00 - 1885.05) + (2080.10 - 2041.10) - 4
netPerformanceWithCurrencyEffectMap: {
max: new Big('39.95') // (1890.00 - 1885.05) + (2080.10 - 2041.10) - 4
},
marketPrice: 237.8000030517578,
marketPriceInBaseCurrency: 237.8000030517578,
quantity: new Big('0'),
symbol: 'JNUG',
tags: [],
valueInBaseCurrency: new Big('0')
}
],
totalFeesWithCurrencyEffect: new Big('4'),
totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: new Big('0'),
totalInvestmentWithCurrencyEffect: new Big('0'),
totalLiabilitiesWithCurrencyEffect: new Big('0')
});
expect(investments).toEqual([
{ date: '2025-12-11', investment: new Big('1885.05') },
{ date: '2025-12-18', investment: new Big('2041.1') },
{ date: '2025-12-28', investment: new Big('0') }
]);
expect(investmentsByMonth).toEqual([
{ date: '2025-12-01', investment: 0 }
]);
expect(investmentsByYear).toEqual([
{ date: '2025-01-01', investment: 0 }
]);
});
});
});
import {
activityDummyData,
assetProfileDummyData,
loadExportFile,
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, ExportResponse } from '@ghostfolio/common/interfaces';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Big } from 'big.js';
import { join } from 'node:path';
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 exportResponse: ExportResponse;
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
let portfolioCalculatorFactory: PortfolioCalculatorFactory;
let portfolioSnapshotService: PortfolioSnapshotService;
let redisCacheService: RedisCacheService;
beforeAll(() => {
exportResponse = loadExportFile(
join(
__dirname,
'../../../../../../../test/import/ok/jnug-buy-and-sell-and-buy-and-sell.json'
)
);
});
beforeEach(() => {
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, 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.only('with JNUG buy and sell', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2025-12-28').getTime());
const activities: Activity[] = exportResponse.activities.map(
(activity) => ({
...activityDummyData,
...activity,
assetProfile: {
...assetProfileDummyData,
currency: activity.currency,
dataSource: activity.dataSource,
name: 'Direxion Daily Junior Gold Miners Index Bull 2X Shares',
symbol: activity.symbol
},
date: parseDate(activity.date),
feeInAssetProfileCurrency: activity.fee,
feeInBaseCurrency: activity.fee,
unitPriceInAssetProfileCurrency: activity.unitPrice
})
);
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.ROAI,
currency: exportResponse.user.settings.currency,
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
data: portfolioSnapshot.historicalData,
groupBy: 'month'
});
const investmentsByYear = portfolioCalculator.getInvestmentsByGroup({
data: portfolioSnapshot.historicalData,
groupBy: 'year'
});
expect(portfolioSnapshot).toMatchObject({
currentValueInBaseCurrency: new Big('0'),
errors: [],
hasErrors: false,
positions: [
{
activitiesCount: 4,
averagePrice: new Big('0'),
currency: 'USD',
dataSource: 'YAHOO',
dateOfFirstActivity: '2025-12-11',
dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'),
fee: new Big('4'),
feeInBaseCurrency: new Big('4'),
grossPerformance: new Big('43.95'), // (1890.00 - 1885.05) + (2080.10 - 2041.10)
grossPerformanceWithCurrencyEffect: new Big('43.95'), // (1890.00 - 1885.05) + (2080.10 - 2041.10)
investment: new Big('0'),
investmentWithCurrencyEffect: new Big('0'),
netPerformance: new Big('39.95'), // (1890.00 - 1885.05) + (2080.10 - 2041.10) - 4
netPerformanceWithCurrencyEffectMap: {
max: new Big('39.95') // (1890.00 - 1885.05) + (2080.10 - 2041.10) - 4
},
marketPrice: 237.8000030517578,
marketPriceInBaseCurrency: 237.8000030517578,
quantity: new Big('0'),
symbol: 'JNUG',
tags: [],
valueInBaseCurrency: new Big('0')
}
],
totalFeesWithCurrencyEffect: new Big('4'),
totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: new Big('0'),
totalInvestmentWithCurrencyEffect: new Big('0'),
totalLiabilitiesWithCurrencyEffect: new Big('0')
});
expect(investments).toEqual([
{ date: '2025-12-11', investment: new Big('1885.05') },
{ date: '2025-12-18', investment: new Big('2041.1') },
{ date: '2025-12-28', investment: new Big('0') }
]);
expect(investmentsByMonth).toEqual([
{ date: '2025-12-01', investment: 0 }
]);
expect(investmentsByYear).toEqual([
{ date: '2025-01-01', investment: 0 }
]);
});
});
});

14
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-liability.spec.ts

@ -1,6 +1,6 @@
import {
activityDummyData,
symbolProfileDummyData,
assetProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
@ -85,17 +85,17 @@ describe('PortfolioCalculator', () => {
const activities: Activity[] = [
{
...activityDummyData,
date: new Date('2023-01-01'), // Date in future
feeInAssetProfileCurrency: 0,
feeInBaseCurrency: 0,
quantity: 1,
SymbolProfile: {
...symbolProfileDummyData,
assetProfile: {
...assetProfileDummyData,
currency: 'USD',
dataSource: 'MANUAL',
name: 'Loan',
symbol: '55196015-1365-4560-aa60-8751ae6d18f8'
},
date: new Date('2023-01-01'), // Date in future
feeInAssetProfileCurrency: 0,
feeInBaseCurrency: 0,
quantity: 1,
type: 'LIABILITY',
unitPriceInAssetProfileCurrency: 3000
}

38
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-and-sell.spec.ts

@ -1,6 +1,6 @@
import {
activityDummyData,
symbolProfileDummyData,
assetProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
@ -78,49 +78,49 @@ describe('PortfolioCalculator', () => {
const activities: Activity[] = [
{
...activityDummyData,
date: new Date('2024-03-08'),
feeInAssetProfileCurrency: 0,
feeInBaseCurrency: 0,
quantity: 0.3333333333333333,
SymbolProfile: {
...symbolProfileDummyData,
assetProfile: {
...assetProfileDummyData,
currency: 'USD',
dataSource: 'YAHOO',
name: 'Microsoft Inc.',
symbol: 'MSFT'
},
date: new Date('2024-03-08'),
feeInAssetProfileCurrency: 0,
feeInBaseCurrency: 0,
quantity: 0.3333333333333333,
type: 'BUY',
unitPriceInAssetProfileCurrency: 408
},
{
...activityDummyData,
date: new Date('2024-03-13'),
feeInAssetProfileCurrency: 0,
feeInBaseCurrency: 0,
quantity: 0.6666666666666666,
SymbolProfile: {
...symbolProfileDummyData,
assetProfile: {
...assetProfileDummyData,
currency: 'USD',
dataSource: 'YAHOO',
name: 'Microsoft Inc.',
symbol: 'MSFT'
},
date: new Date('2024-03-13'),
feeInAssetProfileCurrency: 0,
feeInBaseCurrency: 0,
quantity: 0.6666666666666666,
type: 'BUY',
unitPriceInAssetProfileCurrency: 400
},
{
...activityDummyData,
date: new Date('2024-03-14'),
feeInAssetProfileCurrency: 0,
feeInBaseCurrency: 0,
quantity: 1,
SymbolProfile: {
...symbolProfileDummyData,
assetProfile: {
...assetProfileDummyData,
currency: 'USD',
dataSource: 'YAHOO',
name: 'Microsoft Inc.',
symbol: 'MSFT'
},
date: new Date('2024-03-14'),
feeInAssetProfileCurrency: 0,
feeInBaseCurrency: 0,
quantity: 1,
type: 'SELL',
unitPriceInAssetProfileCurrency: 411
}

26
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-with-dividend.spec.ts

@ -1,6 +1,6 @@
import {
activityDummyData,
symbolProfileDummyData,
assetProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
@ -85,33 +85,33 @@ describe('PortfolioCalculator', () => {
const activities: Activity[] = [
{
...activityDummyData,
date: new Date('2021-09-16'),
feeInAssetProfileCurrency: 19,
feeInBaseCurrency: 19,
quantity: 1,
SymbolProfile: {
...symbolProfileDummyData,
assetProfile: {
...assetProfileDummyData,
currency: 'USD',
dataSource: 'YAHOO',
name: 'Microsoft Inc.',
symbol: 'MSFT'
},
date: new Date('2021-09-16'),
feeInAssetProfileCurrency: 19,
feeInBaseCurrency: 19,
quantity: 1,
type: 'BUY',
unitPriceInAssetProfileCurrency: 298.58
},
{
...activityDummyData,
date: new Date('2021-11-16'),
feeInAssetProfileCurrency: 0,
feeInBaseCurrency: 0,
quantity: 1,
SymbolProfile: {
...symbolProfileDummyData,
assetProfile: {
...assetProfileDummyData,
currency: 'USD',
dataSource: 'YAHOO',
name: 'Microsoft Inc.',
symbol: 'MSFT'
},
date: new Date('2021-11-16'),
feeInAssetProfileCurrency: 0,
feeInBaseCurrency: 0,
quantity: 1,
type: 'DIVIDEND',
unitPriceInAssetProfileCurrency: 0.62
}

12
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell-partially.spec.ts

@ -1,7 +1,7 @@
import {
activityDummyData,
assetProfileDummyData,
loadExportFile,
symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
@ -99,16 +99,16 @@ describe('PortfolioCalculator', () => {
(activity) => ({
...activityDummyData,
...activity,
date: parseDate(activity.date),
feeInAssetProfileCurrency: activity.fee,
feeInBaseCurrency: activity.fee,
SymbolProfile: {
...symbolProfileDummyData,
assetProfile: {
...assetProfileDummyData,
currency: activity.currency,
dataSource: activity.dataSource,
name: 'Novartis AG',
symbol: activity.symbol
},
date: parseDate(activity.date),
feeInAssetProfileCurrency: activity.fee,
feeInBaseCurrency: activity.fee,
unitPriceInAssetProfileCurrency: activity.unitPrice
})
);

528
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell.spec.ts

@ -1,264 +1,264 @@
import {
activityDummyData,
loadExportFile,
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 { Activity, ExportResponse } from '@ghostfolio/common/interfaces';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Big } from 'big.js';
import { join } from 'node:path';
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 exportResponse: ExportResponse;
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
let portfolioCalculatorFactory: PortfolioCalculatorFactory;
let portfolioSnapshotService: PortfolioSnapshotService;
let redisCacheService: RedisCacheService;
beforeAll(() => {
exportResponse = loadExportFile(
join(
__dirname,
'../../../../../../../test/import/ok/novn-buy-and-sell.json'
)
);
});
beforeEach(() => {
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, 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.only('with NOVN.SW buy and sell', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2022-04-11').getTime());
const activities: Activity[] = exportResponse.activities.map(
(activity) => ({
...activityDummyData,
...activity,
date: parseDate(activity.date),
feeInAssetProfileCurrency: activity.fee,
feeInBaseCurrency: activity.fee,
SymbolProfile: {
...symbolProfileDummyData,
currency: activity.currency,
dataSource: activity.dataSource,
name: 'Novartis AG',
symbol: activity.symbol
},
unitPriceInAssetProfileCurrency: activity.unitPrice
})
);
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.ROAI,
currency: exportResponse.user.settings.currency,
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
data: portfolioSnapshot.historicalData,
groupBy: 'month'
});
const investmentsByYear = portfolioCalculator.getInvestmentsByGroup({
data: portfolioSnapshot.historicalData,
groupBy: 'year'
});
expect(portfolioSnapshot.historicalData[0]).toEqual({
date: '2022-03-06',
investmentValueWithCurrencyEffect: 0,
netPerformance: 0,
netPerformanceInPercentage: 0,
netPerformanceInPercentageWithCurrencyEffect: 0,
netPerformanceWithCurrencyEffect: 0,
netWorth: 0,
totalAccountBalance: 0,
totalInvestment: 0,
totalInvestmentValueWithCurrencyEffect: 0,
value: 0,
valueWithCurrencyEffect: 0
});
/**
* Closing price on 2022-03-07 is unknown,
* hence it uses the last unit price (2022-04-11): 87.8
*/
expect(portfolioSnapshot.historicalData[1]).toEqual({
date: '2022-03-07',
investmentValueWithCurrencyEffect: 151.6,
netPerformance: 24, // 2 * (87.8 - 75.8) = 24
netPerformanceInPercentage: 0.158311345646438, // 24 ÷ 151.6 = 0.158311345646438
netPerformanceInPercentageWithCurrencyEffect: 0.158311345646438, // 24 ÷ 151.6 = 0.158311345646438
netPerformanceWithCurrencyEffect: 24,
netWorth: 175.6, // 2 * 87.8 = 175.6
totalAccountBalance: 0,
totalInvestment: 151.6,
totalInvestmentValueWithCurrencyEffect: 151.6,
value: 175.6, // 2 * 87.8 = 175.6
valueWithCurrencyEffect: 175.6
});
expect(
portfolioSnapshot.historicalData[
portfolioSnapshot.historicalData.length - 1
]
).toEqual({
date: '2022-04-11',
investmentValueWithCurrencyEffect: 0,
netPerformance: 19.86,
netPerformanceInPercentage: 0.13100263852242744,
netPerformanceInPercentageWithCurrencyEffect: 0.13100263852242744,
netPerformanceWithCurrencyEffect: 19.86,
netWorth: 0,
totalAccountBalance: 0,
totalInvestment: 0,
totalInvestmentValueWithCurrencyEffect: 0,
value: 0,
valueWithCurrencyEffect: 0
});
expect(portfolioSnapshot).toMatchObject({
currentValueInBaseCurrency: new Big('0'),
errors: [],
hasErrors: false,
positions: [
{
activitiesCount: 2,
averagePrice: new Big('0'),
currency: 'CHF',
dataSource: 'YAHOO',
dateOfFirstActivity: '2022-03-07',
dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'),
fee: new Big('0'),
feeInBaseCurrency: new Big('0'),
grossPerformance: new Big('19.86'),
grossPerformancePercentage: new Big('0.13100263852242744063'),
grossPerformancePercentageWithCurrencyEffect: new Big(
'0.13100263852242744063'
),
grossPerformanceWithCurrencyEffect: new Big('19.86'),
investment: new Big('0'),
investmentWithCurrencyEffect: new Big('0'),
netPerformance: new Big('19.86'),
netPerformancePercentage: new Big('0.13100263852242744063'),
netPerformancePercentageWithCurrencyEffectMap: {
max: new Big('0.13100263852242744063')
},
netPerformanceWithCurrencyEffectMap: {
max: new Big('19.86')
},
marketPrice: 87.8,
marketPriceInBaseCurrency: 87.8,
quantity: new Big('0'),
symbol: 'NOVN.SW',
tags: [],
timeWeightedInvestment: new Big('151.6'),
timeWeightedInvestmentWithCurrencyEffect: new Big('151.6'),
valueInBaseCurrency: new Big('0')
}
],
totalFeesWithCurrencyEffect: new Big('0'),
totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: new Big('0'),
totalInvestmentWithCurrencyEffect: new Big('0'),
totalLiabilitiesWithCurrencyEffect: new Big('0')
});
expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject(
expect.objectContaining({
netPerformance: 19.86,
netPerformanceInPercentage: 0.13100263852242744063,
netPerformanceInPercentageWithCurrencyEffect: 0.13100263852242744063,
netPerformanceWithCurrencyEffect: 19.86,
totalInvestment: 0,
totalInvestmentValueWithCurrencyEffect: 0
})
);
expect(investments).toEqual([
{ date: '2022-03-07', investment: new Big('151.6') },
{ date: '2022-04-08', investment: new Big('0') }
]);
expect(investmentsByMonth).toEqual([
{ date: '2022-03-01', investment: 151.6 },
{ date: '2022-04-01', investment: -151.6 }
]);
expect(investmentsByYear).toEqual([
{ date: '2022-01-01', investment: 0 }
]);
});
});
});
import {
activityDummyData,
assetProfileDummyData,
loadExportFile,
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, ExportResponse } from '@ghostfolio/common/interfaces';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Big } from 'big.js';
import { join } from 'node:path';
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 exportResponse: ExportResponse;
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
let portfolioCalculatorFactory: PortfolioCalculatorFactory;
let portfolioSnapshotService: PortfolioSnapshotService;
let redisCacheService: RedisCacheService;
beforeAll(() => {
exportResponse = loadExportFile(
join(
__dirname,
'../../../../../../../test/import/ok/novn-buy-and-sell.json'
)
);
});
beforeEach(() => {
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, 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.only('with NOVN.SW buy and sell', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2022-04-11').getTime());
const activities: Activity[] = exportResponse.activities.map(
(activity) => ({
...activityDummyData,
...activity,
assetProfile: {
...assetProfileDummyData,
currency: activity.currency,
dataSource: activity.dataSource,
name: 'Novartis AG',
symbol: activity.symbol
},
date: parseDate(activity.date),
feeInAssetProfileCurrency: activity.fee,
feeInBaseCurrency: activity.fee,
unitPriceInAssetProfileCurrency: activity.unitPrice
})
);
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.ROAI,
currency: exportResponse.user.settings.currency,
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
data: portfolioSnapshot.historicalData,
groupBy: 'month'
});
const investmentsByYear = portfolioCalculator.getInvestmentsByGroup({
data: portfolioSnapshot.historicalData,
groupBy: 'year'
});
expect(portfolioSnapshot.historicalData[0]).toEqual({
date: '2022-03-06',
investmentValueWithCurrencyEffect: 0,
netPerformance: 0,
netPerformanceInPercentage: 0,
netPerformanceInPercentageWithCurrencyEffect: 0,
netPerformanceWithCurrencyEffect: 0,
netWorth: 0,
totalAccountBalance: 0,
totalInvestment: 0,
totalInvestmentValueWithCurrencyEffect: 0,
value: 0,
valueWithCurrencyEffect: 0
});
/**
* Closing price on 2022-03-07 is unknown,
* hence it uses the last unit price (2022-04-11): 87.8
*/
expect(portfolioSnapshot.historicalData[1]).toEqual({
date: '2022-03-07',
investmentValueWithCurrencyEffect: 151.6,
netPerformance: 24, // 2 * (87.8 - 75.8) = 24
netPerformanceInPercentage: 0.158311345646438, // 24 ÷ 151.6 = 0.158311345646438
netPerformanceInPercentageWithCurrencyEffect: 0.158311345646438, // 24 ÷ 151.6 = 0.158311345646438
netPerformanceWithCurrencyEffect: 24,
netWorth: 175.6, // 2 * 87.8 = 175.6
totalAccountBalance: 0,
totalInvestment: 151.6,
totalInvestmentValueWithCurrencyEffect: 151.6,
value: 175.6, // 2 * 87.8 = 175.6
valueWithCurrencyEffect: 175.6
});
expect(
portfolioSnapshot.historicalData[
portfolioSnapshot.historicalData.length - 1
]
).toEqual({
date: '2022-04-11',
investmentValueWithCurrencyEffect: 0,
netPerformance: 19.86,
netPerformanceInPercentage: 0.13100263852242744,
netPerformanceInPercentageWithCurrencyEffect: 0.13100263852242744,
netPerformanceWithCurrencyEffect: 19.86,
netWorth: 0,
totalAccountBalance: 0,
totalInvestment: 0,
totalInvestmentValueWithCurrencyEffect: 0,
value: 0,
valueWithCurrencyEffect: 0
});
expect(portfolioSnapshot).toMatchObject({
currentValueInBaseCurrency: new Big('0'),
errors: [],
hasErrors: false,
positions: [
{
activitiesCount: 2,
averagePrice: new Big('0'),
currency: 'CHF',
dataSource: 'YAHOO',
dateOfFirstActivity: '2022-03-07',
dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'),
fee: new Big('0'),
feeInBaseCurrency: new Big('0'),
grossPerformance: new Big('19.86'),
grossPerformancePercentage: new Big('0.13100263852242744063'),
grossPerformancePercentageWithCurrencyEffect: new Big(
'0.13100263852242744063'
),
grossPerformanceWithCurrencyEffect: new Big('19.86'),
investment: new Big('0'),
investmentWithCurrencyEffect: new Big('0'),
netPerformance: new Big('19.86'),
netPerformancePercentage: new Big('0.13100263852242744063'),
netPerformancePercentageWithCurrencyEffectMap: {
max: new Big('0.13100263852242744063')
},
netPerformanceWithCurrencyEffectMap: {
max: new Big('19.86')
},
marketPrice: 87.8,
marketPriceInBaseCurrency: 87.8,
quantity: new Big('0'),
symbol: 'NOVN.SW',
tags: [],
timeWeightedInvestment: new Big('151.6'),
timeWeightedInvestmentWithCurrencyEffect: new Big('151.6'),
valueInBaseCurrency: new Big('0')
}
],
totalFeesWithCurrencyEffect: new Big('0'),
totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: new Big('0'),
totalInvestmentWithCurrencyEffect: new Big('0'),
totalLiabilitiesWithCurrencyEffect: new Big('0')
});
expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject(
expect.objectContaining({
netPerformance: 19.86,
netPerformanceInPercentage: 0.13100263852242744063,
netPerformanceInPercentageWithCurrencyEffect: 0.13100263852242744063,
netPerformanceWithCurrencyEffect: 19.86,
totalInvestment: 0,
totalInvestmentValueWithCurrencyEffect: 0
})
);
expect(investments).toEqual([
{ date: '2022-03-07', investment: new Big('151.6') },
{ date: '2022-04-08', investment: new Big('0') }
]);
expect(investmentsByMonth).toEqual([
{ date: '2022-03-01', investment: 151.6 },
{ date: '2022-04-01', investment: -151.6 }
]);
expect(investmentsByYear).toEqual([
{ date: '2022-01-01', investment: 0 }
]);
});
});
});

14
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-valuable.spec.ts

@ -1,6 +1,6 @@
import {
activityDummyData,
symbolProfileDummyData,
assetProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
@ -85,17 +85,17 @@ describe('PortfolioCalculator', () => {
const activities: Activity[] = [
{
...activityDummyData,
date: new Date('2022-01-01'),
feeInAssetProfileCurrency: 0,
feeInBaseCurrency: 0,
quantity: 1,
SymbolProfile: {
...symbolProfileDummyData,
assetProfile: {
...assetProfileDummyData,
currency: 'USD',
dataSource: 'MANUAL',
name: 'Penthouse Apartment',
symbol: 'dac95060-d4f2-4653-a253-2c45e6fb5cde'
},
date: new Date('2022-01-01'),
feeInAssetProfileCurrency: 0,
feeInBaseCurrency: 0,
quantity: 1,
type: 'BUY',
unitPriceInAssetProfileCurrency: 500000
}

2
apps/api/src/app/portfolio/interfaces/portfolio-order.interface.ts

@ -2,7 +2,7 @@ import { Activity } from '@ghostfolio/common/interfaces';
export interface PortfolioOrder extends Pick<Activity, 'tags' | 'type'> {
assetProfile: Pick<
Activity['SymbolProfile'],
Activity['assetProfile'],
'assetSubClass' | 'currency' | 'dataSource' | 'name' | 'symbol' | 'userId'
>;
date: string;

6
apps/api/src/app/portfolio/portfolio.service.spec.ts

@ -373,15 +373,15 @@ describe('PortfolioService', () => {
{
account,
accountId: account.id,
assetProfile: { symbol: 'AAPL' },
quantity: 1,
SymbolProfile: { symbol: 'AAPL' },
type: 'BUY'
},
{
account: null,
accountId: null,
assetProfile: { symbol: 'BABA' },
quantity: 2,
SymbolProfile: { symbol: 'BABA' },
type: 'BUY'
}
],
@ -409,8 +409,8 @@ describe('PortfolioService', () => {
{
account,
accountId: account.id,
assetProfile: { symbol: 'AAPL' },
quantity: 1,
SymbolProfile: { symbol: 'AAPL' },
type: 'BUY'
}
],

18
apps/api/src/app/portfolio/portfolio.service.ts

@ -829,10 +829,9 @@ export class PortfolioService {
timeWeightedInvestmentWithCurrencyEffect
} = holding;
const activitiesOfHolding = activities.filter(({ SymbolProfile }) => {
const activitiesOfHolding = activities.filter(({ assetProfile }) => {
return (
SymbolProfile.dataSource === dataSource &&
SymbolProfile.symbol === symbol
assetProfile.dataSource === dataSource && assetProfile.symbol === symbol
);
});
@ -2056,11 +2055,11 @@ export class PortfolioService {
.filter(({ isDraft, type }) => {
return isDraft === false && type === activityType;
})
.map(({ currency, quantity, SymbolProfile, unitPrice }) => {
.map(({ assetProfile, currency, quantity, unitPrice }) => {
return new Big(
this.exchangeRateDataService.toCurrency(
new Big(quantity).mul(unitPrice).toNumber(),
currency ?? SymbolProfile.currency,
currency ?? assetProfile.currency,
userCurrency
)
);
@ -2198,16 +2197,11 @@ export class PortfolioService {
}
}
for (const {
account,
quantity,
SymbolProfile,
type
} of ordersByAccount) {
for (const { account, assetProfile, quantity, type } of ordersByAccount) {
const currentValueOfSymbolInBaseCurrency =
getFactor(type) *
quantity *
(portfolioItemsNow[SymbolProfile.symbol]?.marketPriceInBaseCurrency ??
(portfolioItemsNow[assetProfile.symbol]?.marketPriceInBaseCurrency ??
0);
if (accounts[account?.id || UNKNOWN_KEY]?.valueInBaseCurrency) {

7
apps/api/src/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor.ts

@ -78,8 +78,12 @@ export class TransformDataSourceInResponseInterceptor<
valueMap,
object: data,
paths: [
'activities[*].assetProfile.dataSource',
'activities[*].dataSource',
/* @deprecated */
'activities[*].SymbolProfile.dataSource',
'assetProfile.dataSource',
'benchmarks[*].dataSource',
'errors[*].dataSource',
@ -88,7 +92,10 @@ export class TransformDataSourceInResponseInterceptor<
'holdings[*].assetProfile.dataSource',
'holdings[*].dataSource',
'items[*].dataSource',
/* @deprecated */
'SymbolProfile.dataSource',
'watchlist[*].dataSource'
]
});

2
apps/client/src/app/pages/portfolio/activities/activities-page.component.ts

@ -409,10 +409,10 @@ export class GfActivitiesPageComponent implements OnInit {
activity: {
...aActivity,
accountId: aActivity?.accountId,
assetProfile: null,
date: new Date(),
id: null,
fee: 0,
SymbolProfile: null,
type: aActivity?.type ?? 'BUY',
unitPrice: null
},

30
apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts

@ -119,7 +119,7 @@ export class GfCreateOrUpdateActivityDialogComponent {
}
public ngOnInit() {
this.currencyOfAssetProfile = this.data.activity?.SymbolProfile?.currency;
this.currencyOfAssetProfile = this.data.activity?.assetProfile?.currency;
this.hasPermissionToCreateOwnTag =
this.data.user?.settings?.isExperimentalFeatures &&
hasPermission(this.data.user?.permissions, permissions.createOwnTag);
@ -185,31 +185,31 @@ export class GfCreateOrUpdateActivityDialogComponent {
? this.data.accounts[0].id
: this.data.activity?.accountId
],
assetClass: [this.data.activity?.SymbolProfile?.assetClass],
assetSubClass: [this.data.activity?.SymbolProfile?.assetSubClass],
assetClass: [this.data.activity?.assetProfile?.assetClass],
assetSubClass: [this.data.activity?.assetProfile?.assetSubClass],
comment: [this.data.activity?.comment],
currency: [
this.data.activity?.SymbolProfile?.currency,
this.data.activity?.assetProfile?.currency,
Validators.required
],
currencyOfUnitPrice: [
this.data.activity?.currency ??
this.data.activity?.SymbolProfile?.currency,
this.data.activity?.assetProfile?.currency,
Validators.required
],
dataSource: [
this.data.activity?.SymbolProfile?.dataSource,
this.data.activity?.assetProfile?.dataSource,
Validators.required
],
date: [this.data.activity?.date, Validators.required],
fee: [this.data.activity?.fee, Validators.required],
name: [this.data.activity?.SymbolProfile?.name, Validators.required],
name: [this.data.activity?.assetProfile?.name, Validators.required],
quantity: [this.data.activity?.quantity, Validators.required],
searchSymbol: [
this.data.activity?.SymbolProfile
this.data.activity?.assetProfile
? {
dataSource: this.data.activity?.SymbolProfile?.dataSource,
symbol: this.data.activity?.SymbolProfile?.symbol
dataSource: this.data.activity?.assetProfile?.dataSource,
symbol: this.data.activity?.assetProfile?.symbol
}
: null,
Validators.required
@ -310,7 +310,7 @@ export class GfCreateOrUpdateActivityDialogComponent {
this.activityForm.get('searchSymbol')?.valueChanges.subscribe(() => {
if (this.activityForm.get('searchSymbol')?.invalid) {
this.data.activity.SymbolProfile = null;
this.data.activity.assetProfile = null;
} else if (
['BUY', 'DIVIDEND', 'SELL'].includes(
this.activityForm.get('type')?.value
@ -454,11 +454,11 @@ export class GfCreateOrUpdateActivityDialogComponent {
this.activityForm.get('type')?.disable();
}
if (this.data.activity?.SymbolProfile?.symbol) {
if (this.data.activity?.assetProfile?.symbol) {
this.dataService
.fetchSymbolItem({
dataSource: this.data.activity?.SymbolProfile?.dataSource,
symbol: this.data.activity?.SymbolProfile?.symbol
dataSource: this.data.activity?.assetProfile?.dataSource,
symbol: this.data.activity?.assetProfile?.symbol
})
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ marketPrice }) => {
@ -569,7 +569,7 @@ export class GfCreateOrUpdateActivityDialogComponent {
})
.pipe(
catchError(() => {
this.data.activity.SymbolProfile = null;
this.data.activity.assetProfile = null;
this.isLoading = false;

4
apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/interfaces/interfaces.ts

@ -4,9 +4,9 @@ import { Account } from '@prisma/client';
export interface CreateOrUpdateActivityDialogParams {
accounts: Account[];
activity: Partial<Omit<Activity, 'id' | 'SymbolProfile' | 'unitPrice'>> & {
activity: Partial<Omit<Activity, 'assetProfile' | 'id' | 'unitPrice'>> & {
assetProfile: Activity['assetProfile'] | null;
id: string | null;
SymbolProfile: Activity['SymbolProfile'] | null;
unitPrice: number | null;
};
user: User;

8
apps/client/src/app/services/import-activities.service.ts

@ -165,12 +165,12 @@ export class ImportActivitiesService {
private convertToCreateOrderDto({
accountId,
assetProfile,
comment,
currency,
date,
fee,
quantity,
SymbolProfile,
tags,
type,
unitPrice,
@ -184,10 +184,10 @@ export class ImportActivitiesService {
updateAccountBalance,
accountId: accountId ?? undefined,
comment: comment ?? undefined,
currency: currency ?? SymbolProfile.currency ?? '',
dataSource: SymbolProfile.dataSource,
currency: currency ?? assetProfile.currency ?? '',
dataSource: assetProfile.dataSource,
date: date.toString(),
symbol: SymbolProfile.symbol,
symbol: assetProfile.symbol,
tags: tags?.map(({ id }) => {
return id;
})

12
libs/common/src/lib/config.ts

@ -106,13 +106,20 @@ export const DEFAULT_REDACTED_PATHS = [
'accounts[*].valueInBaseCurrency',
'activities[*].account.balance',
'activities[*].account.comment',
'activities[*].assetProfile.symbolMapping',
'activities[*].assetProfile.watchedByCount',
'activities[*].comment',
'activities[*].fee',
'activities[*].feeInAssetProfileCurrency',
'activities[*].feeInBaseCurrency',
'activities[*].quantity',
/* @deprecated */
'activities[*].SymbolProfile.symbolMapping',
/* @deprecated */
'activities[*].SymbolProfile.watchedByCount',
'activities[*].value',
'activities[*].valueInBaseCurrency',
'balance',
@ -143,8 +150,13 @@ export const DEFAULT_REDACTED_PATHS = [
'platforms[*].balance',
'platforms[*].valueInBaseCurrency',
'quantity',
/* @deprecated */
'SymbolProfile.symbolMapping',
/* @deprecated */
'SymbolProfile.watchedByCount',
'totalBalanceInBaseCurrency',
'totalDividendInBaseCurrency',
'totalInterestInBaseCurrency',

8
libs/common/src/lib/interfaces/activities.interface.ts

@ -5,10 +5,16 @@ import { Order, Tag } from '@prisma/client';
export interface Activity extends Order {
account?: AccountWithPlatform;
assetProfile: EnhancedSymbolProfile;
error?: ActivityError;
feeInAssetProfileCurrency: number;
feeInBaseCurrency: number;
SymbolProfile: EnhancedSymbolProfile;
/**
* @deprecated Use `assetProfile` instead
*/
SymbolProfile?: EnhancedSymbolProfile;
tagIds?: string[];
tags?: Tag[];
unitPriceInAssetProfileCurrency: number;

6
libs/common/src/lib/interfaces/responses/public-portfolio-response.interface.ts

@ -26,7 +26,13 @@ export interface PublicPortfolioResponse extends PublicPortfolioResponseV1 {
Order,
'currency' | 'date' | 'fee' | 'quantity' | 'type' | 'unitPrice'
> & {
assetProfile?: EnhancedSymbolProfile;
/**
* @deprecated Use `assetProfile` instead
*/
SymbolProfile?: EnhancedSymbolProfile;
value: number;
valueInBaseCurrency: number;
})[];

16
libs/ui/src/lib/activities-table/activities-table.component.html

@ -149,9 +149,9 @@
<th *matHeaderCellDef class="px-1" mat-header-cell></th>
<td *matCellDef="let element" class="px-1 text-center" mat-cell>
<gf-entity-logo
[dataSource]="element.SymbolProfile?.dataSource"
[symbol]="element.SymbolProfile?.symbol"
[tooltip]="element.SymbolProfile?.name"
[dataSource]="element.assetProfile?.dataSource"
[symbol]="element.assetProfile?.symbol"
[tooltip]="element.assetProfile?.name"
/>
</td>
</ng-container>
@ -162,18 +162,18 @@
</th>
<td *matCellDef="let element" class="px-1" mat-cell>
<div class="align-items-center d-flex line-height-1 text-nowrap">
<span class="text-truncate">{{ element.SymbolProfile?.name }}</span>
<span class="text-truncate">{{ element.assetProfile?.name }}</span>
@if (element.isDraft) {
<span class="badge badge-secondary ml-1" i18n>Draft</span>
}
</div>
@if (
element.SymbolProfile?.dataSource !== 'MANUAL' &&
!isUUID(element.SymbolProfile?.symbol)
element.assetProfile?.dataSource !== 'MANUAL' &&
!isUUID(element.assetProfile?.symbol)
) {
<div>
<small class="text-muted">{{
element.SymbolProfile?.symbol | gfSymbol
element.assetProfile?.symbol | gfSymbol
}}</small>
</div>
}
@ -309,7 +309,7 @@
class="d-none d-lg-table-cell px-1"
mat-cell
>
{{ element.currency ?? element.SymbolProfile?.currency }}
{{ element.currency ?? element.assetProfile?.currency }}
</td>
</ng-container>

10
libs/ui/src/lib/activities-table/activities-table.component.stories.ts

@ -56,7 +56,7 @@ const activities: Activity[] = [
url: 'https://interactivebrokers.com'
}
},
SymbolProfile: {
assetProfile: {
assetClass: 'EQUITY',
assetSubClass: 'ETF',
comment: undefined,
@ -123,7 +123,7 @@ const activities: Activity[] = [
url: 'https://interactivebrokers.com'
}
},
SymbolProfile: {
assetProfile: {
assetClass: 'EQUITY',
assetSubClass: 'ETF',
comment: undefined,
@ -190,7 +190,7 @@ const activities: Activity[] = [
url: 'https://interactivebrokers.com'
}
},
SymbolProfile: {
assetProfile: {
assetClass: 'LIQUIDITY',
assetSubClass: 'CRYPTOCURRENCY',
comment: undefined,
@ -257,7 +257,7 @@ const activities: Activity[] = [
url: 'https://interactivebrokers.com'
}
},
SymbolProfile: {
assetProfile: {
assetClass: 'FIXED_INCOME',
assetSubClass: 'BOND',
comment: 'No data',
@ -324,7 +324,7 @@ const activities: Activity[] = [
url: 'https://interactivebrokers.com'
}
},
SymbolProfile: {
assetProfile: {
assetClass: 'EQUITY',
assetSubClass: 'ETF',
comment: undefined,

4
libs/ui/src/lib/activities-table/activities-table.component.ts

@ -285,8 +285,8 @@ export class GfActivitiesTableComponent implements AfterViewInit, OnInit {
}
} else if (this.canClickActivity(activity)) {
this.activityClicked.emit({
dataSource: activity.SymbolProfile.dataSource,
symbol: activity.SymbolProfile.symbol
dataSource: activity.assetProfile.dataSource,
symbol: activity.assetProfile.symbol
});
}
}

Loading…
Cancel
Save