mirror of https://github.com/ghostfolio/ghostfolio
committed by
GitHub
645 changed files with 53165 additions and 19782 deletions
@ -0,0 +1 @@ |
|||||
|
v18 |
@ -1,11 +0,0 @@ |
|||||
module.exports = { |
|
||||
stories: [], |
|
||||
addons: ['@storybook/addon-essentials'] |
|
||||
// uncomment the property below if you want to apply some webpack config globally
|
|
||||
// webpackFinal: async (config, { configType }) => {
|
|
||||
// // Make whatever fine-grained changes you need that should apply to all storybook configs
|
|
||||
|
|
||||
// // Return the altered config
|
|
||||
// return config;
|
|
||||
// },
|
|
||||
}; |
|
@ -1,10 +0,0 @@ |
|||||
{ |
|
||||
"extends": "../tsconfig.base.json", |
|
||||
"exclude": [ |
|
||||
"../**/*.spec.js", |
|
||||
"../**/*.spec.ts", |
|
||||
"../**/*.spec.tsx", |
|
||||
"../**/*.spec.jsx" |
|
||||
], |
|
||||
"include": ["../**/*"] |
|
||||
} |
|
File diff suppressed because it is too large
@ -0,0 +1,31 @@ |
|||||
|
# Ghostfolio Development Guide |
||||
|
|
||||
|
## Git |
||||
|
|
||||
|
### Rebase |
||||
|
|
||||
|
`git rebase -i --autosquash main` |
||||
|
|
||||
|
## Dependencies |
||||
|
|
||||
|
### Nx |
||||
|
|
||||
|
#### Upgrade |
||||
|
|
||||
|
1. Run `yarn nx migrate latest` |
||||
|
1. Make sure `package.json` changes make sense and then run `yarn install` |
||||
|
1. Run `yarn nx migrate --run-migrations` |
||||
|
|
||||
|
### Prisma |
||||
|
|
||||
|
#### Synchronize schema with database for prototyping |
||||
|
|
||||
|
Run `yarn database:push` |
||||
|
|
||||
|
https://www.prisma.io/docs/concepts/components/prisma-migrate/db-push |
||||
|
|
||||
|
#### Create schema migration |
||||
|
|
||||
|
Run `yarn prisma migrate dev --name added_job_title` |
||||
|
|
||||
|
https://www.prisma.io/docs/concepts/components/prisma-migrate#getting-started-with-prisma-migrate |
@ -0,0 +1,18 @@ |
|||||
|
import { Prisma } from '@prisma/client'; |
||||
|
import { IsObject, IsOptional, IsString } from 'class-validator'; |
||||
|
|
||||
|
export class UpdateAssetProfileDto { |
||||
|
@IsString() |
||||
|
@IsOptional() |
||||
|
comment?: string; |
||||
|
|
||||
|
@IsObject() |
||||
|
@IsOptional() |
||||
|
scraperConfiguration?: Prisma.InputJsonObject; |
||||
|
|
||||
|
@IsObject() |
||||
|
@IsOptional() |
||||
|
symbolMapping?: { |
||||
|
[dataProvider: string]: string; |
||||
|
}; |
||||
|
} |
@ -0,0 +1,42 @@ |
|||||
|
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces'; |
||||
|
import { |
||||
|
Controller, |
||||
|
Get, |
||||
|
HttpException, |
||||
|
Param, |
||||
|
UseGuards |
||||
|
} from '@nestjs/common'; |
||||
|
import { AuthGuard } from '@nestjs/passport'; |
||||
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; |
||||
|
|
||||
|
import { ExchangeRateService } from './exchange-rate.service'; |
||||
|
|
||||
|
@Controller('exchange-rate') |
||||
|
export class ExchangeRateController { |
||||
|
public constructor( |
||||
|
private readonly exchangeRateService: ExchangeRateService |
||||
|
) {} |
||||
|
|
||||
|
@Get(':symbol/:dateString') |
||||
|
@UseGuards(AuthGuard('jwt')) |
||||
|
public async getExchangeRate( |
||||
|
@Param('dateString') dateString: string, |
||||
|
@Param('symbol') symbol: string |
||||
|
): Promise<IDataProviderHistoricalResponse> { |
||||
|
const date = new Date(dateString); |
||||
|
|
||||
|
const exchangeRate = await this.exchangeRateService.getExchangeRate({ |
||||
|
date, |
||||
|
symbol |
||||
|
}); |
||||
|
|
||||
|
if (exchangeRate) { |
||||
|
return { marketPrice: exchangeRate }; |
||||
|
} |
||||
|
|
||||
|
throw new HttpException( |
||||
|
getReasonPhrase(StatusCodes.NOT_FOUND), |
||||
|
StatusCodes.NOT_FOUND |
||||
|
); |
||||
|
} |
||||
|
} |
@ -0,0 +1,13 @@ |
|||||
|
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; |
||||
|
import { Module } from '@nestjs/common'; |
||||
|
|
||||
|
import { ExchangeRateController } from './exchange-rate.controller'; |
||||
|
import { ExchangeRateService } from './exchange-rate.service'; |
||||
|
|
||||
|
@Module({ |
||||
|
controllers: [ExchangeRateController], |
||||
|
exports: [ExchangeRateService], |
||||
|
imports: [ExchangeRateDataModule], |
||||
|
providers: [ExchangeRateService] |
||||
|
}) |
||||
|
export class ExchangeRateModule {} |
@ -0,0 +1,26 @@ |
|||||
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; |
||||
|
import { Injectable } from '@nestjs/common'; |
||||
|
|
||||
|
@Injectable() |
||||
|
export class ExchangeRateService { |
||||
|
public constructor( |
||||
|
private readonly exchangeRateDataService: ExchangeRateDataService |
||||
|
) {} |
||||
|
|
||||
|
public async getExchangeRate({ |
||||
|
date, |
||||
|
symbol |
||||
|
}: { |
||||
|
date: Date; |
||||
|
symbol: string; |
||||
|
}): Promise<number> { |
||||
|
const [currency1, currency2] = symbol.split('-'); |
||||
|
|
||||
|
return this.exchangeRateDataService.toCurrencyAtDate( |
||||
|
1, |
||||
|
currency1, |
||||
|
currency2, |
||||
|
date |
||||
|
); |
||||
|
} |
||||
|
} |
@ -0,0 +1,44 @@ |
|||||
|
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor'; |
||||
|
import { |
||||
|
Controller, |
||||
|
Get, |
||||
|
HttpException, |
||||
|
Param, |
||||
|
UseInterceptors |
||||
|
} from '@nestjs/common'; |
||||
|
import { DataSource } from '@prisma/client'; |
||||
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; |
||||
|
|
||||
|
import { HealthService } from './health.service'; |
||||
|
|
||||
|
@Controller('health') |
||||
|
export class HealthController { |
||||
|
public constructor(private readonly healthService: HealthService) {} |
||||
|
|
||||
|
@Get() |
||||
|
public async getHealth() {} |
||||
|
|
||||
|
@Get('data-provider/:dataSource') |
||||
|
@UseInterceptors(TransformDataSourceInRequestInterceptor) |
||||
|
public async getHealthOfDataProvider( |
||||
|
@Param('dataSource') dataSource: DataSource |
||||
|
) { |
||||
|
if (!DataSource[dataSource]) { |
||||
|
throw new HttpException( |
||||
|
getReasonPhrase(StatusCodes.NOT_FOUND), |
||||
|
StatusCodes.NOT_FOUND |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
const hasResponse = await this.healthService.hasResponseFromDataProvider( |
||||
|
dataSource |
||||
|
); |
||||
|
|
||||
|
if (hasResponse !== true) { |
||||
|
throw new HttpException( |
||||
|
getReasonPhrase(StatusCodes.SERVICE_UNAVAILABLE), |
||||
|
StatusCodes.SERVICE_UNAVAILABLE |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
} |
@ -0,0 +1,13 @@ |
|||||
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; |
||||
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; |
||||
|
import { Module } from '@nestjs/common'; |
||||
|
|
||||
|
import { HealthController } from './health.controller'; |
||||
|
import { HealthService } from './health.service'; |
||||
|
|
||||
|
@Module({ |
||||
|
controllers: [HealthController], |
||||
|
imports: [ConfigurationModule, DataProviderModule], |
||||
|
providers: [HealthService] |
||||
|
}) |
||||
|
export class HealthModule {} |
@ -0,0 +1,14 @@ |
|||||
|
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; |
||||
|
import { Injectable } from '@nestjs/common'; |
||||
|
import { DataSource } from '@prisma/client'; |
||||
|
|
||||
|
@Injectable() |
||||
|
export class HealthService { |
||||
|
public constructor( |
||||
|
private readonly dataProviderService: DataProviderService |
||||
|
) {} |
||||
|
|
||||
|
public async hasResponseFromDataProvider(aDataSource: DataSource) { |
||||
|
return this.dataProviderService.checkQuote(aDataSource); |
||||
|
} |
||||
|
} |
@ -1,149 +1,493 @@ |
|||||
import { AccountService } from '@ghostfolio/api/app/account/account.service'; |
import { AccountService } from '@ghostfolio/api/app/account/account.service'; |
||||
|
import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto'; |
||||
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto'; |
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto'; |
||||
|
import { |
||||
|
Activity, |
||||
|
ActivityError |
||||
|
} from '@ghostfolio/api/app/order/interfaces/activities.interface'; |
||||
import { OrderService } from '@ghostfolio/api/app/order/order.service'; |
import { OrderService } from '@ghostfolio/api/app/order/order.service'; |
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; |
import { PlatformService } from '@ghostfolio/api/app/platform/platform.service'; |
||||
|
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; |
||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.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 { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; |
||||
|
import { parseDate } from '@ghostfolio/common/helper'; |
||||
|
import { UniqueAsset } from '@ghostfolio/common/interfaces'; |
||||
|
import { |
||||
|
AccountWithPlatform, |
||||
|
OrderWithAccount |
||||
|
} from '@ghostfolio/common/types'; |
||||
import { Injectable } from '@nestjs/common'; |
import { Injectable } from '@nestjs/common'; |
||||
import { isSameDay, parseISO } from 'date-fns'; |
import { DataSource, Prisma, SymbolProfile } from '@prisma/client'; |
||||
|
import Big from 'big.js'; |
||||
|
import { endOfToday, isAfter, isSameDay, parseISO } from 'date-fns'; |
||||
|
import { v4 as uuidv4 } from 'uuid'; |
||||
|
|
||||
@Injectable() |
@Injectable() |
||||
export class ImportService { |
export class ImportService { |
||||
public constructor( |
public constructor( |
||||
private readonly accountService: AccountService, |
private readonly accountService: AccountService, |
||||
private readonly configurationService: ConfigurationService, |
|
||||
private readonly dataProviderService: DataProviderService, |
private readonly dataProviderService: DataProviderService, |
||||
private readonly orderService: OrderService |
private readonly exchangeRateDataService: ExchangeRateDataService, |
||||
|
private readonly orderService: OrderService, |
||||
|
private readonly platformService: PlatformService, |
||||
|
private readonly portfolioService: PortfolioService, |
||||
|
private readonly symbolProfileService: SymbolProfileService |
||||
) {} |
) {} |
||||
|
|
||||
|
public async getDividends({ |
||||
|
dataSource, |
||||
|
symbol, |
||||
|
userCurrency |
||||
|
}: UniqueAsset & { userCurrency: string }): Promise<Activity[]> { |
||||
|
try { |
||||
|
const { firstBuyDate, historicalData, orders } = |
||||
|
await this.portfolioService.getPosition(dataSource, undefined, symbol); |
||||
|
|
||||
|
const [[assetProfile], dividends] = await Promise.all([ |
||||
|
this.symbolProfileService.getSymbolProfiles([ |
||||
|
{ |
||||
|
dataSource, |
||||
|
symbol |
||||
|
} |
||||
|
]), |
||||
|
await this.dataProviderService.getDividends({ |
||||
|
dataSource, |
||||
|
symbol, |
||||
|
from: parseDate(firstBuyDate), |
||||
|
granularity: 'day', |
||||
|
to: new Date() |
||||
|
}) |
||||
|
]); |
||||
|
|
||||
|
const accounts = orders.map((order) => { |
||||
|
return order.Account; |
||||
|
}); |
||||
|
|
||||
|
const Account = this.isUniqueAccount(accounts) ? accounts[0] : undefined; |
||||
|
|
||||
|
return Object.entries(dividends).map(([dateString, { marketPrice }]) => { |
||||
|
const quantity = |
||||
|
historicalData.find((historicalDataItem) => { |
||||
|
return historicalDataItem.date === dateString; |
||||
|
})?.quantity ?? 0; |
||||
|
|
||||
|
const value = new Big(quantity).mul(marketPrice).toNumber(); |
||||
|
|
||||
|
const isDuplicate = orders.some((activity) => { |
||||
|
return ( |
||||
|
activity.SymbolProfile.currency === assetProfile.currency && |
||||
|
activity.SymbolProfile.dataSource === assetProfile.dataSource && |
||||
|
isSameDay(activity.date, parseDate(dateString)) && |
||||
|
activity.quantity === quantity && |
||||
|
activity.SymbolProfile.symbol === assetProfile.symbol && |
||||
|
activity.type === 'DIVIDEND' && |
||||
|
activity.unitPrice === marketPrice |
||||
|
); |
||||
|
}); |
||||
|
|
||||
|
const error: ActivityError = isDuplicate |
||||
|
? { code: 'IS_DUPLICATE' } |
||||
|
: undefined; |
||||
|
|
||||
|
return { |
||||
|
Account, |
||||
|
error, |
||||
|
quantity, |
||||
|
value, |
||||
|
accountId: Account?.id, |
||||
|
accountUserId: undefined, |
||||
|
comment: undefined, |
||||
|
createdAt: undefined, |
||||
|
date: parseDate(dateString), |
||||
|
fee: 0, |
||||
|
feeInBaseCurrency: 0, |
||||
|
id: assetProfile.id, |
||||
|
isDraft: false, |
||||
|
SymbolProfile: <SymbolProfile>(<unknown>assetProfile), |
||||
|
symbolProfileId: assetProfile.id, |
||||
|
type: 'DIVIDEND', |
||||
|
unitPrice: marketPrice, |
||||
|
updatedAt: undefined, |
||||
|
userId: Account?.userId, |
||||
|
valueInBaseCurrency: this.exchangeRateDataService.toCurrency( |
||||
|
value, |
||||
|
assetProfile.currency, |
||||
|
userCurrency |
||||
|
) |
||||
|
}; |
||||
|
}); |
||||
|
} catch { |
||||
|
return []; |
||||
|
} |
||||
|
} |
||||
|
|
||||
public async import({ |
public async import({ |
||||
activities, |
accountsDto, |
||||
|
activitiesDto, |
||||
|
isDryRun = false, |
||||
maxActivitiesToImport, |
maxActivitiesToImport, |
||||
|
userCurrency, |
||||
userId |
userId |
||||
}: { |
}: { |
||||
activities: Partial<CreateOrderDto>[]; |
accountsDto: Partial<CreateAccountDto>[]; |
||||
|
activitiesDto: Partial<CreateOrderDto>[]; |
||||
|
isDryRun?: boolean; |
||||
maxActivitiesToImport: number; |
maxActivitiesToImport: number; |
||||
|
userCurrency: string; |
||||
userId: string; |
userId: string; |
||||
}): Promise<void> { |
}): Promise<Activity[]> { |
||||
for (const activity of activities) { |
const accountIdMapping: { [oldAccountId: string]: string } = {}; |
||||
|
|
||||
|
if (!isDryRun && accountsDto?.length) { |
||||
|
const [existingAccounts, existingPlatforms] = await Promise.all([ |
||||
|
this.accountService.accounts({ |
||||
|
where: { |
||||
|
id: { |
||||
|
in: accountsDto.map(({ id }) => { |
||||
|
return id; |
||||
|
}) |
||||
|
} |
||||
|
} |
||||
|
}), |
||||
|
this.platformService.getPlatforms() |
||||
|
]); |
||||
|
|
||||
|
for (const account of accountsDto) { |
||||
|
// Check if there is any existing account with the same ID
|
||||
|
const accountWithSameId = existingAccounts.find( |
||||
|
(existingAccount) => existingAccount.id === account.id |
||||
|
); |
||||
|
|
||||
|
// If there is no account or if the account belongs to a different user then create a new account
|
||||
|
if (!accountWithSameId || accountWithSameId.userId !== userId) { |
||||
|
let oldAccountId: string; |
||||
|
const platformId = account.platformId; |
||||
|
|
||||
|
delete account.platformId; |
||||
|
|
||||
|
if (accountWithSameId) { |
||||
|
oldAccountId = account.id; |
||||
|
delete account.id; |
||||
|
} |
||||
|
|
||||
|
let accountObject: Prisma.AccountCreateInput = { |
||||
|
...account, |
||||
|
User: { connect: { id: userId } } |
||||
|
}; |
||||
|
|
||||
|
if ( |
||||
|
existingPlatforms.some(({ id }) => { |
||||
|
return id === platformId; |
||||
|
}) |
||||
|
) { |
||||
|
accountObject = { |
||||
|
...accountObject, |
||||
|
Platform: { connect: { id: platformId } } |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
const newAccount = await this.accountService.createAccount( |
||||
|
accountObject, |
||||
|
userId |
||||
|
); |
||||
|
|
||||
|
// Store the new to old account ID mappings for updating activities
|
||||
|
if (accountWithSameId && oldAccountId) { |
||||
|
accountIdMapping[oldAccountId] = newAccount.id; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
for (const activity of activitiesDto) { |
||||
if (!activity.dataSource) { |
if (!activity.dataSource) { |
||||
if (activity.type === 'ITEM') { |
if (activity.type === 'ITEM' || activity.type === 'LIABILITY') { |
||||
activity.dataSource = 'MANUAL'; |
activity.dataSource = DataSource.MANUAL; |
||||
} else { |
} else { |
||||
activity.dataSource = this.dataProviderService.getPrimaryDataSource(); |
activity.dataSource = |
||||
|
this.dataProviderService.getDataSourceForImport(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// If a new account is created, then update the accountId in all activities
|
||||
|
if (!isDryRun) { |
||||
|
if (Object.keys(accountIdMapping).includes(activity.accountId)) { |
||||
|
activity.accountId = accountIdMapping[activity.accountId]; |
||||
} |
} |
||||
} |
} |
||||
} |
} |
||||
|
|
||||
await this.validateActivities({ |
const assetProfiles = await this.validateActivities({ |
||||
activities, |
activitiesDto, |
||||
maxActivitiesToImport, |
maxActivitiesToImport, |
||||
userId |
userId |
||||
}); |
}); |
||||
|
|
||||
const accountIds = (await this.accountService.getAccounts(userId)).map( |
const activitiesExtendedWithErrors = await this.extendActivitiesWithErrors({ |
||||
(account) => { |
activitiesDto, |
||||
return account.id; |
userId |
||||
|
}); |
||||
|
|
||||
|
const accounts = (await this.accountService.getAccounts(userId)).map( |
||||
|
({ id, name }) => { |
||||
|
return { id, name }; |
||||
} |
} |
||||
); |
); |
||||
|
|
||||
|
if (isDryRun) { |
||||
|
accountsDto.forEach(({ id, name }) => { |
||||
|
accounts.push({ id, name }); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
const activities: Activity[] = []; |
||||
|
|
||||
for (const { |
for (const { |
||||
accountId, |
accountId, |
||||
comment, |
comment, |
||||
currency, |
|
||||
dataSource, |
|
||||
date, |
date, |
||||
|
error, |
||||
fee, |
fee, |
||||
quantity, |
quantity, |
||||
symbol, |
SymbolProfile: assetProfile, |
||||
type, |
type, |
||||
unitPrice |
unitPrice |
||||
} of activities) { |
} of activitiesExtendedWithErrors) { |
||||
await this.orderService.createOrder({ |
const validatedAccount = accounts.find(({ id }) => { |
||||
|
return id === accountId; |
||||
|
}); |
||||
|
|
||||
|
let order: |
||||
|
| OrderWithAccount |
||||
|
| (Omit<OrderWithAccount, 'Account'> & { |
||||
|
Account?: { id: string; name: string }; |
||||
|
}); |
||||
|
|
||||
|
if (isDryRun) { |
||||
|
order = { |
||||
|
comment, |
||||
|
date, |
||||
|
fee, |
||||
|
quantity, |
||||
|
type, |
||||
|
unitPrice, |
||||
|
userId, |
||||
|
accountId: validatedAccount?.id, |
||||
|
accountUserId: undefined, |
||||
|
createdAt: new Date(), |
||||
|
id: uuidv4(), |
||||
|
isDraft: isAfter(date, endOfToday()), |
||||
|
SymbolProfile: { |
||||
|
assetClass: assetProfile.assetClass, |
||||
|
assetSubClass: assetProfile.assetSubClass, |
||||
|
comment: assetProfile.comment, |
||||
|
countries: assetProfile.countries, |
||||
|
createdAt: assetProfile.createdAt, |
||||
|
currency: assetProfile.currency, |
||||
|
dataSource: assetProfile.dataSource, |
||||
|
id: assetProfile.id, |
||||
|
isin: assetProfile.isin, |
||||
|
name: assetProfile.name, |
||||
|
scraperConfiguration: assetProfile.scraperConfiguration, |
||||
|
sectors: assetProfile.sectors, |
||||
|
symbol: assetProfile.currency, |
||||
|
symbolMapping: assetProfile.symbolMapping, |
||||
|
updatedAt: assetProfile.updatedAt, |
||||
|
url: assetProfile.url, |
||||
|
...assetProfiles[assetProfile.symbol] |
||||
|
}, |
||||
|
Account: validatedAccount, |
||||
|
symbolProfileId: undefined, |
||||
|
updatedAt: new Date() |
||||
|
}; |
||||
|
} else { |
||||
|
if (error) { |
||||
|
continue; |
||||
|
} |
||||
|
|
||||
|
order = await this.orderService.createOrder({ |
||||
|
comment, |
||||
|
date, |
||||
|
fee, |
||||
|
quantity, |
||||
|
type, |
||||
|
unitPrice, |
||||
|
userId, |
||||
|
accountId: validatedAccount?.id, |
||||
|
SymbolProfile: { |
||||
|
connectOrCreate: { |
||||
|
create: { |
||||
|
currency: assetProfile.currency, |
||||
|
dataSource: assetProfile.dataSource, |
||||
|
symbol: assetProfile.symbol |
||||
|
}, |
||||
|
where: { |
||||
|
dataSource_symbol: { |
||||
|
dataSource: assetProfile.dataSource, |
||||
|
symbol: assetProfile.symbol |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
}, |
||||
|
updateAccountBalance: false, |
||||
|
User: { connect: { id: userId } } |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
const value = new Big(quantity).mul(unitPrice).toNumber(); |
||||
|
|
||||
|
//@ts-ignore
|
||||
|
activities.push({ |
||||
|
...order, |
||||
|
error, |
||||
|
value, |
||||
|
feeInBaseCurrency: this.exchangeRateDataService.toCurrency( |
||||
|
fee, |
||||
|
assetProfile.currency, |
||||
|
userCurrency |
||||
|
), |
||||
|
valueInBaseCurrency: this.exchangeRateDataService.toCurrency( |
||||
|
value, |
||||
|
assetProfile.currency, |
||||
|
userCurrency |
||||
|
) |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
return activities; |
||||
|
} |
||||
|
|
||||
|
private async extendActivitiesWithErrors({ |
||||
|
activitiesDto, |
||||
|
userId |
||||
|
}: { |
||||
|
activitiesDto: Partial<CreateOrderDto>[]; |
||||
|
userId: string; |
||||
|
}): Promise<Partial<Activity>[]> { |
||||
|
const existingActivities = await this.orderService.orders({ |
||||
|
include: { SymbolProfile: true }, |
||||
|
orderBy: { date: 'desc' }, |
||||
|
where: { userId } |
||||
|
}); |
||||
|
|
||||
|
return activitiesDto.map( |
||||
|
({ |
||||
|
accountId, |
||||
comment, |
comment, |
||||
|
currency, |
||||
|
dataSource, |
||||
|
date: dateString, |
||||
fee, |
fee, |
||||
quantity, |
quantity, |
||||
|
symbol, |
||||
type, |
type, |
||||
unitPrice, |
unitPrice |
||||
userId, |
}) => { |
||||
accountId: accountIds.includes(accountId) ? accountId : undefined, |
const date = parseISO(<string>(<unknown>dateString)); |
||||
date: parseISO(<string>(<unknown>date)), |
const isDuplicate = existingActivities.some((activity) => { |
||||
SymbolProfile: { |
return ( |
||||
connectOrCreate: { |
activity.SymbolProfile.currency === currency && |
||||
create: { |
activity.SymbolProfile.dataSource === dataSource && |
||||
currency, |
isSameDay(activity.date, date) && |
||||
dataSource, |
activity.fee === fee && |
||||
symbol |
activity.quantity === quantity && |
||||
}, |
activity.SymbolProfile.symbol === symbol && |
||||
where: { |
activity.type === type && |
||||
dataSource_symbol: { |
activity.unitPrice === unitPrice |
||||
dataSource, |
); |
||||
symbol |
}); |
||||
} |
|
||||
} |
const error: ActivityError = isDuplicate |
||||
|
? { code: 'IS_DUPLICATE' } |
||||
|
: undefined; |
||||
|
|
||||
|
return { |
||||
|
accountId, |
||||
|
comment, |
||||
|
date, |
||||
|
error, |
||||
|
fee, |
||||
|
quantity, |
||||
|
type, |
||||
|
unitPrice, |
||||
|
SymbolProfile: { |
||||
|
currency, |
||||
|
dataSource, |
||||
|
symbol, |
||||
|
assetClass: null, |
||||
|
assetSubClass: null, |
||||
|
comment: null, |
||||
|
countries: null, |
||||
|
createdAt: undefined, |
||||
|
id: undefined, |
||||
|
isin: null, |
||||
|
name: null, |
||||
|
scraperConfiguration: null, |
||||
|
sectors: null, |
||||
|
symbolMapping: null, |
||||
|
updatedAt: undefined, |
||||
|
url: null |
||||
} |
} |
||||
}, |
}; |
||||
User: { connect: { id: userId } } |
} |
||||
}); |
); |
||||
|
} |
||||
|
|
||||
|
private isUniqueAccount(accounts: AccountWithPlatform[]) { |
||||
|
const uniqueAccountIds = new Set<string>(); |
||||
|
|
||||
|
for (const account of accounts) { |
||||
|
uniqueAccountIds.add(account.id); |
||||
} |
} |
||||
|
|
||||
|
return uniqueAccountIds.size === 1; |
||||
} |
} |
||||
|
|
||||
private async validateActivities({ |
private async validateActivities({ |
||||
activities, |
activitiesDto, |
||||
maxActivitiesToImport, |
maxActivitiesToImport, |
||||
userId |
userId |
||||
}: { |
}: { |
||||
activities: Partial<CreateOrderDto>[]; |
activitiesDto: Partial<CreateOrderDto>[]; |
||||
maxActivitiesToImport: number; |
maxActivitiesToImport: number; |
||||
userId: string; |
userId: string; |
||||
}) { |
}) { |
||||
if (activities?.length > maxActivitiesToImport) { |
if (activitiesDto?.length > maxActivitiesToImport) { |
||||
throw new Error(`Too many activities (${maxActivitiesToImport} at most)`); |
throw new Error(`Too many activities (${maxActivitiesToImport} at most)`); |
||||
} |
} |
||||
|
|
||||
const existingActivities = await this.orderService.orders({ |
const assetProfiles: { |
||||
include: { SymbolProfile: true }, |
[symbol: string]: Partial<SymbolProfile>; |
||||
orderBy: { date: 'desc' }, |
} = {}; |
||||
where: { userId } |
|
||||
}); |
|
||||
|
|
||||
for (const [ |
for (const [ |
||||
index, |
index, |
||||
{ currency, dataSource, date, fee, quantity, symbol, type, unitPrice } |
{ currency, dataSource, symbol } |
||||
] of activities.entries()) { |
] of activitiesDto.entries()) { |
||||
const duplicateActivity = existingActivities.find((activity) => { |
|
||||
return ( |
|
||||
activity.SymbolProfile.currency === currency && |
|
||||
activity.SymbolProfile.dataSource === dataSource && |
|
||||
isSameDay(activity.date, parseISO(<string>(<unknown>date))) && |
|
||||
activity.fee === fee && |
|
||||
activity.quantity === quantity && |
|
||||
activity.SymbolProfile.symbol === symbol && |
|
||||
activity.type === type && |
|
||||
activity.unitPrice === unitPrice |
|
||||
); |
|
||||
}); |
|
||||
|
|
||||
if (duplicateActivity) { |
|
||||
throw new Error(`activities.${index} is a duplicate activity`); |
|
||||
} |
|
||||
|
|
||||
if (dataSource !== 'MANUAL') { |
if (dataSource !== 'MANUAL') { |
||||
const quotes = await this.dataProviderService.getQuotes([ |
const assetProfile = ( |
||||
{ dataSource, symbol } |
await this.dataProviderService.getAssetProfiles([ |
||||
]); |
{ dataSource, symbol } |
||||
|
]) |
||||
|
)?.[symbol]; |
||||
|
|
||||
if (quotes[symbol] === undefined) { |
if (assetProfile === undefined) { |
||||
throw new Error( |
throw new Error( |
||||
`activities.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")` |
`activities.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")` |
||||
); |
); |
||||
} |
} |
||||
|
|
||||
if (quotes[symbol].currency !== currency) { |
if (assetProfile.currency !== currency) { |
||||
throw new Error( |
throw new Error( |
||||
`activities.${index}.currency ("${currency}") does not match with "${quotes[symbol].currency}"` |
`activities.${index}.currency ("${currency}") does not match with "${assetProfile.currency}"` |
||||
); |
); |
||||
} |
} |
||||
|
|
||||
|
assetProfiles[symbol] = assetProfile; |
||||
} |
} |
||||
} |
} |
||||
|
|
||||
|
return assetProfiles; |
||||
} |
} |
||||
} |
} |
||||
|
@ -0,0 +1,54 @@ |
|||||
|
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor'; |
||||
|
import { |
||||
|
Controller, |
||||
|
Get, |
||||
|
HttpStatus, |
||||
|
Param, |
||||
|
Query, |
||||
|
Res, |
||||
|
UseInterceptors |
||||
|
} from '@nestjs/common'; |
||||
|
import { DataSource } from '@prisma/client'; |
||||
|
import { Response } from 'express'; |
||||
|
|
||||
|
import { LogoService } from './logo.service'; |
||||
|
|
||||
|
@Controller('logo') |
||||
|
export class LogoController { |
||||
|
public constructor(private readonly logoService: LogoService) {} |
||||
|
|
||||
|
@Get(':dataSource/:symbol') |
||||
|
@UseInterceptors(TransformDataSourceInRequestInterceptor) |
||||
|
public async getLogoByDataSourceAndSymbol( |
||||
|
@Param('dataSource') dataSource: DataSource, |
||||
|
@Param('symbol') symbol: string, |
||||
|
@Res() response: Response |
||||
|
) { |
||||
|
try { |
||||
|
const buffer = await this.logoService.getLogoByDataSourceAndSymbol({ |
||||
|
dataSource, |
||||
|
symbol |
||||
|
}); |
||||
|
|
||||
|
response.contentType('image/png'); |
||||
|
response.send(buffer); |
||||
|
} catch { |
||||
|
response.status(HttpStatus.NOT_FOUND).send(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@Get() |
||||
|
public async getLogoByUrl( |
||||
|
@Query('url') url: string, |
||||
|
@Res() response: Response |
||||
|
) { |
||||
|
try { |
||||
|
const buffer = await this.logoService.getLogoByUrl(url); |
||||
|
|
||||
|
response.contentType('image/png'); |
||||
|
response.send(buffer); |
||||
|
} catch { |
||||
|
response.status(HttpStatus.NOT_FOUND).send(); |
||||
|
} |
||||
|
} |
||||
|
} |
@ -0,0 +1,13 @@ |
|||||
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; |
||||
|
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; |
||||
|
import { Module } from '@nestjs/common'; |
||||
|
|
||||
|
import { LogoController } from './logo.controller'; |
||||
|
import { LogoService } from './logo.service'; |
||||
|
|
||||
|
@Module({ |
||||
|
controllers: [LogoController], |
||||
|
imports: [ConfigurationModule, SymbolProfileModule], |
||||
|
providers: [LogoService] |
||||
|
}) |
||||
|
export class LogoModule {} |
@ -0,0 +1,55 @@ |
|||||
|
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; |
||||
|
import { UniqueAsset } from '@ghostfolio/common/interfaces'; |
||||
|
import { HttpException, Injectable } from '@nestjs/common'; |
||||
|
import { DataSource } from '@prisma/client'; |
||||
|
import * as bent from 'bent'; |
||||
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; |
||||
|
|
||||
|
@Injectable() |
||||
|
export class LogoService { |
||||
|
public constructor( |
||||
|
private readonly symbolProfileService: SymbolProfileService |
||||
|
) {} |
||||
|
|
||||
|
public async getLogoByDataSourceAndSymbol({ |
||||
|
dataSource, |
||||
|
symbol |
||||
|
}: UniqueAsset) { |
||||
|
if (!DataSource[dataSource]) { |
||||
|
throw new HttpException( |
||||
|
getReasonPhrase(StatusCodes.NOT_FOUND), |
||||
|
StatusCodes.NOT_FOUND |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
const [assetProfile] = await this.symbolProfileService.getSymbolProfiles([ |
||||
|
{ dataSource, symbol } |
||||
|
]); |
||||
|
|
||||
|
if (!assetProfile) { |
||||
|
throw new HttpException( |
||||
|
getReasonPhrase(StatusCodes.NOT_FOUND), |
||||
|
StatusCodes.NOT_FOUND |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
return this.getBuffer(assetProfile.url); |
||||
|
} |
||||
|
|
||||
|
public async getLogoByUrl(aUrl: string) { |
||||
|
return this.getBuffer(aUrl); |
||||
|
} |
||||
|
|
||||
|
private getBuffer(aUrl: string) { |
||||
|
const get = bent( |
||||
|
`https://t0.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${aUrl}&size=64`, |
||||
|
'GET', |
||||
|
'buffer', |
||||
|
200, |
||||
|
{ |
||||
|
'User-Agent': 'request' |
||||
|
} |
||||
|
); |
||||
|
return get(); |
||||
|
} |
||||
|
} |
@ -0,0 +1,9 @@ |
|||||
|
import { IsString } from 'class-validator'; |
||||
|
|
||||
|
export class CreatePlatformDto { |
||||
|
@IsString() |
||||
|
name: string; |
||||
|
|
||||
|
@IsString() |
||||
|
url: string; |
||||
|
} |
@ -0,0 +1,114 @@ |
|||||
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; |
||||
|
import type { RequestWithUser } from '@ghostfolio/common/types'; |
||||
|
import { |
||||
|
Body, |
||||
|
Controller, |
||||
|
Delete, |
||||
|
Get, |
||||
|
HttpException, |
||||
|
Inject, |
||||
|
Param, |
||||
|
Post, |
||||
|
Put, |
||||
|
UseGuards |
||||
|
} from '@nestjs/common'; |
||||
|
import { REQUEST } from '@nestjs/core'; |
||||
|
import { AuthGuard } from '@nestjs/passport'; |
||||
|
import { Platform } from '@prisma/client'; |
||||
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; |
||||
|
|
||||
|
import { CreatePlatformDto } from './create-platform.dto'; |
||||
|
import { PlatformService } from './platform.service'; |
||||
|
import { UpdatePlatformDto } from './update-platform.dto'; |
||||
|
|
||||
|
@Controller('platform') |
||||
|
export class PlatformController { |
||||
|
public constructor( |
||||
|
private readonly platformService: PlatformService, |
||||
|
@Inject(REQUEST) private readonly request: RequestWithUser |
||||
|
) {} |
||||
|
|
||||
|
@Get() |
||||
|
@UseGuards(AuthGuard('jwt')) |
||||
|
public async getPlatforms() { |
||||
|
return this.platformService.getPlatformsWithAccountCount(); |
||||
|
} |
||||
|
|
||||
|
@Post() |
||||
|
@UseGuards(AuthGuard('jwt')) |
||||
|
public async createPlatform( |
||||
|
@Body() data: CreatePlatformDto |
||||
|
): Promise<Platform> { |
||||
|
if ( |
||||
|
!hasPermission(this.request.user.permissions, permissions.createPlatform) |
||||
|
) { |
||||
|
throw new HttpException( |
||||
|
getReasonPhrase(StatusCodes.FORBIDDEN), |
||||
|
StatusCodes.FORBIDDEN |
||||
|
); |
||||
|
} |
||||
|
return this.platformService.createPlatform(data); |
||||
|
} |
||||
|
|
||||
|
@Put(':id') |
||||
|
@UseGuards(AuthGuard('jwt')) |
||||
|
public async updatePlatform( |
||||
|
@Param('id') id: string, |
||||
|
@Body() data: UpdatePlatformDto |
||||
|
) { |
||||
|
if ( |
||||
|
!hasPermission(this.request.user.permissions, permissions.updatePlatform) |
||||
|
) { |
||||
|
throw new HttpException( |
||||
|
getReasonPhrase(StatusCodes.FORBIDDEN), |
||||
|
StatusCodes.FORBIDDEN |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
const originalPlatform = await this.platformService.getPlatform({ |
||||
|
id |
||||
|
}); |
||||
|
|
||||
|
if (!originalPlatform) { |
||||
|
throw new HttpException( |
||||
|
getReasonPhrase(StatusCodes.FORBIDDEN), |
||||
|
StatusCodes.FORBIDDEN |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
return this.platformService.updatePlatform({ |
||||
|
data: { |
||||
|
...data |
||||
|
}, |
||||
|
where: { |
||||
|
id |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
@Delete(':id') |
||||
|
@UseGuards(AuthGuard('jwt')) |
||||
|
public async deletePlatform(@Param('id') id: string) { |
||||
|
if ( |
||||
|
!hasPermission(this.request.user.permissions, permissions.deletePlatform) |
||||
|
) { |
||||
|
throw new HttpException( |
||||
|
getReasonPhrase(StatusCodes.FORBIDDEN), |
||||
|
StatusCodes.FORBIDDEN |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
const originalPlatform = await this.platformService.getPlatform({ |
||||
|
id |
||||
|
}); |
||||
|
|
||||
|
if (!originalPlatform) { |
||||
|
throw new HttpException( |
||||
|
getReasonPhrase(StatusCodes.FORBIDDEN), |
||||
|
StatusCodes.FORBIDDEN |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
return this.platformService.deletePlatform({ id }); |
||||
|
} |
||||
|
} |
@ -0,0 +1,13 @@ |
|||||
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; |
||||
|
import { Module } from '@nestjs/common'; |
||||
|
|
||||
|
import { PlatformController } from './platform.controller'; |
||||
|
import { PlatformService } from './platform.service'; |
||||
|
|
||||
|
@Module({ |
||||
|
controllers: [PlatformController], |
||||
|
exports: [PlatformService], |
||||
|
imports: [PrismaModule], |
||||
|
providers: [PlatformService] |
||||
|
}) |
||||
|
export class PlatformModule {} |
@ -0,0 +1,83 @@ |
|||||
|
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; |
||||
|
import { Injectable } from '@nestjs/common'; |
||||
|
import { Platform, Prisma } from '@prisma/client'; |
||||
|
|
||||
|
@Injectable() |
||||
|
export class PlatformService { |
||||
|
public constructor(private readonly prismaService: PrismaService) {} |
||||
|
|
||||
|
public async getPlatform( |
||||
|
platformWhereUniqueInput: Prisma.PlatformWhereUniqueInput |
||||
|
): Promise<Platform> { |
||||
|
return this.prismaService.platform.findUnique({ |
||||
|
where: platformWhereUniqueInput |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
public async getPlatforms({ |
||||
|
cursor, |
||||
|
orderBy, |
||||
|
skip, |
||||
|
take, |
||||
|
where |
||||
|
}: { |
||||
|
cursor?: Prisma.PlatformWhereUniqueInput; |
||||
|
orderBy?: Prisma.PlatformOrderByWithRelationInput; |
||||
|
skip?: number; |
||||
|
take?: number; |
||||
|
where?: Prisma.PlatformWhereInput; |
||||
|
} = {}) { |
||||
|
return this.prismaService.platform.findMany({ |
||||
|
cursor, |
||||
|
orderBy, |
||||
|
skip, |
||||
|
take, |
||||
|
where |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
public async getPlatformsWithAccountCount() { |
||||
|
const platformsWithAccountCount = |
||||
|
await this.prismaService.platform.findMany({ |
||||
|
include: { |
||||
|
_count: { |
||||
|
select: { Account: true } |
||||
|
} |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
return platformsWithAccountCount.map(({ _count, id, name, url }) => { |
||||
|
return { |
||||
|
id, |
||||
|
name, |
||||
|
url, |
||||
|
accountCount: _count.Account |
||||
|
}; |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
public async createPlatform(data: Prisma.PlatformCreateInput) { |
||||
|
return this.prismaService.platform.create({ |
||||
|
data |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
public async updatePlatform({ |
||||
|
data, |
||||
|
where |
||||
|
}: { |
||||
|
data: Prisma.PlatformUpdateInput; |
||||
|
where: Prisma.PlatformWhereUniqueInput; |
||||
|
}): Promise<Platform> { |
||||
|
return this.prismaService.platform.update({ |
||||
|
data, |
||||
|
where |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
public async deletePlatform( |
||||
|
where: Prisma.PlatformWhereUniqueInput |
||||
|
): Promise<Platform> { |
||||
|
return this.prismaService.platform.delete({ where }); |
||||
|
} |
||||
|
} |
@ -0,0 +1,12 @@ |
|||||
|
import { IsString } from 'class-validator'; |
||||
|
|
||||
|
export class UpdatePlatformDto { |
||||
|
@IsString() |
||||
|
id: string; |
||||
|
|
||||
|
@IsString() |
||||
|
name: string; |
||||
|
|
||||
|
@IsString() |
||||
|
url: string; |
||||
|
} |
@ -0,0 +1,9 @@ |
|||||
|
import { DataProviderInfo, ResponseError } from '@ghostfolio/common/interfaces'; |
||||
|
|
||||
|
import { GetValueObject } from './get-value-object.interface'; |
||||
|
|
||||
|
export interface GetValuesObject { |
||||
|
dataProviderInfos: DataProviderInfo[]; |
||||
|
errors: ResponseError['errors']; |
||||
|
values: GetValueObject[]; |
||||
|
} |
@ -0,0 +1,132 @@ |
|||||
|
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; |
||||
|
import { parseDate } from '@ghostfolio/common/helper'; |
||||
|
import Big from 'big.js'; |
||||
|
|
||||
|
import { CurrentRateServiceMock } from './current-rate.service.mock'; |
||||
|
import { PortfolioCalculator } from './portfolio-calculator'; |
||||
|
|
||||
|
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { |
||||
|
return { |
||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
|
CurrentRateService: jest.fn().mockImplementation(() => { |
||||
|
return CurrentRateServiceMock; |
||||
|
}) |
||||
|
}; |
||||
|
}); |
||||
|
|
||||
|
describe('PortfolioCalculator', () => { |
||||
|
let currentRateService: CurrentRateService; |
||||
|
|
||||
|
beforeEach(() => { |
||||
|
currentRateService = new CurrentRateService(null, null, null); |
||||
|
}); |
||||
|
|
||||
|
describe('get current positions', () => { |
||||
|
it.only('with NOVN.SW buy and sell', async () => { |
||||
|
const portfolioCalculator = new PortfolioCalculator({ |
||||
|
currentRateService, |
||||
|
currency: 'CHF', |
||||
|
orders: [ |
||||
|
{ |
||||
|
currency: 'CHF', |
||||
|
date: '2022-03-07', |
||||
|
dataSource: 'YAHOO', |
||||
|
fee: new Big(0), |
||||
|
name: 'Novartis AG', |
||||
|
quantity: new Big(2), |
||||
|
symbol: 'NOVN.SW', |
||||
|
type: 'BUY', |
||||
|
unitPrice: new Big(75.8) |
||||
|
}, |
||||
|
{ |
||||
|
currency: 'CHF', |
||||
|
date: '2022-04-08', |
||||
|
dataSource: 'YAHOO', |
||||
|
fee: new Big(0), |
||||
|
name: 'Novartis AG', |
||||
|
quantity: new Big(2), |
||||
|
symbol: 'NOVN.SW', |
||||
|
type: 'SELL', |
||||
|
unitPrice: new Big(85.73) |
||||
|
} |
||||
|
] |
||||
|
}); |
||||
|
|
||||
|
portfolioCalculator.computeTransactionPoints(); |
||||
|
|
||||
|
const spy = jest |
||||
|
.spyOn(Date, 'now') |
||||
|
.mockImplementation(() => parseDate('2022-04-11').getTime()); |
||||
|
|
||||
|
const chartData = await portfolioCalculator.getChartData( |
||||
|
parseDate('2022-03-07') |
||||
|
); |
||||
|
|
||||
|
const currentPositions = await portfolioCalculator.getCurrentPositions( |
||||
|
parseDate('2022-03-07') |
||||
|
); |
||||
|
|
||||
|
const investments = portfolioCalculator.getInvestments(); |
||||
|
|
||||
|
const investmentsByMonth = |
||||
|
portfolioCalculator.getInvestmentsByGroup('month'); |
||||
|
|
||||
|
spy.mockRestore(); |
||||
|
|
||||
|
expect(chartData[0]).toEqual({ |
||||
|
date: '2022-03-07', |
||||
|
netPerformanceInPercentage: 0, |
||||
|
netPerformance: 0, |
||||
|
totalInvestment: 151.6, |
||||
|
value: 151.6 |
||||
|
}); |
||||
|
|
||||
|
expect(chartData[chartData.length - 1]).toEqual({ |
||||
|
date: '2022-04-11', |
||||
|
netPerformanceInPercentage: 13.100263852242744, |
||||
|
netPerformance: 19.86, |
||||
|
totalInvestment: 0, |
||||
|
value: 0 |
||||
|
}); |
||||
|
|
||||
|
expect(currentPositions).toEqual({ |
||||
|
currentValue: new Big('0'), |
||||
|
errors: [], |
||||
|
grossPerformance: new Big('19.86'), |
||||
|
grossPerformancePercentage: new Big('0.13100263852242744063'), |
||||
|
hasErrors: false, |
||||
|
netPerformance: new Big('19.86'), |
||||
|
netPerformancePercentage: new Big('0.13100263852242744063'), |
||||
|
positions: [ |
||||
|
{ |
||||
|
averagePrice: new Big('0'), |
||||
|
currency: 'CHF', |
||||
|
dataSource: 'YAHOO', |
||||
|
fee: new Big('0'), |
||||
|
firstBuyDate: '2022-03-07', |
||||
|
grossPerformance: new Big('19.86'), |
||||
|
grossPerformancePercentage: new Big('0.13100263852242744063'), |
||||
|
investment: new Big('0'), |
||||
|
netPerformance: new Big('19.86'), |
||||
|
netPerformancePercentage: new Big('0.13100263852242744063'), |
||||
|
marketPrice: 87.8, |
||||
|
quantity: new Big('0'), |
||||
|
symbol: 'NOVN.SW', |
||||
|
transactionCount: 2 |
||||
|
} |
||||
|
], |
||||
|
totalInvestment: new Big('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: new Big('151.6') }, |
||||
|
{ date: '2022-04-01', investment: new Big('-171.46') } |
||||
|
]); |
||||
|
}); |
||||
|
}); |
||||
|
}); |
File diff suppressed because it is too large
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue