mirror of https://github.com/ghostfolio/ghostfolio
committed by
GitHub
37 changed files with 4159 additions and 3751 deletions
@ -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 { 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; |
|||
}) |
|||
}; |
|||
}); |
|||
|
|||
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 |
|||
); |
|||
}); |
|||
|
|||
describe('get current positions', () => { |
|||
it.only('with BALN.SW buy and buy', async () => { |
|||
jest.useFakeTimers().setSystemTime(parseDate('2021-12-18').getTime()); |
|||
|
|||
const activities: Activity[] = [ |
|||
{ |
|||
...activityDummyData, |
|||
date: new Date('2021-11-22'), |
|||
feeInAssetProfileCurrency: 1.55, |
|||
quantity: 2, |
|||
SymbolProfile: { |
|||
...symbolProfileDummyData, |
|||
currency: 'CHF', |
|||
dataSource: 'YAHOO', |
|||
name: 'Bâloise Holding AG', |
|||
symbol: 'BALN.SW' |
|||
}, |
|||
type: 'BUY', |
|||
unitPriceInAssetProfileCurrency: 142.9 |
|||
}, |
|||
{ |
|||
...activityDummyData, |
|||
date: new Date('2021-11-30'), |
|||
feeInAssetProfileCurrency: 1.65, |
|||
quantity: 2, |
|||
SymbolProfile: { |
|||
...symbolProfileDummyData, |
|||
currency: 'CHF', |
|||
dataSource: 'YAHOO', |
|||
name: 'Bâloise Holding AG', |
|||
symbol: 'BALN.SW' |
|||
}, |
|||
type: 'BUY', |
|||
unitPriceInAssetProfileCurrency: 136.6 |
|||
} |
|||
]; |
|||
|
|||
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).toMatchObject({ |
|||
currentValueInBaseCurrency: new Big('595.6'), |
|||
errors: [], |
|||
hasErrors: false, |
|||
positions: [ |
|||
{ |
|||
averagePrice: new Big('139.75'), |
|||
currency: 'CHF', |
|||
dataSource: 'YAHOO', |
|||
dividend: new Big('0'), |
|||
dividendInBaseCurrency: new Big('0'), |
|||
fee: new Big('3.2'), |
|||
feeInBaseCurrency: new Big('3.2'), |
|||
firstBuyDate: '2021-11-22', |
|||
grossPerformance: new Big('36.6'), |
|||
grossPerformancePercentage: new Big('0.07706261539956593567'), |
|||
grossPerformancePercentageWithCurrencyEffect: new Big( |
|||
'0.07706261539956593567' |
|||
), |
|||
grossPerformanceWithCurrencyEffect: new Big('36.6'), |
|||
investment: new Big('559'), |
|||
investmentWithCurrencyEffect: new Big('559'), |
|||
netPerformance: new Big('33.4'), |
|||
netPerformancePercentage: new Big('0.07032490039195361342'), |
|||
netPerformancePercentageWithCurrencyEffectMap: { |
|||
max: new Big('0.06986689805847808234') |
|||
}, |
|||
netPerformanceWithCurrencyEffectMap: { |
|||
max: new Big('33.4') |
|||
}, |
|||
marketPrice: 148.9, |
|||
marketPriceInBaseCurrency: 148.9, |
|||
quantity: new Big('4'), |
|||
symbol: 'BALN.SW', |
|||
tags: [], |
|||
timeWeightedInvestment: new Big('474.93846153846153846154'), |
|||
timeWeightedInvestmentWithCurrencyEffect: new Big( |
|||
'474.93846153846153846154' |
|||
), |
|||
transactionCount: 2, |
|||
valueInBaseCurrency: new Big('595.6') |
|||
} |
|||
], |
|||
totalFeesWithCurrencyEffect: new Big('3.2'), |
|||
totalInterestWithCurrencyEffect: new Big('0'), |
|||
totalInvestment: new Big('559'), |
|||
totalInvestmentWithCurrencyEffect: new Big('559'), |
|||
totalLiabilitiesWithCurrencyEffect: new Big('0') |
|||
}); |
|||
|
|||
expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject( |
|||
expect.objectContaining({ |
|||
netPerformance: 33.4, |
|||
netPerformanceInPercentage: 0.07032490039195362, |
|||
netPerformanceInPercentageWithCurrencyEffect: 0.07032490039195362, |
|||
netPerformanceWithCurrencyEffect: 33.4, |
|||
totalInvestmentValueWithCurrencyEffect: 559 |
|||
}) |
|||
); |
|||
|
|||
expect(investments).toEqual([ |
|||
{ date: '2021-11-22', investment: new Big('285.8') }, |
|||
{ date: '2021-11-30', investment: new Big('559') } |
|||
]); |
|||
|
|||
expect(investmentsByMonth).toEqual([ |
|||
{ date: '2021-11-01', investment: 559 }, |
|||
{ date: '2021-12-01', investment: 0 } |
|||
]); |
|||
}); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,132 @@ |
|||
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 { Tag } from '@prisma/client'; |
|||
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/btcusd-short.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 BTCUSD short sell (in USD)', async () => { |
|||
jest.useFakeTimers().setSystemTime(parseDate('2022-01-14').getTime()); |
|||
|
|||
const activities: Activity[] = activityDtos.map((activity) => ({ |
|||
...activityDummyData, |
|||
...activity, |
|||
date: parseDate(activity.date), |
|||
feeInAssetProfileCurrency: activity.fee, |
|||
SymbolProfile: { |
|||
...symbolProfileDummyData, |
|||
currency: 'USD', |
|||
dataSource: activity.dataSource, |
|||
name: 'Bitcoin', |
|||
symbol: activity.symbol |
|||
}, |
|||
tags: activity.tags?.map((id) => { |
|||
return { id } as Tag; |
|||
}), |
|||
unitPriceInAssetProfileCurrency: activity.unitPrice |
|||
})); |
|||
|
|||
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ |
|||
activities, |
|||
calculationType: PerformanceCalculationType.ROAI, |
|||
currency: 'USD', |
|||
userId: userDummyData.id |
|||
}); |
|||
|
|||
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); |
|||
|
|||
expect(portfolioSnapshot.positions[0].averagePrice).toEqual( |
|||
Big(45647.95) |
|||
); |
|||
}); |
|||
}); |
|||
}); |
|||
@ -1,38 +0,0 @@ |
|||
import { GfDialogFooterComponent } from '@ghostfolio/client/components/dialog-footer/dialog-footer.component'; |
|||
import { GfDialogHeaderComponent } from '@ghostfolio/client/components/dialog-header/dialog-header.component'; |
|||
import { GfInvestmentChartModule } from '@ghostfolio/client/components/investment-chart/investment-chart.module'; |
|||
import { GfAccountBalancesComponent } from '@ghostfolio/ui/account-balances'; |
|||
import { GfActivitiesTableComponent } from '@ghostfolio/ui/activities-table'; |
|||
import { GfHoldingsTableComponent } from '@ghostfolio/ui/holdings-table'; |
|||
import { GfValueComponent } from '@ghostfolio/ui/value'; |
|||
|
|||
import { CommonModule } from '@angular/common'; |
|||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; |
|||
import { MatButtonModule } from '@angular/material/button'; |
|||
import { MatDialogModule } from '@angular/material/dialog'; |
|||
import { MatTabsModule } from '@angular/material/tabs'; |
|||
import { IonIcon } from '@ionic/angular/standalone'; |
|||
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; |
|||
|
|||
import { AccountDetailDialog } from './account-detail-dialog.component'; |
|||
|
|||
@NgModule({ |
|||
declarations: [AccountDetailDialog], |
|||
imports: [ |
|||
CommonModule, |
|||
GfAccountBalancesComponent, |
|||
GfActivitiesTableComponent, |
|||
GfDialogFooterComponent, |
|||
GfDialogHeaderComponent, |
|||
GfHoldingsTableComponent, |
|||
GfInvestmentChartModule, |
|||
GfValueComponent, |
|||
IonIcon, |
|||
MatButtonModule, |
|||
MatDialogModule, |
|||
MatTabsModule, |
|||
NgxSkeletonLoaderModule |
|||
], |
|||
schemas: [CUSTOM_ELEMENTS_SCHEMA] |
|||
}) |
|||
export class GfAccountDetailDialogModule {} |
|||
@ -1,25 +0,0 @@ |
|||
import { CommonModule } from '@angular/common'; |
|||
import { NgModule } from '@angular/core'; |
|||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'; |
|||
import { MatButtonModule } from '@angular/material/button'; |
|||
import { MatDialogModule } from '@angular/material/dialog'; |
|||
import { MatFormFieldModule } from '@angular/material/form-field'; |
|||
import { MatInputModule } from '@angular/material/input'; |
|||
import { MatSelectModule } from '@angular/material/select'; |
|||
|
|||
import { CreateOrUpdateAccessDialog } from './create-or-update-access-dialog.component'; |
|||
|
|||
@NgModule({ |
|||
declarations: [CreateOrUpdateAccessDialog], |
|||
imports: [ |
|||
CommonModule, |
|||
FormsModule, |
|||
MatButtonModule, |
|||
MatDialogModule, |
|||
MatFormFieldModule, |
|||
MatInputModule, |
|||
MatSelectModule, |
|||
ReactiveFormsModule |
|||
] |
|||
}) |
|||
export class GfCreateOrUpdateAccessDialogModule {} |
|||
@ -1,20 +0,0 @@ |
|||
import { AuthGuard } from '@ghostfolio/client/core/auth.guard'; |
|||
|
|||
import { NgModule } from '@angular/core'; |
|||
import { RouterModule, Routes } from '@angular/router'; |
|||
|
|||
import { PublicPageComponent } from './public-page.component'; |
|||
|
|||
const routes: Routes = [ |
|||
{ |
|||
canActivate: [AuthGuard], |
|||
component: PublicPageComponent, |
|||
path: ':id' |
|||
} |
|||
]; |
|||
|
|||
@NgModule({ |
|||
imports: [RouterModule.forChild(routes)], |
|||
exports: [RouterModule] |
|||
}) |
|||
export class PublicPageRoutingModule {} |
|||
@ -1,28 +0,0 @@ |
|||
import { GfWorldMapChartComponent } from '@ghostfolio/client/components/world-map-chart/world-map-chart.component'; |
|||
import { GfHoldingsTableComponent } from '@ghostfolio/ui/holdings-table'; |
|||
import { GfPortfolioProportionChartComponent } from '@ghostfolio/ui/portfolio-proportion-chart'; |
|||
import { GfValueComponent } from '@ghostfolio/ui/value'; |
|||
|
|||
import { CommonModule } from '@angular/common'; |
|||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; |
|||
import { MatButtonModule } from '@angular/material/button'; |
|||
import { MatCardModule } from '@angular/material/card'; |
|||
|
|||
import { PublicPageRoutingModule } from './public-page-routing.module'; |
|||
import { PublicPageComponent } from './public-page.component'; |
|||
|
|||
@NgModule({ |
|||
declarations: [PublicPageComponent], |
|||
imports: [ |
|||
CommonModule, |
|||
GfHoldingsTableComponent, |
|||
GfPortfolioProportionChartComponent, |
|||
GfValueComponent, |
|||
GfWorldMapChartComponent, |
|||
MatButtonModule, |
|||
MatCardModule, |
|||
PublicPageRoutingModule |
|||
], |
|||
schemas: [CUSTOM_ELEMENTS_SCHEMA] |
|||
}) |
|||
export class PublicPageModule {} |
|||
@ -0,0 +1,13 @@ |
|||
import { AuthGuard } from '@ghostfolio/client/core/auth.guard'; |
|||
|
|||
import { Routes } from '@angular/router'; |
|||
|
|||
import { GfPublicPageComponent } from './public-page.component'; |
|||
|
|||
export const routes: Routes = [ |
|||
{ |
|||
canActivate: [AuthGuard], |
|||
component: GfPublicPageComponent, |
|||
path: ':id' |
|||
} |
|||
]; |
|||
File diff suppressed because it is too large
File diff suppressed because it is too large
File diff suppressed because it is too large
File diff suppressed because it is too large
File diff suppressed because it is too large
File diff suppressed because it is too large
File diff suppressed because it is too large
File diff suppressed because it is too large
File diff suppressed because it is too large
File diff suppressed because it is too large
File diff suppressed because it is too large
File diff suppressed because it is too large
@ -0,0 +1,42 @@ |
|||
{ |
|||
"meta": { |
|||
"date": "2021-12-12T00:00:00.000Z", |
|||
"version": "dev" |
|||
}, |
|||
"accounts": [], |
|||
"platforms": [], |
|||
"tags": [], |
|||
"activities": [ |
|||
{ |
|||
"accountId": null, |
|||
"comment": null, |
|||
"fee": 4.46, |
|||
"quantity": 1, |
|||
"type": "SELL", |
|||
"unitPrice": 44558.42, |
|||
"currency": "USD", |
|||
"dataSource": "YAHOO", |
|||
"date": "2021-12-12T00:00:00.000Z", |
|||
"symbol": "BTCUSD", |
|||
"tags": [] |
|||
}, |
|||
{ |
|||
"accountId": null, |
|||
"comment": null, |
|||
"fee": 4.46, |
|||
"quantity": 1, |
|||
"type": "SELL", |
|||
"unitPrice": 46737.48, |
|||
"currency": "USD", |
|||
"dataSource": "YAHOO", |
|||
"date": "2021-12-13T00:00:00.000Z", |
|||
"symbol": "BTCUSD", |
|||
"tags": [] |
|||
} |
|||
], |
|||
"user": { |
|||
"settings": { |
|||
"currency": "USD" |
|||
} |
|||
} |
|||
} |
|||
Loading…
Reference in new issue