mirror of https://github.com/ghostfolio/ghostfolio
244 changed files with 24397 additions and 7476 deletions
@ -1,9 +1,12 @@ |
|||||
import { IsString } from 'class-validator'; |
import { IsString, IsUrl } from 'class-validator'; |
||||
|
|
||||
export class CreatePlatformDto { |
export class CreatePlatformDto { |
||||
@IsString() |
@IsString() |
||||
name: string; |
name: string; |
||||
|
|
||||
@IsString() |
@IsUrl({ |
||||
|
protocols: ['https'], |
||||
|
require_protocol: true |
||||
|
}) |
||||
url: string; |
url: string; |
||||
} |
} |
||||
|
@ -0,0 +1,37 @@ |
|||||
|
import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator'; |
||||
|
import { PortfolioSnapshot } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-snapshot.interface'; |
||||
|
import { |
||||
|
SymbolMetrics, |
||||
|
TimelinePosition, |
||||
|
UniqueAsset |
||||
|
} from '@ghostfolio/common/interfaces'; |
||||
|
|
||||
|
export class MWRPortfolioCalculator extends PortfolioCalculator { |
||||
|
protected calculateOverallPerformance( |
||||
|
positions: TimelinePosition[] |
||||
|
): PortfolioSnapshot { |
||||
|
throw new Error('Method not implemented.'); |
||||
|
} |
||||
|
|
||||
|
protected getSymbolMetrics({ |
||||
|
dataSource, |
||||
|
end, |
||||
|
exchangeRates, |
||||
|
isChartMode = false, |
||||
|
marketSymbolMap, |
||||
|
start, |
||||
|
step = 1, |
||||
|
symbol |
||||
|
}: { |
||||
|
end: Date; |
||||
|
exchangeRates: { [dateString: string]: number }; |
||||
|
isChartMode?: boolean; |
||||
|
marketSymbolMap: { |
||||
|
[date: string]: { [symbol: string]: Big }; |
||||
|
}; |
||||
|
start: Date; |
||||
|
step?: number; |
||||
|
} & UniqueAsset): SymbolMetrics { |
||||
|
throw new Error('Method not implemented.'); |
||||
|
} |
||||
|
} |
@ -0,0 +1,26 @@ |
|||||
|
export const activityDummyData = { |
||||
|
accountId: undefined, |
||||
|
accountUserId: undefined, |
||||
|
comment: undefined, |
||||
|
createdAt: new Date(), |
||||
|
currency: undefined, |
||||
|
feeInBaseCurrency: undefined, |
||||
|
id: undefined, |
||||
|
isDraft: false, |
||||
|
symbolProfileId: undefined, |
||||
|
updatedAt: new Date(), |
||||
|
userId: undefined, |
||||
|
value: undefined, |
||||
|
valueInBaseCurrency: undefined |
||||
|
}; |
||||
|
|
||||
|
export const symbolProfileDummyData = { |
||||
|
activitiesCount: undefined, |
||||
|
assetClass: undefined, |
||||
|
assetSubClass: undefined, |
||||
|
countries: [], |
||||
|
createdAt: undefined, |
||||
|
id: undefined, |
||||
|
sectors: [], |
||||
|
updatedAt: undefined |
||||
|
}; |
@ -0,0 +1,56 @@ |
|||||
|
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; |
||||
|
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; |
||||
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; |
||||
|
import { DateRange } from '@ghostfolio/common/types'; |
||||
|
|
||||
|
import { Injectable } from '@nestjs/common'; |
||||
|
|
||||
|
import { MWRPortfolioCalculator } from './mwr/portfolio-calculator'; |
||||
|
import { PortfolioCalculator } from './portfolio-calculator'; |
||||
|
import { TWRPortfolioCalculator } from './twr/portfolio-calculator'; |
||||
|
|
||||
|
export enum PerformanceCalculationType { |
||||
|
MWR = 'MWR', // Money-Weighted Rate of Return
|
||||
|
TWR = 'TWR' // Time-Weighted Rate of Return
|
||||
|
} |
||||
|
|
||||
|
@Injectable() |
||||
|
export class PortfolioCalculatorFactory { |
||||
|
public constructor( |
||||
|
private readonly currentRateService: CurrentRateService, |
||||
|
private readonly exchangeRateDataService: ExchangeRateDataService |
||||
|
) {} |
||||
|
|
||||
|
public createCalculator({ |
||||
|
activities, |
||||
|
calculationType, |
||||
|
currency, |
||||
|
dateRange = 'max' |
||||
|
}: { |
||||
|
activities: Activity[]; |
||||
|
calculationType: PerformanceCalculationType; |
||||
|
currency: string; |
||||
|
dateRange?: DateRange; |
||||
|
}): PortfolioCalculator { |
||||
|
switch (calculationType) { |
||||
|
case PerformanceCalculationType.MWR: |
||||
|
return new MWRPortfolioCalculator({ |
||||
|
activities, |
||||
|
currency, |
||||
|
dateRange, |
||||
|
currentRateService: this.currentRateService, |
||||
|
exchangeRateDataService: this.exchangeRateDataService |
||||
|
}); |
||||
|
case PerformanceCalculationType.TWR: |
||||
|
return new TWRPortfolioCalculator({ |
||||
|
activities, |
||||
|
currency, |
||||
|
currentRateService: this.currentRateService, |
||||
|
dateRange, |
||||
|
exchangeRateDataService: this.exchangeRateDataService |
||||
|
}); |
||||
|
default: |
||||
|
throw new Error('Invalid calculation type'); |
||||
|
} |
||||
|
} |
||||
|
} |
@ -0,0 +1,928 @@ |
|||||
|
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; |
||||
|
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; |
||||
|
import { PortfolioOrder } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-order.interface'; |
||||
|
import { PortfolioSnapshot } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-snapshot.interface'; |
||||
|
import { TransactionPointSymbol } from '@ghostfolio/api/app/portfolio/interfaces/transaction-point-symbol.interface'; |
||||
|
import { TransactionPoint } from '@ghostfolio/api/app/portfolio/interfaces/transaction-point.interface'; |
||||
|
import { |
||||
|
getFactor, |
||||
|
getInterval |
||||
|
} from '@ghostfolio/api/helper/portfolio.helper'; |
||||
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; |
||||
|
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces'; |
||||
|
import { MAX_CHART_ITEMS } from '@ghostfolio/common/config'; |
||||
|
import { |
||||
|
DATE_FORMAT, |
||||
|
getSum, |
||||
|
parseDate, |
||||
|
resetHours |
||||
|
} from '@ghostfolio/common/helper'; |
||||
|
import { |
||||
|
DataProviderInfo, |
||||
|
HistoricalDataItem, |
||||
|
InvestmentItem, |
||||
|
ResponseError, |
||||
|
SymbolMetrics, |
||||
|
TimelinePosition, |
||||
|
UniqueAsset |
||||
|
} from '@ghostfolio/common/interfaces'; |
||||
|
import { DateRange, GroupBy } from '@ghostfolio/common/types'; |
||||
|
|
||||
|
import { Big } from 'big.js'; |
||||
|
import { |
||||
|
differenceInDays, |
||||
|
eachDayOfInterval, |
||||
|
endOfDay, |
||||
|
format, |
||||
|
isBefore, |
||||
|
isSameDay, |
||||
|
max, |
||||
|
subDays |
||||
|
} from 'date-fns'; |
||||
|
import { last, uniq, uniqBy } from 'lodash'; |
||||
|
|
||||
|
export abstract class PortfolioCalculator { |
||||
|
protected static readonly ENABLE_LOGGING = false; |
||||
|
|
||||
|
protected orders: PortfolioOrder[]; |
||||
|
|
||||
|
private currency: string; |
||||
|
private currentRateService: CurrentRateService; |
||||
|
private dataProviderInfos: DataProviderInfo[]; |
||||
|
private endDate: Date; |
||||
|
private exchangeRateDataService: ExchangeRateDataService; |
||||
|
private snapshot: PortfolioSnapshot; |
||||
|
private snapshotPromise: Promise<void>; |
||||
|
private startDate: Date; |
||||
|
private transactionPoints: TransactionPoint[]; |
||||
|
|
||||
|
public constructor({ |
||||
|
activities, |
||||
|
currency, |
||||
|
currentRateService, |
||||
|
dateRange, |
||||
|
exchangeRateDataService |
||||
|
}: { |
||||
|
activities: Activity[]; |
||||
|
currency: string; |
||||
|
currentRateService: CurrentRateService; |
||||
|
dateRange: DateRange; |
||||
|
exchangeRateDataService: ExchangeRateDataService; |
||||
|
}) { |
||||
|
this.currency = currency; |
||||
|
this.currentRateService = currentRateService; |
||||
|
this.exchangeRateDataService = exchangeRateDataService; |
||||
|
this.orders = activities.map( |
||||
|
({ date, fee, quantity, SymbolProfile, tags = [], type, unitPrice }) => { |
||||
|
return { |
||||
|
SymbolProfile, |
||||
|
tags, |
||||
|
type, |
||||
|
date: format(date, DATE_FORMAT), |
||||
|
fee: new Big(fee), |
||||
|
quantity: new Big(quantity), |
||||
|
unitPrice: new Big(unitPrice) |
||||
|
}; |
||||
|
} |
||||
|
); |
||||
|
|
||||
|
this.orders.sort((a, b) => { |
||||
|
return a.date?.localeCompare(b.date); |
||||
|
}); |
||||
|
|
||||
|
const { endDate, startDate } = getInterval(dateRange); |
||||
|
|
||||
|
this.endDate = endDate; |
||||
|
this.startDate = startDate; |
||||
|
|
||||
|
this.computeTransactionPoints(); |
||||
|
|
||||
|
this.snapshotPromise = this.initialize(); |
||||
|
} |
||||
|
|
||||
|
protected abstract calculateOverallPerformance( |
||||
|
positions: TimelinePosition[] |
||||
|
): PortfolioSnapshot; |
||||
|
|
||||
|
public async computeSnapshot( |
||||
|
start: Date, |
||||
|
end?: Date |
||||
|
): Promise<PortfolioSnapshot> { |
||||
|
const lastTransactionPoint = last(this.transactionPoints); |
||||
|
|
||||
|
let endDate = end; |
||||
|
|
||||
|
if (!endDate) { |
||||
|
endDate = new Date(Date.now()); |
||||
|
|
||||
|
if (lastTransactionPoint) { |
||||
|
endDate = max([endDate, parseDate(lastTransactionPoint.date)]); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
const transactionPoints = this.transactionPoints?.filter(({ date }) => { |
||||
|
return isBefore(parseDate(date), endDate); |
||||
|
}); |
||||
|
|
||||
|
if (!transactionPoints.length) { |
||||
|
return { |
||||
|
currentValueInBaseCurrency: new Big(0), |
||||
|
grossPerformance: new Big(0), |
||||
|
grossPerformancePercentage: new Big(0), |
||||
|
grossPerformancePercentageWithCurrencyEffect: new Big(0), |
||||
|
grossPerformanceWithCurrencyEffect: new Big(0), |
||||
|
hasErrors: false, |
||||
|
netPerformance: new Big(0), |
||||
|
netPerformancePercentage: new Big(0), |
||||
|
netPerformancePercentageWithCurrencyEffect: new Big(0), |
||||
|
netPerformanceWithCurrencyEffect: new Big(0), |
||||
|
positions: [], |
||||
|
totalFeesWithCurrencyEffect: new Big(0), |
||||
|
totalInterestWithCurrencyEffect: new Big(0), |
||||
|
totalInvestment: new Big(0), |
||||
|
totalInvestmentWithCurrencyEffect: new Big(0), |
||||
|
totalLiabilitiesWithCurrencyEffect: new Big(0), |
||||
|
totalValuablesWithCurrencyEffect: new Big(0) |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
const currencies: { [symbol: string]: string } = {}; |
||||
|
const dataGatheringItems: IDataGatheringItem[] = []; |
||||
|
let dates: Date[] = []; |
||||
|
let firstIndex = transactionPoints.length; |
||||
|
let firstTransactionPoint: TransactionPoint = null; |
||||
|
let totalInterestWithCurrencyEffect = new Big(0); |
||||
|
let totalLiabilitiesWithCurrencyEffect = new Big(0); |
||||
|
let totalValuablesWithCurrencyEffect = new Big(0); |
||||
|
|
||||
|
dates.push(resetHours(start)); |
||||
|
|
||||
|
for (const { currency, dataSource, symbol } of transactionPoints[ |
||||
|
firstIndex - 1 |
||||
|
].items) { |
||||
|
dataGatheringItems.push({ |
||||
|
dataSource, |
||||
|
symbol |
||||
|
}); |
||||
|
|
||||
|
currencies[symbol] = currency; |
||||
|
} |
||||
|
|
||||
|
for (let i = 0; i < transactionPoints.length; i++) { |
||||
|
if ( |
||||
|
!isBefore(parseDate(transactionPoints[i].date), start) && |
||||
|
firstTransactionPoint === null |
||||
|
) { |
||||
|
firstTransactionPoint = transactionPoints[i]; |
||||
|
firstIndex = i; |
||||
|
} |
||||
|
|
||||
|
if (firstTransactionPoint !== null) { |
||||
|
dates.push(resetHours(parseDate(transactionPoints[i].date))); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
dates.push(resetHours(endDate)); |
||||
|
|
||||
|
// Add dates of last week for fallback
|
||||
|
dates.push(subDays(resetHours(new Date()), 7)); |
||||
|
dates.push(subDays(resetHours(new Date()), 6)); |
||||
|
dates.push(subDays(resetHours(new Date()), 5)); |
||||
|
dates.push(subDays(resetHours(new Date()), 4)); |
||||
|
dates.push(subDays(resetHours(new Date()), 3)); |
||||
|
dates.push(subDays(resetHours(new Date()), 2)); |
||||
|
dates.push(subDays(resetHours(new Date()), 1)); |
||||
|
dates.push(resetHours(new Date())); |
||||
|
|
||||
|
dates = uniq( |
||||
|
dates.map((date) => { |
||||
|
return date.getTime(); |
||||
|
}) |
||||
|
) |
||||
|
.map((timestamp) => { |
||||
|
return new Date(timestamp); |
||||
|
}) |
||||
|
.sort((a, b) => { |
||||
|
return a.getTime() - b.getTime(); |
||||
|
}); |
||||
|
|
||||
|
let exchangeRatesByCurrency = |
||||
|
await this.exchangeRateDataService.getExchangeRatesByCurrency({ |
||||
|
currencies: uniq(Object.values(currencies)), |
||||
|
endDate: endOfDay(endDate), |
||||
|
startDate: this.getStartDate(), |
||||
|
targetCurrency: this.currency |
||||
|
}); |
||||
|
|
||||
|
const { |
||||
|
dataProviderInfos, |
||||
|
errors: currentRateErrors, |
||||
|
values: marketSymbols |
||||
|
} = await this.currentRateService.getValues({ |
||||
|
dataGatheringItems, |
||||
|
dateQuery: { |
||||
|
in: dates |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
this.dataProviderInfos = dataProviderInfos; |
||||
|
|
||||
|
const marketSymbolMap: { |
||||
|
[date: string]: { [symbol: string]: Big }; |
||||
|
} = {}; |
||||
|
|
||||
|
for (const marketSymbol of marketSymbols) { |
||||
|
const date = format(marketSymbol.date, DATE_FORMAT); |
||||
|
|
||||
|
if (!marketSymbolMap[date]) { |
||||
|
marketSymbolMap[date] = {}; |
||||
|
} |
||||
|
|
||||
|
if (marketSymbol.marketPrice) { |
||||
|
marketSymbolMap[date][marketSymbol.symbol] = new Big( |
||||
|
marketSymbol.marketPrice |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
const endDateString = format(endDate, DATE_FORMAT); |
||||
|
|
||||
|
if (firstIndex > 0) { |
||||
|
firstIndex--; |
||||
|
} |
||||
|
|
||||
|
const positions: TimelinePosition[] = []; |
||||
|
let hasAnySymbolMetricsErrors = false; |
||||
|
|
||||
|
const errors: ResponseError['errors'] = []; |
||||
|
|
||||
|
for (const item of lastTransactionPoint.items) { |
||||
|
const marketPriceInBaseCurrency = ( |
||||
|
marketSymbolMap[endDateString]?.[item.symbol] ?? item.averagePrice |
||||
|
).mul( |
||||
|
exchangeRatesByCurrency[`${item.currency}${this.currency}`]?.[ |
||||
|
endDateString |
||||
|
] |
||||
|
); |
||||
|
|
||||
|
const { |
||||
|
grossPerformance, |
||||
|
grossPerformancePercentage, |
||||
|
grossPerformancePercentageWithCurrencyEffect, |
||||
|
grossPerformanceWithCurrencyEffect, |
||||
|
hasErrors, |
||||
|
netPerformance, |
||||
|
netPerformancePercentage, |
||||
|
netPerformancePercentageWithCurrencyEffect, |
||||
|
netPerformanceWithCurrencyEffect, |
||||
|
timeWeightedInvestment, |
||||
|
timeWeightedInvestmentWithCurrencyEffect, |
||||
|
totalDividend, |
||||
|
totalDividendInBaseCurrency, |
||||
|
totalInterestInBaseCurrency, |
||||
|
totalInvestment, |
||||
|
totalInvestmentWithCurrencyEffect, |
||||
|
totalLiabilitiesInBaseCurrency, |
||||
|
totalValuablesInBaseCurrency |
||||
|
} = this.getSymbolMetrics({ |
||||
|
marketSymbolMap, |
||||
|
start, |
||||
|
dataSource: item.dataSource, |
||||
|
end: endDate, |
||||
|
exchangeRates: |
||||
|
exchangeRatesByCurrency[`${item.currency}${this.currency}`], |
||||
|
symbol: item.symbol |
||||
|
}); |
||||
|
|
||||
|
hasAnySymbolMetricsErrors = hasAnySymbolMetricsErrors || hasErrors; |
||||
|
|
||||
|
positions.push({ |
||||
|
dividend: totalDividend, |
||||
|
dividendInBaseCurrency: totalDividendInBaseCurrency, |
||||
|
timeWeightedInvestment, |
||||
|
timeWeightedInvestmentWithCurrencyEffect, |
||||
|
averagePrice: item.averagePrice, |
||||
|
currency: item.currency, |
||||
|
dataSource: item.dataSource, |
||||
|
fee: item.fee, |
||||
|
firstBuyDate: item.firstBuyDate, |
||||
|
grossPerformance: !hasErrors ? grossPerformance ?? null : null, |
||||
|
grossPerformancePercentage: !hasErrors |
||||
|
? grossPerformancePercentage ?? null |
||||
|
: null, |
||||
|
grossPerformancePercentageWithCurrencyEffect: !hasErrors |
||||
|
? grossPerformancePercentageWithCurrencyEffect ?? null |
||||
|
: null, |
||||
|
grossPerformanceWithCurrencyEffect: !hasErrors |
||||
|
? grossPerformanceWithCurrencyEffect ?? null |
||||
|
: null, |
||||
|
investment: totalInvestment, |
||||
|
investmentWithCurrencyEffect: totalInvestmentWithCurrencyEffect, |
||||
|
marketPrice: |
||||
|
marketSymbolMap[endDateString]?.[item.symbol]?.toNumber() ?? null, |
||||
|
marketPriceInBaseCurrency: |
||||
|
marketPriceInBaseCurrency?.toNumber() ?? null, |
||||
|
netPerformance: !hasErrors ? netPerformance ?? null : null, |
||||
|
netPerformancePercentage: !hasErrors |
||||
|
? netPerformancePercentage ?? null |
||||
|
: null, |
||||
|
netPerformancePercentageWithCurrencyEffect: !hasErrors |
||||
|
? netPerformancePercentageWithCurrencyEffect ?? null |
||||
|
: null, |
||||
|
netPerformanceWithCurrencyEffect: !hasErrors |
||||
|
? netPerformanceWithCurrencyEffect ?? null |
||||
|
: null, |
||||
|
quantity: item.quantity, |
||||
|
symbol: item.symbol, |
||||
|
tags: item.tags, |
||||
|
transactionCount: item.transactionCount, |
||||
|
valueInBaseCurrency: new Big(marketPriceInBaseCurrency).mul( |
||||
|
item.quantity |
||||
|
) |
||||
|
}); |
||||
|
|
||||
|
totalInterestWithCurrencyEffect = totalInterestWithCurrencyEffect.plus( |
||||
|
totalInterestInBaseCurrency |
||||
|
); |
||||
|
|
||||
|
totalLiabilitiesWithCurrencyEffect = |
||||
|
totalLiabilitiesWithCurrencyEffect.plus(totalLiabilitiesInBaseCurrency); |
||||
|
|
||||
|
totalValuablesWithCurrencyEffect = totalValuablesWithCurrencyEffect.plus( |
||||
|
totalValuablesInBaseCurrency |
||||
|
); |
||||
|
|
||||
|
if ( |
||||
|
(hasErrors || |
||||
|
currentRateErrors.find(({ dataSource, symbol }) => { |
||||
|
return dataSource === item.dataSource && symbol === item.symbol; |
||||
|
})) && |
||||
|
item.investment.gt(0) |
||||
|
) { |
||||
|
errors.push({ dataSource: item.dataSource, symbol: item.symbol }); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
const overall = this.calculateOverallPerformance(positions); |
||||
|
|
||||
|
return { |
||||
|
...overall, |
||||
|
errors, |
||||
|
positions, |
||||
|
totalInterestWithCurrencyEffect, |
||||
|
totalLiabilitiesWithCurrencyEffect, |
||||
|
totalValuablesWithCurrencyEffect, |
||||
|
hasErrors: hasAnySymbolMetricsErrors || overall.hasErrors |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
public async getChart({ |
||||
|
dateRange = 'max', |
||||
|
withDataDecimation = true |
||||
|
}: { |
||||
|
dateRange?: DateRange; |
||||
|
withDataDecimation?: boolean; |
||||
|
}): Promise<HistoricalDataItem[]> { |
||||
|
if (this.getTransactionPoints().length === 0) { |
||||
|
return []; |
||||
|
} |
||||
|
|
||||
|
const { endDate, startDate } = getInterval(dateRange, this.getStartDate()); |
||||
|
|
||||
|
const daysInMarket = differenceInDays(endDate, startDate) + 1; |
||||
|
const step = withDataDecimation |
||||
|
? Math.round(daysInMarket / Math.min(daysInMarket, MAX_CHART_ITEMS)) |
||||
|
: 1; |
||||
|
|
||||
|
return this.getChartData({ |
||||
|
step, |
||||
|
end: endDate, |
||||
|
start: startDate |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
public async getChartData({ |
||||
|
end = new Date(Date.now()), |
||||
|
start, |
||||
|
step = 1 |
||||
|
}: { |
||||
|
end?: Date; |
||||
|
start: Date; |
||||
|
step?: number; |
||||
|
}): Promise<HistoricalDataItem[]> { |
||||
|
const symbols: { [symbol: string]: boolean } = {}; |
||||
|
|
||||
|
const transactionPointsBeforeEndDate = |
||||
|
this.transactionPoints?.filter((transactionPoint) => { |
||||
|
return isBefore(parseDate(transactionPoint.date), end); |
||||
|
}) ?? []; |
||||
|
|
||||
|
const currencies: { [symbol: string]: string } = {}; |
||||
|
const dataGatheringItems: IDataGatheringItem[] = []; |
||||
|
const firstIndex = transactionPointsBeforeEndDate.length; |
||||
|
|
||||
|
let dates = eachDayOfInterval({ start, end }, { step }).map((date) => { |
||||
|
return resetHours(date); |
||||
|
}); |
||||
|
|
||||
|
const includesEndDate = isSameDay(last(dates), end); |
||||
|
|
||||
|
if (!includesEndDate) { |
||||
|
dates.push(resetHours(end)); |
||||
|
} |
||||
|
|
||||
|
if (transactionPointsBeforeEndDate.length > 0) { |
||||
|
for (const { |
||||
|
currency, |
||||
|
dataSource, |
||||
|
symbol |
||||
|
} of transactionPointsBeforeEndDate[firstIndex - 1].items) { |
||||
|
dataGatheringItems.push({ |
||||
|
dataSource, |
||||
|
symbol |
||||
|
}); |
||||
|
currencies[symbol] = currency; |
||||
|
symbols[symbol] = true; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
const { dataProviderInfos, values: marketSymbols } = |
||||
|
await this.currentRateService.getValues({ |
||||
|
dataGatheringItems, |
||||
|
dateQuery: { |
||||
|
in: dates |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
this.dataProviderInfos = dataProviderInfos; |
||||
|
|
||||
|
const marketSymbolMap: { |
||||
|
[date: string]: { [symbol: string]: Big }; |
||||
|
} = {}; |
||||
|
|
||||
|
let exchangeRatesByCurrency = |
||||
|
await this.exchangeRateDataService.getExchangeRatesByCurrency({ |
||||
|
currencies: uniq(Object.values(currencies)), |
||||
|
endDate: endOfDay(end), |
||||
|
startDate: this.getStartDate(), |
||||
|
targetCurrency: this.currency |
||||
|
}); |
||||
|
|
||||
|
for (const marketSymbol of marketSymbols) { |
||||
|
const dateString = format(marketSymbol.date, DATE_FORMAT); |
||||
|
if (!marketSymbolMap[dateString]) { |
||||
|
marketSymbolMap[dateString] = {}; |
||||
|
} |
||||
|
if (marketSymbol.marketPrice) { |
||||
|
marketSymbolMap[dateString][marketSymbol.symbol] = new Big( |
||||
|
marketSymbol.marketPrice |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
const accumulatedValuesByDate: { |
||||
|
[date: string]: { |
||||
|
investmentValueWithCurrencyEffect: Big; |
||||
|
totalCurrentValue: Big; |
||||
|
totalCurrentValueWithCurrencyEffect: Big; |
||||
|
totalInvestmentValue: Big; |
||||
|
totalInvestmentValueWithCurrencyEffect: Big; |
||||
|
totalNetPerformanceValue: Big; |
||||
|
totalNetPerformanceValueWithCurrencyEffect: Big; |
||||
|
totalTimeWeightedInvestmentValue: Big; |
||||
|
totalTimeWeightedInvestmentValueWithCurrencyEffect: Big; |
||||
|
}; |
||||
|
} = {}; |
||||
|
|
||||
|
const valuesBySymbol: { |
||||
|
[symbol: string]: { |
||||
|
currentValues: { [date: string]: Big }; |
||||
|
currentValuesWithCurrencyEffect: { [date: string]: Big }; |
||||
|
investmentValuesAccumulated: { [date: string]: Big }; |
||||
|
investmentValuesAccumulatedWithCurrencyEffect: { [date: string]: Big }; |
||||
|
investmentValuesWithCurrencyEffect: { [date: string]: Big }; |
||||
|
netPerformanceValues: { [date: string]: Big }; |
||||
|
netPerformanceValuesWithCurrencyEffect: { [date: string]: Big }; |
||||
|
timeWeightedInvestmentValues: { [date: string]: Big }; |
||||
|
timeWeightedInvestmentValuesWithCurrencyEffect: { [date: string]: Big }; |
||||
|
}; |
||||
|
} = {}; |
||||
|
|
||||
|
for (const symbol of Object.keys(symbols)) { |
||||
|
const { |
||||
|
currentValues, |
||||
|
currentValuesWithCurrencyEffect, |
||||
|
investmentValuesAccumulated, |
||||
|
investmentValuesAccumulatedWithCurrencyEffect, |
||||
|
investmentValuesWithCurrencyEffect, |
||||
|
netPerformanceValues, |
||||
|
netPerformanceValuesWithCurrencyEffect, |
||||
|
timeWeightedInvestmentValues, |
||||
|
timeWeightedInvestmentValuesWithCurrencyEffect |
||||
|
} = this.getSymbolMetrics({ |
||||
|
end, |
||||
|
marketSymbolMap, |
||||
|
start, |
||||
|
step, |
||||
|
symbol, |
||||
|
dataSource: null, |
||||
|
exchangeRates: |
||||
|
exchangeRatesByCurrency[`${currencies[symbol]}${this.currency}`], |
||||
|
isChartMode: true |
||||
|
}); |
||||
|
|
||||
|
valuesBySymbol[symbol] = { |
||||
|
currentValues, |
||||
|
currentValuesWithCurrencyEffect, |
||||
|
investmentValuesAccumulated, |
||||
|
investmentValuesAccumulatedWithCurrencyEffect, |
||||
|
investmentValuesWithCurrencyEffect, |
||||
|
netPerformanceValues, |
||||
|
netPerformanceValuesWithCurrencyEffect, |
||||
|
timeWeightedInvestmentValues, |
||||
|
timeWeightedInvestmentValuesWithCurrencyEffect |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
for (const currentDate of dates) { |
||||
|
const dateString = format(currentDate, DATE_FORMAT); |
||||
|
|
||||
|
for (const symbol of Object.keys(valuesBySymbol)) { |
||||
|
const symbolValues = valuesBySymbol[symbol]; |
||||
|
|
||||
|
const currentValue = |
||||
|
symbolValues.currentValues?.[dateString] ?? new Big(0); |
||||
|
|
||||
|
const currentValueWithCurrencyEffect = |
||||
|
symbolValues.currentValuesWithCurrencyEffect?.[dateString] ?? |
||||
|
new Big(0); |
||||
|
|
||||
|
const investmentValueAccumulated = |
||||
|
symbolValues.investmentValuesAccumulated?.[dateString] ?? new Big(0); |
||||
|
|
||||
|
const investmentValueAccumulatedWithCurrencyEffect = |
||||
|
symbolValues.investmentValuesAccumulatedWithCurrencyEffect?.[ |
||||
|
dateString |
||||
|
] ?? new Big(0); |
||||
|
|
||||
|
const investmentValueWithCurrencyEffect = |
||||
|
symbolValues.investmentValuesWithCurrencyEffect?.[dateString] ?? |
||||
|
new Big(0); |
||||
|
|
||||
|
const netPerformanceValue = |
||||
|
symbolValues.netPerformanceValues?.[dateString] ?? new Big(0); |
||||
|
|
||||
|
const netPerformanceValueWithCurrencyEffect = |
||||
|
symbolValues.netPerformanceValuesWithCurrencyEffect?.[dateString] ?? |
||||
|
new Big(0); |
||||
|
|
||||
|
const timeWeightedInvestmentValue = |
||||
|
symbolValues.timeWeightedInvestmentValues?.[dateString] ?? new Big(0); |
||||
|
|
||||
|
const timeWeightedInvestmentValueWithCurrencyEffect = |
||||
|
symbolValues.timeWeightedInvestmentValuesWithCurrencyEffect?.[ |
||||
|
dateString |
||||
|
] ?? new Big(0); |
||||
|
|
||||
|
accumulatedValuesByDate[dateString] = { |
||||
|
investmentValueWithCurrencyEffect: ( |
||||
|
accumulatedValuesByDate[dateString] |
||||
|
?.investmentValueWithCurrencyEffect ?? new Big(0) |
||||
|
).add(investmentValueWithCurrencyEffect), |
||||
|
totalCurrentValue: ( |
||||
|
accumulatedValuesByDate[dateString]?.totalCurrentValue ?? new Big(0) |
||||
|
).add(currentValue), |
||||
|
totalCurrentValueWithCurrencyEffect: ( |
||||
|
accumulatedValuesByDate[dateString] |
||||
|
?.totalCurrentValueWithCurrencyEffect ?? new Big(0) |
||||
|
).add(currentValueWithCurrencyEffect), |
||||
|
totalInvestmentValue: ( |
||||
|
accumulatedValuesByDate[dateString]?.totalInvestmentValue ?? |
||||
|
new Big(0) |
||||
|
).add(investmentValueAccumulated), |
||||
|
totalInvestmentValueWithCurrencyEffect: ( |
||||
|
accumulatedValuesByDate[dateString] |
||||
|
?.totalInvestmentValueWithCurrencyEffect ?? new Big(0) |
||||
|
).add(investmentValueAccumulatedWithCurrencyEffect), |
||||
|
totalNetPerformanceValue: ( |
||||
|
accumulatedValuesByDate[dateString]?.totalNetPerformanceValue ?? |
||||
|
new Big(0) |
||||
|
).add(netPerformanceValue), |
||||
|
totalNetPerformanceValueWithCurrencyEffect: ( |
||||
|
accumulatedValuesByDate[dateString] |
||||
|
?.totalNetPerformanceValueWithCurrencyEffect ?? new Big(0) |
||||
|
).add(netPerformanceValueWithCurrencyEffect), |
||||
|
totalTimeWeightedInvestmentValue: ( |
||||
|
accumulatedValuesByDate[dateString] |
||||
|
?.totalTimeWeightedInvestmentValue ?? new Big(0) |
||||
|
).add(timeWeightedInvestmentValue), |
||||
|
totalTimeWeightedInvestmentValueWithCurrencyEffect: ( |
||||
|
accumulatedValuesByDate[dateString] |
||||
|
?.totalTimeWeightedInvestmentValueWithCurrencyEffect ?? new Big(0) |
||||
|
).add(timeWeightedInvestmentValueWithCurrencyEffect) |
||||
|
}; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return Object.entries(accumulatedValuesByDate).map(([date, values]) => { |
||||
|
const { |
||||
|
investmentValueWithCurrencyEffect, |
||||
|
totalCurrentValue, |
||||
|
totalCurrentValueWithCurrencyEffect, |
||||
|
totalInvestmentValue, |
||||
|
totalInvestmentValueWithCurrencyEffect, |
||||
|
totalNetPerformanceValue, |
||||
|
totalNetPerformanceValueWithCurrencyEffect, |
||||
|
totalTimeWeightedInvestmentValue, |
||||
|
totalTimeWeightedInvestmentValueWithCurrencyEffect |
||||
|
} = values; |
||||
|
|
||||
|
const netPerformanceInPercentage = totalTimeWeightedInvestmentValue.eq(0) |
||||
|
? 0 |
||||
|
: totalNetPerformanceValue |
||||
|
.div(totalTimeWeightedInvestmentValue) |
||||
|
.mul(100) |
||||
|
.toNumber(); |
||||
|
|
||||
|
const netPerformanceInPercentageWithCurrencyEffect = |
||||
|
totalTimeWeightedInvestmentValueWithCurrencyEffect.eq(0) |
||||
|
? 0 |
||||
|
: totalNetPerformanceValueWithCurrencyEffect |
||||
|
.div(totalTimeWeightedInvestmentValueWithCurrencyEffect) |
||||
|
.mul(100) |
||||
|
.toNumber(); |
||||
|
|
||||
|
return { |
||||
|
date, |
||||
|
netPerformanceInPercentage, |
||||
|
netPerformanceInPercentageWithCurrencyEffect, |
||||
|
investmentValueWithCurrencyEffect: |
||||
|
investmentValueWithCurrencyEffect.toNumber(), |
||||
|
netPerformance: totalNetPerformanceValue.toNumber(), |
||||
|
netPerformanceWithCurrencyEffect: |
||||
|
totalNetPerformanceValueWithCurrencyEffect.toNumber(), |
||||
|
totalInvestment: totalInvestmentValue.toNumber(), |
||||
|
totalInvestmentValueWithCurrencyEffect: |
||||
|
totalInvestmentValueWithCurrencyEffect.toNumber(), |
||||
|
value: totalCurrentValue.toNumber(), |
||||
|
valueWithCurrencyEffect: totalCurrentValueWithCurrencyEffect.toNumber() |
||||
|
}; |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
public getDataProviderInfos() { |
||||
|
return this.dataProviderInfos; |
||||
|
} |
||||
|
|
||||
|
public async getDividendInBaseCurrency() { |
||||
|
await this.snapshotPromise; |
||||
|
|
||||
|
return getSum( |
||||
|
this.snapshot.positions.map(({ dividendInBaseCurrency }) => { |
||||
|
return dividendInBaseCurrency; |
||||
|
}) |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
public async getFeesInBaseCurrency() { |
||||
|
await this.snapshotPromise; |
||||
|
|
||||
|
return this.snapshot.totalFeesWithCurrencyEffect; |
||||
|
} |
||||
|
|
||||
|
public async getInterestInBaseCurrency() { |
||||
|
await this.snapshotPromise; |
||||
|
|
||||
|
return this.snapshot.totalInterestWithCurrencyEffect; |
||||
|
} |
||||
|
|
||||
|
public getInvestments(): { date: string; investment: Big }[] { |
||||
|
if (this.transactionPoints.length === 0) { |
||||
|
return []; |
||||
|
} |
||||
|
|
||||
|
return this.transactionPoints.map((transactionPoint) => { |
||||
|
return { |
||||
|
date: transactionPoint.date, |
||||
|
investment: transactionPoint.items.reduce( |
||||
|
(investment, transactionPointSymbol) => |
||||
|
investment.plus(transactionPointSymbol.investment), |
||||
|
new Big(0) |
||||
|
) |
||||
|
}; |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
public getInvestmentsByGroup({ |
||||
|
data, |
||||
|
groupBy |
||||
|
}: { |
||||
|
data: HistoricalDataItem[]; |
||||
|
groupBy: GroupBy; |
||||
|
}): InvestmentItem[] { |
||||
|
const groupedData: { [dateGroup: string]: Big } = {}; |
||||
|
|
||||
|
for (const { date, investmentValueWithCurrencyEffect } of data) { |
||||
|
const dateGroup = |
||||
|
groupBy === 'month' ? date.substring(0, 7) : date.substring(0, 4); |
||||
|
groupedData[dateGroup] = (groupedData[dateGroup] ?? new Big(0)).plus( |
||||
|
investmentValueWithCurrencyEffect |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
return Object.keys(groupedData).map((dateGroup) => ({ |
||||
|
date: groupBy === 'month' ? `${dateGroup}-01` : `${dateGroup}-01-01`, |
||||
|
investment: groupedData[dateGroup].toNumber() |
||||
|
})); |
||||
|
} |
||||
|
|
||||
|
public async getLiabilitiesInBaseCurrency() { |
||||
|
await this.snapshotPromise; |
||||
|
|
||||
|
return this.snapshot.totalLiabilitiesWithCurrencyEffect; |
||||
|
} |
||||
|
|
||||
|
public async getSnapshot() { |
||||
|
await this.snapshotPromise; |
||||
|
|
||||
|
return this.snapshot; |
||||
|
} |
||||
|
|
||||
|
public getStartDate() { |
||||
|
return this.transactionPoints.length > 0 |
||||
|
? parseDate(this.transactionPoints[0].date) |
||||
|
: new Date(); |
||||
|
} |
||||
|
|
||||
|
protected abstract getSymbolMetrics({ |
||||
|
dataSource, |
||||
|
end, |
||||
|
exchangeRates, |
||||
|
isChartMode, |
||||
|
marketSymbolMap, |
||||
|
start, |
||||
|
step, |
||||
|
symbol |
||||
|
}: { |
||||
|
end: Date; |
||||
|
exchangeRates: { [dateString: string]: number }; |
||||
|
isChartMode?: boolean; |
||||
|
marketSymbolMap: { |
||||
|
[date: string]: { [symbol: string]: Big }; |
||||
|
}; |
||||
|
start: Date; |
||||
|
step?: number; |
||||
|
} & UniqueAsset): SymbolMetrics; |
||||
|
|
||||
|
public getTransactionPoints() { |
||||
|
return this.transactionPoints; |
||||
|
} |
||||
|
|
||||
|
public async getValuablesInBaseCurrency() { |
||||
|
await this.snapshotPromise; |
||||
|
|
||||
|
return this.snapshot.totalValuablesWithCurrencyEffect; |
||||
|
} |
||||
|
|
||||
|
private computeTransactionPoints() { |
||||
|
this.transactionPoints = []; |
||||
|
const symbols: { [symbol: string]: TransactionPointSymbol } = {}; |
||||
|
|
||||
|
let lastDate: string = null; |
||||
|
let lastTransactionPoint: TransactionPoint = null; |
||||
|
|
||||
|
for (const { |
||||
|
fee, |
||||
|
date, |
||||
|
quantity, |
||||
|
SymbolProfile, |
||||
|
tags, |
||||
|
type, |
||||
|
unitPrice |
||||
|
} of this.orders) { |
||||
|
let currentTransactionPointItem: TransactionPointSymbol; |
||||
|
const oldAccumulatedSymbol = symbols[SymbolProfile.symbol]; |
||||
|
|
||||
|
const factor = getFactor(type); |
||||
|
|
||||
|
if (oldAccumulatedSymbol) { |
||||
|
let investment = oldAccumulatedSymbol.investment; |
||||
|
|
||||
|
const newQuantity = quantity |
||||
|
.mul(factor) |
||||
|
.plus(oldAccumulatedSymbol.quantity); |
||||
|
|
||||
|
if (type === 'BUY') { |
||||
|
investment = oldAccumulatedSymbol.investment.plus( |
||||
|
quantity.mul(unitPrice) |
||||
|
); |
||||
|
} else if (type === 'SELL') { |
||||
|
investment = oldAccumulatedSymbol.investment.minus( |
||||
|
quantity.mul(oldAccumulatedSymbol.averagePrice) |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
currentTransactionPointItem = { |
||||
|
investment, |
||||
|
averagePrice: newQuantity.gt(0) |
||||
|
? investment.div(newQuantity) |
||||
|
: new Big(0), |
||||
|
currency: SymbolProfile.currency, |
||||
|
dataSource: SymbolProfile.dataSource, |
||||
|
dividend: new Big(0), |
||||
|
fee: oldAccumulatedSymbol.fee.plus(fee), |
||||
|
firstBuyDate: oldAccumulatedSymbol.firstBuyDate, |
||||
|
quantity: newQuantity, |
||||
|
symbol: SymbolProfile.symbol, |
||||
|
tags: oldAccumulatedSymbol.tags.concat(tags), |
||||
|
transactionCount: oldAccumulatedSymbol.transactionCount + 1 |
||||
|
}; |
||||
|
} else { |
||||
|
currentTransactionPointItem = { |
||||
|
fee, |
||||
|
tags, |
||||
|
averagePrice: unitPrice, |
||||
|
currency: SymbolProfile.currency, |
||||
|
dataSource: SymbolProfile.dataSource, |
||||
|
dividend: new Big(0), |
||||
|
firstBuyDate: date, |
||||
|
investment: unitPrice.mul(quantity).mul(factor), |
||||
|
quantity: quantity.mul(factor), |
||||
|
symbol: SymbolProfile.symbol, |
||||
|
transactionCount: 1 |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
currentTransactionPointItem.tags = uniqBy( |
||||
|
currentTransactionPointItem.tags, |
||||
|
'id' |
||||
|
); |
||||
|
|
||||
|
symbols[SymbolProfile.symbol] = currentTransactionPointItem; |
||||
|
|
||||
|
const items = lastTransactionPoint?.items ?? []; |
||||
|
|
||||
|
const newItems = items.filter(({ symbol }) => { |
||||
|
return symbol !== SymbolProfile.symbol; |
||||
|
}); |
||||
|
|
||||
|
newItems.push(currentTransactionPointItem); |
||||
|
|
||||
|
newItems.sort((a, b) => { |
||||
|
return a.symbol?.localeCompare(b.symbol); |
||||
|
}); |
||||
|
|
||||
|
let fees = new Big(0); |
||||
|
|
||||
|
if (type === 'FEE') { |
||||
|
fees = fee; |
||||
|
} |
||||
|
|
||||
|
let interest = new Big(0); |
||||
|
|
||||
|
if (type === 'INTEREST') { |
||||
|
interest = quantity.mul(unitPrice); |
||||
|
} |
||||
|
|
||||
|
let liabilities = new Big(0); |
||||
|
|
||||
|
if (type === 'LIABILITY') { |
||||
|
liabilities = quantity.mul(unitPrice); |
||||
|
} |
||||
|
|
||||
|
let valuables = new Big(0); |
||||
|
|
||||
|
if (type === 'ITEM') { |
||||
|
valuables = quantity.mul(unitPrice); |
||||
|
} |
||||
|
|
||||
|
if (lastDate !== date || lastTransactionPoint === null) { |
||||
|
lastTransactionPoint = { |
||||
|
date, |
||||
|
fees, |
||||
|
interest, |
||||
|
liabilities, |
||||
|
valuables, |
||||
|
items: newItems |
||||
|
}; |
||||
|
|
||||
|
this.transactionPoints.push(lastTransactionPoint); |
||||
|
} else { |
||||
|
lastTransactionPoint.fees = lastTransactionPoint.fees.plus(fees); |
||||
|
lastTransactionPoint.interest = |
||||
|
lastTransactionPoint.interest.plus(interest); |
||||
|
lastTransactionPoint.items = newItems; |
||||
|
lastTransactionPoint.liabilities = |
||||
|
lastTransactionPoint.liabilities.plus(liabilities); |
||||
|
lastTransactionPoint.valuables = |
||||
|
lastTransactionPoint.valuables.plus(valuables); |
||||
|
} |
||||
|
|
||||
|
lastDate = date; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private async initialize() { |
||||
|
this.snapshot = await this.computeSnapshot(this.startDate, this.endDate); |
||||
|
} |
||||
|
} |
@ -0,0 +1,195 @@ |
|||||
|
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; |
||||
|
import { |
||||
|
activityDummyData, |
||||
|
symbolProfileDummyData |
||||
|
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; |
||||
|
import { |
||||
|
PortfolioCalculatorFactory, |
||||
|
PerformanceCalculationType |
||||
|
} 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 { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; |
||||
|
import { parseDate } from '@ghostfolio/common/helper'; |
||||
|
|
||||
|
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; |
||||
|
}) |
||||
|
}; |
||||
|
}); |
||||
|
|
||||
|
describe('PortfolioCalculator', () => { |
||||
|
let currentRateService: CurrentRateService; |
||||
|
let exchangeRateDataService: ExchangeRateDataService; |
||||
|
let factory: PortfolioCalculatorFactory; |
||||
|
|
||||
|
beforeEach(() => { |
||||
|
currentRateService = new CurrentRateService(null, null, null, null); |
||||
|
|
||||
|
exchangeRateDataService = new ExchangeRateDataService( |
||||
|
null, |
||||
|
null, |
||||
|
null, |
||||
|
null |
||||
|
); |
||||
|
|
||||
|
factory = new PortfolioCalculatorFactory( |
||||
|
currentRateService, |
||||
|
exchangeRateDataService |
||||
|
); |
||||
|
}); |
||||
|
|
||||
|
describe('get current positions', () => { |
||||
|
it.only('with BALN.SW buy and sell in two activities', async () => { |
||||
|
const spy = jest |
||||
|
.spyOn(Date, 'now') |
||||
|
.mockImplementation(() => parseDate('2021-12-18').getTime()); |
||||
|
|
||||
|
const activities: Activity[] = [ |
||||
|
{ |
||||
|
...activityDummyData, |
||||
|
date: new Date('2021-11-22'), |
||||
|
fee: 1.55, |
||||
|
quantity: 2, |
||||
|
SymbolProfile: { |
||||
|
...symbolProfileDummyData, |
||||
|
currency: 'CHF', |
||||
|
dataSource: 'YAHOO', |
||||
|
name: 'Bâloise Holding AG', |
||||
|
symbol: 'BALN.SW' |
||||
|
}, |
||||
|
type: 'BUY', |
||||
|
unitPrice: 142.9 |
||||
|
}, |
||||
|
{ |
||||
|
...activityDummyData, |
||||
|
date: new Date('2021-11-30'), |
||||
|
fee: 1.65, |
||||
|
quantity: 1, |
||||
|
SymbolProfile: { |
||||
|
...symbolProfileDummyData, |
||||
|
currency: 'CHF', |
||||
|
dataSource: 'YAHOO', |
||||
|
name: 'Bâloise Holding AG', |
||||
|
symbol: 'BALN.SW' |
||||
|
}, |
||||
|
type: 'SELL', |
||||
|
unitPrice: 136.6 |
||||
|
}, |
||||
|
{ |
||||
|
...activityDummyData, |
||||
|
date: new Date('2021-11-30'), |
||||
|
fee: 0, |
||||
|
quantity: 1, |
||||
|
SymbolProfile: { |
||||
|
...symbolProfileDummyData, |
||||
|
currency: 'CHF', |
||||
|
dataSource: 'YAHOO', |
||||
|
name: 'Bâloise Holding AG', |
||||
|
symbol: 'BALN.SW' |
||||
|
}, |
||||
|
type: 'SELL', |
||||
|
unitPrice: 136.6 |
||||
|
} |
||||
|
]; |
||||
|
|
||||
|
const portfolioCalculator = factory.createCalculator({ |
||||
|
activities, |
||||
|
calculationType: PerformanceCalculationType.TWR, |
||||
|
currency: 'CHF' |
||||
|
}); |
||||
|
|
||||
|
const chartData = await portfolioCalculator.getChartData({ |
||||
|
start: parseDate('2021-11-22') |
||||
|
}); |
||||
|
|
||||
|
const portfolioSnapshot = await portfolioCalculator.computeSnapshot( |
||||
|
parseDate('2021-11-22') |
||||
|
); |
||||
|
|
||||
|
const investments = portfolioCalculator.getInvestments(); |
||||
|
|
||||
|
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({ |
||||
|
data: chartData, |
||||
|
groupBy: 'month' |
||||
|
}); |
||||
|
|
||||
|
spy.mockRestore(); |
||||
|
|
||||
|
expect(portfolioSnapshot).toEqual({ |
||||
|
currentValueInBaseCurrency: new Big('0'), |
||||
|
errors: [], |
||||
|
grossPerformance: new Big('-12.6'), |
||||
|
grossPerformancePercentage: new Big('-0.04408677396780965649'), |
||||
|
grossPerformancePercentageWithCurrencyEffect: new Big( |
||||
|
'-0.04408677396780965649' |
||||
|
), |
||||
|
grossPerformanceWithCurrencyEffect: new Big('-12.6'), |
||||
|
hasErrors: false, |
||||
|
netPerformance: new Big('-15.8'), |
||||
|
netPerformancePercentage: new Big('-0.05528341497550734703'), |
||||
|
netPerformancePercentageWithCurrencyEffect: new Big( |
||||
|
'-0.05528341497550734703' |
||||
|
), |
||||
|
netPerformanceWithCurrencyEffect: new Big('-15.8'), |
||||
|
positions: [ |
||||
|
{ |
||||
|
averagePrice: new Big('0'), |
||||
|
currency: 'CHF', |
||||
|
dataSource: 'YAHOO', |
||||
|
dividend: new Big('0'), |
||||
|
dividendInBaseCurrency: new Big('0'), |
||||
|
fee: new Big('3.2'), |
||||
|
firstBuyDate: '2021-11-22', |
||||
|
grossPerformance: new Big('-12.6'), |
||||
|
grossPerformancePercentage: new Big('-0.04408677396780965649'), |
||||
|
grossPerformancePercentageWithCurrencyEffect: new Big( |
||||
|
'-0.04408677396780965649' |
||||
|
), |
||||
|
grossPerformanceWithCurrencyEffect: new Big('-12.6'), |
||||
|
investment: new Big('0'), |
||||
|
investmentWithCurrencyEffect: new Big('0'), |
||||
|
netPerformance: new Big('-15.8'), |
||||
|
netPerformancePercentage: new Big('-0.05528341497550734703'), |
||||
|
netPerformancePercentageWithCurrencyEffect: new Big( |
||||
|
'-0.05528341497550734703' |
||||
|
), |
||||
|
netPerformanceWithCurrencyEffect: new Big('-15.8'), |
||||
|
marketPrice: 148.9, |
||||
|
marketPriceInBaseCurrency: 148.9, |
||||
|
quantity: new Big('0'), |
||||
|
symbol: 'BALN.SW', |
||||
|
tags: [], |
||||
|
timeWeightedInvestment: new Big('285.80000000000000396627'), |
||||
|
timeWeightedInvestmentWithCurrencyEffect: new Big( |
||||
|
'285.80000000000000396627' |
||||
|
), |
||||
|
transactionCount: 3, |
||||
|
valueInBaseCurrency: new Big('0') |
||||
|
} |
||||
|
], |
||||
|
totalFeesWithCurrencyEffect: new Big('3.2'), |
||||
|
totalInterestWithCurrencyEffect: new Big('0'), |
||||
|
totalInvestment: new Big('0'), |
||||
|
totalInvestmentWithCurrencyEffect: new Big('0'), |
||||
|
totalLiabilitiesWithCurrencyEffect: new Big('0'), |
||||
|
totalValuablesWithCurrencyEffect: new Big('0') |
||||
|
}); |
||||
|
|
||||
|
expect(investments).toEqual([ |
||||
|
{ date: '2021-11-22', investment: new Big('285.8') }, |
||||
|
{ date: '2021-11-30', investment: new Big('0') } |
||||
|
]); |
||||
|
|
||||
|
expect(investmentsByMonth).toEqual([ |
||||
|
{ date: '2021-11-01', investment: 0 }, |
||||
|
{ date: '2021-12-01', investment: 0 } |
||||
|
]); |
||||
|
}); |
||||
|
}); |
||||
|
}); |
@ -0,0 +1,134 @@ |
|||||
|
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; |
||||
|
import { |
||||
|
activityDummyData, |
||||
|
symbolProfileDummyData |
||||
|
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; |
||||
|
import { |
||||
|
PortfolioCalculatorFactory, |
||||
|
PerformanceCalculationType |
||||
|
} 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 { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; |
||||
|
import { parseDate } from '@ghostfolio/common/helper'; |
||||
|
|
||||
|
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; |
||||
|
}) |
||||
|
}; |
||||
|
}); |
||||
|
|
||||
|
describe('PortfolioCalculator', () => { |
||||
|
let currentRateService: CurrentRateService; |
||||
|
let exchangeRateDataService: ExchangeRateDataService; |
||||
|
let factory: PortfolioCalculatorFactory; |
||||
|
|
||||
|
beforeEach(() => { |
||||
|
currentRateService = new CurrentRateService(null, null, null, null); |
||||
|
|
||||
|
exchangeRateDataService = new ExchangeRateDataService( |
||||
|
null, |
||||
|
null, |
||||
|
null, |
||||
|
null |
||||
|
); |
||||
|
|
||||
|
factory = new PortfolioCalculatorFactory( |
||||
|
currentRateService, |
||||
|
exchangeRateDataService |
||||
|
); |
||||
|
}); |
||||
|
|
||||
|
describe('compute portfolio snapshot', () => { |
||||
|
it.only('with fee activity', async () => { |
||||
|
const spy = jest |
||||
|
.spyOn(Date, 'now') |
||||
|
.mockImplementation(() => parseDate('2021-12-18').getTime()); |
||||
|
|
||||
|
const activities: Activity[] = [ |
||||
|
{ |
||||
|
...activityDummyData, |
||||
|
date: new Date('2021-09-01'), |
||||
|
fee: 49, |
||||
|
quantity: 0, |
||||
|
SymbolProfile: { |
||||
|
...symbolProfileDummyData, |
||||
|
currency: 'USD', |
||||
|
dataSource: 'MANUAL', |
||||
|
name: 'Account Opening Fee', |
||||
|
symbol: '2c463fb3-af07-486e-adb0-8301b3d72141' |
||||
|
}, |
||||
|
type: 'FEE', |
||||
|
unitPrice: 0 |
||||
|
} |
||||
|
]; |
||||
|
|
||||
|
const portfolioCalculator = factory.createCalculator({ |
||||
|
activities, |
||||
|
calculationType: PerformanceCalculationType.TWR, |
||||
|
currency: 'USD' |
||||
|
}); |
||||
|
|
||||
|
const portfolioSnapshot = await portfolioCalculator.computeSnapshot( |
||||
|
parseDate('2021-11-30') |
||||
|
); |
||||
|
|
||||
|
spy.mockRestore(); |
||||
|
|
||||
|
expect(portfolioSnapshot).toEqual({ |
||||
|
currentValueInBaseCurrency: new Big('0'), |
||||
|
errors: [], |
||||
|
grossPerformance: new Big('0'), |
||||
|
grossPerformancePercentage: new Big('0'), |
||||
|
grossPerformancePercentageWithCurrencyEffect: new Big('0'), |
||||
|
grossPerformanceWithCurrencyEffect: new Big('0'), |
||||
|
hasErrors: true, |
||||
|
netPerformance: new Big('0'), |
||||
|
netPerformancePercentage: new Big('0'), |
||||
|
netPerformancePercentageWithCurrencyEffect: new Big('0'), |
||||
|
netPerformanceWithCurrencyEffect: new Big('0'), |
||||
|
positions: [ |
||||
|
{ |
||||
|
averagePrice: new Big('0'), |
||||
|
currency: 'USD', |
||||
|
dataSource: 'MANUAL', |
||||
|
dividend: new Big('0'), |
||||
|
dividendInBaseCurrency: new Big('0'), |
||||
|
fee: new Big('49'), |
||||
|
firstBuyDate: '2021-09-01', |
||||
|
grossPerformance: null, |
||||
|
grossPerformancePercentage: null, |
||||
|
grossPerformancePercentageWithCurrencyEffect: null, |
||||
|
grossPerformanceWithCurrencyEffect: null, |
||||
|
investment: new Big('0'), |
||||
|
investmentWithCurrencyEffect: new Big('0'), |
||||
|
marketPrice: null, |
||||
|
marketPriceInBaseCurrency: 0, |
||||
|
netPerformance: null, |
||||
|
netPerformancePercentage: null, |
||||
|
netPerformancePercentageWithCurrencyEffect: null, |
||||
|
netPerformanceWithCurrencyEffect: null, |
||||
|
quantity: new Big('0'), |
||||
|
symbol: '2c463fb3-af07-486e-adb0-8301b3d72141', |
||||
|
tags: [], |
||||
|
timeWeightedInvestment: new Big('0'), |
||||
|
timeWeightedInvestmentWithCurrencyEffect: new Big('0'), |
||||
|
transactionCount: 1, |
||||
|
valueInBaseCurrency: new Big('0') |
||||
|
} |
||||
|
], |
||||
|
totalFeesWithCurrencyEffect: new Big('49'), |
||||
|
totalInterestWithCurrencyEffect: new Big('0'), |
||||
|
totalInvestment: new Big('0'), |
||||
|
totalInvestmentWithCurrencyEffect: new Big('0'), |
||||
|
totalLiabilitiesWithCurrencyEffect: new Big('0'), |
||||
|
totalValuablesWithCurrencyEffect: new Big('0') |
||||
|
}); |
||||
|
}); |
||||
|
}); |
||||
|
}); |
@ -0,0 +1,134 @@ |
|||||
|
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; |
||||
|
import { |
||||
|
activityDummyData, |
||||
|
symbolProfileDummyData |
||||
|
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; |
||||
|
import { |
||||
|
PortfolioCalculatorFactory, |
||||
|
PerformanceCalculationType |
||||
|
} 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 { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; |
||||
|
import { parseDate } from '@ghostfolio/common/helper'; |
||||
|
|
||||
|
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; |
||||
|
}) |
||||
|
}; |
||||
|
}); |
||||
|
|
||||
|
describe('PortfolioCalculator', () => { |
||||
|
let currentRateService: CurrentRateService; |
||||
|
let exchangeRateDataService: ExchangeRateDataService; |
||||
|
let factory: PortfolioCalculatorFactory; |
||||
|
|
||||
|
beforeEach(() => { |
||||
|
currentRateService = new CurrentRateService(null, null, null, null); |
||||
|
|
||||
|
exchangeRateDataService = new ExchangeRateDataService( |
||||
|
null, |
||||
|
null, |
||||
|
null, |
||||
|
null |
||||
|
); |
||||
|
|
||||
|
factory = new PortfolioCalculatorFactory( |
||||
|
currentRateService, |
||||
|
exchangeRateDataService |
||||
|
); |
||||
|
}); |
||||
|
|
||||
|
describe('compute portfolio snapshot', () => { |
||||
|
it.only('with item activity', async () => { |
||||
|
const spy = jest |
||||
|
.spyOn(Date, 'now') |
||||
|
.mockImplementation(() => parseDate('2022-01-31').getTime()); |
||||
|
|
||||
|
const activities: Activity[] = [ |
||||
|
{ |
||||
|
...activityDummyData, |
||||
|
date: new Date('2022-01-01'), |
||||
|
fee: 0, |
||||
|
quantity: 1, |
||||
|
SymbolProfile: { |
||||
|
...symbolProfileDummyData, |
||||
|
currency: 'USD', |
||||
|
dataSource: 'MANUAL', |
||||
|
name: 'Penthouse Apartment', |
||||
|
symbol: 'dac95060-d4f2-4653-a253-2c45e6fb5cde' |
||||
|
}, |
||||
|
type: 'ITEM', |
||||
|
unitPrice: 500000 |
||||
|
} |
||||
|
]; |
||||
|
|
||||
|
const portfolioCalculator = factory.createCalculator({ |
||||
|
activities, |
||||
|
calculationType: PerformanceCalculationType.TWR, |
||||
|
currency: 'USD' |
||||
|
}); |
||||
|
|
||||
|
const portfolioSnapshot = await portfolioCalculator.computeSnapshot( |
||||
|
parseDate('2022-01-01') |
||||
|
); |
||||
|
|
||||
|
spy.mockRestore(); |
||||
|
|
||||
|
expect(portfolioSnapshot).toEqual({ |
||||
|
currentValueInBaseCurrency: new Big('0'), |
||||
|
errors: [], |
||||
|
grossPerformance: new Big('0'), |
||||
|
grossPerformancePercentage: new Big('0'), |
||||
|
grossPerformancePercentageWithCurrencyEffect: new Big('0'), |
||||
|
grossPerformanceWithCurrencyEffect: new Big('0'), |
||||
|
hasErrors: true, |
||||
|
netPerformance: new Big('0'), |
||||
|
netPerformancePercentage: new Big('0'), |
||||
|
netPerformancePercentageWithCurrencyEffect: new Big('0'), |
||||
|
netPerformanceWithCurrencyEffect: new Big('0'), |
||||
|
positions: [ |
||||
|
{ |
||||
|
averagePrice: new Big('500000'), |
||||
|
currency: 'USD', |
||||
|
dataSource: 'MANUAL', |
||||
|
dividend: new Big('0'), |
||||
|
dividendInBaseCurrency: new Big('0'), |
||||
|
fee: new Big('0'), |
||||
|
firstBuyDate: '2022-01-01', |
||||
|
grossPerformance: null, |
||||
|
grossPerformancePercentage: null, |
||||
|
grossPerformancePercentageWithCurrencyEffect: null, |
||||
|
grossPerformanceWithCurrencyEffect: null, |
||||
|
investment: new Big('0'), |
||||
|
investmentWithCurrencyEffect: new Big('0'), |
||||
|
marketPrice: null, |
||||
|
marketPriceInBaseCurrency: 500000, |
||||
|
netPerformance: null, |
||||
|
netPerformancePercentage: null, |
||||
|
netPerformancePercentageWithCurrencyEffect: null, |
||||
|
netPerformanceWithCurrencyEffect: null, |
||||
|
quantity: new Big('0'), |
||||
|
symbol: 'dac95060-d4f2-4653-a253-2c45e6fb5cde', |
||||
|
tags: [], |
||||
|
timeWeightedInvestment: new Big('0'), |
||||
|
timeWeightedInvestmentWithCurrencyEffect: new Big('0'), |
||||
|
transactionCount: 1, |
||||
|
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') |
||||
|
}); |
||||
|
}); |
||||
|
}); |
||||
|
}); |
@ -0,0 +1,134 @@ |
|||||
|
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; |
||||
|
import { |
||||
|
activityDummyData, |
||||
|
symbolProfileDummyData |
||||
|
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; |
||||
|
import { |
||||
|
PortfolioCalculatorFactory, |
||||
|
PerformanceCalculationType |
||||
|
} 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 { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; |
||||
|
import { parseDate } from '@ghostfolio/common/helper'; |
||||
|
|
||||
|
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; |
||||
|
}) |
||||
|
}; |
||||
|
}); |
||||
|
|
||||
|
describe('PortfolioCalculator', () => { |
||||
|
let currentRateService: CurrentRateService; |
||||
|
let exchangeRateDataService: ExchangeRateDataService; |
||||
|
let factory: PortfolioCalculatorFactory; |
||||
|
|
||||
|
beforeEach(() => { |
||||
|
currentRateService = new CurrentRateService(null, null, null, null); |
||||
|
|
||||
|
exchangeRateDataService = new ExchangeRateDataService( |
||||
|
null, |
||||
|
null, |
||||
|
null, |
||||
|
null |
||||
|
); |
||||
|
|
||||
|
factory = new PortfolioCalculatorFactory( |
||||
|
currentRateService, |
||||
|
exchangeRateDataService |
||||
|
); |
||||
|
}); |
||||
|
|
||||
|
describe('compute portfolio snapshot', () => { |
||||
|
it.only('with liability activity', async () => { |
||||
|
const spy = jest |
||||
|
.spyOn(Date, 'now') |
||||
|
.mockImplementation(() => parseDate('2022-01-31').getTime()); |
||||
|
|
||||
|
const activities: Activity[] = [ |
||||
|
{ |
||||
|
...activityDummyData, |
||||
|
date: new Date('2022-01-01'), |
||||
|
fee: 0, |
||||
|
quantity: 1, |
||||
|
SymbolProfile: { |
||||
|
...symbolProfileDummyData, |
||||
|
currency: 'USD', |
||||
|
dataSource: 'MANUAL', |
||||
|
name: 'Loan', |
||||
|
symbol: '55196015-1365-4560-aa60-8751ae6d18f8' |
||||
|
}, |
||||
|
type: 'LIABILITY', |
||||
|
unitPrice: 3000 |
||||
|
} |
||||
|
]; |
||||
|
|
||||
|
const portfolioCalculator = factory.createCalculator({ |
||||
|
activities, |
||||
|
calculationType: PerformanceCalculationType.TWR, |
||||
|
currency: 'USD' |
||||
|
}); |
||||
|
|
||||
|
const portfolioSnapshot = await portfolioCalculator.computeSnapshot( |
||||
|
parseDate('2022-01-01') |
||||
|
); |
||||
|
|
||||
|
spy.mockRestore(); |
||||
|
|
||||
|
expect(portfolioSnapshot).toEqual({ |
||||
|
currentValueInBaseCurrency: new Big('0'), |
||||
|
errors: [], |
||||
|
grossPerformance: new Big('0'), |
||||
|
grossPerformancePercentage: new Big('0'), |
||||
|
grossPerformancePercentageWithCurrencyEffect: new Big('0'), |
||||
|
grossPerformanceWithCurrencyEffect: new Big('0'), |
||||
|
hasErrors: true, |
||||
|
netPerformance: new Big('0'), |
||||
|
netPerformancePercentage: new Big('0'), |
||||
|
netPerformancePercentageWithCurrencyEffect: new Big('0'), |
||||
|
netPerformanceWithCurrencyEffect: new Big('0'), |
||||
|
positions: [ |
||||
|
{ |
||||
|
averagePrice: new Big('3000'), |
||||
|
currency: 'USD', |
||||
|
dataSource: 'MANUAL', |
||||
|
dividend: new Big('0'), |
||||
|
dividendInBaseCurrency: new Big('0'), |
||||
|
fee: new Big('0'), |
||||
|
firstBuyDate: '2022-01-01', |
||||
|
grossPerformance: null, |
||||
|
grossPerformancePercentage: null, |
||||
|
grossPerformancePercentageWithCurrencyEffect: null, |
||||
|
grossPerformanceWithCurrencyEffect: null, |
||||
|
investment: new Big('0'), |
||||
|
investmentWithCurrencyEffect: new Big('0'), |
||||
|
marketPrice: null, |
||||
|
marketPriceInBaseCurrency: 3000, |
||||
|
netPerformance: null, |
||||
|
netPerformancePercentage: null, |
||||
|
netPerformancePercentageWithCurrencyEffect: null, |
||||
|
netPerformanceWithCurrencyEffect: null, |
||||
|
quantity: new Big('0'), |
||||
|
symbol: '55196015-1365-4560-aa60-8751ae6d18f8', |
||||
|
tags: [], |
||||
|
timeWeightedInvestment: new Big('0'), |
||||
|
timeWeightedInvestmentWithCurrencyEffect: new Big('0'), |
||||
|
transactionCount: 1, |
||||
|
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') |
||||
|
}); |
||||
|
}); |
||||
|
}); |
||||
|
}); |
@ -0,0 +1,142 @@ |
|||||
|
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; |
||||
|
import { |
||||
|
activityDummyData, |
||||
|
symbolProfileDummyData |
||||
|
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; |
||||
|
import { |
||||
|
PerformanceCalculationType, |
||||
|
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 { 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 { parseDate } from '@ghostfolio/common/helper'; |
||||
|
|
||||
|
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/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 currentRateService: CurrentRateService; |
||||
|
let exchangeRateDataService: ExchangeRateDataService; |
||||
|
let factory: PortfolioCalculatorFactory; |
||||
|
|
||||
|
beforeEach(() => { |
||||
|
currentRateService = new CurrentRateService(null, null, null, null); |
||||
|
|
||||
|
exchangeRateDataService = new ExchangeRateDataService( |
||||
|
null, |
||||
|
null, |
||||
|
null, |
||||
|
null |
||||
|
); |
||||
|
|
||||
|
factory = new PortfolioCalculatorFactory( |
||||
|
currentRateService, |
||||
|
exchangeRateDataService |
||||
|
); |
||||
|
}); |
||||
|
|
||||
|
describe('get current positions', () => { |
||||
|
it.only('with MSFT buy', async () => { |
||||
|
const spy = jest |
||||
|
.spyOn(Date, 'now') |
||||
|
.mockImplementation(() => parseDate('2023-07-10').getTime()); |
||||
|
|
||||
|
const activities: Activity[] = [ |
||||
|
{ |
||||
|
...activityDummyData, |
||||
|
date: new Date('2021-09-16'), |
||||
|
fee: 19, |
||||
|
quantity: 1, |
||||
|
SymbolProfile: { |
||||
|
...symbolProfileDummyData, |
||||
|
currency: 'USD', |
||||
|
dataSource: 'YAHOO', |
||||
|
name: 'Microsoft Inc.', |
||||
|
symbol: 'MSFT' |
||||
|
}, |
||||
|
type: 'BUY', |
||||
|
unitPrice: 298.58 |
||||
|
}, |
||||
|
{ |
||||
|
...activityDummyData, |
||||
|
date: new Date('2021-11-16'), |
||||
|
fee: 0, |
||||
|
quantity: 1, |
||||
|
SymbolProfile: { |
||||
|
...symbolProfileDummyData, |
||||
|
currency: 'USD', |
||||
|
dataSource: 'YAHOO', |
||||
|
name: 'Microsoft Inc.', |
||||
|
symbol: 'MSFT' |
||||
|
}, |
||||
|
type: 'DIVIDEND', |
||||
|
unitPrice: 0.62 |
||||
|
} |
||||
|
]; |
||||
|
|
||||
|
const portfolioCalculator = factory.createCalculator({ |
||||
|
activities, |
||||
|
calculationType: PerformanceCalculationType.TWR, |
||||
|
currency: 'USD' |
||||
|
}); |
||||
|
|
||||
|
const portfolioSnapshot = await portfolioCalculator.computeSnapshot( |
||||
|
parseDate('2023-07-10') |
||||
|
); |
||||
|
|
||||
|
spy.mockRestore(); |
||||
|
|
||||
|
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', |
||||
|
investment: new Big('298.58'), |
||||
|
investmentWithCurrencyEffect: new Big('298.58'), |
||||
|
marketPrice: 331.83, |
||||
|
marketPriceInBaseCurrency: 331.83, |
||||
|
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') |
||||
|
}); |
||||
|
}); |
||||
|
}); |
||||
|
}); |
@ -0,0 +1,27 @@ |
|||||
|
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; |
||||
|
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; |
||||
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; |
||||
|
|
||||
|
describe('PortfolioCalculator', () => { |
||||
|
let currentRateService: CurrentRateService; |
||||
|
let exchangeRateDataService: ExchangeRateDataService; |
||||
|
let factory: PortfolioCalculatorFactory; |
||||
|
|
||||
|
beforeEach(() => { |
||||
|
currentRateService = new CurrentRateService(null, null, null, null); |
||||
|
|
||||
|
exchangeRateDataService = new ExchangeRateDataService( |
||||
|
null, |
||||
|
null, |
||||
|
null, |
||||
|
null |
||||
|
); |
||||
|
|
||||
|
factory = new PortfolioCalculatorFactory( |
||||
|
currentRateService, |
||||
|
exchangeRateDataService |
||||
|
); |
||||
|
}); |
||||
|
|
||||
|
test.skip('Skip empty test', () => 1); |
||||
|
}); |
@ -0,0 +1,900 @@ |
|||||
|
import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator'; |
||||
|
import { PortfolioOrderItem } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-order-item.interface'; |
||||
|
import { PortfolioSnapshot } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-snapshot.interface'; |
||||
|
import { getFactor } from '@ghostfolio/api/helper/portfolio.helper'; |
||||
|
import { DATE_FORMAT } from '@ghostfolio/common/helper'; |
||||
|
import { |
||||
|
SymbolMetrics, |
||||
|
TimelinePosition, |
||||
|
UniqueAsset |
||||
|
} from '@ghostfolio/common/interfaces'; |
||||
|
|
||||
|
import { Logger } from '@nestjs/common'; |
||||
|
import { Big } from 'big.js'; |
||||
|
import { |
||||
|
addDays, |
||||
|
addMilliseconds, |
||||
|
differenceInDays, |
||||
|
format, |
||||
|
isBefore |
||||
|
} from 'date-fns'; |
||||
|
import { cloneDeep, first, last, sortBy } from 'lodash'; |
||||
|
|
||||
|
export class TWRPortfolioCalculator extends PortfolioCalculator { |
||||
|
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 netPerformanceWithCurrencyEffect = new Big(0); |
||||
|
let totalFeesWithCurrencyEffect = new Big(0); |
||||
|
let 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) { |
||||
|
if (currentPosition.fee) { |
||||
|
totalFeesWithCurrencyEffect = totalFeesWithCurrencyEffect.plus( |
||||
|
currentPosition.fee |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
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); |
||||
|
|
||||
|
netPerformanceWithCurrencyEffect = |
||||
|
netPerformanceWithCurrencyEffect.plus( |
||||
|
currentPosition.netPerformanceWithCurrencyEffect |
||||
|
); |
||||
|
} 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 { |
||||
|
currentValueInBaseCurrency, |
||||
|
grossPerformance, |
||||
|
grossPerformanceWithCurrencyEffect, |
||||
|
hasErrors, |
||||
|
netPerformance, |
||||
|
netPerformanceWithCurrencyEffect, |
||||
|
positions, |
||||
|
totalFeesWithCurrencyEffect, |
||||
|
totalInterestWithCurrencyEffect, |
||||
|
totalInvestment, |
||||
|
totalInvestmentWithCurrencyEffect, |
||||
|
netPerformancePercentage: totalTimeWeightedInvestment.eq(0) |
||||
|
? new Big(0) |
||||
|
: netPerformance.div(totalTimeWeightedInvestment), |
||||
|
netPerformancePercentageWithCurrencyEffect: |
||||
|
totalTimeWeightedInvestmentWithCurrencyEffect.eq(0) |
||||
|
? new Big(0) |
||||
|
: netPerformanceWithCurrencyEffect.div( |
||||
|
totalTimeWeightedInvestmentWithCurrencyEffect |
||||
|
), |
||||
|
grossPerformancePercentage: totalTimeWeightedInvestment.eq(0) |
||||
|
? new Big(0) |
||||
|
: grossPerformance.div(totalTimeWeightedInvestment), |
||||
|
grossPerformancePercentageWithCurrencyEffect: |
||||
|
totalTimeWeightedInvestmentWithCurrencyEffect.eq(0) |
||||
|
? new Big(0) |
||||
|
: grossPerformanceWithCurrencyEffect.div( |
||||
|
totalTimeWeightedInvestmentWithCurrencyEffect |
||||
|
), |
||||
|
totalLiabilitiesWithCurrencyEffect: new Big(0), |
||||
|
totalValuablesWithCurrencyEffect: new Big(0) |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
protected getSymbolMetrics({ |
||||
|
dataSource, |
||||
|
end, |
||||
|
exchangeRates, |
||||
|
isChartMode = false, |
||||
|
marketSymbolMap, |
||||
|
start, |
||||
|
step = 1, |
||||
|
symbol |
||||
|
}: { |
||||
|
end: Date; |
||||
|
exchangeRates: { [dateString: string]: number }; |
||||
|
isChartMode?: boolean; |
||||
|
marketSymbolMap: { |
||||
|
[date: string]: { [symbol: string]: Big }; |
||||
|
}; |
||||
|
start: Date; |
||||
|
step?: number; |
||||
|
} & UniqueAsset): SymbolMetrics { |
||||
|
const currentExchangeRate = exchangeRates[format(new Date(), DATE_FORMAT)]; |
||||
|
const currentValues: { [date: string]: Big } = {}; |
||||
|
const currentValuesWithCurrencyEffect: { [date: string]: Big } = {}; |
||||
|
let fees = new Big(0); |
||||
|
let feesAtStartDate = new Big(0); |
||||
|
let feesAtStartDateWithCurrencyEffect = new Big(0); |
||||
|
let feesWithCurrencyEffect = new Big(0); |
||||
|
let grossPerformance = new Big(0); |
||||
|
let grossPerformanceWithCurrencyEffect = new Big(0); |
||||
|
let grossPerformanceAtStartDate = new Big(0); |
||||
|
let grossPerformanceAtStartDateWithCurrencyEffect = new Big(0); |
||||
|
let grossPerformanceFromSells = new Big(0); |
||||
|
let grossPerformanceFromSellsWithCurrencyEffect = new Big(0); |
||||
|
let initialValue: Big; |
||||
|
let initialValueWithCurrencyEffect: Big; |
||||
|
let investmentAtStartDate: Big; |
||||
|
let investmentAtStartDateWithCurrencyEffect: Big; |
||||
|
const investmentValuesAccumulated: { [date: string]: Big } = {}; |
||||
|
const investmentValuesAccumulatedWithCurrencyEffect: { |
||||
|
[date: string]: Big; |
||||
|
} = {}; |
||||
|
const investmentValuesWithCurrencyEffect: { [date: string]: Big } = {}; |
||||
|
let lastAveragePrice = new Big(0); |
||||
|
let lastAveragePriceWithCurrencyEffect = new Big(0); |
||||
|
const netPerformanceValues: { [date: string]: Big } = {}; |
||||
|
const netPerformanceValuesWithCurrencyEffect: { [date: string]: Big } = {}; |
||||
|
const timeWeightedInvestmentValues: { [date: string]: Big } = {}; |
||||
|
|
||||
|
const timeWeightedInvestmentValuesWithCurrencyEffect: { |
||||
|
[date: string]: Big; |
||||
|
} = {}; |
||||
|
|
||||
|
let totalDividend = new Big(0); |
||||
|
let totalDividendInBaseCurrency = new Big(0); |
||||
|
let totalInterest = new Big(0); |
||||
|
let totalInterestInBaseCurrency = new Big(0); |
||||
|
let totalInvestment = new Big(0); |
||||
|
let totalInvestmentFromBuyTransactions = new Big(0); |
||||
|
let totalInvestmentFromBuyTransactionsWithCurrencyEffect = new Big(0); |
||||
|
let totalInvestmentWithCurrencyEffect = new Big(0); |
||||
|
let totalLiabilities = new Big(0); |
||||
|
let totalLiabilitiesInBaseCurrency = new Big(0); |
||||
|
let totalQuantityFromBuyTransactions = new Big(0); |
||||
|
let totalUnits = new Big(0); |
||||
|
let totalValuables = new Big(0); |
||||
|
let totalValuablesInBaseCurrency = new Big(0); |
||||
|
let valueAtStartDate: Big; |
||||
|
let valueAtStartDateWithCurrencyEffect: Big; |
||||
|
|
||||
|
// Clone orders to keep the original values in this.orders
|
||||
|
let orders: PortfolioOrderItem[] = cloneDeep(this.orders).filter( |
||||
|
({ SymbolProfile }) => { |
||||
|
return SymbolProfile.symbol === symbol; |
||||
|
} |
||||
|
); |
||||
|
|
||||
|
if (orders.length <= 0) { |
||||
|
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), |
||||
|
netPerformancePercentageWithCurrencyEffect: new Big(0), |
||||
|
netPerformanceValues: {}, |
||||
|
netPerformanceValuesWithCurrencyEffect: {}, |
||||
|
netPerformanceWithCurrencyEffect: new Big(0), |
||||
|
timeWeightedInvestment: new Big(0), |
||||
|
timeWeightedInvestmentValues: {}, |
||||
|
timeWeightedInvestmentValuesWithCurrencyEffect: {}, |
||||
|
timeWeightedInvestmentWithCurrencyEffect: 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), |
||||
|
totalLiabilities: new Big(0), |
||||
|
totalLiabilitiesInBaseCurrency: new Big(0), |
||||
|
totalValuables: new Big(0), |
||||
|
totalValuablesInBaseCurrency: new Big(0) |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
const dateOfFirstTransaction = new Date(first(orders).date); |
||||
|
|
||||
|
const unitPriceAtStartDate = |
||||
|
marketSymbolMap[format(start, DATE_FORMAT)]?.[symbol]; |
||||
|
|
||||
|
const unitPriceAtEndDate = |
||||
|
marketSymbolMap[format(end, DATE_FORMAT)]?.[symbol]; |
||||
|
|
||||
|
if ( |
||||
|
!unitPriceAtEndDate || |
||||
|
(!unitPriceAtStartDate && isBefore(dateOfFirstTransaction, start)) |
||||
|
) { |
||||
|
return { |
||||
|
currentValues: {}, |
||||
|
currentValuesWithCurrencyEffect: {}, |
||||
|
feesWithCurrencyEffect: new Big(0), |
||||
|
grossPerformance: new Big(0), |
||||
|
grossPerformancePercentage: new Big(0), |
||||
|
grossPerformancePercentageWithCurrencyEffect: new Big(0), |
||||
|
grossPerformanceWithCurrencyEffect: new Big(0), |
||||
|
hasErrors: true, |
||||
|
initialValue: new Big(0), |
||||
|
initialValueWithCurrencyEffect: new Big(0), |
||||
|
investmentValuesAccumulated: {}, |
||||
|
investmentValuesAccumulatedWithCurrencyEffect: {}, |
||||
|
investmentValuesWithCurrencyEffect: {}, |
||||
|
netPerformance: new Big(0), |
||||
|
netPerformancePercentage: new Big(0), |
||||
|
netPerformancePercentageWithCurrencyEffect: new Big(0), |
||||
|
netPerformanceValues: {}, |
||||
|
netPerformanceValuesWithCurrencyEffect: {}, |
||||
|
netPerformanceWithCurrencyEffect: new Big(0), |
||||
|
timeWeightedInvestment: new Big(0), |
||||
|
timeWeightedInvestmentValues: {}, |
||||
|
timeWeightedInvestmentValuesWithCurrencyEffect: {}, |
||||
|
timeWeightedInvestmentWithCurrencyEffect: 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), |
||||
|
totalLiabilities: new Big(0), |
||||
|
totalLiabilitiesInBaseCurrency: new Big(0), |
||||
|
totalValuables: new Big(0), |
||||
|
totalValuablesInBaseCurrency: new Big(0) |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
// Add a synthetic order at the start and the end date
|
||||
|
orders.push({ |
||||
|
date: format(start, DATE_FORMAT), |
||||
|
fee: new Big(0), |
||||
|
feeInBaseCurrency: new Big(0), |
||||
|
itemType: 'start', |
||||
|
quantity: new Big(0), |
||||
|
SymbolProfile: { |
||||
|
dataSource, |
||||
|
symbol |
||||
|
}, |
||||
|
type: 'BUY', |
||||
|
unitPrice: unitPriceAtStartDate |
||||
|
}); |
||||
|
|
||||
|
orders.push({ |
||||
|
date: format(end, DATE_FORMAT), |
||||
|
fee: new Big(0), |
||||
|
feeInBaseCurrency: new Big(0), |
||||
|
itemType: 'end', |
||||
|
SymbolProfile: { |
||||
|
dataSource, |
||||
|
symbol |
||||
|
}, |
||||
|
quantity: new Big(0), |
||||
|
type: 'BUY', |
||||
|
unitPrice: unitPriceAtEndDate |
||||
|
}); |
||||
|
|
||||
|
let day = start; |
||||
|
let lastUnitPrice: Big; |
||||
|
|
||||
|
if (isChartMode) { |
||||
|
const datesWithOrders = {}; |
||||
|
|
||||
|
for (const order of orders) { |
||||
|
datesWithOrders[order.date] = true; |
||||
|
} |
||||
|
|
||||
|
while (isBefore(day, end)) { |
||||
|
const hasDate = datesWithOrders[format(day, DATE_FORMAT)]; |
||||
|
|
||||
|
if (!hasDate) { |
||||
|
orders.push({ |
||||
|
date: format(day, DATE_FORMAT), |
||||
|
fee: new Big(0), |
||||
|
feeInBaseCurrency: new Big(0), |
||||
|
quantity: new Big(0), |
||||
|
SymbolProfile: { |
||||
|
dataSource, |
||||
|
symbol |
||||
|
}, |
||||
|
type: 'BUY', |
||||
|
unitPrice: |
||||
|
marketSymbolMap[format(day, DATE_FORMAT)]?.[symbol] ?? |
||||
|
lastUnitPrice |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
lastUnitPrice = last(orders).unitPrice; |
||||
|
|
||||
|
day = addDays(day, step); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Sort orders so that the start and end placeholder order are at the correct
|
||||
|
// position
|
||||
|
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(); |
||||
|
}); |
||||
|
|
||||
|
const indexOfStartOrder = orders.findIndex(({ itemType }) => { |
||||
|
return itemType === 'start'; |
||||
|
}); |
||||
|
|
||||
|
const indexOfEndOrder = orders.findIndex(({ itemType }) => { |
||||
|
return itemType === 'end'; |
||||
|
}); |
||||
|
|
||||
|
let totalInvestmentDays = 0; |
||||
|
let sumOfTimeWeightedInvestments = new Big(0); |
||||
|
let sumOfTimeWeightedInvestmentsWithCurrencyEffect = new Big(0); |
||||
|
|
||||
|
for (let i = 0; i < orders.length; i += 1) { |
||||
|
const order = orders[i]; |
||||
|
|
||||
|
if (PortfolioCalculator.ENABLE_LOGGING) { |
||||
|
console.log(); |
||||
|
console.log(); |
||||
|
console.log(i + 1, order.type, order.itemType); |
||||
|
} |
||||
|
|
||||
|
const exchangeRateAtOrderDate = exchangeRates[order.date]; |
||||
|
|
||||
|
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 = |
||||
|
indexOfStartOrder === 0 |
||||
|
? orders[i + 1]?.unitPrice |
||||
|
: unitPriceAtStartDate; |
||||
|
} |
||||
|
|
||||
|
if (order.fee) { |
||||
|
order.feeInBaseCurrency = order.fee.mul(currentExchangeRate ?? 1); |
||||
|
order.feeInBaseCurrencyWithCurrencyEffect = order.fee.mul( |
||||
|
exchangeRateAtOrderDate ?? 1 |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
if (order.unitPrice) { |
||||
|
order.unitPriceInBaseCurrency = order.unitPrice.mul( |
||||
|
currentExchangeRate ?? 1 |
||||
|
); |
||||
|
|
||||
|
order.unitPriceInBaseCurrencyWithCurrencyEffect = order.unitPrice.mul( |
||||
|
exchangeRateAtOrderDate ?? 1 |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
const valueOfInvestmentBeforeTransaction = totalUnits.mul( |
||||
|
order.unitPriceInBaseCurrency |
||||
|
); |
||||
|
|
||||
|
const valueOfInvestmentBeforeTransactionWithCurrencyEffect = |
||||
|
totalUnits.mul(order.unitPriceInBaseCurrencyWithCurrencyEffect); |
||||
|
|
||||
|
if (!investmentAtStartDate && i >= indexOfStartOrder) { |
||||
|
investmentAtStartDate = totalInvestment ?? new Big(0); |
||||
|
|
||||
|
investmentAtStartDateWithCurrencyEffect = |
||||
|
totalInvestmentWithCurrencyEffect ?? new Big(0); |
||||
|
|
||||
|
valueAtStartDate = valueOfInvestmentBeforeTransaction; |
||||
|
|
||||
|
valueAtStartDateWithCurrencyEffect = |
||||
|
valueOfInvestmentBeforeTransactionWithCurrencyEffect; |
||||
|
} |
||||
|
|
||||
|
let transactionInvestment = new Big(0); |
||||
|
let transactionInvestmentWithCurrencyEffect = new Big(0); |
||||
|
|
||||
|
if (order.type === 'BUY') { |
||||
|
transactionInvestment = order.quantity |
||||
|
.mul(order.unitPriceInBaseCurrency) |
||||
|
.mul(getFactor(order.type)); |
||||
|
|
||||
|
transactionInvestmentWithCurrencyEffect = order.quantity |
||||
|
.mul(order.unitPriceInBaseCurrencyWithCurrencyEffect) |
||||
|
.mul(getFactor(order.type)); |
||||
|
|
||||
|
totalQuantityFromBuyTransactions = |
||||
|
totalQuantityFromBuyTransactions.plus(order.quantity); |
||||
|
|
||||
|
totalInvestmentFromBuyTransactions = |
||||
|
totalInvestmentFromBuyTransactions.plus(transactionInvestment); |
||||
|
|
||||
|
totalInvestmentFromBuyTransactionsWithCurrencyEffect = |
||||
|
totalInvestmentFromBuyTransactionsWithCurrencyEffect.plus( |
||||
|
transactionInvestmentWithCurrencyEffect |
||||
|
); |
||||
|
} else if (order.type === 'SELL') { |
||||
|
if (totalUnits.gt(0)) { |
||||
|
transactionInvestment = totalInvestment |
||||
|
.div(totalUnits) |
||||
|
.mul(order.quantity) |
||||
|
.mul(getFactor(order.type)); |
||||
|
transactionInvestmentWithCurrencyEffect = |
||||
|
totalInvestmentWithCurrencyEffect |
||||
|
.div(totalUnits) |
||||
|
.mul(order.quantity) |
||||
|
.mul(getFactor(order.type)); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
if (PortfolioCalculator.ENABLE_LOGGING) { |
||||
|
console.log('totalInvestment', totalInvestment.toNumber()); |
||||
|
|
||||
|
console.log( |
||||
|
'totalInvestmentWithCurrencyEffect', |
||||
|
totalInvestmentWithCurrencyEffect.toNumber() |
||||
|
); |
||||
|
|
||||
|
console.log('order.quantity', order.quantity.toNumber()); |
||||
|
console.log('transactionInvestment', transactionInvestment.toNumber()); |
||||
|
|
||||
|
console.log( |
||||
|
'transactionInvestmentWithCurrencyEffect', |
||||
|
transactionInvestmentWithCurrencyEffect.toNumber() |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
const totalInvestmentBeforeTransaction = totalInvestment; |
||||
|
|
||||
|
const totalInvestmentBeforeTransactionWithCurrencyEffect = |
||||
|
totalInvestmentWithCurrencyEffect; |
||||
|
|
||||
|
totalInvestment = totalInvestment.plus(transactionInvestment); |
||||
|
|
||||
|
totalInvestmentWithCurrencyEffect = |
||||
|
totalInvestmentWithCurrencyEffect.plus( |
||||
|
transactionInvestmentWithCurrencyEffect |
||||
|
); |
||||
|
|
||||
|
if (i >= indexOfStartOrder && !initialValue) { |
||||
|
if ( |
||||
|
i === indexOfStartOrder && |
||||
|
!valueOfInvestmentBeforeTransaction.eq(0) |
||||
|
) { |
||||
|
initialValue = valueOfInvestmentBeforeTransaction; |
||||
|
|
||||
|
initialValueWithCurrencyEffect = |
||||
|
valueOfInvestmentBeforeTransactionWithCurrencyEffect; |
||||
|
} else if (transactionInvestment.gt(0)) { |
||||
|
initialValue = transactionInvestment; |
||||
|
|
||||
|
initialValueWithCurrencyEffect = |
||||
|
transactionInvestmentWithCurrencyEffect; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
fees = fees.plus(order.feeInBaseCurrency ?? 0); |
||||
|
|
||||
|
feesWithCurrencyEffect = feesWithCurrencyEffect.plus( |
||||
|
order.feeInBaseCurrencyWithCurrencyEffect ?? 0 |
||||
|
); |
||||
|
|
||||
|
totalUnits = totalUnits.plus(order.quantity.mul(getFactor(order.type))); |
||||
|
|
||||
|
if (order.type === 'DIVIDEND') { |
||||
|
const dividend = order.quantity.mul(order.unitPrice); |
||||
|
|
||||
|
totalDividend = totalDividend.plus(dividend); |
||||
|
totalDividendInBaseCurrency = totalDividendInBaseCurrency.plus( |
||||
|
dividend.mul(exchangeRateAtOrderDate ?? 1) |
||||
|
); |
||||
|
} else if (order.type === 'INTEREST') { |
||||
|
const interest = order.quantity.mul(order.unitPrice); |
||||
|
|
||||
|
totalInterest = totalInterest.plus(interest); |
||||
|
totalInterestInBaseCurrency = totalInterestInBaseCurrency.plus( |
||||
|
interest.mul(exchangeRateAtOrderDate ?? 1) |
||||
|
); |
||||
|
} else if (order.type === 'ITEM') { |
||||
|
const valuables = order.quantity.mul(order.unitPrice); |
||||
|
|
||||
|
totalValuables = totalValuables.plus(valuables); |
||||
|
totalValuablesInBaseCurrency = totalValuablesInBaseCurrency.plus( |
||||
|
valuables.mul(exchangeRateAtOrderDate ?? 1) |
||||
|
); |
||||
|
} else if (order.type === 'LIABILITY') { |
||||
|
const liabilities = order.quantity.mul(order.unitPrice); |
||||
|
|
||||
|
totalLiabilities = totalLiabilities.plus(liabilities); |
||||
|
totalLiabilitiesInBaseCurrency = totalLiabilitiesInBaseCurrency.plus( |
||||
|
liabilities.mul(exchangeRateAtOrderDate ?? 1) |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
const valueOfInvestment = totalUnits.mul(order.unitPriceInBaseCurrency); |
||||
|
|
||||
|
const valueOfInvestmentWithCurrencyEffect = totalUnits.mul( |
||||
|
order.unitPriceInBaseCurrencyWithCurrencyEffect |
||||
|
); |
||||
|
|
||||
|
const grossPerformanceFromSell = |
||||
|
order.type === 'SELL' |
||||
|
? order.unitPriceInBaseCurrency |
||||
|
.minus(lastAveragePrice) |
||||
|
.mul(order.quantity) |
||||
|
: new Big(0); |
||||
|
|
||||
|
const grossPerformanceFromSellWithCurrencyEffect = |
||||
|
order.type === 'SELL' |
||||
|
? order.unitPriceInBaseCurrencyWithCurrencyEffect |
||||
|
.minus(lastAveragePriceWithCurrencyEffect) |
||||
|
.mul(order.quantity) |
||||
|
: new Big(0); |
||||
|
|
||||
|
grossPerformanceFromSells = grossPerformanceFromSells.plus( |
||||
|
grossPerformanceFromSell |
||||
|
); |
||||
|
|
||||
|
grossPerformanceFromSellsWithCurrencyEffect = |
||||
|
grossPerformanceFromSellsWithCurrencyEffect.plus( |
||||
|
grossPerformanceFromSellWithCurrencyEffect |
||||
|
); |
||||
|
|
||||
|
lastAveragePrice = totalQuantityFromBuyTransactions.eq(0) |
||||
|
? new Big(0) |
||||
|
: totalInvestmentFromBuyTransactions.div( |
||||
|
totalQuantityFromBuyTransactions |
||||
|
); |
||||
|
|
||||
|
lastAveragePriceWithCurrencyEffect = totalQuantityFromBuyTransactions.eq( |
||||
|
0 |
||||
|
) |
||||
|
? new Big(0) |
||||
|
: totalInvestmentFromBuyTransactionsWithCurrencyEffect.div( |
||||
|
totalQuantityFromBuyTransactions |
||||
|
); |
||||
|
|
||||
|
if (PortfolioCalculator.ENABLE_LOGGING) { |
||||
|
console.log( |
||||
|
'grossPerformanceFromSells', |
||||
|
grossPerformanceFromSells.toNumber() |
||||
|
); |
||||
|
console.log( |
||||
|
'grossPerformanceFromSellWithCurrencyEffect', |
||||
|
grossPerformanceFromSellWithCurrencyEffect.toNumber() |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
const newGrossPerformance = valueOfInvestment |
||||
|
.minus(totalInvestment) |
||||
|
.plus(grossPerformanceFromSells); |
||||
|
|
||||
|
const newGrossPerformanceWithCurrencyEffect = |
||||
|
valueOfInvestmentWithCurrencyEffect |
||||
|
.minus(totalInvestmentWithCurrencyEffect) |
||||
|
.plus(grossPerformanceFromSellsWithCurrencyEffect); |
||||
|
|
||||
|
grossPerformance = newGrossPerformance; |
||||
|
|
||||
|
grossPerformanceWithCurrencyEffect = |
||||
|
newGrossPerformanceWithCurrencyEffect; |
||||
|
|
||||
|
if (order.itemType === 'start') { |
||||
|
feesAtStartDate = fees; |
||||
|
feesAtStartDateWithCurrencyEffect = feesWithCurrencyEffect; |
||||
|
grossPerformanceAtStartDate = grossPerformance; |
||||
|
|
||||
|
grossPerformanceAtStartDateWithCurrencyEffect = |
||||
|
grossPerformanceWithCurrencyEffect; |
||||
|
} |
||||
|
|
||||
|
if (i > indexOfStartOrder && ['BUY', 'SELL'].includes(order.type)) { |
||||
|
// Only consider periods with an investment for the calculation of
|
||||
|
// the time weighted investment
|
||||
|
if (valueOfInvestmentBeforeTransaction.gt(0)) { |
||||
|
// Calculate the number of days since the previous order
|
||||
|
const orderDate = new Date(order.date); |
||||
|
const previousOrderDate = new Date(orders[i - 1].date); |
||||
|
|
||||
|
let daysSinceLastOrder = differenceInDays( |
||||
|
orderDate, |
||||
|
previousOrderDate |
||||
|
); |
||||
|
if (daysSinceLastOrder <= 0) { |
||||
|
// The time between two activities on the same day is unknown
|
||||
|
// -> Set it to the smallest floating point number greater than 0
|
||||
|
daysSinceLastOrder = Number.EPSILON; |
||||
|
} |
||||
|
|
||||
|
// Sum up the total investment days since the start date to calculate
|
||||
|
// the time weighted investment
|
||||
|
totalInvestmentDays += daysSinceLastOrder; |
||||
|
|
||||
|
sumOfTimeWeightedInvestments = sumOfTimeWeightedInvestments.add( |
||||
|
valueAtStartDate |
||||
|
.minus(investmentAtStartDate) |
||||
|
.plus(totalInvestmentBeforeTransaction) |
||||
|
.mul(daysSinceLastOrder) |
||||
|
); |
||||
|
|
||||
|
sumOfTimeWeightedInvestmentsWithCurrencyEffect = |
||||
|
sumOfTimeWeightedInvestmentsWithCurrencyEffect.add( |
||||
|
valueAtStartDateWithCurrencyEffect |
||||
|
.minus(investmentAtStartDateWithCurrencyEffect) |
||||
|
.plus(totalInvestmentBeforeTransactionWithCurrencyEffect) |
||||
|
.mul(daysSinceLastOrder) |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
if (isChartMode) { |
||||
|
currentValues[order.date] = valueOfInvestment; |
||||
|
|
||||
|
currentValuesWithCurrencyEffect[order.date] = |
||||
|
valueOfInvestmentWithCurrencyEffect; |
||||
|
|
||||
|
netPerformanceValues[order.date] = grossPerformance |
||||
|
.minus(grossPerformanceAtStartDate) |
||||
|
.minus(fees.minus(feesAtStartDate)); |
||||
|
|
||||
|
netPerformanceValuesWithCurrencyEffect[order.date] = |
||||
|
grossPerformanceWithCurrencyEffect |
||||
|
.minus(grossPerformanceAtStartDateWithCurrencyEffect) |
||||
|
.minus( |
||||
|
feesWithCurrencyEffect.minus(feesAtStartDateWithCurrencyEffect) |
||||
|
); |
||||
|
|
||||
|
investmentValuesAccumulated[order.date] = totalInvestment; |
||||
|
|
||||
|
investmentValuesAccumulatedWithCurrencyEffect[order.date] = |
||||
|
totalInvestmentWithCurrencyEffect; |
||||
|
|
||||
|
investmentValuesWithCurrencyEffect[order.date] = ( |
||||
|
investmentValuesWithCurrencyEffect[order.date] ?? new Big(0) |
||||
|
).add(transactionInvestmentWithCurrencyEffect); |
||||
|
|
||||
|
timeWeightedInvestmentValues[order.date] = |
||||
|
totalInvestmentDays > 0 |
||||
|
? sumOfTimeWeightedInvestments.div(totalInvestmentDays) |
||||
|
: new Big(0); |
||||
|
|
||||
|
timeWeightedInvestmentValuesWithCurrencyEffect[order.date] = |
||||
|
totalInvestmentDays > 0 |
||||
|
? sumOfTimeWeightedInvestmentsWithCurrencyEffect.div( |
||||
|
totalInvestmentDays |
||||
|
) |
||||
|
: new Big(0); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
if (PortfolioCalculator.ENABLE_LOGGING) { |
||||
|
console.log('totalInvestment', totalInvestment.toNumber()); |
||||
|
|
||||
|
console.log( |
||||
|
'totalInvestmentWithCurrencyEffect', |
||||
|
totalInvestmentWithCurrencyEffect.toNumber() |
||||
|
); |
||||
|
|
||||
|
console.log( |
||||
|
'totalGrossPerformance', |
||||
|
grossPerformance.minus(grossPerformanceAtStartDate).toNumber() |
||||
|
); |
||||
|
|
||||
|
console.log( |
||||
|
'totalGrossPerformanceWithCurrencyEffect', |
||||
|
grossPerformanceWithCurrencyEffect |
||||
|
.minus(grossPerformanceAtStartDateWithCurrencyEffect) |
||||
|
.toNumber() |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
if (i === indexOfEndOrder) { |
||||
|
break; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
const totalGrossPerformance = grossPerformance.minus( |
||||
|
grossPerformanceAtStartDate |
||||
|
); |
||||
|
|
||||
|
const totalGrossPerformanceWithCurrencyEffect = |
||||
|
grossPerformanceWithCurrencyEffect.minus( |
||||
|
grossPerformanceAtStartDateWithCurrencyEffect |
||||
|
); |
||||
|
|
||||
|
const totalNetPerformance = grossPerformance |
||||
|
.minus(grossPerformanceAtStartDate) |
||||
|
.minus(fees.minus(feesAtStartDate)); |
||||
|
|
||||
|
const totalNetPerformanceWithCurrencyEffect = |
||||
|
grossPerformanceWithCurrencyEffect |
||||
|
.minus(grossPerformanceAtStartDateWithCurrencyEffect) |
||||
|
.minus(feesWithCurrencyEffect.minus(feesAtStartDateWithCurrencyEffect)); |
||||
|
|
||||
|
const timeWeightedAverageInvestmentBetweenStartAndEndDate = |
||||
|
totalInvestmentDays > 0 |
||||
|
? sumOfTimeWeightedInvestments.div(totalInvestmentDays) |
||||
|
: new Big(0); |
||||
|
|
||||
|
const timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect = |
||||
|
totalInvestmentDays > 0 |
||||
|
? sumOfTimeWeightedInvestmentsWithCurrencyEffect.div( |
||||
|
totalInvestmentDays |
||||
|
) |
||||
|
: new Big(0); |
||||
|
|
||||
|
const grossPerformancePercentage = |
||||
|
timeWeightedAverageInvestmentBetweenStartAndEndDate.gt(0) |
||||
|
? totalGrossPerformance.div( |
||||
|
timeWeightedAverageInvestmentBetweenStartAndEndDate |
||||
|
) |
||||
|
: new Big(0); |
||||
|
|
||||
|
const grossPerformancePercentageWithCurrencyEffect = |
||||
|
timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect.gt( |
||||
|
0 |
||||
|
) |
||||
|
? totalGrossPerformanceWithCurrencyEffect.div( |
||||
|
timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect |
||||
|
) |
||||
|
: new Big(0); |
||||
|
|
||||
|
const feesPerUnit = totalUnits.gt(0) |
||||
|
? fees.minus(feesAtStartDate).div(totalUnits) |
||||
|
: new Big(0); |
||||
|
|
||||
|
const feesPerUnitWithCurrencyEffect = totalUnits.gt(0) |
||||
|
? feesWithCurrencyEffect |
||||
|
.minus(feesAtStartDateWithCurrencyEffect) |
||||
|
.div(totalUnits) |
||||
|
: new Big(0); |
||||
|
|
||||
|
const netPerformancePercentage = |
||||
|
timeWeightedAverageInvestmentBetweenStartAndEndDate.gt(0) |
||||
|
? totalNetPerformance.div( |
||||
|
timeWeightedAverageInvestmentBetweenStartAndEndDate |
||||
|
) |
||||
|
: new Big(0); |
||||
|
|
||||
|
const netPerformancePercentageWithCurrencyEffect = |
||||
|
timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect.gt( |
||||
|
0 |
||||
|
) |
||||
|
? totalNetPerformanceWithCurrencyEffect.div( |
||||
|
timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect |
||||
|
) |
||||
|
: new Big(0); |
||||
|
|
||||
|
if (PortfolioCalculator.ENABLE_LOGGING) { |
||||
|
console.log( |
||||
|
` |
||||
|
${symbol} |
||||
|
Unit price: ${orders[indexOfStartOrder].unitPrice.toFixed( |
||||
|
2 |
||||
|
)} -> ${unitPriceAtEndDate.toFixed(2)} |
||||
|
Total investment: ${totalInvestment.toFixed(2)} |
||||
|
Total investment with currency effect: ${totalInvestmentWithCurrencyEffect.toFixed( |
||||
|
2 |
||||
|
)} |
||||
|
Time weighted investment: ${timeWeightedAverageInvestmentBetweenStartAndEndDate.toFixed( |
||||
|
2 |
||||
|
)} |
||||
|
Time weighted investment with currency effect: ${timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect.toFixed( |
||||
|
2 |
||||
|
)} |
||||
|
Total dividend: ${totalDividend.toFixed(2)} |
||||
|
Gross performance: ${totalGrossPerformance.toFixed( |
||||
|
2 |
||||
|
)} / ${grossPerformancePercentage.mul(100).toFixed(2)}% |
||||
|
Gross performance with currency effect: ${totalGrossPerformanceWithCurrencyEffect.toFixed( |
||||
|
2 |
||||
|
)} / ${grossPerformancePercentageWithCurrencyEffect |
||||
|
.mul(100) |
||||
|
.toFixed(2)}% |
||||
|
Fees per unit: ${feesPerUnit.toFixed(2)} |
||||
|
Fees per unit with currency effect: ${feesPerUnitWithCurrencyEffect.toFixed( |
||||
|
2 |
||||
|
)} |
||||
|
Net performance: ${totalNetPerformance.toFixed( |
||||
|
2 |
||||
|
)} / ${netPerformancePercentage.mul(100).toFixed(2)}% |
||||
|
Net performance with currency effect: ${totalNetPerformanceWithCurrencyEffect.toFixed( |
||||
|
2 |
||||
|
)} / ${netPerformancePercentageWithCurrencyEffect.mul(100).toFixed(2)}%` |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
return { |
||||
|
currentValues, |
||||
|
currentValuesWithCurrencyEffect, |
||||
|
feesWithCurrencyEffect, |
||||
|
grossPerformancePercentage, |
||||
|
grossPerformancePercentageWithCurrencyEffect, |
||||
|
initialValue, |
||||
|
initialValueWithCurrencyEffect, |
||||
|
investmentValuesAccumulated, |
||||
|
investmentValuesAccumulatedWithCurrencyEffect, |
||||
|
investmentValuesWithCurrencyEffect, |
||||
|
netPerformancePercentage, |
||||
|
netPerformancePercentageWithCurrencyEffect, |
||||
|
netPerformanceValues, |
||||
|
netPerformanceValuesWithCurrencyEffect, |
||||
|
timeWeightedInvestmentValues, |
||||
|
timeWeightedInvestmentValuesWithCurrencyEffect, |
||||
|
totalDividend, |
||||
|
totalDividendInBaseCurrency, |
||||
|
totalInterest, |
||||
|
totalInterestInBaseCurrency, |
||||
|
totalInvestment, |
||||
|
totalInvestmentWithCurrencyEffect, |
||||
|
totalLiabilities, |
||||
|
totalLiabilitiesInBaseCurrency, |
||||
|
totalValuables, |
||||
|
totalValuablesInBaseCurrency, |
||||
|
grossPerformance: totalGrossPerformance, |
||||
|
grossPerformanceWithCurrencyEffect: |
||||
|
totalGrossPerformanceWithCurrencyEffect, |
||||
|
hasErrors: totalUnits.gt(0) && (!initialValue || !unitPriceAtEndDate), |
||||
|
netPerformance: totalNetPerformance, |
||||
|
netPerformanceWithCurrencyEffect: totalNetPerformanceWithCurrencyEffect, |
||||
|
timeWeightedInvestment: |
||||
|
timeWeightedAverageInvestmentBetweenStartAndEndDate, |
||||
|
timeWeightedInvestmentWithCurrencyEffect: |
||||
|
timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect |
||||
|
}; |
||||
|
} |
||||
|
} |
@ -1,11 +1,11 @@ |
|||||
import Big from 'big.js'; |
import { Big } from 'big.js'; |
||||
|
|
||||
import { PortfolioOrder } from './portfolio-order.interface'; |
import { PortfolioOrder } from './portfolio-order.interface'; |
||||
|
|
||||
export interface PortfolioOrderItem extends PortfolioOrder { |
export interface PortfolioOrderItem extends PortfolioOrder { |
||||
feeInBaseCurrency?: Big; |
feeInBaseCurrency?: Big; |
||||
feeInBaseCurrencyWithCurrencyEffect?: Big; |
feeInBaseCurrencyWithCurrencyEffect?: Big; |
||||
itemType?: '' | 'start' | 'end'; |
itemType?: 'end' | 'start'; |
||||
unitPriceInBaseCurrency?: Big; |
unitPriceInBaseCurrency?: Big; |
||||
unitPriceInBaseCurrencyWithCurrencyEffect?: Big; |
unitPriceInBaseCurrencyWithCurrencyEffect?: Big; |
||||
} |
} |
@ -1,15 +1,12 @@ |
|||||
import { DataSource, Tag, Type as TypeOfOrder } from '@prisma/client'; |
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; |
||||
import Big from 'big.js'; |
|
||||
|
|
||||
export interface PortfolioOrder { |
export interface PortfolioOrder extends Pick<Activity, 'tags' | 'type'> { |
||||
currency: string; |
|
||||
date: string; |
date: string; |
||||
dataSource: DataSource; |
|
||||
fee: Big; |
fee: Big; |
||||
name: string; |
|
||||
quantity: Big; |
quantity: Big; |
||||
symbol: string; |
SymbolProfile: Pick< |
||||
tags?: Tag[]; |
Activity['SymbolProfile'], |
||||
type: TypeOfOrder; |
'currency' | 'dataSource' | 'name' | 'symbol' |
||||
|
>; |
||||
unitPrice: Big; |
unitPrice: Big; |
||||
} |
} |
||||
|
@ -1,6 +1,12 @@ |
|||||
|
import { Big } from 'big.js'; |
||||
|
|
||||
import { TransactionPointSymbol } from './transaction-point-symbol.interface'; |
import { TransactionPointSymbol } from './transaction-point-symbol.interface'; |
||||
|
|
||||
export interface TransactionPoint { |
export interface TransactionPoint { |
||||
date: string; |
date: string; |
||||
|
fees: Big; |
||||
|
interest: Big; |
||||
items: TransactionPointSymbol[]; |
items: TransactionPointSymbol[]; |
||||
|
liabilities: Big; |
||||
|
valuables: Big; |
||||
} |
} |
||||
|
File diff suppressed because it is too large
File diff suppressed because it is too large
@ -1,4 +1,4 @@ |
|||||
export const environment = { |
export const environment = { |
||||
production: true, |
production: true, |
||||
version: `v${require('../../../../package.json').version}` |
version: `${require('../../../../package.json').version}` |
||||
}; |
}; |
||||
|
@ -0,0 +1,90 @@ |
|||||
|
import { resetHours } from '@ghostfolio/common/helper'; |
||||
|
import { DateRange } from '@ghostfolio/common/types'; |
||||
|
|
||||
|
import { Type as ActivityType } from '@prisma/client'; |
||||
|
import { |
||||
|
endOfDay, |
||||
|
max, |
||||
|
subDays, |
||||
|
startOfMonth, |
||||
|
startOfWeek, |
||||
|
startOfYear, |
||||
|
subYears, |
||||
|
endOfYear |
||||
|
} from 'date-fns'; |
||||
|
|
||||
|
export function getFactor(activityType: ActivityType) { |
||||
|
let factor: number; |
||||
|
|
||||
|
switch (activityType) { |
||||
|
case 'BUY': |
||||
|
factor = 1; |
||||
|
break; |
||||
|
case 'SELL': |
||||
|
factor = -1; |
||||
|
break; |
||||
|
default: |
||||
|
factor = 0; |
||||
|
break; |
||||
|
} |
||||
|
|
||||
|
return factor; |
||||
|
} |
||||
|
|
||||
|
export function getInterval( |
||||
|
aDateRange: DateRange, |
||||
|
portfolioStart = new Date(0) |
||||
|
) { |
||||
|
let endDate = endOfDay(new Date(Date.now())); |
||||
|
let startDate = portfolioStart; |
||||
|
|
||||
|
switch (aDateRange) { |
||||
|
case '1d': |
||||
|
startDate = max([ |
||||
|
startDate, |
||||
|
subDays(resetHours(new Date(Date.now())), 1) |
||||
|
]); |
||||
|
break; |
||||
|
case 'mtd': |
||||
|
startDate = max([ |
||||
|
startDate, |
||||
|
subDays(startOfMonth(resetHours(new Date(Date.now()))), 1) |
||||
|
]); |
||||
|
break; |
||||
|
case 'wtd': |
||||
|
startDate = max([ |
||||
|
startDate, |
||||
|
subDays( |
||||
|
startOfWeek(resetHours(new Date(Date.now())), { weekStartsOn: 1 }), |
||||
|
1 |
||||
|
) |
||||
|
]); |
||||
|
break; |
||||
|
case 'ytd': |
||||
|
startDate = max([ |
||||
|
startDate, |
||||
|
subDays(startOfYear(resetHours(new Date(Date.now()))), 1) |
||||
|
]); |
||||
|
break; |
||||
|
case '1y': |
||||
|
startDate = max([ |
||||
|
startDate, |
||||
|
subYears(resetHours(new Date(Date.now())), 1) |
||||
|
]); |
||||
|
break; |
||||
|
case '5y': |
||||
|
startDate = max([ |
||||
|
startDate, |
||||
|
subYears(resetHours(new Date(Date.now())), 5) |
||||
|
]); |
||||
|
break; |
||||
|
case 'max': |
||||
|
break; |
||||
|
default: |
||||
|
// '2024', '2023', '2022', etc.
|
||||
|
endDate = endOfYear(new Date(aDateRange)); |
||||
|
startDate = max([startDate, new Date(aDateRange)]); |
||||
|
} |
||||
|
|
||||
|
return { endDate, startDate }; |
||||
|
} |
@ -1,5 +1,3 @@ |
|||||
@import 'apps/client/src/styles/ghostfolio-style'; |
|
||||
|
|
||||
:host { |
:host { |
||||
display: block; |
display: block; |
||||
} |
} |
||||
|
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue