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 - Improved the loading state of the symbol autocomplete component
- Consolidated the duplicated translations of the asset classes and asset sub classes - 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 ### Fixed
- Improved the handling of indices in the _Financial Modeling Prep_ service - 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 ## 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) values: Object.values(AssetSubClass)
}); });
const holdingsTableRows = Object.values(holdings) const holdingsTableRows = [...holdings]
.sort((a, b) => { .sort((a, b) => {
return b.allocationInPercentage - a.allocationInPercentage; return b.allocationInPercentage - a.allocationInPercentage;
}) })

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

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

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

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

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

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

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

@ -19,7 +19,7 @@ export class TwrPortfolioCalculator extends PortfolioCalculator {
end: Date; end: Date;
exchangeRates: { [dateString: string]: number }; exchangeRates: { [dateString: string]: number };
marketSymbolMap: { marketSymbolMap: {
[date: string]: { [symbol: string]: Big }; [date: string]: { [assetProfileIdentifier: string]: Big };
}; };
start: Date; start: Date;
step?: number; 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 { parseDate, resetHours } from '@ghostfolio/common/helper';
import { DataSource } from '@prisma/client';
import { import {
addDays, addDays,
eachDayOfInterval, eachDayOfInterval,
@ -12,7 +13,15 @@ import { GetValueObject } from './interfaces/get-value-object.interface';
import { GetValuesObject } from './interfaces/get-values-object.interface'; import { GetValuesObject } from './interfaces/get-values-object.interface';
import { GetValuesParams } from './interfaces/get-values-params.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) { switch (symbol) {
case '55196015-1365-4560-aa60-8751ae6d18f8': case '55196015-1365-4560-aa60-8751ae6d18f8':
if (isSameDay(parseDate('2022-01-31'), date)) { 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)) { } else if (isSameDay(parseDate('2023-07-09'), date)) {
return { marketPrice: 337.22 }; return { marketPrice: 337.22 };
} else if (isSameDay(parseDate('2023-07-10'), date)) { } 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 }; return { marketPrice: 0 };
@ -117,8 +131,11 @@ export const CurrentRateServiceMock = {
values.push({ values.push({
date, date,
dataSource: dataGatheringItem.dataSource, dataSource: dataGatheringItem.dataSource,
marketPrice: mockGetValue(dataGatheringItem.symbol, date) marketPrice: mockGetValue({
.marketPrice, date,
dataSource: dataGatheringItem.dataSource,
symbol: dataGatheringItem.symbol
}).marketPrice,
symbol: dataGatheringItem.symbol symbol: dataGatheringItem.symbol
}); });
} }
@ -132,8 +149,11 @@ export const CurrentRateServiceMock = {
values.push({ values.push({
date, date,
dataSource: dataGatheringItem.dataSource, dataSource: dataGatheringItem.dataSource,
marketPrice: mockGetValue(dataGatheringItem.symbol, date) marketPrice: mockGetValue({
.marketPrice, date,
dataSource: dataGatheringItem.dataSource,
symbol: dataGatheringItem.symbol
}).marketPrice,
symbol: dataGatheringItem.symbol symbol: dataGatheringItem.symbol
}); });
} }

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

@ -128,13 +128,13 @@ export class PortfolioController {
!hasScope(impersonationScopes, scopes.portfolioReadValues) || !hasScope(impersonationScopes, scopes.portfolioReadValues) ||
isRestrictedView(this.request.user) isRestrictedView(this.request.user)
) { ) {
const totalInvestment = Object.values(holdings) const totalInvestment = holdings
.map(({ investment }) => { .map(({ investment }) => {
return investment; return investment;
}) })
.reduce((a, b) => a + b, 0); .reduce((a, b) => a + b, 0);
const totalValue = Object.values(holdings) const totalValue = holdings
.filter(({ assetProfile }) => { .filter(({ assetProfile }) => {
return ( return (
assetProfile.assetClass !== AssetClass.LIQUIDITY && assetProfile.assetClass !== AssetClass.LIQUIDITY &&
@ -148,11 +148,9 @@ export class PortfolioController {
return a + b; return a + b;
}, 0); }, 0);
for (const [, portfolioPosition] of Object.entries(holdings)) { for (const holding of holdings) {
portfolioPosition.investment = holding.investment = holding.investment / totalInvestment;
portfolioPosition.investment / totalInvestment; holding.valueInPercentage = holding.valueInBaseCurrency / totalValue;
portfolioPosition.valueInPercentage =
portfolioPosition.valueInBaseCurrency / totalValue;
} }
for (const [name, { valueInBaseCurrency }] of Object.entries(accounts)) { for (const [name, { valueInBaseCurrency }] of Object.entries(accounts)) {
@ -204,8 +202,8 @@ export class PortfolioController {
]); ]);
} }
for (const [symbol, portfolioPosition] of Object.entries(holdings)) { for (const [index, portfolioPosition] of holdings.entries()) {
holdings[symbol] = { holdings[index] = {
...portfolioPosition, ...portfolioPosition,
assetProfile: { assetProfile: {
...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 { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.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 { 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 { parseDate } from '@ghostfolio/common/helper';
import { import {
AssetProfileIdentifier, AssetProfileIdentifier,
@ -115,10 +115,10 @@ describe('PortfolioService', () => {
}); });
describe('getAggregatedMarkets', () => { describe('getAggregatedMarkets', () => {
const getAggregatedMarkets = (holdings: object) => { const getAggregatedMarkets = (holdings: object[]) => {
return ( return (
portfolioService as unknown as { portfolioService as unknown as {
getAggregatedMarkets: (aHoldings: object) => { getAggregatedMarkets: (aHoldings: object[]) => {
markets: Record< markets: Record<
string, string,
{ valueInBaseCurrency: number; valueInPercentage: number } { 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', () => { it('should distribute holdings with countries to their market and route holdings without countries (e.g. commodities, cryptocurrencies) to the unknown bucket', () => {
const holdings = { const holdings = [
'GC=F': { {
// Gold // Gold (GC=F)
assetProfile: { countries: [] }, assetProfile: { countries: [] },
markets: { developedMarkets: 0, emergingMarkets: 0, otherMarkets: 0 }, markets: { developedMarkets: 0, emergingMarkets: 0, otherMarkets: 0 },
marketsAdvanced: { marketsAdvanced: {
@ -145,7 +145,8 @@ describe('PortfolioService', () => {
}, },
valueInBaseCurrency: 500 valueInBaseCurrency: 500
}, },
MSFT: { {
// MSFT
assetProfile: { countries: [{ code: 'US', weight: 1 }] }, assetProfile: { countries: [{ code: 'US', weight: 1 }] },
markets: { developedMarkets: 1, emergingMarkets: 0, otherMarkets: 0 }, markets: { developedMarkets: 1, emergingMarkets: 0, otherMarkets: 0 },
marketsAdvanced: { marketsAdvanced: {
@ -158,7 +159,7 @@ describe('PortfolioService', () => {
}, },
valueInBaseCurrency: 1000 valueInBaseCurrency: 1000
} }
}; ];
const { markets, marketsAdvanced } = getAggregatedMarkets(holdings); const { markets, marketsAdvanced } = getAggregatedMarkets(holdings);
@ -213,15 +214,16 @@ describe('PortfolioService', () => {
}); });
describe('getDetails', () => { describe('getDetails', () => {
it('should return cash holdings when the calculator emits cash positions with the exchange-rate data source', async () => { const setUpCashOnlyPortfolio = ({
const accountId = randomUUID(); baseCurrency = 'CHF',
emergencyFund
}: { baseCurrency?: string; emergencyFund?: number } = {}) => {
const cashAccount: AccountWithBalance = { const cashAccount: AccountWithBalance = {
balance: 2000, balance: 2000,
comment: null, comment: null,
createdAt: parseDate('2024-01-01'), createdAt: parseDate('2024-01-01'),
currency: 'USD', currency: 'USD',
id: accountId, id: randomUUID(),
name: 'USD', name: 'USD',
platformId: null, platformId: null,
updatedAt: parseDate('2024-01-01'), updatedAt: parseDate('2024-01-01'),
@ -252,7 +254,8 @@ describe('PortfolioService', () => {
id: userDummyData.id, id: userDummyData.id,
settings: { settings: {
settings: { settings: {
baseCurrency: 'CHF' baseCurrency,
emergencyFund
} }
} }
} as unknown as Awaited<ReturnType<typeof userService.user>>); } as unknown as Awaited<ReturnType<typeof userService.user>>);
@ -319,15 +322,37 @@ describe('PortfolioService', () => {
'getValueOfAccountsAndPlatforms' 'getValueOfAccountsAndPlatforms'
) )
.mockResolvedValue({ accounts: {}, platforms: {} }); .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({ const { holdings } = await portfolioService.getDetails({
filters: [], filters: [],
userId: userDummyData.id userId: userDummyData.id
}); });
expect(holdings['USD']).toBeDefined(); expect(holdings).toEqual([
expect(holdings['USD'].assetProfile.dataSource).toBe(DataSource.YAHOO); expect.objectContaining({
expect(holdings['USD'].assetProfile.symbol).toBe('USD'); 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, account,
accountId: account.id, accountId: account.id,
assetProfile: { symbol: 'AAPL' }, assetProfile: { dataSource: DataSource.YAHOO, symbol: 'AAPL' },
quantity: 1, quantity: 1,
type: 'BUY' type: 'BUY'
}, },
{ {
account: null, account: null,
accountId: null, accountId: null,
assetProfile: { symbol: 'BABA' }, assetProfile: { dataSource: DataSource.YAHOO, symbol: 'BABA' },
quantity: 2, quantity: 2,
type: 'BUY' type: 'BUY'
} }
], ],
filters: [], filters: [],
portfolioItemsNow: { portfolioItemsNow: {
AAPL: { marketPriceInBaseCurrency: 10 }, 'YAHOO-AAPL': { marketPriceInBaseCurrency: 10 },
BABA: { marketPriceInBaseCurrency: 20 } 'YAHOO-BABA': { marketPriceInBaseCurrency: 20 }
}, },
userCurrency: 'USD', userCurrency: 'USD',
userId: userDummyData.id userId: userDummyData.id
@ -486,14 +511,14 @@ describe('PortfolioService', () => {
{ {
account, account,
accountId: account.id, accountId: account.id,
assetProfile: { symbol: 'AAPL' }, assetProfile: { dataSource: DataSource.YAHOO, symbol: 'AAPL' },
quantity: 1, quantity: 1,
type: 'BUY' type: 'BUY'
} }
], ],
filters: [], filters: [],
portfolioItemsNow: { portfolioItemsNow: {
AAPL: { marketPriceInBaseCurrency: 10 } 'YAHOO-AAPL': { marketPriceInBaseCurrency: 10 }
}, },
userCurrency: 'USD', userCurrency: 'USD',
userId: userDummyData.id userId: userDummyData.id
@ -509,28 +534,28 @@ describe('PortfolioService', () => {
{ {
account, account,
accountId: account.id, accountId: account.id,
assetProfile: { symbol: 'AAPL' }, assetProfile: { dataSource: DataSource.YAHOO, symbol: 'AAPL' },
quantity: 0.1, quantity: 0.1,
type: 'BUY' type: 'BUY'
}, },
{ {
account, account,
accountId: account.id, accountId: account.id,
assetProfile: { symbol: 'AAPL' }, assetProfile: { dataSource: DataSource.YAHOO, symbol: 'AAPL' },
quantity: 0.2, quantity: 0.2,
type: 'BUY' type: 'BUY'
}, },
{ {
account, account,
accountId: account.id, accountId: account.id,
assetProfile: { symbol: 'AAPL' }, assetProfile: { dataSource: DataSource.YAHOO, symbol: 'AAPL' },
quantity: 0.3, quantity: 0.3,
type: 'SELL' type: 'SELL'
} }
], ],
filters: [], filters: [],
portfolioItemsNow: { portfolioItemsNow: {
AAPL: { marketPriceInBaseCurrency: 1234.5678 } 'YAHOO-AAPL': { marketPriceInBaseCurrency: 1234.5678 }
}, },
userCurrency: 'USD', userCurrency: 'USD',
userId: userDummyData.id userId: userDummyData.id
@ -547,21 +572,21 @@ describe('PortfolioService', () => {
{ {
account, account,
accountId: account.id, accountId: account.id,
assetProfile: { symbol: 'AAPL' }, assetProfile: { dataSource: DataSource.YAHOO, symbol: 'AAPL' },
quantity: 0.1, quantity: 0.1,
type: 'BUY' type: 'BUY'
}, },
{ {
account, account,
accountId: account.id, accountId: account.id,
assetProfile: { symbol: 'AAPL' }, assetProfile: { dataSource: DataSource.YAHOO, symbol: 'AAPL' },
quantity: 0.2, quantity: 0.2,
type: 'BUY' type: 'BUY'
} }
], ],
filters: [{ id: 'AAPL', type: 'SYMBOL' }], filters: [{ id: 'AAPL', type: 'SYMBOL' }],
portfolioItemsNow: { portfolioItemsNow: {
AAPL: { marketPriceInBaseCurrency: 10 } 'YAHOO-AAPL': { marketPriceInBaseCurrency: 10 }
}, },
userCurrency: 'USD', userCurrency: 'USD',
userId: userDummyData.id userId: userDummyData.id
@ -576,14 +601,14 @@ describe('PortfolioService', () => {
{ {
account, account,
accountId: account.id, accountId: account.id,
assetProfile: { symbol: 'AAPL' }, assetProfile: { dataSource: DataSource.YAHOO, symbol: 'AAPL' },
quantity: 1, quantity: 1,
type: 'BUY' type: 'BUY'
} }
], ],
filters: [], filters: [],
portfolioItemsNow: { portfolioItemsNow: {
AAPL: { marketPriceInBaseCurrency: 10 } 'YAHOO-AAPL': { marketPriceInBaseCurrency: 10 }
}, },
userCurrency: 'USD', userCurrency: 'USD',
userId: userDummyData.id userId: userDummyData.id
@ -616,14 +641,14 @@ describe('PortfolioService', () => {
{ {
account, account,
accountId: account.id, accountId: account.id,
assetProfile: { symbol: 'AAPL' }, assetProfile: { dataSource: DataSource.YAHOO, symbol: 'AAPL' },
quantity: 1, quantity: 1,
type: 'BUY' type: 'BUY'
} }
], ],
filters: [{ id: 'AAPL', type: 'SYMBOL' }], filters: [{ id: 'AAPL', type: 'SYMBOL' }],
portfolioItemsNow: { portfolioItemsNow: {
AAPL: { marketPriceInBaseCurrency: 10 } 'YAHOO-AAPL': { marketPriceInBaseCurrency: 10 }
}, },
userCurrency: 'USD', userCurrency: 'USD',
userId: userDummyData.id userId: userDummyData.id

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

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

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

@ -109,8 +109,8 @@ describe('redactAttributes', () => {
} }
}, },
hasError: false, hasError: false,
holdings: { holdings: [
'AAPL.US': { {
activitiesCount: 1, activitiesCount: 1,
currency: 'USD', currency: 'USD',
markets: { markets: {
@ -162,7 +162,7 @@ describe('redactAttributes', () => {
valueInBaseCurrency: 11039.5, valueInBaseCurrency: 11039.5,
valueInPercentage: 0.0694356974830054 valueInPercentage: 0.0694356974830054
}, },
'ALV.DE': { {
activitiesCount: 2, activitiesCount: 2,
currency: 'EUR', currency: 'EUR',
markets: { markets: {
@ -209,7 +209,7 @@ describe('redactAttributes', () => {
valueInBaseCurrency: 6616.826601205088, valueInBaseCurrency: 6616.826601205088,
valueInPercentage: 0.04161818652826481 valueInPercentage: 0.04161818652826481
}, },
AMZN: { {
activitiesCount: 1, activitiesCount: 1,
currency: 'USD', currency: 'USD',
markets: { markets: {
@ -261,7 +261,7 @@ describe('redactAttributes', () => {
valueInBaseCurrency: 18799, valueInBaseCurrency: 18799,
valueInPercentage: 0.11824101426541227 valueInPercentage: 0.11824101426541227
}, },
bitcoin: { {
activitiesCount: 1, activitiesCount: 1,
currency: 'USD', currency: 'USD',
markets: { markets: {
@ -312,7 +312,7 @@ describe('redactAttributes', () => {
valueInBaseCurrency: 36985.0332704, valueInBaseCurrency: 36985.0332704,
valueInPercentage: 0.232626620912395 valueInPercentage: 0.232626620912395
}, },
BONDORA_GO_AND_GROW: { {
activitiesCount: 5, activitiesCount: 5,
currency: 'EUR', currency: 'EUR',
markets: { markets: {
@ -363,7 +363,7 @@ describe('redactAttributes', () => {
valueInBaseCurrency: 2231.644722160232, valueInBaseCurrency: 2231.644722160232,
valueInPercentage: 0.014036487867880205 valueInPercentage: 0.014036487867880205
}, },
FRANKLY95P: { {
activitiesCount: 6, activitiesCount: 6,
currency: 'CHF', currency: 'CHF',
markets: { markets: {
@ -487,7 +487,7 @@ describe('redactAttributes', () => {
valueInBaseCurrency: 22363.19795483481, valueInBaseCurrency: 22363.19795483481,
valueInPercentage: 0.14065892911313693 valueInPercentage: 0.14065892911313693
}, },
MSFT: { {
activitiesCount: 1, activitiesCount: 1,
currency: 'USD', currency: 'USD',
markets: { markets: {
@ -539,7 +539,7 @@ describe('redactAttributes', () => {
valueInBaseCurrency: 12840.6, valueInBaseCurrency: 12840.6,
valueInPercentage: 0.08076416659271518 valueInPercentage: 0.08076416659271518
}, },
TSLA: { {
activitiesCount: 1, activitiesCount: 1,
currency: 'USD', currency: 'USD',
markets: { markets: {
@ -591,7 +591,7 @@ describe('redactAttributes', () => {
valueInBaseCurrency: 39069, valueInBaseCurrency: 39069,
valueInPercentage: 0.2457342510950259 valueInPercentage: 0.2457342510950259
}, },
VTI: { {
activitiesCount: 5, activitiesCount: 5,
currency: 'USD', currency: 'USD',
markets: { markets: {
@ -763,7 +763,7 @@ describe('redactAttributes', () => {
valueInBaseCurrency: 14102.5, valueInBaseCurrency: 14102.5,
valueInPercentage: 0.08870120238725339 valueInPercentage: 0.08870120238725339
}, },
'VWRL.SW': { {
activitiesCount: 5, activitiesCount: 5,
currency: 'CHF', currency: 'CHF',
markets: { markets: {
@ -1171,7 +1171,7 @@ describe('redactAttributes', () => {
valueInBaseCurrency: 23079.20085622547, valueInBaseCurrency: 23079.20085622547,
valueInPercentage: 0.145162408515095 valueInPercentage: 0.145162408515095
}, },
'XDWD.DE': { {
activitiesCount: 1, activitiesCount: 1,
currency: 'EUR', currency: 'EUR',
markets: { markets: {
@ -1449,7 +1449,7 @@ describe('redactAttributes', () => {
valueInBaseCurrency: 8847.35550100424, valueInBaseCurrency: 8847.35550100424,
valueInPercentage: 0.055647656152211074 valueInPercentage: 0.055647656152211074
}, },
USD: { {
activitiesCount: 0, activitiesCount: 0,
currency: 'USD', currency: 'USD',
allocationInPercentage: 0.20291717628620132, allocationInPercentage: 0.20291717628620132,
@ -1476,7 +1476,7 @@ describe('redactAttributes', () => {
valueInBaseCurrency: 49890, valueInBaseCurrency: 49890,
valueInPercentage: 0.3137956381563603 valueInPercentage: 0.3137956381563603
} }
}, ],
platforms: { platforms: {
'a5b14588-49a0-48e4-b9f7-e186b27860b7': { 'a5b14588-49a0-48e4-b9f7-e186b27860b7': {
balance: 0, balance: 0,
@ -1613,8 +1613,8 @@ describe('redactAttributes', () => {
} }
}, },
hasError: false, hasError: false,
holdings: { holdings: [
'AAPL.US': { {
activitiesCount: 1, activitiesCount: 1,
currency: 'USD', currency: 'USD',
markets: { markets: {
@ -1666,7 +1666,7 @@ describe('redactAttributes', () => {
valueInBaseCurrency: null, valueInBaseCurrency: null,
valueInPercentage: 0.0694356974830054 valueInPercentage: 0.0694356974830054
}, },
'ALV.DE': { {
activitiesCount: 2, activitiesCount: 2,
currency: 'EUR', currency: 'EUR',
markets: { markets: {
@ -1713,7 +1713,7 @@ describe('redactAttributes', () => {
valueInBaseCurrency: null, valueInBaseCurrency: null,
valueInPercentage: 0.04161818652826481 valueInPercentage: 0.04161818652826481
}, },
AMZN: { {
activitiesCount: 1, activitiesCount: 1,
currency: 'USD', currency: 'USD',
markets: { markets: {
@ -1765,7 +1765,7 @@ describe('redactAttributes', () => {
valueInBaseCurrency: null, valueInBaseCurrency: null,
valueInPercentage: 0.11824101426541227 valueInPercentage: 0.11824101426541227
}, },
bitcoin: { {
activitiesCount: 1, activitiesCount: 1,
currency: 'USD', currency: 'USD',
markets: { markets: {
@ -1816,7 +1816,7 @@ describe('redactAttributes', () => {
valueInBaseCurrency: null, valueInBaseCurrency: null,
valueInPercentage: 0.232626620912395 valueInPercentage: 0.232626620912395
}, },
BONDORA_GO_AND_GROW: { {
activitiesCount: 5, activitiesCount: 5,
currency: 'EUR', currency: 'EUR',
markets: { markets: {
@ -1867,7 +1867,7 @@ describe('redactAttributes', () => {
valueInBaseCurrency: null, valueInBaseCurrency: null,
valueInPercentage: 0.014036487867880205 valueInPercentage: 0.014036487867880205
}, },
FRANKLY95P: { {
activitiesCount: 6, activitiesCount: 6,
currency: 'CHF', currency: 'CHF',
markets: { markets: {
@ -1971,7 +1971,7 @@ describe('redactAttributes', () => {
valueInBaseCurrency: null, valueInBaseCurrency: null,
valueInPercentage: 0.14065892911313693 valueInPercentage: 0.14065892911313693
}, },
MSFT: { {
activitiesCount: 1, activitiesCount: 1,
currency: 'USD', currency: 'USD',
markets: { markets: {
@ -2023,7 +2023,7 @@ describe('redactAttributes', () => {
valueInBaseCurrency: null, valueInBaseCurrency: null,
valueInPercentage: 0.08076416659271518 valueInPercentage: 0.08076416659271518
}, },
TSLA: { {
activitiesCount: 1, activitiesCount: 1,
currency: 'USD', currency: 'USD',
markets: { markets: {
@ -2075,7 +2075,7 @@ describe('redactAttributes', () => {
valueInBaseCurrency: null, valueInBaseCurrency: null,
valueInPercentage: 0.2457342510950259 valueInPercentage: 0.2457342510950259
}, },
VTI: { {
activitiesCount: 5, activitiesCount: 5,
currency: 'USD', currency: 'USD',
markets: { markets: {
@ -2247,7 +2247,7 @@ describe('redactAttributes', () => {
valueInBaseCurrency: null, valueInBaseCurrency: null,
valueInPercentage: 0.08870120238725339 valueInPercentage: 0.08870120238725339
}, },
'VWRL.SW': { {
activitiesCount: 5, activitiesCount: 5,
currency: 'CHF', currency: 'CHF',
markets: { markets: {
@ -2647,7 +2647,7 @@ describe('redactAttributes', () => {
valueInBaseCurrency: null, valueInBaseCurrency: null,
valueInPercentage: 0.145162408515095 valueInPercentage: 0.145162408515095
}, },
'XDWD.DE': { {
activitiesCount: 1, activitiesCount: 1,
currency: 'EUR', currency: 'EUR',
markets: { markets: {
@ -2925,7 +2925,7 @@ describe('redactAttributes', () => {
valueInBaseCurrency: null, valueInBaseCurrency: null,
valueInPercentage: 0.055647656152211074 valueInPercentage: 0.055647656152211074
}, },
USD: { {
activitiesCount: 0, activitiesCount: 0,
currency: 'USD', currency: 'USD',
allocationInPercentage: 0.20291717628620132, allocationInPercentage: 0.20291717628620132,
@ -2952,7 +2952,7 @@ describe('redactAttributes', () => {
valueInBaseCurrency: null, valueInBaseCurrency: null,
valueInPercentage: 0.3137956381563603 valueInPercentage: 0.3137956381563603
} }
}, ],
platforms: { platforms: {
'a5b14588-49a0-48e4-b9f7-e186b27860b7': { 'a5b14588-49a0-48e4-b9f7-e186b27860b7': {
balance: null, 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 { MAX_TOP_HOLDINGS, UNKNOWN_KEY } from '@ghostfolio/common/config';
import { import {
canOpenHoldingDetail, canOpenHoldingDetail,
getAssetProfileIdentifier,
getCountryName getCountryName
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
import { import {
@ -87,7 +88,7 @@ export class GfAllocationsPageComponent implements OnInit {
() => this.deviceDetectorService.deviceInfo().deviceType () => this.deviceDetectorService.deviceInfo().deviceType
); );
protected holdings: { protected holdings: {
[symbol: string]: Pick< [assetProfileIdentifier: string]: Pick<
PortfolioPosition['assetProfile'], PortfolioPosition['assetProfile'],
| 'assetClass' | 'assetClass'
| 'assetClassLabel' | 'assetClassLabel'
@ -118,7 +119,7 @@ export class GfAllocationsPageComponent implements OnInit {
[name: string]: { name: string; value: number }; [name: string]: { name: string; value: number };
}; };
protected symbols: { protected symbols: {
[name: string]: { [symbol: string]: {
dataSource?: DataSource; dataSource?: DataSource;
isClickable?: boolean; isClickable?: boolean;
name: string; name: string;
@ -329,7 +330,7 @@ export class GfAllocationsPageComponent implements OnInit {
this.portfolioDetails = { this.portfolioDetails = {
accounts: {}, accounts: {},
createdAt: new Date(), createdAt: new Date(),
holdings: {}, holdings: [],
platforms: {}, platforms: {},
summary: undefined summary: undefined
}; };
@ -369,10 +370,12 @@ export class GfAllocationsPageComponent implements OnInit {
}; };
} }
for (const [symbol, position] of Object.entries( for (const position of this.portfolioDetails.holdings) {
this.portfolioDetails.holdings const assetProfileIdentifier = getAssetProfileIdentifier(
)) { position.assetProfile
this.holdings[symbol] = { );
this.holdings[assetProfileIdentifier] = {
assetClass: assetClass:
position.assetProfile.assetClass || (UNKNOWN_KEY as AssetClass), position.assetProfile.assetClass || (UNKNOWN_KEY as AssetClass),
assetClassLabel: position.assetProfile.assetClassLabel ?? UNKNOWN_KEY, assetClassLabel: position.assetProfile.assetClassLabel ?? UNKNOWN_KEY,
@ -498,21 +501,34 @@ export class GfAllocationsPageComponent implements OnInit {
} }
} }
if (this.holdings[symbol].assetSubClass === 'ETF') { if (this.holdings[assetProfileIdentifier].assetSubClass === 'ETF') {
this.totalValueInEtf += this.holdings[symbol].value; 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] = { this.symbols[symbol] = {
symbol, symbol,
value,
dataSource: position.assetProfile.dataSource, dataSource: position.assetProfile.dataSource,
isClickable: canOpenHoldingDetail(position), isClickable: canOpenHoldingDetail(position),
name: position.assetProfile.name ?? '', name: position.assetProfile.name ?? ''
value:
(isNumber(position.valueInBaseCurrency)
? position.valueInBaseCurrency
: position.valueInPercentage) ?? 0
}; };
} }
}
this.markets = this.portfolioDetails.markets; this.markets = this.portfolioDetails.markets;
@ -558,8 +574,8 @@ export class GfAllocationsPageComponent implements OnInit {
name, name,
allocationInPercentage: allocationInPercentage:
this.totalValueInEtf > 0 ? value / this.totalValueInEtf : 0, this.totalValueInEtf > 0 ? value / this.totalValueInEtf : 0,
parents: Object.entries(this.portfolioDetails.holdings) parents: this.portfolioDetails.holdings
.map(([symbol, holding]) => { .map((holding) => {
if (holding.assetProfile.holdings.length > 0) { if (holding.assetProfile.holdings.length > 0) {
const currentParentHolding = holding.assetProfile.holdings.find( const currentParentHolding = holding.assetProfile.holdings.find(
(parentHolding) => { (parentHolding) => {
@ -573,11 +589,11 @@ export class GfAllocationsPageComponent implements OnInit {
return currentParentHolding && return currentParentHolding &&
isNumber(currentParentHolding.valueInBaseCurrency) isNumber(currentParentHolding.valueInBaseCurrency)
? { ? {
symbol,
allocationInPercentage: allocationInPercentage:
currentParentHolding.valueInBaseCurrency / value, currentParentHolding.valueInBaseCurrency / value,
name: holding.assetProfile.name ?? '', name: holding.assetProfile.name ?? '',
position: holding, position: holding,
symbol: holding.assetProfile.symbol,
valueInBaseCurrency: valueInBaseCurrency:
currentParentHolding.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 { UNKNOWN_KEY } from '@ghostfolio/common/config';
import { getCountryName } from '@ghostfolio/common/helper'; import {
getAssetProfileIdentifier,
getCountryName
} from '@ghostfolio/common/helper';
import { import {
InfoItem, InfoItem,
PortfolioPosition, PortfolioPosition,
@ -67,7 +70,7 @@ export class GfPublicPageComponent implements OnInit {
() => this.deviceDetectorService.deviceInfo().deviceType () => this.deviceDetectorService.deviceInfo().deviceType
); );
protected hasPermissionForSubscription: boolean; protected hasPermissionForSubscription: boolean;
protected holdings: PublicPortfolioResponse['holdings'][string][]; protected holdings: PublicPortfolioResponse['holdings'];
protected info: InfoItem; protected info: InfoItem;
protected isLoading = true; protected isLoading = true;
protected latestActivitiesDataSource: MatTableDataSource< protected latestActivitiesDataSource: MatTableDataSource<
@ -78,7 +81,7 @@ export class GfPublicPageComponent implements OnInit {
}; };
protected readonly pageSize = Number.MAX_SAFE_INTEGER; protected readonly pageSize = Number.MAX_SAFE_INTEGER;
protected positions: { protected positions: {
[symbol: string]: Pick< [assetProfileIdentifier: string]: Pick<
PortfolioPosition['assetProfile'], PortfolioPosition['assetProfile'],
'currency' | 'name' 'currency' | 'name'
> & { > & {
@ -90,7 +93,7 @@ export class GfPublicPageComponent implements OnInit {
[name: string]: { name: string; value: number }; [name: string]: { name: string; value: number };
}; };
protected symbols: { protected symbols: {
[name: string]: { name: string; symbol: string; value: number }; [symbol: string]: { name: string; symbol: string; value: number };
}; };
protected readonly UNKNOWN_KEY = UNKNOWN_KEY; protected readonly UNKNOWN_KEY = UNKNOWN_KEY;
@ -175,12 +178,14 @@ export class GfPublicPageComponent implements OnInit {
} }
}; };
for (const [symbol, position] of Object.entries( for (const position of this.publicPortfolioDetails.holdings) {
this.publicPortfolioDetails.holdings const assetProfileIdentifier = getAssetProfileIdentifier(
)) { position.assetProfile
);
this.holdings.push(position); this.holdings.push(position);
this.positions[symbol] = { this.positions[assetProfileIdentifier] = {
currency: position.assetProfile.currency, currency: position.assetProfile.currency,
name: position.assetProfile.name, name: position.assetProfile.name,
value: position.allocationInPercentage value: position.allocationInPercentage
@ -199,10 +204,7 @@ export class GfPublicPageComponent implements OnInit {
} else { } else {
this.continents[continent] = { this.continents[continent] = {
name: translate(continent), name: translate(continent),
value: value: weight * (position.valueInBaseCurrency ?? 0)
weight *
(this.publicPortfolioDetails.holdings[symbol]
.valueInBaseCurrency ?? 0)
}; };
} }
@ -212,21 +214,16 @@ export class GfPublicPageComponent implements OnInit {
} else { } else {
this.countries[code] = { this.countries[code] = {
name: getCountryName({ code }), name: getCountryName({ code }),
value: value: weight * (position.valueInBaseCurrency ?? 0)
weight *
(this.publicPortfolioDetails.holdings[symbol]
.valueInBaseCurrency ?? 0)
}; };
} }
} }
} else { } else {
this.continents[UNKNOWN_KEY].value += this.continents[UNKNOWN_KEY].value +=
this.publicPortfolioDetails.holdings[symbol].valueInBaseCurrency ?? position.valueInBaseCurrency ?? 0;
0;
this.countries[UNKNOWN_KEY].value += this.countries[UNKNOWN_KEY].value +=
this.publicPortfolioDetails.holdings[symbol].valueInBaseCurrency ?? position.valueInBaseCurrency ?? 0;
0;
} }
if (position.assetProfile.sectors.length > 0) { if (position.assetProfile.sectors.length > 0) {
@ -239,27 +236,33 @@ export class GfPublicPageComponent implements OnInit {
} else { } else {
this.sectors[name] = { this.sectors[name] = {
name: translate(name), name: translate(name),
value: value: weight * (position.valueInBaseCurrency ?? 0)
weight *
(this.publicPortfolioDetails.holdings[symbol]
.valueInBaseCurrency ?? 0)
}; };
} }
} }
} else { } else {
this.sectors[UNKNOWN_KEY].value += this.sectors[UNKNOWN_KEY].value += position.valueInBaseCurrency ?? 0;
this.publicPortfolioDetails.holdings[symbol].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] = { this.symbols[symbol] = {
symbol, symbol,
name: position.assetProfile.name ?? symbol, value,
value: isNumber(position.valueInBaseCurrency) name: position.assetProfile.name ?? symbol
? position.valueInBaseCurrency
: (position.valueInPercentage ?? 0)
}; };
} }
} }
}
} }

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

@ -17,7 +17,7 @@ export interface PortfolioDetails {
}; };
}; };
createdAt: Date; createdAt: Date;
holdings: { [symbol: string]: PortfolioPosition }; holdings: PortfolioPosition[];
markets?: { markets?: {
[key in Market]: { [key in Market]: {
id: 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 { export interface PublicPortfolioResponse extends PublicPortfolioResponseV1 {
alias?: string; alias?: string;
hasDetails: boolean; hasDetails: boolean;
holdings: { holdings: Pick<
[symbol: string]: Pick<
PortfolioPosition, PortfolioPosition,
| 'allocationInPercentage' | 'allocationInPercentage'
| 'assetProfile' | 'assetProfile'
@ -20,8 +19,7 @@ export interface PublicPortfolioResponse extends PublicPortfolioResponseV1 {
| 'netPerformancePercentWithCurrencyEffect' | 'netPerformancePercentWithCurrencyEffect'
| 'valueInBaseCurrency' | 'valueInBaseCurrency'
| 'valueInPercentage' | 'valueInPercentage'
>; >[];
};
latestActivities: (Pick< latestActivities: (Pick<
Order, Order,
'currency' | 'date' | 'fee' | 'quantity' | 'type' | 'unitPrice' 'currency' | 'date' | 'fee' | 'quantity' | 'type' | 'unitPrice'

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

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

Loading…
Cancel
Save