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 |
|||
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 { 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 |
|||
); |
|||
}); |
|||
|
|||
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.ROAI, |
|||
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, |
|||
totalAccountBalance: 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({ |
|||
date: '2022-03-07', |
|||
investmentValueWithCurrencyEffect: 151.6, |
|||
netPerformance: 24, // 2 * (87.8 - 75.8) = 24
|
|||
netPerformanceInPercentage: 0.158311345646438, // 24 ÷ 151.6 = 0.158311345646438
|
|||
netPerformanceInPercentageWithCurrencyEffect: 0.158311345646438, // 24 ÷ 151.6 = 0.158311345646438
|
|||
netPerformanceWithCurrencyEffect: 24, |
|||
netWorth: 175.6, // 2 * 87.8 = 175.6
|
|||
totalAccountBalance: 0, |
|||
totalInvestment: 151.6, |
|||
totalInvestmentValueWithCurrencyEffect: 151.6, |
|||
value: 175.6, // 2 * 87.8 = 175.6
|
|||
valueWithCurrencyEffect: 175.6 |
|||
}); |
|||
|
|||
expect( |
|||
portfolioSnapshot.historicalData[ |
|||
portfolioSnapshot.historicalData.length - 1 |
|||
] |
|||
).toEqual({ |
|||
date: '2022-04-11', |
|||
investmentValueWithCurrencyEffect: 0, |
|||
netPerformance: 19.86, |
|||
netPerformanceInPercentage: 0.13100263852242744, |
|||
netPerformanceInPercentageWithCurrencyEffect: 0.13100263852242744, |
|||
netPerformanceWithCurrencyEffect: 19.86, |
|||
netWorth: 0, |
|||
totalAccountBalance: 0, |
|||
totalInvestment: 0, |
|||
totalInvestmentValueWithCurrencyEffect: 0, |
|||
value: 0, |
|||
valueWithCurrencyEffect: 0 |
|||
}); |
|||
|
|||
expect(portfolioSnapshot).toMatchObject({ |
|||
currentValueInBaseCurrency: new Big('0'), |
|||
errors: [], |
|||
hasErrors: false, |
|||
positions: [ |
|||
{ |
|||
averagePrice: new Big('0'), |
|||
currency: 'CHF', |
|||
dataSource: 'YAHOO', |
|||
dividend: new Big('0'), |
|||
dividendInBaseCurrency: new Big('0'), |
|||
fee: new Big('0'), |
|||
feeInBaseCurrency: new Big('0'), |
|||
firstBuyDate: '2022-03-07', |
|||
grossPerformance: new Big('19.86'), |
|||
grossPerformancePercentage: new Big('0.13100263852242744063'), |
|||
grossPerformancePercentageWithCurrencyEffect: new Big( |
|||
'0.13100263852242744063' |
|||
), |
|||
grossPerformanceWithCurrencyEffect: new Big('19.86'), |
|||
investment: new Big('0'), |
|||
investmentWithCurrencyEffect: new Big('0'), |
|||
netPerformance: new Big('19.86'), |
|||
netPerformancePercentage: new Big('0.13100263852242744063'), |
|||
netPerformancePercentageWithCurrencyEffectMap: { |
|||
max: new Big('0.13100263852242744063') |
|||
}, |
|||
netPerformanceWithCurrencyEffectMap: { |
|||
max: new Big('19.86') |
|||
}, |
|||
marketPrice: 87.8, |
|||
marketPriceInBaseCurrency: 87.8, |
|||
quantity: new Big('0'), |
|||
symbol: 'NOVN.SW', |
|||
tags: [], |
|||
timeWeightedInvestment: new Big('151.6'), |
|||
timeWeightedInvestmentWithCurrencyEffect: new Big('151.6'), |
|||
transactionCount: 2, |
|||
valueInBaseCurrency: new Big('0') |
|||
} |
|||
], |
|||
totalFeesWithCurrencyEffect: new Big('0'), |
|||
totalInterestWithCurrencyEffect: new Big('0'), |
|||
totalInvestment: new Big('0'), |
|||
totalInvestmentWithCurrencyEffect: new Big('0'), |
|||
totalLiabilitiesWithCurrencyEffect: new Big('0'), |
|||
totalValuablesWithCurrencyEffect: new Big('0') |
|||
}); |
|||
|
|||
expect(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 } |
|||
]); |
|||
}); |
|||
}); |
|||
}); |
|||
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.ROAI, |
|||
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 |
|||
}); |
|||
|
|||
/** |
|||
* 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({ |
|||
date: '2022-03-07', |
|||
investmentValueWithCurrencyEffect: 151.6, |
|||
netPerformance: 24, |
|||
netPerformanceInPercentage: 0.158311345646438, |
|||
netPerformanceInPercentageWithCurrencyEffect: 0.158311345646438, |
|||
netPerformanceWithCurrencyEffect: 24, |
|||
timeWeightedPerformanceInPercentage: 0, |
|||
timeWeightedPerformanceInPercentageWithCurrencyEffect: 0, |
|||
netWorth: 175.6, |
|||
totalAccountBalance: 0, |
|||
totalInvestment: 151.6, |
|||
totalInvestmentValueWithCurrencyEffect: 151.6, |
|||
value: 175.6, // 2 * 87.8 = 175.6
|
|||
valueWithCurrencyEffect: 175.6 |
|||
}); |
|||
|
|||
expect( |
|||
portfolioSnapshot.historicalData[ |
|||
portfolioSnapshot.historicalData.length - 1 |
|||
] |
|||
).toEqual({ |
|||
date: '2022-04-11', |
|||
investmentValueWithCurrencyEffect: 0, |
|||
netPerformance: 19.86, |
|||
netPerformanceInPercentage: 0.13100263852242744, |
|||
netPerformanceInPercentageWithCurrencyEffect: 0.13100263852242744, |
|||
netPerformanceWithCurrencyEffect: 19.86, |
|||
timeWeightedPerformanceInPercentage: -0.02357630979498861, |
|||
timeWeightedPerformanceInPercentageWithCurrencyEffect: |
|||
-0.02357630979498861, |
|||
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,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 { LogPerformance } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor'; |
|||
import { |
|||
AssetProfileIdentifier, |
|||
SymbolMetrics |
|||
} from '@ghostfolio/common/interfaces'; |
|||
import { PortfolioSnapshot } from '@ghostfolio/common/models'; |
|||
import { PortfolioSnapshot, TimelinePosition } from '@ghostfolio/common/models'; |
|||
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; |
|||
|
|||
import { Logger } from '@nestjs/common'; |
|||
import { Big } from 'big.js'; |
|||
import { cloneDeep } from 'lodash'; |
|||
|
|||
import { PortfolioOrderItem } from '../../interfaces/portfolio-order-item.interface'; |
|||
import { RoiPortfolioCalculatorSymbolMetricsHelper } from './portfolio-calculator-symbolmetrics-helper'; |
|||
|
|||
export class RoiPortfolioCalculator extends PortfolioCalculator { |
|||
protected calculateOverallPerformance(): PortfolioSnapshot { |
|||
throw new Error('Method not implemented.'); |
|||
} |
|||
private chartDates: string[]; |
|||
|
|||
protected getPerformanceCalculationType() { |
|||
return PerformanceCalculationType.ROI; |
|||
@LogPerformance |
|||
protected calculateOverallPerformance( |
|||
positions: TimelinePosition[] |
|||
): PortfolioSnapshot { |
|||
let currentValueInBaseCurrency = new Big(0); |
|||
let grossPerformance = new Big(0); |
|||
let grossPerformanceWithCurrencyEffect = new Big(0); |
|||
let hasErrors = false; |
|||
let netPerformance = new Big(0); |
|||
let totalFeesWithCurrencyEffect = new Big(0); |
|||
const totalInterestWithCurrencyEffect = new Big(0); |
|||
let totalInvestment = new Big(0); |
|||
let totalInvestmentWithCurrencyEffect = new Big(0); |
|||
let totalTimeWeightedInvestment = new Big(0); |
|||
let totalTimeWeightedInvestmentWithCurrencyEffect = new Big(0); |
|||
|
|||
for (const currentPosition of positions) { |
|||
({ |
|||
totalFeesWithCurrencyEffect, |
|||
currentValueInBaseCurrency, |
|||
hasErrors, |
|||
totalInvestment, |
|||
totalInvestmentWithCurrencyEffect, |
|||
grossPerformance, |
|||
grossPerformanceWithCurrencyEffect, |
|||
netPerformance, |
|||
totalTimeWeightedInvestment, |
|||
totalTimeWeightedInvestmentWithCurrencyEffect |
|||
} = this.calculatePositionMetrics( |
|||
currentPosition, |
|||
totalFeesWithCurrencyEffect, |
|||
currentValueInBaseCurrency, |
|||
hasErrors, |
|||
totalInvestment, |
|||
totalInvestmentWithCurrencyEffect, |
|||
grossPerformance, |
|||
grossPerformanceWithCurrencyEffect, |
|||
netPerformance, |
|||
totalTimeWeightedInvestment, |
|||
totalTimeWeightedInvestmentWithCurrencyEffect |
|||
)); |
|||
} |
|||
|
|||
return { |
|||
currentValueInBaseCurrency, |
|||
hasErrors, |
|||
positions, |
|||
totalFeesWithCurrencyEffect, |
|||
totalInterestWithCurrencyEffect, |
|||
totalInvestment, |
|||
totalInvestmentWithCurrencyEffect, |
|||
activitiesCount: this.activities.filter(({ type }) => { |
|||
return ['BUY', 'SELL', 'STAKE'].includes(type); |
|||
}).length, |
|||
createdAt: new Date(), |
|||
errors: [], |
|||
historicalData: [], |
|||
totalLiabilitiesWithCurrencyEffect: new Big(0), |
|||
totalValuablesWithCurrencyEffect: new Big(0) |
|||
}; |
|||
} |
|||
|
|||
protected getSymbolMetrics({}: { |
|||
protected getSymbolMetrics({ |
|||
chartDateMap, |
|||
dataSource, |
|||
end, |
|||
exchangeRates, |
|||
marketSymbolMap, |
|||
start, |
|||
symbol |
|||
}: { |
|||
chartDateMap?: { [date: string]: boolean }; |
|||
end: Date; |
|||
exchangeRates: { [dateString: string]: number }; |
|||
marketSymbolMap: { |
|||
[date: string]: { [symbol: string]: Big }; |
|||
}; |
|||
start: Date; |
|||
step?: number; |
|||
} & AssetProfileIdentifier): SymbolMetrics { |
|||
throw new Error('Method not implemented.'); |
|||
if (!this.chartDates) { |
|||
this.chartDates = Object.keys(chartDateMap).sort(); |
|||
} |
|||
const symbolMetricsHelperClass = |
|||
new RoiPortfolioCalculatorSymbolMetricsHelper( |
|||
PortfolioCalculator.ENABLE_LOGGING, |
|||
marketSymbolMap, |
|||
this.chartDates |
|||
); |
|||
const symbolMetricsHelper = |
|||
symbolMetricsHelperClass.getSymbolMetricHelperObject( |
|||
exchangeRates, |
|||
start, |
|||
end, |
|||
marketSymbolMap, |
|||
symbol |
|||
); |
|||
|
|||
let orders: PortfolioOrderItem[] = cloneDeep( |
|||
this.activities.filter(({ SymbolProfile }) => { |
|||
return SymbolProfile.symbol === symbol; |
|||
}) |
|||
); |
|||
|
|||
if (!orders.length) { |
|||
return symbolMetricsHelper.symbolMetrics; |
|||
} |
|||
|
|||
if ( |
|||
symbolMetricsHelperClass.hasNoUnitPriceAtEndOrStartDate( |
|||
symbolMetricsHelper.unitPriceAtEndDate, |
|||
symbolMetricsHelper.unitPriceAtStartDate, |
|||
orders, |
|||
start |
|||
) |
|||
) { |
|||
symbolMetricsHelper.symbolMetrics.hasErrors = true; |
|||
return symbolMetricsHelper.symbolMetrics; |
|||
} |
|||
|
|||
symbolMetricsHelperClass.addSyntheticStartAndEndOrder( |
|||
orders, |
|||
symbolMetricsHelper, |
|||
dataSource, |
|||
symbol |
|||
); |
|||
|
|||
orders = symbolMetricsHelperClass.fillOrdersAndSortByTime( |
|||
orders, |
|||
symbolMetricsHelper, |
|||
chartDateMap, |
|||
marketSymbolMap, |
|||
symbol, |
|||
dataSource |
|||
); |
|||
|
|||
symbolMetricsHelper.indexOfStartOrder = orders.findIndex(({ itemType }) => { |
|||
return itemType === 'start'; |
|||
}); |
|||
symbolMetricsHelper.indexOfEndOrder = orders.findIndex(({ itemType }) => { |
|||
return itemType === 'end'; |
|||
}); |
|||
|
|||
for (let i = 0; i < orders.length; i++) { |
|||
symbolMetricsHelperClass.processOrderMetrics( |
|||
orders, |
|||
i, |
|||
exchangeRates, |
|||
symbolMetricsHelper |
|||
); |
|||
if (i === symbolMetricsHelper.indexOfEndOrder) { |
|||
break; |
|||
} |
|||
} |
|||
|
|||
symbolMetricsHelperClass.handleOverallPerformanceCalculation( |
|||
symbolMetricsHelper |
|||
); |
|||
symbolMetricsHelperClass.calculateNetPerformanceByDateRange( |
|||
start, |
|||
symbolMetricsHelper |
|||
); |
|||
|
|||
return symbolMetricsHelper.symbolMetrics; |
|||
} |
|||
|
|||
protected getPerformanceCalculationType() { |
|||
return PerformanceCalculationType.ROI; |
|||
} |
|||
|
|||
private calculatePositionMetrics( |
|||
currentPosition: TimelinePosition, |
|||
totalFeesWithCurrencyEffect: Big, |
|||
currentValueInBaseCurrency: Big, |
|||
hasErrors: boolean, |
|||
totalInvestment: Big, |
|||
totalInvestmentWithCurrencyEffect: Big, |
|||
grossPerformance: Big, |
|||
grossPerformanceWithCurrencyEffect: Big, |
|||
netPerformance: Big, |
|||
totalTimeWeightedInvestment: Big, |
|||
totalTimeWeightedInvestmentWithCurrencyEffect: Big |
|||
) { |
|||
if (currentPosition.feeInBaseCurrency) { |
|||
totalFeesWithCurrencyEffect = totalFeesWithCurrencyEffect.plus( |
|||
currentPosition.feeInBaseCurrency |
|||
); |
|||
} |
|||
|
|||
if (currentPosition.valueInBaseCurrency) { |
|||
currentValueInBaseCurrency = currentValueInBaseCurrency.plus( |
|||
currentPosition.valueInBaseCurrency |
|||
); |
|||
} else { |
|||
hasErrors = true; |
|||
} |
|||
|
|||
if (currentPosition.investment) { |
|||
totalInvestment = totalInvestment.plus(currentPosition.investment); |
|||
|
|||
totalInvestmentWithCurrencyEffect = |
|||
totalInvestmentWithCurrencyEffect.plus( |
|||
currentPosition.investmentWithCurrencyEffect |
|||
); |
|||
} else { |
|||
hasErrors = true; |
|||
} |
|||
|
|||
if (currentPosition.grossPerformance) { |
|||
grossPerformance = grossPerformance.plus( |
|||
currentPosition.grossPerformance |
|||
); |
|||
|
|||
grossPerformanceWithCurrencyEffect = |
|||
grossPerformanceWithCurrencyEffect.plus( |
|||
currentPosition.grossPerformanceWithCurrencyEffect |
|||
); |
|||
|
|||
netPerformance = netPerformance.plus(currentPosition.netPerformance); |
|||
} else if (!currentPosition.quantity.eq(0)) { |
|||
hasErrors = true; |
|||
} |
|||
|
|||
if (currentPosition.timeWeightedInvestment) { |
|||
totalTimeWeightedInvestment = totalTimeWeightedInvestment.plus( |
|||
currentPosition.timeWeightedInvestment |
|||
); |
|||
|
|||
totalTimeWeightedInvestmentWithCurrencyEffect = |
|||
totalTimeWeightedInvestmentWithCurrencyEffect.plus( |
|||
currentPosition.timeWeightedInvestmentWithCurrencyEffect |
|||
); |
|||
} else if (!currentPosition.quantity.eq(0)) { |
|||
Logger.warn( |
|||
`Missing historical market data for ${currentPosition.symbol} (${currentPosition.dataSource})`, |
|||
'PortfolioCalculator' |
|||
); |
|||
|
|||
hasErrors = true; |
|||
} |
|||
return { |
|||
totalFeesWithCurrencyEffect, |
|||
currentValueInBaseCurrency, |
|||
hasErrors, |
|||
totalInvestment, |
|||
totalInvestmentWithCurrencyEffect, |
|||
grossPerformance, |
|||
grossPerformanceWithCurrencyEffect, |
|||
netPerformance, |
|||
totalTimeWeightedInvestment, |
|||
totalTimeWeightedInvestmentWithCurrencyEffect |
|||
}; |
|||
} |
|||
} |
|||
|
@ -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 { |
|||
holdings: PortfolioPosition[]; |
|||
performance: PortfolioPerformance; |
|||
} |
|||
|
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue