mirror of https://github.com/ghostfolio/ghostfolio
committed by
GitHub
47 changed files with 764 additions and 360 deletions
@ -0,0 +1,272 @@ |
|||||
|
import { AccountService } from '@ghostfolio/api/app/account/account.service'; |
||||
|
import { CashDetails } from '@ghostfolio/api/app/account/interfaces/cash-details.interface'; |
||||
|
import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service'; |
||||
|
import { userDummyData } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; |
||||
|
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; |
||||
|
import { UserService } from '@ghostfolio/api/app/user/user.service'; |
||||
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; |
||||
|
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; |
||||
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; |
||||
|
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service'; |
||||
|
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; |
||||
|
import { parseDate } from '@ghostfolio/common/helper'; |
||||
|
|
||||
|
import { Account, DataSource } from '@prisma/client'; |
||||
|
import { Big } from 'big.js'; |
||||
|
import { randomUUID } from 'node:crypto'; |
||||
|
|
||||
|
import { PortfolioService } from './portfolio.service'; |
||||
|
|
||||
|
describe('PortfolioService', () => { |
||||
|
let accountService: AccountService; |
||||
|
let activitiesService: ActivitiesService; |
||||
|
let configurationService: ConfigurationService; |
||||
|
let dataProviderService: DataProviderService; |
||||
|
let exchangeRateDataService: ExchangeRateDataService; |
||||
|
let impersonationService: ImpersonationService; |
||||
|
let portfolioCalculatorFactory: PortfolioCalculatorFactory; |
||||
|
let portfolioService: PortfolioService; |
||||
|
let symbolProfileService: SymbolProfileService; |
||||
|
let userService: UserService; |
||||
|
|
||||
|
beforeEach(() => { |
||||
|
configurationService = new ConfigurationService(); |
||||
|
|
||||
|
dataProviderService = new DataProviderService( |
||||
|
configurationService, |
||||
|
null, |
||||
|
null, |
||||
|
null, |
||||
|
null, |
||||
|
null |
||||
|
); |
||||
|
|
||||
|
exchangeRateDataService = new ExchangeRateDataService( |
||||
|
null, |
||||
|
null, |
||||
|
null, |
||||
|
null |
||||
|
); |
||||
|
|
||||
|
accountService = new AccountService( |
||||
|
null, |
||||
|
null, |
||||
|
exchangeRateDataService, |
||||
|
null |
||||
|
); |
||||
|
|
||||
|
activitiesService = new ActivitiesService( |
||||
|
null, |
||||
|
accountService, |
||||
|
null, |
||||
|
dataProviderService, |
||||
|
null, |
||||
|
exchangeRateDataService, |
||||
|
null, |
||||
|
null |
||||
|
); |
||||
|
|
||||
|
impersonationService = new ImpersonationService(null, null); |
||||
|
|
||||
|
portfolioCalculatorFactory = new PortfolioCalculatorFactory( |
||||
|
configurationService, |
||||
|
null, |
||||
|
exchangeRateDataService, |
||||
|
null, |
||||
|
null |
||||
|
); |
||||
|
|
||||
|
symbolProfileService = new SymbolProfileService(null); |
||||
|
|
||||
|
userService = new UserService( |
||||
|
null, |
||||
|
null, |
||||
|
null, |
||||
|
null, |
||||
|
null, |
||||
|
null, |
||||
|
null, |
||||
|
null |
||||
|
); |
||||
|
|
||||
|
portfolioService = new PortfolioService( |
||||
|
null, |
||||
|
accountService, |
||||
|
activitiesService, |
||||
|
null, |
||||
|
portfolioCalculatorFactory, |
||||
|
dataProviderService, |
||||
|
exchangeRateDataService, |
||||
|
null, |
||||
|
impersonationService, |
||||
|
null, |
||||
|
null, |
||||
|
symbolProfileService, |
||||
|
userService |
||||
|
); |
||||
|
}); |
||||
|
|
||||
|
describe('getCashSymbolProfiles', () => { |
||||
|
it('should use the exchange-rate data source so the symbol-profile join in getDetails matches the calculator positions', () => { |
||||
|
jest |
||||
|
.spyOn(dataProviderService, 'getDataSourceForExchangeRates') |
||||
|
.mockReturnValue(DataSource.YAHOO); |
||||
|
|
||||
|
const cashDetails: CashDetails = { |
||||
|
accounts: [ |
||||
|
{ |
||||
|
balance: 2000, |
||||
|
comment: null, |
||||
|
createdAt: parseDate('2024-01-01'), |
||||
|
currency: 'USD', |
||||
|
id: randomUUID(), |
||||
|
isExcluded: false, |
||||
|
name: 'USD', |
||||
|
platformId: null, |
||||
|
updatedAt: parseDate('2024-01-01'), |
||||
|
userId: userDummyData.id |
||||
|
} |
||||
|
], |
||||
|
balanceInBaseCurrency: 1820 |
||||
|
}; |
||||
|
|
||||
|
const assetProfiles = ( |
||||
|
portfolioService as unknown as { |
||||
|
getCashSymbolProfiles: ( |
||||
|
aCashDetails: CashDetails |
||||
|
) => { dataSource: DataSource; symbol: string }[]; |
||||
|
} |
||||
|
).getCashSymbolProfiles(cashDetails); |
||||
|
|
||||
|
expect(assetProfiles).toHaveLength(1); |
||||
|
expect(assetProfiles[0].dataSource).toBe(DataSource.YAHOO); |
||||
|
expect(assetProfiles[0].symbol).toBe('USD'); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
describe('getDetails', () => { |
||||
|
it('should return cash holdings when the calculator emits cash positions with the exchange-rate data source', async () => { |
||||
|
const accountId = randomUUID(); |
||||
|
|
||||
|
const cashAccount: Account = { |
||||
|
balance: 2000, |
||||
|
comment: null, |
||||
|
createdAt: parseDate('2024-01-01'), |
||||
|
currency: 'USD', |
||||
|
id: accountId, |
||||
|
isExcluded: false, |
||||
|
name: 'USD', |
||||
|
platformId: null, |
||||
|
updatedAt: parseDate('2024-01-01'), |
||||
|
userId: userDummyData.id |
||||
|
}; |
||||
|
|
||||
|
jest.spyOn(accountService, 'getCashDetails').mockResolvedValue({ |
||||
|
accounts: [cashAccount], |
||||
|
balanceInBaseCurrency: 1820 |
||||
|
}); |
||||
|
|
||||
|
jest |
||||
|
.spyOn(activitiesService, 'getActivitiesForPortfolioCalculator') |
||||
|
.mockResolvedValue({ activities: [], count: 0 }); |
||||
|
|
||||
|
jest |
||||
|
.spyOn(dataProviderService, 'getDataSourceForExchangeRates') |
||||
|
.mockReturnValue(DataSource.YAHOO); |
||||
|
|
||||
|
jest |
||||
|
.spyOn(impersonationService, 'validateImpersonationId') |
||||
|
.mockResolvedValue(null); |
||||
|
|
||||
|
jest |
||||
|
.spyOn(symbolProfileService, 'getSymbolProfiles') |
||||
|
.mockResolvedValue([]); |
||||
|
|
||||
|
jest.spyOn(userService, 'user').mockResolvedValue({ |
||||
|
accessesGet: [], |
||||
|
accounts: [], |
||||
|
activityCount: 0, |
||||
|
dataProviderGhostfolioDailyRequests: 0, |
||||
|
id: userDummyData.id, |
||||
|
settings: { |
||||
|
settings: { |
||||
|
baseCurrency: 'CHF' |
||||
|
} |
||||
|
} |
||||
|
} as unknown as Awaited<ReturnType<typeof userService.user>>); |
||||
|
|
||||
|
const usdPosition = { |
||||
|
activitiesCount: 1, |
||||
|
averagePrice: new Big(1), |
||||
|
currency: 'USD', |
||||
|
dataSource: DataSource.YAHOO, |
||||
|
dateOfFirstActivity: '2024-01-01', |
||||
|
dividend: new Big(0), |
||||
|
dividendInBaseCurrency: new Big(0), |
||||
|
fee: new Big(0), |
||||
|
feeInBaseCurrency: new Big(0), |
||||
|
grossPerformance: new Big(0), |
||||
|
grossPerformancePercentage: new Big(0), |
||||
|
grossPerformancePercentageWithCurrencyEffect: new Big(0), |
||||
|
grossPerformanceWithCurrencyEffect: new Big(0), |
||||
|
investment: new Big(1820), |
||||
|
investmentWithCurrencyEffect: new Big(1820), |
||||
|
marketPrice: 1, |
||||
|
marketPriceInBaseCurrency: 0.91, |
||||
|
netPerformance: new Big(0), |
||||
|
netPerformancePercentage: new Big(0), |
||||
|
netPerformancePercentageWithCurrencyEffectMap: {}, |
||||
|
netPerformanceWithCurrencyEffectMap: {}, |
||||
|
quantity: new Big(2000), |
||||
|
symbol: 'USD', |
||||
|
tags: [], |
||||
|
timeWeightedInvestment: new Big(0), |
||||
|
timeWeightedInvestmentWithCurrencyEffect: new Big(0), |
||||
|
valueInBaseCurrency: new Big(1820) |
||||
|
}; |
||||
|
|
||||
|
jest |
||||
|
.spyOn(portfolioCalculatorFactory, 'createCalculator') |
||||
|
.mockReturnValue({ |
||||
|
getSnapshot: jest.fn().mockResolvedValue({ |
||||
|
activitiesCount: 1, |
||||
|
createdAt: parseDate('2024-01-01'), |
||||
|
currentValueInBaseCurrency: new Big(1820), |
||||
|
errors: [], |
||||
|
hasErrors: false, |
||||
|
historicalData: [], |
||||
|
positions: [usdPosition], |
||||
|
totalFeesWithCurrencyEffect: new Big(0), |
||||
|
totalInterestWithCurrencyEffect: new Big(0), |
||||
|
totalInvestment: new Big(1820), |
||||
|
totalInvestmentWithCurrencyEffect: new Big(1820), |
||||
|
totalLiabilitiesWithCurrencyEffect: new Big(0) |
||||
|
}) |
||||
|
} as unknown as ReturnType< |
||||
|
typeof portfolioCalculatorFactory.createCalculator |
||||
|
>); |
||||
|
|
||||
|
jest |
||||
|
.spyOn( |
||||
|
portfolioService as unknown as { |
||||
|
getValueOfAccountsAndPlatforms: () => Promise<{ |
||||
|
accounts: object; |
||||
|
platforms: object; |
||||
|
}>; |
||||
|
}, |
||||
|
'getValueOfAccountsAndPlatforms' |
||||
|
) |
||||
|
.mockResolvedValue({ accounts: {}, platforms: {} }); |
||||
|
|
||||
|
const { holdings } = await portfolioService.getDetails({ |
||||
|
filters: [], |
||||
|
impersonationId: userDummyData.id, |
||||
|
userId: userDummyData.id |
||||
|
}); |
||||
|
|
||||
|
expect(holdings['USD']).toBeDefined(); |
||||
|
expect(holdings['USD'].assetProfile.dataSource).toBe(DataSource.YAHOO); |
||||
|
expect(holdings['USD'].assetProfile.symbol).toBe('USD'); |
||||
|
}); |
||||
|
}); |
||||
|
}); |
||||
@ -0,0 +1,9 @@ |
|||||
|
import { FetchService } from '@ghostfolio/api/services/fetch/fetch.service'; |
||||
|
|
||||
|
import { Module } from '@nestjs/common'; |
||||
|
|
||||
|
@Module({ |
||||
|
exports: [FetchService], |
||||
|
providers: [FetchService] |
||||
|
}) |
||||
|
export class FetchModule {} |
||||
@ -0,0 +1,63 @@ |
|||||
|
import { redactPaths } from '@ghostfolio/api/helper/object.helper'; |
||||
|
|
||||
|
import { Injectable, Logger } from '@nestjs/common'; |
||||
|
|
||||
|
@Injectable() |
||||
|
export class FetchService { |
||||
|
private static readonly REDACTED_QUERY_PARAM_NAMES = ['apikey', 'api_token']; |
||||
|
|
||||
|
public async fetch( |
||||
|
input: RequestInfo | URL, |
||||
|
init?: RequestInit |
||||
|
): Promise<Response> { |
||||
|
const method = ( |
||||
|
init?.method ?? |
||||
|
(input instanceof Request ? input.method : undefined) ?? |
||||
|
'GET' |
||||
|
).toUpperCase(); |
||||
|
|
||||
|
const url = input instanceof Request ? input.url : input.toString(); |
||||
|
const urlRedacted = this.redactUrl(url); |
||||
|
|
||||
|
Logger.debug(`${method} ${urlRedacted}`, 'FetchService'); |
||||
|
|
||||
|
try { |
||||
|
return await globalThis.fetch(input, init); |
||||
|
} catch (error) { |
||||
|
if (error instanceof Error) { |
||||
|
Logger.error( |
||||
|
`${method} ${urlRedacted} failed: [${error.name}] ${error.message}`, |
||||
|
'FetchService' |
||||
|
); |
||||
|
} else { |
||||
|
Logger.error( |
||||
|
`${method} ${urlRedacted} failed: ${String(error)}`, |
||||
|
'FetchService' |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
throw error; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private redactUrl(rawUrl: string): string { |
||||
|
try { |
||||
|
const url = new URL(rawUrl); |
||||
|
|
||||
|
const redacted = redactPaths({ |
||||
|
object: Object.fromEntries(url.searchParams), |
||||
|
paths: FetchService.REDACTED_QUERY_PARAM_NAMES |
||||
|
}); |
||||
|
|
||||
|
for (const [key, value] of Object.entries(redacted)) { |
||||
|
if (value === null) { |
||||
|
url.searchParams.set(key, '*******'); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return url.toString(); |
||||
|
} catch { |
||||
|
return rawUrl; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -1,28 +1,34 @@ |
|||||
import { Directive, EventEmitter, HostListener, Output } from '@angular/core'; |
import { Directive, output } from '@angular/core'; |
||||
|
|
||||
@Directive({ |
@Directive({ |
||||
|
host: { |
||||
|
'(dragenter)': 'onDragEnter($event)', |
||||
|
'(dragover)': 'onDragOver($event)', |
||||
|
'(drop)': 'onDrop($event)' |
||||
|
}, |
||||
selector: '[gfFileDrop]' |
selector: '[gfFileDrop]' |
||||
}) |
}) |
||||
export class GfFileDropDirective { |
export class GfFileDropDirective { |
||||
@Output() filesDropped = new EventEmitter<FileList>(); |
public readonly filesDropped = output<FileList>(); |
||||
|
|
||||
@HostListener('dragenter', ['$event']) onDragEnter(event: DragEvent) { |
public onDragEnter(event: DragEvent) { |
||||
event.preventDefault(); |
event.preventDefault(); |
||||
event.stopPropagation(); |
event.stopPropagation(); |
||||
} |
} |
||||
|
|
||||
@HostListener('dragover', ['$event']) onDragOver(event: DragEvent) { |
public onDragOver(event: DragEvent) { |
||||
event.preventDefault(); |
event.preventDefault(); |
||||
event.stopPropagation(); |
event.stopPropagation(); |
||||
} |
} |
||||
|
|
||||
@HostListener('drop', ['$event']) onDrop(event: DragEvent) { |
public onDrop(event: DragEvent) { |
||||
event.preventDefault(); |
event.preventDefault(); |
||||
event.stopPropagation(); |
event.stopPropagation(); |
||||
|
|
||||
|
if (event.dataTransfer) { |
||||
// Prevent the browser's default behavior for handling the file drop
|
// Prevent the browser's default behavior for handling the file drop
|
||||
event.dataTransfer.dropEffect = 'copy'; |
event.dataTransfer.dropEffect = 'copy'; |
||||
|
|
||||
this.filesDropped.emit(event.dataTransfer.files); |
this.filesDropped.emit(event.dataTransfer.files); |
||||
} |
} |
||||
|
} |
||||
} |
} |
||||
|
|||||
@ -0,0 +1,9 @@ |
|||||
|
<a |
||||
|
class="align-items-center d-flex justify-content-center" |
||||
|
color="primary" |
||||
|
mat-fab |
||||
|
[queryParams]="queryParams()" |
||||
|
[routerLink]="[]" |
||||
|
> |
||||
|
<ion-icon name="add-outline" size="large" /> |
||||
|
</a> |
||||
@ -0,0 +1,14 @@ |
|||||
|
:host { |
||||
|
bottom: calc(constant(safe-area-inset-bottom) + 2rem); |
||||
|
bottom: calc(env(safe-area-inset-bottom) + 2rem); |
||||
|
position: fixed; |
||||
|
right: 2rem; |
||||
|
z-index: 999; |
||||
|
} |
||||
|
|
||||
|
:host-context(gf-page-tabs) { |
||||
|
@media (max-width: 575.98px) { |
||||
|
bottom: calc(constant(safe-area-inset-bottom) + 5rem); |
||||
|
bottom: calc(env(safe-area-inset-bottom) + 5rem); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,21 @@ |
|||||
|
import { ChangeDetectionStrategy, Component, input } from '@angular/core'; |
||||
|
import { MatButtonModule } from '@angular/material/button'; |
||||
|
import { Params, RouterModule } from '@angular/router'; |
||||
|
import { IonIcon } from '@ionic/angular/standalone'; |
||||
|
import { addIcons } from 'ionicons'; |
||||
|
import { addOutline } from 'ionicons/icons'; |
||||
|
|
||||
|
@Component({ |
||||
|
changeDetection: ChangeDetectionStrategy.OnPush, |
||||
|
imports: [IonIcon, MatButtonModule, RouterModule], |
||||
|
selector: 'gf-fab', |
||||
|
styleUrls: ['./fab.component.scss'], |
||||
|
templateUrl: './fab.component.html' |
||||
|
}) |
||||
|
export class GfFabComponent { |
||||
|
public readonly queryParams = input.required<Params>(); |
||||
|
|
||||
|
public constructor() { |
||||
|
addIcons({ addOutline }); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1 @@ |
|||||
|
export * from './fab.component'; |
||||
Loading…
Reference in new issue