mirror of https://github.com/ghostfolio/ghostfolio
701 changed files with 100581 additions and 133572 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 +1 @@ |
|||||
v18 |
v20 |
||||
|
@ -1,4 +1,5 @@ |
|||||
/.nx/cache |
/.nx/cache |
||||
|
/.nx/workspace-data |
||||
/apps/client/src/polyfills.ts |
/apps/client/src/polyfills.ts |
||||
/dist |
/dist |
||||
/test/import |
/test/import |
||||
|
@ -1 +0,0 @@ |
|||||
network-timeout 600000 |
|
@ -0,0 +1,12 @@ |
|||||
|
import { IsISO8601, IsNumber, IsUUID } from 'class-validator'; |
||||
|
|
||||
|
export class CreateAccountBalanceDto { |
||||
|
@IsUUID() |
||||
|
accountId: string; |
||||
|
|
||||
|
@IsNumber() |
||||
|
balance: number; |
||||
|
|
||||
|
@IsISO8601() |
||||
|
date: string; |
||||
|
} |
@ -0,0 +1,29 @@ |
|||||
|
import { AdminService } from '@ghostfolio/api/app/admin/admin.service'; |
||||
|
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor'; |
||||
|
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor'; |
||||
|
import type { AdminMarketDataDetails } from '@ghostfolio/common/interfaces'; |
||||
|
|
||||
|
import { Controller, Get, Param, UseInterceptors } from '@nestjs/common'; |
||||
|
import { DataSource } from '@prisma/client'; |
||||
|
import { pick } from 'lodash'; |
||||
|
|
||||
|
@Controller('asset') |
||||
|
export class AssetController { |
||||
|
public constructor(private readonly adminService: AdminService) {} |
||||
|
|
||||
|
@Get(':dataSource/:symbol') |
||||
|
@UseInterceptors(TransformDataSourceInRequestInterceptor) |
||||
|
@UseInterceptors(TransformDataSourceInResponseInterceptor) |
||||
|
public async getAsset( |
||||
|
@Param('dataSource') dataSource: DataSource, |
||||
|
@Param('symbol') symbol: string |
||||
|
): Promise<AdminMarketDataDetails> { |
||||
|
const { assetProfile, marketData } = |
||||
|
await this.adminService.getMarketDataBySymbol({ dataSource, symbol }); |
||||
|
|
||||
|
return { |
||||
|
marketData, |
||||
|
assetProfile: pick(assetProfile, ['dataSource', 'name', 'symbol']) |
||||
|
}; |
||||
|
} |
||||
|
} |
@ -0,0 +1,17 @@ |
|||||
|
import { AdminModule } from '@ghostfolio/api/app/admin/admin.module'; |
||||
|
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module'; |
||||
|
import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module'; |
||||
|
|
||||
|
import { Module } from '@nestjs/common'; |
||||
|
|
||||
|
import { AssetController } from './asset.controller'; |
||||
|
|
||||
|
@Module({ |
||||
|
controllers: [AssetController], |
||||
|
imports: [ |
||||
|
AdminModule, |
||||
|
TransformDataSourceInRequestModule, |
||||
|
TransformDataSourceInResponseModule |
||||
|
] |
||||
|
}) |
||||
|
export class AssetModule {} |
@ -0,0 +1,6 @@ |
|||||
|
import { BenchmarkResponse } from '@ghostfolio/common/interfaces'; |
||||
|
|
||||
|
export interface BenchmarkValue { |
||||
|
benchmarks: BenchmarkResponse['benchmarks']; |
||||
|
expiration: number; |
||||
|
} |
@ -1,9 +1,12 @@ |
|||||
import { IsString } from 'class-validator'; |
import { IsString, IsUrl } from 'class-validator'; |
||||
|
|
||||
export class CreatePlatformDto { |
export class CreatePlatformDto { |
||||
@IsString() |
@IsString() |
||||
name: string; |
name: string; |
||||
|
|
||||
@IsString() |
@IsUrl({ |
||||
|
protocols: ['https'], |
||||
|
require_protocol: true |
||||
|
}) |
||||
url: string; |
url: string; |
||||
} |
} |
||||
|
@ -0,0 +1,34 @@ |
|||||
|
import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator'; |
||||
|
import { |
||||
|
AssetProfileIdentifier, |
||||
|
SymbolMetrics |
||||
|
} from '@ghostfolio/common/interfaces'; |
||||
|
import { PortfolioSnapshot, TimelinePosition } from '@ghostfolio/common/models'; |
||||
|
|
||||
|
export class MWRPortfolioCalculator extends PortfolioCalculator { |
||||
|
protected calculateOverallPerformance( |
||||
|
positions: TimelinePosition[] |
||||
|
): PortfolioSnapshot { |
||||
|
throw new Error('Method not implemented.'); |
||||
|
} |
||||
|
|
||||
|
protected getSymbolMetrics({ |
||||
|
dataSource, |
||||
|
end, |
||||
|
exchangeRates, |
||||
|
marketSymbolMap, |
||||
|
start, |
||||
|
step = 1, |
||||
|
symbol |
||||
|
}: { |
||||
|
end: Date; |
||||
|
exchangeRates: { [dateString: string]: number }; |
||||
|
marketSymbolMap: { |
||||
|
[date: string]: { [symbol: string]: Big }; |
||||
|
}; |
||||
|
start: Date; |
||||
|
step?: number; |
||||
|
} & AssetProfileIdentifier): SymbolMetrics { |
||||
|
throw new Error('Method not implemented.'); |
||||
|
} |
||||
|
} |
@ -0,0 +1,31 @@ |
|||||
|
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, |
||||
|
holdings: [], |
||||
|
id: undefined, |
||||
|
sectors: [], |
||||
|
updatedAt: undefined |
||||
|
}; |
||||
|
|
||||
|
export const userDummyData = { |
||||
|
id: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx' |
||||
|
}; |
@ -0,0 +1,72 @@ |
|||||
|
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; |
||||
|
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; |
||||
|
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; |
||||
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; |
||||
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; |
||||
|
import { Filter, HistoricalDataItem } from '@ghostfolio/common/interfaces'; |
||||
|
|
||||
|
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 configurationService: ConfigurationService, |
||||
|
private readonly currentRateService: CurrentRateService, |
||||
|
private readonly exchangeRateDataService: ExchangeRateDataService, |
||||
|
private readonly redisCacheService: RedisCacheService |
||||
|
) {} |
||||
|
|
||||
|
public createCalculator({ |
||||
|
accountBalanceItems = [], |
||||
|
activities, |
||||
|
calculationType, |
||||
|
currency, |
||||
|
filters = [], |
||||
|
userId |
||||
|
}: { |
||||
|
accountBalanceItems?: HistoricalDataItem[]; |
||||
|
activities: Activity[]; |
||||
|
calculationType: PerformanceCalculationType; |
||||
|
currency: string; |
||||
|
filters?: Filter[]; |
||||
|
userId: string; |
||||
|
}): PortfolioCalculator { |
||||
|
switch (calculationType) { |
||||
|
case PerformanceCalculationType.MWR: |
||||
|
return new MWRPortfolioCalculator({ |
||||
|
accountBalanceItems, |
||||
|
activities, |
||||
|
currency, |
||||
|
filters, |
||||
|
userId, |
||||
|
configurationService: this.configurationService, |
||||
|
currentRateService: this.currentRateService, |
||||
|
exchangeRateDataService: this.exchangeRateDataService, |
||||
|
redisCacheService: this.redisCacheService |
||||
|
}); |
||||
|
case PerformanceCalculationType.TWR: |
||||
|
return new TWRPortfolioCalculator({ |
||||
|
accountBalanceItems, |
||||
|
activities, |
||||
|
currency, |
||||
|
currentRateService: this.currentRateService, |
||||
|
filters, |
||||
|
userId, |
||||
|
configurationService: this.configurationService, |
||||
|
exchangeRateDataService: this.exchangeRateDataService, |
||||
|
redisCacheService: this.redisCacheService |
||||
|
}); |
||||
|
default: |
||||
|
throw new Error('Invalid calculation type'); |
||||
|
} |
||||
|
} |
||||
|
} |
File diff suppressed because it is too large
@ -0,0 +1,207 @@ |
|||||
|
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; |
||||
|
import { |
||||
|
activityDummyData, |
||||
|
symbolProfileDummyData, |
||||
|
userDummyData |
||||
|
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; |
||||
|
import { |
||||
|
PortfolioCalculatorFactory, |
||||
|
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 { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; |
||||
|
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; |
||||
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; |
||||
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; |
||||
|
import { parseDate } from '@ghostfolio/common/helper'; |
||||
|
|
||||
|
import { Big } from 'big.js'; |
||||
|
import { last } from 'lodash'; |
||||
|
|
||||
|
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/app/redis-cache/redis-cache.service', () => { |
||||
|
return { |
||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
|
RedisCacheService: jest.fn().mockImplementation(() => { |
||||
|
return RedisCacheServiceMock; |
||||
|
}) |
||||
|
}; |
||||
|
}); |
||||
|
|
||||
|
describe('PortfolioCalculator', () => { |
||||
|
let configurationService: ConfigurationService; |
||||
|
let currentRateService: CurrentRateService; |
||||
|
let exchangeRateDataService: ExchangeRateDataService; |
||||
|
let factory: PortfolioCalculatorFactory; |
||||
|
let redisCacheService: RedisCacheService; |
||||
|
|
||||
|
beforeEach(() => { |
||||
|
configurationService = new ConfigurationService(); |
||||
|
|
||||
|
currentRateService = new CurrentRateService(null, null, null, null); |
||||
|
|
||||
|
exchangeRateDataService = new ExchangeRateDataService( |
||||
|
null, |
||||
|
null, |
||||
|
null, |
||||
|
null |
||||
|
); |
||||
|
|
||||
|
redisCacheService = new RedisCacheService(null, null); |
||||
|
|
||||
|
factory = new PortfolioCalculatorFactory( |
||||
|
configurationService, |
||||
|
currentRateService, |
||||
|
exchangeRateDataService, |
||||
|
redisCacheService |
||||
|
); |
||||
|
}); |
||||
|
|
||||
|
describe('get current positions', () => { |
||||
|
it.only('with BALN.SW buy and sell in two activities', async () => { |
||||
|
jest.useFakeTimers().setSystemTime(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', |
||||
|
userId: userDummyData.id |
||||
|
}); |
||||
|
|
||||
|
const portfolioSnapshot = await portfolioCalculator.getSnapshot(); |
||||
|
|
||||
|
const investments = portfolioCalculator.getInvestments(); |
||||
|
|
||||
|
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({ |
||||
|
data: portfolioSnapshot.historicalData, |
||||
|
groupBy: 'month' |
||||
|
}); |
||||
|
|
||||
|
expect(portfolioSnapshot).toMatchObject({ |
||||
|
currentValueInBaseCurrency: new Big('0'), |
||||
|
errors: [], |
||||
|
hasErrors: false, |
||||
|
positions: [ |
||||
|
{ |
||||
|
averagePrice: new Big('0'), |
||||
|
currency: 'CHF', |
||||
|
dataSource: 'YAHOO', |
||||
|
dividend: new Big('0'), |
||||
|
dividendInBaseCurrency: new Big('0'), |
||||
|
fee: new Big('3.2'), |
||||
|
feeInBaseCurrency: 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'), |
||||
|
netPerformancePercentageWithCurrencyEffectMap: { |
||||
|
max: new Big('-0.0552834149755073478') |
||||
|
}, |
||||
|
netPerformanceWithCurrencyEffectMap: { |
||||
|
max: 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(last(portfolioSnapshot.historicalData)).toMatchObject( |
||||
|
expect.objectContaining({ |
||||
|
netPerformance: -15.8, |
||||
|
netPerformanceInPercentage: -0.05528341497550734703, |
||||
|
netPerformanceInPercentageWithCurrencyEffect: -0.05528341497550734703, |
||||
|
netPerformanceWithCurrencyEffect: -15.8, |
||||
|
totalInvestmentValueWithCurrencyEffect: 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,192 @@ |
|||||
|
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; |
||||
|
import { |
||||
|
activityDummyData, |
||||
|
symbolProfileDummyData, |
||||
|
userDummyData |
||||
|
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; |
||||
|
import { |
||||
|
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 { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; |
||||
|
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; |
||||
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; |
||||
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; |
||||
|
import { parseDate } from '@ghostfolio/common/helper'; |
||||
|
|
||||
|
import { Big } from 'big.js'; |
||||
|
import { last } from 'lodash'; |
||||
|
|
||||
|
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/app/redis-cache/redis-cache.service', () => { |
||||
|
return { |
||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
|
RedisCacheService: jest.fn().mockImplementation(() => { |
||||
|
return RedisCacheServiceMock; |
||||
|
}) |
||||
|
}; |
||||
|
}); |
||||
|
|
||||
|
describe('PortfolioCalculator', () => { |
||||
|
let configurationService: ConfigurationService; |
||||
|
let currentRateService: CurrentRateService; |
||||
|
let exchangeRateDataService: ExchangeRateDataService; |
||||
|
let factory: PortfolioCalculatorFactory; |
||||
|
let redisCacheService: RedisCacheService; |
||||
|
|
||||
|
beforeEach(() => { |
||||
|
configurationService = new ConfigurationService(); |
||||
|
|
||||
|
currentRateService = new CurrentRateService(null, null, null, null); |
||||
|
|
||||
|
exchangeRateDataService = new ExchangeRateDataService( |
||||
|
null, |
||||
|
null, |
||||
|
null, |
||||
|
null |
||||
|
); |
||||
|
|
||||
|
redisCacheService = new RedisCacheService(null, null); |
||||
|
|
||||
|
factory = new PortfolioCalculatorFactory( |
||||
|
configurationService, |
||||
|
currentRateService, |
||||
|
exchangeRateDataService, |
||||
|
redisCacheService |
||||
|
); |
||||
|
}); |
||||
|
|
||||
|
describe('get current positions', () => { |
||||
|
it.only('with BALN.SW buy and sell', async () => { |
||||
|
jest.useFakeTimers().setSystemTime(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: 2, |
||||
|
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', |
||||
|
userId: userDummyData.id |
||||
|
}); |
||||
|
|
||||
|
const portfolioSnapshot = await portfolioCalculator.getSnapshot(); |
||||
|
|
||||
|
const investments = portfolioCalculator.getInvestments(); |
||||
|
|
||||
|
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({ |
||||
|
data: portfolioSnapshot.historicalData, |
||||
|
groupBy: 'month' |
||||
|
}); |
||||
|
|
||||
|
expect(portfolioSnapshot).toMatchObject({ |
||||
|
currentValueInBaseCurrency: new Big('0'), |
||||
|
errors: [], |
||||
|
hasErrors: false, |
||||
|
positions: [ |
||||
|
{ |
||||
|
averagePrice: new Big('0'), |
||||
|
currency: 'CHF', |
||||
|
dataSource: 'YAHOO', |
||||
|
dividend: new Big('0'), |
||||
|
dividendInBaseCurrency: new Big('0'), |
||||
|
fee: new Big('3.2'), |
||||
|
feeInBaseCurrency: new Big('3.2'), |
||||
|
firstBuyDate: '2021-11-22', |
||||
|
grossPerformance: new Big('-12.6'), |
||||
|
grossPerformancePercentage: new Big('-0.0440867739678096571'), |
||||
|
grossPerformancePercentageWithCurrencyEffect: new Big( |
||||
|
'-0.0440867739678096571' |
||||
|
), |
||||
|
grossPerformanceWithCurrencyEffect: new Big('-12.6'), |
||||
|
investment: new Big('0'), |
||||
|
investmentWithCurrencyEffect: new Big('0'), |
||||
|
netPerformance: new Big('-15.8'), |
||||
|
netPerformancePercentage: new Big('-0.0552834149755073478'), |
||||
|
netPerformancePercentageWithCurrencyEffectMap: { |
||||
|
max: new Big('-0.0552834149755073478') |
||||
|
}, |
||||
|
netPerformanceWithCurrencyEffectMap: { |
||||
|
max: new Big('-15.8') |
||||
|
}, |
||||
|
marketPrice: 148.9, |
||||
|
marketPriceInBaseCurrency: 148.9, |
||||
|
quantity: new Big('0'), |
||||
|
symbol: 'BALN.SW', |
||||
|
tags: [], |
||||
|
timeWeightedInvestment: new Big('285.8'), |
||||
|
timeWeightedInvestmentWithCurrencyEffect: new Big('285.8'), |
||||
|
transactionCount: 2, |
||||
|
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(last(portfolioSnapshot.historicalData)).toMatchObject( |
||||
|
expect.objectContaining({ |
||||
|
netPerformance: -15.8, |
||||
|
netPerformanceInPercentage: -0.05528341497550734703, |
||||
|
netPerformanceInPercentageWithCurrencyEffect: -0.05528341497550734703, |
||||
|
netPerformanceWithCurrencyEffect: -15.8, |
||||
|
totalInvestmentValueWithCurrencyEffect: 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,182 @@ |
|||||
|
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; |
||||
|
import { |
||||
|
activityDummyData, |
||||
|
symbolProfileDummyData, |
||||
|
userDummyData |
||||
|
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; |
||||
|
import { |
||||
|
PortfolioCalculatorFactory, |
||||
|
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 { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; |
||||
|
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; |
||||
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; |
||||
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; |
||||
|
import { parseDate } from '@ghostfolio/common/helper'; |
||||
|
|
||||
|
import { Big } from 'big.js'; |
||||
|
import { last } from 'lodash'; |
||||
|
|
||||
|
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/app/redis-cache/redis-cache.service', () => { |
||||
|
return { |
||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
|
RedisCacheService: jest.fn().mockImplementation(() => { |
||||
|
return RedisCacheServiceMock; |
||||
|
}) |
||||
|
}; |
||||
|
}); |
||||
|
|
||||
|
describe('PortfolioCalculator', () => { |
||||
|
let configurationService: ConfigurationService; |
||||
|
let currentRateService: CurrentRateService; |
||||
|
let exchangeRateDataService: ExchangeRateDataService; |
||||
|
let factory: PortfolioCalculatorFactory; |
||||
|
let redisCacheService: RedisCacheService; |
||||
|
|
||||
|
beforeEach(() => { |
||||
|
configurationService = new ConfigurationService(); |
||||
|
|
||||
|
currentRateService = new CurrentRateService(null, null, null, null); |
||||
|
|
||||
|
exchangeRateDataService = new ExchangeRateDataService( |
||||
|
null, |
||||
|
null, |
||||
|
null, |
||||
|
null |
||||
|
); |
||||
|
|
||||
|
redisCacheService = new RedisCacheService(null, null); |
||||
|
|
||||
|
factory = new PortfolioCalculatorFactory( |
||||
|
configurationService, |
||||
|
currentRateService, |
||||
|
exchangeRateDataService, |
||||
|
redisCacheService |
||||
|
); |
||||
|
}); |
||||
|
|
||||
|
describe('get current positions', () => { |
||||
|
it.only('with BALN.SW buy', async () => { |
||||
|
jest.useFakeTimers().setSystemTime(parseDate('2021-12-18').getTime()); |
||||
|
|
||||
|
const activities: Activity[] = [ |
||||
|
{ |
||||
|
...activityDummyData, |
||||
|
date: new Date('2021-11-30'), |
||||
|
fee: 1.55, |
||||
|
quantity: 2, |
||||
|
SymbolProfile: { |
||||
|
...symbolProfileDummyData, |
||||
|
currency: 'CHF', |
||||
|
dataSource: 'YAHOO', |
||||
|
name: 'Bâloise Holding AG', |
||||
|
symbol: 'BALN.SW' |
||||
|
}, |
||||
|
type: 'BUY', |
||||
|
unitPrice: 136.6 |
||||
|
} |
||||
|
]; |
||||
|
|
||||
|
const portfolioCalculator = factory.createCalculator({ |
||||
|
activities, |
||||
|
calculationType: PerformanceCalculationType.TWR, |
||||
|
currency: 'CHF', |
||||
|
userId: userDummyData.id |
||||
|
}); |
||||
|
|
||||
|
const portfolioSnapshot = await portfolioCalculator.getSnapshot(); |
||||
|
|
||||
|
const investments = portfolioCalculator.getInvestments(); |
||||
|
|
||||
|
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({ |
||||
|
data: portfolioSnapshot.historicalData, |
||||
|
groupBy: 'month' |
||||
|
}); |
||||
|
|
||||
|
expect(portfolioSnapshot).toMatchObject({ |
||||
|
currentValueInBaseCurrency: new Big('297.8'), |
||||
|
errors: [], |
||||
|
hasErrors: false, |
||||
|
positions: [ |
||||
|
{ |
||||
|
averagePrice: new Big('136.6'), |
||||
|
currency: 'CHF', |
||||
|
dataSource: 'YAHOO', |
||||
|
dividend: new Big('0'), |
||||
|
dividendInBaseCurrency: new Big('0'), |
||||
|
fee: new Big('1.55'), |
||||
|
feeInBaseCurrency: new Big('1.55'), |
||||
|
firstBuyDate: '2021-11-30', |
||||
|
grossPerformance: new Big('24.6'), |
||||
|
grossPerformancePercentage: new Big('0.09004392386530014641'), |
||||
|
grossPerformancePercentageWithCurrencyEffect: new Big( |
||||
|
'0.09004392386530014641' |
||||
|
), |
||||
|
grossPerformanceWithCurrencyEffect: new Big('24.6'), |
||||
|
investment: new Big('273.2'), |
||||
|
investmentWithCurrencyEffect: new Big('273.2'), |
||||
|
netPerformance: new Big('23.05'), |
||||
|
netPerformancePercentage: new Big('0.08437042459736456808'), |
||||
|
netPerformancePercentageWithCurrencyEffectMap: { |
||||
|
max: new Big('0.08437042459736456808') |
||||
|
}, |
||||
|
netPerformanceWithCurrencyEffectMap: { |
||||
|
'1d': new Big('10.00'), // 2 * (148.9 - 143.9) -> no fees in this time period
|
||||
|
'1y': new Big('23.05'), // 2 * (148.9 - 136.6) - 1.55
|
||||
|
'5y': new Big('23.05'), // 2 * (148.9 - 136.6) - 1.55
|
||||
|
max: new Big('23.05'), // 2 * (148.9 - 136.6) - 1.55
|
||||
|
mtd: new Big('24.60'), // 2 * (148.9 - 136.6) -> no fees in this time period
|
||||
|
wtd: new Big('13.80'), // 2 * (148.9 - 142.0) -> no fees in this time period
|
||||
|
ytd: new Big('23.05') // 2 * (148.9 - 136.6) - 1.55
|
||||
|
}, |
||||
|
marketPrice: 148.9, |
||||
|
marketPriceInBaseCurrency: 148.9, |
||||
|
quantity: new Big('2'), |
||||
|
symbol: 'BALN.SW', |
||||
|
tags: [], |
||||
|
timeWeightedInvestment: new Big('273.2'), |
||||
|
timeWeightedInvestmentWithCurrencyEffect: new Big('273.2'), |
||||
|
transactionCount: 1, |
||||
|
valueInBaseCurrency: new Big('297.8') |
||||
|
} |
||||
|
], |
||||
|
totalFeesWithCurrencyEffect: new Big('1.55'), |
||||
|
totalInterestWithCurrencyEffect: new Big('0'), |
||||
|
totalInvestment: new Big('273.2'), |
||||
|
totalInvestmentWithCurrencyEffect: new Big('273.2'), |
||||
|
totalLiabilitiesWithCurrencyEffect: new Big('0'), |
||||
|
totalValuablesWithCurrencyEffect: new Big('0') |
||||
|
}); |
||||
|
|
||||
|
expect(last(portfolioSnapshot.historicalData)).toMatchObject( |
||||
|
expect.objectContaining({ |
||||
|
netPerformance: 23.05, |
||||
|
netPerformanceInPercentage: 0.08437042459736457, |
||||
|
netPerformanceInPercentageWithCurrencyEffect: 0.08437042459736457, |
||||
|
netPerformanceWithCurrencyEffect: 23.05, |
||||
|
totalInvestmentValueWithCurrencyEffect: 273.2 |
||||
|
}) |
||||
|
); |
||||
|
|
||||
|
expect(investments).toEqual([ |
||||
|
{ date: '2021-11-30', investment: new Big('273.2') } |
||||
|
]); |
||||
|
|
||||
|
expect(investmentsByMonth).toEqual([ |
||||
|
{ date: '2021-11-01', investment: 273.2 }, |
||||
|
{ date: '2021-12-01', investment: 0 } |
||||
|
]); |
||||
|
}); |
||||
|
}); |
||||
|
}); |
@ -0,0 +1,154 @@ |
|||||
|
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; |
||||
|
import { |
||||
|
activityDummyData, |
||||
|
symbolProfileDummyData, |
||||
|
userDummyData |
||||
|
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; |
||||
|
import { |
||||
|
PortfolioCalculatorFactory, |
||||
|
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 { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; |
||||
|
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; |
||||
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; |
||||
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; |
||||
|
import { parseDate } from '@ghostfolio/common/helper'; |
||||
|
|
||||
|
import { Big } from 'big.js'; |
||||
|
import { last } from 'lodash'; |
||||
|
|
||||
|
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/app/redis-cache/redis-cache.service', () => { |
||||
|
return { |
||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
|
RedisCacheService: jest.fn().mockImplementation(() => { |
||||
|
return RedisCacheServiceMock; |
||||
|
}) |
||||
|
}; |
||||
|
}); |
||||
|
|
||||
|
describe('PortfolioCalculator', () => { |
||||
|
let configurationService: ConfigurationService; |
||||
|
let currentRateService: CurrentRateService; |
||||
|
let exchangeRateDataService: ExchangeRateDataService; |
||||
|
let factory: PortfolioCalculatorFactory; |
||||
|
let redisCacheService: RedisCacheService; |
||||
|
|
||||
|
beforeEach(() => { |
||||
|
configurationService = new ConfigurationService(); |
||||
|
|
||||
|
currentRateService = new CurrentRateService(null, null, null, null); |
||||
|
|
||||
|
exchangeRateDataService = new ExchangeRateDataService( |
||||
|
null, |
||||
|
null, |
||||
|
null, |
||||
|
null |
||||
|
); |
||||
|
|
||||
|
redisCacheService = new RedisCacheService(null, null); |
||||
|
|
||||
|
factory = new PortfolioCalculatorFactory( |
||||
|
configurationService, |
||||
|
currentRateService, |
||||
|
exchangeRateDataService, |
||||
|
redisCacheService |
||||
|
); |
||||
|
}); |
||||
|
|
||||
|
describe('compute portfolio snapshot', () => { |
||||
|
it.only('with fee activity', async () => { |
||||
|
jest.useFakeTimers().setSystemTime(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', |
||||
|
userId: userDummyData.id |
||||
|
}); |
||||
|
|
||||
|
const portfolioSnapshot = await portfolioCalculator.getSnapshot(); |
||||
|
|
||||
|
expect(portfolioSnapshot).toMatchObject({ |
||||
|
currentValueInBaseCurrency: new Big('0'), |
||||
|
errors: [], |
||||
|
hasErrors: true, |
||||
|
positions: [ |
||||
|
{ |
||||
|
averagePrice: new Big('0'), |
||||
|
currency: 'USD', |
||||
|
dataSource: 'MANUAL', |
||||
|
dividend: new Big('0'), |
||||
|
dividendInBaseCurrency: new Big('0'), |
||||
|
fee: new Big('49'), |
||||
|
feeInBaseCurrency: 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, |
||||
|
netPerformancePercentageWithCurrencyEffectMap: null, |
||||
|
netPerformanceWithCurrencyEffectMap: 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') |
||||
|
}); |
||||
|
|
||||
|
expect(last(portfolioSnapshot.historicalData)).toMatchObject( |
||||
|
expect.objectContaining({ |
||||
|
netPerformance: 0, |
||||
|
netPerformanceInPercentage: 0, |
||||
|
netPerformanceInPercentageWithCurrencyEffect: 0, |
||||
|
netPerformanceWithCurrencyEffect: 0, |
||||
|
totalInvestmentValueWithCurrencyEffect: 0 |
||||
|
}) |
||||
|
); |
||||
|
}); |
||||
|
}); |
||||
|
}); |
@ -0,0 +1,212 @@ |
|||||
|
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; |
||||
|
import { |
||||
|
activityDummyData, |
||||
|
symbolProfileDummyData, |
||||
|
userDummyData |
||||
|
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; |
||||
|
import { |
||||
|
PortfolioCalculatorFactory, |
||||
|
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 { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; |
||||
|
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; |
||||
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; |
||||
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; |
||||
|
import { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service.mock'; |
||||
|
import { parseDate } from '@ghostfolio/common/helper'; |
||||
|
|
||||
|
import { Big } from 'big.js'; |
||||
|
import { last } from 'lodash'; |
||||
|
|
||||
|
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/app/redis-cache/redis-cache.service', () => { |
||||
|
return { |
||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
|
RedisCacheService: jest.fn().mockImplementation(() => { |
||||
|
return RedisCacheServiceMock; |
||||
|
}) |
||||
|
}; |
||||
|
}); |
||||
|
|
||||
|
jest.mock( |
||||
|
'@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service', |
||||
|
() => { |
||||
|
return { |
||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
|
ExchangeRateDataService: jest.fn().mockImplementation(() => { |
||||
|
return ExchangeRateDataServiceMock; |
||||
|
}) |
||||
|
}; |
||||
|
} |
||||
|
); |
||||
|
|
||||
|
describe('PortfolioCalculator', () => { |
||||
|
let configurationService: ConfigurationService; |
||||
|
let currentRateService: CurrentRateService; |
||||
|
let exchangeRateDataService: ExchangeRateDataService; |
||||
|
let factory: PortfolioCalculatorFactory; |
||||
|
let redisCacheService: RedisCacheService; |
||||
|
|
||||
|
beforeEach(() => { |
||||
|
configurationService = new ConfigurationService(); |
||||
|
|
||||
|
currentRateService = new CurrentRateService(null, null, null, null); |
||||
|
|
||||
|
exchangeRateDataService = new ExchangeRateDataService( |
||||
|
null, |
||||
|
null, |
||||
|
null, |
||||
|
null |
||||
|
); |
||||
|
|
||||
|
redisCacheService = new RedisCacheService(null, null); |
||||
|
|
||||
|
factory = new PortfolioCalculatorFactory( |
||||
|
configurationService, |
||||
|
currentRateService, |
||||
|
exchangeRateDataService, |
||||
|
redisCacheService |
||||
|
); |
||||
|
}); |
||||
|
|
||||
|
describe('get current positions', () => { |
||||
|
it.only('with GOOGL buy', async () => { |
||||
|
jest.useFakeTimers().setSystemTime(parseDate('2023-07-10').getTime()); |
||||
|
|
||||
|
const activities: Activity[] = [ |
||||
|
{ |
||||
|
...activityDummyData, |
||||
|
date: new Date('2023-01-03'), |
||||
|
fee: 1, |
||||
|
quantity: 1, |
||||
|
SymbolProfile: { |
||||
|
...symbolProfileDummyData, |
||||
|
currency: 'USD', |
||||
|
dataSource: 'YAHOO', |
||||
|
name: 'Alphabet Inc.', |
||||
|
symbol: 'GOOGL' |
||||
|
}, |
||||
|
type: 'BUY', |
||||
|
unitPrice: 89.12 |
||||
|
} |
||||
|
]; |
||||
|
|
||||
|
const portfolioCalculator = factory.createCalculator({ |
||||
|
activities, |
||||
|
calculationType: PerformanceCalculationType.TWR, |
||||
|
currency: 'CHF', |
||||
|
userId: userDummyData.id |
||||
|
}); |
||||
|
|
||||
|
const portfolioSnapshot = await portfolioCalculator.getSnapshot(); |
||||
|
|
||||
|
const investments = portfolioCalculator.getInvestments(); |
||||
|
|
||||
|
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({ |
||||
|
data: portfolioSnapshot.historicalData, |
||||
|
groupBy: 'month' |
||||
|
}); |
||||
|
|
||||
|
expect(portfolioSnapshot).toMatchObject({ |
||||
|
currentValueInBaseCurrency: new Big('103.10483'), |
||||
|
errors: [], |
||||
|
hasErrors: false, |
||||
|
positions: [ |
||||
|
{ |
||||
|
averagePrice: new Big('89.12'), |
||||
|
currency: 'USD', |
||||
|
dataSource: 'YAHOO', |
||||
|
dividend: new Big('0'), |
||||
|
dividendInBaseCurrency: new Big('0'), |
||||
|
fee: new Big('1'), |
||||
|
feeInBaseCurrency: new Big('0.9238'), |
||||
|
firstBuyDate: '2023-01-03', |
||||
|
grossPerformance: new Big('27.33').mul(0.8854), |
||||
|
grossPerformancePercentage: new Big('0.3066651705565529623'), |
||||
|
grossPerformancePercentageWithCurrencyEffect: new Big( |
||||
|
'0.25235044599563974109' |
||||
|
), |
||||
|
grossPerformanceWithCurrencyEffect: new Big('20.775774'), |
||||
|
investment: new Big('89.12').mul(0.8854), |
||||
|
investmentWithCurrencyEffect: new Big('82.329056'), |
||||
|
netPerformance: new Big('26.33').mul(0.8854), |
||||
|
netPerformancePercentage: new Big('0.29544434470377019749'), |
||||
|
netPerformancePercentageWithCurrencyEffectMap: { |
||||
|
max: new Big('0.24112962014285697628') |
||||
|
}, |
||||
|
netPerformanceWithCurrencyEffectMap: { |
||||
|
max: new Big('19.851974') |
||||
|
}, |
||||
|
marketPrice: 116.45, |
||||
|
marketPriceInBaseCurrency: 103.10483, |
||||
|
quantity: new Big('1'), |
||||
|
symbol: 'GOOGL', |
||||
|
tags: [], |
||||
|
timeWeightedInvestment: new Big('89.12').mul(0.8854), |
||||
|
timeWeightedInvestmentWithCurrencyEffect: new Big('82.329056'), |
||||
|
transactionCount: 1, |
||||
|
valueInBaseCurrency: new Big('103.10483') |
||||
|
} |
||||
|
], |
||||
|
totalFeesWithCurrencyEffect: new Big('0.9238'), |
||||
|
totalInterestWithCurrencyEffect: new Big('0'), |
||||
|
totalInvestment: new Big('89.12').mul(0.8854), |
||||
|
totalInvestmentWithCurrencyEffect: new Big('82.329056'), |
||||
|
totalLiabilitiesWithCurrencyEffect: new Big('0'), |
||||
|
totalValuablesWithCurrencyEffect: new Big('0') |
||||
|
}); |
||||
|
|
||||
|
expect(last(portfolioSnapshot.historicalData)).toMatchObject( |
||||
|
expect.objectContaining({ |
||||
|
netPerformance: new Big('26.33').mul(0.8854).toNumber(), |
||||
|
netPerformanceInPercentage: 0.29544434470377019749, |
||||
|
netPerformanceInPercentageWithCurrencyEffect: 0.24112962014285697628, |
||||
|
netPerformanceWithCurrencyEffect: 19.851974, |
||||
|
totalInvestmentValueWithCurrencyEffect: 82.329056 |
||||
|
}) |
||||
|
); |
||||
|
|
||||
|
expect(investments).toEqual([ |
||||
|
{ date: '2023-01-03', investment: new Big('89.12') } |
||||
|
]); |
||||
|
|
||||
|
expect(investmentsByMonth).toEqual([ |
||||
|
{ date: '2023-01-01', investment: 82.329056 }, |
||||
|
{ |
||||
|
date: '2023-02-01', |
||||
|
investment: 0 |
||||
|
}, |
||||
|
{ |
||||
|
date: '2023-03-01', |
||||
|
investment: 0 |
||||
|
}, |
||||
|
{ |
||||
|
date: '2023-04-01', |
||||
|
investment: 0 |
||||
|
}, |
||||
|
{ |
||||
|
date: '2023-05-01', |
||||
|
investment: 0 |
||||
|
}, |
||||
|
{ |
||||
|
date: '2023-06-01', |
||||
|
investment: 0 |
||||
|
}, |
||||
|
{ |
||||
|
date: '2023-07-01', |
||||
|
investment: 0 |
||||
|
} |
||||
|
]); |
||||
|
}); |
||||
|
}); |
||||
|
}); |
@ -0,0 +1,154 @@ |
|||||
|
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; |
||||
|
import { |
||||
|
activityDummyData, |
||||
|
symbolProfileDummyData, |
||||
|
userDummyData |
||||
|
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; |
||||
|
import { |
||||
|
PortfolioCalculatorFactory, |
||||
|
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 { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; |
||||
|
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; |
||||
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; |
||||
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; |
||||
|
import { parseDate } from '@ghostfolio/common/helper'; |
||||
|
|
||||
|
import { Big } from 'big.js'; |
||||
|
import { last } from 'lodash'; |
||||
|
|
||||
|
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/app/redis-cache/redis-cache.service', () => { |
||||
|
return { |
||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
|
RedisCacheService: jest.fn().mockImplementation(() => { |
||||
|
return RedisCacheServiceMock; |
||||
|
}) |
||||
|
}; |
||||
|
}); |
||||
|
|
||||
|
describe('PortfolioCalculator', () => { |
||||
|
let configurationService: ConfigurationService; |
||||
|
let currentRateService: CurrentRateService; |
||||
|
let exchangeRateDataService: ExchangeRateDataService; |
||||
|
let factory: PortfolioCalculatorFactory; |
||||
|
let redisCacheService: RedisCacheService; |
||||
|
|
||||
|
beforeEach(() => { |
||||
|
configurationService = new ConfigurationService(); |
||||
|
|
||||
|
currentRateService = new CurrentRateService(null, null, null, null); |
||||
|
|
||||
|
exchangeRateDataService = new ExchangeRateDataService( |
||||
|
null, |
||||
|
null, |
||||
|
null, |
||||
|
null |
||||
|
); |
||||
|
|
||||
|
redisCacheService = new RedisCacheService(null, null); |
||||
|
|
||||
|
factory = new PortfolioCalculatorFactory( |
||||
|
configurationService, |
||||
|
currentRateService, |
||||
|
exchangeRateDataService, |
||||
|
redisCacheService |
||||
|
); |
||||
|
}); |
||||
|
|
||||
|
describe('compute portfolio snapshot', () => { |
||||
|
it.only('with item activity', async () => { |
||||
|
jest.useFakeTimers().setSystemTime(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', |
||||
|
userId: userDummyData.id |
||||
|
}); |
||||
|
|
||||
|
const portfolioSnapshot = await portfolioCalculator.getSnapshot(); |
||||
|
|
||||
|
expect(portfolioSnapshot).toMatchObject({ |
||||
|
currentValueInBaseCurrency: new Big('0'), |
||||
|
errors: [], |
||||
|
hasErrors: true, |
||||
|
positions: [ |
||||
|
{ |
||||
|
averagePrice: new Big('500000'), |
||||
|
currency: 'USD', |
||||
|
dataSource: 'MANUAL', |
||||
|
dividend: new Big('0'), |
||||
|
dividendInBaseCurrency: new Big('0'), |
||||
|
fee: new Big('0'), |
||||
|
feeInBaseCurrency: 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, |
||||
|
netPerformancePercentageWithCurrencyEffectMap: null, |
||||
|
netPerformanceWithCurrencyEffectMap: 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') |
||||
|
}); |
||||
|
|
||||
|
expect(last(portfolioSnapshot.historicalData)).toMatchObject( |
||||
|
expect.objectContaining({ |
||||
|
netPerformance: 0, |
||||
|
netPerformanceInPercentage: 0, |
||||
|
netPerformanceInPercentageWithCurrencyEffect: 0, |
||||
|
netPerformanceWithCurrencyEffect: 0, |
||||
|
totalInvestmentValueWithCurrencyEffect: 0 |
||||
|
}) |
||||
|
); |
||||
|
}); |
||||
|
}); |
||||
|
}); |
@ -0,0 +1,103 @@ |
|||||
|
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; |
||||
|
import { |
||||
|
activityDummyData, |
||||
|
symbolProfileDummyData, |
||||
|
userDummyData |
||||
|
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; |
||||
|
import { |
||||
|
PortfolioCalculatorFactory, |
||||
|
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 { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; |
||||
|
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; |
||||
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; |
||||
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; |
||||
|
import { 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/app/redis-cache/redis-cache.service', () => { |
||||
|
return { |
||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
|
RedisCacheService: jest.fn().mockImplementation(() => { |
||||
|
return RedisCacheServiceMock; |
||||
|
}) |
||||
|
}; |
||||
|
}); |
||||
|
|
||||
|
describe('PortfolioCalculator', () => { |
||||
|
let configurationService: ConfigurationService; |
||||
|
let currentRateService: CurrentRateService; |
||||
|
let exchangeRateDataService: ExchangeRateDataService; |
||||
|
let factory: PortfolioCalculatorFactory; |
||||
|
let redisCacheService: RedisCacheService; |
||||
|
|
||||
|
beforeEach(() => { |
||||
|
configurationService = new ConfigurationService(); |
||||
|
|
||||
|
currentRateService = new CurrentRateService(null, null, null, null); |
||||
|
|
||||
|
exchangeRateDataService = new ExchangeRateDataService( |
||||
|
null, |
||||
|
null, |
||||
|
null, |
||||
|
null |
||||
|
); |
||||
|
|
||||
|
redisCacheService = new RedisCacheService(null, null); |
||||
|
|
||||
|
factory = new PortfolioCalculatorFactory( |
||||
|
configurationService, |
||||
|
currentRateService, |
||||
|
exchangeRateDataService, |
||||
|
redisCacheService |
||||
|
); |
||||
|
}); |
||||
|
|
||||
|
describe('compute portfolio snapshot', () => { |
||||
|
it.only('with liability activity', async () => { |
||||
|
jest.useFakeTimers().setSystemTime(parseDate('2022-01-31').getTime()); |
||||
|
|
||||
|
const activities: Activity[] = [ |
||||
|
{ |
||||
|
...activityDummyData, |
||||
|
date: new Date('2023-01-01'), // Date in future
|
||||
|
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', |
||||
|
userId: userDummyData.id |
||||
|
}); |
||||
|
|
||||
|
const liabilitiesInBaseCurrency = |
||||
|
await portfolioCalculator.getLiabilitiesInBaseCurrency(); |
||||
|
|
||||
|
expect(liabilitiesInBaseCurrency).toEqual(new Big(3000)); |
||||
|
}); |
||||
|
}); |
||||
|
}); |
@ -0,0 +1,165 @@ |
|||||
|
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; |
||||
|
import { |
||||
|
activityDummyData, |
||||
|
symbolProfileDummyData, |
||||
|
userDummyData |
||||
|
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; |
||||
|
import { |
||||
|
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 { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; |
||||
|
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; |
||||
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; |
||||
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; |
||||
|
import { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service.mock'; |
||||
|
import { parseDate } from '@ghostfolio/common/helper'; |
||||
|
|
||||
|
import { Big } from 'big.js'; |
||||
|
import { last } from 'lodash'; |
||||
|
|
||||
|
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/app/redis-cache/redis-cache.service', () => { |
||||
|
return { |
||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
|
RedisCacheService: jest.fn().mockImplementation(() => { |
||||
|
return RedisCacheServiceMock; |
||||
|
}) |
||||
|
}; |
||||
|
}); |
||||
|
|
||||
|
jest.mock( |
||||
|
'@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service', |
||||
|
() => { |
||||
|
return { |
||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
|
ExchangeRateDataService: jest.fn().mockImplementation(() => { |
||||
|
return ExchangeRateDataServiceMock; |
||||
|
}) |
||||
|
}; |
||||
|
} |
||||
|
); |
||||
|
|
||||
|
describe('PortfolioCalculator', () => { |
||||
|
let configurationService: ConfigurationService; |
||||
|
let currentRateService: CurrentRateService; |
||||
|
let exchangeRateDataService: ExchangeRateDataService; |
||||
|
let factory: PortfolioCalculatorFactory; |
||||
|
let redisCacheService: RedisCacheService; |
||||
|
|
||||
|
beforeEach(() => { |
||||
|
configurationService = new ConfigurationService(); |
||||
|
|
||||
|
currentRateService = new CurrentRateService(null, null, null, null); |
||||
|
|
||||
|
exchangeRateDataService = new ExchangeRateDataService( |
||||
|
null, |
||||
|
null, |
||||
|
null, |
||||
|
null |
||||
|
); |
||||
|
|
||||
|
redisCacheService = new RedisCacheService(null, null); |
||||
|
|
||||
|
factory = new PortfolioCalculatorFactory( |
||||
|
configurationService, |
||||
|
currentRateService, |
||||
|
exchangeRateDataService, |
||||
|
redisCacheService |
||||
|
); |
||||
|
}); |
||||
|
|
||||
|
describe('get current positions', () => { |
||||
|
it.only('with MSFT buy', async () => { |
||||
|
jest.useFakeTimers().setSystemTime(parseDate('2023-07-10').getTime()); |
||||
|
|
||||
|
const activities: Activity[] = [ |
||||
|
{ |
||||
|
...activityDummyData, |
||||
|
date: new Date('2021-09-16'), |
||||
|
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', |
||||
|
userId: userDummyData.id |
||||
|
}); |
||||
|
|
||||
|
const portfolioSnapshot = await portfolioCalculator.getSnapshot(); |
||||
|
|
||||
|
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') |
||||
|
}); |
||||
|
|
||||
|
expect(last(portfolioSnapshot.historicalData)).toMatchObject( |
||||
|
expect.objectContaining({ |
||||
|
totalInvestmentValueWithCurrencyEffect: 298.58 |
||||
|
}) |
||||
|
); |
||||
|
}); |
||||
|
}); |
||||
|
}); |
@ -0,0 +1,103 @@ |
|||||
|
import { userDummyData } 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 { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; |
||||
|
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; |
||||
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; |
||||
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; |
||||
|
import { parseDate } from '@ghostfolio/common/helper'; |
||||
|
|
||||
|
import { Big } from 'big.js'; |
||||
|
import { subDays } from 'date-fns'; |
||||
|
import { last } from 'lodash'; |
||||
|
|
||||
|
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/app/redis-cache/redis-cache.service', () => { |
||||
|
return { |
||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
|
RedisCacheService: jest.fn().mockImplementation(() => { |
||||
|
return RedisCacheServiceMock; |
||||
|
}) |
||||
|
}; |
||||
|
}); |
||||
|
|
||||
|
describe('PortfolioCalculator', () => { |
||||
|
let configurationService: ConfigurationService; |
||||
|
let currentRateService: CurrentRateService; |
||||
|
let exchangeRateDataService: ExchangeRateDataService; |
||||
|
let factory: PortfolioCalculatorFactory; |
||||
|
let redisCacheService: RedisCacheService; |
||||
|
|
||||
|
beforeEach(() => { |
||||
|
configurationService = new ConfigurationService(); |
||||
|
|
||||
|
currentRateService = new CurrentRateService(null, null, null, null); |
||||
|
|
||||
|
exchangeRateDataService = new ExchangeRateDataService( |
||||
|
null, |
||||
|
null, |
||||
|
null, |
||||
|
null |
||||
|
); |
||||
|
|
||||
|
redisCacheService = new RedisCacheService(null, null); |
||||
|
|
||||
|
factory = new PortfolioCalculatorFactory( |
||||
|
configurationService, |
||||
|
currentRateService, |
||||
|
exchangeRateDataService, |
||||
|
redisCacheService |
||||
|
); |
||||
|
}); |
||||
|
|
||||
|
describe('get current positions', () => { |
||||
|
it('with no orders', async () => { |
||||
|
jest.useFakeTimers().setSystemTime(parseDate('2021-12-18').getTime()); |
||||
|
|
||||
|
const portfolioCalculator = factory.createCalculator({ |
||||
|
activities: [], |
||||
|
calculationType: PerformanceCalculationType.TWR, |
||||
|
currency: 'CHF', |
||||
|
userId: userDummyData.id |
||||
|
}); |
||||
|
|
||||
|
const portfolioSnapshot = await portfolioCalculator.getSnapshot(); |
||||
|
|
||||
|
const investments = portfolioCalculator.getInvestments(); |
||||
|
|
||||
|
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({ |
||||
|
data: portfolioSnapshot.historicalData, |
||||
|
groupBy: 'month' |
||||
|
}); |
||||
|
|
||||
|
expect(portfolioSnapshot).toMatchObject({ |
||||
|
currentValueInBaseCurrency: new Big(0), |
||||
|
hasErrors: false, |
||||
|
historicalData: [], |
||||
|
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') |
||||
|
}); |
||||
|
|
||||
|
expect(investments).toEqual([]); |
||||
|
|
||||
|
expect(investmentsByMonth).toEqual([]); |
||||
|
}); |
||||
|
}); |
||||
|
}); |
@ -0,0 +1,194 @@ |
|||||
|
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; |
||||
|
import { |
||||
|
activityDummyData, |
||||
|
symbolProfileDummyData, |
||||
|
userDummyData |
||||
|
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; |
||||
|
import { |
||||
|
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 { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; |
||||
|
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; |
||||
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; |
||||
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; |
||||
|
import { parseDate } from '@ghostfolio/common/helper'; |
||||
|
|
||||
|
import { Big } from 'big.js'; |
||||
|
import { last } from 'lodash'; |
||||
|
|
||||
|
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/app/redis-cache/redis-cache.service', () => { |
||||
|
return { |
||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
|
RedisCacheService: jest.fn().mockImplementation(() => { |
||||
|
return RedisCacheServiceMock; |
||||
|
}) |
||||
|
}; |
||||
|
}); |
||||
|
|
||||
|
describe('PortfolioCalculator', () => { |
||||
|
let configurationService: ConfigurationService; |
||||
|
let currentRateService: CurrentRateService; |
||||
|
let exchangeRateDataService: ExchangeRateDataService; |
||||
|
let factory: PortfolioCalculatorFactory; |
||||
|
let redisCacheService: RedisCacheService; |
||||
|
|
||||
|
beforeEach(() => { |
||||
|
configurationService = new ConfigurationService(); |
||||
|
|
||||
|
currentRateService = new CurrentRateService(null, null, null, null); |
||||
|
|
||||
|
exchangeRateDataService = new ExchangeRateDataService( |
||||
|
null, |
||||
|
null, |
||||
|
null, |
||||
|
null |
||||
|
); |
||||
|
|
||||
|
redisCacheService = new RedisCacheService(null, null); |
||||
|
|
||||
|
factory = new PortfolioCalculatorFactory( |
||||
|
configurationService, |
||||
|
currentRateService, |
||||
|
exchangeRateDataService, |
||||
|
redisCacheService |
||||
|
); |
||||
|
}); |
||||
|
|
||||
|
describe('get current positions', () => { |
||||
|
it.only('with NOVN.SW buy and sell partially', async () => { |
||||
|
jest.useFakeTimers().setSystemTime(parseDate('2022-04-11').getTime()); |
||||
|
|
||||
|
const activities: Activity[] = [ |
||||
|
{ |
||||
|
...activityDummyData, |
||||
|
date: new Date('2022-03-07'), |
||||
|
fee: 1.3, |
||||
|
quantity: 2, |
||||
|
SymbolProfile: { |
||||
|
...symbolProfileDummyData, |
||||
|
currency: 'CHF', |
||||
|
dataSource: 'YAHOO', |
||||
|
name: 'Novartis AG', |
||||
|
symbol: 'NOVN.SW' |
||||
|
}, |
||||
|
type: 'BUY', |
||||
|
unitPrice: 75.8 |
||||
|
}, |
||||
|
{ |
||||
|
...activityDummyData, |
||||
|
date: new Date('2022-04-08'), |
||||
|
fee: 2.95, |
||||
|
quantity: 1, |
||||
|
SymbolProfile: { |
||||
|
...symbolProfileDummyData, |
||||
|
currency: 'CHF', |
||||
|
dataSource: 'YAHOO', |
||||
|
name: 'Novartis AG', |
||||
|
symbol: 'NOVN.SW' |
||||
|
}, |
||||
|
type: 'SELL', |
||||
|
unitPrice: 85.73 |
||||
|
} |
||||
|
]; |
||||
|
|
||||
|
const portfolioCalculator = factory.createCalculator({ |
||||
|
activities, |
||||
|
calculationType: PerformanceCalculationType.TWR, |
||||
|
currency: 'CHF', |
||||
|
userId: userDummyData.id |
||||
|
}); |
||||
|
|
||||
|
const portfolioSnapshot = await portfolioCalculator.getSnapshot(); |
||||
|
|
||||
|
const investments = portfolioCalculator.getInvestments(); |
||||
|
|
||||
|
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({ |
||||
|
data: portfolioSnapshot.historicalData, |
||||
|
groupBy: 'month' |
||||
|
}); |
||||
|
|
||||
|
expect(portfolioSnapshot).toMatchObject({ |
||||
|
currentValueInBaseCurrency: new Big('87.8'), |
||||
|
errors: [], |
||||
|
hasErrors: false, |
||||
|
positions: [ |
||||
|
{ |
||||
|
averagePrice: new Big('75.80'), |
||||
|
currency: 'CHF', |
||||
|
dataSource: 'YAHOO', |
||||
|
dividend: new Big('0'), |
||||
|
dividendInBaseCurrency: new Big('0'), |
||||
|
fee: new Big('4.25'), |
||||
|
feeInBaseCurrency: new Big('4.25'), |
||||
|
firstBuyDate: '2022-03-07', |
||||
|
grossPerformance: new Big('21.93'), |
||||
|
grossPerformancePercentage: new Big('0.15113417083448194384'), |
||||
|
grossPerformancePercentageWithCurrencyEffect: new Big( |
||||
|
'0.15113417083448194384' |
||||
|
), |
||||
|
grossPerformanceWithCurrencyEffect: new Big('21.93'), |
||||
|
investment: new Big('75.80'), |
||||
|
investmentWithCurrencyEffect: new Big('75.80'), |
||||
|
netPerformance: new Big('17.68'), |
||||
|
netPerformancePercentage: new Big('0.12184460284330327256'), |
||||
|
netPerformancePercentageWithCurrencyEffectMap: { |
||||
|
max: new Big('0.12348284960422163588') |
||||
|
}, |
||||
|
netPerformanceWithCurrencyEffectMap: { |
||||
|
max: new Big('17.68') |
||||
|
}, |
||||
|
marketPrice: 87.8, |
||||
|
marketPriceInBaseCurrency: 87.8, |
||||
|
quantity: new Big('1'), |
||||
|
symbol: 'NOVN.SW', |
||||
|
tags: [], |
||||
|
timeWeightedInvestment: new Big('145.10285714285714285714'), |
||||
|
timeWeightedInvestmentWithCurrencyEffect: new Big( |
||||
|
'145.10285714285714285714' |
||||
|
), |
||||
|
transactionCount: 2, |
||||
|
valueInBaseCurrency: new Big('87.8') |
||||
|
} |
||||
|
], |
||||
|
totalFeesWithCurrencyEffect: new Big('4.25'), |
||||
|
totalInterestWithCurrencyEffect: new Big('0'), |
||||
|
totalInvestment: new Big('75.80'), |
||||
|
totalInvestmentWithCurrencyEffect: new Big('75.80'), |
||||
|
totalLiabilitiesWithCurrencyEffect: new Big('0'), |
||||
|
totalValuablesWithCurrencyEffect: new Big('0') |
||||
|
}); |
||||
|
|
||||
|
expect(last(portfolioSnapshot.historicalData)).toMatchObject( |
||||
|
expect.objectContaining({ |
||||
|
netPerformance: 17.68, |
||||
|
netPerformanceInPercentage: 0.12184460284330327256, |
||||
|
netPerformanceInPercentageWithCurrencyEffect: 0.12184460284330327256, |
||||
|
netPerformanceWithCurrencyEffect: 17.68, |
||||
|
totalInvestmentValueWithCurrencyEffect: 75.8 |
||||
|
}) |
||||
|
); |
||||
|
|
||||
|
expect(investments).toEqual([ |
||||
|
{ date: '2022-03-07', investment: new Big('151.6') }, |
||||
|
{ date: '2022-04-08', investment: new Big('75.8') } |
||||
|
]); |
||||
|
|
||||
|
expect(investmentsByMonth).toEqual([ |
||||
|
{ date: '2022-03-01', investment: 151.6 }, |
||||
|
{ date: '2022-04-01', investment: -75.8 } |
||||
|
]); |
||||
|
}); |
||||
|
}); |
||||
|
}); |
@ -0,0 +1,241 @@ |
|||||
|
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; |
||||
|
import { |
||||
|
activityDummyData, |
||||
|
symbolProfileDummyData, |
||||
|
userDummyData |
||||
|
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; |
||||
|
import { |
||||
|
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 { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; |
||||
|
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; |
||||
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; |
||||
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; |
||||
|
import { parseDate } from '@ghostfolio/common/helper'; |
||||
|
|
||||
|
import { Big } from 'big.js'; |
||||
|
import { last } from 'lodash'; |
||||
|
|
||||
|
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/app/redis-cache/redis-cache.service', () => { |
||||
|
return { |
||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
|
RedisCacheService: jest.fn().mockImplementation(() => { |
||||
|
return RedisCacheServiceMock; |
||||
|
}) |
||||
|
}; |
||||
|
}); |
||||
|
|
||||
|
describe('PortfolioCalculator', () => { |
||||
|
let configurationService: ConfigurationService; |
||||
|
let currentRateService: CurrentRateService; |
||||
|
let exchangeRateDataService: ExchangeRateDataService; |
||||
|
let factory: PortfolioCalculatorFactory; |
||||
|
let redisCacheService: RedisCacheService; |
||||
|
|
||||
|
beforeEach(() => { |
||||
|
configurationService = new ConfigurationService(); |
||||
|
|
||||
|
currentRateService = new CurrentRateService(null, null, null, null); |
||||
|
|
||||
|
exchangeRateDataService = new ExchangeRateDataService( |
||||
|
null, |
||||
|
null, |
||||
|
null, |
||||
|
null |
||||
|
); |
||||
|
|
||||
|
redisCacheService = new RedisCacheService(null, null); |
||||
|
|
||||
|
factory = new PortfolioCalculatorFactory( |
||||
|
configurationService, |
||||
|
currentRateService, |
||||
|
exchangeRateDataService, |
||||
|
redisCacheService |
||||
|
); |
||||
|
}); |
||||
|
|
||||
|
describe('get current positions', () => { |
||||
|
it.only('with NOVN.SW buy and sell', async () => { |
||||
|
jest.useFakeTimers().setSystemTime(parseDate('2022-04-11').getTime()); |
||||
|
|
||||
|
const activities: Activity[] = [ |
||||
|
{ |
||||
|
...activityDummyData, |
||||
|
date: new Date('2022-03-07'), |
||||
|
fee: 0, |
||||
|
quantity: 2, |
||||
|
SymbolProfile: { |
||||
|
...symbolProfileDummyData, |
||||
|
currency: 'CHF', |
||||
|
dataSource: 'YAHOO', |
||||
|
name: 'Novartis AG', |
||||
|
symbol: 'NOVN.SW' |
||||
|
}, |
||||
|
type: 'BUY', |
||||
|
unitPrice: 75.8 |
||||
|
}, |
||||
|
{ |
||||
|
...activityDummyData, |
||||
|
date: new Date('2022-04-08'), |
||||
|
fee: 0, |
||||
|
quantity: 2, |
||||
|
SymbolProfile: { |
||||
|
...symbolProfileDummyData, |
||||
|
currency: 'CHF', |
||||
|
dataSource: 'YAHOO', |
||||
|
name: 'Novartis AG', |
||||
|
symbol: 'NOVN.SW' |
||||
|
}, |
||||
|
type: 'SELL', |
||||
|
unitPrice: 85.73 |
||||
|
} |
||||
|
]; |
||||
|
|
||||
|
const portfolioCalculator = factory.createCalculator({ |
||||
|
activities, |
||||
|
calculationType: PerformanceCalculationType.TWR, |
||||
|
currency: 'CHF', |
||||
|
userId: userDummyData.id |
||||
|
}); |
||||
|
|
||||
|
const portfolioSnapshot = await portfolioCalculator.getSnapshot(); |
||||
|
|
||||
|
const investments = portfolioCalculator.getInvestments(); |
||||
|
|
||||
|
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({ |
||||
|
data: portfolioSnapshot.historicalData, |
||||
|
groupBy: 'month' |
||||
|
}); |
||||
|
|
||||
|
expect(portfolioSnapshot.historicalData[0]).toEqual({ |
||||
|
date: '2022-03-06', |
||||
|
investmentValueWithCurrencyEffect: 0, |
||||
|
netPerformance: 0, |
||||
|
netPerformanceInPercentage: 0, |
||||
|
netPerformanceInPercentageWithCurrencyEffect: 0, |
||||
|
netPerformanceWithCurrencyEffect: 0, |
||||
|
netWorth: 0, |
||||
|
totalAccountBalance: 0, |
||||
|
totalInvestment: 0, |
||||
|
totalInvestmentValueWithCurrencyEffect: 0, |
||||
|
value: 0, |
||||
|
valueWithCurrencyEffect: 0 |
||||
|
}); |
||||
|
|
||||
|
expect(portfolioSnapshot.historicalData[1]).toEqual({ |
||||
|
date: '2022-03-07', |
||||
|
investmentValueWithCurrencyEffect: 151.6, |
||||
|
netPerformance: 0, |
||||
|
netPerformanceInPercentage: 0, |
||||
|
netPerformanceInPercentageWithCurrencyEffect: 0, |
||||
|
netPerformanceWithCurrencyEffect: 0, |
||||
|
netWorth: 151.6, |
||||
|
totalAccountBalance: 0, |
||||
|
totalInvestment: 151.6, |
||||
|
totalInvestmentValueWithCurrencyEffect: 151.6, |
||||
|
value: 151.6, |
||||
|
valueWithCurrencyEffect: 151.6 |
||||
|
}); |
||||
|
|
||||
|
expect( |
||||
|
portfolioSnapshot.historicalData[ |
||||
|
portfolioSnapshot.historicalData.length - 1 |
||||
|
] |
||||
|
).toEqual({ |
||||
|
date: '2022-04-11', |
||||
|
investmentValueWithCurrencyEffect: 0, |
||||
|
netPerformance: 19.86, |
||||
|
netPerformanceInPercentage: 0.13100263852242744, |
||||
|
netPerformanceInPercentageWithCurrencyEffect: 0.13100263852242744, |
||||
|
netPerformanceWithCurrencyEffect: 19.86, |
||||
|
netWorth: 0, |
||||
|
totalAccountBalance: 0, |
||||
|
totalInvestment: 0, |
||||
|
totalInvestmentValueWithCurrencyEffect: 0, |
||||
|
value: 0, |
||||
|
valueWithCurrencyEffect: 0 |
||||
|
}); |
||||
|
|
||||
|
expect(portfolioSnapshot).toMatchObject({ |
||||
|
currentValueInBaseCurrency: new Big('0'), |
||||
|
errors: [], |
||||
|
hasErrors: false, |
||||
|
positions: [ |
||||
|
{ |
||||
|
averagePrice: new Big('0'), |
||||
|
currency: 'CHF', |
||||
|
dataSource: 'YAHOO', |
||||
|
dividend: new Big('0'), |
||||
|
dividendInBaseCurrency: new Big('0'), |
||||
|
fee: new Big('0'), |
||||
|
feeInBaseCurrency: new Big('0'), |
||||
|
firstBuyDate: '2022-03-07', |
||||
|
grossPerformance: new Big('19.86'), |
||||
|
grossPerformancePercentage: new Big('0.13100263852242744063'), |
||||
|
grossPerformancePercentageWithCurrencyEffect: new Big( |
||||
|
'0.13100263852242744063' |
||||
|
), |
||||
|
grossPerformanceWithCurrencyEffect: new Big('19.86'), |
||||
|
investment: new Big('0'), |
||||
|
investmentWithCurrencyEffect: new Big('0'), |
||||
|
netPerformance: new Big('19.86'), |
||||
|
netPerformancePercentage: new Big('0.13100263852242744063'), |
||||
|
netPerformancePercentageWithCurrencyEffectMap: { |
||||
|
max: new Big('0.13100263852242744063') |
||||
|
}, |
||||
|
netPerformanceWithCurrencyEffectMap: { |
||||
|
max: new Big('19.86') |
||||
|
}, |
||||
|
marketPrice: 87.8, |
||||
|
marketPriceInBaseCurrency: 87.8, |
||||
|
quantity: new Big('0'), |
||||
|
symbol: 'NOVN.SW', |
||||
|
tags: [], |
||||
|
timeWeightedInvestment: new Big('151.6'), |
||||
|
timeWeightedInvestmentWithCurrencyEffect: new Big('151.6'), |
||||
|
transactionCount: 2, |
||||
|
valueInBaseCurrency: new Big('0') |
||||
|
} |
||||
|
], |
||||
|
totalFeesWithCurrencyEffect: new Big('0'), |
||||
|
totalInterestWithCurrencyEffect: new Big('0'), |
||||
|
totalInvestment: new Big('0'), |
||||
|
totalInvestmentWithCurrencyEffect: new Big('0'), |
||||
|
totalLiabilitiesWithCurrencyEffect: new Big('0'), |
||||
|
totalValuablesWithCurrencyEffect: new Big('0') |
||||
|
}); |
||||
|
|
||||
|
expect(last(portfolioSnapshot.historicalData)).toMatchObject( |
||||
|
expect.objectContaining({ |
||||
|
netPerformance: 19.86, |
||||
|
netPerformanceInPercentage: 0.13100263852242744063, |
||||
|
netPerformanceInPercentageWithCurrencyEffect: 0.13100263852242744063, |
||||
|
netPerformanceWithCurrencyEffect: 19.86, |
||||
|
totalInvestmentValueWithCurrencyEffect: 0 |
||||
|
}) |
||||
|
); |
||||
|
|
||||
|
expect(investments).toEqual([ |
||||
|
{ date: '2022-03-07', investment: new Big('151.6') }, |
||||
|
{ date: '2022-04-08', investment: new Big('0') } |
||||
|
]); |
||||
|
|
||||
|
expect(investmentsByMonth).toEqual([ |
||||
|
{ date: '2022-03-01', investment: 151.6 }, |
||||
|
{ date: '2022-04-01', investment: -151.6 } |
||||
|
]); |
||||
|
}); |
||||
|
}); |
||||
|
}); |
@ -0,0 +1,37 @@ |
|||||
|
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; |
||||
|
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; |
||||
|
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; |
||||
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; |
||||
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; |
||||
|
|
||||
|
describe('PortfolioCalculator', () => { |
||||
|
let configurationService: ConfigurationService; |
||||
|
let currentRateService: CurrentRateService; |
||||
|
let exchangeRateDataService: ExchangeRateDataService; |
||||
|
let factory: PortfolioCalculatorFactory; |
||||
|
let redisCacheService: RedisCacheService; |
||||
|
|
||||
|
beforeEach(() => { |
||||
|
configurationService = new ConfigurationService(); |
||||
|
|
||||
|
currentRateService = new CurrentRateService(null, null, null, null); |
||||
|
|
||||
|
exchangeRateDataService = new ExchangeRateDataService( |
||||
|
null, |
||||
|
null, |
||||
|
null, |
||||
|
null |
||||
|
); |
||||
|
|
||||
|
redisCacheService = new RedisCacheService(null, null); |
||||
|
|
||||
|
factory = new PortfolioCalculatorFactory( |
||||
|
configurationService, |
||||
|
currentRateService, |
||||
|
exchangeRateDataService, |
||||
|
redisCacheService |
||||
|
); |
||||
|
}); |
||||
|
|
||||
|
test.skip('Skip empty test', () => 1); |
||||
|
}); |
@ -0,0 +1,964 @@ |
|||||
|
import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator'; |
||||
|
import { PortfolioOrderItem } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-order-item.interface'; |
||||
|
import { getFactor } from '@ghostfolio/api/helper/portfolio.helper'; |
||||
|
import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper'; |
||||
|
import { DATE_FORMAT } from '@ghostfolio/common/helper'; |
||||
|
import { |
||||
|
AssetProfileIdentifier, |
||||
|
SymbolMetrics |
||||
|
} from '@ghostfolio/common/interfaces'; |
||||
|
import { PortfolioSnapshot, TimelinePosition } from '@ghostfolio/common/models'; |
||||
|
import { DateRange } from '@ghostfolio/common/types'; |
||||
|
|
||||
|
import { Logger } from '@nestjs/common'; |
||||
|
import { Big } from 'big.js'; |
||||
|
import { |
||||
|
addDays, |
||||
|
addMilliseconds, |
||||
|
differenceInDays, |
||||
|
eachDayOfInterval, |
||||
|
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 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.feeInBaseCurrency) { |
||||
|
totalFeesWithCurrencyEffect = totalFeesWithCurrencyEffect.plus( |
||||
|
currentPosition.feeInBaseCurrency |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
if (currentPosition.valueInBaseCurrency) { |
||||
|
currentValueInBaseCurrency = currentValueInBaseCurrency.plus( |
||||
|
currentPosition.valueInBaseCurrency |
||||
|
); |
||||
|
} else { |
||||
|
hasErrors = true; |
||||
|
} |
||||
|
|
||||
|
if (currentPosition.investment) { |
||||
|
totalInvestment = totalInvestment.plus(currentPosition.investment); |
||||
|
|
||||
|
totalInvestmentWithCurrencyEffect = |
||||
|
totalInvestmentWithCurrencyEffect.plus( |
||||
|
currentPosition.investmentWithCurrencyEffect |
||||
|
); |
||||
|
} else { |
||||
|
hasErrors = true; |
||||
|
} |
||||
|
|
||||
|
if (currentPosition.grossPerformance) { |
||||
|
grossPerformance = grossPerformance.plus( |
||||
|
currentPosition.grossPerformance |
||||
|
); |
||||
|
|
||||
|
grossPerformanceWithCurrencyEffect = |
||||
|
grossPerformanceWithCurrencyEffect.plus( |
||||
|
currentPosition.grossPerformanceWithCurrencyEffect |
||||
|
); |
||||
|
|
||||
|
netPerformance = netPerformance.plus(currentPosition.netPerformance); |
||||
|
} else if (!currentPosition.quantity.eq(0)) { |
||||
|
hasErrors = true; |
||||
|
} |
||||
|
|
||||
|
if (currentPosition.timeWeightedInvestment) { |
||||
|
totalTimeWeightedInvestment = totalTimeWeightedInvestment.plus( |
||||
|
currentPosition.timeWeightedInvestment |
||||
|
); |
||||
|
|
||||
|
totalTimeWeightedInvestmentWithCurrencyEffect = |
||||
|
totalTimeWeightedInvestmentWithCurrencyEffect.plus( |
||||
|
currentPosition.timeWeightedInvestmentWithCurrencyEffect |
||||
|
); |
||||
|
} else if (!currentPosition.quantity.eq(0)) { |
||||
|
Logger.warn( |
||||
|
`Missing historical market data for ${currentPosition.symbol} (${currentPosition.dataSource})`, |
||||
|
'PortfolioCalculator' |
||||
|
); |
||||
|
|
||||
|
hasErrors = true; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return { |
||||
|
currentValueInBaseCurrency, |
||||
|
hasErrors, |
||||
|
positions, |
||||
|
totalFeesWithCurrencyEffect, |
||||
|
totalInterestWithCurrencyEffect, |
||||
|
totalInvestment, |
||||
|
totalInvestmentWithCurrencyEffect, |
||||
|
historicalData: [], |
||||
|
totalLiabilitiesWithCurrencyEffect: new Big(0), |
||||
|
totalValuablesWithCurrencyEffect: new Big(0) |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
protected getSymbolMetrics({ |
||||
|
chartDateMap, |
||||
|
dataSource, |
||||
|
end, |
||||
|
exchangeRates, |
||||
|
marketSymbolMap, |
||||
|
start, |
||||
|
symbol |
||||
|
}: { |
||||
|
chartDateMap?: { [date: string]: boolean }; |
||||
|
end: Date; |
||||
|
exchangeRates: { [dateString: string]: number }; |
||||
|
marketSymbolMap: { |
||||
|
[date: string]: { [symbol: string]: Big }; |
||||
|
}; |
||||
|
start: Date; |
||||
|
} & AssetProfileIdentifier): 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 totalAccountBalanceInBaseCurrency = new Big(0); |
||||
|
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.activities).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), |
||||
|
netPerformancePercentageWithCurrencyEffectMap: {}, |
||||
|
netPerformanceValues: {}, |
||||
|
netPerformanceValuesWithCurrencyEffect: {}, |
||||
|
netPerformanceWithCurrencyEffectMap: {}, |
||||
|
timeWeightedInvestment: new Big(0), |
||||
|
timeWeightedInvestmentValues: {}, |
||||
|
timeWeightedInvestmentValuesWithCurrencyEffect: {}, |
||||
|
timeWeightedInvestmentWithCurrencyEffect: new Big(0), |
||||
|
totalAccountBalanceInBaseCurrency: new Big(0), |
||||
|
totalDividend: new Big(0), |
||||
|
totalDividendInBaseCurrency: new Big(0), |
||||
|
totalInterest: new Big(0), |
||||
|
totalInterestInBaseCurrency: new Big(0), |
||||
|
totalInvestment: new Big(0), |
||||
|
totalInvestmentWithCurrencyEffect: new Big(0), |
||||
|
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), |
||||
|
netPerformancePercentageWithCurrencyEffectMap: {}, |
||||
|
netPerformanceWithCurrencyEffectMap: {}, |
||||
|
netPerformanceValues: {}, |
||||
|
netPerformanceValuesWithCurrencyEffect: {}, |
||||
|
timeWeightedInvestment: new Big(0), |
||||
|
timeWeightedInvestmentValues: {}, |
||||
|
timeWeightedInvestmentValuesWithCurrencyEffect: {}, |
||||
|
timeWeightedInvestmentWithCurrencyEffect: new Big(0), |
||||
|
totalAccountBalanceInBaseCurrency: new Big(0), |
||||
|
totalDividend: new Big(0), |
||||
|
totalDividendInBaseCurrency: new Big(0), |
||||
|
totalInterest: new Big(0), |
||||
|
totalInterestInBaseCurrency: new Big(0), |
||||
|
totalInvestment: new Big(0), |
||||
|
totalInvestmentWithCurrencyEffect: new Big(0), |
||||
|
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; |
||||
|
|
||||
|
const ordersByDate: { [date: string]: PortfolioOrderItem[] } = {}; |
||||
|
|
||||
|
for (const order of orders) { |
||||
|
ordersByDate[order.date] = ordersByDate[order.date] ?? []; |
||||
|
ordersByDate[order.date].push(order); |
||||
|
} |
||||
|
|
||||
|
while (isBefore(day, end)) { |
||||
|
const dateString = format(day, DATE_FORMAT); |
||||
|
|
||||
|
if (ordersByDate[dateString]?.length > 0) { |
||||
|
for (let order of ordersByDate[dateString]) { |
||||
|
order.unitPriceFromMarketData = |
||||
|
marketSymbolMap[dateString]?.[symbol] ?? lastUnitPrice; |
||||
|
} |
||||
|
} else if (chartDateMap[dateString]) { |
||||
|
orders.push({ |
||||
|
date: dateString, |
||||
|
fee: new Big(0), |
||||
|
feeInBaseCurrency: new Big(0), |
||||
|
quantity: new Big(0), |
||||
|
SymbolProfile: { |
||||
|
dataSource, |
||||
|
symbol |
||||
|
}, |
||||
|
type: 'BUY', |
||||
|
unitPrice: marketSymbolMap[dateString]?.[symbol] ?? lastUnitPrice, |
||||
|
unitPriceFromMarketData: |
||||
|
marketSymbolMap[dateString]?.[symbol] ?? lastUnitPrice |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
const lastOrder = last(orders); |
||||
|
|
||||
|
lastUnitPrice = lastOrder.unitPriceFromMarketData ?? lastOrder.unitPrice; |
||||
|
|
||||
|
day = addDays(day, 1); |
||||
|
} |
||||
|
|
||||
|
// 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.date, |
||||
|
order.type, |
||||
|
order.itemType ? `(${order.itemType})` : '' |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
const exchangeRateAtOrderDate = exchangeRates[order.date]; |
||||
|
|
||||
|
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) |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
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 |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
const unitPrice = ['BUY', 'SELL'].includes(order.type) |
||||
|
? order.unitPrice |
||||
|
: order.unitPriceFromMarketData; |
||||
|
|
||||
|
if (unitPrice) { |
||||
|
order.unitPriceInBaseCurrency = unitPrice.mul(currentExchangeRate ?? 1); |
||||
|
|
||||
|
order.unitPriceInBaseCurrencyWithCurrencyEffect = 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('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))); |
||||
|
|
||||
|
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) { |
||||
|
// Only consider periods with an investment for the calculation of
|
||||
|
// the time weighted investment
|
||||
|
if ( |
||||
|
valueOfInvestmentBeforeTransaction.gt(0) && |
||||
|
['BUY', 'SELL'].includes(order.type) |
||||
|
) { |
||||
|
// 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) |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
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 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 netPerformancePercentageWithCurrencyEffectMap: { |
||||
|
[key: DateRange]: Big; |
||||
|
} = {}; |
||||
|
|
||||
|
const netPerformanceWithCurrencyEffectMap: { |
||||
|
[key: DateRange]: Big; |
||||
|
} = {}; |
||||
|
|
||||
|
for (const dateRange of <DateRange[]>[ |
||||
|
'1d', |
||||
|
'1y', |
||||
|
'5y', |
||||
|
'max', |
||||
|
'mtd', |
||||
|
'wtd', |
||||
|
'ytd' |
||||
|
// TODO:
|
||||
|
// ...eachYearOfInterval({ end, start })
|
||||
|
// .filter((date) => {
|
||||
|
// return !isThisYear(date);
|
||||
|
// })
|
||||
|
// .map((date) => {
|
||||
|
// return format(date, 'yyyy');
|
||||
|
// })
|
||||
|
]) { |
||||
|
// TODO: getIntervalFromDateRange(dateRange, start)
|
||||
|
let { endDate, startDate } = getIntervalFromDateRange(dateRange); |
||||
|
|
||||
|
if (isBefore(startDate, start)) { |
||||
|
startDate = start; |
||||
|
} |
||||
|
|
||||
|
const currentValuesAtDateRangeStartWithCurrencyEffect = |
||||
|
currentValuesWithCurrencyEffect[format(startDate, DATE_FORMAT)] ?? |
||||
|
new Big(0); |
||||
|
|
||||
|
const investmentValuesAccumulatedAtStartDateWithCurrencyEffect = |
||||
|
investmentValuesAccumulatedWithCurrencyEffect[ |
||||
|
format(startDate, DATE_FORMAT) |
||||
|
] ?? new Big(0); |
||||
|
|
||||
|
const grossPerformanceAtDateRangeStartWithCurrencyEffect = |
||||
|
currentValuesAtDateRangeStartWithCurrencyEffect.minus( |
||||
|
investmentValuesAccumulatedAtStartDateWithCurrencyEffect |
||||
|
); |
||||
|
|
||||
|
const dates = eachDayOfInterval({ |
||||
|
end: endDate, |
||||
|
start: startDate |
||||
|
}).map((date) => { |
||||
|
return format(date, DATE_FORMAT); |
||||
|
}); |
||||
|
|
||||
|
let average = new Big(0); |
||||
|
let dayCount = 0; |
||||
|
|
||||
|
for (const date of dates) { |
||||
|
if ( |
||||
|
investmentValuesAccumulatedWithCurrencyEffect[date] instanceof Big && |
||||
|
investmentValuesAccumulatedWithCurrencyEffect[date].gt(0) |
||||
|
) { |
||||
|
average = average.add( |
||||
|
investmentValuesAccumulatedWithCurrencyEffect[date].add( |
||||
|
grossPerformanceAtDateRangeStartWithCurrencyEffect |
||||
|
) |
||||
|
); |
||||
|
|
||||
|
dayCount++; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
if (dayCount > 0) { |
||||
|
average = average.div(dayCount); |
||||
|
} |
||||
|
|
||||
|
netPerformanceWithCurrencyEffectMap[dateRange] = |
||||
|
netPerformanceValuesWithCurrencyEffect[ |
||||
|
format(endDate, DATE_FORMAT) |
||||
|
]?.minus( |
||||
|
// If the date range is 'max', take 0 as a start value. Otherwise,
|
||||
|
// the value of the end of the day of the start date is taken which
|
||||
|
// differs from the buying price.
|
||||
|
dateRange === 'max' |
||||
|
? new Big(0) |
||||
|
: (netPerformanceValuesWithCurrencyEffect[ |
||||
|
format(startDate, DATE_FORMAT) |
||||
|
] ?? new Big(0)) |
||||
|
) ?? new Big(0); |
||||
|
|
||||
|
netPerformancePercentageWithCurrencyEffectMap[dateRange] = average.gt(0) |
||||
|
? netPerformanceWithCurrencyEffectMap[dateRange].div(average) |
||||
|
: 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: ${netPerformancePercentageWithCurrencyEffectMap[ |
||||
|
'max' |
||||
|
].toFixed(2)}%` |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
return { |
||||
|
currentValues, |
||||
|
currentValuesWithCurrencyEffect, |
||||
|
feesWithCurrencyEffect, |
||||
|
grossPerformancePercentage, |
||||
|
grossPerformancePercentageWithCurrencyEffect, |
||||
|
initialValue, |
||||
|
initialValueWithCurrencyEffect, |
||||
|
investmentValuesAccumulated, |
||||
|
investmentValuesAccumulatedWithCurrencyEffect, |
||||
|
investmentValuesWithCurrencyEffect, |
||||
|
netPerformancePercentage, |
||||
|
netPerformancePercentageWithCurrencyEffectMap, |
||||
|
netPerformanceValues, |
||||
|
netPerformanceValuesWithCurrencyEffect, |
||||
|
netPerformanceWithCurrencyEffectMap, |
||||
|
timeWeightedInvestmentValues, |
||||
|
timeWeightedInvestmentValuesWithCurrencyEffect, |
||||
|
totalAccountBalanceInBaseCurrency, |
||||
|
totalDividend, |
||||
|
totalDividendInBaseCurrency, |
||||
|
totalInterest, |
||||
|
totalInterestInBaseCurrency, |
||||
|
totalInvestment, |
||||
|
totalInvestmentWithCurrencyEffect, |
||||
|
totalLiabilities, |
||||
|
totalLiabilitiesInBaseCurrency, |
||||
|
totalValuables, |
||||
|
totalValuablesInBaseCurrency, |
||||
|
grossPerformance: totalGrossPerformance, |
||||
|
grossPerformanceWithCurrencyEffect: |
||||
|
totalGrossPerformanceWithCurrencyEffect, |
||||
|
hasErrors: totalUnits.gt(0) && (!initialValue || !unitPriceAtEndDate), |
||||
|
netPerformance: totalNetPerformance, |
||||
|
timeWeightedInvestment: |
||||
|
timeWeightedAverageInvestmentBetweenStartAndEndDate, |
||||
|
timeWeightedInvestmentWithCurrencyEffect: |
||||
|
timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect |
||||
|
}; |
||||
|
} |
||||
|
} |
@ -1,19 +0,0 @@ |
|||||
import { ResponseError, TimelinePosition } from '@ghostfolio/common/interfaces'; |
|
||||
|
|
||||
import Big from 'big.js'; |
|
||||
|
|
||||
export interface CurrentPositions extends ResponseError { |
|
||||
positions: TimelinePosition[]; |
|
||||
grossPerformance: Big; |
|
||||
grossPerformanceWithCurrencyEffect: Big; |
|
||||
grossPerformancePercentage: Big; |
|
||||
grossPerformancePercentageWithCurrencyEffect: Big; |
|
||||
netAnnualizedPerformance?: Big; |
|
||||
netAnnualizedPerformanceWithCurrencyEffect?: Big; |
|
||||
netPerformance: Big; |
|
||||
netPerformanceWithCurrencyEffect: Big; |
|
||||
netPerformancePercentage: Big; |
|
||||
netPerformancePercentageWithCurrencyEffect: Big; |
|
||||
currentValue: Big; |
|
||||
totalInvestment: Big; |
|
||||
} |
|
@ -1,6 +1,6 @@ |
|||||
import { UniqueAsset } from '@ghostfolio/common/interfaces'; |
import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces'; |
||||
|
|
||||
export interface GetValueObject extends UniqueAsset { |
export interface GetValueObject extends AssetProfileIdentifier { |
||||
date: Date; |
date: Date; |
||||
marketPrice: number; |
marketPrice: number; |
||||
} |
} |
||||
|
@ -1,11 +1,12 @@ |
|||||
import Big from 'big.js'; |
import { Big } from 'big.js'; |
||||
|
|
||||
import { PortfolioOrder } from './portfolio-order.interface'; |
import { PortfolioOrder } from './portfolio-order.interface'; |
||||
|
|
||||
export interface PortfolioOrderItem extends PortfolioOrder { |
export interface PortfolioOrderItem extends PortfolioOrder { |
||||
feeInBaseCurrency?: Big; |
feeInBaseCurrency?: Big; |
||||
feeInBaseCurrencyWithCurrencyEffect?: Big; |
feeInBaseCurrencyWithCurrencyEffect?: Big; |
||||
itemType?: '' | 'start' | 'end'; |
itemType?: 'end' | 'start'; |
||||
|
unitPriceFromMarketData?: Big; |
||||
unitPriceInBaseCurrency?: Big; |
unitPriceInBaseCurrency?: Big; |
||||
unitPriceInBaseCurrencyWithCurrencyEffect?: Big; |
unitPriceInBaseCurrencyWithCurrencyEffect?: Big; |
||||
} |
} |
@ -1,15 +1,12 @@ |
|||||
import { DataSource, Tag, Type as TypeOfOrder } from '@prisma/client'; |
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; |
||||
import Big from 'big.js'; |
|
||||
|
|
||||
export interface PortfolioOrder { |
export interface PortfolioOrder extends Pick<Activity, 'tags' | 'type'> { |
||||
currency: string; |
|
||||
date: string; |
date: string; |
||||
dataSource: DataSource; |
|
||||
fee: Big; |
fee: Big; |
||||
name: string; |
|
||||
quantity: Big; |
quantity: Big; |
||||
symbol: string; |
SymbolProfile: Pick< |
||||
tags?: Tag[]; |
Activity['SymbolProfile'], |
||||
type: TypeOfOrder; |
'currency' | 'dataSource' | 'name' | 'symbol' |
||||
|
>; |
||||
unitPrice: Big; |
unitPrice: Big; |
||||
} |
} |
||||
|
@ -1,5 +0,0 @@ |
|||||
import { Position } from '@ghostfolio/common/interfaces'; |
|
||||
|
|
||||
export interface PortfolioPositions { |
|
||||
positions: Position[]; |
|
||||
} |
|
@ -1,6 +1,12 @@ |
|||||
|
import { Big } from 'big.js'; |
||||
|
|
||||
import { TransactionPointSymbol } from './transaction-point-symbol.interface'; |
import { TransactionPointSymbol } from './transaction-point-symbol.interface'; |
||||
|
|
||||
export interface TransactionPoint { |
export interface TransactionPoint { |
||||
date: string; |
date: string; |
||||
|
fees: Big; |
||||
|
interest: Big; |
||||
items: TransactionPointSymbol[]; |
items: TransactionPointSymbol[]; |
||||
|
liabilities: Big; |
||||
|
valuables: Big; |
||||
} |
} |
||||
|
@ -1,150 +0,0 @@ |
|||||
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; |
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; |
|
||||
import { parseDate } from '@ghostfolio/common/helper'; |
|
||||
|
|
||||
import Big from 'big.js'; |
|
||||
|
|
||||
import { CurrentRateServiceMock } from './current-rate.service.mock'; |
|
||||
import { PortfolioCalculator } from './portfolio-calculator'; |
|
||||
|
|
||||
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; |
|
||||
|
|
||||
beforeEach(() => { |
|
||||
currentRateService = new CurrentRateService(null, null); |
|
||||
|
|
||||
exchangeRateDataService = new ExchangeRateDataService( |
|
||||
null, |
|
||||
null, |
|
||||
null, |
|
||||
null |
|
||||
); |
|
||||
}); |
|
||||
|
|
||||
describe('get current positions', () => { |
|
||||
it.only('with BALN.SW buy and sell', async () => { |
|
||||
const portfolioCalculator = new PortfolioCalculator({ |
|
||||
currentRateService, |
|
||||
exchangeRateDataService, |
|
||||
currency: 'CHF', |
|
||||
orders: [ |
|
||||
{ |
|
||||
currency: 'CHF', |
|
||||
date: '2021-11-22', |
|
||||
dataSource: 'YAHOO', |
|
||||
fee: new Big(1.55), |
|
||||
name: 'Bâloise Holding AG', |
|
||||
quantity: new Big(2), |
|
||||
symbol: 'BALN.SW', |
|
||||
type: 'BUY', |
|
||||
unitPrice: new Big(142.9) |
|
||||
}, |
|
||||
{ |
|
||||
currency: 'CHF', |
|
||||
date: '2021-11-30', |
|
||||
dataSource: 'YAHOO', |
|
||||
fee: new Big(1.65), |
|
||||
name: 'Bâloise Holding AG', |
|
||||
quantity: new Big(2), |
|
||||
symbol: 'BALN.SW', |
|
||||
type: 'SELL', |
|
||||
unitPrice: new Big(136.6) |
|
||||
} |
|
||||
] |
|
||||
}); |
|
||||
|
|
||||
portfolioCalculator.computeTransactionPoints(); |
|
||||
|
|
||||
const spy = jest |
|
||||
.spyOn(Date, 'now') |
|
||||
.mockImplementation(() => parseDate('2021-12-18').getTime()); |
|
||||
|
|
||||
const chartData = await portfolioCalculator.getChartData({ |
|
||||
start: parseDate('2021-11-22') |
|
||||
}); |
|
||||
|
|
||||
const currentPositions = await portfolioCalculator.getCurrentPositions( |
|
||||
parseDate('2021-11-22') |
|
||||
); |
|
||||
|
|
||||
const investments = portfolioCalculator.getInvestments(); |
|
||||
|
|
||||
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({ |
|
||||
data: chartData, |
|
||||
groupBy: 'month' |
|
||||
}); |
|
||||
|
|
||||
spy.mockRestore(); |
|
||||
|
|
||||
expect(currentPositions).toEqual({ |
|
||||
currentValue: new Big('0'), |
|
||||
errors: [], |
|
||||
grossPerformance: new Big('-12.6'), |
|
||||
grossPerformancePercentage: new Big('-0.0440867739678096571'), |
|
||||
grossPerformancePercentageWithCurrencyEffect: new Big( |
|
||||
'-0.0440867739678096571' |
|
||||
), |
|
||||
grossPerformanceWithCurrencyEffect: new Big('-12.6'), |
|
||||
hasErrors: false, |
|
||||
netPerformance: new Big('-15.8'), |
|
||||
netPerformancePercentage: new Big('-0.0552834149755073478'), |
|
||||
netPerformancePercentageWithCurrencyEffect: new Big( |
|
||||
'-0.0552834149755073478' |
|
||||
), |
|
||||
netPerformanceWithCurrencyEffect: new Big('-15.8'), |
|
||||
positions: [ |
|
||||
{ |
|
||||
averagePrice: new Big('0'), |
|
||||
currency: 'CHF', |
|
||||
dataSource: 'YAHOO', |
|
||||
fee: new Big('3.2'), |
|
||||
firstBuyDate: '2021-11-22', |
|
||||
grossPerformance: new Big('-12.6'), |
|
||||
grossPerformancePercentage: new Big('-0.0440867739678096571'), |
|
||||
grossPerformancePercentageWithCurrencyEffect: new Big( |
|
||||
'-0.0440867739678096571' |
|
||||
), |
|
||||
grossPerformanceWithCurrencyEffect: new Big('-12.6'), |
|
||||
investment: new Big('0'), |
|
||||
investmentWithCurrencyEffect: new Big('0'), |
|
||||
netPerformance: new Big('-15.8'), |
|
||||
netPerformancePercentage: new Big('-0.0552834149755073478'), |
|
||||
netPerformancePercentageWithCurrencyEffect: new Big( |
|
||||
'-0.0552834149755073478' |
|
||||
), |
|
||||
netPerformanceWithCurrencyEffect: new Big('-15.8'), |
|
||||
marketPrice: 148.9, |
|
||||
marketPriceInBaseCurrency: 148.9, |
|
||||
quantity: new Big('0'), |
|
||||
symbol: 'BALN.SW', |
|
||||
timeWeightedInvestment: new Big('285.8'), |
|
||||
timeWeightedInvestmentWithCurrencyEffect: new Big('285.8'), |
|
||||
transactionCount: 2 |
|
||||
} |
|
||||
], |
|
||||
totalInvestment: new Big('0'), |
|
||||
totalInvestmentWithCurrencyEffect: 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 } |
|
||||
]); |
|
||||
}); |
|
||||
}); |
|
||||
}); |
|
@ -1,138 +0,0 @@ |
|||||
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; |
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; |
|
||||
import { parseDate } from '@ghostfolio/common/helper'; |
|
||||
|
|
||||
import Big from 'big.js'; |
|
||||
|
|
||||
import { CurrentRateServiceMock } from './current-rate.service.mock'; |
|
||||
import { PortfolioCalculator } from './portfolio-calculator'; |
|
||||
|
|
||||
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; |
|
||||
|
|
||||
beforeEach(() => { |
|
||||
currentRateService = new CurrentRateService(null, null); |
|
||||
|
|
||||
exchangeRateDataService = new ExchangeRateDataService( |
|
||||
null, |
|
||||
null, |
|
||||
null, |
|
||||
null |
|
||||
); |
|
||||
}); |
|
||||
|
|
||||
describe('get current positions', () => { |
|
||||
it.only('with BALN.SW buy', async () => { |
|
||||
const portfolioCalculator = new PortfolioCalculator({ |
|
||||
currentRateService, |
|
||||
exchangeRateDataService, |
|
||||
currency: 'CHF', |
|
||||
orders: [ |
|
||||
{ |
|
||||
currency: 'CHF', |
|
||||
date: '2021-11-30', |
|
||||
dataSource: 'YAHOO', |
|
||||
fee: new Big(1.55), |
|
||||
name: 'Bâloise Holding AG', |
|
||||
quantity: new Big(2), |
|
||||
symbol: 'BALN.SW', |
|
||||
type: 'BUY', |
|
||||
unitPrice: new Big(136.6) |
|
||||
} |
|
||||
] |
|
||||
}); |
|
||||
|
|
||||
portfolioCalculator.computeTransactionPoints(); |
|
||||
|
|
||||
const spy = jest |
|
||||
.spyOn(Date, 'now') |
|
||||
.mockImplementation(() => parseDate('2021-12-18').getTime()); |
|
||||
|
|
||||
const chartData = await portfolioCalculator.getChartData({ |
|
||||
start: parseDate('2021-11-30') |
|
||||
}); |
|
||||
|
|
||||
const currentPositions = await portfolioCalculator.getCurrentPositions( |
|
||||
parseDate('2021-11-30') |
|
||||
); |
|
||||
|
|
||||
const investments = portfolioCalculator.getInvestments(); |
|
||||
|
|
||||
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({ |
|
||||
data: chartData, |
|
||||
groupBy: 'month' |
|
||||
}); |
|
||||
|
|
||||
spy.mockRestore(); |
|
||||
|
|
||||
expect(currentPositions).toEqual({ |
|
||||
currentValue: new Big('297.8'), |
|
||||
errors: [], |
|
||||
grossPerformance: new Big('24.6'), |
|
||||
grossPerformancePercentage: new Big('0.09004392386530014641'), |
|
||||
grossPerformancePercentageWithCurrencyEffect: new Big( |
|
||||
'0.09004392386530014641' |
|
||||
), |
|
||||
grossPerformanceWithCurrencyEffect: new Big('24.6'), |
|
||||
hasErrors: false, |
|
||||
netPerformance: new Big('23.05'), |
|
||||
netPerformancePercentage: new Big('0.08437042459736456808'), |
|
||||
netPerformancePercentageWithCurrencyEffect: new Big( |
|
||||
'0.08437042459736456808' |
|
||||
), |
|
||||
netPerformanceWithCurrencyEffect: new Big('23.05'), |
|
||||
positions: [ |
|
||||
{ |
|
||||
averagePrice: new Big('136.6'), |
|
||||
currency: 'CHF', |
|
||||
dataSource: 'YAHOO', |
|
||||
fee: new Big('1.55'), |
|
||||
firstBuyDate: '2021-11-30', |
|
||||
grossPerformance: new Big('24.6'), |
|
||||
grossPerformancePercentage: new Big('0.09004392386530014641'), |
|
||||
grossPerformancePercentageWithCurrencyEffect: new Big( |
|
||||
'0.09004392386530014641' |
|
||||
), |
|
||||
grossPerformanceWithCurrencyEffect: new Big('24.6'), |
|
||||
investment: new Big('273.2'), |
|
||||
investmentWithCurrencyEffect: new Big('273.2'), |
|
||||
netPerformance: new Big('23.05'), |
|
||||
netPerformancePercentage: new Big('0.08437042459736456808'), |
|
||||
netPerformancePercentageWithCurrencyEffect: new Big( |
|
||||
'0.08437042459736456808' |
|
||||
), |
|
||||
netPerformanceWithCurrencyEffect: new Big('23.05'), |
|
||||
marketPrice: 148.9, |
|
||||
marketPriceInBaseCurrency: 148.9, |
|
||||
quantity: new Big('2'), |
|
||||
symbol: 'BALN.SW', |
|
||||
timeWeightedInvestment: new Big('273.2'), |
|
||||
timeWeightedInvestmentWithCurrencyEffect: new Big('273.2'), |
|
||||
transactionCount: 1 |
|
||||
} |
|
||||
], |
|
||||
totalInvestment: new Big('273.2'), |
|
||||
totalInvestmentWithCurrencyEffect: new Big('273.2') |
|
||||
}); |
|
||||
|
|
||||
expect(investments).toEqual([ |
|
||||
{ date: '2021-11-30', investment: new Big('273.2') } |
|
||||
]); |
|
||||
|
|
||||
expect(investmentsByMonth).toEqual([ |
|
||||
{ date: '2021-11-01', investment: 273.2 }, |
|
||||
{ date: '2021-12-01', investment: 0 } |
|
||||
]); |
|
||||
}); |
|
||||
}); |
|
||||
}); |
|
@ -1,175 +0,0 @@ |
|||||
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; |
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; |
|
||||
import { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service.mock'; |
|
||||
import { parseDate } from '@ghostfolio/common/helper'; |
|
||||
|
|
||||
import Big from 'big.js'; |
|
||||
|
|
||||
import { CurrentRateServiceMock } from './current-rate.service.mock'; |
|
||||
import { PortfolioCalculator } from './portfolio-calculator'; |
|
||||
|
|
||||
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; |
|
||||
|
|
||||
beforeEach(() => { |
|
||||
currentRateService = new CurrentRateService(null, null); |
|
||||
|
|
||||
exchangeRateDataService = new ExchangeRateDataService( |
|
||||
null, |
|
||||
null, |
|
||||
null, |
|
||||
null |
|
||||
); |
|
||||
}); |
|
||||
|
|
||||
describe('get current positions', () => { |
|
||||
it.only('with GOOGL buy', async () => { |
|
||||
const portfolioCalculator = new PortfolioCalculator({ |
|
||||
currentRateService, |
|
||||
exchangeRateDataService, |
|
||||
currency: 'CHF', |
|
||||
orders: [ |
|
||||
{ |
|
||||
currency: 'USD', |
|
||||
date: '2023-01-03', |
|
||||
dataSource: 'YAHOO', |
|
||||
fee: new Big(1), |
|
||||
name: 'Alphabet Inc.', |
|
||||
quantity: new Big(1), |
|
||||
symbol: 'GOOGL', |
|
||||
type: 'BUY', |
|
||||
unitPrice: new Big(89.12) |
|
||||
} |
|
||||
] |
|
||||
}); |
|
||||
|
|
||||
portfolioCalculator.computeTransactionPoints(); |
|
||||
|
|
||||
const spy = jest |
|
||||
.spyOn(Date, 'now') |
|
||||
.mockImplementation(() => parseDate('2023-07-10').getTime()); |
|
||||
|
|
||||
const chartData = await portfolioCalculator.getChartData({ |
|
||||
start: parseDate('2023-01-03') |
|
||||
}); |
|
||||
|
|
||||
const currentPositions = await portfolioCalculator.getCurrentPositions( |
|
||||
parseDate('2023-01-03') |
|
||||
); |
|
||||
|
|
||||
const investments = portfolioCalculator.getInvestments(); |
|
||||
|
|
||||
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({ |
|
||||
data: chartData, |
|
||||
groupBy: 'month' |
|
||||
}); |
|
||||
|
|
||||
spy.mockRestore(); |
|
||||
|
|
||||
expect(currentPositions).toEqual({ |
|
||||
currentValue: new Big('103.10483'), |
|
||||
errors: [], |
|
||||
grossPerformance: new Big('27.33'), |
|
||||
grossPerformancePercentage: new Big('0.3066651705565529623'), |
|
||||
grossPerformancePercentageWithCurrencyEffect: new Big( |
|
||||
'0.25235044599563974109' |
|
||||
), |
|
||||
grossPerformanceWithCurrencyEffect: new Big('20.775774'), |
|
||||
hasErrors: false, |
|
||||
netPerformance: new Big('26.33'), |
|
||||
netPerformancePercentage: new Big('0.29544434470377019749'), |
|
||||
netPerformancePercentageWithCurrencyEffect: new Big( |
|
||||
'0.24112962014285697628' |
|
||||
), |
|
||||
netPerformanceWithCurrencyEffect: new Big('19.851974'), |
|
||||
positions: [ |
|
||||
{ |
|
||||
averagePrice: new Big('89.12'), |
|
||||
currency: 'USD', |
|
||||
dataSource: 'YAHOO', |
|
||||
fee: new Big('1'), |
|
||||
firstBuyDate: '2023-01-03', |
|
||||
grossPerformance: new Big('27.33'), |
|
||||
grossPerformancePercentage: new Big('0.3066651705565529623'), |
|
||||
grossPerformancePercentageWithCurrencyEffect: new Big( |
|
||||
'0.25235044599563974109' |
|
||||
), |
|
||||
grossPerformanceWithCurrencyEffect: new Big('20.775774'), |
|
||||
investment: new Big('89.12'), |
|
||||
investmentWithCurrencyEffect: new Big('82.329056'), |
|
||||
netPerformance: new Big('26.33'), |
|
||||
netPerformancePercentage: new Big('0.29544434470377019749'), |
|
||||
netPerformancePercentageWithCurrencyEffect: new Big( |
|
||||
'0.24112962014285697628' |
|
||||
), |
|
||||
netPerformanceWithCurrencyEffect: new Big('19.851974'), |
|
||||
marketPrice: 116.45, |
|
||||
marketPriceInBaseCurrency: 103.10483, |
|
||||
quantity: new Big('1'), |
|
||||
symbol: 'GOOGL', |
|
||||
tags: undefined, |
|
||||
timeWeightedInvestment: new Big('89.12'), |
|
||||
timeWeightedInvestmentWithCurrencyEffect: new Big('82.329056'), |
|
||||
transactionCount: 1 |
|
||||
} |
|
||||
], |
|
||||
totalInvestment: new Big('89.12'), |
|
||||
totalInvestmentWithCurrencyEffect: new Big('82.329056') |
|
||||
}); |
|
||||
|
|
||||
expect(investments).toEqual([ |
|
||||
{ date: '2023-01-03', investment: new Big('89.12') } |
|
||||
]); |
|
||||
|
|
||||
expect(investmentsByMonth).toEqual([ |
|
||||
{ date: '2023-01-01', investment: 82.329056 }, |
|
||||
{ |
|
||||
date: '2023-02-01', |
|
||||
investment: 0 |
|
||||
}, |
|
||||
{ |
|
||||
date: '2023-03-01', |
|
||||
investment: 0 |
|
||||
}, |
|
||||
{ |
|
||||
date: '2023-04-01', |
|
||||
investment: 0 |
|
||||
}, |
|
||||
{ |
|
||||
date: '2023-05-01', |
|
||||
investment: 0 |
|
||||
}, |
|
||||
{ |
|
||||
date: '2023-06-01', |
|
||||
investment: 0 |
|
||||
}, |
|
||||
{ |
|
||||
date: '2023-07-01', |
|
||||
investment: 0 |
|
||||
} |
|
||||
]); |
|
||||
}); |
|
||||
}); |
|
||||
}); |
|
@ -1,86 +0,0 @@ |
|||||
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; |
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; |
|
||||
import { parseDate } from '@ghostfolio/common/helper'; |
|
||||
|
|
||||
import Big from 'big.js'; |
|
||||
|
|
||||
import { CurrentRateServiceMock } from './current-rate.service.mock'; |
|
||||
import { PortfolioCalculator } from './portfolio-calculator'; |
|
||||
|
|
||||
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; |
|
||||
|
|
||||
beforeEach(() => { |
|
||||
currentRateService = new CurrentRateService(null, null); |
|
||||
|
|
||||
exchangeRateDataService = new ExchangeRateDataService( |
|
||||
null, |
|
||||
null, |
|
||||
null, |
|
||||
null |
|
||||
); |
|
||||
}); |
|
||||
|
|
||||
describe('get current positions', () => { |
|
||||
it('with no orders', async () => { |
|
||||
const portfolioCalculator = new PortfolioCalculator({ |
|
||||
currentRateService, |
|
||||
exchangeRateDataService, |
|
||||
currency: 'CHF', |
|
||||
orders: [] |
|
||||
}); |
|
||||
|
|
||||
portfolioCalculator.computeTransactionPoints(); |
|
||||
|
|
||||
const spy = jest |
|
||||
.spyOn(Date, 'now') |
|
||||
.mockImplementation(() => parseDate('2021-12-18').getTime()); |
|
||||
|
|
||||
const chartData = await portfolioCalculator.getChartData({ |
|
||||
start: new Date() |
|
||||
}); |
|
||||
|
|
||||
const currentPositions = await portfolioCalculator.getCurrentPositions( |
|
||||
new Date() |
|
||||
); |
|
||||
|
|
||||
const investments = portfolioCalculator.getInvestments(); |
|
||||
|
|
||||
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({ |
|
||||
data: chartData, |
|
||||
groupBy: 'month' |
|
||||
}); |
|
||||
|
|
||||
spy.mockRestore(); |
|
||||
|
|
||||
expect(currentPositions).toEqual({ |
|
||||
currentValue: 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: [], |
|
||||
totalInvestment: new Big(0) |
|
||||
}); |
|
||||
|
|
||||
expect(investments).toEqual([]); |
|
||||
|
|
||||
expect(investmentsByMonth).toEqual([]); |
|
||||
}); |
|
||||
}); |
|
||||
}); |
|
@ -1,152 +0,0 @@ |
|||||
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; |
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; |
|
||||
import { parseDate } from '@ghostfolio/common/helper'; |
|
||||
|
|
||||
import Big from 'big.js'; |
|
||||
|
|
||||
import { CurrentRateServiceMock } from './current-rate.service.mock'; |
|
||||
import { PortfolioCalculator } from './portfolio-calculator'; |
|
||||
|
|
||||
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; |
|
||||
|
|
||||
beforeEach(() => { |
|
||||
currentRateService = new CurrentRateService(null, null); |
|
||||
|
|
||||
exchangeRateDataService = new ExchangeRateDataService( |
|
||||
null, |
|
||||
null, |
|
||||
null, |
|
||||
null |
|
||||
); |
|
||||
}); |
|
||||
|
|
||||
describe('get current positions', () => { |
|
||||
it.only('with NOVN.SW buy and sell partially', async () => { |
|
||||
const portfolioCalculator = new PortfolioCalculator({ |
|
||||
currentRateService, |
|
||||
exchangeRateDataService, |
|
||||
currency: 'CHF', |
|
||||
orders: [ |
|
||||
{ |
|
||||
currency: 'CHF', |
|
||||
date: '2022-03-07', |
|
||||
dataSource: 'YAHOO', |
|
||||
fee: new Big(1.3), |
|
||||
name: 'Novartis AG', |
|
||||
quantity: new Big(2), |
|
||||
symbol: 'NOVN.SW', |
|
||||
type: 'BUY', |
|
||||
unitPrice: new Big(75.8) |
|
||||
}, |
|
||||
{ |
|
||||
currency: 'CHF', |
|
||||
date: '2022-04-08', |
|
||||
dataSource: 'YAHOO', |
|
||||
fee: new Big(2.95), |
|
||||
name: 'Novartis AG', |
|
||||
quantity: new Big(1), |
|
||||
symbol: 'NOVN.SW', |
|
||||
type: 'SELL', |
|
||||
unitPrice: new Big(85.73) |
|
||||
} |
|
||||
] |
|
||||
}); |
|
||||
|
|
||||
portfolioCalculator.computeTransactionPoints(); |
|
||||
|
|
||||
const spy = jest |
|
||||
.spyOn(Date, 'now') |
|
||||
.mockImplementation(() => parseDate('2022-04-11').getTime()); |
|
||||
|
|
||||
const chartData = await portfolioCalculator.getChartData({ |
|
||||
start: parseDate('2022-03-07') |
|
||||
}); |
|
||||
|
|
||||
const currentPositions = await portfolioCalculator.getCurrentPositions( |
|
||||
parseDate('2022-03-07') |
|
||||
); |
|
||||
|
|
||||
const investments = portfolioCalculator.getInvestments(); |
|
||||
|
|
||||
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({ |
|
||||
data: chartData, |
|
||||
groupBy: 'month' |
|
||||
}); |
|
||||
|
|
||||
spy.mockRestore(); |
|
||||
|
|
||||
expect(currentPositions).toEqual({ |
|
||||
currentValue: new Big('87.8'), |
|
||||
errors: [], |
|
||||
grossPerformance: new Big('21.93'), |
|
||||
grossPerformancePercentage: new Big('0.15113417083448194384'), |
|
||||
grossPerformancePercentageWithCurrencyEffect: new Big( |
|
||||
'0.15113417083448194384' |
|
||||
), |
|
||||
grossPerformanceWithCurrencyEffect: new Big('21.93'), |
|
||||
hasErrors: false, |
|
||||
netPerformance: new Big('17.68'), |
|
||||
netPerformancePercentage: new Big('0.12184460284330327256'), |
|
||||
netPerformancePercentageWithCurrencyEffect: new Big( |
|
||||
'0.12184460284330327256' |
|
||||
), |
|
||||
netPerformanceWithCurrencyEffect: new Big('17.68'), |
|
||||
positions: [ |
|
||||
{ |
|
||||
averagePrice: new Big('75.80'), |
|
||||
currency: 'CHF', |
|
||||
dataSource: 'YAHOO', |
|
||||
fee: new Big('4.25'), |
|
||||
firstBuyDate: '2022-03-07', |
|
||||
grossPerformance: new Big('21.93'), |
|
||||
grossPerformancePercentage: new Big('0.15113417083448194384'), |
|
||||
grossPerformancePercentageWithCurrencyEffect: new Big( |
|
||||
'0.15113417083448194384' |
|
||||
), |
|
||||
grossPerformanceWithCurrencyEffect: new Big('21.93'), |
|
||||
investment: new Big('75.80'), |
|
||||
investmentWithCurrencyEffect: new Big('75.80'), |
|
||||
netPerformance: new Big('17.68'), |
|
||||
netPerformancePercentage: new Big('0.12184460284330327256'), |
|
||||
netPerformancePercentageWithCurrencyEffect: new Big( |
|
||||
'0.12184460284330327256' |
|
||||
), |
|
||||
netPerformanceWithCurrencyEffect: new Big('17.68'), |
|
||||
marketPrice: 87.8, |
|
||||
marketPriceInBaseCurrency: 87.8, |
|
||||
quantity: new Big('1'), |
|
||||
symbol: 'NOVN.SW', |
|
||||
timeWeightedInvestment: new Big('145.10285714285714285714'), |
|
||||
timeWeightedInvestmentWithCurrencyEffect: new Big( |
|
||||
'145.10285714285714285714' |
|
||||
), |
|
||||
transactionCount: 2 |
|
||||
} |
|
||||
], |
|
||||
totalInvestment: new Big('75.80'), |
|
||||
totalInvestmentWithCurrencyEffect: new Big('75.80') |
|
||||
}); |
|
||||
|
|
||||
expect(investments).toEqual([ |
|
||||
{ date: '2022-03-07', investment: new Big('151.6') }, |
|
||||
{ date: '2022-04-08', investment: new Big('75.8') } |
|
||||
]); |
|
||||
|
|
||||
expect(investmentsByMonth).toEqual([ |
|
||||
{ date: '2022-03-01', investment: 151.6 }, |
|
||||
{ date: '2022-04-01', investment: -75.8 } |
|
||||
]); |
|
||||
}); |
|
||||
}); |
|
||||
}); |
|
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue