mirror of https://github.com/ghostfolio/ghostfolio
116 changed files with 22524 additions and 36839 deletions
@ -0,0 +1 @@ |
|||||
|
14d4daf73eefed7da7c32ec19bc37e678be0244fb46c8f4965bfe9ece7384706ed58222ad9b96323893c1d845bc33a308e7524c2c79636062cbb095e0780cb51 |
@ -1,24 +0,0 @@ |
|||||
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 |
|
||||
|
|
@ -0,0 +1,47 @@ |
|||||
|
name: Docker image CD - Branch |
||||
|
|
||||
|
on: |
||||
|
push: |
||||
|
branches: |
||||
|
- '*' |
||||
|
|
||||
|
jobs: |
||||
|
build_and_push: |
||||
|
runs-on: ubuntu-latest |
||||
|
steps: |
||||
|
- name: Checkout code |
||||
|
uses: actions/checkout@v3 |
||||
|
|
||||
|
- name: Docker metadata |
||||
|
id: meta |
||||
|
uses: docker/metadata-action@v4 |
||||
|
with: |
||||
|
images: dandevaud/ghostfolio |
||||
|
tags: | |
||||
|
type=semver,pattern={{major}} |
||||
|
type=semver,pattern={{version}} |
||||
|
|
||||
|
- name: Set up QEMU |
||||
|
uses: docker/setup-qemu-action@v2 |
||||
|
|
||||
|
- name: Set up Docker Buildx |
||||
|
id: buildx |
||||
|
uses: docker/setup-buildx-action@v2 |
||||
|
|
||||
|
- name: Login to DockerHub |
||||
|
if: github.event_name != 'pull_request' |
||||
|
uses: docker/login-action@v2 |
||||
|
with: |
||||
|
username: ${{ secrets.DOCKER_HUB_USERNAME }} |
||||
|
password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} |
||||
|
|
||||
|
- name: Build and push |
||||
|
uses: docker/build-push-action@v3 |
||||
|
with: |
||||
|
context: . |
||||
|
platforms: linux/amd64,linux/arm/v7,linux/arm64 |
||||
|
push: ${{ github.event_name != 'pull_request' }} |
||||
|
tags: dandevaud/ghostfolio:${{ github.ref_name }} |
||||
|
labels: ${{ steps.meta.output.labels }} |
||||
|
cache-from: type=gha |
||||
|
cache-to: type=gha,mode=max |
@ -0,0 +1,47 @@ |
|||||
|
name: Docker image CD - DEV |
||||
|
|
||||
|
on: |
||||
|
push: |
||||
|
branches: |
||||
|
- 'dockerpush' |
||||
|
|
||||
|
jobs: |
||||
|
build_and_push: |
||||
|
runs-on: ubuntu-latest |
||||
|
steps: |
||||
|
- name: Checkout code |
||||
|
uses: actions/checkout@v3 |
||||
|
|
||||
|
- name: Docker metadata |
||||
|
id: meta |
||||
|
uses: docker/metadata-action@v4 |
||||
|
with: |
||||
|
images: dandevaud/ghostfolio |
||||
|
tags: | |
||||
|
type=semver,pattern={{major}} |
||||
|
type=semver,pattern={{version}} |
||||
|
|
||||
|
- name: Set up QEMU |
||||
|
uses: docker/setup-qemu-action@v2 |
||||
|
|
||||
|
- name: Set up Docker Buildx |
||||
|
id: buildx |
||||
|
uses: docker/setup-buildx-action@v2 |
||||
|
|
||||
|
- name: Login to DockerHub |
||||
|
if: github.event_name != 'pull_request' |
||||
|
uses: docker/login-action@v2 |
||||
|
with: |
||||
|
username: ${{ secrets.DOCKER_HUB_USERNAME }} |
||||
|
password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} |
||||
|
|
||||
|
- name: Build and push |
||||
|
uses: docker/build-push-action@v3 |
||||
|
with: |
||||
|
context: . |
||||
|
platforms: linux/amd64,linux/arm/v7,linux/arm64 |
||||
|
push: ${{ github.event_name != 'pull_request' }} |
||||
|
tags: dandevaud/ghostfolio:beta |
||||
|
labels: ${{ steps.meta.output.labels }} |
||||
|
cache-from: type=gha |
||||
|
cache-to: type=gha,mode=max |
@ -0,0 +1,47 @@ |
|||||
|
name: Docker image CD - DEV |
||||
|
|
||||
|
on: |
||||
|
push: |
||||
|
branches: |
||||
|
- 'main' |
||||
|
|
||||
|
jobs: |
||||
|
build_and_push: |
||||
|
runs-on: ubuntu-latest |
||||
|
steps: |
||||
|
- name: Checkout code |
||||
|
uses: actions/checkout@v3 |
||||
|
|
||||
|
- name: Docker metadata |
||||
|
id: meta |
||||
|
uses: docker/metadata-action@v4 |
||||
|
with: |
||||
|
images: dandevaud/ghostfolio |
||||
|
tags: | |
||||
|
type=semver,pattern={{major}} |
||||
|
type=semver,pattern={{version}} |
||||
|
|
||||
|
- name: Set up QEMU |
||||
|
uses: docker/setup-qemu-action@v2 |
||||
|
|
||||
|
- name: Set up Docker Buildx |
||||
|
id: buildx |
||||
|
uses: docker/setup-buildx-action@v2 |
||||
|
|
||||
|
- name: Login to DockerHub |
||||
|
if: github.event_name != 'pull_request' |
||||
|
uses: docker/login-action@v2 |
||||
|
with: |
||||
|
username: ${{ secrets.DOCKER_HUB_USERNAME }} |
||||
|
password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} |
||||
|
|
||||
|
- name: Build and push |
||||
|
uses: docker/build-push-action@v3 |
||||
|
with: |
||||
|
context: . |
||||
|
platforms: linux/amd64,linux/arm/v7,linux/arm64 |
||||
|
push: ${{ github.event_name != 'pull_request' }} |
||||
|
tags: dandevaud/ghostfolio:main |
||||
|
labels: ${{ steps.meta.output.labels }} |
||||
|
cache-from: type=gha |
||||
|
cache-to: type=gha,mode=max |
@ -1,6 +1,2 @@ |
|||||
# Run linting and stop the commit process if any errors are found |
|
||||
# --quiet suppresses warnings (temporary until all warnings are fixed) |
|
||||
npm run affected:lint --base=main --head=HEAD --parallel=2 --quiet || exit 1 |
|
||||
|
|
||||
# Check formatting on modified and uncommitted files, stop the commit if issues are found |
# Check formatting on modified and uncommitted files, stop the commit if issues are found |
||||
npm run format:check --uncommitted || exit 1 |
npm run format:write --uncommitted || exit 1 |
||||
|
@ -0,0 +1,6 @@ |
|||||
|
# Run linting and stop the commit process if any errors are found |
||||
|
# --quiet suppresses warnings (temporary until all warnings are fixed) |
||||
|
npm run affected:lint --base=main --head=HEAD --parallel=2 --quiet || exit 1 |
||||
|
|
||||
|
# Check formatting on modified and uncommitted files, stop the commit if issues are found |
||||
|
npm run format:check --uncommitted || exit 1 |
File diff suppressed because it is too large
@ -1,256 +1,264 @@ |
|||||
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto'; |
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto'; |
||||
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; |
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; |
||||
import { |
import { |
||||
activityDummyData, |
activityDummyData, |
||||
loadActivityExportFile, |
loadActivityExportFile, |
||||
symbolProfileDummyData, |
symbolProfileDummyData, |
||||
userDummyData |
userDummyData |
||||
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; |
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; |
||||
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; |
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; |
||||
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; |
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; |
||||
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; |
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; |
||||
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; |
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; |
||||
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; |
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; |
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; |
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; |
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; |
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; |
||||
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; |
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; |
||||
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; |
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; |
||||
import { parseDate } from '@ghostfolio/common/helper'; |
import { parseDate } from '@ghostfolio/common/helper'; |
||||
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; |
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; |
||||
|
|
||||
import { Big } from 'big.js'; |
import { Big } from 'big.js'; |
||||
import { join } from 'path'; |
import { join } from 'path'; |
||||
|
|
||||
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { |
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { |
||||
return { |
return { |
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
CurrentRateService: jest.fn().mockImplementation(() => { |
CurrentRateService: jest.fn().mockImplementation(() => { |
||||
return CurrentRateServiceMock; |
return CurrentRateServiceMock; |
||||
}) |
}) |
||||
}; |
}; |
||||
}); |
}); |
||||
|
|
||||
jest.mock( |
jest.mock( |
||||
'@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service', |
'@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service', |
||||
() => { |
() => { |
||||
return { |
return { |
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
PortfolioSnapshotService: jest.fn().mockImplementation(() => { |
PortfolioSnapshotService: jest.fn().mockImplementation(() => { |
||||
return PortfolioSnapshotServiceMock; |
return PortfolioSnapshotServiceMock; |
||||
}) |
}) |
||||
}; |
}; |
||||
} |
} |
||||
); |
); |
||||
|
|
||||
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { |
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { |
||||
return { |
return { |
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
RedisCacheService: jest.fn().mockImplementation(() => { |
RedisCacheService: jest.fn().mockImplementation(() => { |
||||
return RedisCacheServiceMock; |
return RedisCacheServiceMock; |
||||
}) |
}) |
||||
}; |
}; |
||||
}); |
}); |
||||
|
|
||||
describe('PortfolioCalculator', () => { |
describe('PortfolioCalculator', () => { |
||||
let activityDtos: CreateOrderDto[]; |
let activityDtos: CreateOrderDto[]; |
||||
|
|
||||
let configurationService: ConfigurationService; |
let configurationService: ConfigurationService; |
||||
let currentRateService: CurrentRateService; |
let currentRateService: CurrentRateService; |
||||
let exchangeRateDataService: ExchangeRateDataService; |
let exchangeRateDataService: ExchangeRateDataService; |
||||
let portfolioCalculatorFactory: PortfolioCalculatorFactory; |
let portfolioCalculatorFactory: PortfolioCalculatorFactory; |
||||
let portfolioSnapshotService: PortfolioSnapshotService; |
let portfolioSnapshotService: PortfolioSnapshotService; |
||||
let redisCacheService: RedisCacheService; |
let redisCacheService: RedisCacheService; |
||||
|
|
||||
beforeAll(() => { |
beforeAll(() => { |
||||
activityDtos = loadActivityExportFile( |
activityDtos = loadActivityExportFile( |
||||
join( |
join( |
||||
__dirname, |
__dirname, |
||||
'../../../../../../../test/import/ok-novn-buy-and-sell.json' |
'../../../../../../../test/import/ok-novn-buy-and-sell.json' |
||||
) |
) |
||||
); |
); |
||||
}); |
}); |
||||
|
|
||||
beforeEach(() => { |
beforeEach(() => { |
||||
configurationService = new ConfigurationService(); |
configurationService = new ConfigurationService(); |
||||
|
|
||||
currentRateService = new CurrentRateService(null, null, null, null); |
currentRateService = new CurrentRateService(null, null, null, null); |
||||
|
|
||||
exchangeRateDataService = new ExchangeRateDataService( |
exchangeRateDataService = new ExchangeRateDataService( |
||||
null, |
null, |
||||
null, |
null, |
||||
null, |
null, |
||||
null |
null |
||||
); |
); |
||||
|
|
||||
portfolioSnapshotService = new PortfolioSnapshotService(null); |
portfolioSnapshotService = new PortfolioSnapshotService(null); |
||||
|
|
||||
redisCacheService = new RedisCacheService(null, null); |
redisCacheService = new RedisCacheService(null, null); |
||||
|
|
||||
portfolioCalculatorFactory = new PortfolioCalculatorFactory( |
portfolioCalculatorFactory = new PortfolioCalculatorFactory( |
||||
configurationService, |
configurationService, |
||||
currentRateService, |
currentRateService, |
||||
exchangeRateDataService, |
exchangeRateDataService, |
||||
portfolioSnapshotService, |
portfolioSnapshotService, |
||||
redisCacheService |
redisCacheService, |
||||
); |
null |
||||
}); |
); |
||||
|
}); |
||||
describe('get current positions', () => { |
|
||||
it.only('with NOVN.SW buy and sell', async () => { |
describe('get current positions', () => { |
||||
jest.useFakeTimers().setSystemTime(parseDate('2022-04-11').getTime()); |
it.only('with NOVN.SW buy and sell', async () => { |
||||
|
jest.useFakeTimers().setSystemTime(parseDate('2022-04-11').getTime()); |
||||
const activities: Activity[] = activityDtos.map((activity) => ({ |
|
||||
...activityDummyData, |
const activities: Activity[] = activityDtos.map((activity) => ({ |
||||
...activity, |
...activityDummyData, |
||||
date: parseDate(activity.date), |
...activity, |
||||
feeInAssetProfileCurrency: activity.fee, |
date: parseDate(activity.date), |
||||
SymbolProfile: { |
feeInAssetProfileCurrency: activity.fee, |
||||
...symbolProfileDummyData, |
SymbolProfile: { |
||||
currency: activity.currency, |
...symbolProfileDummyData, |
||||
dataSource: activity.dataSource, |
currency: activity.currency, |
||||
name: 'Novartis AG', |
dataSource: activity.dataSource, |
||||
symbol: activity.symbol |
name: 'Novartis AG', |
||||
}, |
symbol: activity.symbol |
||||
unitPriceInAssetProfileCurrency: activity.unitPrice |
}, |
||||
})); |
unitPriceInAssetProfileCurrency: activity.unitPrice |
||||
|
})); |
||||
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ |
|
||||
activities, |
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ |
||||
calculationType: PerformanceCalculationType.ROAI, |
activities, |
||||
currency: 'CHF', |
calculationType: PerformanceCalculationType.ROAI, |
||||
userId: userDummyData.id |
currency: 'CHF', |
||||
}); |
userId: userDummyData.id |
||||
|
}); |
||||
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); |
|
||||
|
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); |
||||
const investments = portfolioCalculator.getInvestments(); |
|
||||
|
const investments = portfolioCalculator.getInvestments(); |
||||
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({ |
|
||||
data: portfolioSnapshot.historicalData, |
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({ |
||||
groupBy: 'month' |
data: portfolioSnapshot.historicalData, |
||||
}); |
groupBy: 'month' |
||||
|
}); |
||||
expect(portfolioSnapshot.historicalData[0]).toEqual({ |
|
||||
date: '2022-03-06', |
expect(portfolioSnapshot.historicalData[0]).toEqual({ |
||||
investmentValueWithCurrencyEffect: 0, |
date: '2022-03-06', |
||||
netPerformance: 0, |
investmentValueWithCurrencyEffect: 0, |
||||
netPerformanceInPercentage: 0, |
netPerformance: 0, |
||||
netPerformanceInPercentageWithCurrencyEffect: 0, |
netPerformanceInPercentage: 0, |
||||
netPerformanceWithCurrencyEffect: 0, |
netPerformanceInPercentageWithCurrencyEffect: 0, |
||||
netWorth: 0, |
netPerformanceWithCurrencyEffect: 0, |
||||
totalAccountBalance: 0, |
netWorth: 0, |
||||
totalInvestment: 0, |
timeWeightedPerformanceInPercentage: 0, |
||||
totalInvestmentValueWithCurrencyEffect: 0, |
timeWeightedPerformanceInPercentageWithCurrencyEffect: 0, |
||||
value: 0, |
totalAccountBalance: 0, |
||||
valueWithCurrencyEffect: 0 |
totalInvestment: 0, |
||||
}); |
totalInvestmentValueWithCurrencyEffect: 0, |
||||
|
value: 0, |
||||
/** |
valueWithCurrencyEffect: 0 |
||||
* Closing price on 2022-03-07 is unknown, |
}); |
||||
* hence it uses the last unit price (2022-04-11): 87.8 |
|
||||
*/ |
/** |
||||
expect(portfolioSnapshot.historicalData[1]).toEqual({ |
* Closing price on 2022-03-07 is unknown, |
||||
date: '2022-03-07', |
* hence it uses the last unit price (2022-04-11): 87.8 |
||||
investmentValueWithCurrencyEffect: 151.6, |
*/ |
||||
netPerformance: 24, // 2 * (87.8 - 75.8) = 24
|
expect(portfolioSnapshot.historicalData[1]).toEqual({ |
||||
netPerformanceInPercentage: 0.158311345646438, // 24 ÷ 151.6 = 0.158311345646438
|
date: '2022-03-07', |
||||
netPerformanceInPercentageWithCurrencyEffect: 0.158311345646438, // 24 ÷ 151.6 = 0.158311345646438
|
investmentValueWithCurrencyEffect: 151.6, |
||||
netPerformanceWithCurrencyEffect: 24, |
netPerformance: 24, |
||||
netWorth: 175.6, // 2 * 87.8 = 175.6
|
netPerformanceInPercentage: 0.158311345646438, |
||||
totalAccountBalance: 0, |
netPerformanceInPercentageWithCurrencyEffect: 0.158311345646438, |
||||
totalInvestment: 151.6, |
netPerformanceWithCurrencyEffect: 24, |
||||
totalInvestmentValueWithCurrencyEffect: 151.6, |
timeWeightedPerformanceInPercentage: 0, |
||||
value: 175.6, // 2 * 87.8 = 175.6
|
timeWeightedPerformanceInPercentageWithCurrencyEffect: 0, |
||||
valueWithCurrencyEffect: 175.6 |
netWorth: 175.6, |
||||
}); |
totalAccountBalance: 0, |
||||
|
totalInvestment: 151.6, |
||||
expect( |
totalInvestmentValueWithCurrencyEffect: 151.6, |
||||
portfolioSnapshot.historicalData[ |
value: 175.6, // 2 * 87.8 = 175.6
|
||||
portfolioSnapshot.historicalData.length - 1 |
valueWithCurrencyEffect: 175.6 |
||||
] |
}); |
||||
).toEqual({ |
|
||||
date: '2022-04-11', |
expect( |
||||
investmentValueWithCurrencyEffect: 0, |
portfolioSnapshot.historicalData[ |
||||
netPerformance: 19.86, |
portfolioSnapshot.historicalData.length - 1 |
||||
netPerformanceInPercentage: 0.13100263852242744, |
] |
||||
netPerformanceInPercentageWithCurrencyEffect: 0.13100263852242744, |
).toEqual({ |
||||
netPerformanceWithCurrencyEffect: 19.86, |
date: '2022-04-11', |
||||
netWorth: 0, |
investmentValueWithCurrencyEffect: 0, |
||||
totalAccountBalance: 0, |
netPerformance: 19.86, |
||||
totalInvestment: 0, |
netPerformanceInPercentage: 0.13100263852242744, |
||||
totalInvestmentValueWithCurrencyEffect: 0, |
netPerformanceInPercentageWithCurrencyEffect: 0.13100263852242744, |
||||
value: 0, |
netPerformanceWithCurrencyEffect: 19.86, |
||||
valueWithCurrencyEffect: 0 |
timeWeightedPerformanceInPercentage: -0.02357630979498861, |
||||
}); |
timeWeightedPerformanceInPercentageWithCurrencyEffect: |
||||
|
-0.02357630979498861, |
||||
expect(portfolioSnapshot).toMatchObject({ |
netWorth: 0, |
||||
currentValueInBaseCurrency: new Big('0'), |
totalAccountBalance: 0, |
||||
errors: [], |
totalInvestment: 0, |
||||
hasErrors: false, |
totalInvestmentValueWithCurrencyEffect: 0, |
||||
positions: [ |
value: 0, |
||||
{ |
valueWithCurrencyEffect: 0 |
||||
averagePrice: new Big('0'), |
}); |
||||
currency: 'CHF', |
|
||||
dataSource: 'YAHOO', |
expect(portfolioSnapshot).toMatchObject({ |
||||
dividend: new Big('0'), |
currentValueInBaseCurrency: new Big('0'), |
||||
dividendInBaseCurrency: new Big('0'), |
errors: [], |
||||
fee: new Big('0'), |
hasErrors: false, |
||||
feeInBaseCurrency: new Big('0'), |
positions: [ |
||||
firstBuyDate: '2022-03-07', |
{ |
||||
grossPerformance: new Big('19.86'), |
averagePrice: new Big('0'), |
||||
grossPerformancePercentage: new Big('0.13100263852242744063'), |
currency: 'CHF', |
||||
grossPerformancePercentageWithCurrencyEffect: new Big( |
dataSource: 'YAHOO', |
||||
'0.13100263852242744063' |
dividend: new Big('0'), |
||||
), |
dividendInBaseCurrency: new Big('0'), |
||||
grossPerformanceWithCurrencyEffect: new Big('19.86'), |
fee: new Big('0'), |
||||
investment: new Big('0'), |
feeInBaseCurrency: new Big('0'), |
||||
investmentWithCurrencyEffect: new Big('0'), |
firstBuyDate: '2022-03-07', |
||||
netPerformance: new Big('19.86'), |
grossPerformance: new Big('19.86'), |
||||
netPerformancePercentage: new Big('0.13100263852242744063'), |
grossPerformancePercentage: new Big('0.13100263852242744063'), |
||||
netPerformancePercentageWithCurrencyEffectMap: { |
grossPerformancePercentageWithCurrencyEffect: new Big( |
||||
max: new Big('0.13100263852242744063') |
'0.13100263852242744063' |
||||
}, |
), |
||||
netPerformanceWithCurrencyEffectMap: { |
grossPerformanceWithCurrencyEffect: new Big('19.86'), |
||||
max: new Big('19.86') |
investment: new Big('0'), |
||||
}, |
investmentWithCurrencyEffect: new Big('0'), |
||||
marketPrice: 87.8, |
netPerformance: new Big('19.86'), |
||||
marketPriceInBaseCurrency: 87.8, |
netPerformancePercentage: new Big('0.13100263852242744063'), |
||||
quantity: new Big('0'), |
netPerformancePercentageWithCurrencyEffectMap: { |
||||
symbol: 'NOVN.SW', |
max: new Big('0.13100263852242744063') |
||||
tags: [], |
}, |
||||
timeWeightedInvestment: new Big('151.6'), |
netPerformanceWithCurrencyEffectMap: { |
||||
timeWeightedInvestmentWithCurrencyEffect: new Big('151.6'), |
max: new Big('19.86') |
||||
transactionCount: 2, |
}, |
||||
valueInBaseCurrency: new Big('0') |
marketPrice: 87.8, |
||||
} |
marketPriceInBaseCurrency: 87.8, |
||||
], |
quantity: new Big('0'), |
||||
totalFeesWithCurrencyEffect: new Big('0'), |
symbol: 'NOVN.SW', |
||||
totalInterestWithCurrencyEffect: new Big('0'), |
tags: [], |
||||
totalInvestment: new Big('0'), |
timeWeightedInvestment: new Big('151.6'), |
||||
totalInvestmentWithCurrencyEffect: new Big('0'), |
timeWeightedInvestmentWithCurrencyEffect: new Big('151.6'), |
||||
totalLiabilitiesWithCurrencyEffect: new Big('0'), |
transactionCount: 2, |
||||
totalValuablesWithCurrencyEffect: new Big('0') |
valueInBaseCurrency: new Big('0') |
||||
}); |
} |
||||
|
], |
||||
expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject( |
totalFeesWithCurrencyEffect: new Big('0'), |
||||
expect.objectContaining({ |
totalInterestWithCurrencyEffect: new Big('0'), |
||||
netPerformance: 19.86, |
totalInvestment: new Big('0'), |
||||
netPerformanceInPercentage: 0.13100263852242744063, |
totalInvestmentWithCurrencyEffect: new Big('0'), |
||||
netPerformanceInPercentageWithCurrencyEffect: 0.13100263852242744063, |
totalLiabilitiesWithCurrencyEffect: new Big('0'), |
||||
netPerformanceWithCurrencyEffect: 19.86, |
totalValuablesWithCurrencyEffect: new Big('0') |
||||
totalInvestmentValueWithCurrencyEffect: 0 |
}); |
||||
}) |
|
||||
); |
expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject( |
||||
|
expect.objectContaining({ |
||||
expect(investments).toEqual([ |
netPerformance: 19.86, |
||||
{ date: '2022-03-07', investment: new Big('151.6') }, |
netPerformanceInPercentage: 0.13100263852242744063, |
||||
{ date: '2022-04-08', investment: new Big('0') } |
netPerformanceInPercentageWithCurrencyEffect: 0.13100263852242744063, |
||||
]); |
netPerformanceWithCurrencyEffect: 19.86, |
||||
|
totalInvestmentValueWithCurrencyEffect: 0 |
||||
expect(investmentsByMonth).toEqual([ |
}) |
||||
{ date: '2022-03-01', investment: 151.6 }, |
); |
||||
{ date: '2022-04-01', investment: -151.6 } |
|
||||
]); |
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,208 @@ |
|||||
|
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; |
||||
|
import { |
||||
|
activityDummyData, |
||||
|
symbolProfileDummyData, |
||||
|
userDummyData |
||||
|
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; |
||||
|
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; |
||||
|
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; |
||||
|
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; |
||||
|
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; |
||||
|
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; |
||||
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; |
||||
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; |
||||
|
import { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service.mock'; |
||||
|
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; |
||||
|
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; |
||||
|
import { parseDate } from '@ghostfolio/common/helper'; |
||||
|
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; |
||||
|
|
||||
|
import { Big } from 'big.js'; |
||||
|
|
||||
|
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { |
||||
|
return { |
||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
|
CurrentRateService: jest.fn().mockImplementation(() => { |
||||
|
return CurrentRateServiceMock; |
||||
|
}) |
||||
|
}; |
||||
|
}); |
||||
|
|
||||
|
jest.mock( |
||||
|
'@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service', |
||||
|
() => { |
||||
|
return { |
||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
|
PortfolioSnapshotService: jest.fn().mockImplementation(() => { |
||||
|
return PortfolioSnapshotServiceMock; |
||||
|
}) |
||||
|
}; |
||||
|
} |
||||
|
); |
||||
|
|
||||
|
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { |
||||
|
return { |
||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
|
RedisCacheService: jest.fn().mockImplementation(() => { |
||||
|
return RedisCacheServiceMock; |
||||
|
}) |
||||
|
}; |
||||
|
}); |
||||
|
|
||||
|
jest.mock( |
||||
|
'@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service', |
||||
|
() => { |
||||
|
return { |
||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
|
ExchangeRateDataService: jest.fn().mockImplementation(() => { |
||||
|
return ExchangeRateDataServiceMock; |
||||
|
}) |
||||
|
}; |
||||
|
} |
||||
|
); |
||||
|
|
||||
|
describe('PortfolioCalculator', () => { |
||||
|
let configurationService: ConfigurationService; |
||||
|
let currentRateService: CurrentRateService; |
||||
|
let exchangeRateDataService: ExchangeRateDataService; |
||||
|
let portfolioCalculatorFactory: PortfolioCalculatorFactory; |
||||
|
let portfolioSnapshotService: PortfolioSnapshotService; |
||||
|
let redisCacheService: RedisCacheService; |
||||
|
|
||||
|
beforeEach(() => { |
||||
|
configurationService = new ConfigurationService(); |
||||
|
|
||||
|
currentRateService = new CurrentRateService(null, null, null, null); |
||||
|
|
||||
|
exchangeRateDataService = new ExchangeRateDataService( |
||||
|
null, |
||||
|
null, |
||||
|
null, |
||||
|
null |
||||
|
); |
||||
|
|
||||
|
portfolioSnapshotService = new PortfolioSnapshotService(null); |
||||
|
|
||||
|
redisCacheService = new RedisCacheService(null, null); |
||||
|
|
||||
|
portfolioCalculatorFactory = new PortfolioCalculatorFactory( |
||||
|
configurationService, |
||||
|
currentRateService, |
||||
|
exchangeRateDataService, |
||||
|
portfolioSnapshotService, |
||||
|
redisCacheService, |
||||
|
null |
||||
|
); |
||||
|
}); |
||||
|
|
||||
|
describe('get current positions', () => { |
||||
|
it.only('with GOOGL buy', async () => { |
||||
|
jest.useFakeTimers().setSystemTime(parseDate('2023-07-10').getTime()); |
||||
|
|
||||
|
const activities: Activity[] = [ |
||||
|
{ |
||||
|
...activityDummyData, |
||||
|
date: new Date('2023-01-03'), |
||||
|
feeInAssetProfileCurrency: 1, |
||||
|
quantity: 1, |
||||
|
SymbolProfile: { |
||||
|
...symbolProfileDummyData, |
||||
|
currency: 'USD', |
||||
|
dataSource: 'YAHOO', |
||||
|
name: 'Alphabet Inc.', |
||||
|
symbol: 'GOOGL' |
||||
|
}, |
||||
|
type: 'BUY', |
||||
|
unitPriceInAssetProfileCurrency: 89.12 |
||||
|
} |
||||
|
]; |
||||
|
|
||||
|
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ |
||||
|
activities, |
||||
|
calculationType: PerformanceCalculationType.ROI, |
||||
|
currency: 'CHF', |
||||
|
userId: userDummyData.id |
||||
|
}); |
||||
|
|
||||
|
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); |
||||
|
|
||||
|
const investments = portfolioCalculator.getInvestments(); |
||||
|
|
||||
|
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({ |
||||
|
data: portfolioSnapshot.historicalData, |
||||
|
groupBy: 'month' |
||||
|
}); |
||||
|
|
||||
|
expect(portfolioSnapshot).toMatchObject({ |
||||
|
currentValueInBaseCurrency: new Big('103.10483'), |
||||
|
errors: [], |
||||
|
hasErrors: false, |
||||
|
positions: [ |
||||
|
{ |
||||
|
averagePrice: new Big('89.12'), |
||||
|
currency: 'USD', |
||||
|
dataSource: 'YAHOO', |
||||
|
dividend: new Big('0'), |
||||
|
dividendInBaseCurrency: new Big('0'), |
||||
|
fee: new Big('1'), |
||||
|
feeInBaseCurrency: new Big('0.9238'), |
||||
|
firstBuyDate: '2023-01-03', |
||||
|
grossPerformance: new Big('27.33').mul(0.8854), |
||||
|
grossPerformancePercentage: new Big('0.3066651705565529623'), |
||||
|
grossPerformancePercentageWithCurrencyEffect: new Big( |
||||
|
'0.25235044599563974109' |
||||
|
), |
||||
|
grossPerformanceWithCurrencyEffect: new Big('20.775774'), |
||||
|
investment: new Big('89.12').mul(0.8854), |
||||
|
investmentWithCurrencyEffect: new Big('82.329056'), |
||||
|
netPerformance: new Big('26.33').mul(0.8854), |
||||
|
netPerformancePercentage: new Big('0.29544434470377019749'), |
||||
|
netPerformancePercentageWithCurrencyEffectMap: { |
||||
|
max: new Big('0.24112962014285697628') |
||||
|
}, |
||||
|
netPerformanceWithCurrencyEffectMap: { max: new Big('19.851974') }, |
||||
|
marketPrice: 116.45, |
||||
|
marketPriceInBaseCurrency: 103.10483, |
||||
|
quantity: new Big('1'), |
||||
|
symbol: 'GOOGL', |
||||
|
tags: [], |
||||
|
timeWeightedInvestment: new Big('89.12').mul(0.8854), |
||||
|
timeWeightedInvestmentWithCurrencyEffect: new Big('82.329056'), |
||||
|
transactionCount: 1, |
||||
|
valueInBaseCurrency: new Big('103.10483') |
||||
|
} |
||||
|
], |
||||
|
totalFeesWithCurrencyEffect: new Big('0.9238'), |
||||
|
totalInterestWithCurrencyEffect: new Big('0'), |
||||
|
totalInvestment: new Big('89.12').mul(0.8854), |
||||
|
totalInvestmentWithCurrencyEffect: new Big('82.329056'), |
||||
|
totalLiabilitiesWithCurrencyEffect: new Big('0'), |
||||
|
totalValuablesWithCurrencyEffect: new Big('0') |
||||
|
}); |
||||
|
|
||||
|
expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject( |
||||
|
expect.objectContaining({ |
||||
|
netPerformance: new Big('26.33').mul(0.8854).toNumber(), |
||||
|
netPerformanceInPercentage: 0.29544434470377019749, |
||||
|
netPerformanceInPercentageWithCurrencyEffect: 0.24112962014285697628, |
||||
|
netPerformanceWithCurrencyEffect: 19.851974, |
||||
|
totalInvestmentValueWithCurrencyEffect: 82.329056 |
||||
|
}) |
||||
|
); |
||||
|
|
||||
|
expect(investments).toEqual([ |
||||
|
{ date: '2023-01-03', investment: new Big('89.12') } |
||||
|
]); |
||||
|
|
||||
|
expect(investmentsByMonth).toEqual([ |
||||
|
{ date: '2023-01-01', investment: 82.329056 }, |
||||
|
{ date: '2023-02-01', investment: 0 }, |
||||
|
{ date: '2023-03-01', investment: 0 }, |
||||
|
{ date: '2023-04-01', investment: 0 }, |
||||
|
{ date: '2023-05-01', investment: 0 }, |
||||
|
{ date: '2023-06-01', investment: 0 }, |
||||
|
{ date: '2023-07-01', investment: 0 } |
||||
|
]); |
||||
|
}); |
||||
|
}); |
||||
|
}); |
@ -0,0 +1,39 @@ |
|||||
|
import { SymbolMetrics } from '@ghostfolio/common/interfaces'; |
||||
|
|
||||
|
import { Big } from 'big.js'; |
||||
|
|
||||
|
import { PortfolioOrderItem } from '../../interfaces/portfolio-order-item.interface'; |
||||
|
|
||||
|
export class PortfolioCalculatorSymbolMetricsHelperObject { |
||||
|
currentExchangeRate: number; |
||||
|
endDateString: string; |
||||
|
exchangeRateAtOrderDate: number; |
||||
|
fees: Big = new Big(0); |
||||
|
feesWithCurrencyEffect: Big = new Big(0); |
||||
|
feesAtStartDate: Big = new Big(0); |
||||
|
feesAtStartDateWithCurrencyEffect: Big = new Big(0); |
||||
|
grossPerformanceAtStartDate: Big = new Big(0); |
||||
|
grossPerformanceAtStartDateWithCurrencyEffect: Big = new Big(0); |
||||
|
indexOfEndOrder: number; |
||||
|
indexOfStartOrder: number; |
||||
|
initialValue: Big; |
||||
|
initialValueWithCurrencyEffect: Big; |
||||
|
investmentAtStartDate: Big; |
||||
|
investmentAtStartDateWithCurrencyEffect: Big; |
||||
|
investmentValueBeforeTransaction: Big = new Big(0); |
||||
|
investmentValueBeforeTransactionWithCurrencyEffect: Big = new Big(0); |
||||
|
ordersByDate: { [date: string]: PortfolioOrderItem[] } = {}; |
||||
|
startDateString: string; |
||||
|
symbolMetrics: SymbolMetrics; |
||||
|
totalUnits: Big = new Big(0); |
||||
|
totalInvestmentFromBuyTransactions: Big = new Big(0); |
||||
|
totalInvestmentFromBuyTransactionsWithCurrencyEffect: Big = new Big(0); |
||||
|
totalQuantityFromBuyTransactions: Big = new Big(0); |
||||
|
totalValueOfPositionsSold: Big = new Big(0); |
||||
|
totalValueOfPositionsSoldWithCurrencyEffect: Big = new Big(0); |
||||
|
unitPrice: Big; |
||||
|
unitPriceAtEndDate: Big = new Big(0); |
||||
|
unitPriceAtStartDate: Big = new Big(0); |
||||
|
valueAtStartDate: Big = new Big(0); |
||||
|
valueAtStartDateWithCurrencyEffect: Big = new Big(0); |
||||
|
} |
@ -0,0 +1,198 @@ |
|||||
|
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; |
||||
|
import { |
||||
|
activityDummyData, |
||||
|
symbolProfileDummyData, |
||||
|
userDummyData |
||||
|
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; |
||||
|
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; |
||||
|
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; |
||||
|
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; |
||||
|
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; |
||||
|
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; |
||||
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; |
||||
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; |
||||
|
import { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service.mock'; |
||||
|
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; |
||||
|
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; |
||||
|
import { parseDate } from '@ghostfolio/common/helper'; |
||||
|
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; |
||||
|
|
||||
|
import { Big } from 'big.js'; |
||||
|
|
||||
|
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { |
||||
|
return { |
||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
|
CurrentRateService: jest.fn().mockImplementation(() => { |
||||
|
return CurrentRateServiceMock; |
||||
|
}) |
||||
|
}; |
||||
|
}); |
||||
|
|
||||
|
jest.mock( |
||||
|
'@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service', |
||||
|
() => { |
||||
|
return { |
||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
|
PortfolioSnapshotService: jest.fn().mockImplementation(() => { |
||||
|
return PortfolioSnapshotServiceMock; |
||||
|
}) |
||||
|
}; |
||||
|
} |
||||
|
); |
||||
|
|
||||
|
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { |
||||
|
return { |
||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
|
RedisCacheService: jest.fn().mockImplementation(() => { |
||||
|
return RedisCacheServiceMock; |
||||
|
}) |
||||
|
}; |
||||
|
}); |
||||
|
|
||||
|
jest.mock( |
||||
|
'@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service', |
||||
|
() => { |
||||
|
return { |
||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
|
ExchangeRateDataService: jest.fn().mockImplementation(() => { |
||||
|
return ExchangeRateDataServiceMock; |
||||
|
}) |
||||
|
}; |
||||
|
} |
||||
|
); |
||||
|
|
||||
|
describe('PortfolioCalculator', () => { |
||||
|
let configurationService: ConfigurationService; |
||||
|
let currentRateService: CurrentRateService; |
||||
|
let exchangeRateDataService: ExchangeRateDataService; |
||||
|
let portfolioCalculatorFactory: PortfolioCalculatorFactory; |
||||
|
let portfolioSnapshotService: PortfolioSnapshotService; |
||||
|
let redisCacheService: RedisCacheService; |
||||
|
|
||||
|
beforeEach(() => { |
||||
|
configurationService = new ConfigurationService(); |
||||
|
|
||||
|
currentRateService = new CurrentRateService(null, null, null, null); |
||||
|
|
||||
|
exchangeRateDataService = new ExchangeRateDataService( |
||||
|
null, |
||||
|
null, |
||||
|
null, |
||||
|
null |
||||
|
); |
||||
|
|
||||
|
portfolioSnapshotService = new PortfolioSnapshotService(null); |
||||
|
|
||||
|
redisCacheService = new RedisCacheService(null, null); |
||||
|
|
||||
|
portfolioCalculatorFactory = new PortfolioCalculatorFactory( |
||||
|
configurationService, |
||||
|
currentRateService, |
||||
|
exchangeRateDataService, |
||||
|
portfolioSnapshotService, |
||||
|
redisCacheService, |
||||
|
null |
||||
|
); |
||||
|
}); |
||||
|
|
||||
|
describe('get current positions', () => { |
||||
|
it.only('with MSFT buy', async () => { |
||||
|
jest.useFakeTimers().setSystemTime(parseDate('2023-07-10').getTime()); |
||||
|
|
||||
|
const activities: Activity[] = [ |
||||
|
{ |
||||
|
...activityDummyData, |
||||
|
date: new Date('2021-09-16'), |
||||
|
feeInAssetProfileCurrency: 19, |
||||
|
quantity: 1, |
||||
|
SymbolProfile: { |
||||
|
...symbolProfileDummyData, |
||||
|
currency: 'USD', |
||||
|
dataSource: 'YAHOO', |
||||
|
name: 'Microsoft Inc.', |
||||
|
symbol: 'MSFT' |
||||
|
}, |
||||
|
type: 'BUY', |
||||
|
unitPriceInAssetProfileCurrency: 298.58 |
||||
|
}, |
||||
|
{ |
||||
|
...activityDummyData, |
||||
|
date: new Date('2021-11-16'), |
||||
|
feeInAssetProfileCurrency: 0, |
||||
|
quantity: 1, |
||||
|
SymbolProfile: { |
||||
|
...symbolProfileDummyData, |
||||
|
currency: 'USD', |
||||
|
dataSource: 'YAHOO', |
||||
|
name: 'Microsoft Inc.', |
||||
|
symbol: 'MSFT' |
||||
|
}, |
||||
|
type: 'DIVIDEND', |
||||
|
unitPriceInAssetProfileCurrency: 0.62 |
||||
|
} |
||||
|
]; |
||||
|
|
||||
|
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ |
||||
|
activities, |
||||
|
calculationType: PerformanceCalculationType.ROI, |
||||
|
currency: 'USD', |
||||
|
userId: userDummyData.id |
||||
|
}); |
||||
|
|
||||
|
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); |
||||
|
|
||||
|
expect(portfolioSnapshot).toMatchObject({ |
||||
|
errors: [], |
||||
|
hasErrors: false, |
||||
|
positions: [ |
||||
|
{ |
||||
|
averagePrice: new Big('298.58'), |
||||
|
currency: 'USD', |
||||
|
dataSource: 'YAHOO', |
||||
|
dividend: new Big('0.62'), |
||||
|
dividendInBaseCurrency: new Big('0.62'), |
||||
|
fee: new Big('19'), |
||||
|
firstBuyDate: '2021-09-16', |
||||
|
grossPerformance: new Big('33.87'), |
||||
|
grossPerformancePercentage: new Big('0.11343693482483756447'), |
||||
|
grossPerformancePercentageWithCurrencyEffect: new Big( |
||||
|
'0.11343693482483756447' |
||||
|
), |
||||
|
grossPerformanceWithCurrencyEffect: new Big('33.87'), |
||||
|
investment: new Big('298.58'), |
||||
|
investmentWithCurrencyEffect: new Big('298.58'), |
||||
|
marketPrice: 331.83, |
||||
|
marketPriceInBaseCurrency: 331.83, |
||||
|
netPerformance: new Big('14.87'), |
||||
|
netPerformancePercentage: new Big('0.04980239801728180052'), |
||||
|
netPerformancePercentageWithCurrencyEffectMap: { |
||||
|
max: new Big('0.04980239801728180052') |
||||
|
}, |
||||
|
netPerformanceWithCurrencyEffectMap: { |
||||
|
'1d': new Big('-5.39'), |
||||
|
'5y': new Big('14.87'), |
||||
|
max: new Big('14.87'), |
||||
|
wtd: new Big('-5.39') |
||||
|
}, |
||||
|
quantity: new Big('1'), |
||||
|
symbol: 'MSFT', |
||||
|
tags: [], |
||||
|
transactionCount: 2 |
||||
|
} |
||||
|
], |
||||
|
totalFeesWithCurrencyEffect: new Big('19'), |
||||
|
totalInterestWithCurrencyEffect: new Big('0'), |
||||
|
totalInvestment: new Big('298.58'), |
||||
|
totalInvestmentWithCurrencyEffect: new Big('298.58'), |
||||
|
totalLiabilitiesWithCurrencyEffect: new Big('0'), |
||||
|
totalValuablesWithCurrencyEffect: new Big('0') |
||||
|
}); |
||||
|
|
||||
|
expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject( |
||||
|
expect.objectContaining({ |
||||
|
totalInvestmentValueWithCurrencyEffect: 298.58 |
||||
|
}) |
||||
|
); |
||||
|
}); |
||||
|
}); |
||||
|
}); |
@ -0,0 +1,202 @@ |
|||||
|
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto'; |
||||
|
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; |
||||
|
import { |
||||
|
activityDummyData, |
||||
|
loadActivityExportFile, |
||||
|
symbolProfileDummyData, |
||||
|
userDummyData |
||||
|
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; |
||||
|
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; |
||||
|
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; |
||||
|
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; |
||||
|
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; |
||||
|
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; |
||||
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; |
||||
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; |
||||
|
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; |
||||
|
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; |
||||
|
import { parseDate } from '@ghostfolio/common/helper'; |
||||
|
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; |
||||
|
|
||||
|
import { Big } from 'big.js'; |
||||
|
import { join } from 'path'; |
||||
|
|
||||
|
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { |
||||
|
return { |
||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
|
CurrentRateService: jest.fn().mockImplementation(() => { |
||||
|
return CurrentRateServiceMock; |
||||
|
}) |
||||
|
}; |
||||
|
}); |
||||
|
|
||||
|
jest.mock( |
||||
|
'@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service', |
||||
|
() => { |
||||
|
return { |
||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
|
PortfolioSnapshotService: jest.fn().mockImplementation(() => { |
||||
|
return PortfolioSnapshotServiceMock; |
||||
|
}) |
||||
|
}; |
||||
|
} |
||||
|
); |
||||
|
|
||||
|
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { |
||||
|
return { |
||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
|
RedisCacheService: jest.fn().mockImplementation(() => { |
||||
|
return RedisCacheServiceMock; |
||||
|
}) |
||||
|
}; |
||||
|
}); |
||||
|
|
||||
|
describe('PortfolioCalculator', () => { |
||||
|
let activityDtos: CreateOrderDto[]; |
||||
|
|
||||
|
let configurationService: ConfigurationService; |
||||
|
let currentRateService: CurrentRateService; |
||||
|
let exchangeRateDataService: ExchangeRateDataService; |
||||
|
let portfolioCalculatorFactory: PortfolioCalculatorFactory; |
||||
|
let portfolioSnapshotService: PortfolioSnapshotService; |
||||
|
let redisCacheService: RedisCacheService; |
||||
|
|
||||
|
beforeAll(() => { |
||||
|
activityDtos = loadActivityExportFile( |
||||
|
join( |
||||
|
__dirname, |
||||
|
'../../../../../../../test/import/ok-novn-buy-and-sell-partially.json' |
||||
|
) |
||||
|
); |
||||
|
}); |
||||
|
|
||||
|
beforeEach(() => { |
||||
|
configurationService = new ConfigurationService(); |
||||
|
|
||||
|
currentRateService = new CurrentRateService(null, null, null, null); |
||||
|
|
||||
|
exchangeRateDataService = new ExchangeRateDataService( |
||||
|
null, |
||||
|
null, |
||||
|
null, |
||||
|
null |
||||
|
); |
||||
|
|
||||
|
portfolioSnapshotService = new PortfolioSnapshotService(null); |
||||
|
|
||||
|
redisCacheService = new RedisCacheService(null, null); |
||||
|
|
||||
|
portfolioCalculatorFactory = new PortfolioCalculatorFactory( |
||||
|
configurationService, |
||||
|
currentRateService, |
||||
|
exchangeRateDataService, |
||||
|
portfolioSnapshotService, |
||||
|
redisCacheService, |
||||
|
null |
||||
|
); |
||||
|
}); |
||||
|
|
||||
|
describe('get current positions', () => { |
||||
|
it.only('with NOVN.SW buy and sell partially', async () => { |
||||
|
jest.useFakeTimers().setSystemTime(parseDate('2022-04-11').getTime()); |
||||
|
|
||||
|
const activities: Activity[] = activityDtos.map((activity) => ({ |
||||
|
...activityDummyData, |
||||
|
...activity, |
||||
|
date: parseDate(activity.date), |
||||
|
feeInAssetProfileCurrency: activity.fee, |
||||
|
SymbolProfile: { |
||||
|
...symbolProfileDummyData, |
||||
|
currency: activity.currency, |
||||
|
dataSource: activity.dataSource, |
||||
|
name: 'Novartis AG', |
||||
|
symbol: activity.symbol |
||||
|
}, |
||||
|
unitPriceInAssetProfileCurrency: activity.unitPrice |
||||
|
})); |
||||
|
|
||||
|
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ |
||||
|
activities, |
||||
|
calculationType: PerformanceCalculationType.ROI, |
||||
|
currency: 'CHF', |
||||
|
userId: userDummyData.id |
||||
|
}); |
||||
|
|
||||
|
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); |
||||
|
|
||||
|
const investments = portfolioCalculator.getInvestments(); |
||||
|
|
||||
|
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({ |
||||
|
data: portfolioSnapshot.historicalData, |
||||
|
groupBy: 'month' |
||||
|
}); |
||||
|
|
||||
|
expect(portfolioSnapshot).toMatchObject({ |
||||
|
currentValueInBaseCurrency: new Big('87.8'), |
||||
|
errors: [], |
||||
|
hasErrors: false, |
||||
|
positions: [ |
||||
|
{ |
||||
|
averagePrice: new Big('75.80'), |
||||
|
currency: 'CHF', |
||||
|
dataSource: 'YAHOO', |
||||
|
dividend: new Big('0'), |
||||
|
dividendInBaseCurrency: new Big('0'), |
||||
|
fee: new Big('4.25'), |
||||
|
feeInBaseCurrency: new Big('4.25'), |
||||
|
firstBuyDate: '2022-03-07', |
||||
|
grossPerformance: new Big('21.93'), |
||||
|
grossPerformancePercentage: new Big('0.14465699208443271768'), |
||||
|
grossPerformancePercentageWithCurrencyEffect: new Big( |
||||
|
'0.14465699208443271768' |
||||
|
), |
||||
|
grossPerformanceWithCurrencyEffect: new Big('21.93'), |
||||
|
investment: new Big('75.80'), |
||||
|
investmentWithCurrencyEffect: new Big('75.80'), |
||||
|
netPerformance: new Big('17.68'), |
||||
|
netPerformancePercentage: new Big('0.11662269129287598945'), |
||||
|
netPerformancePercentageWithCurrencyEffectMap: { |
||||
|
max: new Big('0.11662269129287598945') |
||||
|
}, |
||||
|
netPerformanceWithCurrencyEffectMap: { max: new Big('17.68') }, |
||||
|
marketPrice: 87.8, |
||||
|
marketPriceInBaseCurrency: 87.8, |
||||
|
quantity: new Big('1'), |
||||
|
symbol: 'NOVN.SW', |
||||
|
tags: [], |
||||
|
timeWeightedInvestment: new Big('151.6'), |
||||
|
timeWeightedInvestmentWithCurrencyEffect: new Big('151.6'), |
||||
|
transactionCount: 2, |
||||
|
valueInBaseCurrency: new Big('87.8') |
||||
|
} |
||||
|
], |
||||
|
totalFeesWithCurrencyEffect: new Big('4.25'), |
||||
|
totalInterestWithCurrencyEffect: new Big('0'), |
||||
|
totalInvestment: new Big('75.80'), |
||||
|
totalInvestmentWithCurrencyEffect: new Big('75.80'), |
||||
|
totalLiabilitiesWithCurrencyEffect: new Big('0'), |
||||
|
totalValuablesWithCurrencyEffect: new Big('0') |
||||
|
}); |
||||
|
|
||||
|
expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject( |
||||
|
expect.objectContaining({ |
||||
|
netPerformance: 17.68, |
||||
|
netPerformanceInPercentage: 0.11662269129287598945, |
||||
|
netPerformanceInPercentageWithCurrencyEffect: 0.11662269129287598945, |
||||
|
netPerformanceWithCurrencyEffect: 17.68, |
||||
|
totalInvestmentValueWithCurrencyEffect: 75.8 |
||||
|
}) |
||||
|
); |
||||
|
|
||||
|
expect(investments).toEqual([ |
||||
|
{ date: '2022-03-07', investment: new Big('151.6') }, |
||||
|
{ date: '2022-04-08', investment: new Big('75.8') } |
||||
|
]); |
||||
|
|
||||
|
expect(investmentsByMonth).toEqual([ |
||||
|
{ date: '2022-03-01', investment: 151.6 }, |
||||
|
{ date: '2022-04-01', investment: -75.8 } |
||||
|
]); |
||||
|
}); |
||||
|
}); |
||||
|
}); |
@ -0,0 +1,259 @@ |
|||||
|
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto'; |
||||
|
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; |
||||
|
import { |
||||
|
activityDummyData, |
||||
|
loadActivityExportFile, |
||||
|
symbolProfileDummyData, |
||||
|
userDummyData |
||||
|
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; |
||||
|
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; |
||||
|
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; |
||||
|
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; |
||||
|
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; |
||||
|
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; |
||||
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; |
||||
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; |
||||
|
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; |
||||
|
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; |
||||
|
import { parseDate } from '@ghostfolio/common/helper'; |
||||
|
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; |
||||
|
|
||||
|
import { Big } from 'big.js'; |
||||
|
import { join } from 'path'; |
||||
|
|
||||
|
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { |
||||
|
return { |
||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
|
CurrentRateService: jest.fn().mockImplementation(() => { |
||||
|
return CurrentRateServiceMock; |
||||
|
}) |
||||
|
}; |
||||
|
}); |
||||
|
|
||||
|
jest.mock( |
||||
|
'@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service', |
||||
|
() => { |
||||
|
return { |
||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
|
PortfolioSnapshotService: jest.fn().mockImplementation(() => { |
||||
|
return PortfolioSnapshotServiceMock; |
||||
|
}) |
||||
|
}; |
||||
|
} |
||||
|
); |
||||
|
|
||||
|
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { |
||||
|
return { |
||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
|
RedisCacheService: jest.fn().mockImplementation(() => { |
||||
|
return RedisCacheServiceMock; |
||||
|
}) |
||||
|
}; |
||||
|
}); |
||||
|
|
||||
|
describe('PortfolioCalculator', () => { |
||||
|
let activityDtos: CreateOrderDto[]; |
||||
|
|
||||
|
let configurationService: ConfigurationService; |
||||
|
let currentRateService: CurrentRateService; |
||||
|
let exchangeRateDataService: ExchangeRateDataService; |
||||
|
let portfolioCalculatorFactory: PortfolioCalculatorFactory; |
||||
|
let portfolioSnapshotService: PortfolioSnapshotService; |
||||
|
let redisCacheService: RedisCacheService; |
||||
|
|
||||
|
beforeAll(() => { |
||||
|
activityDtos = loadActivityExportFile( |
||||
|
join( |
||||
|
__dirname, |
||||
|
'../../../../../../../test/import/ok-novn-buy-and-sell.json' |
||||
|
) |
||||
|
); |
||||
|
}); |
||||
|
|
||||
|
beforeEach(() => { |
||||
|
configurationService = new ConfigurationService(); |
||||
|
|
||||
|
currentRateService = new CurrentRateService(null, null, null, null); |
||||
|
|
||||
|
exchangeRateDataService = new ExchangeRateDataService( |
||||
|
null, |
||||
|
null, |
||||
|
null, |
||||
|
null |
||||
|
); |
||||
|
|
||||
|
portfolioSnapshotService = new PortfolioSnapshotService(null); |
||||
|
|
||||
|
redisCacheService = new RedisCacheService(null, null); |
||||
|
|
||||
|
portfolioCalculatorFactory = new PortfolioCalculatorFactory( |
||||
|
configurationService, |
||||
|
currentRateService, |
||||
|
exchangeRateDataService, |
||||
|
portfolioSnapshotService, |
||||
|
redisCacheService, |
||||
|
null |
||||
|
); |
||||
|
}); |
||||
|
|
||||
|
describe('get current positions', () => { |
||||
|
it.only('with NOVN.SW buy and sell', async () => { |
||||
|
jest.useFakeTimers().setSystemTime(parseDate('2022-04-11').getTime()); |
||||
|
|
||||
|
const activities: Activity[] = activityDtos.map((activity) => ({ |
||||
|
...activityDummyData, |
||||
|
...activity, |
||||
|
date: parseDate(activity.date), |
||||
|
feeInAssetProfileCurrency: activity.fee, |
||||
|
SymbolProfile: { |
||||
|
...symbolProfileDummyData, |
||||
|
currency: activity.currency, |
||||
|
dataSource: activity.dataSource, |
||||
|
name: 'Novartis AG', |
||||
|
symbol: activity.symbol |
||||
|
}, |
||||
|
unitPriceInAssetProfileCurrency: activity.unitPrice |
||||
|
})); |
||||
|
|
||||
|
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ |
||||
|
activities, |
||||
|
calculationType: PerformanceCalculationType.ROI, |
||||
|
currency: 'CHF', |
||||
|
userId: userDummyData.id |
||||
|
}); |
||||
|
|
||||
|
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); |
||||
|
|
||||
|
const investments = portfolioCalculator.getInvestments(); |
||||
|
|
||||
|
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({ |
||||
|
data: portfolioSnapshot.historicalData, |
||||
|
groupBy: 'month' |
||||
|
}); |
||||
|
|
||||
|
expect(portfolioSnapshot.historicalData[0]).toEqual({ |
||||
|
date: '2022-03-06', |
||||
|
investmentValueWithCurrencyEffect: 0, |
||||
|
netPerformance: 0, |
||||
|
netPerformanceInPercentage: 0, |
||||
|
netPerformanceInPercentageWithCurrencyEffect: 0, |
||||
|
netPerformanceWithCurrencyEffect: 0, |
||||
|
netWorth: 0, |
||||
|
timeWeightedPerformanceInPercentage: 0, |
||||
|
timeWeightedPerformanceInPercentageWithCurrencyEffect: 0, |
||||
|
totalAccountBalance: 0, |
||||
|
totalInvestment: 0, |
||||
|
totalInvestmentValueWithCurrencyEffect: 0, |
||||
|
value: 0, |
||||
|
valueWithCurrencyEffect: 0 |
||||
|
}); |
||||
|
|
||||
|
expect(portfolioSnapshot.historicalData[1]).toEqual({ |
||||
|
date: '2022-03-07', |
||||
|
investmentValueWithCurrencyEffect: 151.6, |
||||
|
netPerformance: 0, |
||||
|
netPerformanceInPercentage: 0, |
||||
|
netPerformanceInPercentageWithCurrencyEffect: 0, |
||||
|
netPerformanceWithCurrencyEffect: 0, |
||||
|
timeWeightedPerformanceInPercentage: 0, |
||||
|
timeWeightedPerformanceInPercentageWithCurrencyEffect: 0, |
||||
|
netWorth: 151.6, |
||||
|
totalAccountBalance: 0, |
||||
|
totalInvestment: 151.6, |
||||
|
totalInvestmentValueWithCurrencyEffect: 151.6, |
||||
|
value: 151.6, |
||||
|
valueWithCurrencyEffect: 151.6 |
||||
|
}); |
||||
|
|
||||
|
expect( |
||||
|
portfolioSnapshot.historicalData[ |
||||
|
portfolioSnapshot.historicalData.length - 1 |
||||
|
] |
||||
|
).toEqual({ |
||||
|
date: '2022-04-11', |
||||
|
investmentValueWithCurrencyEffect: 0, |
||||
|
netPerformance: 19.86, |
||||
|
netPerformanceInPercentage: 0.13100263852242744, |
||||
|
netPerformanceInPercentageWithCurrencyEffect: 0.13100263852242744, |
||||
|
timeWeightedPerformanceInPercentage: 0.13100263852242744, |
||||
|
timeWeightedPerformanceInPercentageWithCurrencyEffect: 0.13100263852242744, |
||||
|
netPerformanceWithCurrencyEffect: 19.86, |
||||
|
netWorth: 0, |
||||
|
totalAccountBalance: 0, |
||||
|
totalInvestment: 0, |
||||
|
totalInvestmentValueWithCurrencyEffect: 0, |
||||
|
value: 0, |
||||
|
valueWithCurrencyEffect: 0 |
||||
|
}); |
||||
|
|
||||
|
expect(portfolioSnapshot).toMatchObject({ |
||||
|
currentValueInBaseCurrency: new Big('0'), |
||||
|
errors: [], |
||||
|
hasErrors: false, |
||||
|
positions: [ |
||||
|
{ |
||||
|
averagePrice: new Big('0'), |
||||
|
currency: 'CHF', |
||||
|
dataSource: 'YAHOO', |
||||
|
dividend: new Big('0'), |
||||
|
dividendInBaseCurrency: new Big('0'), |
||||
|
fee: new Big('0'), |
||||
|
feeInBaseCurrency: new Big('0'), |
||||
|
firstBuyDate: '2022-03-07', |
||||
|
grossPerformance: new Big('19.86'), |
||||
|
grossPerformancePercentage: new Big('0.13100263852242744063'), |
||||
|
grossPerformancePercentageWithCurrencyEffect: new Big( |
||||
|
'0.13100263852242744063' |
||||
|
), |
||||
|
grossPerformanceWithCurrencyEffect: new Big('19.86'), |
||||
|
investment: new Big('0'), |
||||
|
investmentWithCurrencyEffect: new Big('0'), |
||||
|
netPerformance: new Big('19.86'), |
||||
|
netPerformancePercentage: new Big('0.13100263852242744063'), |
||||
|
netPerformancePercentageWithCurrencyEffectMap: { |
||||
|
max: new Big('0.13100263852242744063') |
||||
|
}, |
||||
|
netPerformanceWithCurrencyEffectMap: { |
||||
|
max: new Big('19.86') |
||||
|
}, |
||||
|
marketPrice: 87.8, |
||||
|
marketPriceInBaseCurrency: 87.8, |
||||
|
quantity: new Big('0'), |
||||
|
symbol: 'NOVN.SW', |
||||
|
tags: [], |
||||
|
timeWeightedInvestment: new Big('151.6'), |
||||
|
timeWeightedInvestmentWithCurrencyEffect: new Big('151.6'), |
||||
|
transactionCount: 2, |
||||
|
valueInBaseCurrency: new Big('0') |
||||
|
} |
||||
|
], |
||||
|
totalFeesWithCurrencyEffect: new Big('0'), |
||||
|
totalInterestWithCurrencyEffect: new Big('0'), |
||||
|
totalInvestment: new Big('0'), |
||||
|
totalInvestmentWithCurrencyEffect: new Big('0'), |
||||
|
totalLiabilitiesWithCurrencyEffect: new Big('0'), |
||||
|
totalValuablesWithCurrencyEffect: new Big('0') |
||||
|
}); |
||||
|
|
||||
|
expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject( |
||||
|
expect.objectContaining({ |
||||
|
netPerformance: 19.86, |
||||
|
netPerformanceInPercentage: 0.13100263852242744063, |
||||
|
netPerformanceInPercentageWithCurrencyEffect: 0.13100263852242744063, |
||||
|
netPerformanceWithCurrencyEffect: 19.86, |
||||
|
totalInvestmentValueWithCurrencyEffect: 0 |
||||
|
}) |
||||
|
); |
||||
|
|
||||
|
expect(investments).toEqual([ |
||||
|
{ date: '2022-03-07', investment: new Big('151.6') }, |
||||
|
{ date: '2022-04-08', investment: new Big('0') } |
||||
|
]); |
||||
|
|
||||
|
expect(investmentsByMonth).toEqual([ |
||||
|
{ date: '2022-03-01', investment: 151.6 }, |
||||
|
{ date: '2022-04-01', investment: -151.6 } |
||||
|
]); |
||||
|
}); |
||||
|
}); |
||||
|
}); |
@ -0,0 +1,894 @@ |
|||||
|
import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper'; |
||||
|
import { DATE_FORMAT } from '@ghostfolio/common/helper'; |
||||
|
import { SymbolMetrics } from '@ghostfolio/common/interfaces'; |
||||
|
import { DateRangeTypes } from '@ghostfolio/common/types/date-range.type'; |
||||
|
|
||||
|
import { DataSource } from '@prisma/client'; |
||||
|
import { Big } from 'big.js'; |
||||
|
import { isBefore, addMilliseconds, format } from 'date-fns'; |
||||
|
import { sortBy } from 'lodash'; |
||||
|
|
||||
|
import { getFactor } from '../../../../helper/portfolio.helper'; |
||||
|
import { PortfolioOrderItem } from '../../interfaces/portfolio-order-item.interface'; |
||||
|
import { PortfolioCalculatorSymbolMetricsHelperObject } from './portfolio-calculator-helper-object'; |
||||
|
|
||||
|
export class RoiPortfolioCalculatorSymbolMetricsHelper { |
||||
|
private ENABLE_LOGGING: boolean; |
||||
|
private baseCurrencySuffix = 'InBaseCurrency'; |
||||
|
private chartDates: string[]; |
||||
|
private marketSymbolMap: { [date: string]: { [symbol: string]: Big } }; |
||||
|
public constructor( |
||||
|
ENABLE_LOGGING: boolean, |
||||
|
marketSymbolMap: { [date: string]: { [symbol: string]: Big } }, |
||||
|
chartDates: string[] |
||||
|
) { |
||||
|
this.ENABLE_LOGGING = ENABLE_LOGGING; |
||||
|
this.marketSymbolMap = marketSymbolMap; |
||||
|
this.chartDates = chartDates; |
||||
|
} |
||||
|
|
||||
|
public calculateNetPerformanceByDateRange( |
||||
|
start: Date, |
||||
|
symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject |
||||
|
) { |
||||
|
for (const dateRange of DateRangeTypes) { |
||||
|
const dateInterval = getIntervalFromDateRange(dateRange); |
||||
|
const endDate = dateInterval.endDate; |
||||
|
let startDate = dateInterval.startDate; |
||||
|
|
||||
|
if (isBefore(startDate, start)) { |
||||
|
startDate = start; |
||||
|
} |
||||
|
|
||||
|
const rangeEndDateString = format(endDate, DATE_FORMAT); |
||||
|
const rangeStartDateString = format(startDate, DATE_FORMAT); |
||||
|
|
||||
|
symbolMetricsHelper.symbolMetrics.netPerformanceWithCurrencyEffectMap[ |
||||
|
dateRange |
||||
|
] = |
||||
|
symbolMetricsHelper.symbolMetrics.netPerformanceValuesWithCurrencyEffect[ |
||||
|
rangeEndDateString |
||||
|
]?.minus( |
||||
|
// If the date range is 'max', take 0 as a start value. Otherwise,
|
||||
|
// the value of the end of the day of the start date is taken which
|
||||
|
// differs from the buying price.
|
||||
|
dateRange === 'max' |
||||
|
? new Big(0) |
||||
|
: (symbolMetricsHelper.symbolMetrics |
||||
|
.netPerformanceValuesWithCurrencyEffect[rangeStartDateString] ?? |
||||
|
new Big(0)) |
||||
|
) ?? new Big(0); |
||||
|
|
||||
|
const investmentBasis = this.calculateInvestmentBasis( |
||||
|
symbolMetricsHelper, |
||||
|
rangeStartDateString, |
||||
|
rangeEndDateString |
||||
|
); |
||||
|
|
||||
|
symbolMetricsHelper.symbolMetrics.netPerformancePercentageWithCurrencyEffectMap[ |
||||
|
dateRange |
||||
|
] = investmentBasis.gt(0) |
||||
|
? symbolMetricsHelper.symbolMetrics.netPerformanceWithCurrencyEffectMap[ |
||||
|
dateRange |
||||
|
].div(investmentBasis) |
||||
|
: new Big(0); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public handleOverallPerformanceCalculation( |
||||
|
symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject |
||||
|
) { |
||||
|
symbolMetricsHelper.symbolMetrics.grossPerformance = |
||||
|
symbolMetricsHelper.symbolMetrics.grossPerformance.minus( |
||||
|
symbolMetricsHelper.grossPerformanceAtStartDate |
||||
|
); |
||||
|
symbolMetricsHelper.symbolMetrics.grossPerformanceWithCurrencyEffect = |
||||
|
symbolMetricsHelper.symbolMetrics.grossPerformanceWithCurrencyEffect.minus( |
||||
|
symbolMetricsHelper.grossPerformanceAtStartDateWithCurrencyEffect |
||||
|
); |
||||
|
|
||||
|
symbolMetricsHelper.symbolMetrics.netPerformance = |
||||
|
symbolMetricsHelper.symbolMetrics.grossPerformance.minus( |
||||
|
symbolMetricsHelper.fees.minus(symbolMetricsHelper.feesAtStartDate) |
||||
|
); |
||||
|
|
||||
|
symbolMetricsHelper.symbolMetrics.timeWeightedInvestment = new Big( |
||||
|
symbolMetricsHelper.totalInvestmentFromBuyTransactions |
||||
|
); |
||||
|
symbolMetricsHelper.symbolMetrics.timeWeightedInvestmentWithCurrencyEffect = |
||||
|
new Big( |
||||
|
symbolMetricsHelper.totalInvestmentFromBuyTransactionsWithCurrencyEffect |
||||
|
); |
||||
|
|
||||
|
if (symbolMetricsHelper.symbolMetrics.timeWeightedInvestment.gt(0)) { |
||||
|
symbolMetricsHelper.symbolMetrics.netPerformancePercentage = |
||||
|
symbolMetricsHelper.symbolMetrics.netPerformance.div( |
||||
|
symbolMetricsHelper.symbolMetrics.timeWeightedInvestment |
||||
|
); |
||||
|
symbolMetricsHelper.symbolMetrics.grossPerformancePercentage = |
||||
|
symbolMetricsHelper.symbolMetrics.grossPerformance.div( |
||||
|
symbolMetricsHelper.symbolMetrics.timeWeightedInvestment |
||||
|
); |
||||
|
symbolMetricsHelper.symbolMetrics.grossPerformancePercentageWithCurrencyEffect = |
||||
|
symbolMetricsHelper.symbolMetrics.grossPerformanceWithCurrencyEffect.div( |
||||
|
symbolMetricsHelper.symbolMetrics |
||||
|
.timeWeightedInvestmentWithCurrencyEffect |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public processOrderMetrics( |
||||
|
orders: PortfolioOrderItem[], |
||||
|
i: number, |
||||
|
exchangeRates: { [dateString: string]: number }, |
||||
|
symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject |
||||
|
) { |
||||
|
const order = orders[i]; |
||||
|
this.writeOrderToLogIfNecessary(i, order); |
||||
|
|
||||
|
symbolMetricsHelper.exchangeRateAtOrderDate = exchangeRates[order.date]; |
||||
|
const value = order.quantity.gt(0) |
||||
|
? order.quantity.mul(order.unitPrice) |
||||
|
: new Big(0); |
||||
|
|
||||
|
this.handleNoneBuyAndSellOrders(order, value, symbolMetricsHelper); |
||||
|
this.handleStartOrder( |
||||
|
order, |
||||
|
i, |
||||
|
orders, |
||||
|
symbolMetricsHelper.unitPriceAtStartDate |
||||
|
); |
||||
|
this.handleOrderFee(order, symbolMetricsHelper); |
||||
|
symbolMetricsHelper.unitPrice = this.getUnitPriceAndFillCurrencyDeviations( |
||||
|
order, |
||||
|
symbolMetricsHelper |
||||
|
); |
||||
|
|
||||
|
if (order.unitPriceInBaseCurrency) { |
||||
|
symbolMetricsHelper.investmentValueBeforeTransaction = |
||||
|
symbolMetricsHelper.totalUnits.mul(order.unitPriceInBaseCurrency); |
||||
|
symbolMetricsHelper.investmentValueBeforeTransactionWithCurrencyEffect = |
||||
|
symbolMetricsHelper.totalUnits.mul( |
||||
|
order.unitPriceInBaseCurrencyWithCurrencyEffect |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
this.handleInitialInvestmentValues(symbolMetricsHelper, i, order); |
||||
|
|
||||
|
const { transactionInvestment, transactionInvestmentWithCurrencyEffect } = |
||||
|
this.handleBuyAndSellTranscation(order, symbolMetricsHelper); |
||||
|
|
||||
|
this.logTransactionValuesIfRequested( |
||||
|
order, |
||||
|
transactionInvestment, |
||||
|
transactionInvestmentWithCurrencyEffect |
||||
|
); |
||||
|
|
||||
|
this.updateTotalInvestments( |
||||
|
symbolMetricsHelper, |
||||
|
transactionInvestment, |
||||
|
transactionInvestmentWithCurrencyEffect |
||||
|
); |
||||
|
|
||||
|
this.setInitialValueIfNecessary( |
||||
|
symbolMetricsHelper, |
||||
|
transactionInvestment, |
||||
|
transactionInvestmentWithCurrencyEffect |
||||
|
); |
||||
|
|
||||
|
this.accumulateFees(symbolMetricsHelper, order); |
||||
|
|
||||
|
symbolMetricsHelper.totalUnits = symbolMetricsHelper.totalUnits.plus( |
||||
|
order.quantity.mul(getFactor(order.type)) |
||||
|
); |
||||
|
|
||||
|
this.fillOrderUnitPricesIfMissing(order, symbolMetricsHelper); |
||||
|
|
||||
|
const valueOfInvestment = symbolMetricsHelper.totalUnits.mul( |
||||
|
order.unitPriceInBaseCurrency |
||||
|
); |
||||
|
|
||||
|
const valueOfInvestmentWithCurrencyEffect = |
||||
|
symbolMetricsHelper.totalUnits.mul( |
||||
|
order.unitPriceInBaseCurrencyWithCurrencyEffect |
||||
|
); |
||||
|
|
||||
|
const valueOfPositionsSold = |
||||
|
order.type === 'SELL' |
||||
|
? order.unitPriceInBaseCurrency.mul(order.quantity) |
||||
|
: new Big(0); |
||||
|
|
||||
|
const valueOfPositionsSoldWithCurrencyEffect = |
||||
|
order.type === 'SELL' |
||||
|
? order.unitPriceInBaseCurrencyWithCurrencyEffect.mul(order.quantity) |
||||
|
: new Big(0); |
||||
|
|
||||
|
symbolMetricsHelper.totalValueOfPositionsSold = |
||||
|
symbolMetricsHelper.totalValueOfPositionsSold.plus(valueOfPositionsSold); |
||||
|
symbolMetricsHelper.totalValueOfPositionsSoldWithCurrencyEffect = |
||||
|
symbolMetricsHelper.totalValueOfPositionsSoldWithCurrencyEffect.plus( |
||||
|
valueOfPositionsSoldWithCurrencyEffect |
||||
|
); |
||||
|
|
||||
|
this.handlePerformanceCalculation( |
||||
|
valueOfInvestment, |
||||
|
symbolMetricsHelper, |
||||
|
valueOfInvestmentWithCurrencyEffect, |
||||
|
order |
||||
|
); |
||||
|
|
||||
|
symbolMetricsHelper.symbolMetrics.investmentValuesAccumulated[order.date] = |
||||
|
new Big(symbolMetricsHelper.symbolMetrics.totalInvestment.toNumber()); |
||||
|
|
||||
|
symbolMetricsHelper.symbolMetrics.investmentValuesAccumulatedWithCurrencyEffect[ |
||||
|
order.date |
||||
|
] = new Big( |
||||
|
symbolMetricsHelper.symbolMetrics.totalInvestmentWithCurrencyEffect.toNumber() |
||||
|
); |
||||
|
|
||||
|
symbolMetricsHelper.symbolMetrics.investmentValuesWithCurrencyEffect[ |
||||
|
order.date |
||||
|
] = ( |
||||
|
symbolMetricsHelper.symbolMetrics.investmentValuesWithCurrencyEffect[ |
||||
|
order.date |
||||
|
] ?? new Big(0) |
||||
|
).add(transactionInvestmentWithCurrencyEffect); |
||||
|
} |
||||
|
|
||||
|
public handlePerformanceCalculation( |
||||
|
valueOfInvestment: Big, |
||||
|
symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject, |
||||
|
valueOfInvestmentWithCurrencyEffect: Big, |
||||
|
order: PortfolioOrderItem |
||||
|
) { |
||||
|
this.calculateGrossPerformance( |
||||
|
valueOfInvestment, |
||||
|
symbolMetricsHelper, |
||||
|
valueOfInvestmentWithCurrencyEffect |
||||
|
); |
||||
|
|
||||
|
this.calculateNetPerformance( |
||||
|
symbolMetricsHelper, |
||||
|
order, |
||||
|
valueOfInvestment, |
||||
|
valueOfInvestmentWithCurrencyEffect |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
public calculateNetPerformance( |
||||
|
symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject, |
||||
|
order: PortfolioOrderItem, |
||||
|
valueOfInvestment: Big, |
||||
|
valueOfInvestmentWithCurrencyEffect: Big |
||||
|
) { |
||||
|
symbolMetricsHelper.symbolMetrics.currentValues[order.date] = new Big( |
||||
|
valueOfInvestment |
||||
|
); |
||||
|
symbolMetricsHelper.symbolMetrics.currentValuesWithCurrencyEffect[ |
||||
|
order.date |
||||
|
] = new Big(valueOfInvestmentWithCurrencyEffect); |
||||
|
|
||||
|
symbolMetricsHelper.symbolMetrics.timeWeightedInvestmentValues[order.date] = |
||||
|
new Big(symbolMetricsHelper.totalInvestmentFromBuyTransactions); |
||||
|
symbolMetricsHelper.symbolMetrics.timeWeightedInvestmentValuesWithCurrencyEffect[ |
||||
|
order.date |
||||
|
] = new Big( |
||||
|
symbolMetricsHelper.totalInvestmentFromBuyTransactionsWithCurrencyEffect |
||||
|
); |
||||
|
|
||||
|
symbolMetricsHelper.symbolMetrics.netPerformanceValues[order.date] = |
||||
|
symbolMetricsHelper.symbolMetrics.grossPerformance |
||||
|
.minus(symbolMetricsHelper.grossPerformanceAtStartDate) |
||||
|
.minus( |
||||
|
symbolMetricsHelper.fees.minus(symbolMetricsHelper.feesAtStartDate) |
||||
|
); |
||||
|
|
||||
|
symbolMetricsHelper.symbolMetrics.netPerformanceValuesWithCurrencyEffect[ |
||||
|
order.date |
||||
|
] = symbolMetricsHelper.symbolMetrics.grossPerformanceWithCurrencyEffect |
||||
|
.minus(symbolMetricsHelper.grossPerformanceAtStartDateWithCurrencyEffect) |
||||
|
.minus( |
||||
|
symbolMetricsHelper.feesWithCurrencyEffect.minus( |
||||
|
symbolMetricsHelper.feesAtStartDateWithCurrencyEffect |
||||
|
) |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
public calculateGrossPerformance( |
||||
|
valueOfInvestment: Big, |
||||
|
symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject, |
||||
|
valueOfInvestmentWithCurrencyEffect: Big |
||||
|
) { |
||||
|
const newGrossPerformance = valueOfInvestment |
||||
|
.minus(symbolMetricsHelper.totalInvestmentFromBuyTransactions) |
||||
|
.plus(symbolMetricsHelper.totalValueOfPositionsSold) |
||||
|
.plus( |
||||
|
symbolMetricsHelper.symbolMetrics.totalDividend.mul( |
||||
|
symbolMetricsHelper.currentExchangeRate |
||||
|
) |
||||
|
) |
||||
|
.plus( |
||||
|
symbolMetricsHelper.symbolMetrics.totalInterest.mul( |
||||
|
symbolMetricsHelper.currentExchangeRate |
||||
|
) |
||||
|
); |
||||
|
|
||||
|
const newGrossPerformanceWithCurrencyEffect = |
||||
|
valueOfInvestmentWithCurrencyEffect |
||||
|
.minus( |
||||
|
symbolMetricsHelper.totalInvestmentFromBuyTransactionsWithCurrencyEffect |
||||
|
) |
||||
|
.plus(symbolMetricsHelper.totalValueOfPositionsSoldWithCurrencyEffect) |
||||
|
.plus(symbolMetricsHelper.symbolMetrics.totalDividendInBaseCurrency) |
||||
|
.plus(symbolMetricsHelper.symbolMetrics.totalInterestInBaseCurrency); |
||||
|
|
||||
|
symbolMetricsHelper.symbolMetrics.grossPerformance = newGrossPerformance; |
||||
|
symbolMetricsHelper.symbolMetrics.grossPerformanceWithCurrencyEffect = |
||||
|
newGrossPerformanceWithCurrencyEffect; |
||||
|
} |
||||
|
|
||||
|
public accumulateFees( |
||||
|
symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject, |
||||
|
order: PortfolioOrderItem |
||||
|
) { |
||||
|
symbolMetricsHelper.fees = symbolMetricsHelper.fees.plus( |
||||
|
order.feeInBaseCurrency ?? 0 |
||||
|
); |
||||
|
|
||||
|
symbolMetricsHelper.feesWithCurrencyEffect = |
||||
|
symbolMetricsHelper.feesWithCurrencyEffect.plus( |
||||
|
order.feeInBaseCurrencyWithCurrencyEffect ?? 0 |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
public updateTotalInvestments( |
||||
|
symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject, |
||||
|
transactionInvestment: Big, |
||||
|
transactionInvestmentWithCurrencyEffect: Big |
||||
|
) { |
||||
|
symbolMetricsHelper.symbolMetrics.totalInvestment = |
||||
|
symbolMetricsHelper.symbolMetrics.totalInvestment.plus( |
||||
|
transactionInvestment |
||||
|
); |
||||
|
|
||||
|
symbolMetricsHelper.symbolMetrics.totalInvestmentWithCurrencyEffect = |
||||
|
symbolMetricsHelper.symbolMetrics.totalInvestmentWithCurrencyEffect.plus( |
||||
|
transactionInvestmentWithCurrencyEffect |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
public setInitialValueIfNecessary( |
||||
|
symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject, |
||||
|
transactionInvestment: Big, |
||||
|
transactionInvestmentWithCurrencyEffect: Big |
||||
|
) { |
||||
|
if (!symbolMetricsHelper.initialValue && transactionInvestment.gt(0)) { |
||||
|
symbolMetricsHelper.initialValue = transactionInvestment; |
||||
|
symbolMetricsHelper.initialValueWithCurrencyEffect = |
||||
|
transactionInvestmentWithCurrencyEffect; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public logTransactionValuesIfRequested( |
||||
|
order: PortfolioOrderItem, |
||||
|
transactionInvestment: Big, |
||||
|
transactionInvestmentWithCurrencyEffect: Big |
||||
|
) { |
||||
|
if (this.ENABLE_LOGGING) { |
||||
|
console.log('order.quantity', order.quantity.toNumber()); |
||||
|
console.log('transactionInvestment', transactionInvestment.toNumber()); |
||||
|
|
||||
|
console.log( |
||||
|
'transactionInvestmentWithCurrencyEffect', |
||||
|
transactionInvestmentWithCurrencyEffect.toNumber() |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public handleBuyAndSellTranscation( |
||||
|
order: PortfolioOrderItem, |
||||
|
symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject |
||||
|
) { |
||||
|
switch (order.type) { |
||||
|
case 'BUY': |
||||
|
return this.handleBuyTransaction(order, symbolMetricsHelper); |
||||
|
case 'SELL': |
||||
|
return this.handleSellTransaction(symbolMetricsHelper, order); |
||||
|
default: |
||||
|
return { |
||||
|
transactionInvestment: new Big(0), |
||||
|
transactionInvestmentWithCurrencyEffect: new Big(0) |
||||
|
}; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public handleSellTransaction( |
||||
|
symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject, |
||||
|
order: PortfolioOrderItem |
||||
|
) { |
||||
|
let transactionInvestment = new Big(0); |
||||
|
let transactionInvestmentWithCurrencyEffect = new Big(0); |
||||
|
if (symbolMetricsHelper.totalUnits.gt(0)) { |
||||
|
transactionInvestment = symbolMetricsHelper.symbolMetrics.totalInvestment |
||||
|
.div(symbolMetricsHelper.totalUnits) |
||||
|
.mul(order.quantity) |
||||
|
.mul(getFactor(order.type)); |
||||
|
transactionInvestmentWithCurrencyEffect = |
||||
|
symbolMetricsHelper.symbolMetrics.totalInvestmentWithCurrencyEffect |
||||
|
.div(symbolMetricsHelper.totalUnits) |
||||
|
.mul(order.quantity) |
||||
|
.mul(getFactor(order.type)); |
||||
|
} |
||||
|
return { transactionInvestment, transactionInvestmentWithCurrencyEffect }; |
||||
|
} |
||||
|
|
||||
|
public handleBuyTransaction( |
||||
|
order: PortfolioOrderItem, |
||||
|
symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject |
||||
|
) { |
||||
|
const transactionInvestment = order.quantity |
||||
|
.mul(order.unitPriceInBaseCurrency) |
||||
|
.mul(getFactor(order.type)); |
||||
|
|
||||
|
const transactionInvestmentWithCurrencyEffect = order.quantity |
||||
|
.mul(order.unitPriceInBaseCurrencyWithCurrencyEffect) |
||||
|
.mul(getFactor(order.type)); |
||||
|
|
||||
|
symbolMetricsHelper.totalQuantityFromBuyTransactions = |
||||
|
symbolMetricsHelper.totalQuantityFromBuyTransactions.plus(order.quantity); |
||||
|
|
||||
|
symbolMetricsHelper.totalInvestmentFromBuyTransactions = |
||||
|
symbolMetricsHelper.totalInvestmentFromBuyTransactions.plus( |
||||
|
transactionInvestment |
||||
|
); |
||||
|
|
||||
|
symbolMetricsHelper.totalInvestmentFromBuyTransactionsWithCurrencyEffect = |
||||
|
symbolMetricsHelper.totalInvestmentFromBuyTransactionsWithCurrencyEffect.plus( |
||||
|
transactionInvestmentWithCurrencyEffect |
||||
|
); |
||||
|
return { transactionInvestment, transactionInvestmentWithCurrencyEffect }; |
||||
|
} |
||||
|
|
||||
|
public handleInitialInvestmentValues( |
||||
|
symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject, |
||||
|
i: number, |
||||
|
order: PortfolioOrderItem |
||||
|
) { |
||||
|
if ( |
||||
|
!symbolMetricsHelper.investmentAtStartDate && |
||||
|
i >= symbolMetricsHelper.indexOfStartOrder |
||||
|
) { |
||||
|
symbolMetricsHelper.investmentAtStartDate = new Big( |
||||
|
symbolMetricsHelper.symbolMetrics.totalInvestment.toNumber() |
||||
|
); |
||||
|
symbolMetricsHelper.investmentAtStartDateWithCurrencyEffect = new Big( |
||||
|
symbolMetricsHelper.symbolMetrics.totalInvestmentWithCurrencyEffect.toNumber() |
||||
|
); |
||||
|
|
||||
|
symbolMetricsHelper.valueAtStartDate = new Big( |
||||
|
symbolMetricsHelper.investmentValueBeforeTransaction.toNumber() |
||||
|
); |
||||
|
|
||||
|
symbolMetricsHelper.valueAtStartDateWithCurrencyEffect = new Big( |
||||
|
symbolMetricsHelper.investmentValueBeforeTransactionWithCurrencyEffect.toNumber() |
||||
|
); |
||||
|
} |
||||
|
if (order.itemType === 'start') { |
||||
|
symbolMetricsHelper.feesAtStartDate = symbolMetricsHelper.fees; |
||||
|
symbolMetricsHelper.feesAtStartDateWithCurrencyEffect = |
||||
|
symbolMetricsHelper.feesWithCurrencyEffect; |
||||
|
symbolMetricsHelper.grossPerformanceAtStartDate = |
||||
|
symbolMetricsHelper.symbolMetrics.grossPerformance; |
||||
|
|
||||
|
symbolMetricsHelper.grossPerformanceAtStartDateWithCurrencyEffect = |
||||
|
symbolMetricsHelper.symbolMetrics.grossPerformanceWithCurrencyEffect; |
||||
|
} |
||||
|
|
||||
|
if ( |
||||
|
i >= symbolMetricsHelper.indexOfStartOrder && |
||||
|
!symbolMetricsHelper.initialValue |
||||
|
) { |
||||
|
if ( |
||||
|
i === symbolMetricsHelper.indexOfStartOrder && |
||||
|
!symbolMetricsHelper.symbolMetrics.totalInvestment.eq(0) |
||||
|
) { |
||||
|
symbolMetricsHelper.initialValue = new Big( |
||||
|
symbolMetricsHelper.symbolMetrics.totalInvestment.toNumber() |
||||
|
); |
||||
|
|
||||
|
symbolMetricsHelper.initialValueWithCurrencyEffect = new Big( |
||||
|
symbolMetricsHelper.symbolMetrics.totalInvestmentWithCurrencyEffect.toNumber() |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public getSymbolMetricHelperObject( |
||||
|
exchangeRates: { [dateString: string]: number }, |
||||
|
start: Date, |
||||
|
end: Date, |
||||
|
marketSymbolMap: { [date: string]: { [symbol: string]: Big } }, |
||||
|
symbol: string |
||||
|
): PortfolioCalculatorSymbolMetricsHelperObject { |
||||
|
const symbolMetricsHelper = |
||||
|
new PortfolioCalculatorSymbolMetricsHelperObject(); |
||||
|
symbolMetricsHelper.symbolMetrics = this.createEmptySymbolMetrics(); |
||||
|
symbolMetricsHelper.currentExchangeRate = |
||||
|
exchangeRates[format(new Date(), DATE_FORMAT)]; |
||||
|
symbolMetricsHelper.startDateString = format(start, DATE_FORMAT); |
||||
|
symbolMetricsHelper.endDateString = format(end, DATE_FORMAT); |
||||
|
symbolMetricsHelper.unitPriceAtStartDate = |
||||
|
marketSymbolMap[symbolMetricsHelper.startDateString]?.[symbol]; |
||||
|
symbolMetricsHelper.unitPriceAtEndDate = |
||||
|
marketSymbolMap[symbolMetricsHelper.endDateString]?.[symbol]; |
||||
|
|
||||
|
symbolMetricsHelper.totalUnits = new Big(0); |
||||
|
|
||||
|
return symbolMetricsHelper; |
||||
|
} |
||||
|
|
||||
|
public getUnitPriceAndFillCurrencyDeviations( |
||||
|
order: PortfolioOrderItem, |
||||
|
symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject |
||||
|
) { |
||||
|
const unitprice = ['BUY', 'SELL'].includes(order.type) |
||||
|
? order.unitPrice |
||||
|
: order.unitPriceFromMarketData; |
||||
|
if (unitprice) { |
||||
|
order.unitPriceInBaseCurrency = unitprice.mul( |
||||
|
symbolMetricsHelper.currentExchangeRate ?? 1 |
||||
|
); |
||||
|
|
||||
|
order.unitPriceInBaseCurrencyWithCurrencyEffect = unitprice.mul( |
||||
|
symbolMetricsHelper.exchangeRateAtOrderDate ?? 1 |
||||
|
); |
||||
|
} |
||||
|
return unitprice; |
||||
|
} |
||||
|
|
||||
|
public handleOrderFee( |
||||
|
order: PortfolioOrderItem, |
||||
|
symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject |
||||
|
) { |
||||
|
if (order.fee) { |
||||
|
order.feeInBaseCurrency = order.fee.mul( |
||||
|
symbolMetricsHelper.currentExchangeRate ?? 1 |
||||
|
); |
||||
|
order.feeInBaseCurrencyWithCurrencyEffect = order.fee.mul( |
||||
|
symbolMetricsHelper.exchangeRateAtOrderDate ?? 1 |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public handleStartOrder( |
||||
|
order: PortfolioOrderItem, |
||||
|
i: number, |
||||
|
orders: PortfolioOrderItem[], |
||||
|
unitPriceAtStartDate: Big.Big |
||||
|
) { |
||||
|
if (order.itemType === 'start') { |
||||
|
// Take the unit price of the order as the market price if there are no
|
||||
|
// orders of this symbol before the start date
|
||||
|
order.unitPrice = |
||||
|
i === 0 ? orders[i + 1]?.unitPrice : unitPriceAtStartDate; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public handleNoneBuyAndSellOrders( |
||||
|
order: PortfolioOrderItem, |
||||
|
value: Big.Big, |
||||
|
symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject |
||||
|
) { |
||||
|
const symbolMetricsKey = this.getSymbolMetricsKeyFromOrderType(order.type); |
||||
|
if (symbolMetricsKey) { |
||||
|
this.calculateMetrics(value, symbolMetricsHelper, symbolMetricsKey); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public getSymbolMetricsKeyFromOrderType( |
||||
|
orderType: PortfolioOrderItem['type'] |
||||
|
): keyof SymbolMetrics { |
||||
|
switch (orderType) { |
||||
|
case 'DIVIDEND': |
||||
|
return 'totalDividend'; |
||||
|
case 'INTEREST': |
||||
|
return 'totalInterest'; |
||||
|
case 'ITEM': |
||||
|
return 'totalValuables'; |
||||
|
case 'LIABILITY': |
||||
|
return 'totalLiabilities'; |
||||
|
default: |
||||
|
return undefined; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public calculateMetrics( |
||||
|
value: Big, |
||||
|
symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject, |
||||
|
key: keyof SymbolMetrics |
||||
|
) { |
||||
|
const stringKey = key.toString(); |
||||
|
symbolMetricsHelper.symbolMetrics[stringKey] = ( |
||||
|
symbolMetricsHelper.symbolMetrics[stringKey] as Big |
||||
|
).plus(value); |
||||
|
|
||||
|
if ( |
||||
|
Object.keys(symbolMetricsHelper.symbolMetrics).includes( |
||||
|
stringKey + this.baseCurrencySuffix |
||||
|
) |
||||
|
) { |
||||
|
symbolMetricsHelper.symbolMetrics[stringKey + this.baseCurrencySuffix] = ( |
||||
|
symbolMetricsHelper.symbolMetrics[ |
||||
|
stringKey + this.baseCurrencySuffix |
||||
|
] as Big |
||||
|
).plus(value.mul(symbolMetricsHelper.exchangeRateAtOrderDate ?? 1)); |
||||
|
} else { |
||||
|
throw new Error( |
||||
|
`Key ${stringKey + this.baseCurrencySuffix} not found in symbolMetrics` |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public writeOrderToLogIfNecessary(i: number, order: PortfolioOrderItem) { |
||||
|
if (this.ENABLE_LOGGING) { |
||||
|
console.log(); |
||||
|
console.log(); |
||||
|
console.log( |
||||
|
i + 1, |
||||
|
order.date, |
||||
|
order.type, |
||||
|
order.itemType ? `(${order.itemType})` : '' |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public fillOrdersAndSortByTime( |
||||
|
orders: PortfolioOrderItem[], |
||||
|
symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject, |
||||
|
chartDateMap: { [date: string]: boolean }, |
||||
|
marketSymbolMap: { [date: string]: { [symbol: string]: Big.Big } }, |
||||
|
symbol: string, |
||||
|
dataSource: DataSource |
||||
|
) { |
||||
|
this.fillOrdersByDate(orders, symbolMetricsHelper.ordersByDate); |
||||
|
|
||||
|
this.chartDates ??= Object.keys(chartDateMap).sort(); |
||||
|
|
||||
|
this.fillOrdersWithDatesFromChartDate( |
||||
|
symbolMetricsHelper, |
||||
|
marketSymbolMap, |
||||
|
symbol, |
||||
|
orders, |
||||
|
dataSource |
||||
|
); |
||||
|
|
||||
|
// Sort orders so that the start and end placeholder order are at the correct
|
||||
|
// position
|
||||
|
orders = this.sortOrdersByTime(orders); |
||||
|
return orders; |
||||
|
} |
||||
|
|
||||
|
public sortOrdersByTime(orders: PortfolioOrderItem[]) { |
||||
|
orders = sortBy(orders, ({ date, itemType }) => { |
||||
|
let sortIndex = new Date(date); |
||||
|
|
||||
|
if (itemType === 'end') { |
||||
|
sortIndex = addMilliseconds(sortIndex, 1); |
||||
|
} else if (itemType === 'start') { |
||||
|
sortIndex = addMilliseconds(sortIndex, -1); |
||||
|
} |
||||
|
|
||||
|
return sortIndex.getTime(); |
||||
|
}); |
||||
|
return orders; |
||||
|
} |
||||
|
|
||||
|
public fillOrdersWithDatesFromChartDate( |
||||
|
symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject, |
||||
|
marketSymbolMap: { [date: string]: { [symbol: string]: Big.Big } }, |
||||
|
symbol: string, |
||||
|
orders: PortfolioOrderItem[], |
||||
|
dataSource: DataSource |
||||
|
) { |
||||
|
let lastUnitPrice: Big; |
||||
|
for (const dateString of this.chartDates) { |
||||
|
if (dateString < symbolMetricsHelper.startDateString) { |
||||
|
continue; |
||||
|
} else if (dateString > symbolMetricsHelper.endDateString) { |
||||
|
break; |
||||
|
} |
||||
|
|
||||
|
if (symbolMetricsHelper.ordersByDate[dateString]?.length > 0) { |
||||
|
for (const order of symbolMetricsHelper.ordersByDate[dateString]) { |
||||
|
order.unitPriceFromMarketData = |
||||
|
marketSymbolMap[dateString]?.[symbol] ?? lastUnitPrice; |
||||
|
} |
||||
|
} else { |
||||
|
orders.push( |
||||
|
this.getFakeOrder( |
||||
|
dateString, |
||||
|
dataSource, |
||||
|
symbol, |
||||
|
marketSymbolMap, |
||||
|
lastUnitPrice |
||||
|
) |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
const lastOrder = orders.at(-1); |
||||
|
|
||||
|
lastUnitPrice = lastOrder.unitPriceFromMarketData ?? lastOrder.unitPrice; |
||||
|
} |
||||
|
return lastUnitPrice; |
||||
|
} |
||||
|
|
||||
|
public getFakeOrder( |
||||
|
dateString: string, |
||||
|
dataSource: DataSource, |
||||
|
symbol: string, |
||||
|
marketSymbolMap: { [date: string]: { [symbol: string]: Big.Big } }, |
||||
|
lastUnitPrice: Big.Big |
||||
|
): PortfolioOrderItem { |
||||
|
return { |
||||
|
date: dateString, |
||||
|
fee: new Big(0), |
||||
|
feeInBaseCurrency: new Big(0), |
||||
|
quantity: new Big(0), |
||||
|
SymbolProfile: { |
||||
|
dataSource, |
||||
|
symbol |
||||
|
}, |
||||
|
type: 'BUY', |
||||
|
unitPrice: marketSymbolMap[dateString]?.[symbol] ?? lastUnitPrice, |
||||
|
unitPriceFromMarketData: |
||||
|
marketSymbolMap[dateString]?.[symbol] ?? lastUnitPrice |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
public fillOrdersByDate( |
||||
|
orders: PortfolioOrderItem[], |
||||
|
ordersByDate: { [date: string]: PortfolioOrderItem[] } |
||||
|
) { |
||||
|
for (const order of orders) { |
||||
|
ordersByDate[order.date] = ordersByDate[order.date] ?? []; |
||||
|
ordersByDate[order.date].push(order); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public addSyntheticStartAndEndOrder( |
||||
|
orders: PortfolioOrderItem[], |
||||
|
symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject, |
||||
|
dataSource: DataSource, |
||||
|
symbol: string |
||||
|
) { |
||||
|
orders.push({ |
||||
|
date: symbolMetricsHelper.startDateString, |
||||
|
fee: new Big(0), |
||||
|
feeInBaseCurrency: new Big(0), |
||||
|
itemType: 'start', |
||||
|
quantity: new Big(0), |
||||
|
SymbolProfile: { |
||||
|
dataSource, |
||||
|
symbol |
||||
|
}, |
||||
|
type: 'BUY', |
||||
|
unitPrice: symbolMetricsHelper.unitPriceAtStartDate |
||||
|
}); |
||||
|
|
||||
|
orders.push({ |
||||
|
date: symbolMetricsHelper.endDateString, |
||||
|
fee: new Big(0), |
||||
|
feeInBaseCurrency: new Big(0), |
||||
|
itemType: 'end', |
||||
|
SymbolProfile: { |
||||
|
dataSource, |
||||
|
symbol |
||||
|
}, |
||||
|
quantity: new Big(0), |
||||
|
type: 'BUY', |
||||
|
unitPrice: symbolMetricsHelper.unitPriceAtEndDate |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
public hasNoUnitPriceAtEndOrStartDate( |
||||
|
unitPriceAtEndDate: Big.Big, |
||||
|
unitPriceAtStartDate: Big.Big, |
||||
|
orders: PortfolioOrderItem[], |
||||
|
start: Date |
||||
|
) { |
||||
|
return ( |
||||
|
!unitPriceAtEndDate || |
||||
|
(!unitPriceAtStartDate && isBefore(new Date(orders[0].date), start)) |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
public createEmptySymbolMetrics(): SymbolMetrics { |
||||
|
return { |
||||
|
currentValues: {}, |
||||
|
currentValuesWithCurrencyEffect: {}, |
||||
|
feesWithCurrencyEffect: new Big(0), |
||||
|
grossPerformance: new Big(0), |
||||
|
grossPerformancePercentage: new Big(0), |
||||
|
grossPerformancePercentageWithCurrencyEffect: new Big(0), |
||||
|
grossPerformanceWithCurrencyEffect: new Big(0), |
||||
|
hasErrors: false, |
||||
|
initialValue: new Big(0), |
||||
|
initialValueWithCurrencyEffect: new Big(0), |
||||
|
investmentValuesAccumulated: {}, |
||||
|
investmentValuesAccumulatedWithCurrencyEffect: {}, |
||||
|
investmentValuesWithCurrencyEffect: {}, |
||||
|
netPerformance: new Big(0), |
||||
|
netPerformancePercentage: new Big(0), |
||||
|
netPerformancePercentageWithCurrencyEffectMap: {}, |
||||
|
netPerformanceValues: {}, |
||||
|
netPerformanceValuesWithCurrencyEffect: {}, |
||||
|
netPerformanceWithCurrencyEffectMap: {}, |
||||
|
timeWeightedInvestment: new Big(0), |
||||
|
timeWeightedInvestmentValues: {}, |
||||
|
timeWeightedInvestmentValuesWithCurrencyEffect: {}, |
||||
|
timeWeightedInvestmentWithCurrencyEffect: new Big(0), |
||||
|
totalAccountBalanceInBaseCurrency: new Big(0), |
||||
|
totalDividend: new Big(0), |
||||
|
totalDividendInBaseCurrency: new Big(0), |
||||
|
totalInterest: new Big(0), |
||||
|
totalInterestInBaseCurrency: new Big(0), |
||||
|
totalInvestment: new Big(0), |
||||
|
totalInvestmentWithCurrencyEffect: new Big(0), |
||||
|
unitPrices: {}, |
||||
|
totalLiabilities: new Big(0), |
||||
|
totalLiabilitiesInBaseCurrency: new Big(0), |
||||
|
totalValuables: new Big(0), |
||||
|
totalValuablesInBaseCurrency: new Big(0) |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
private fillOrderUnitPricesIfMissing( |
||||
|
order: PortfolioOrderItem, |
||||
|
symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject |
||||
|
) { |
||||
|
order.unitPriceInBaseCurrency ??= this.marketSymbolMap[order.date]?.[ |
||||
|
order.SymbolProfile.symbol |
||||
|
].mul(symbolMetricsHelper.currentExchangeRate); |
||||
|
|
||||
|
order.unitPriceInBaseCurrencyWithCurrencyEffect ??= this.marketSymbolMap[ |
||||
|
order.date |
||||
|
]?.[order.SymbolProfile.symbol].mul( |
||||
|
symbolMetricsHelper.exchangeRateAtOrderDate |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
private calculateInvestmentBasis( |
||||
|
symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject, |
||||
|
rangeStartDateString: string, |
||||
|
rangeEndDateString: string |
||||
|
) { |
||||
|
let investmentBasis = this.getValueOrZero( |
||||
|
symbolMetricsHelper.symbolMetrics.currentValuesWithCurrencyEffect[ |
||||
|
rangeStartDateString |
||||
|
] |
||||
|
).plus( |
||||
|
this.getValueOrZero( |
||||
|
symbolMetricsHelper.symbolMetrics |
||||
|
.timeWeightedInvestmentValuesWithCurrencyEffect[rangeEndDateString] |
||||
|
)?.minus( |
||||
|
this.getValueOrZero( |
||||
|
symbolMetricsHelper.symbolMetrics |
||||
|
.timeWeightedInvestmentValuesWithCurrencyEffect[ |
||||
|
rangeStartDateString |
||||
|
] |
||||
|
) |
||||
|
) |
||||
|
); |
||||
|
|
||||
|
if (!investmentBasis.gt(0)) { |
||||
|
investmentBasis = |
||||
|
symbolMetricsHelper.symbolMetrics |
||||
|
.timeWeightedInvestmentValuesWithCurrencyEffect[rangeEndDateString]; |
||||
|
} |
||||
|
return investmentBasis; |
||||
|
} |
||||
|
|
||||
|
private getValueOrZero(value: Big | undefined) { |
||||
|
return value ?? new Big(0); |
||||
|
} |
||||
|
} |
@ -1,29 +1,272 @@ |
|||||
import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator'; |
import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator'; |
||||
|
import { LogPerformance } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor'; |
||||
import { |
import { |
||||
AssetProfileIdentifier, |
AssetProfileIdentifier, |
||||
SymbolMetrics |
SymbolMetrics |
||||
} from '@ghostfolio/common/interfaces'; |
} from '@ghostfolio/common/interfaces'; |
||||
import { PortfolioSnapshot } from '@ghostfolio/common/models'; |
import { PortfolioSnapshot, TimelinePosition } from '@ghostfolio/common/models'; |
||||
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; |
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; |
||||
|
|
||||
|
import { Logger } from '@nestjs/common'; |
||||
|
import { Big } from 'big.js'; |
||||
|
import { cloneDeep } from 'lodash'; |
||||
|
|
||||
|
import { PortfolioOrderItem } from '../../interfaces/portfolio-order-item.interface'; |
||||
|
import { RoiPortfolioCalculatorSymbolMetricsHelper } from './portfolio-calculator-symbolmetrics-helper'; |
||||
|
|
||||
export class RoiPortfolioCalculator extends PortfolioCalculator { |
export class RoiPortfolioCalculator extends PortfolioCalculator { |
||||
protected calculateOverallPerformance(): PortfolioSnapshot { |
private chartDates: string[]; |
||||
throw new Error('Method not implemented.'); |
|
||||
} |
|
||||
|
|
||||
protected getPerformanceCalculationType() { |
@LogPerformance |
||||
return PerformanceCalculationType.ROI; |
protected calculateOverallPerformance( |
||||
|
positions: TimelinePosition[] |
||||
|
): PortfolioSnapshot { |
||||
|
let currentValueInBaseCurrency = new Big(0); |
||||
|
let grossPerformance = new Big(0); |
||||
|
let grossPerformanceWithCurrencyEffect = new Big(0); |
||||
|
let hasErrors = false; |
||||
|
let netPerformance = new Big(0); |
||||
|
let totalFeesWithCurrencyEffect = new Big(0); |
||||
|
const totalInterestWithCurrencyEffect = new Big(0); |
||||
|
let totalInvestment = new Big(0); |
||||
|
let totalInvestmentWithCurrencyEffect = new Big(0); |
||||
|
let totalTimeWeightedInvestment = new Big(0); |
||||
|
let totalTimeWeightedInvestmentWithCurrencyEffect = new Big(0); |
||||
|
|
||||
|
for (const currentPosition of positions) { |
||||
|
({ |
||||
|
totalFeesWithCurrencyEffect, |
||||
|
currentValueInBaseCurrency, |
||||
|
hasErrors, |
||||
|
totalInvestment, |
||||
|
totalInvestmentWithCurrencyEffect, |
||||
|
grossPerformance, |
||||
|
grossPerformanceWithCurrencyEffect, |
||||
|
netPerformance, |
||||
|
totalTimeWeightedInvestment, |
||||
|
totalTimeWeightedInvestmentWithCurrencyEffect |
||||
|
} = this.calculatePositionMetrics( |
||||
|
currentPosition, |
||||
|
totalFeesWithCurrencyEffect, |
||||
|
currentValueInBaseCurrency, |
||||
|
hasErrors, |
||||
|
totalInvestment, |
||||
|
totalInvestmentWithCurrencyEffect, |
||||
|
grossPerformance, |
||||
|
grossPerformanceWithCurrencyEffect, |
||||
|
netPerformance, |
||||
|
totalTimeWeightedInvestment, |
||||
|
totalTimeWeightedInvestmentWithCurrencyEffect |
||||
|
)); |
||||
|
} |
||||
|
|
||||
|
return { |
||||
|
currentValueInBaseCurrency, |
||||
|
hasErrors, |
||||
|
positions, |
||||
|
totalFeesWithCurrencyEffect, |
||||
|
totalInterestWithCurrencyEffect, |
||||
|
totalInvestment, |
||||
|
totalInvestmentWithCurrencyEffect, |
||||
|
activitiesCount: this.activities.filter(({ type }) => { |
||||
|
return ['BUY', 'SELL', 'STAKE'].includes(type); |
||||
|
}).length, |
||||
|
createdAt: new Date(), |
||||
|
errors: [], |
||||
|
historicalData: [], |
||||
|
totalLiabilitiesWithCurrencyEffect: new Big(0), |
||||
|
totalValuablesWithCurrencyEffect: new Big(0) |
||||
|
}; |
||||
} |
} |
||||
|
|
||||
protected getSymbolMetrics({}: { |
protected getSymbolMetrics({ |
||||
|
chartDateMap, |
||||
|
dataSource, |
||||
|
end, |
||||
|
exchangeRates, |
||||
|
marketSymbolMap, |
||||
|
start, |
||||
|
symbol |
||||
|
}: { |
||||
|
chartDateMap?: { [date: string]: boolean }; |
||||
end: Date; |
end: Date; |
||||
exchangeRates: { [dateString: string]: number }; |
exchangeRates: { [dateString: string]: number }; |
||||
marketSymbolMap: { |
marketSymbolMap: { |
||||
[date: string]: { [symbol: string]: Big }; |
[date: string]: { [symbol: string]: Big }; |
||||
}; |
}; |
||||
start: Date; |
start: Date; |
||||
step?: number; |
|
||||
} & AssetProfileIdentifier): SymbolMetrics { |
} & AssetProfileIdentifier): SymbolMetrics { |
||||
throw new Error('Method not implemented.'); |
if (!this.chartDates) { |
||||
|
this.chartDates = Object.keys(chartDateMap).sort(); |
||||
|
} |
||||
|
const symbolMetricsHelperClass = |
||||
|
new RoiPortfolioCalculatorSymbolMetricsHelper( |
||||
|
PortfolioCalculator.ENABLE_LOGGING, |
||||
|
marketSymbolMap, |
||||
|
this.chartDates |
||||
|
); |
||||
|
const symbolMetricsHelper = |
||||
|
symbolMetricsHelperClass.getSymbolMetricHelperObject( |
||||
|
exchangeRates, |
||||
|
start, |
||||
|
end, |
||||
|
marketSymbolMap, |
||||
|
symbol |
||||
|
); |
||||
|
|
||||
|
let orders: PortfolioOrderItem[] = cloneDeep( |
||||
|
this.activities.filter(({ SymbolProfile }) => { |
||||
|
return SymbolProfile.symbol === symbol; |
||||
|
}) |
||||
|
); |
||||
|
|
||||
|
if (!orders.length) { |
||||
|
return symbolMetricsHelper.symbolMetrics; |
||||
|
} |
||||
|
|
||||
|
if ( |
||||
|
symbolMetricsHelperClass.hasNoUnitPriceAtEndOrStartDate( |
||||
|
symbolMetricsHelper.unitPriceAtEndDate, |
||||
|
symbolMetricsHelper.unitPriceAtStartDate, |
||||
|
orders, |
||||
|
start |
||||
|
) |
||||
|
) { |
||||
|
symbolMetricsHelper.symbolMetrics.hasErrors = true; |
||||
|
return symbolMetricsHelper.symbolMetrics; |
||||
|
} |
||||
|
|
||||
|
symbolMetricsHelperClass.addSyntheticStartAndEndOrder( |
||||
|
orders, |
||||
|
symbolMetricsHelper, |
||||
|
dataSource, |
||||
|
symbol |
||||
|
); |
||||
|
|
||||
|
orders = symbolMetricsHelperClass.fillOrdersAndSortByTime( |
||||
|
orders, |
||||
|
symbolMetricsHelper, |
||||
|
chartDateMap, |
||||
|
marketSymbolMap, |
||||
|
symbol, |
||||
|
dataSource |
||||
|
); |
||||
|
|
||||
|
symbolMetricsHelper.indexOfStartOrder = orders.findIndex(({ itemType }) => { |
||||
|
return itemType === 'start'; |
||||
|
}); |
||||
|
symbolMetricsHelper.indexOfEndOrder = orders.findIndex(({ itemType }) => { |
||||
|
return itemType === 'end'; |
||||
|
}); |
||||
|
|
||||
|
for (let i = 0; i < orders.length; i++) { |
||||
|
symbolMetricsHelperClass.processOrderMetrics( |
||||
|
orders, |
||||
|
i, |
||||
|
exchangeRates, |
||||
|
symbolMetricsHelper |
||||
|
); |
||||
|
if (i === symbolMetricsHelper.indexOfEndOrder) { |
||||
|
break; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
symbolMetricsHelperClass.handleOverallPerformanceCalculation( |
||||
|
symbolMetricsHelper |
||||
|
); |
||||
|
symbolMetricsHelperClass.calculateNetPerformanceByDateRange( |
||||
|
start, |
||||
|
symbolMetricsHelper |
||||
|
); |
||||
|
|
||||
|
return symbolMetricsHelper.symbolMetrics; |
||||
|
} |
||||
|
|
||||
|
protected getPerformanceCalculationType() { |
||||
|
return PerformanceCalculationType.ROI; |
||||
|
} |
||||
|
|
||||
|
private calculatePositionMetrics( |
||||
|
currentPosition: TimelinePosition, |
||||
|
totalFeesWithCurrencyEffect: Big, |
||||
|
currentValueInBaseCurrency: Big, |
||||
|
hasErrors: boolean, |
||||
|
totalInvestment: Big, |
||||
|
totalInvestmentWithCurrencyEffect: Big, |
||||
|
grossPerformance: Big, |
||||
|
grossPerformanceWithCurrencyEffect: Big, |
||||
|
netPerformance: Big, |
||||
|
totalTimeWeightedInvestment: Big, |
||||
|
totalTimeWeightedInvestmentWithCurrencyEffect: Big |
||||
|
) { |
||||
|
if (currentPosition.feeInBaseCurrency) { |
||||
|
totalFeesWithCurrencyEffect = totalFeesWithCurrencyEffect.plus( |
||||
|
currentPosition.feeInBaseCurrency |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
if (currentPosition.valueInBaseCurrency) { |
||||
|
currentValueInBaseCurrency = currentValueInBaseCurrency.plus( |
||||
|
currentPosition.valueInBaseCurrency |
||||
|
); |
||||
|
} else { |
||||
|
hasErrors = true; |
||||
|
} |
||||
|
|
||||
|
if (currentPosition.investment) { |
||||
|
totalInvestment = totalInvestment.plus(currentPosition.investment); |
||||
|
|
||||
|
totalInvestmentWithCurrencyEffect = |
||||
|
totalInvestmentWithCurrencyEffect.plus( |
||||
|
currentPosition.investmentWithCurrencyEffect |
||||
|
); |
||||
|
} else { |
||||
|
hasErrors = true; |
||||
|
} |
||||
|
|
||||
|
if (currentPosition.grossPerformance) { |
||||
|
grossPerformance = grossPerformance.plus( |
||||
|
currentPosition.grossPerformance |
||||
|
); |
||||
|
|
||||
|
grossPerformanceWithCurrencyEffect = |
||||
|
grossPerformanceWithCurrencyEffect.plus( |
||||
|
currentPosition.grossPerformanceWithCurrencyEffect |
||||
|
); |
||||
|
|
||||
|
netPerformance = netPerformance.plus(currentPosition.netPerformance); |
||||
|
} else if (!currentPosition.quantity.eq(0)) { |
||||
|
hasErrors = true; |
||||
|
} |
||||
|
|
||||
|
if (currentPosition.timeWeightedInvestment) { |
||||
|
totalTimeWeightedInvestment = totalTimeWeightedInvestment.plus( |
||||
|
currentPosition.timeWeightedInvestment |
||||
|
); |
||||
|
|
||||
|
totalTimeWeightedInvestmentWithCurrencyEffect = |
||||
|
totalTimeWeightedInvestmentWithCurrencyEffect.plus( |
||||
|
currentPosition.timeWeightedInvestmentWithCurrencyEffect |
||||
|
); |
||||
|
} else if (!currentPosition.quantity.eq(0)) { |
||||
|
Logger.warn( |
||||
|
`Missing historical market data for ${currentPosition.symbol} (${currentPosition.dataSource})`, |
||||
|
'PortfolioCalculator' |
||||
|
); |
||||
|
|
||||
|
hasErrors = true; |
||||
|
} |
||||
|
return { |
||||
|
totalFeesWithCurrencyEffect, |
||||
|
currentValueInBaseCurrency, |
||||
|
hasErrors, |
||||
|
totalInvestment, |
||||
|
totalInvestmentWithCurrencyEffect, |
||||
|
grossPerformance, |
||||
|
grossPerformanceWithCurrencyEffect, |
||||
|
netPerformance, |
||||
|
totalTimeWeightedInvestment, |
||||
|
totalTimeWeightedInvestmentWithCurrencyEffect |
||||
|
}; |
||||
} |
} |
||||
} |
} |
||||
|
@ -0,0 +1,82 @@ |
|||||
|
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; |
||||
|
|
||||
|
import { Injectable } from '@nestjs/common'; |
||||
|
import { Prisma, Tag } from '@prisma/client'; |
||||
|
|
||||
|
@Injectable() |
||||
|
export class TagService { |
||||
|
public constructor(private readonly prismaService: PrismaService) {} |
||||
|
|
||||
|
public async createTag(data: Prisma.TagCreateInput) { |
||||
|
return this.prismaService.tag.create({ |
||||
|
data |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
public async deleteTag(where: Prisma.TagWhereUniqueInput): Promise<Tag> { |
||||
|
return this.prismaService.tag.delete({ where }); |
||||
|
} |
||||
|
|
||||
|
public async getTag( |
||||
|
tagWhereUniqueInput: Prisma.TagWhereUniqueInput |
||||
|
): Promise<Tag> { |
||||
|
return this.prismaService.tag.findUnique({ |
||||
|
where: tagWhereUniqueInput |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
public async getTags({ |
||||
|
cursor, |
||||
|
orderBy, |
||||
|
skip, |
||||
|
take, |
||||
|
where |
||||
|
}: { |
||||
|
cursor?: Prisma.TagWhereUniqueInput; |
||||
|
orderBy?: Prisma.TagOrderByWithRelationInput; |
||||
|
skip?: number; |
||||
|
take?: number; |
||||
|
where?: Prisma.TagWhereInput; |
||||
|
} = {}) { |
||||
|
return this.prismaService.tag.findMany({ |
||||
|
cursor, |
||||
|
orderBy, |
||||
|
skip, |
||||
|
take, |
||||
|
where |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
public async getTagsWithActivityCount() { |
||||
|
const tagsWithOrderCount = await this.prismaService.tag.findMany({ |
||||
|
include: { |
||||
|
_count: { |
||||
|
select: { orders: true, symbolProfile: true } |
||||
|
} |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
return tagsWithOrderCount.map(({ _count, id, name, userId }) => { |
||||
|
return { |
||||
|
id, |
||||
|
name, |
||||
|
userId, |
||||
|
activityCount: _count.orders, |
||||
|
holdingCount: _count.symbolProfile |
||||
|
}; |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
public async updateTag({ |
||||
|
data, |
||||
|
where |
||||
|
}: { |
||||
|
data: Prisma.TagUpdateInput; |
||||
|
where: Prisma.TagWhereUniqueInput; |
||||
|
}): Promise<Tag> { |
||||
|
return this.prismaService.tag.update({ |
||||
|
data, |
||||
|
where |
||||
|
}); |
||||
|
} |
||||
|
} |
@ -0,0 +1,25 @@ |
|||||
|
import { resetHours } from '@ghostfolio/common/helper'; |
||||
|
|
||||
|
import { addDays } from 'date-fns'; |
||||
|
|
||||
|
import { DateQuery } from '../app/portfolio/interfaces/date-query.interface'; |
||||
|
|
||||
|
export class DateQueryHelper { |
||||
|
public handleDateQueryIn(dateQuery: DateQuery): { |
||||
|
query: DateQuery; |
||||
|
dates: Date[]; |
||||
|
} { |
||||
|
let dates = []; |
||||
|
let query = dateQuery; |
||||
|
if (dateQuery.in?.length > 0) { |
||||
|
dates = dateQuery.in; |
||||
|
const end = Math.max(...dates.map((d) => d.getTime())); |
||||
|
const start = Math.min(...dates.map((d) => d.getTime())); |
||||
|
query = { |
||||
|
gte: resetHours(new Date(start)), |
||||
|
lt: resetHours(addDays(end, 1)) |
||||
|
}; |
||||
|
} |
||||
|
return { query, dates }; |
||||
|
} |
||||
|
} |
@ -0,0 +1,12 @@ |
|||||
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; |
||||
|
|
||||
|
import { Module } from '@nestjs/common'; |
||||
|
|
||||
|
import { SymbolProfileOverwriteService } from './symbol-profile-overwrite.service'; |
||||
|
|
||||
|
@Module({ |
||||
|
imports: [PrismaModule], |
||||
|
providers: [SymbolProfileOverwriteService], |
||||
|
exports: [SymbolProfileOverwriteService] |
||||
|
}) |
||||
|
export class SymbolProfileOverwriteModule {} |
@ -0,0 +1,69 @@ |
|||||
|
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; |
||||
|
|
||||
|
import { Injectable } from '@nestjs/common'; |
||||
|
import { DataSource, Prisma, SymbolProfileOverrides } from '@prisma/client'; |
||||
|
|
||||
|
@Injectable() |
||||
|
export class SymbolProfileOverwriteService { |
||||
|
public constructor(private readonly prismaService: PrismaService) {} |
||||
|
|
||||
|
public async add( |
||||
|
assetProfileOverwrite: Prisma.SymbolProfileOverridesCreateInput |
||||
|
): Promise<SymbolProfileOverrides | never> { |
||||
|
return this.prismaService.symbolProfileOverrides.create({ |
||||
|
data: assetProfileOverwrite |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
public async delete(symbolProfileId: string) { |
||||
|
return this.prismaService.symbolProfileOverrides.delete({ |
||||
|
where: { symbolProfileId: symbolProfileId } |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
public updateSymbolProfileOverrides({ |
||||
|
assetClass, |
||||
|
assetSubClass, |
||||
|
name, |
||||
|
countries, |
||||
|
sectors, |
||||
|
url, |
||||
|
symbolProfileId |
||||
|
}: Prisma.SymbolProfileOverridesUpdateInput & { symbolProfileId: string }) { |
||||
|
return this.prismaService.symbolProfileOverrides.update({ |
||||
|
data: { |
||||
|
assetClass, |
||||
|
assetSubClass, |
||||
|
name, |
||||
|
countries, |
||||
|
sectors, |
||||
|
url |
||||
|
}, |
||||
|
where: { symbolProfileId: symbolProfileId } |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
public async GetSymbolProfileId( |
||||
|
Symbol: string, |
||||
|
datasource: DataSource |
||||
|
): Promise<string> { |
||||
|
const SymbolProfileId = await this.prismaService.symbolProfile |
||||
|
.findFirst({ |
||||
|
where: { |
||||
|
symbol: Symbol, |
||||
|
dataSource: datasource |
||||
|
} |
||||
|
}) |
||||
|
.then((s) => s.id); |
||||
|
|
||||
|
const symbolProfileIdSaved = await this.prismaService.symbolProfileOverrides |
||||
|
.findFirst({ |
||||
|
where: { |
||||
|
symbolProfileId: SymbolProfileId |
||||
|
} |
||||
|
}) |
||||
|
.then((s) => s?.symbolProfileId); |
||||
|
|
||||
|
return symbolProfileIdSaved; |
||||
|
} |
||||
|
} |
@ -0,0 +1,46 @@ |
|||||
|
import { Prisma, PrismaClient } from '@prisma/client'; |
||||
|
|
||||
|
class Chunk<T> implements Iterable<T[] | undefined> { |
||||
|
protected constructor( |
||||
|
private readonly values: readonly T[], |
||||
|
private readonly size: number |
||||
|
) {} |
||||
|
|
||||
|
*[Symbol.iterator]() { |
||||
|
const copy = [...this.values]; |
||||
|
if (copy.length === 0) yield undefined; |
||||
|
while (copy.length) yield copy.splice(0, this.size); |
||||
|
} |
||||
|
|
||||
|
map<U>(mapper: (items?: T[]) => U): U[] { |
||||
|
return Array.from(this).map((items) => mapper(items)); |
||||
|
} |
||||
|
|
||||
|
static of<U>(values: readonly U[]) { |
||||
|
return { |
||||
|
by: (size: number) => new Chunk(values, size) |
||||
|
}; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
export type Queryable<T, Result> = ( |
||||
|
p: PrismaClient, |
||||
|
vs?: T[] |
||||
|
) => Prisma.PrismaPromise<Result>; |
||||
|
export class BatchPrismaClient { |
||||
|
constructor( |
||||
|
private readonly prisma: PrismaClient, |
||||
|
private readonly size = 32_000 |
||||
|
) {} |
||||
|
|
||||
|
over<T>(values: readonly T[]) { |
||||
|
return { |
||||
|
with: <Result>(queryable: Queryable<T, Result>) => |
||||
|
this.prisma.$transaction( |
||||
|
Chunk.of(values) |
||||
|
.by(this.size) |
||||
|
.map((vs) => queryable(this.prisma, vs)) |
||||
|
) |
||||
|
}; |
||||
|
} |
||||
|
} |
@ -1,5 +1,9 @@ |
|||||
import { PortfolioPosition } from '@ghostfolio/common/interfaces'; |
import { |
||||
|
PortfolioPosition, |
||||
|
PortfolioPerformance |
||||
|
} from '@ghostfolio/common/interfaces'; |
||||
|
|
||||
export interface PortfolioHoldingsResponse { |
export interface PortfolioHoldingsResponse { |
||||
holdings: PortfolioPosition[]; |
holdings: PortfolioPosition[]; |
||||
|
performance: PortfolioPerformance; |
||||
} |
} |
||||
|
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue