Browse Source

Bugfix/fix portfolio calculation for holdings with same symbol from different data sources (#7664)

* Fix portfolio calculation for holdings with same symbol

* Update changelog
pull/7743/head
Thomas Kaul 1 day ago
committed by GitHub
parent
commit
01b6c7bd76
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 2
      CHANGELOG.md
  2. 2
      apps/api/src/app/endpoints/ai/ai.service.ts
  3. 41
      apps/api/src/app/endpoints/public/public.service.ts
  4. 2
      apps/api/src/app/portfolio/calculator/mwr/portfolio-calculator.ts
  5. 102
      apps/api/src/app/portfolio/calculator/portfolio-calculator.ts
  6. 175
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-from-two-data-sources.spec.ts
  7. 25
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts
  8. 2
      apps/api/src/app/portfolio/calculator/roi/portfolio-calculator.ts
  9. 2
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator.ts
  10. 32
      apps/api/src/app/portfolio/current-rate.service.mock.ts
  11. 16
      apps/api/src/app/portfolio/portfolio.controller.ts
  12. 91
      apps/api/src/app/portfolio/portfolio.service.spec.ts
  13. 69
      apps/api/src/app/portfolio/portfolio.service.ts
  14. 56
      apps/api/src/helper/object.helper.spec.ts
  15. 50
      apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts
  16. 65
      apps/client/src/app/pages/public/public-page.component.ts
  17. 2
      libs/common/src/lib/interfaces/portfolio-details.interface.ts
  18. 6
      libs/common/src/lib/interfaces/responses/public-portfolio-response.interface.ts
  19. 46
      libs/ui/src/lib/services/data.service.ts

2
CHANGELOG.md

@ -15,10 +15,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Improved the loading state of the symbol autocomplete component
- Consolidated the duplicated translations of the asset classes and asset sub classes
- Changed the holdings in the portfolio endpoints from a map keyed by the symbol to an array
### Fixed
- Improved the handling of indices in the _Financial Modeling Prep_ service
- Fixed the portfolio calculation for holdings with the same symbol from different data sources
## 3.62.0 - 2026-08-27

2
apps/api/src/app/endpoints/ai/ai.service.ts

@ -118,7 +118,7 @@ export class AiService {
values: Object.values(AssetSubClass)
});
const holdingsTableRows = Object.values(holdings)
const holdingsTableRows = [...holdings]
.sort((a, b) => {
return b.allocationInPercentage - a.allocationInPercentage;
})

41
apps/api/src/app/endpoints/public/public.service.ts

@ -133,7 +133,7 @@ export class PublicService {
latestActivities,
markets,
alias: access.alias,
holdings: {},
holdings: [],
performance: {
'1d': {
relativeChange:
@ -151,7 +151,7 @@ export class PublicService {
};
const totalValue = getSum(
Object.values(holdings).map(({ assetProfile, marketPrice, quantity }) => {
holdings.map(({ assetProfile, marketPrice, quantity }) => {
return new Big(
this.exchangeRateDataService.toCurrency(
quantity * marketPrice,
@ -162,33 +162,32 @@ export class PublicService {
})
).toNumber();
for (const [symbol, portfolioPosition] of Object.entries(holdings)) {
publicPortfolioResponse.holdings[symbol] = {
allocationInPercentage:
portfolioPosition.valueInBaseCurrency / totalValue,
for (const holding of holdings) {
publicPortfolioResponse.holdings.push({
allocationInPercentage: holding.valueInBaseCurrency / totalValue,
assetProfile: {
...portfolioPosition.assetProfile,
...holding.assetProfile,
assetClass:
hasDetails ||
portfolioPosition.assetProfile.assetClass === AssetClass.LIQUIDITY
? portfolioPosition.assetProfile.assetClass
holding.assetProfile.assetClass === AssetClass.LIQUIDITY
? holding.assetProfile.assetClass
: undefined,
assetClassLabel:
hasDetails ||
portfolioPosition.assetProfile.assetClass === AssetClass.LIQUIDITY
? portfolioPosition.assetProfile.assetClassLabel
holding.assetProfile.assetClass === AssetClass.LIQUIDITY
? holding.assetProfile.assetClassLabel
: undefined,
assetSubClass:
hasDetails ||
portfolioPosition.assetProfile.assetSubClass === AssetSubClass.CASH
? portfolioPosition.assetProfile.assetSubClass
holding.assetProfile.assetSubClass === AssetSubClass.CASH
? holding.assetProfile.assetSubClass
: undefined,
assetSubClassLabel:
hasDetails ||
portfolioPosition.assetProfile.assetSubClass === AssetSubClass.CASH
? portfolioPosition.assetProfile.assetSubClassLabel
holding.assetProfile.assetSubClass === AssetSubClass.CASH
? holding.assetProfile.assetSubClassLabel
: undefined,
holdings: portfolioPosition.assetProfile.holdings?.map(
holdings: holding.assetProfile.holdings?.map(
({ allocationInPercentage, name }) => {
return { allocationInPercentage, name };
}
@ -202,12 +201,12 @@ export class PublicService {
sectors: []
})
},
dateOfFirstActivity: portfolioPosition.dateOfFirstActivity,
markets: hasDetails ? portfolioPosition.markets : undefined,
dateOfFirstActivity: holding.dateOfFirstActivity,
markets: hasDetails ? holding.markets : undefined,
netPerformancePercentWithCurrencyEffect:
portfolioPosition.netPerformancePercentWithCurrencyEffect,
valueInPercentage: portfolioPosition.valueInBaseCurrency / totalValue
};
holding.netPerformancePercentWithCurrencyEffect,
valueInPercentage: holding.valueInBaseCurrency / totalValue
});
}
return publicPortfolioResponse;

2
apps/api/src/app/portfolio/calculator/mwr/portfolio-calculator.ts

@ -19,7 +19,7 @@ export class MwrPortfolioCalculator extends PortfolioCalculator {
end: Date;
exchangeRates: { [dateString: string]: number };
marketSymbolMap: {
[date: string]: { [symbol: string]: Big };
[date: string]: { [assetProfileIdentifier: string]: Big };
};
start: Date;
step?: number;

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

@ -22,6 +22,7 @@ import {
} from '@ghostfolio/common/config';
import {
DATE_FORMAT,
getAssetProfileIdentifier,
getSum,
parseDate,
resetHours
@ -72,8 +73,8 @@ export abstract class PortfolioCalculator {
protected accountBalanceItems: HistoricalDataItem[];
protected activities: PortfolioOrder[];
protected activitiesBySymbol: {
[symbol: string]: PortfolioOrder[];
protected activitiesByAssetProfileIdentifier: {
[assetProfileIdentifier: string]: PortfolioOrder[];
};
private configurationService: ConfigurationService;
@ -165,9 +166,12 @@ export abstract class PortfolioCalculator {
return a.date?.localeCompare(b.date);
});
this.activitiesBySymbol = groupBy(this.activities, ({ assetProfile }) => {
return assetProfile.symbol;
});
this.activitiesByAssetProfileIdentifier = groupBy(
this.activities,
({ assetProfile }) => {
return getAssetProfileIdentifier(assetProfile);
}
);
this.portfolioSnapshotService = portfolioSnapshotService;
this.redisCacheService = redisCacheService;
@ -221,8 +225,8 @@ export abstract class PortfolioCalculator {
};
}
const cashSymbols = new Set<string>();
const currencies: { [symbol: string]: string } = {};
const cashAssetProfileIdentifiers = new Set<string>();
const currencies: { [assetProfileIdentifier: string]: string } = {};
const dataGatheringItems: DataGatheringItem[] = [];
let firstIndex = transactionPoints.length;
let firstTransactionPoint: TransactionPoint = null;
@ -244,7 +248,7 @@ export abstract class PortfolioCalculator {
});
}
currencies[symbol] = currency;
currencies[getAssetProfileIdentifier({ dataSource, symbol })] = currency;
}
for (let i = 0; i < transactionPoints.length; i++) {
@ -280,7 +284,7 @@ export abstract class PortfolioCalculator {
this.dataProviderInfos = dataProviderInfos;
const marketSymbolMap: {
[date: string]: { [symbol: string]: Big };
[date: string]: { [assetProfileIdentifier: string]: Big };
} = {};
for (const marketSymbol of marketSymbols) {
@ -291,9 +295,8 @@ export abstract class PortfolioCalculator {
}
if (marketSymbol.marketPrice) {
marketSymbolMap[date][marketSymbol.symbol] = new Big(
marketSymbol.marketPrice
);
marketSymbolMap[date][getAssetProfileIdentifier(marketSymbol)] =
new Big(marketSymbol.marketPrice);
}
}
@ -346,8 +349,8 @@ export abstract class PortfolioCalculator {
};
} = {};
const valuesBySymbol: {
[symbol: string]: {
const valuesByAssetProfileIdentifier: {
[assetProfileIdentifier: string]: {
currentValues: { [date: string]: Big };
currentValuesWithCurrencyEffect: { [date: string]: Big };
investmentValuesAccumulated: { [date: string]: Big };
@ -362,8 +365,11 @@ export abstract class PortfolioCalculator {
} = {};
for (const item of lastTransactionPoint.items) {
const assetProfileIdentifier = getAssetProfileIdentifier(item);
const marketPriceInBaseCurrency = (
marketSymbolMap[endDateString]?.[item.symbol] ?? item.averagePrice
marketSymbolMap[endDateString]?.[assetProfileIdentifier] ??
item.averagePrice
).mul(
exchangeRatesByCurrency[`${item.currency}${this.currency}`]?.[
endDateString
@ -421,7 +427,8 @@ export abstract class PortfolioCalculator {
// contributes nothing but its balance to the performance calculation. It
// is therefore excluded from the value and the investment, while still
// contributing to the net worth.
valuesBySymbol[item.symbol] = isCashInBaseCurrency
valuesByAssetProfileIdentifier[assetProfileIdentifier] =
isCashInBaseCurrency
? {
currentValues: {},
currentValuesWithCurrencyEffect: {},
@ -474,7 +481,9 @@ export abstract class PortfolioCalculator {
investment: totalInvestment,
investmentWithCurrencyEffect: totalInvestmentWithCurrencyEffect,
marketPrice:
marketSymbolMap[endDateString]?.[item.symbol]?.toNumber() ?? 1,
marketSymbolMap[endDateString]?.[
assetProfileIdentifier
]?.toNumber() ?? 1,
marketPriceInBaseCurrency: marketPriceInBaseCurrency?.toNumber() ?? 1,
netPerformance: !hasErrors ? (netPerformance ?? null) : null,
netPerformancePercentage: !hasErrors
@ -493,7 +502,7 @@ export abstract class PortfolioCalculator {
});
if (item.assetSubClass === AssetSubClass.CASH) {
cashSymbols.add(item.symbol);
cashAssetProfileIdentifiers.add(assetProfileIdentifier);
totalCashInBaseCurrency =
totalCashInBaseCurrency.plus(valueInBaseCurrency);
@ -518,45 +527,51 @@ export abstract class PortfolioCalculator {
}
}
const assetProfileIdentifiers = Object.keys(valuesByAssetProfileIdentifier);
for (const dateString of chartDates) {
for (const symbol of Object.keys(valuesBySymbol)) {
const symbolValues = valuesBySymbol[symbol];
for (const assetProfileIdentifier of assetProfileIdentifiers) {
const assetProfileValues =
valuesByAssetProfileIdentifier[assetProfileIdentifier];
const currentValue =
symbolValues.currentValues?.[dateString] ?? new Big(0);
assetProfileValues.currentValues?.[dateString] ?? new Big(0);
const currentValueWithCurrencyEffect =
symbolValues.currentValuesWithCurrencyEffect?.[dateString] ??
assetProfileValues.currentValuesWithCurrencyEffect?.[dateString] ??
new Big(0);
const investmentValueAccumulated =
symbolValues.investmentValuesAccumulated?.[dateString] ?? new Big(0);
assetProfileValues.investmentValuesAccumulated?.[dateString] ??
new Big(0);
const investmentValueAccumulatedWithCurrencyEffect =
symbolValues.investmentValuesAccumulatedWithCurrencyEffect?.[
assetProfileValues.investmentValuesAccumulatedWithCurrencyEffect?.[
dateString
] ?? new Big(0);
const investmentValueWithCurrencyEffect =
symbolValues.investmentValuesWithCurrencyEffect?.[dateString] ??
assetProfileValues.investmentValuesWithCurrencyEffect?.[dateString] ??
new Big(0);
const netPerformanceValue =
symbolValues.netPerformanceValues?.[dateString] ?? new Big(0);
assetProfileValues.netPerformanceValues?.[dateString] ?? new Big(0);
const netPerformanceValueWithCurrencyEffect =
symbolValues.netPerformanceValuesWithCurrencyEffect?.[dateString] ??
new Big(0);
assetProfileValues.netPerformanceValuesWithCurrencyEffect?.[
dateString
] ?? new Big(0);
const netWorthValueWithCurrencyEffect =
symbolValues.netWorthValuesWithCurrencyEffect?.[dateString] ??
assetProfileValues.netWorthValuesWithCurrencyEffect?.[dateString] ??
new Big(0);
const timeWeightedInvestmentValue =
symbolValues.timeWeightedInvestmentValues?.[dateString] ?? new Big(0);
assetProfileValues.timeWeightedInvestmentValues?.[dateString] ??
new Big(0);
const timeWeightedInvestmentValueWithCurrencyEffect =
symbolValues.timeWeightedInvestmentValuesWithCurrencyEffect?.[
assetProfileValues.timeWeightedInvestmentValuesWithCurrencyEffect?.[
dateString
] ?? new Big(0);
@ -569,7 +584,7 @@ export abstract class PortfolioCalculator {
accumulatedValuesByDate[dateString]
?.totalCashValueWithCurrencyEffect ?? new Big(0)
).add(
cashSymbols.has(symbol)
cashAssetProfileIdentifiers.has(assetProfileIdentifier)
? netWorthValueWithCurrencyEffect
: new Big(0)
),
@ -879,7 +894,7 @@ export abstract class PortfolioCalculator {
end: Date;
exchangeRates: { [dateString: string]: number };
marketSymbolMap: {
[date: string]: { [symbol: string]: Big };
[date: string]: { [assetProfileIdentifier: string]: Big };
};
start: Date;
} & AssetProfileIdentifier): SymbolMetrics;
@ -977,7 +992,9 @@ export abstract class PortfolioCalculator {
@LogPerformance
private computeTransactionPoints() {
this.transactionPoints = [];
const symbols: { [symbol: string]: TransactionPointSymbol } = {};
const transactionPointSymbols: {
[assetProfileIdentifier: string]: TransactionPointSymbol;
} = {};
let lastDate: string = null;
let lastTransactionPoint: TransactionPoint = null;
@ -1001,7 +1018,10 @@ export abstract class PortfolioCalculator {
const skipErrors = !!assetProfile.userId; // Skip errors for custom asset profiles
const symbol = assetProfile.symbol;
const oldAccumulatedSymbol = symbols[symbol];
const assetProfileIdentifier = getAssetProfileIdentifier(assetProfile);
const oldAccumulatedSymbol =
transactionPointSymbols[assetProfileIdentifier];
if (oldAccumulatedSymbol) {
let investment = oldAccumulatedSymbol.investment;
@ -1083,18 +1103,22 @@ export abstract class PortfolioCalculator {
'id'
);
symbols[symbol] = currentTransactionPointItem;
transactionPointSymbols[assetProfileIdentifier] =
currentTransactionPointItem;
const items = lastTransactionPoint?.items ?? [];
const newItems = items.filter(({ symbol }) => {
return symbol !== assetProfile.symbol;
const newItems = items.filter((item) => {
return getAssetProfileIdentifier(item) !== assetProfileIdentifier;
});
newItems.push(currentTransactionPointItem);
newItems.sort((a, b) => {
return a.symbol?.localeCompare(b.symbol);
return (
a.symbol?.localeCompare(b.symbol) ||
a.dataSource?.localeCompare(b.dataSource)
);
});
let fees = new Big(0);

175
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-from-two-data-sources.spec.ts

@ -0,0 +1,175 @@
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 { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service.mock';
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;
})
};
});
jest.mock(
'@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service',
() => {
return {
ExchangeRateDataService: jest.fn().mockImplementation(() => {
return ExchangeRateDataServiceMock;
})
};
}
);
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, 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 MSFT buy from two data sources', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2023-07-10').getTime());
const activities: Activity[] = [
{
...activityDummyData,
assetProfile: {
...assetProfileDummyData,
currency: 'USD',
dataSource: 'YAHOO',
name: 'Microsoft Inc.',
symbol: 'MSFT'
},
date: new Date('2021-11-16'),
feeInAssetProfileCurrency: 0,
feeInBaseCurrency: 0,
quantity: 1,
type: 'BUY',
unitPriceInAssetProfileCurrency: 339.51
},
{
...activityDummyData,
assetProfile: {
...assetProfileDummyData,
currency: 'USD',
dataSource: 'EOD_HISTORICAL_DATA',
name: 'Microsoft Inc.',
symbol: 'MSFT'
},
date: new Date('2021-11-16'),
feeInAssetProfileCurrency: 0,
feeInBaseCurrency: 0,
quantity: 2,
type: 'BUY',
unitPriceInAssetProfileCurrency: 339.51
}
];
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.ROAI,
currency: 'USD',
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
// The holdings must not be aggregated, because they belong to two
// different asset profiles. Each holding must be valuated with the
// market price of its own data source.
expect(portfolioSnapshot.positions).toEqual([
expect.objectContaining({
activitiesCount: 1,
dataSource: 'EOD_HISTORICAL_DATA',
investment: new Big('679.02'),
marketPrice: 332.47,
quantity: new Big('2'),
symbol: 'MSFT',
valueInBaseCurrency: new Big('664.94')
}),
expect.objectContaining({
activitiesCount: 1,
dataSource: 'YAHOO',
investment: new Big('339.51'),
marketPrice: 331.83,
quantity: new Big('1'),
symbol: 'MSFT',
valueInBaseCurrency: new Big('331.83')
})
]);
expect(portfolioSnapshot.currentValueInBaseCurrency).toEqual(
new Big('996.77')
);
expect(portfolioSnapshot.totalInvestment).toEqual(new Big('1018.53'));
});
});
});

25
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts

@ -3,7 +3,11 @@ import { PortfolioCalculatorPosition } from '@ghostfolio/api/app/portfolio/inter
import { PortfolioOrderItem } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-order-item.interface';
import { getFactor } from '@ghostfolio/api/helper/portfolio.helper';
import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper';
import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper';
import {
DATE_FORMAT,
getAssetProfileIdentifier,
parseDate
} from '@ghostfolio/common/helper';
import {
AssetProfileIdentifier,
SymbolMetrics
@ -140,7 +144,7 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
end: Date;
exchangeRates: { [dateString: string]: number };
marketSymbolMap: {
[date: string]: { [symbol: string]: Big };
[date: string]: { [assetProfileIdentifier: string]: Big };
};
start: Date;
} & AssetProfileIdentifier): SymbolMetrics {
@ -192,10 +196,15 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
let valueAtStartDate: Big;
let valueAtStartDateWithCurrencyEffect: Big;
const assetProfileIdentifier = getAssetProfileIdentifier({
dataSource,
symbol
});
// Copy the items as they are enriched below. A shallow copy is sufficient
// because only top-level properties are written.
let orders: PortfolioOrderItem[] = (
this.activitiesBySymbol[symbol] ?? []
this.activitiesByAssetProfileIdentifier[assetProfileIdentifier] ?? []
).map((activity) => {
return { ...activity };
});
@ -275,8 +284,11 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
const endDateString = format(end, DATE_FORMAT);
const startDateString = format(start, DATE_FORMAT);
const unitPriceAtStartDate = marketSymbolMap[startDateString]?.[symbol];
let unitPriceAtEndDate = marketSymbolMap[endDateString]?.[symbol];
const unitPriceAtStartDate =
marketSymbolMap[startDateString]?.[assetProfileIdentifier];
let unitPriceAtEndDate =
marketSymbolMap[endDateString]?.[assetProfileIdentifier];
const latestActivity = orders.at(-1);
@ -391,7 +403,8 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
break;
}
const unitPrice = marketSymbolMap[dateString]?.[symbol] ?? lastUnitPrice;
const unitPrice =
marketSymbolMap[dateString]?.[assetProfileIdentifier] ?? lastUnitPrice;
if (ordersByDate[dateString]?.length > 0) {
for (const order of ordersByDate[dateString]) {

2
apps/api/src/app/portfolio/calculator/roi/portfolio-calculator.ts

@ -19,7 +19,7 @@ export class RoiPortfolioCalculator extends PortfolioCalculator {
end: Date;
exchangeRates: { [dateString: string]: number };
marketSymbolMap: {
[date: string]: { [symbol: string]: Big };
[date: string]: { [assetProfileIdentifier: string]: Big };
};
start: Date;
step?: number;

2
apps/api/src/app/portfolio/calculator/twr/portfolio-calculator.ts

@ -19,7 +19,7 @@ export class TwrPortfolioCalculator extends PortfolioCalculator {
end: Date;
exchangeRates: { [dateString: string]: number };
marketSymbolMap: {
[date: string]: { [symbol: string]: Big };
[date: string]: { [assetProfileIdentifier: string]: Big };
};
start: Date;
step?: number;

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

@ -1,5 +1,6 @@
import { parseDate, resetHours } from '@ghostfolio/common/helper';
import { DataSource } from '@prisma/client';
import {
addDays,
eachDayOfInterval,
@ -12,7 +13,15 @@ import { GetValueObject } from './interfaces/get-value-object.interface';
import { GetValuesObject } from './interfaces/get-values-object.interface';
import { GetValuesParams } from './interfaces/get-values-params.interface';
function mockGetValue(symbol: string, date: Date) {
function mockGetValue({
dataSource,
date,
symbol
}: {
dataSource: DataSource;
date: Date;
symbol: string;
}) {
switch (symbol) {
case '55196015-1365-4560-aa60-8751ae6d18f8':
if (isSameDay(parseDate('2022-01-31'), date)) {
@ -83,7 +92,12 @@ function mockGetValue(symbol: string, date: Date) {
} else if (isSameDay(parseDate('2023-07-09'), date)) {
return { marketPrice: 337.22 };
} else if (isSameDay(parseDate('2023-07-10'), date)) {
return { marketPrice: 331.83 };
// Deviating market prices per data source to verify that the market
// price is resolved by the asset profile identifier
return {
marketPrice:
dataSource === DataSource.EOD_HISTORICAL_DATA ? 332.47 : 331.83
};
}
return { marketPrice: 0 };
@ -117,8 +131,11 @@ export const CurrentRateServiceMock = {
values.push({
date,
dataSource: dataGatheringItem.dataSource,
marketPrice: mockGetValue(dataGatheringItem.symbol, date)
.marketPrice,
marketPrice: mockGetValue({
date,
dataSource: dataGatheringItem.dataSource,
symbol: dataGatheringItem.symbol
}).marketPrice,
symbol: dataGatheringItem.symbol
});
}
@ -132,8 +149,11 @@ export const CurrentRateServiceMock = {
values.push({
date,
dataSource: dataGatheringItem.dataSource,
marketPrice: mockGetValue(dataGatheringItem.symbol, date)
.marketPrice,
marketPrice: mockGetValue({
date,
dataSource: dataGatheringItem.dataSource,
symbol: dataGatheringItem.symbol
}).marketPrice,
symbol: dataGatheringItem.symbol
});
}

16
apps/api/src/app/portfolio/portfolio.controller.ts

@ -128,13 +128,13 @@ export class PortfolioController {
!hasScope(impersonationScopes, scopes.portfolioReadValues) ||
isRestrictedView(this.request.user)
) {
const totalInvestment = Object.values(holdings)
const totalInvestment = holdings
.map(({ investment }) => {
return investment;
})
.reduce((a, b) => a + b, 0);
const totalValue = Object.values(holdings)
const totalValue = holdings
.filter(({ assetProfile }) => {
return (
assetProfile.assetClass !== AssetClass.LIQUIDITY &&
@ -148,11 +148,9 @@ export class PortfolioController {
return a + b;
}, 0);
for (const [, portfolioPosition] of Object.entries(holdings)) {
portfolioPosition.investment =
portfolioPosition.investment / totalInvestment;
portfolioPosition.valueInPercentage =
portfolioPosition.valueInBaseCurrency / totalValue;
for (const holding of holdings) {
holding.investment = holding.investment / totalInvestment;
holding.valueInPercentage = holding.valueInBaseCurrency / totalValue;
}
for (const [name, { valueInBaseCurrency }] of Object.entries(accounts)) {
@ -204,8 +202,8 @@ export class PortfolioController {
]);
}
for (const [symbol, portfolioPosition] of Object.entries(holdings)) {
holdings[symbol] = {
for (const [index, portfolioPosition] of holdings.entries()) {
holdings[index] = {
...portfolioPosition,
assetProfile: {
...portfolioPosition.assetProfile,

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

@ -9,7 +9,7 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration/con
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
import { TAG_ID_EMERGENCY_FUND, UNKNOWN_KEY } from '@ghostfolio/common/config';
import { parseDate } from '@ghostfolio/common/helper';
import {
AssetProfileIdentifier,
@ -115,10 +115,10 @@ describe('PortfolioService', () => {
});
describe('getAggregatedMarkets', () => {
const getAggregatedMarkets = (holdings: object) => {
const getAggregatedMarkets = (holdings: object[]) => {
return (
portfolioService as unknown as {
getAggregatedMarkets: (aHoldings: object) => {
getAggregatedMarkets: (aHoldings: object[]) => {
markets: Record<
string,
{ valueInBaseCurrency: number; valueInPercentage: number }
@ -130,9 +130,9 @@ describe('PortfolioService', () => {
};
it('should distribute holdings with countries to their market and route holdings without countries (e.g. commodities, cryptocurrencies) to the unknown bucket', () => {
const holdings = {
'GC=F': {
// Gold
const holdings = [
{
// Gold (GC=F)
assetProfile: { countries: [] },
markets: { developedMarkets: 0, emergingMarkets: 0, otherMarkets: 0 },
marketsAdvanced: {
@ -145,7 +145,8 @@ describe('PortfolioService', () => {
},
valueInBaseCurrency: 500
},
MSFT: {
{
// MSFT
assetProfile: { countries: [{ code: 'US', weight: 1 }] },
markets: { developedMarkets: 1, emergingMarkets: 0, otherMarkets: 0 },
marketsAdvanced: {
@ -158,7 +159,7 @@ describe('PortfolioService', () => {
},
valueInBaseCurrency: 1000
}
};
];
const { markets, marketsAdvanced } = getAggregatedMarkets(holdings);
@ -213,15 +214,16 @@ describe('PortfolioService', () => {
});
describe('getDetails', () => {
it('should return cash holdings when the calculator emits cash positions with the exchange-rate data source', async () => {
const accountId = randomUUID();
const setUpCashOnlyPortfolio = ({
baseCurrency = 'CHF',
emergencyFund
}: { baseCurrency?: string; emergencyFund?: number } = {}) => {
const cashAccount: AccountWithBalance = {
balance: 2000,
comment: null,
createdAt: parseDate('2024-01-01'),
currency: 'USD',
id: accountId,
id: randomUUID(),
name: 'USD',
platformId: null,
updatedAt: parseDate('2024-01-01'),
@ -252,7 +254,8 @@ describe('PortfolioService', () => {
id: userDummyData.id,
settings: {
settings: {
baseCurrency: 'CHF'
baseCurrency,
emergencyFund
}
}
} as unknown as Awaited<ReturnType<typeof userService.user>>);
@ -319,15 +322,37 @@ describe('PortfolioService', () => {
'getValueOfAccountsAndPlatforms'
)
.mockResolvedValue({ accounts: {}, platforms: {} });
};
it('should return cash holdings when the calculator emits cash positions with the exchange-rate data source', async () => {
setUpCashOnlyPortfolio();
const { holdings } = await portfolioService.getDetails({
filters: [],
userId: userDummyData.id
});
expect(holdings['USD']).toBeDefined();
expect(holdings['USD'].assetProfile.dataSource).toBe(DataSource.YAHOO);
expect(holdings['USD'].assetProfile.symbol).toBe('USD');
expect(holdings).toEqual([
expect.objectContaining({
assetProfile: expect.objectContaining({
dataSource: DataSource.YAHOO,
symbol: 'USD'
})
})
]);
});
it('should replace the existing cash holding instead of adding a second one when filtering by the emergency fund tag', async () => {
setUpCashOnlyPortfolio({ baseCurrency: 'USD', emergencyFund: 1000 });
const { holdings } = await portfolioService.getDetails({
filters: [{ id: TAG_ID_EMERGENCY_FUND, type: 'TAG' }],
userId: userDummyData.id
});
expect(holdings).toHaveLength(1);
expect(holdings[0].assetProfile.symbol).toBe('USD');
expect(holdings[0].valueInBaseCurrency).toBe(1000);
});
});
@ -450,22 +475,22 @@ describe('PortfolioService', () => {
{
account,
accountId: account.id,
assetProfile: { symbol: 'AAPL' },
assetProfile: { dataSource: DataSource.YAHOO, symbol: 'AAPL' },
quantity: 1,
type: 'BUY'
},
{
account: null,
accountId: null,
assetProfile: { symbol: 'BABA' },
assetProfile: { dataSource: DataSource.YAHOO, symbol: 'BABA' },
quantity: 2,
type: 'BUY'
}
],
filters: [],
portfolioItemsNow: {
AAPL: { marketPriceInBaseCurrency: 10 },
BABA: { marketPriceInBaseCurrency: 20 }
'YAHOO-AAPL': { marketPriceInBaseCurrency: 10 },
'YAHOO-BABA': { marketPriceInBaseCurrency: 20 }
},
userCurrency: 'USD',
userId: userDummyData.id
@ -486,14 +511,14 @@ describe('PortfolioService', () => {
{
account,
accountId: account.id,
assetProfile: { symbol: 'AAPL' },
assetProfile: { dataSource: DataSource.YAHOO, symbol: 'AAPL' },
quantity: 1,
type: 'BUY'
}
],
filters: [],
portfolioItemsNow: {
AAPL: { marketPriceInBaseCurrency: 10 }
'YAHOO-AAPL': { marketPriceInBaseCurrency: 10 }
},
userCurrency: 'USD',
userId: userDummyData.id
@ -509,28 +534,28 @@ describe('PortfolioService', () => {
{
account,
accountId: account.id,
assetProfile: { symbol: 'AAPL' },
assetProfile: { dataSource: DataSource.YAHOO, symbol: 'AAPL' },
quantity: 0.1,
type: 'BUY'
},
{
account,
accountId: account.id,
assetProfile: { symbol: 'AAPL' },
assetProfile: { dataSource: DataSource.YAHOO, symbol: 'AAPL' },
quantity: 0.2,
type: 'BUY'
},
{
account,
accountId: account.id,
assetProfile: { symbol: 'AAPL' },
assetProfile: { dataSource: DataSource.YAHOO, symbol: 'AAPL' },
quantity: 0.3,
type: 'SELL'
}
],
filters: [],
portfolioItemsNow: {
AAPL: { marketPriceInBaseCurrency: 1234.5678 }
'YAHOO-AAPL': { marketPriceInBaseCurrency: 1234.5678 }
},
userCurrency: 'USD',
userId: userDummyData.id
@ -547,21 +572,21 @@ describe('PortfolioService', () => {
{
account,
accountId: account.id,
assetProfile: { symbol: 'AAPL' },
assetProfile: { dataSource: DataSource.YAHOO, symbol: 'AAPL' },
quantity: 0.1,
type: 'BUY'
},
{
account,
accountId: account.id,
assetProfile: { symbol: 'AAPL' },
assetProfile: { dataSource: DataSource.YAHOO, symbol: 'AAPL' },
quantity: 0.2,
type: 'BUY'
}
],
filters: [{ id: 'AAPL', type: 'SYMBOL' }],
portfolioItemsNow: {
AAPL: { marketPriceInBaseCurrency: 10 }
'YAHOO-AAPL': { marketPriceInBaseCurrency: 10 }
},
userCurrency: 'USD',
userId: userDummyData.id
@ -576,14 +601,14 @@ describe('PortfolioService', () => {
{
account,
accountId: account.id,
assetProfile: { symbol: 'AAPL' },
assetProfile: { dataSource: DataSource.YAHOO, symbol: 'AAPL' },
quantity: 1,
type: 'BUY'
}
],
filters: [],
portfolioItemsNow: {
AAPL: { marketPriceInBaseCurrency: 10 }
'YAHOO-AAPL': { marketPriceInBaseCurrency: 10 }
},
userCurrency: 'USD',
userId: userDummyData.id
@ -616,14 +641,14 @@ describe('PortfolioService', () => {
{
account,
accountId: account.id,
assetProfile: { symbol: 'AAPL' },
assetProfile: { dataSource: DataSource.YAHOO, symbol: 'AAPL' },
quantity: 1,
type: 'BUY'
}
],
filters: [{ id: 'AAPL', type: 'SYMBOL' }],
portfolioItemsNow: {
AAPL: { marketPriceInBaseCurrency: 10 }
'YAHOO-AAPL': { marketPriceInBaseCurrency: 10 }
},
userCurrency: 'USD',
userId: userDummyData.id

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

@ -397,14 +397,12 @@ export class PortfolioService {
return type !== 'SEARCH_QUERY';
});
const { holdings: holdingsMap } = await this.getDetails({
let { holdings } = await this.getDetails({
dateRange,
userId,
filters: filtersWithoutSearchQueryFilter
});
let holdings = Object.values(holdingsMap);
if (filterBySearchQuery) {
const fuse = new Fuse(holdings, {
keys: ['assetProfile.isin', 'assetProfile.name', 'assetProfile.symbol'],
@ -546,7 +544,7 @@ export class PortfolioService {
currency: userCurrency
});
const holdings: PortfolioDetails['holdings'] = {};
const holdings: PortfolioDetails['holdings'] = [];
const {
HOLDING_TYPE: [filterByHoldingType] = [],
@ -586,9 +584,12 @@ export class PortfolioService {
] = symbolProfile;
}
const portfolioItemsNow: { [symbol: string]: TimelinePosition } = {};
const portfolioItemsNow: {
[assetProfileIdentifier: string]: TimelinePosition;
} = {};
for (const position of positions) {
portfolioItemsNow[position.symbol] = position;
portfolioItemsNow[getAssetProfileIdentifier(position)] = position;
}
for (const {
@ -643,7 +644,7 @@ export class PortfolioService {
}));
}
holdings[symbol] = {
holdings.push({
activitiesCount,
markets,
marketsAdvanced,
@ -694,7 +695,7 @@ export class PortfolioService {
netPerformanceWithCurrencyEffectMap?.[dateRange]?.toNumber() ?? 0,
quantity: quantity.toNumber(),
valueInBaseCurrency: valueInBaseCurrency.toNumber()
};
});
}
const { accounts, platforms } = await this.getValueOfAccountsAndPlatforms({
@ -730,11 +731,29 @@ export class PortfolioService {
valueInBaseCurrency: emergencyFundInCash
};
holdings[userCurrency] = {
const emergencyFundCashHolding = {
...emergencyFundCashPositions[userCurrency],
investment: emergencyFundInCash,
valueInBaseCurrency: emergencyFundInCash
};
const emergencyFundCashHoldingAssetProfileIdentifier =
getAssetProfileIdentifier(emergencyFundCashHolding.assetProfile);
const indexOfEmergencyFundCashHolding = holdings.findIndex(
({ assetProfile }) => {
return (
getAssetProfileIdentifier(assetProfile) ===
emergencyFundCashHoldingAssetProfileIdentifier
);
}
);
if (indexOfEmergencyFundCashHolding >= 0) {
holdings[indexOfEmergencyFundCashHolding] = emergencyFundCashHolding;
} else {
holdings.push(emergencyFundCashHolding);
}
}
let markets: PortfolioDetails['markets'];
@ -1157,7 +1176,7 @@ export class PortfolioService {
userSettings
}).toNumber();
const hasOpenHoldings = Object.keys(holdings).length > 0;
const hasOpenHoldings = holdings.length > 0;
const marketsAdvancedTotalInBaseCurrency = getSum(
Object.values(marketsAdvanced).map(({ valueInBaseCurrency }) => {
@ -1234,13 +1253,13 @@ export class PortfolioService {
new CurrencyClusterRiskBaseCurrencyCurrentInvestment(
this.exchangeRateDataService,
this.i18nService,
Object.values(holdings),
holdings,
userSettings.language
),
new CurrencyClusterRiskCurrentInvestment(
this.exchangeRateDataService,
this.i18nService,
Object.values(holdings),
holdings,
userSettings.language
)
],
@ -1261,13 +1280,13 @@ export class PortfolioService {
this.exchangeRateDataService,
this.i18nService,
userSettings.language,
Object.values(holdings)
holdings
),
new AssetClassClusterRiskFixedIncome(
this.exchangeRateDataService,
this.i18nService,
userSettings.language,
Object.values(holdings)
holdings
)
],
userSettings
@ -1428,7 +1447,7 @@ export class PortfolioService {
});
}
private getAggregatedMarkets(holdings: Record<string, PortfolioPosition>): {
private getAggregatedMarkets(holdings: PortfolioPosition[]): {
markets: PortfolioDetails['markets'];
marketsAdvanced: PortfolioDetails['marketsAdvanced'];
} {
@ -1493,7 +1512,7 @@ export class PortfolioService {
}
};
for (const [, position] of Object.entries(holdings)) {
for (const position of holdings) {
const value = position.valueInBaseCurrency;
if (position.assetProfile.countries.length > 0) {
@ -1574,7 +1593,7 @@ export class PortfolioService {
userCurrency: string;
value: Big;
}) {
const cashPositions: PortfolioDetails['holdings'] = {
const cashPositions: { [currency: string]: PortfolioPosition } = {
[userCurrency]: this.getInitialCashPosition({
balance: 0,
currency: userCurrency
@ -1603,12 +1622,10 @@ export class PortfolioService {
}
}
for (const symbol of Object.keys(cashPositions)) {
for (const cashPosition of Object.values(cashPositions)) {
// Calculate allocations for each currency
cashPositions[symbol].allocationInPercentage = value.gt(0)
? new Big(cashPositions[symbol].valueInBaseCurrency)
.div(value)
.toNumber()
cashPosition.allocationInPercentage = value.gt(0)
? new Big(cashPosition.valueInBaseCurrency).div(value).toNumber()
: 0;
}
@ -1715,7 +1732,7 @@ export class PortfolioService {
}) {
// TODO: Use current value of activities instead of holdings
// tagged with EMERGENCY_FUND_TAG_ID
const emergencyFundHoldings = Object.values(holdings).filter(({ tags }) => {
const emergencyFundHoldings = holdings.filter(({ tags }) => {
return (
tags?.some(({ id }) => {
return id === TAG_ID_EMERGENCY_FUND;
@ -1748,7 +1765,7 @@ export class PortfolioService {
assetClass: AssetClass.LIQUIDITY,
assetSubClass: AssetSubClass.CASH,
countries: [],
dataSource: undefined,
dataSource: this.dataProviderService.getDataSourceForExchangeRates(),
holdings: [],
name: currency,
sectors: [],
@ -2258,8 +2275,8 @@ export class PortfolioService {
valueOfAccountInBaseCurrency = valueOfAccountInBaseCurrency.plus(
currentQuantityOfSymbol.mul(
portfolioItemsNow[assetProfile.symbol]?.marketPriceInBaseCurrency ??
0
portfolioItemsNow[getAssetProfileIdentifier(assetProfile)]
?.marketPriceInBaseCurrency ?? 0
)
);
}

56
apps/api/src/helper/object.helper.spec.ts

@ -109,8 +109,8 @@ describe('redactAttributes', () => {
}
},
hasError: false,
holdings: {
'AAPL.US': {
holdings: [
{
activitiesCount: 1,
currency: 'USD',
markets: {
@ -162,7 +162,7 @@ describe('redactAttributes', () => {
valueInBaseCurrency: 11039.5,
valueInPercentage: 0.0694356974830054
},
'ALV.DE': {
{
activitiesCount: 2,
currency: 'EUR',
markets: {
@ -209,7 +209,7 @@ describe('redactAttributes', () => {
valueInBaseCurrency: 6616.826601205088,
valueInPercentage: 0.04161818652826481
},
AMZN: {
{
activitiesCount: 1,
currency: 'USD',
markets: {
@ -261,7 +261,7 @@ describe('redactAttributes', () => {
valueInBaseCurrency: 18799,
valueInPercentage: 0.11824101426541227
},
bitcoin: {
{
activitiesCount: 1,
currency: 'USD',
markets: {
@ -312,7 +312,7 @@ describe('redactAttributes', () => {
valueInBaseCurrency: 36985.0332704,
valueInPercentage: 0.232626620912395
},
BONDORA_GO_AND_GROW: {
{
activitiesCount: 5,
currency: 'EUR',
markets: {
@ -363,7 +363,7 @@ describe('redactAttributes', () => {
valueInBaseCurrency: 2231.644722160232,
valueInPercentage: 0.014036487867880205
},
FRANKLY95P: {
{
activitiesCount: 6,
currency: 'CHF',
markets: {
@ -487,7 +487,7 @@ describe('redactAttributes', () => {
valueInBaseCurrency: 22363.19795483481,
valueInPercentage: 0.14065892911313693
},
MSFT: {
{
activitiesCount: 1,
currency: 'USD',
markets: {
@ -539,7 +539,7 @@ describe('redactAttributes', () => {
valueInBaseCurrency: 12840.6,
valueInPercentage: 0.08076416659271518
},
TSLA: {
{
activitiesCount: 1,
currency: 'USD',
markets: {
@ -591,7 +591,7 @@ describe('redactAttributes', () => {
valueInBaseCurrency: 39069,
valueInPercentage: 0.2457342510950259
},
VTI: {
{
activitiesCount: 5,
currency: 'USD',
markets: {
@ -763,7 +763,7 @@ describe('redactAttributes', () => {
valueInBaseCurrency: 14102.5,
valueInPercentage: 0.08870120238725339
},
'VWRL.SW': {
{
activitiesCount: 5,
currency: 'CHF',
markets: {
@ -1171,7 +1171,7 @@ describe('redactAttributes', () => {
valueInBaseCurrency: 23079.20085622547,
valueInPercentage: 0.145162408515095
},
'XDWD.DE': {
{
activitiesCount: 1,
currency: 'EUR',
markets: {
@ -1449,7 +1449,7 @@ describe('redactAttributes', () => {
valueInBaseCurrency: 8847.35550100424,
valueInPercentage: 0.055647656152211074
},
USD: {
{
activitiesCount: 0,
currency: 'USD',
allocationInPercentage: 0.20291717628620132,
@ -1476,7 +1476,7 @@ describe('redactAttributes', () => {
valueInBaseCurrency: 49890,
valueInPercentage: 0.3137956381563603
}
},
],
platforms: {
'a5b14588-49a0-48e4-b9f7-e186b27860b7': {
balance: 0,
@ -1613,8 +1613,8 @@ describe('redactAttributes', () => {
}
},
hasError: false,
holdings: {
'AAPL.US': {
holdings: [
{
activitiesCount: 1,
currency: 'USD',
markets: {
@ -1666,7 +1666,7 @@ describe('redactAttributes', () => {
valueInBaseCurrency: null,
valueInPercentage: 0.0694356974830054
},
'ALV.DE': {
{
activitiesCount: 2,
currency: 'EUR',
markets: {
@ -1713,7 +1713,7 @@ describe('redactAttributes', () => {
valueInBaseCurrency: null,
valueInPercentage: 0.04161818652826481
},
AMZN: {
{
activitiesCount: 1,
currency: 'USD',
markets: {
@ -1765,7 +1765,7 @@ describe('redactAttributes', () => {
valueInBaseCurrency: null,
valueInPercentage: 0.11824101426541227
},
bitcoin: {
{
activitiesCount: 1,
currency: 'USD',
markets: {
@ -1816,7 +1816,7 @@ describe('redactAttributes', () => {
valueInBaseCurrency: null,
valueInPercentage: 0.232626620912395
},
BONDORA_GO_AND_GROW: {
{
activitiesCount: 5,
currency: 'EUR',
markets: {
@ -1867,7 +1867,7 @@ describe('redactAttributes', () => {
valueInBaseCurrency: null,
valueInPercentage: 0.014036487867880205
},
FRANKLY95P: {
{
activitiesCount: 6,
currency: 'CHF',
markets: {
@ -1971,7 +1971,7 @@ describe('redactAttributes', () => {
valueInBaseCurrency: null,
valueInPercentage: 0.14065892911313693
},
MSFT: {
{
activitiesCount: 1,
currency: 'USD',
markets: {
@ -2023,7 +2023,7 @@ describe('redactAttributes', () => {
valueInBaseCurrency: null,
valueInPercentage: 0.08076416659271518
},
TSLA: {
{
activitiesCount: 1,
currency: 'USD',
markets: {
@ -2075,7 +2075,7 @@ describe('redactAttributes', () => {
valueInBaseCurrency: null,
valueInPercentage: 0.2457342510950259
},
VTI: {
{
activitiesCount: 5,
currency: 'USD',
markets: {
@ -2247,7 +2247,7 @@ describe('redactAttributes', () => {
valueInBaseCurrency: null,
valueInPercentage: 0.08870120238725339
},
'VWRL.SW': {
{
activitiesCount: 5,
currency: 'CHF',
markets: {
@ -2647,7 +2647,7 @@ describe('redactAttributes', () => {
valueInBaseCurrency: null,
valueInPercentage: 0.145162408515095
},
'XDWD.DE': {
{
activitiesCount: 1,
currency: 'EUR',
markets: {
@ -2925,7 +2925,7 @@ describe('redactAttributes', () => {
valueInBaseCurrency: null,
valueInPercentage: 0.055647656152211074
},
USD: {
{
activitiesCount: 0,
currency: 'USD',
allocationInPercentage: 0.20291717628620132,
@ -2952,7 +2952,7 @@ describe('redactAttributes', () => {
valueInBaseCurrency: null,
valueInPercentage: 0.3137956381563603
}
},
],
platforms: {
'a5b14588-49a0-48e4-b9f7-e186b27860b7': {
balance: null,

50
apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts

@ -8,6 +8,7 @@ import { UserService } from '@ghostfolio/client/services/user/user.service';
import { MAX_TOP_HOLDINGS, UNKNOWN_KEY } from '@ghostfolio/common/config';
import {
canOpenHoldingDetail,
getAssetProfileIdentifier,
getCountryName
} from '@ghostfolio/common/helper';
import {
@ -87,7 +88,7 @@ export class GfAllocationsPageComponent implements OnInit {
() => this.deviceDetectorService.deviceInfo().deviceType
);
protected holdings: {
[symbol: string]: Pick<
[assetProfileIdentifier: string]: Pick<
PortfolioPosition['assetProfile'],
| 'assetClass'
| 'assetClassLabel'
@ -118,7 +119,7 @@ export class GfAllocationsPageComponent implements OnInit {
[name: string]: { name: string; value: number };
};
protected symbols: {
[name: string]: {
[symbol: string]: {
dataSource?: DataSource;
isClickable?: boolean;
name: string;
@ -329,7 +330,7 @@ export class GfAllocationsPageComponent implements OnInit {
this.portfolioDetails = {
accounts: {},
createdAt: new Date(),
holdings: {},
holdings: [],
platforms: {},
summary: undefined
};
@ -369,10 +370,12 @@ export class GfAllocationsPageComponent implements OnInit {
};
}
for (const [symbol, position] of Object.entries(
this.portfolioDetails.holdings
)) {
this.holdings[symbol] = {
for (const position of this.portfolioDetails.holdings) {
const assetProfileIdentifier = getAssetProfileIdentifier(
position.assetProfile
);
this.holdings[assetProfileIdentifier] = {
assetClass:
position.assetProfile.assetClass || (UNKNOWN_KEY as AssetClass),
assetClassLabel: position.assetProfile.assetClassLabel ?? UNKNOWN_KEY,
@ -498,21 +501,34 @@ export class GfAllocationsPageComponent implements OnInit {
}
}
if (this.holdings[symbol].assetSubClass === 'ETF') {
this.totalValueInEtf += this.holdings[symbol].value;
if (this.holdings[assetProfileIdentifier].assetSubClass === 'ETF') {
this.totalValueInEtf += this.holdings[assetProfileIdentifier].value;
}
const symbol = position.assetProfile.symbol;
const value =
(isNumber(position.valueInBaseCurrency)
? position.valueInBaseCurrency
: position.valueInPercentage) ?? 0;
const symbolData = this.symbols[symbol];
if (symbolData) {
// Aggregate holdings with the same symbol from different data sources
symbolData.dataSource = undefined;
symbolData.isClickable = false;
symbolData.value += value;
} else {
this.symbols[symbol] = {
symbol,
value,
dataSource: position.assetProfile.dataSource,
isClickable: canOpenHoldingDetail(position),
name: position.assetProfile.name ?? '',
value:
(isNumber(position.valueInBaseCurrency)
? position.valueInBaseCurrency
: position.valueInPercentage) ?? 0
name: position.assetProfile.name ?? ''
};
}
}
this.markets = this.portfolioDetails.markets;
@ -558,8 +574,8 @@ export class GfAllocationsPageComponent implements OnInit {
name,
allocationInPercentage:
this.totalValueInEtf > 0 ? value / this.totalValueInEtf : 0,
parents: Object.entries(this.portfolioDetails.holdings)
.map(([symbol, holding]) => {
parents: this.portfolioDetails.holdings
.map((holding) => {
if (holding.assetProfile.holdings.length > 0) {
const currentParentHolding = holding.assetProfile.holdings.find(
(parentHolding) => {
@ -573,11 +589,11 @@ export class GfAllocationsPageComponent implements OnInit {
return currentParentHolding &&
isNumber(currentParentHolding.valueInBaseCurrency)
? {
symbol,
allocationInPercentage:
currentParentHolding.valueInBaseCurrency / value,
name: holding.assetProfile.name ?? '',
position: holding,
symbol: holding.assetProfile.symbol,
valueInBaseCurrency:
currentParentHolding.valueInBaseCurrency
}

65
apps/client/src/app/pages/public/public-page.component.ts

@ -1,5 +1,8 @@
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
import { getCountryName } from '@ghostfolio/common/helper';
import {
getAssetProfileIdentifier,
getCountryName
} from '@ghostfolio/common/helper';
import {
InfoItem,
PortfolioPosition,
@ -67,7 +70,7 @@ export class GfPublicPageComponent implements OnInit {
() => this.deviceDetectorService.deviceInfo().deviceType
);
protected hasPermissionForSubscription: boolean;
protected holdings: PublicPortfolioResponse['holdings'][string][];
protected holdings: PublicPortfolioResponse['holdings'];
protected info: InfoItem;
protected isLoading = true;
protected latestActivitiesDataSource: MatTableDataSource<
@ -78,7 +81,7 @@ export class GfPublicPageComponent implements OnInit {
};
protected readonly pageSize = Number.MAX_SAFE_INTEGER;
protected positions: {
[symbol: string]: Pick<
[assetProfileIdentifier: string]: Pick<
PortfolioPosition['assetProfile'],
'currency' | 'name'
> & {
@ -90,7 +93,7 @@ export class GfPublicPageComponent implements OnInit {
[name: string]: { name: string; value: number };
};
protected symbols: {
[name: string]: { name: string; symbol: string; value: number };
[symbol: string]: { name: string; symbol: string; value: number };
};
protected readonly UNKNOWN_KEY = UNKNOWN_KEY;
@ -175,12 +178,14 @@ export class GfPublicPageComponent implements OnInit {
}
};
for (const [symbol, position] of Object.entries(
this.publicPortfolioDetails.holdings
)) {
for (const position of this.publicPortfolioDetails.holdings) {
const assetProfileIdentifier = getAssetProfileIdentifier(
position.assetProfile
);
this.holdings.push(position);
this.positions[symbol] = {
this.positions[assetProfileIdentifier] = {
currency: position.assetProfile.currency,
name: position.assetProfile.name,
value: position.allocationInPercentage
@ -199,10 +204,7 @@ export class GfPublicPageComponent implements OnInit {
} else {
this.continents[continent] = {
name: translate(continent),
value:
weight *
(this.publicPortfolioDetails.holdings[symbol]
.valueInBaseCurrency ?? 0)
value: weight * (position.valueInBaseCurrency ?? 0)
};
}
@ -212,21 +214,16 @@ export class GfPublicPageComponent implements OnInit {
} else {
this.countries[code] = {
name: getCountryName({ code }),
value:
weight *
(this.publicPortfolioDetails.holdings[symbol]
.valueInBaseCurrency ?? 0)
value: weight * (position.valueInBaseCurrency ?? 0)
};
}
}
} else {
this.continents[UNKNOWN_KEY].value +=
this.publicPortfolioDetails.holdings[symbol].valueInBaseCurrency ??
0;
position.valueInBaseCurrency ?? 0;
this.countries[UNKNOWN_KEY].value +=
this.publicPortfolioDetails.holdings[symbol].valueInBaseCurrency ??
0;
position.valueInBaseCurrency ?? 0;
}
if (position.assetProfile.sectors.length > 0) {
@ -239,27 +236,33 @@ export class GfPublicPageComponent implements OnInit {
} else {
this.sectors[name] = {
name: translate(name),
value:
weight *
(this.publicPortfolioDetails.holdings[symbol]
.valueInBaseCurrency ?? 0)
value: weight * (position.valueInBaseCurrency ?? 0)
};
}
}
} else {
this.sectors[UNKNOWN_KEY].value +=
this.publicPortfolioDetails.holdings[symbol].valueInBaseCurrency ??
0;
this.sectors[UNKNOWN_KEY].value += position.valueInBaseCurrency ?? 0;
}
}
const symbol = position.assetProfile.symbol;
const value = isNumber(position.valueInBaseCurrency)
? position.valueInBaseCurrency
: (position.valueInPercentage ?? 0);
const symbolData = this.symbols[symbol];
if (symbolData) {
// Aggregate holdings with the same symbol from different data sources
symbolData.value += value;
} else {
this.symbols[symbol] = {
symbol,
name: position.assetProfile.name ?? symbol,
value: isNumber(position.valueInBaseCurrency)
? position.valueInBaseCurrency
: (position.valueInPercentage ?? 0)
value,
name: position.assetProfile.name ?? symbol
};
}
}
}
}

2
libs/common/src/lib/interfaces/portfolio-details.interface.ts

@ -17,7 +17,7 @@ export interface PortfolioDetails {
};
};
createdAt: Date;
holdings: { [symbol: string]: PortfolioPosition };
holdings: PortfolioPosition[];
markets?: {
[key in Market]: {
id: Market;

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

@ -10,8 +10,7 @@ import { Order } from '@prisma/client';
export interface PublicPortfolioResponse extends PublicPortfolioResponseV1 {
alias?: string;
hasDetails: boolean;
holdings: {
[symbol: string]: Pick<
holdings: Pick<
PortfolioPosition,
| 'allocationInPercentage'
| 'assetProfile'
@ -20,8 +19,7 @@ export interface PublicPortfolioResponse extends PublicPortfolioResponseV1 {
| 'netPerformancePercentWithCurrencyEffect'
| 'valueInBaseCurrency'
| 'valueInPercentage'
>;
};
>[];
latestActivities: (Pick<
Order,
'currency' | 'date' | 'fee' | 'quantity' | 'type' | 'unitPrice'

46
libs/ui/src/lib/services/data.service.ts

@ -647,24 +647,22 @@ export class DataService {
.pipe(
map((response) => {
if (response.holdings) {
for (const symbol of Object.keys(response.holdings)) {
response.holdings[symbol].assetProfile.assetClassLabel =
translate(response.holdings[symbol].assetProfile.assetClass);
for (const holding of response.holdings) {
holding.assetProfile.assetClassLabel = translate(
holding.assetProfile.assetClass
);
response.holdings[symbol].assetProfile.assetSubClassLabel =
translate(response.holdings[symbol].assetProfile.assetSubClass);
holding.assetProfile.assetSubClassLabel = translate(
holding.assetProfile.assetSubClass
);
response.holdings[symbol].dateOfFirstActivity = response.holdings[
symbol
].dateOfFirstActivity
? parseISO(response.holdings[symbol].dateOfFirstActivity)
holding.dateOfFirstActivity = holding.dateOfFirstActivity
? parseISO(holding.dateOfFirstActivity)
: undefined;
response.holdings[symbol].value = isNumber(
response.holdings[symbol].value
)
? response.holdings[symbol].value
: response.holdings[symbol].valueInPercentage;
holding.value = isNumber(holding.value)
? holding.value
: holding.valueInPercentage;
}
}
@ -782,18 +780,20 @@ export class DataService {
.pipe(
map((response) => {
if (response.holdings) {
for (const symbol of Object.keys(response.holdings)) {
response.holdings[symbol].assetProfile.assetClassLabel =
translate(response.holdings[symbol].assetProfile.assetClass);
for (const holding of response.holdings) {
holding.assetProfile.assetClassLabel = translate(
holding.assetProfile.assetClass
);
response.holdings[symbol].assetProfile.assetSubClassLabel =
translate(response.holdings[symbol].assetProfile.assetSubClass);
holding.assetProfile.assetSubClassLabel = translate(
holding.assetProfile.assetSubClass
);
response.holdings[symbol].valueInBaseCurrency = isNumber(
response.holdings[symbol].valueInBaseCurrency
holding.valueInBaseCurrency = isNumber(
holding.valueInBaseCurrency
)
? response.holdings[symbol].valueInBaseCurrency
: response.holdings[symbol].valueInPercentage;
? holding.valueInBaseCurrency
: holding.valueInPercentage;
}
}

Loading…
Cancel
Save