Browse Source

Merge pull request #175 from dandevaud/feature/Use-total-investment-in-calculation

Add ROI Calculator
pull/5027/head
dandevaud 2 months ago
committed by GitHub
parent
commit
70f2e78f08
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 1
      apps/api/src/app/portfolio/calculator/portfolio-calculator.factory.ts
  2. 418
      apps/api/src/app/portfolio/calculator/portfolio-calculator.ts
  3. 6
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell.spec.ts
  4. 208
      apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-googl-buy.spec.ts
  5. 39
      apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-helper-object.ts
  6. 198
      apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-msft-buy-with-dividend.spec.ts
  7. 202
      apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-novn-buy-and-sell-partially.spec.ts
  8. 259
      apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-novn-buy-and-sell.spec.ts
  9. 868
      apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-symbolmetrics-helper.ts
  10. 261
      apps/api/src/app/portfolio/calculator/roi/portfolio-calculator.ts
  11. 10
      apps/api/src/app/portfolio/portfolio.controller.ts
  12. 803
      apps/api/src/app/portfolio/portfolio.service.ts
  13. 5
      apps/api/src/app/user/update-user-setting.dto.ts
  14. 9
      apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.html
  15. 5
      apps/client/src/app/components/home-holdings/home-holdings.component.ts
  16. 1
      apps/client/src/app/components/home-holdings/home-holdings.html
  17. 12
      apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html
  18. 22
      apps/client/src/app/components/portfolio-summary/portfolio-summary.component.ts
  19. 4
      apps/client/src/app/components/user-account-settings/user-account-settings.html
  20. 8
      apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts
  21. 4
      libs/common/src/lib/interfaces/historical-data-item.interface.ts
  22. 6
      libs/common/src/lib/interfaces/responses/portfolio-holdings-response.interface.ts
  23. 13
      libs/common/src/lib/types/date-range.type.ts
  24. 2
      libs/ui/src/lib/holdings-table/holdings-table.component.html
  25. 16
      libs/ui/src/lib/holdings-table/holdings-table.component.ts

1
apps/api/src/app/portfolio/calculator/portfolio-calculator.factory.ts

@ -14,6 +14,7 @@ import { OrderService } from '../../order/order.service';
import { MwrPortfolioCalculator } from './mwr/portfolio-calculator';
import { PortfolioCalculator } from './portfolio-calculator';
import { RoaiPortfolioCalculator } from './roai/portfolio-calculator';
// import { RoaiPortfolioCalculator } from './roai/portfolio-calculator';
import { RoiPortfolioCalculator } from './roi/portfolio-calculator';
import { TwrPortfolioCalculator } from './twr/portfolio-calculator';

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

@ -64,19 +64,19 @@ export abstract class PortfolioCalculator {
protected configurationService: ConfigurationService;
protected currency: string;
protected currentRateService: CurrentRateService;
private dataProviderInfos: DataProviderInfo[];
private endDate: Date;
protected exchangeRateDataService: ExchangeRateDataService;
protected orderService: OrderService;
protected snapshot: PortfolioSnapshot;
protected snapshotPromise: Promise<void>;
protected userId: string;
protected marketMap: { [date: string]: { [symbol: string]: Big } } = {};
private dataProviderInfos: DataProviderInfo[];
private endDate: Date;
private filters: Filter[];
private portfolioSnapshotService: PortfolioSnapshotService;
private redisCacheService: RedisCacheService;
private snapshot: PortfolioSnapshot;
private snapshotPromise: Promise<void>;
private startDate: Date;
private transactionPoints: TransactionPoint[];
protected userId: string;
protected marketMap: { [date: string]: { [symbol: string]: Big } } = {};
private holdings: { [date: string]: { [symbol: string]: Big } } = {};
private holdingCurrencies: { [symbol: string]: string } = {};
@ -557,56 +557,9 @@ export abstract class PortfolioCalculator {
}
}
const historicalData: HistoricalDataItem[] = Object.entries(
const historicalData: HistoricalDataItem[] = this.getHistoricalDataItems(
accumulatedValuesByDate
).map(([date, values]) => {
const {
investmentValueWithCurrencyEffect,
totalAccountBalanceWithCurrencyEffect,
totalCurrentValue,
totalCurrentValueWithCurrencyEffect,
totalInvestmentValue,
totalInvestmentValueWithCurrencyEffect,
totalNetPerformanceValue,
totalNetPerformanceValueWithCurrencyEffect,
totalTimeWeightedInvestmentValue,
totalTimeWeightedInvestmentValueWithCurrencyEffect
} = values;
const netPerformanceInPercentage = totalTimeWeightedInvestmentValue.eq(0)
? 0
: totalNetPerformanceValue
.div(totalTimeWeightedInvestmentValue)
.toNumber();
const netPerformanceInPercentageWithCurrencyEffect =
totalTimeWeightedInvestmentValueWithCurrencyEffect.eq(0)
? 0
: totalNetPerformanceValueWithCurrencyEffect
.div(totalTimeWeightedInvestmentValueWithCurrencyEffect)
.toNumber();
return {
date,
netPerformanceInPercentage,
netPerformanceInPercentageWithCurrencyEffect,
investmentValueWithCurrencyEffect:
investmentValueWithCurrencyEffect.toNumber(),
netPerformance: totalNetPerformanceValue.toNumber(),
netPerformanceWithCurrencyEffect:
totalNetPerformanceValueWithCurrencyEffect.toNumber(),
// TODO: Add valuables
netWorth: totalCurrentValueWithCurrencyEffect
.plus(totalAccountBalanceWithCurrencyEffect)
.toNumber(),
totalAccountBalance: totalAccountBalanceWithCurrencyEffect.toNumber(),
totalInvestment: totalInvestmentValue.toNumber(),
totalInvestmentValueWithCurrencyEffect:
totalInvestmentValueWithCurrencyEffect.toNumber(),
value: totalCurrentValue.toNumber(),
valueWithCurrencyEffect: totalCurrentValueWithCurrencyEffect.toNumber()
};
});
);
const overall = this.calculateOverallPerformance(positions);
@ -668,30 +621,26 @@ export abstract class PortfolioCalculator {
{}
);
const totalInvestment = await Object.keys(holdings[endString]).reduce(
(sum, holding) => {
if (!holdings[endString][holding].toNumber()) {
return sum;
}
const symbol = marketMap.values.find((m) => m.symbol === holding);
return Object.keys(holdings[endString]).reduce((sum, holding) => {
if (!holdings[endString][holding].toNumber()) {
return sum;
}
const symbol = marketMap.values.find((m) => m.symbol === holding);
if (symbol?.marketPrice === undefined) {
Logger.warn(
`Missing historical market data for ${holding} (${end})`,
'PortfolioCalculator'
);
return sum;
} else {
const symbolCurrency = this.getCurrency(holding);
const price = new Big(currencyRates[symbolCurrency]).mul(
symbol.marketPrice
);
return sum.plus(new Big(price).mul(holdings[endString][holding]));
}
},
new Big(0)
);
return totalInvestment;
if (symbol?.marketPrice === undefined) {
Logger.warn(
`Missing historical market data for ${holding} (${end})`,
'PortfolioCalculator'
);
return sum;
} else {
const symbolCurrency = this.getCurrency(holding);
const price = new Big(currencyRates[symbolCurrency]).mul(
symbol.marketPrice
);
return sum.plus(new Big(price).mul(holdings[endString][holding]));
}
}, new Big(0));
}
@LogPerformance
@ -774,7 +723,16 @@ export abstract class PortfolioCalculator {
}
@LogPerformance
public async getPerformance({ end, start }) {
public async getPerformance({ end, start }): Promise<{
chart: HistoricalDataItem[];
netPerformance: number;
netPerformanceInPercentage: number;
netPerformanceWithCurrencyEffect: number;
netPerformanceInPercentageWithCurrencyEffect: number;
netWorth: number;
totalInvestment: number;
valueWithCurrencyEffect: number;
}> {
await this.snapshotPromise;
const { historicalData } = this.snapshot;
@ -783,6 +741,10 @@ export abstract class PortfolioCalculator {
let netPerformanceAtStartDate: number;
let netPerformanceWithCurrencyEffectAtStartDate: number;
let lastTimeWeightedPerformancePercentage: number;
let lastTimeWeightedPerformancePercentageWithCurrencyEffect: number;
let timeWeightedPerformanceInPercentage: number;
let timeWeightedPerformanceInPercentageWithCurrencyEffect: number;
const totalInvestmentValuesWithCurrencyEffect: number[] = [];
for (const historicalDataItem of historicalData) {
@ -815,6 +777,19 @@ export abstract class PortfolioCalculator {
totalInvestmentValuesWithCurrencyEffect.length
: 0;
({
timeWeightedPerformanceInPercentage,
timeWeightedPerformanceInPercentageWithCurrencyEffect,
lastTimeWeightedPerformancePercentage,
lastTimeWeightedPerformancePercentageWithCurrencyEffect
} = this.calculateTimeWeightedPerformance(
lastTimeWeightedPerformancePercentage,
historicalDataItem,
lastTimeWeightedPerformancePercentageWithCurrencyEffect,
timeWeightedPerformanceInPercentage,
timeWeightedPerformanceInPercentageWithCurrencyEffect
));
chart.push({
...historicalDataItem,
netPerformance:
@ -829,7 +804,9 @@ export abstract class PortfolioCalculator {
timeWeightedInvestmentValue === 0
? 0
: netPerformanceWithCurrencyEffectSinceStartDate /
timeWeightedInvestmentValue
timeWeightedInvestmentValue,
timeWeightedPerformanceInPercentage,
timeWeightedPerformanceInPercentageWithCurrencyEffect
// TODO: Add net worth with valuables
// netWorth: totalCurrentValueWithCurrencyEffect
// .plus(totalAccountBalanceWithCurrencyEffect)
@ -839,42 +816,108 @@ export abstract class PortfolioCalculator {
}
}
return { chart };
}
const last = chart.at(-1);
public getStartDate() {
let firstAccountBalanceDate: Date;
let firstActivityDate: Date;
try {
const firstAccountBalanceDateString = this.accountBalanceItems[0]?.date;
firstAccountBalanceDate = firstAccountBalanceDateString
? parseDate(firstAccountBalanceDateString)
: new Date();
} catch (error) {
firstAccountBalanceDate = new Date();
}
return {
chart,
netPerformance: last?.netPerformance ?? 0,
netPerformanceInPercentage: last?.netPerformanceInPercentage ?? 0,
netPerformanceWithCurrencyEffect:
last?.netPerformanceWithCurrencyEffect ?? 0,
netPerformanceInPercentageWithCurrencyEffect:
last?.netPerformanceInPercentageWithCurrencyEffect ?? 0,
netWorth: last?.netWorth ?? 0,
totalInvestment: last?.totalInvestment ?? 0,
valueWithCurrencyEffect: last?.valueWithCurrencyEffect ?? 0
};
}
try {
const firstActivityDateString = this.transactionPoints[0].date;
firstActivityDate = firstActivityDateString
? parseDate(firstActivityDateString)
: new Date();
} catch (error) {
firstActivityDate = new Date();
}
@LogPerformance
protected getHistoricalDataItems(accumulatedValuesByDate: {
[date: string]: {
investmentValueWithCurrencyEffect: Big;
totalAccountBalanceWithCurrencyEffect: Big;
totalCurrentValue: Big;
totalCurrentValueWithCurrencyEffect: Big;
totalInvestmentValue: Big;
totalInvestmentValueWithCurrencyEffect: Big;
totalNetPerformanceValue: Big;
totalNetPerformanceValueWithCurrencyEffect: Big;
totalTimeWeightedInvestmentValue: Big;
totalTimeWeightedInvestmentValueWithCurrencyEffect: Big;
};
}): HistoricalDataItem[] {
let previousDateString = '';
let timeWeightedPerformancePreviousPeriod = new Big(0);
let timeWeightedPerformancePreviousPeriodWithCurrencyEffect = new Big(0);
return Object.entries(accumulatedValuesByDate).map(([date, values]) => {
const {
investmentValueWithCurrencyEffect,
totalAccountBalanceWithCurrencyEffect,
totalCurrentValue,
totalCurrentValueWithCurrencyEffect,
totalInvestmentValue,
totalInvestmentValueWithCurrencyEffect,
totalNetPerformanceValue,
totalNetPerformanceValueWithCurrencyEffect,
totalTimeWeightedInvestmentValue,
totalTimeWeightedInvestmentValueWithCurrencyEffect
} = values;
return min([firstAccountBalanceDate, firstActivityDate]);
}
const netPerformanceInPercentage = totalTimeWeightedInvestmentValue.eq(0)
? 0
: totalNetPerformanceValue
.div(totalTimeWeightedInvestmentValue)
.toNumber();
public getTransactionPoints() {
return this.transactionPoints;
}
const netPerformanceInPercentageWithCurrencyEffect =
totalTimeWeightedInvestmentValueWithCurrencyEffect.eq(0)
? 0
: totalNetPerformanceValueWithCurrencyEffect
.div(totalTimeWeightedInvestmentValueWithCurrencyEffect)
.toNumber();
public async getValuablesInBaseCurrency() {
await this.snapshotPromise;
let timeWeightedPerformanceInPercentage: number;
let timeWeightedPerformanceInPercentageWithCurrencyEffect: number;
({
timeWeightedPerformanceInPercentage,
timeWeightedPerformanceInPercentageWithCurrencyEffect,
previousDateString,
timeWeightedPerformancePreviousPeriod,
timeWeightedPerformancePreviousPeriodWithCurrencyEffect
} = this.handleTimeWeightedPerformance(
accumulatedValuesByDate,
previousDateString,
totalNetPerformanceValue,
totalNetPerformanceValueWithCurrencyEffect,
timeWeightedPerformancePreviousPeriod,
timeWeightedPerformancePreviousPeriodWithCurrencyEffect,
date
));
return this.snapshot.totalValuablesWithCurrencyEffect;
return {
date,
netPerformanceInPercentage,
netPerformanceInPercentageWithCurrencyEffect,
investmentValueWithCurrencyEffect:
investmentValueWithCurrencyEffect.toNumber(),
netPerformance: totalNetPerformanceValue.toNumber(),
netPerformanceWithCurrencyEffect:
totalNetPerformanceValueWithCurrencyEffect.toNumber(),
// TODO: Add valuables
netWorth: totalCurrentValueWithCurrencyEffect
.plus(totalAccountBalanceWithCurrencyEffect)
.toNumber(),
totalAccountBalance: totalAccountBalanceWithCurrencyEffect.toNumber(),
totalInvestment: totalInvestmentValue.toNumber(),
totalInvestmentValueWithCurrencyEffect:
totalInvestmentValueWithCurrencyEffect.toNumber(),
value: totalCurrentValue.toNumber(),
valueWithCurrencyEffect: totalCurrentValueWithCurrencyEffect.toNumber(),
timeWeightedPerformanceInPercentage,
timeWeightedPerformanceInPercentageWithCurrencyEffect
};
});
}
@LogPerformance
@ -1258,6 +1301,76 @@ export abstract class PortfolioCalculator {
);
}
public getStartDate() {
let firstAccountBalanceDate: Date;
let firstActivityDate: Date;
try {
const firstAccountBalanceDateString = this.accountBalanceItems[0]?.date;
firstAccountBalanceDate = firstAccountBalanceDateString
? parseDate(firstAccountBalanceDateString)
: new Date();
} catch (error) {
firstAccountBalanceDate = new Date();
}
try {
const firstActivityDateString = this.transactionPoints[0].date;
firstActivityDate = firstActivityDateString
? parseDate(firstActivityDateString)
: new Date();
} catch (error) {
firstActivityDate = new Date();
}
return min([firstAccountBalanceDate, firstActivityDate]);
}
public getTransactionPoints() {
return this.transactionPoints;
}
public async getValuablesInBaseCurrency() {
await this.snapshotPromise;
return this.snapshot.totalValuablesWithCurrencyEffect;
}
private calculateTimeWeightedPerformance(
lastTimeWeightedPerformancePercentage: number,
historicalDataItem: HistoricalDataItem,
lastTimeWeightedPerformancePercentageWithCurrencyEffect: number,
timeWeightedPerformanceInPercentage: number,
timeWeightedPerformanceInPercentageWithCurrencyEffect: number
): {
timeWeightedPerformanceInPercentage: number;
timeWeightedPerformanceInPercentageWithCurrencyEffect: number;
lastTimeWeightedPerformancePercentage: number;
lastTimeWeightedPerformancePercentageWithCurrencyEffect: number;
} {
timeWeightedPerformanceInPercentage = lastTimeWeightedPerformancePercentage
? (1 + timeWeightedPerformanceInPercentage) *
((1 + historicalDataItem.timeWeightedPerformanceInPercentage) /
(1 + lastTimeWeightedPerformancePercentage)) -
1
: 0;
timeWeightedPerformanceInPercentageWithCurrencyEffect =
lastTimeWeightedPerformancePercentageWithCurrencyEffect
? (1 + timeWeightedPerformanceInPercentageWithCurrencyEffect) *
((1 +
historicalDataItem.timeWeightedPerformanceInPercentageWithCurrencyEffect) /
(1 + lastTimeWeightedPerformancePercentageWithCurrencyEffect)) -
1
: 0;
return {
timeWeightedPerformanceInPercentage,
timeWeightedPerformanceInPercentageWithCurrencyEffect,
lastTimeWeightedPerformancePercentage:
historicalDataItem.timeWeightedPerformanceInPercentage,
lastTimeWeightedPerformancePercentageWithCurrencyEffect:
historicalDataItem.timeWeightedPerformanceInPercentageWithCurrencyEffect
};
}
private calculateHoldings(
investmentByDate: { [date: string]: PortfolioOrder[] },
start: Date,
@ -1361,6 +1474,91 @@ export abstract class PortfolioCalculator {
return chartDateMap;
}
private handleTimeWeightedPerformance(
accumulatedValuesByDate: {
[date: string]: {
investmentValueWithCurrencyEffect: Big;
totalAccountBalanceWithCurrencyEffect: Big;
totalCurrentValue: Big;
totalCurrentValueWithCurrencyEffect: Big;
totalInvestmentValue: Big;
totalInvestmentValueWithCurrencyEffect: Big;
totalNetPerformanceValue: Big;
totalNetPerformanceValueWithCurrencyEffect: Big;
totalTimeWeightedInvestmentValue: Big;
totalTimeWeightedInvestmentValueWithCurrencyEffect: Big;
};
},
previousDateString: string,
totalNetPerformanceValue: Big,
totalNetPerformanceValueWithCurrencyEffect: Big,
timeWeightedPerformancePreviousPeriod: Big,
timeWeightedPerformancePreviousPeriodWithCurrencyEffect: Big,
date: string
): {
timeWeightedPerformanceInPercentage: number;
timeWeightedPerformanceInPercentageWithCurrencyEffect: number;
previousDateString: string;
timeWeightedPerformancePreviousPeriod: Big;
timeWeightedPerformancePreviousPeriodWithCurrencyEffect: Big;
} {
const previousValues = accumulatedValuesByDate[previousDateString] ?? {
totalNetPerformanceValue: new Big(0),
totalNetPerformanceValueWithCurrencyEffect: new Big(0),
totalTimeWeightedInvestmentValue: new Big(0),
totalTimeWeightedInvestmentValueWithCurrencyEffect: new Big(0),
totalCurrentValue: new Big(0),
totalCurrentValueWithCurrencyEffect: new Big(0)
};
const timeWeightedPerformanceCurrentPeriod = this.divideByOrZero(
(div) =>
totalNetPerformanceValue
.minus(previousValues.totalNetPerformanceValue)
.div(div),
previousValues.totalCurrentValue
);
const timeWeightedPerformanceCurrentPeriodWithCurrencyEffect =
this.divideByOrZero(
(div) =>
totalNetPerformanceValueWithCurrencyEffect
.minus(previousValues.totalNetPerformanceValueWithCurrencyEffect)
.div(div),
previousValues.totalCurrentValueWithCurrencyEffect
);
const timeWeightedPerformanceInPercentage = new Big(1)
.plus(timeWeightedPerformancePreviousPeriod)
.mul(new Big(1).plus(timeWeightedPerformanceCurrentPeriod))
.minus(1);
const timeWeightedPerformanceInPercentageWithCurrencyEffect = new Big(1)
.plus(timeWeightedPerformancePreviousPeriodWithCurrencyEffect)
.mul(
new Big(1).plus(timeWeightedPerformanceCurrentPeriodWithCurrencyEffect)
)
.minus(1);
return {
timeWeightedPerformanceInPercentage:
timeWeightedPerformanceInPercentage.toNumber(),
timeWeightedPerformanceInPercentageWithCurrencyEffect:
timeWeightedPerformanceInPercentageWithCurrencyEffect.toNumber(),
previousDateString: date,
timeWeightedPerformancePreviousPeriod:
timeWeightedPerformanceInPercentage,
timeWeightedPerformancePreviousPeriodWithCurrencyEffect:
timeWeightedPerformanceInPercentageWithCurrencyEffect
};
}
private divideByOrZero(fn: (big: Big) => Big, divisor: Big): Big {
if (divisor.eq(0)) {
return new Big(0);
} else {
return fn(divisor);
}
}
protected abstract getSymbolMetrics({
chartDateMap,
dataSource,

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

@ -139,6 +139,8 @@ describe('PortfolioCalculator', () => {
netPerformanceInPercentageWithCurrencyEffect: 0,
netPerformanceWithCurrencyEffect: 0,
netWorth: 0,
timeWeightedPerformanceInPercentage: 0,
timeWeightedPerformanceInPercentageWithCurrencyEffect: 0,
totalAccountBalance: 0,
totalInvestment: 0,
totalInvestmentValueWithCurrencyEffect: 0,
@ -153,6 +155,8 @@ describe('PortfolioCalculator', () => {
netPerformanceInPercentage: 0,
netPerformanceInPercentageWithCurrencyEffect: 0,
netPerformanceWithCurrencyEffect: 0,
timeWeightedPerformanceInPercentage: 0,
timeWeightedPerformanceInPercentageWithCurrencyEffect: 0,
netWorth: 151.6,
totalAccountBalance: 0,
totalInvestment: 151.6,
@ -172,6 +176,8 @@ describe('PortfolioCalculator', () => {
netPerformanceInPercentage: 0.13100263852242744,
netPerformanceInPercentageWithCurrencyEffect: 0.13100263852242744,
netPerformanceWithCurrencyEffect: 19.86,
timeWeightedPerformanceInPercentage: 0.13100263852242744,
timeWeightedPerformanceInPercentageWithCurrencyEffect: 0.13100263852242744,
netWorth: 0,
totalAccountBalance: 0,
totalInvestment: 0,

208
apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-googl-buy.spec.ts

@ -0,0 +1,208 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import {
activityDummyData,
symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { 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 { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Big } from 'big.js';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
};
});
jest.mock(
'@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service',
() => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
PortfolioSnapshotService: jest.fn().mockImplementation(() => {
return PortfolioSnapshotServiceMock;
})
};
}
);
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock;
})
};
});
jest.mock(
'@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service',
() => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
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(() => {
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
null,
null,
null,
null
);
portfolioSnapshotService = new PortfolioSnapshotService(null);
redisCacheService = new RedisCacheService(null, null);
portfolioCalculatorFactory = new PortfolioCalculatorFactory(
configurationService,
currentRateService,
exchangeRateDataService,
portfolioSnapshotService,
redisCacheService,
null
);
});
describe('get current positions', () => {
it.only('with GOOGL buy', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2023-07-10').getTime());
const activities: Activity[] = [
{
...activityDummyData,
date: new Date('2023-01-03'),
feeInAssetProfileCurrency: 1,
quantity: 1,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'USD',
dataSource: 'YAHOO',
name: 'Alphabet Inc.',
symbol: 'GOOGL'
},
type: 'BUY',
unitPriceInAssetProfileCurrency: 89.12
}
];
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.ROI,
currency: 'CHF',
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
data: portfolioSnapshot.historicalData,
groupBy: 'month'
});
expect(portfolioSnapshot).toMatchObject({
currentValueInBaseCurrency: new Big('103.10483'),
errors: [],
hasErrors: false,
positions: [
{
averagePrice: new Big('89.12'),
currency: 'USD',
dataSource: 'YAHOO',
dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'),
fee: new Big('1'),
feeInBaseCurrency: new Big('0.9238'),
firstBuyDate: '2023-01-03',
grossPerformance: new Big('27.33').mul(0.8854),
grossPerformancePercentage: new Big('0.3066651705565529623'),
grossPerformancePercentageWithCurrencyEffect: new Big(
'0.25235044599563974109'
),
grossPerformanceWithCurrencyEffect: new Big('20.775774'),
investment: new Big('89.12').mul(0.8854),
investmentWithCurrencyEffect: new Big('82.329056'),
netPerformance: new Big('26.33').mul(0.8854),
netPerformancePercentage: new Big('0.29544434470377019749'),
netPerformancePercentageWithCurrencyEffectMap: {
max: new Big('0.24112962014285697628')
},
netPerformanceWithCurrencyEffectMap: { max: new Big('19.851974') },
marketPrice: 116.45,
marketPriceInBaseCurrency: 103.10483,
quantity: new Big('1'),
symbol: 'GOOGL',
tags: [],
timeWeightedInvestment: new Big('89.12').mul(0.8854),
timeWeightedInvestmentWithCurrencyEffect: new Big('82.329056'),
transactionCount: 1,
valueInBaseCurrency: new Big('103.10483')
}
],
totalFeesWithCurrencyEffect: new Big('0.9238'),
totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: new Big('89.12').mul(0.8854),
totalInvestmentWithCurrencyEffect: new Big('82.329056'),
totalLiabilitiesWithCurrencyEffect: new Big('0'),
totalValuablesWithCurrencyEffect: new Big('0')
});
expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject(
expect.objectContaining({
netPerformance: new Big('26.33').mul(0.8854).toNumber(),
netPerformanceInPercentage: 0.29544434470377019749,
netPerformanceInPercentageWithCurrencyEffect: 0.24112962014285697628,
netPerformanceWithCurrencyEffect: 19.851974,
totalInvestmentValueWithCurrencyEffect: 82.329056
})
);
expect(investments).toEqual([
{ date: '2023-01-03', investment: new Big('89.12') }
]);
expect(investmentsByMonth).toEqual([
{ date: '2023-01-01', investment: 82.329056 },
{ date: '2023-02-01', investment: 0 },
{ date: '2023-03-01', investment: 0 },
{ date: '2023-04-01', investment: 0 },
{ date: '2023-05-01', investment: 0 },
{ date: '2023-06-01', investment: 0 },
{ date: '2023-07-01', investment: 0 }
]);
});
});
});

39
apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-helper-object.ts

@ -0,0 +1,39 @@
import { SymbolMetrics } from '@ghostfolio/common/interfaces';
import { Big } from 'big.js';
import { PortfolioOrderItem } from '../../interfaces/portfolio-order-item.interface';
export class PortfolioCalculatorSymbolMetricsHelperObject {
currentExchangeRate: number;
endDateString: string;
exchangeRateAtOrderDate: number;
fees: Big = new Big(0);
feesWithCurrencyEffect: Big = new Big(0);
feesAtStartDate: Big = new Big(0);
feesAtStartDateWithCurrencyEffect: Big = new Big(0);
grossPerformanceAtStartDate: Big = new Big(0);
grossPerformanceAtStartDateWithCurrencyEffect: Big = new Big(0);
indexOfEndOrder: number;
indexOfStartOrder: number;
initialValue: Big;
initialValueWithCurrencyEffect: Big;
investmentAtStartDate: Big;
investmentAtStartDateWithCurrencyEffect: Big;
investmentValueBeforeTransaction: Big = new Big(0);
investmentValueBeforeTransactionWithCurrencyEffect: Big = new Big(0);
ordersByDate: { [date: string]: PortfolioOrderItem[] } = {};
startDateString: string;
symbolMetrics: SymbolMetrics;
totalUnits: Big = new Big(0);
totalInvestmentFromBuyTransactions: Big = new Big(0);
totalInvestmentFromBuyTransactionsWithCurrencyEffect: Big = new Big(0);
totalQuantityFromBuyTransactions: Big = new Big(0);
totalValueOfPositionsSold: Big = new Big(0);
totalValueOfPositionsSoldWithCurrencyEffect: Big = new Big(0);
unitPrice: Big;
unitPriceAtEndDate: Big = new Big(0);
unitPriceAtStartDate: Big = new Big(0);
valueAtStartDate: Big = new Big(0);
valueAtStartDateWithCurrencyEffect: Big = new Big(0);
}

198
apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-msft-buy-with-dividend.spec.ts

@ -0,0 +1,198 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import {
activityDummyData,
symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { 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 { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Big } from 'big.js';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
};
});
jest.mock(
'@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service',
() => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
PortfolioSnapshotService: jest.fn().mockImplementation(() => {
return PortfolioSnapshotServiceMock;
})
};
}
);
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock;
})
};
});
jest.mock(
'@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service',
() => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
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(() => {
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
null,
null,
null,
null
);
portfolioSnapshotService = new PortfolioSnapshotService(null);
redisCacheService = new RedisCacheService(null, null);
portfolioCalculatorFactory = new PortfolioCalculatorFactory(
configurationService,
currentRateService,
exchangeRateDataService,
portfolioSnapshotService,
redisCacheService,
null
);
});
describe('get current positions', () => {
it.only('with MSFT buy', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2023-07-10').getTime());
const activities: Activity[] = [
{
...activityDummyData,
date: new Date('2021-09-16'),
feeInAssetProfileCurrency: 19,
quantity: 1,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'USD',
dataSource: 'YAHOO',
name: 'Microsoft Inc.',
symbol: 'MSFT'
},
type: 'BUY',
unitPriceInAssetProfileCurrency: 298.58
},
{
...activityDummyData,
date: new Date('2021-11-16'),
feeInAssetProfileCurrency: 0,
quantity: 1,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'USD',
dataSource: 'YAHOO',
name: 'Microsoft Inc.',
symbol: 'MSFT'
},
type: 'DIVIDEND',
unitPriceInAssetProfileCurrency: 0.62
}
];
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.ROI,
currency: 'USD',
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
expect(portfolioSnapshot).toMatchObject({
errors: [],
hasErrors: false,
positions: [
{
averagePrice: new Big('298.58'),
currency: 'USD',
dataSource: 'YAHOO',
dividend: new Big('0.62'),
dividendInBaseCurrency: new Big('0.62'),
fee: new Big('19'),
firstBuyDate: '2021-09-16',
grossPerformance: new Big('33.87'),
grossPerformancePercentage: new Big('0.11343693482483756447'),
grossPerformancePercentageWithCurrencyEffect: new Big(
'0.11343693482483756447'
),
grossPerformanceWithCurrencyEffect: new Big('33.87'),
investment: new Big('298.58'),
investmentWithCurrencyEffect: new Big('298.58'),
marketPrice: 331.83,
marketPriceInBaseCurrency: 331.83,
netPerformance: new Big('14.87'),
netPerformancePercentage: new Big('0.04980239801728180052'),
netPerformancePercentageWithCurrencyEffectMap: {
max: new Big('0.04980239801728180052')
},
netPerformanceWithCurrencyEffectMap: {
'1d': new Big('-5.39'),
'5y': new Big('14.87'),
max: new Big('14.87'),
wtd: new Big('-5.39')
},
quantity: new Big('1'),
symbol: 'MSFT',
tags: [],
transactionCount: 2
}
],
totalFeesWithCurrencyEffect: new Big('19'),
totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: new Big('298.58'),
totalInvestmentWithCurrencyEffect: new Big('298.58'),
totalLiabilitiesWithCurrencyEffect: new Big('0'),
totalValuablesWithCurrencyEffect: new Big('0')
});
expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject(
expect.objectContaining({
totalInvestmentValueWithCurrencyEffect: 298.58
})
);
});
});
});

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

@ -0,0 +1,202 @@
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import {
activityDummyData,
loadActivityExportFile,
symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Big } from 'big.js';
import { join } from 'path';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
};
});
jest.mock(
'@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service',
() => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
PortfolioSnapshotService: jest.fn().mockImplementation(() => {
return PortfolioSnapshotServiceMock;
})
};
}
);
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock;
})
};
});
describe('PortfolioCalculator', () => {
let activityDtos: CreateOrderDto[];
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
let portfolioCalculatorFactory: PortfolioCalculatorFactory;
let portfolioSnapshotService: PortfolioSnapshotService;
let redisCacheService: RedisCacheService;
beforeAll(() => {
activityDtos = loadActivityExportFile(
join(
__dirname,
'../../../../../../../test/import/ok-novn-buy-and-sell-partially.json'
)
);
});
beforeEach(() => {
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
null,
null,
null,
null
);
portfolioSnapshotService = new PortfolioSnapshotService(null);
redisCacheService = new RedisCacheService(null, null);
portfolioCalculatorFactory = new PortfolioCalculatorFactory(
configurationService,
currentRateService,
exchangeRateDataService,
portfolioSnapshotService,
redisCacheService,
null
);
});
describe('get current positions', () => {
it.only('with NOVN.SW buy and sell partially', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2022-04-11').getTime());
const activities: Activity[] = activityDtos.map((activity) => ({
...activityDummyData,
...activity,
date: parseDate(activity.date),
feeInAssetProfileCurrency: activity.fee,
SymbolProfile: {
...symbolProfileDummyData,
currency: activity.currency,
dataSource: activity.dataSource,
name: 'Novartis AG',
symbol: activity.symbol
},
unitPriceInAssetProfileCurrency: activity.unitPrice
}));
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.ROI,
currency: 'CHF',
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
data: portfolioSnapshot.historicalData,
groupBy: 'month'
});
expect(portfolioSnapshot).toMatchObject({
currentValueInBaseCurrency: new Big('87.8'),
errors: [],
hasErrors: false,
positions: [
{
averagePrice: new Big('75.80'),
currency: 'CHF',
dataSource: 'YAHOO',
dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'),
fee: new Big('4.25'),
feeInBaseCurrency: new Big('4.25'),
firstBuyDate: '2022-03-07',
grossPerformance: new Big('21.93'),
grossPerformancePercentage: new Big('0.14465699208443271768'),
grossPerformancePercentageWithCurrencyEffect: new Big(
'0.14465699208443271768'
),
grossPerformanceWithCurrencyEffect: new Big('21.93'),
investment: new Big('75.80'),
investmentWithCurrencyEffect: new Big('75.80'),
netPerformance: new Big('17.68'),
netPerformancePercentage: new Big('0.11662269129287598945'),
netPerformancePercentageWithCurrencyEffectMap: {
max: new Big('0.11662269129287598945')
},
netPerformanceWithCurrencyEffectMap: { max: new Big('17.68') },
marketPrice: 87.8,
marketPriceInBaseCurrency: 87.8,
quantity: new Big('1'),
symbol: 'NOVN.SW',
tags: [],
timeWeightedInvestment: new Big('151.6'),
timeWeightedInvestmentWithCurrencyEffect: new Big('151.6'),
transactionCount: 2,
valueInBaseCurrency: new Big('87.8')
}
],
totalFeesWithCurrencyEffect: new Big('4.25'),
totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: new Big('75.80'),
totalInvestmentWithCurrencyEffect: new Big('75.80'),
totalLiabilitiesWithCurrencyEffect: new Big('0'),
totalValuablesWithCurrencyEffect: new Big('0')
});
expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject(
expect.objectContaining({
netPerformance: 17.68,
netPerformanceInPercentage: 0.11662269129287598945,
netPerformanceInPercentageWithCurrencyEffect: 0.11662269129287598945,
netPerformanceWithCurrencyEffect: 17.68,
totalInvestmentValueWithCurrencyEffect: 75.8
})
);
expect(investments).toEqual([
{ date: '2022-03-07', investment: new Big('151.6') },
{ date: '2022-04-08', investment: new Big('75.8') }
]);
expect(investmentsByMonth).toEqual([
{ date: '2022-03-01', investment: 151.6 },
{ date: '2022-04-01', investment: -75.8 }
]);
});
});
});

259
apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-novn-buy-and-sell.spec.ts

@ -0,0 +1,259 @@
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import {
activityDummyData,
loadActivityExportFile,
symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Big } from 'big.js';
import { join } from 'path';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
};
});
jest.mock(
'@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service',
() => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
PortfolioSnapshotService: jest.fn().mockImplementation(() => {
return PortfolioSnapshotServiceMock;
})
};
}
);
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock;
})
};
});
describe('PortfolioCalculator', () => {
let activityDtos: CreateOrderDto[];
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
let portfolioCalculatorFactory: PortfolioCalculatorFactory;
let portfolioSnapshotService: PortfolioSnapshotService;
let redisCacheService: RedisCacheService;
beforeAll(() => {
activityDtos = loadActivityExportFile(
join(
__dirname,
'../../../../../../../test/import/ok-novn-buy-and-sell.json'
)
);
});
beforeEach(() => {
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
null,
null,
null,
null
);
portfolioSnapshotService = new PortfolioSnapshotService(null);
redisCacheService = new RedisCacheService(null, null);
portfolioCalculatorFactory = new PortfolioCalculatorFactory(
configurationService,
currentRateService,
exchangeRateDataService,
portfolioSnapshotService,
redisCacheService,
null
);
});
describe('get current positions', () => {
it.only('with NOVN.SW buy and sell', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2022-04-11').getTime());
const activities: Activity[] = activityDtos.map((activity) => ({
...activityDummyData,
...activity,
date: parseDate(activity.date),
feeInAssetProfileCurrency: activity.fee,
SymbolProfile: {
...symbolProfileDummyData,
currency: activity.currency,
dataSource: activity.dataSource,
name: 'Novartis AG',
symbol: activity.symbol
},
unitPriceInAssetProfileCurrency: activity.unitPrice
}));
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.ROI,
currency: 'CHF',
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
data: portfolioSnapshot.historicalData,
groupBy: 'month'
});
expect(portfolioSnapshot.historicalData[0]).toEqual({
date: '2022-03-06',
investmentValueWithCurrencyEffect: 0,
netPerformance: 0,
netPerformanceInPercentage: 0,
netPerformanceInPercentageWithCurrencyEffect: 0,
netPerformanceWithCurrencyEffect: 0,
netWorth: 0,
timeWeightedPerformanceInPercentage: 0,
timeWeightedPerformanceInPercentageWithCurrencyEffect: 0,
totalAccountBalance: 0,
totalInvestment: 0,
totalInvestmentValueWithCurrencyEffect: 0,
value: 0,
valueWithCurrencyEffect: 0
});
expect(portfolioSnapshot.historicalData[1]).toEqual({
date: '2022-03-07',
investmentValueWithCurrencyEffect: 151.6,
netPerformance: 0,
netPerformanceInPercentage: 0,
netPerformanceInPercentageWithCurrencyEffect: 0,
netPerformanceWithCurrencyEffect: 0,
timeWeightedPerformanceInPercentage: 0,
timeWeightedPerformanceInPercentageWithCurrencyEffect: 0,
netWorth: 151.6,
totalAccountBalance: 0,
totalInvestment: 151.6,
totalInvestmentValueWithCurrencyEffect: 151.6,
value: 151.6,
valueWithCurrencyEffect: 151.6
});
expect(
portfolioSnapshot.historicalData[
portfolioSnapshot.historicalData.length - 1
]
).toEqual({
date: '2022-04-11',
investmentValueWithCurrencyEffect: 0,
netPerformance: 19.86,
netPerformanceInPercentage: 0.13100263852242744,
netPerformanceInPercentageWithCurrencyEffect: 0.13100263852242744,
timeWeightedPerformanceInPercentage: 0.13100263852242744,
timeWeightedPerformanceInPercentageWithCurrencyEffect: 0.13100263852242744,
netPerformanceWithCurrencyEffect: 19.86,
netWorth: 0,
totalAccountBalance: 0,
totalInvestment: 0,
totalInvestmentValueWithCurrencyEffect: 0,
value: 0,
valueWithCurrencyEffect: 0
});
expect(portfolioSnapshot).toMatchObject({
currentValueInBaseCurrency: new Big('0'),
errors: [],
hasErrors: false,
positions: [
{
averagePrice: new Big('0'),
currency: 'CHF',
dataSource: 'YAHOO',
dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'),
fee: new Big('0'),
feeInBaseCurrency: new Big('0'),
firstBuyDate: '2022-03-07',
grossPerformance: new Big('19.86'),
grossPerformancePercentage: new Big('0.13100263852242744063'),
grossPerformancePercentageWithCurrencyEffect: new Big(
'0.13100263852242744063'
),
grossPerformanceWithCurrencyEffect: new Big('19.86'),
investment: new Big('0'),
investmentWithCurrencyEffect: new Big('0'),
netPerformance: new Big('19.86'),
netPerformancePercentage: new Big('0.13100263852242744063'),
netPerformancePercentageWithCurrencyEffectMap: {
max: new Big('0.13100263852242744063')
},
netPerformanceWithCurrencyEffectMap: {
max: new Big('19.86')
},
marketPrice: 87.8,
marketPriceInBaseCurrency: 87.8,
quantity: new Big('0'),
symbol: 'NOVN.SW',
tags: [],
timeWeightedInvestment: new Big('151.6'),
timeWeightedInvestmentWithCurrencyEffect: new Big('151.6'),
transactionCount: 2,
valueInBaseCurrency: new Big('0')
}
],
totalFeesWithCurrencyEffect: new Big('0'),
totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: new Big('0'),
totalInvestmentWithCurrencyEffect: new Big('0'),
totalLiabilitiesWithCurrencyEffect: new Big('0'),
totalValuablesWithCurrencyEffect: new Big('0')
});
expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject(
expect.objectContaining({
netPerformance: 19.86,
netPerformanceInPercentage: 0.13100263852242744063,
netPerformanceInPercentageWithCurrencyEffect: 0.13100263852242744063,
netPerformanceWithCurrencyEffect: 19.86,
totalInvestmentValueWithCurrencyEffect: 0
})
);
expect(investments).toEqual([
{ date: '2022-03-07', investment: new Big('151.6') },
{ date: '2022-04-08', investment: new Big('0') }
]);
expect(investmentsByMonth).toEqual([
{ date: '2022-03-01', investment: 151.6 },
{ date: '2022-04-01', investment: -151.6 }
]);
});
});
});

868
apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-symbolmetrics-helper.ts

@ -0,0 +1,868 @@
import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { SymbolMetrics } from '@ghostfolio/common/interfaces';
import { DateRangeTypes } from '@ghostfolio/common/types/date-range.type';
import { DataSource } from '@prisma/client';
import { Big } from 'big.js';
import { isBefore, addMilliseconds, format } from 'date-fns';
import { sortBy } from 'lodash';
import { getFactor } from '../../../../helper/portfolio.helper';
import { PortfolioOrderItem } from '../../interfaces/portfolio-order-item.interface';
import { PortfolioCalculatorSymbolMetricsHelperObject } from './portfolio-calculator-helper-object';
export class RoiPortfolioCalculatorSymbolMetricsHelper {
private ENABLE_LOGGING: boolean;
private baseCurrencySuffix = 'InBaseCurrency';
private chartDates: string[];
private marketSymbolMap: { [date: string]: { [symbol: string]: Big } };
public constructor(
ENABLE_LOGGING: boolean,
marketSymbolMap: { [date: string]: { [symbol: string]: Big } },
chartDates: string[]
) {
this.ENABLE_LOGGING = ENABLE_LOGGING;
this.marketSymbolMap = marketSymbolMap;
this.chartDates = chartDates;
}
public calculateNetPerformanceByDateRange(
start: Date,
symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject
) {
for (const dateRange of DateRangeTypes) {
const dateInterval = getIntervalFromDateRange(dateRange);
const endDate = dateInterval.endDate;
let startDate = dateInterval.startDate;
if (isBefore(startDate, start)) {
startDate = start;
}
const rangeEndDateString = format(endDate, DATE_FORMAT);
const rangeStartDateString = format(startDate, DATE_FORMAT);
symbolMetricsHelper.symbolMetrics.netPerformanceWithCurrencyEffectMap[
dateRange
] =
symbolMetricsHelper.symbolMetrics.netPerformanceValuesWithCurrencyEffect[
rangeEndDateString
]?.minus(
// If the date range is 'max', take 0 as a start value. Otherwise,
// the value of the end of the day of the start date is taken which
// differs from the buying price.
dateRange === 'max'
? new Big(0)
: (symbolMetricsHelper.symbolMetrics
.netPerformanceValuesWithCurrencyEffect[rangeStartDateString] ??
new Big(0))
) ?? new Big(0);
let investmentBasis =
symbolMetricsHelper.symbolMetrics.currentValuesWithCurrencyEffect[
rangeStartDateString
];
if (
!symbolMetricsHelper.symbolMetrics.currentValuesWithCurrencyEffect[
rangeStartDateString
]?.gt(0)
) {
investmentBasis =
symbolMetricsHelper.symbolMetrics
.timeWeightedInvestmentValuesWithCurrencyEffect[rangeEndDateString];
}
symbolMetricsHelper.symbolMetrics.netPerformancePercentageWithCurrencyEffectMap[
dateRange
] = investmentBasis.gt(0)
? symbolMetricsHelper.symbolMetrics.netPerformanceWithCurrencyEffectMap[
dateRange
].div(investmentBasis)
: new Big(0);
}
}
public handleOverallPerformanceCalculation(
symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject
) {
symbolMetricsHelper.symbolMetrics.grossPerformance =
symbolMetricsHelper.symbolMetrics.grossPerformance.minus(
symbolMetricsHelper.grossPerformanceAtStartDate
);
symbolMetricsHelper.symbolMetrics.grossPerformanceWithCurrencyEffect =
symbolMetricsHelper.symbolMetrics.grossPerformanceWithCurrencyEffect.minus(
symbolMetricsHelper.grossPerformanceAtStartDateWithCurrencyEffect
);
symbolMetricsHelper.symbolMetrics.netPerformance =
symbolMetricsHelper.symbolMetrics.grossPerformance.minus(
symbolMetricsHelper.fees.minus(symbolMetricsHelper.feesAtStartDate)
);
symbolMetricsHelper.symbolMetrics.timeWeightedInvestment = new Big(
symbolMetricsHelper.totalInvestmentFromBuyTransactions
);
symbolMetricsHelper.symbolMetrics.timeWeightedInvestmentWithCurrencyEffect =
new Big(
symbolMetricsHelper.totalInvestmentFromBuyTransactionsWithCurrencyEffect
);
if (symbolMetricsHelper.symbolMetrics.timeWeightedInvestment.gt(0)) {
symbolMetricsHelper.symbolMetrics.netPerformancePercentage =
symbolMetricsHelper.symbolMetrics.netPerformance.div(
symbolMetricsHelper.symbolMetrics.timeWeightedInvestment
);
symbolMetricsHelper.symbolMetrics.grossPerformancePercentage =
symbolMetricsHelper.symbolMetrics.grossPerformance.div(
symbolMetricsHelper.symbolMetrics.timeWeightedInvestment
);
symbolMetricsHelper.symbolMetrics.grossPerformancePercentageWithCurrencyEffect =
symbolMetricsHelper.symbolMetrics.grossPerformanceWithCurrencyEffect.div(
symbolMetricsHelper.symbolMetrics
.timeWeightedInvestmentWithCurrencyEffect
);
}
}
public processOrderMetrics(
orders: PortfolioOrderItem[],
i: number,
exchangeRates: { [dateString: string]: number },
symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject
) {
const order = orders[i];
this.writeOrderToLogIfNecessary(i, order);
symbolMetricsHelper.exchangeRateAtOrderDate = exchangeRates[order.date];
const value = order.quantity.gt(0)
? order.quantity.mul(order.unitPrice)
: new Big(0);
this.handleNoneBuyAndSellOrders(order, value, symbolMetricsHelper);
this.handleStartOrder(
order,
i,
orders,
symbolMetricsHelper.unitPriceAtStartDate
);
this.handleOrderFee(order, symbolMetricsHelper);
symbolMetricsHelper.unitPrice = this.getUnitPriceAndFillCurrencyDeviations(
order,
symbolMetricsHelper
);
if (order.unitPriceInBaseCurrency) {
symbolMetricsHelper.investmentValueBeforeTransaction =
symbolMetricsHelper.totalUnits.mul(order.unitPriceInBaseCurrency);
symbolMetricsHelper.investmentValueBeforeTransactionWithCurrencyEffect =
symbolMetricsHelper.totalUnits.mul(
order.unitPriceInBaseCurrencyWithCurrencyEffect
);
}
this.handleInitialInvestmentValues(symbolMetricsHelper, i, order);
const { transactionInvestment, transactionInvestmentWithCurrencyEffect } =
this.handleBuyAndSellTranscation(order, symbolMetricsHelper);
this.logTransactionValuesIfRequested(
order,
transactionInvestment,
transactionInvestmentWithCurrencyEffect
);
this.updateTotalInvestments(
symbolMetricsHelper,
transactionInvestment,
transactionInvestmentWithCurrencyEffect
);
this.setInitialValueIfNecessary(
symbolMetricsHelper,
transactionInvestment,
transactionInvestmentWithCurrencyEffect
);
this.accumulateFees(symbolMetricsHelper, order);
symbolMetricsHelper.totalUnits = symbolMetricsHelper.totalUnits.plus(
order.quantity.mul(getFactor(order.type))
);
this.fillOrderUnitPricesIfMissing(order, symbolMetricsHelper);
const valueOfInvestment = symbolMetricsHelper.totalUnits.mul(
order.unitPriceInBaseCurrency
);
const valueOfInvestmentWithCurrencyEffect =
symbolMetricsHelper.totalUnits.mul(
order.unitPriceInBaseCurrencyWithCurrencyEffect
);
const valueOfPositionsSold =
order.type === 'SELL'
? order.unitPriceInBaseCurrency.mul(order.quantity)
: new Big(0);
const valueOfPositionsSoldWithCurrencyEffect =
order.type === 'SELL'
? order.unitPriceInBaseCurrencyWithCurrencyEffect.mul(order.quantity)
: new Big(0);
symbolMetricsHelper.totalValueOfPositionsSold =
symbolMetricsHelper.totalValueOfPositionsSold.plus(valueOfPositionsSold);
symbolMetricsHelper.totalValueOfPositionsSoldWithCurrencyEffect =
symbolMetricsHelper.totalValueOfPositionsSoldWithCurrencyEffect.plus(
valueOfPositionsSoldWithCurrencyEffect
);
this.handlePerformanceCalculation(
valueOfInvestment,
symbolMetricsHelper,
valueOfInvestmentWithCurrencyEffect,
order
);
symbolMetricsHelper.symbolMetrics.investmentValuesAccumulated[order.date] =
new Big(symbolMetricsHelper.symbolMetrics.totalInvestment.toNumber());
symbolMetricsHelper.symbolMetrics.investmentValuesAccumulatedWithCurrencyEffect[
order.date
] = new Big(
symbolMetricsHelper.symbolMetrics.totalInvestmentWithCurrencyEffect.toNumber()
);
symbolMetricsHelper.symbolMetrics.investmentValuesWithCurrencyEffect[
order.date
] = (
symbolMetricsHelper.symbolMetrics.investmentValuesWithCurrencyEffect[
order.date
] ?? new Big(0)
).add(transactionInvestmentWithCurrencyEffect);
}
public handlePerformanceCalculation(
valueOfInvestment: Big,
symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject,
valueOfInvestmentWithCurrencyEffect: Big,
order: PortfolioOrderItem
) {
this.calculateGrossPerformance(
valueOfInvestment,
symbolMetricsHelper,
valueOfInvestmentWithCurrencyEffect
);
this.calculateNetPerformance(
symbolMetricsHelper,
order,
valueOfInvestment,
valueOfInvestmentWithCurrencyEffect
);
}
public calculateNetPerformance(
symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject,
order: PortfolioOrderItem,
valueOfInvestment: Big,
valueOfInvestmentWithCurrencyEffect: Big
) {
symbolMetricsHelper.symbolMetrics.currentValues[order.date] = new Big(
valueOfInvestment
);
symbolMetricsHelper.symbolMetrics.currentValuesWithCurrencyEffect[
order.date
] = new Big(valueOfInvestmentWithCurrencyEffect);
symbolMetricsHelper.symbolMetrics.timeWeightedInvestmentValues[order.date] =
new Big(symbolMetricsHelper.totalInvestmentFromBuyTransactions);
symbolMetricsHelper.symbolMetrics.timeWeightedInvestmentValuesWithCurrencyEffect[
order.date
] = new Big(
symbolMetricsHelper.totalInvestmentFromBuyTransactionsWithCurrencyEffect
);
symbolMetricsHelper.symbolMetrics.netPerformanceValues[order.date] =
symbolMetricsHelper.symbolMetrics.grossPerformance
.minus(symbolMetricsHelper.grossPerformanceAtStartDate)
.minus(
symbolMetricsHelper.fees.minus(symbolMetricsHelper.feesAtStartDate)
);
symbolMetricsHelper.symbolMetrics.netPerformanceValuesWithCurrencyEffect[
order.date
] = symbolMetricsHelper.symbolMetrics.grossPerformanceWithCurrencyEffect
.minus(symbolMetricsHelper.grossPerformanceAtStartDateWithCurrencyEffect)
.minus(
symbolMetricsHelper.feesWithCurrencyEffect.minus(
symbolMetricsHelper.feesAtStartDateWithCurrencyEffect
)
);
}
public calculateGrossPerformance(
valueOfInvestment: Big,
symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject,
valueOfInvestmentWithCurrencyEffect: Big
) {
const newGrossPerformance = valueOfInvestment
.minus(symbolMetricsHelper.totalInvestmentFromBuyTransactions)
.plus(symbolMetricsHelper.totalValueOfPositionsSold)
.plus(
symbolMetricsHelper.symbolMetrics.totalDividend.mul(
symbolMetricsHelper.currentExchangeRate
)
)
.plus(
symbolMetricsHelper.symbolMetrics.totalInterest.mul(
symbolMetricsHelper.currentExchangeRate
)
);
const newGrossPerformanceWithCurrencyEffect =
valueOfInvestmentWithCurrencyEffect
.minus(
symbolMetricsHelper.totalInvestmentFromBuyTransactionsWithCurrencyEffect
)
.plus(symbolMetricsHelper.totalValueOfPositionsSoldWithCurrencyEffect)
.plus(symbolMetricsHelper.symbolMetrics.totalDividendInBaseCurrency)
.plus(symbolMetricsHelper.symbolMetrics.totalInterestInBaseCurrency);
symbolMetricsHelper.symbolMetrics.grossPerformance = newGrossPerformance;
symbolMetricsHelper.symbolMetrics.grossPerformanceWithCurrencyEffect =
newGrossPerformanceWithCurrencyEffect;
}
public accumulateFees(
symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject,
order: PortfolioOrderItem
) {
symbolMetricsHelper.fees = symbolMetricsHelper.fees.plus(
order.feeInBaseCurrency ?? 0
);
symbolMetricsHelper.feesWithCurrencyEffect =
symbolMetricsHelper.feesWithCurrencyEffect.plus(
order.feeInBaseCurrencyWithCurrencyEffect ?? 0
);
}
public updateTotalInvestments(
symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject,
transactionInvestment: Big,
transactionInvestmentWithCurrencyEffect: Big
) {
symbolMetricsHelper.symbolMetrics.totalInvestment =
symbolMetricsHelper.symbolMetrics.totalInvestment.plus(
transactionInvestment
);
symbolMetricsHelper.symbolMetrics.totalInvestmentWithCurrencyEffect =
symbolMetricsHelper.symbolMetrics.totalInvestmentWithCurrencyEffect.plus(
transactionInvestmentWithCurrencyEffect
);
}
public setInitialValueIfNecessary(
symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject,
transactionInvestment: Big,
transactionInvestmentWithCurrencyEffect: Big
) {
if (!symbolMetricsHelper.initialValue && transactionInvestment.gt(0)) {
symbolMetricsHelper.initialValue = transactionInvestment;
symbolMetricsHelper.initialValueWithCurrencyEffect =
transactionInvestmentWithCurrencyEffect;
}
}
public logTransactionValuesIfRequested(
order: PortfolioOrderItem,
transactionInvestment: Big,
transactionInvestmentWithCurrencyEffect: Big
) {
if (this.ENABLE_LOGGING) {
console.log('order.quantity', order.quantity.toNumber());
console.log('transactionInvestment', transactionInvestment.toNumber());
console.log(
'transactionInvestmentWithCurrencyEffect',
transactionInvestmentWithCurrencyEffect.toNumber()
);
}
}
public handleBuyAndSellTranscation(
order: PortfolioOrderItem,
symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject
) {
switch (order.type) {
case 'BUY':
return this.handleBuyTransaction(order, symbolMetricsHelper);
case 'SELL':
return this.handleSellTransaction(symbolMetricsHelper, order);
default:
return {
transactionInvestment: new Big(0),
transactionInvestmentWithCurrencyEffect: new Big(0)
};
}
}
public handleSellTransaction(
symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject,
order: PortfolioOrderItem
) {
let transactionInvestment = new Big(0);
let transactionInvestmentWithCurrencyEffect = new Big(0);
if (symbolMetricsHelper.totalUnits.gt(0)) {
transactionInvestment = symbolMetricsHelper.symbolMetrics.totalInvestment
.div(symbolMetricsHelper.totalUnits)
.mul(order.quantity)
.mul(getFactor(order.type));
transactionInvestmentWithCurrencyEffect =
symbolMetricsHelper.symbolMetrics.totalInvestmentWithCurrencyEffect
.div(symbolMetricsHelper.totalUnits)
.mul(order.quantity)
.mul(getFactor(order.type));
}
return { transactionInvestment, transactionInvestmentWithCurrencyEffect };
}
public handleBuyTransaction(
order: PortfolioOrderItem,
symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject
) {
const transactionInvestment = order.quantity
.mul(order.unitPriceInBaseCurrency)
.mul(getFactor(order.type));
const transactionInvestmentWithCurrencyEffect = order.quantity
.mul(order.unitPriceInBaseCurrencyWithCurrencyEffect)
.mul(getFactor(order.type));
symbolMetricsHelper.totalQuantityFromBuyTransactions =
symbolMetricsHelper.totalQuantityFromBuyTransactions.plus(order.quantity);
symbolMetricsHelper.totalInvestmentFromBuyTransactions =
symbolMetricsHelper.totalInvestmentFromBuyTransactions.plus(
transactionInvestment
);
symbolMetricsHelper.totalInvestmentFromBuyTransactionsWithCurrencyEffect =
symbolMetricsHelper.totalInvestmentFromBuyTransactionsWithCurrencyEffect.plus(
transactionInvestmentWithCurrencyEffect
);
return { transactionInvestment, transactionInvestmentWithCurrencyEffect };
}
public handleInitialInvestmentValues(
symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject,
i: number,
order: PortfolioOrderItem
) {
if (
!symbolMetricsHelper.investmentAtStartDate &&
i >= symbolMetricsHelper.indexOfStartOrder
) {
symbolMetricsHelper.investmentAtStartDate = new Big(
symbolMetricsHelper.symbolMetrics.totalInvestment.toNumber()
);
symbolMetricsHelper.investmentAtStartDateWithCurrencyEffect = new Big(
symbolMetricsHelper.symbolMetrics.totalInvestmentWithCurrencyEffect.toNumber()
);
symbolMetricsHelper.valueAtStartDate = new Big(
symbolMetricsHelper.investmentValueBeforeTransaction.toNumber()
);
symbolMetricsHelper.valueAtStartDateWithCurrencyEffect = new Big(
symbolMetricsHelper.investmentValueBeforeTransactionWithCurrencyEffect.toNumber()
);
}
if (order.itemType === 'start') {
symbolMetricsHelper.feesAtStartDate = symbolMetricsHelper.fees;
symbolMetricsHelper.feesAtStartDateWithCurrencyEffect =
symbolMetricsHelper.feesWithCurrencyEffect;
symbolMetricsHelper.grossPerformanceAtStartDate =
symbolMetricsHelper.symbolMetrics.grossPerformance;
symbolMetricsHelper.grossPerformanceAtStartDateWithCurrencyEffect =
symbolMetricsHelper.symbolMetrics.grossPerformanceWithCurrencyEffect;
}
if (
i >= symbolMetricsHelper.indexOfStartOrder &&
!symbolMetricsHelper.initialValue
) {
if (
i === symbolMetricsHelper.indexOfStartOrder &&
!symbolMetricsHelper.symbolMetrics.totalInvestment.eq(0)
) {
symbolMetricsHelper.initialValue = new Big(
symbolMetricsHelper.symbolMetrics.totalInvestment.toNumber()
);
symbolMetricsHelper.initialValueWithCurrencyEffect = new Big(
symbolMetricsHelper.symbolMetrics.totalInvestmentWithCurrencyEffect.toNumber()
);
}
}
}
public getSymbolMetricHelperObject(
exchangeRates: { [dateString: string]: number },
start: Date,
end: Date,
marketSymbolMap: { [date: string]: { [symbol: string]: Big } },
symbol: string
): PortfolioCalculatorSymbolMetricsHelperObject {
const symbolMetricsHelper =
new PortfolioCalculatorSymbolMetricsHelperObject();
symbolMetricsHelper.symbolMetrics = this.createEmptySymbolMetrics();
symbolMetricsHelper.currentExchangeRate =
exchangeRates[format(new Date(), DATE_FORMAT)];
symbolMetricsHelper.startDateString = format(start, DATE_FORMAT);
symbolMetricsHelper.endDateString = format(end, DATE_FORMAT);
symbolMetricsHelper.unitPriceAtStartDate =
marketSymbolMap[symbolMetricsHelper.startDateString]?.[symbol];
symbolMetricsHelper.unitPriceAtEndDate =
marketSymbolMap[symbolMetricsHelper.endDateString]?.[symbol];
symbolMetricsHelper.totalUnits = new Big(0);
return symbolMetricsHelper;
}
public getUnitPriceAndFillCurrencyDeviations(
order: PortfolioOrderItem,
symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject
) {
const unitprice = ['BUY', 'SELL'].includes(order.type)
? order.unitPrice
: order.unitPriceFromMarketData;
if (unitprice) {
order.unitPriceInBaseCurrency = unitprice.mul(
symbolMetricsHelper.currentExchangeRate ?? 1
);
order.unitPriceInBaseCurrencyWithCurrencyEffect = unitprice.mul(
symbolMetricsHelper.exchangeRateAtOrderDate ?? 1
);
}
return unitprice;
}
public handleOrderFee(
order: PortfolioOrderItem,
symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject
) {
if (order.fee) {
order.feeInBaseCurrency = order.fee.mul(
symbolMetricsHelper.currentExchangeRate ?? 1
);
order.feeInBaseCurrencyWithCurrencyEffect = order.fee.mul(
symbolMetricsHelper.exchangeRateAtOrderDate ?? 1
);
}
}
public handleStartOrder(
order: PortfolioOrderItem,
i: number,
orders: PortfolioOrderItem[],
unitPriceAtStartDate: Big.Big
) {
if (order.itemType === 'start') {
// Take the unit price of the order as the market price if there are no
// orders of this symbol before the start date
order.unitPrice =
i === 0 ? orders[i + 1]?.unitPrice : unitPriceAtStartDate;
}
}
public handleNoneBuyAndSellOrders(
order: PortfolioOrderItem,
value: Big.Big,
symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject
) {
const symbolMetricsKey = this.getSymbolMetricsKeyFromOrderType(order.type);
if (symbolMetricsKey) {
this.calculateMetrics(value, symbolMetricsHelper, symbolMetricsKey);
}
}
public getSymbolMetricsKeyFromOrderType(
orderType: PortfolioOrderItem['type']
): keyof SymbolMetrics {
switch (orderType) {
case 'DIVIDEND':
return 'totalDividend';
case 'INTEREST':
return 'totalInterest';
case 'ITEM':
return 'totalValuables';
case 'LIABILITY':
return 'totalLiabilities';
default:
return undefined;
}
}
public calculateMetrics(
value: Big,
symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject,
key: keyof SymbolMetrics
) {
const stringKey = key.toString();
symbolMetricsHelper.symbolMetrics[stringKey] = (
symbolMetricsHelper.symbolMetrics[stringKey] as Big
).plus(value);
if (
Object.keys(symbolMetricsHelper.symbolMetrics).includes(
stringKey + this.baseCurrencySuffix
)
) {
symbolMetricsHelper.symbolMetrics[stringKey + this.baseCurrencySuffix] = (
symbolMetricsHelper.symbolMetrics[
stringKey + this.baseCurrencySuffix
] as Big
).plus(value.mul(symbolMetricsHelper.exchangeRateAtOrderDate ?? 1));
} else {
throw new Error(
`Key ${stringKey + this.baseCurrencySuffix} not found in symbolMetrics`
);
}
}
public writeOrderToLogIfNecessary(i: number, order: PortfolioOrderItem) {
if (this.ENABLE_LOGGING) {
console.log();
console.log();
console.log(
i + 1,
order.date,
order.type,
order.itemType ? `(${order.itemType})` : ''
);
}
}
public fillOrdersAndSortByTime(
orders: PortfolioOrderItem[],
symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject,
chartDateMap: { [date: string]: boolean },
marketSymbolMap: { [date: string]: { [symbol: string]: Big.Big } },
symbol: string,
dataSource: DataSource
) {
this.fillOrdersByDate(orders, symbolMetricsHelper.ordersByDate);
this.chartDates ??= Object.keys(chartDateMap).sort();
this.fillOrdersWithDatesFromChartDate(
symbolMetricsHelper,
marketSymbolMap,
symbol,
orders,
dataSource
);
// Sort orders so that the start and end placeholder order are at the correct
// position
orders = this.sortOrdersByTime(orders);
return orders;
}
public sortOrdersByTime(orders: PortfolioOrderItem[]) {
orders = sortBy(orders, ({ date, itemType }) => {
let sortIndex = new Date(date);
if (itemType === 'end') {
sortIndex = addMilliseconds(sortIndex, 1);
} else if (itemType === 'start') {
sortIndex = addMilliseconds(sortIndex, -1);
}
return sortIndex.getTime();
});
return orders;
}
public fillOrdersWithDatesFromChartDate(
symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject,
marketSymbolMap: { [date: string]: { [symbol: string]: Big.Big } },
symbol: string,
orders: PortfolioOrderItem[],
dataSource: DataSource
) {
let lastUnitPrice: Big;
for (const dateString of this.chartDates) {
if (dateString < symbolMetricsHelper.startDateString) {
continue;
} else if (dateString > symbolMetricsHelper.endDateString) {
break;
}
if (symbolMetricsHelper.ordersByDate[dateString]?.length > 0) {
for (const order of symbolMetricsHelper.ordersByDate[dateString]) {
order.unitPriceFromMarketData =
marketSymbolMap[dateString]?.[symbol] ?? lastUnitPrice;
}
} else {
orders.push(
this.getFakeOrder(
dateString,
dataSource,
symbol,
marketSymbolMap,
lastUnitPrice
)
);
}
const lastOrder = orders.at(-1);
lastUnitPrice = lastOrder.unitPriceFromMarketData ?? lastOrder.unitPrice;
}
return lastUnitPrice;
}
public getFakeOrder(
dateString: string,
dataSource: DataSource,
symbol: string,
marketSymbolMap: { [date: string]: { [symbol: string]: Big.Big } },
lastUnitPrice: Big.Big
): PortfolioOrderItem {
return {
date: dateString,
fee: new Big(0),
feeInBaseCurrency: new Big(0),
quantity: new Big(0),
SymbolProfile: {
dataSource,
symbol
},
type: 'BUY',
unitPrice: marketSymbolMap[dateString]?.[symbol] ?? lastUnitPrice,
unitPriceFromMarketData:
marketSymbolMap[dateString]?.[symbol] ?? lastUnitPrice
};
}
public fillOrdersByDate(
orders: PortfolioOrderItem[],
ordersByDate: { [date: string]: PortfolioOrderItem[] }
) {
for (const order of orders) {
ordersByDate[order.date] = ordersByDate[order.date] ?? [];
ordersByDate[order.date].push(order);
}
}
public addSyntheticStartAndEndOrder(
orders: PortfolioOrderItem[],
symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject,
dataSource: DataSource,
symbol: string
) {
orders.push({
date: symbolMetricsHelper.startDateString,
fee: new Big(0),
feeInBaseCurrency: new Big(0),
itemType: 'start',
quantity: new Big(0),
SymbolProfile: {
dataSource,
symbol
},
type: 'BUY',
unitPrice: symbolMetricsHelper.unitPriceAtStartDate
});
orders.push({
date: symbolMetricsHelper.endDateString,
fee: new Big(0),
feeInBaseCurrency: new Big(0),
itemType: 'end',
SymbolProfile: {
dataSource,
symbol
},
quantity: new Big(0),
type: 'BUY',
unitPrice: symbolMetricsHelper.unitPriceAtEndDate
});
}
public hasNoUnitPriceAtEndOrStartDate(
unitPriceAtEndDate: Big.Big,
unitPriceAtStartDate: Big.Big,
orders: PortfolioOrderItem[],
start: Date
) {
return (
!unitPriceAtEndDate ||
(!unitPriceAtStartDate && isBefore(new Date(orders[0].date), start))
);
}
public createEmptySymbolMetrics(): SymbolMetrics {
return {
currentValues: {},
currentValuesWithCurrencyEffect: {},
feesWithCurrencyEffect: new Big(0),
grossPerformance: new Big(0),
grossPerformancePercentage: new Big(0),
grossPerformancePercentageWithCurrencyEffect: new Big(0),
grossPerformanceWithCurrencyEffect: new Big(0),
hasErrors: false,
initialValue: new Big(0),
initialValueWithCurrencyEffect: new Big(0),
investmentValuesAccumulated: {},
investmentValuesAccumulatedWithCurrencyEffect: {},
investmentValuesWithCurrencyEffect: {},
netPerformance: new Big(0),
netPerformancePercentage: new Big(0),
netPerformancePercentageWithCurrencyEffectMap: {},
netPerformanceValues: {},
netPerformanceValuesWithCurrencyEffect: {},
netPerformanceWithCurrencyEffectMap: {},
timeWeightedInvestment: new Big(0),
timeWeightedInvestmentValues: {},
timeWeightedInvestmentValuesWithCurrencyEffect: {},
timeWeightedInvestmentWithCurrencyEffect: new Big(0),
totalAccountBalanceInBaseCurrency: new Big(0),
totalDividend: new Big(0),
totalDividendInBaseCurrency: new Big(0),
totalInterest: new Big(0),
totalInterestInBaseCurrency: new Big(0),
totalInvestment: new Big(0),
totalInvestmentWithCurrencyEffect: new Big(0),
unitPrices: {},
totalLiabilities: new Big(0),
totalLiabilitiesInBaseCurrency: new Big(0),
totalValuables: new Big(0),
totalValuablesInBaseCurrency: new Big(0)
};
}
private fillOrderUnitPricesIfMissing(
order: PortfolioOrderItem,
symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject
) {
order.unitPriceInBaseCurrency ??= this.marketSymbolMap[order.date]?.[
order.SymbolProfile.symbol
].mul(symbolMetricsHelper.currentExchangeRate);
order.unitPriceInBaseCurrencyWithCurrencyEffect ??= this.marketSymbolMap[
order.date
]?.[order.SymbolProfile.symbol].mul(
symbolMetricsHelper.exchangeRateAtOrderDate
);
}
}

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

@ -1,29 +1,272 @@
import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator';
import { LogPerformance } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor';
import {
AssetProfileIdentifier,
SymbolMetrics
} from '@ghostfolio/common/interfaces';
import { PortfolioSnapshot } from '@ghostfolio/common/models';
import { PortfolioSnapshot, TimelinePosition } from '@ghostfolio/common/models';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Logger } from '@nestjs/common';
import { Big } from 'big.js';
import { cloneDeep } from 'lodash';
import { PortfolioOrderItem } from '../../interfaces/portfolio-order-item.interface';
import { RoiPortfolioCalculatorSymbolMetricsHelper } from './portfolio-calculator-symbolmetrics-helper';
export class RoiPortfolioCalculator extends PortfolioCalculator {
protected calculateOverallPerformance(): PortfolioSnapshot {
throw new Error('Method not implemented.');
}
private chartDates: string[];
protected getPerformanceCalculationType() {
return PerformanceCalculationType.ROI;
@LogPerformance
protected calculateOverallPerformance(
positions: TimelinePosition[]
): PortfolioSnapshot {
let currentValueInBaseCurrency = new Big(0);
let grossPerformance = new Big(0);
let grossPerformanceWithCurrencyEffect = new Big(0);
let hasErrors = false;
let netPerformance = new Big(0);
let totalFeesWithCurrencyEffect = new Big(0);
const totalInterestWithCurrencyEffect = new Big(0);
let totalInvestment = new Big(0);
let totalInvestmentWithCurrencyEffect = new Big(0);
let totalTimeWeightedInvestment = new Big(0);
let totalTimeWeightedInvestmentWithCurrencyEffect = new Big(0);
for (const currentPosition of positions) {
({
totalFeesWithCurrencyEffect,
currentValueInBaseCurrency,
hasErrors,
totalInvestment,
totalInvestmentWithCurrencyEffect,
grossPerformance,
grossPerformanceWithCurrencyEffect,
netPerformance,
totalTimeWeightedInvestment,
totalTimeWeightedInvestmentWithCurrencyEffect
} = this.calculatePositionMetrics(
currentPosition,
totalFeesWithCurrencyEffect,
currentValueInBaseCurrency,
hasErrors,
totalInvestment,
totalInvestmentWithCurrencyEffect,
grossPerformance,
grossPerformanceWithCurrencyEffect,
netPerformance,
totalTimeWeightedInvestment,
totalTimeWeightedInvestmentWithCurrencyEffect
));
}
return {
currentValueInBaseCurrency,
hasErrors,
positions,
totalFeesWithCurrencyEffect,
totalInterestWithCurrencyEffect,
totalInvestment,
totalInvestmentWithCurrencyEffect,
activitiesCount: this.activities.filter(({ type }) => {
return ['BUY', 'SELL', 'STAKE'].includes(type);
}).length,
createdAt: new Date(),
errors: [],
historicalData: [],
totalLiabilitiesWithCurrencyEffect: new Big(0),
totalValuablesWithCurrencyEffect: new Big(0)
};
}
protected getSymbolMetrics({}: {
protected getSymbolMetrics({
chartDateMap,
dataSource,
end,
exchangeRates,
marketSymbolMap,
start,
symbol
}: {
chartDateMap?: { [date: string]: boolean };
end: Date;
exchangeRates: { [dateString: string]: number };
marketSymbolMap: {
[date: string]: { [symbol: string]: Big };
};
start: Date;
step?: number;
} & AssetProfileIdentifier): SymbolMetrics {
throw new Error('Method not implemented.');
if (!this.chartDates) {
this.chartDates = Object.keys(chartDateMap).sort();
}
const symbolMetricsHelperClass =
new RoiPortfolioCalculatorSymbolMetricsHelper(
PortfolioCalculator.ENABLE_LOGGING,
marketSymbolMap,
this.chartDates
);
const symbolMetricsHelper =
symbolMetricsHelperClass.getSymbolMetricHelperObject(
exchangeRates,
start,
end,
marketSymbolMap,
symbol
);
let orders: PortfolioOrderItem[] = cloneDeep(
this.activities.filter(({ SymbolProfile }) => {
return SymbolProfile.symbol === symbol;
})
);
if (!orders.length) {
return symbolMetricsHelper.symbolMetrics;
}
if (
symbolMetricsHelperClass.hasNoUnitPriceAtEndOrStartDate(
symbolMetricsHelper.unitPriceAtEndDate,
symbolMetricsHelper.unitPriceAtStartDate,
orders,
start
)
) {
symbolMetricsHelper.symbolMetrics.hasErrors = true;
return symbolMetricsHelper.symbolMetrics;
}
symbolMetricsHelperClass.addSyntheticStartAndEndOrder(
orders,
symbolMetricsHelper,
dataSource,
symbol
);
orders = symbolMetricsHelperClass.fillOrdersAndSortByTime(
orders,
symbolMetricsHelper,
chartDateMap,
marketSymbolMap,
symbol,
dataSource
);
symbolMetricsHelper.indexOfStartOrder = orders.findIndex(({ itemType }) => {
return itemType === 'start';
});
symbolMetricsHelper.indexOfEndOrder = orders.findIndex(({ itemType }) => {
return itemType === 'end';
});
for (let i = 0; i < orders.length; i++) {
symbolMetricsHelperClass.processOrderMetrics(
orders,
i,
exchangeRates,
symbolMetricsHelper
);
if (i === symbolMetricsHelper.indexOfEndOrder) {
break;
}
}
symbolMetricsHelperClass.handleOverallPerformanceCalculation(
symbolMetricsHelper
);
symbolMetricsHelperClass.calculateNetPerformanceByDateRange(
start,
symbolMetricsHelper
);
return symbolMetricsHelper.symbolMetrics;
}
protected getPerformanceCalculationType() {
return PerformanceCalculationType.ROI;
}
private calculatePositionMetrics(
currentPosition: TimelinePosition,
totalFeesWithCurrencyEffect: Big,
currentValueInBaseCurrency: Big,
hasErrors: boolean,
totalInvestment: Big,
totalInvestmentWithCurrencyEffect: Big,
grossPerformance: Big,
grossPerformanceWithCurrencyEffect: Big,
netPerformance: Big,
totalTimeWeightedInvestment: Big,
totalTimeWeightedInvestmentWithCurrencyEffect: Big
) {
if (currentPosition.feeInBaseCurrency) {
totalFeesWithCurrencyEffect = totalFeesWithCurrencyEffect.plus(
currentPosition.feeInBaseCurrency
);
}
if (currentPosition.valueInBaseCurrency) {
currentValueInBaseCurrency = currentValueInBaseCurrency.plus(
currentPosition.valueInBaseCurrency
);
} else {
hasErrors = true;
}
if (currentPosition.investment) {
totalInvestment = totalInvestment.plus(currentPosition.investment);
totalInvestmentWithCurrencyEffect =
totalInvestmentWithCurrencyEffect.plus(
currentPosition.investmentWithCurrencyEffect
);
} else {
hasErrors = true;
}
if (currentPosition.grossPerformance) {
grossPerformance = grossPerformance.plus(
currentPosition.grossPerformance
);
grossPerformanceWithCurrencyEffect =
grossPerformanceWithCurrencyEffect.plus(
currentPosition.grossPerformanceWithCurrencyEffect
);
netPerformance = netPerformance.plus(currentPosition.netPerformance);
} else if (!currentPosition.quantity.eq(0)) {
hasErrors = true;
}
if (currentPosition.timeWeightedInvestment) {
totalTimeWeightedInvestment = totalTimeWeightedInvestment.plus(
currentPosition.timeWeightedInvestment
);
totalTimeWeightedInvestmentWithCurrencyEffect =
totalTimeWeightedInvestmentWithCurrencyEffect.plus(
currentPosition.timeWeightedInvestmentWithCurrencyEffect
);
} else if (!currentPosition.quantity.eq(0)) {
Logger.warn(
`Missing historical market data for ${currentPosition.symbol} (${currentPosition.dataSource})`,
'PortfolioCalculator'
);
hasErrors = true;
}
return {
totalFeesWithCurrencyEffect,
currentValueInBaseCurrency,
hasErrors,
totalInvestment,
totalInvestmentWithCurrencyEffect,
grossPerformance,
grossPerformanceWithCurrencyEffect,
netPerformance,
totalTimeWeightedInvestment,
totalTimeWeightedInvestmentWithCurrencyEffect
};
}
}

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

@ -412,6 +412,14 @@ export class PortfolioController {
filterByTags
});
const { performance } = await this.portfolioService.getPerformance({
dateRange,
filters,
impersonationId,
withExcludedAccounts: false,
userId: this.request.user.id
});
const { holdings } = await this.portfolioService.getDetails({
dateRange,
filters,
@ -419,7 +427,7 @@ export class PortfolioController {
userId: this.request.user.id
});
return { holdings: Object.values(holdings) };
return { holdings: Object.values(holdings), performance };
}
@Get('investments')

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

@ -1155,9 +1155,8 @@ export class PortfolioService {
const { endDate, startDate } = getIntervalFromDateRange(dateRange);
const range = { end: endDate, start: startDate };
const { chart } = await portfolioCalculator.getPerformance(range);
const {
chart,
netPerformance,
netPerformanceInPercentage,
netPerformanceInPercentageWithCurrencyEffect,
@ -1165,15 +1164,7 @@ export class PortfolioService {
netWorth,
totalInvestment,
valueWithCurrencyEffect
} = chart?.at(-1) ?? {
netPerformance: 0,
netPerformanceInPercentage: 0,
netPerformanceInPercentageWithCurrencyEffect: 0,
netPerformanceWithCurrencyEffect: 0,
netWorth: 0,
totalInvestment: 0,
valueWithCurrencyEffect: 0
};
} = await portfolioCalculator.getPerformance(range);
return {
chart,
@ -1365,145 +1356,6 @@ export class PortfolioService {
await this.orderService.assignTags({ dataSource, symbol, tags, userId });
}
private getAggregatedMarkets(holdings: Record<string, PortfolioPosition>): {
markets: PortfolioDetails['markets'];
marketsAdvanced: PortfolioDetails['marketsAdvanced'];
} {
const markets: PortfolioDetails['markets'] = {
[UNKNOWN_KEY]: {
id: UNKNOWN_KEY,
valueInBaseCurrency: 0,
valueInPercentage: 0
},
developedMarkets: {
id: 'developedMarkets',
valueInBaseCurrency: 0,
valueInPercentage: 0
},
emergingMarkets: {
id: 'emergingMarkets',
valueInBaseCurrency: 0,
valueInPercentage: 0
},
otherMarkets: {
id: 'otherMarkets',
valueInBaseCurrency: 0,
valueInPercentage: 0
}
};
const marketsAdvanced: PortfolioDetails['marketsAdvanced'] = {
[UNKNOWN_KEY]: {
id: UNKNOWN_KEY,
valueInBaseCurrency: 0,
valueInPercentage: 0
},
asiaPacific: {
id: 'asiaPacific',
valueInBaseCurrency: 0,
valueInPercentage: 0
},
emergingMarkets: {
id: 'emergingMarkets',
valueInBaseCurrency: 0,
valueInPercentage: 0
},
europe: {
id: 'europe',
valueInBaseCurrency: 0,
valueInPercentage: 0
},
japan: {
id: 'japan',
valueInBaseCurrency: 0,
valueInPercentage: 0
},
northAmerica: {
id: 'northAmerica',
valueInBaseCurrency: 0,
valueInPercentage: 0
},
otherMarkets: {
id: 'otherMarkets',
valueInBaseCurrency: 0,
valueInPercentage: 0
}
};
for (const [, position] of Object.entries(holdings)) {
const value = position.valueInBaseCurrency;
if (position.assetClass !== AssetClass.LIQUIDITY) {
if (position.countries.length > 0) {
markets.developedMarkets.valueInBaseCurrency +=
position.markets.developedMarkets * value;
markets.emergingMarkets.valueInBaseCurrency +=
position.markets.emergingMarkets * value;
markets.otherMarkets.valueInBaseCurrency +=
position.markets.otherMarkets * value;
marketsAdvanced.asiaPacific.valueInBaseCurrency +=
position.marketsAdvanced.asiaPacific * value;
marketsAdvanced.emergingMarkets.valueInBaseCurrency +=
position.marketsAdvanced.emergingMarkets * value;
marketsAdvanced.europe.valueInBaseCurrency +=
position.marketsAdvanced.europe * value;
marketsAdvanced.japan.valueInBaseCurrency +=
position.marketsAdvanced.japan * value;
marketsAdvanced.northAmerica.valueInBaseCurrency +=
position.marketsAdvanced.northAmerica * value;
marketsAdvanced.otherMarkets.valueInBaseCurrency +=
position.marketsAdvanced.otherMarkets * value;
} else {
markets[UNKNOWN_KEY].valueInBaseCurrency += value;
marketsAdvanced[UNKNOWN_KEY].valueInBaseCurrency += value;
}
}
}
const marketsTotalInBaseCurrency = getSum(
Object.values(markets).map(({ valueInBaseCurrency }) => {
return new Big(valueInBaseCurrency);
})
).toNumber();
markets.developedMarkets.valueInPercentage =
markets.developedMarkets.valueInBaseCurrency / marketsTotalInBaseCurrency;
markets.emergingMarkets.valueInPercentage =
markets.emergingMarkets.valueInBaseCurrency / marketsTotalInBaseCurrency;
markets.otherMarkets.valueInPercentage =
markets.otherMarkets.valueInBaseCurrency / marketsTotalInBaseCurrency;
markets[UNKNOWN_KEY].valueInPercentage =
markets[UNKNOWN_KEY].valueInBaseCurrency / marketsTotalInBaseCurrency;
const marketsAdvancedTotal =
marketsAdvanced.asiaPacific.valueInBaseCurrency +
marketsAdvanced.emergingMarkets.valueInBaseCurrency +
marketsAdvanced.europe.valueInBaseCurrency +
marketsAdvanced.japan.valueInBaseCurrency +
marketsAdvanced.northAmerica.valueInBaseCurrency +
marketsAdvanced.otherMarkets.valueInBaseCurrency +
marketsAdvanced[UNKNOWN_KEY].valueInBaseCurrency;
marketsAdvanced.asiaPacific.valueInPercentage =
marketsAdvanced.asiaPacific.valueInBaseCurrency / marketsAdvancedTotal;
marketsAdvanced.emergingMarkets.valueInPercentage =
marketsAdvanced.emergingMarkets.valueInBaseCurrency /
marketsAdvancedTotal;
marketsAdvanced.europe.valueInPercentage =
marketsAdvanced.europe.valueInBaseCurrency / marketsAdvancedTotal;
marketsAdvanced.japan.valueInPercentage =
marketsAdvanced.japan.valueInBaseCurrency / marketsAdvancedTotal;
marketsAdvanced.northAmerica.valueInPercentage =
marketsAdvanced.northAmerica.valueInBaseCurrency / marketsAdvancedTotal;
marketsAdvanced.otherMarkets.valueInPercentage =
marketsAdvanced.otherMarkets.valueInBaseCurrency / marketsAdvancedTotal;
marketsAdvanced[UNKNOWN_KEY].valueInPercentage =
marketsAdvanced[UNKNOWN_KEY].valueInBaseCurrency / marketsAdvancedTotal;
return { markets, marketsAdvanced };
}
@LogPerformance
private async getCashPositions({
cashDetails,
@ -1555,68 +1407,6 @@ export class PortfolioService {
return cashPositions;
}
private getDividendsByGroup({
dividends,
groupBy
}: {
dividends: InvestmentItem[];
groupBy: GroupBy;
}): InvestmentItem[] {
if (dividends.length === 0) {
return [];
}
const dividendsByGroup: InvestmentItem[] = [];
let currentDate: Date;
let investmentByGroup = new Big(0);
for (const [index, dividend] of dividends.entries()) {
if (
isSameYear(parseDate(dividend.date), currentDate) &&
(groupBy === 'year' ||
isSameMonth(parseDate(dividend.date), currentDate))
) {
// Same group: Add up dividends
investmentByGroup = investmentByGroup.plus(dividend.investment);
} else {
// New group: Store previous group and reset
if (currentDate) {
dividendsByGroup.push({
date: format(
set(currentDate, {
date: 1,
month: groupBy === 'year' ? 0 : currentDate.getMonth()
}),
DATE_FORMAT
),
investment: investmentByGroup.toNumber()
});
}
currentDate = parseDate(dividend.date);
investmentByGroup = new Big(dividend.investment);
}
if (index === dividends.length - 1) {
// Store current month (latest order)
dividendsByGroup.push({
date: format(
set(currentDate, {
date: 1,
month: groupBy === 'year' ? 0 : currentDate.getMonth()
}),
DATE_FORMAT
),
investment: investmentByGroup.toNumber()
});
}
}
return dividendsByGroup;
}
@LogPerformance
private getEmergencyFundHoldingsValueInBaseCurrency({
holdings
@ -1643,137 +1433,15 @@ export class PortfolioService {
return valueInBaseCurrencyOfEmergencyFundHoldings.toNumber();
}
private getInitialCashPosition({
balance,
currency
}: {
balance: number;
currency: string;
}): PortfolioPosition {
return {
currency,
allocationInPercentage: 0,
assetClass: AssetClass.LIQUIDITY,
assetSubClass: AssetSubClass.CASH,
countries: [],
dataSource: undefined,
dateOfFirstActivity: undefined,
dividend: 0,
grossPerformance: 0,
grossPerformancePercent: 0,
grossPerformancePercentWithCurrencyEffect: 0,
grossPerformanceWithCurrencyEffect: 0,
holdings: [],
investment: balance,
marketPrice: 0,
name: currency,
netPerformance: 0,
netPerformancePercent: 0,
netPerformancePercentWithCurrencyEffect: 0,
netPerformanceWithCurrencyEffect: 0,
quantity: 0,
sectors: [],
symbol: currency,
tags: [],
transactionCount: 0,
valueInBaseCurrency: balance
};
}
private getMarkets({
assetProfile
}: {
assetProfile: EnhancedSymbolProfile;
}) {
const markets = {
[UNKNOWN_KEY]: 0,
developedMarkets: 0,
emergingMarkets: 0,
otherMarkets: 0
};
const marketsAdvanced = {
[UNKNOWN_KEY]: 0,
asiaPacific: 0,
emergingMarkets: 0,
europe: 0,
japan: 0,
northAmerica: 0,
otherMarkets: 0
};
if (assetProfile.countries.length > 0) {
for (const country of assetProfile.countries) {
if (developedMarkets.includes(country.code)) {
markets.developedMarkets = new Big(markets.developedMarkets)
.plus(country.weight)
.toNumber();
} else if (emergingMarkets.includes(country.code)) {
markets.emergingMarkets = new Big(markets.emergingMarkets)
.plus(country.weight)
.toNumber();
} else {
markets.otherMarkets = new Big(markets.otherMarkets)
.plus(country.weight)
.toNumber();
}
if (country.code === 'JP') {
marketsAdvanced.japan = new Big(marketsAdvanced.japan)
.plus(country.weight)
.toNumber();
} else if (country.code === 'CA' || country.code === 'US') {
marketsAdvanced.northAmerica = new Big(marketsAdvanced.northAmerica)
.plus(country.weight)
.toNumber();
} else if (asiaPacificMarkets.includes(country.code)) {
marketsAdvanced.asiaPacific = new Big(marketsAdvanced.asiaPacific)
.plus(country.weight)
.toNumber();
} else if (emergingMarkets.includes(country.code)) {
marketsAdvanced.emergingMarkets = new Big(
marketsAdvanced.emergingMarkets
)
.plus(country.weight)
.toNumber();
} else if (europeMarkets.includes(country.code)) {
marketsAdvanced.europe = new Big(marketsAdvanced.europe)
.plus(country.weight)
.toNumber();
} else {
marketsAdvanced.otherMarkets = new Big(marketsAdvanced.otherMarkets)
.plus(country.weight)
.toNumber();
}
}
}
markets[UNKNOWN_KEY] = new Big(1)
.minus(markets.developedMarkets)
.minus(markets.emergingMarkets)
.minus(markets.otherMarkets)
.toNumber();
marketsAdvanced[UNKNOWN_KEY] = new Big(1)
.minus(marketsAdvanced.asiaPacific)
.minus(marketsAdvanced.emergingMarkets)
.minus(marketsAdvanced.europe)
.minus(marketsAdvanced.japan)
.minus(marketsAdvanced.northAmerica)
.minus(marketsAdvanced.otherMarkets)
.toNumber();
return { markets, marketsAdvanced };
}
@LogPerformance
private getReportStatistics(
evaluatedRules: PortfolioReportResponse['rules']
): PortfolioReportResponse['statistics'] {
const rulesActiveCount = Object.values(evaluatedRules)
.flat()
.filter((rule) => {
return rule?.isActive === true;
}).length;
@LogPerformance
private getReportStatistics(
evaluatedRules: PortfolioReportResponse['rules']
): PortfolioReportResponse['statistics'] {
const rulesActiveCount = Object.values(evaluatedRules)
.flat()
.filter((rule) => {
return rule?.isActive === true;
}).length;
const rulesFulfilledCount = Object.values(evaluatedRules)
.flat()
@ -1990,69 +1658,6 @@ export class PortfolioService {
};
}
@LogPerformance
private getSumOfActivityType({
activities,
activityType,
userCurrency
}: {
activities: Activity[];
activityType: ActivityType;
userCurrency: string;
}) {
return getSum(
activities
.filter(({ isDraft, type }) => {
return isDraft === false && type === activityType;
})
.map(({ quantity, SymbolProfile, unitPrice }) => {
return new Big(
this.exchangeRateDataService.toCurrency(
new Big(quantity).mul(unitPrice).toNumber(),
SymbolProfile.currency,
userCurrency
)
);
})
);
}
private getTotalEmergencyFund({
emergencyFundHoldingsValueInBaseCurrency,
userSettings
}: {
emergencyFundHoldingsValueInBaseCurrency: number;
userSettings: UserSettings;
}) {
return new Big(
Math.max(
emergencyFundHoldingsValueInBaseCurrency,
userSettings?.emergencyFund ?? 0
)
);
}
private getUserCurrency(aUser?: UserWithSettings) {
return (
aUser?.Settings?.settings.baseCurrency ??
this.request.user?.Settings?.settings.baseCurrency ??
DEFAULT_CURRENCY
);
}
private async getUserId(aImpersonationId: string, aUserId: string) {
const impersonationUserId =
await this.impersonationService.validateImpersonationId(aImpersonationId);
return impersonationUserId || aUserId;
}
private getUserPerformanceCalculationType(
aUser: UserWithSettings
): PerformanceCalculationType {
return aUser?.Settings?.settings.performanceCalculationType;
}
@LogPerformance
private async getValueOfAccountsAndPlatforms({
activities,
@ -2185,4 +1790,390 @@ export class PortfolioService {
return { accounts, platforms };
}
@LogPerformance
private getSumOfActivityType({
activities,
activityType,
userCurrency
}: {
activities: Activity[];
activityType: ActivityType;
userCurrency: string;
}) {
return getSum(
activities
.filter(({ isDraft, type }) => {
return isDraft === false && type === activityType;
})
.map(({ quantity, SymbolProfile, unitPrice }) => {
return new Big(
this.exchangeRateDataService.toCurrency(
new Big(quantity).mul(unitPrice).toNumber(),
SymbolProfile.currency,
userCurrency
)
);
})
);
}
private getInitialCashPosition({
balance,
currency
}: {
balance: number;
currency: string;
}): PortfolioPosition {
return {
currency,
allocationInPercentage: 0,
assetClass: AssetClass.LIQUIDITY,
assetSubClass: AssetSubClass.CASH,
countries: [],
dataSource: undefined,
dateOfFirstActivity: undefined,
dividend: 0,
grossPerformance: 0,
grossPerformancePercent: 0,
grossPerformancePercentWithCurrencyEffect: 0,
grossPerformanceWithCurrencyEffect: 0,
holdings: [],
investment: balance,
marketPrice: 0,
name: currency,
netPerformance: 0,
netPerformancePercent: 0,
netPerformancePercentWithCurrencyEffect: 0,
netPerformanceWithCurrencyEffect: 0,
quantity: 0,
sectors: [],
symbol: currency,
tags: [],
transactionCount: 0,
valueInBaseCurrency: balance
};
}
private getDividendsByGroup({
dividends,
groupBy
}: {
dividends: InvestmentItem[];
groupBy: GroupBy;
}): InvestmentItem[] {
if (dividends.length === 0) {
return [];
}
const dividendsByGroup: InvestmentItem[] = [];
let currentDate: Date;
let investmentByGroup = new Big(0);
for (const [index, dividend] of dividends.entries()) {
if (
isSameYear(parseDate(dividend.date), currentDate) &&
(groupBy === 'year' ||
isSameMonth(parseDate(dividend.date), currentDate))
) {
// Same group: Add up dividends
investmentByGroup = investmentByGroup.plus(dividend.investment);
} else {
// New group: Store previous group and reset
if (currentDate) {
dividendsByGroup.push({
date: format(
set(currentDate, {
date: 1,
month: groupBy === 'year' ? 0 : currentDate.getMonth()
}),
DATE_FORMAT
),
investment: investmentByGroup.toNumber()
});
}
currentDate = parseDate(dividend.date);
investmentByGroup = new Big(dividend.investment);
}
if (index === dividends.length - 1) {
// Store current month (latest order)
dividendsByGroup.push({
date: format(
set(currentDate, {
date: 1,
month: groupBy === 'year' ? 0 : currentDate.getMonth()
}),
DATE_FORMAT
),
investment: investmentByGroup.toNumber()
});
}
}
return dividendsByGroup;
}
private getMarkets({
assetProfile
}: {
assetProfile: EnhancedSymbolProfile;
}) {
const markets = {
[UNKNOWN_KEY]: 0,
developedMarkets: 0,
emergingMarkets: 0,
otherMarkets: 0
};
const marketsAdvanced = {
[UNKNOWN_KEY]: 0,
asiaPacific: 0,
emergingMarkets: 0,
europe: 0,
japan: 0,
northAmerica: 0,
otherMarkets: 0
};
if (assetProfile.countries.length > 0) {
for (const country of assetProfile.countries) {
if (developedMarkets.includes(country.code)) {
markets.developedMarkets = new Big(markets.developedMarkets)
.plus(country.weight)
.toNumber();
} else if (emergingMarkets.includes(country.code)) {
markets.emergingMarkets = new Big(markets.emergingMarkets)
.plus(country.weight)
.toNumber();
} else {
markets.otherMarkets = new Big(markets.otherMarkets)
.plus(country.weight)
.toNumber();
}
if (country.code === 'JP') {
marketsAdvanced.japan = new Big(marketsAdvanced.japan)
.plus(country.weight)
.toNumber();
} else if (country.code === 'CA' || country.code === 'US') {
marketsAdvanced.northAmerica = new Big(marketsAdvanced.northAmerica)
.plus(country.weight)
.toNumber();
} else if (asiaPacificMarkets.includes(country.code)) {
marketsAdvanced.asiaPacific = new Big(marketsAdvanced.asiaPacific)
.plus(country.weight)
.toNumber();
} else if (emergingMarkets.includes(country.code)) {
marketsAdvanced.emergingMarkets = new Big(
marketsAdvanced.emergingMarkets
)
.plus(country.weight)
.toNumber();
} else if (europeMarkets.includes(country.code)) {
marketsAdvanced.europe = new Big(marketsAdvanced.europe)
.plus(country.weight)
.toNumber();
} else {
marketsAdvanced.otherMarkets = new Big(marketsAdvanced.otherMarkets)
.plus(country.weight)
.toNumber();
}
}
}
markets[UNKNOWN_KEY] = new Big(1)
.minus(markets.developedMarkets)
.minus(markets.emergingMarkets)
.minus(markets.otherMarkets)
.toNumber();
marketsAdvanced[UNKNOWN_KEY] = new Big(1)
.minus(marketsAdvanced.asiaPacific)
.minus(marketsAdvanced.emergingMarkets)
.minus(marketsAdvanced.europe)
.minus(marketsAdvanced.japan)
.minus(marketsAdvanced.northAmerica)
.minus(marketsAdvanced.otherMarkets)
.toNumber();
return { markets, marketsAdvanced };
}
private getTotalEmergencyFund({
emergencyFundHoldingsValueInBaseCurrency,
userSettings
}: {
emergencyFundHoldingsValueInBaseCurrency: number;
userSettings: UserSettings;
}) {
return new Big(
Math.max(
emergencyFundHoldingsValueInBaseCurrency,
userSettings?.emergencyFund ?? 0
)
);
}
private getUserCurrency(aUser?: UserWithSettings) {
return (
aUser?.Settings?.settings.baseCurrency ??
this.request.user?.Settings?.settings.baseCurrency ??
DEFAULT_CURRENCY
);
}
private async getUserId(aImpersonationId: string, aUserId: string) {
const impersonationUserId =
await this.impersonationService.validateImpersonationId(aImpersonationId);
return impersonationUserId || aUserId;
}
private getUserPerformanceCalculationType(
aUser: UserWithSettings
): PerformanceCalculationType {
return aUser?.Settings?.settings.performanceCalculationType;
}
private getAggregatedMarkets(holdings: Record<string, PortfolioPosition>): {
markets: PortfolioDetails['markets'];
marketsAdvanced: PortfolioDetails['marketsAdvanced'];
} {
const markets: PortfolioDetails['markets'] = {
[UNKNOWN_KEY]: {
id: UNKNOWN_KEY,
valueInBaseCurrency: 0,
valueInPercentage: 0
},
developedMarkets: {
id: 'developedMarkets',
valueInBaseCurrency: 0,
valueInPercentage: 0
},
emergingMarkets: {
id: 'emergingMarkets',
valueInBaseCurrency: 0,
valueInPercentage: 0
},
otherMarkets: {
id: 'otherMarkets',
valueInBaseCurrency: 0,
valueInPercentage: 0
}
};
const marketsAdvanced: PortfolioDetails['marketsAdvanced'] = {
[UNKNOWN_KEY]: {
id: UNKNOWN_KEY,
valueInBaseCurrency: 0,
valueInPercentage: 0
},
asiaPacific: {
id: 'asiaPacific',
valueInBaseCurrency: 0,
valueInPercentage: 0
},
emergingMarkets: {
id: 'emergingMarkets',
valueInBaseCurrency: 0,
valueInPercentage: 0
},
europe: {
id: 'europe',
valueInBaseCurrency: 0,
valueInPercentage: 0
},
japan: {
id: 'japan',
valueInBaseCurrency: 0,
valueInPercentage: 0
},
northAmerica: {
id: 'northAmerica',
valueInBaseCurrency: 0,
valueInPercentage: 0
},
otherMarkets: {
id: 'otherMarkets',
valueInBaseCurrency: 0,
valueInPercentage: 0
}
};
for (const [, position] of Object.entries(holdings)) {
const value = position.valueInBaseCurrency;
if (position.assetClass !== AssetClass.LIQUIDITY) {
if (position.countries.length > 0) {
markets.developedMarkets.valueInBaseCurrency +=
position.markets.developedMarkets * value;
markets.emergingMarkets.valueInBaseCurrency +=
position.markets.emergingMarkets * value;
markets.otherMarkets.valueInBaseCurrency +=
position.markets.otherMarkets * value;
marketsAdvanced.asiaPacific.valueInBaseCurrency +=
position.marketsAdvanced.asiaPacific * value;
marketsAdvanced.emergingMarkets.valueInBaseCurrency +=
position.marketsAdvanced.emergingMarkets * value;
marketsAdvanced.europe.valueInBaseCurrency +=
position.marketsAdvanced.europe * value;
marketsAdvanced.japan.valueInBaseCurrency +=
position.marketsAdvanced.japan * value;
marketsAdvanced.northAmerica.valueInBaseCurrency +=
position.marketsAdvanced.northAmerica * value;
marketsAdvanced.otherMarkets.valueInBaseCurrency +=
position.marketsAdvanced.otherMarkets * value;
} else {
markets[UNKNOWN_KEY].valueInBaseCurrency += value;
marketsAdvanced[UNKNOWN_KEY].valueInBaseCurrency += value;
}
}
}
const marketsTotalInBaseCurrency = getSum(
Object.values(markets).map(({ valueInBaseCurrency }) => {
return new Big(valueInBaseCurrency);
})
).toNumber();
markets.developedMarkets.valueInPercentage =
markets.developedMarkets.valueInBaseCurrency / marketsTotalInBaseCurrency;
markets.emergingMarkets.valueInPercentage =
markets.emergingMarkets.valueInBaseCurrency / marketsTotalInBaseCurrency;
markets.otherMarkets.valueInPercentage =
markets.otherMarkets.valueInBaseCurrency / marketsTotalInBaseCurrency;
markets[UNKNOWN_KEY].valueInPercentage =
markets[UNKNOWN_KEY].valueInBaseCurrency / marketsTotalInBaseCurrency;
const marketsAdvancedTotal =
marketsAdvanced.asiaPacific.valueInBaseCurrency +
marketsAdvanced.emergingMarkets.valueInBaseCurrency +
marketsAdvanced.europe.valueInBaseCurrency +
marketsAdvanced.japan.valueInBaseCurrency +
marketsAdvanced.northAmerica.valueInBaseCurrency +
marketsAdvanced.otherMarkets.valueInBaseCurrency +
marketsAdvanced[UNKNOWN_KEY].valueInBaseCurrency;
marketsAdvanced.asiaPacific.valueInPercentage =
marketsAdvanced.asiaPacific.valueInBaseCurrency / marketsAdvancedTotal;
marketsAdvanced.emergingMarkets.valueInPercentage =
marketsAdvanced.emergingMarkets.valueInBaseCurrency /
marketsAdvancedTotal;
marketsAdvanced.europe.valueInPercentage =
marketsAdvanced.europe.valueInBaseCurrency / marketsAdvancedTotal;
marketsAdvanced.japan.valueInPercentage =
marketsAdvanced.japan.valueInBaseCurrency / marketsAdvancedTotal;
marketsAdvanced.northAmerica.valueInPercentage =
marketsAdvanced.northAmerica.valueInBaseCurrency / marketsAdvancedTotal;
marketsAdvanced.otherMarkets.valueInPercentage =
marketsAdvanced.otherMarkets.valueInBaseCurrency / marketsAdvancedTotal;
marketsAdvanced[UNKNOWN_KEY].valueInPercentage =
marketsAdvanced[UNKNOWN_KEY].valueInBaseCurrency / marketsAdvancedTotal;
return { markets, marketsAdvanced };
}
}

5
apps/api/src/app/user/update-user-setting.dto.ts

@ -6,6 +6,7 @@ import type {
HoldingsViewMode,
ViewMode
} from '@ghostfolio/common/types';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import {
IsArray,
@ -117,4 +118,8 @@ export class UpdateUserSettingDto {
@IsOptional()
xRayRules?: XRayRulesSettings;
@IsIn(['TWR', 'ROI', 'ROAI', 'MWR'] as PerformanceCalculationType[])
@IsOptional()
performanceCalculationType?: PerformanceCalculationType;
}

9
apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.html

@ -3,7 +3,14 @@
<div
class="align-items-center d-flex flex-grow-1 h5 mb-0 py-2 text-truncate"
>
<span i18n>Performance</span>
<span i18n
>Performance
{{
user?.settings?.performanceCalculationType === 'ROI'
? '(Time-Weighted)'
: ''
}}</span
>
@if (user?.subscription?.type === 'Basic') {
<gf-premium-indicator class="ml-1" />
}

5
apps/client/src/app/components/home-holdings/home-holdings.component.ts

@ -4,6 +4,7 @@ import { UserService } from '@ghostfolio/client/services/user/user.service';
import {
AssetProfileIdentifier,
PortfolioPosition,
PortfolioPerformance,
ToggleOption,
User
} from '@ghostfolio/common/interfaces';
@ -31,6 +32,7 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
public hasPermissionToAccessHoldingsChart: boolean;
public hasPermissionToCreateOrder: boolean;
public holdings: PortfolioPosition[];
public performance: PortfolioPerformance;
public holdingType: HoldingType = 'ACTIVE';
public holdingTypeOptions: ToggleOption[] = [
{ label: $localize`Active`, value: 'ACTIVE' },
@ -162,8 +164,9 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
this.fetchHoldings()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ holdings }) => {
.subscribe(({ holdings, performance }) => {
this.holdings = holdings;
this.performance = performance;
this.changeDetectorRef.markForCheck();
});

1
apps/client/src/app/components/home-holdings/home-holdings.html

@ -50,6 +50,7 @@
[deviceType]="deviceType"
[holdings]="holdings"
[locale]="user?.settings?.locale"
[performance]="performance"
(holdingClicked)="onHoldingClicked($event)"
/>
@if (hasPermissionToCreateOrder && holdings?.length > 0) {

12
apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html

@ -115,11 +115,13 @@
<div class="flex-nowrap px-3 py-1 row">
<div class="flex-grow-1 text-truncate ml-3">
<ng-container i18n>Net Performance</ng-container>
<abbr
class="initialism ml-2 text-muted"
title="Return on Average Investment"
>(ROAI)</abbr
>
<ng-container *ngIf="this.calculationType">
<abbr
class="initialism ml-2 text-muted"
title="{{ this.calculationType.title }}"
>({{ this.calculationType.value }})</abbr
>
</ng-container>
</div>
<div class="flex-column flex-wrap justify-content-end">
<gf-value

22
apps/client/src/app/components/portfolio-summary/portfolio-summary.component.ts

@ -1,6 +1,7 @@
import { NotificationService } from '@ghostfolio/client/core/notification/notification.service';
import { getDateFnsLocale, getLocale } from '@ghostfolio/common/helper';
import { PortfolioSummary, User } from '@ghostfolio/common/interfaces';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { translate } from '@ghostfolio/ui/i18n';
import {
@ -36,6 +37,8 @@ export class PortfolioSummaryComponent implements OnChanges {
);
public timeInMarket: string;
protected calculationType: { title: string; value: string };
public constructor(private notificationService: NotificationService) {}
public ngOnChanges() {
@ -50,6 +53,7 @@ export class PortfolioSummaryComponent implements OnChanges {
} else {
this.timeInMarket = undefined;
}
this.calculationType = this.getCalulationType();
}
public onEditEmergencyFund() {
@ -64,4 +68,22 @@ export class PortfolioSummaryComponent implements OnChanges {
title: $localize`Please set the amount of your emergency fund.`
});
}
private getCalulationType(): { title: string; value: string } {
switch (this.user?.settings?.performanceCalculationType) {
case PerformanceCalculationType.ROAI:
return {
title: 'Return on Average Investment',
value: PerformanceCalculationType.ROAI
};
case PerformanceCalculationType.ROI:
return {
title: 'Return on Investment',
value: PerformanceCalculationType.ROI
};
default:
return undefined;
}
}
}

4
apps/client/src/app/components/user-account-settings/user-account-settings.html

@ -34,7 +34,6 @@
<mat-form-field appearance="outline" class="w-100 without-hint">
<mat-select
name="performanceCalculationType"
[disabled]="true"
[value]="user.settings.performanceCalculationType"
(selectionChange)="
onChangeUserSetting(
@ -46,6 +45,9 @@
<mat-option value="ROAI"
>Return on Average Investment (ROAI)</mat-option
>
<mat-option value="ROI"
>Return on Investment (ROI)</mat-option
>
</mat-select>
</mat-form-field>
</div>

8
apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts

@ -17,6 +17,7 @@ import type {
DateRange,
GroupBy
} from '@ghostfolio/common/types';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { translate } from '@ghostfolio/ui/i18n';
import { Clipboard } from '@angular/cdk/clipboard';
@ -305,6 +306,7 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
{
date,
netPerformanceInPercentageWithCurrencyEffect,
timeWeightedPerformanceInPercentageWithCurrencyEffect,
totalInvestmentValueWithCurrencyEffect,
valueInPercentage,
valueWithCurrencyEffect
@ -325,7 +327,11 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
}
this.performanceDataItemsInPercentage.push({
date,
value: netPerformanceInPercentageWithCurrencyEffect
value:
this.user?.settings?.performanceCalculationType ===
PerformanceCalculationType.ROI
? timeWeightedPerformanceInPercentageWithCurrencyEffect
: netPerformanceInPercentageWithCurrencyEffect
});
}

4
libs/common/src/lib/interfaces/historical-data-item.interface.ts

@ -16,7 +16,7 @@ export interface HistoricalDataItem {
totalInvestmentValueWithCurrencyEffect?: number;
value?: number;
valueInPercentage?: number;
timeWeightedPerformance?: number;
timeWeightedPerformanceWithCurrencyEffect?: number;
timeWeightedPerformanceInPercentage?: number;
timeWeightedPerformanceInPercentageWithCurrencyEffect?: number;
valueWithCurrencyEffect?: number;
}

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

@ -1,5 +1,9 @@
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
import {
PortfolioPosition,
PortfolioPerformance
} from '@ghostfolio/common/interfaces';
export interface PortfolioHoldingsResponse {
holdings: PortfolioPosition[];
performance: PortfolioPerformance;
}

13
libs/common/src/lib/types/date-range.type.ts

@ -10,3 +10,16 @@ export type DateRange =
| '5y'
| 'max'
| string; // '2024', '2023', '2022', etc.
export const DateRangeTypes: DateRange[] = [
'1d',
'wtd',
'1w',
'mtd',
'1m',
'3m',
'ytd',
'1y',
'5y',
'max'
];

2
libs/ui/src/lib/holdings-table/holdings-table.component.html

@ -252,7 +252,7 @@
<tr
*matFooterRowDef="displayedColumns"
mat-footer-row
[ngClass]="{ hidden: isLoading }"
[ngClass]="{ 'd-none': isLoading }"
></tr>
</table>
</div>

16
libs/ui/src/lib/holdings-table/holdings-table.component.ts

@ -3,7 +3,8 @@ import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module';
import { getLocale } from '@ghostfolio/common/helper';
import {
AssetProfileIdentifier,
PortfolioPosition
PortfolioPosition,
PortfolioPerformance
} from '@ghostfolio/common/interfaces';
import { GfValueComponent } from '@ghostfolio/ui/value';
@ -55,6 +56,7 @@ export class GfHoldingsTableComponent implements OnChanges, OnDestroy {
@Input() holdings: PortfolioPosition[];
@Input() locale = getLocale();
@Input() pageSize = Number.MAX_SAFE_INTEGER;
@Input() performance: PortfolioPerformance;
@Output() holdingClicked = new EventEmitter<AssetProfileIdentifier>();
@ -94,16 +96,10 @@ export class GfHoldingsTableComponent implements OnChanges, OnDestroy {
this.dataSource = new MatTableDataSource(this.holdings);
this.dataSource.paginator = this.paginator;
this.dataSource.sort = this.sort;
this.totalValue = this.dataSource.data.reduce(
(sum, current) => (sum += current.valueInBaseCurrency),
0
);
this.totalChange = this.dataSource.data.reduce(
(sum, current) => (sum += current.netPerformanceWithCurrencyEffect),
0
);
this.totalValue = this.performance.currentValueInBaseCurrency;
this.totalChange = this.performance.netPerformanceWithCurrencyEffect;
this.totalChangePercentage =
this.totalChange / (this.totalValue - this.totalChange);
this.performance.netPerformancePercentageWithCurrencyEffect;
if (this.holdings) {
this.isLoading = false;

Loading…
Cancel
Save