mirror of https://github.com/ghostfolio/ghostfolio
committed by
GitHub
246 changed files with 24434 additions and 6208 deletions
@ -0,0 +1,25 @@ |
|||
COMPOSE_PROJECT_NAME=ghostfolio-development |
|||
|
|||
# CACHE |
|||
REDIS_HOST=localhost |
|||
REDIS_PORT=6379 |
|||
REDIS_PASSWORD=<INSERT_REDIS_PASSWORD> |
|||
|
|||
# POSTGRES |
|||
POSTGRES_DB=ghostfolio-db |
|||
POSTGRES_USER=user |
|||
POSTGRES_PASSWORD=<INSERT_POSTGRES_PASSWORD> |
|||
|
|||
# VARIOUS |
|||
ACCESS_TOKEN_SALT=<INSERT_RANDOM_STRING> |
|||
DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?connect_timeout=300&sslmode=prefer |
|||
JWT_SECRET_KEY=<INSERT_RANDOM_STRING> |
|||
|
|||
# DEVELOPMENT |
|||
|
|||
# Nx 18 enables using plugins to infer targets by default |
|||
# This is disabled for existing workspaces to maintain compatibility |
|||
# For more info, see: https://nx.dev/concepts/inferred-tasks |
|||
NX_ADD_PLUGINS=false |
|||
|
|||
NX_NATIVE_COMMAND_RUNNER=false |
@ -1,9 +1,12 @@ |
|||
import { IsString } from 'class-validator'; |
|||
import { IsString, IsUrl } from 'class-validator'; |
|||
|
|||
export class CreatePlatformDto { |
|||
@IsString() |
|||
name: string; |
|||
|
|||
@IsString() |
|||
@IsUrl({ |
|||
protocols: ['https'], |
|||
require_protocol: true |
|||
}) |
|||
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'; |
|||
|
|||
export interface PortfolioOrderItem extends PortfolioOrder { |
|||
feeInBaseCurrency?: Big; |
|||
feeInBaseCurrencyWithCurrencyEffect?: Big; |
|||
itemType?: '' | 'start' | 'end'; |
|||
itemType?: 'end' | 'start'; |
|||
unitPriceInBaseCurrency?: Big; |
|||
unitPriceInBaseCurrencyWithCurrencyEffect?: Big; |
|||
} |
@ -1,15 +1,12 @@ |
|||
import { DataSource, Tag, Type as TypeOfOrder } from '@prisma/client'; |
|||
import Big from 'big.js'; |
|||
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; |
|||
|
|||
export interface PortfolioOrder { |
|||
currency: string; |
|||
export interface PortfolioOrder extends Pick<Activity, 'tags' | 'type'> { |
|||
date: string; |
|||
dataSource: DataSource; |
|||
fee: Big; |
|||
name: string; |
|||
quantity: Big; |
|||
symbol: string; |
|||
tags?: Tag[]; |
|||
type: TypeOfOrder; |
|||
SymbolProfile: Pick< |
|||
Activity['SymbolProfile'], |
|||
'currency' | 'dataSource' | 'name' | 'symbol' |
|||
>; |
|||
unitPrice: Big; |
|||
} |
|||
|
@ -1,6 +1,12 @@ |
|||
import { Big } from 'big.js'; |
|||
|
|||
import { TransactionPointSymbol } from './transaction-point-symbol.interface'; |
|||
|
|||
export interface TransactionPoint { |
|||
date: string; |
|||
fees: Big; |
|||
interest: Big; |
|||
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 = { |
|||
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 }; |
|||
} |
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue