Compare commits

...

50 Commits

Author SHA1 Message Date
9ad1c2177c Release 2.81.0 (#3403) 2024-05-12 10:42:04 +02:00
15bf9f2f9c Feature/add Türkçe to footer (#3401) 2024-05-12 10:34:33 +02:00
ebc5008569 Feature/improve language localization for de 20240512 (#3402)
* Update translations

* Update changelog
2024-05-12 10:25:59 +02:00
37759ba03f Feature/improve language localization for tr 20240501 (#3355)
* Improve translations

* Update changelog

---------

Co-authored-by: sadmimye <134071831+sadmimye@users.noreply.github.com>
2024-05-12 09:58:11 +02:00
8319b216bb Feature/support delete activities with filtering (#3394)
* Support delete activities with filtering

* Update changelog

---------

Co-authored-by: Thomas Kaul <4159106+dtslvr@users.noreply.github.com>
2024-05-12 09:56:07 +02:00
782d131b0d Feature/add indicator for active filters (#3398)
* Add indicator for active filters

* Update changelog
2024-05-12 09:42:27 +02:00
72e75208df Bugfix/fix position detail dialog close functionality (#3396)
* Handle holding detail dialog open functionality in a single place (AppComponent)

* Update changelog
2024-05-11 20:27:18 +02:00
4b1c27c245 Feature/upgrade nx to version 19.0.2 (#3391)
* Upgrade Nx dependencies to version 19.0.2

* Update changelog
2024-05-11 08:33:33 +02:00
61f0da35bc Feature/disable delete all activities if filters are active (#3389)
* Disable delete all activities button if filters are active

* Update changelog
2024-05-10 08:51:34 +02:00
80464c7846 Release 2.80.0 (#3386) 2024-05-08 20:55:39 +02:00
74f4323903 Feature/increase spacing around floating action buttons (#3385)
* Increase spacing around floating action buttons

* Update changelog
2024-05-08 20:54:13 +02:00
127dbf9dcd Update translations (#3384) 2024-05-08 20:37:53 +02:00
66bdb374e8 Feature/set icon columns of tables to stick at beginning (#3377)
* Set icon columns to stick at the beginning

* Update changelog
2024-05-08 20:04:58 +02:00
4ad4fa2b30 Feature/clean up deprecated GET api/portfolio/positions endpoint (#3373) 2024-05-08 20:04:32 +02:00
1fd836194f Feature/add absolute change column to holdings table (#3378)
* Add absolute change column

* Update changelog
2024-05-08 20:02:50 +02:00
2090db1199 Feature/increase number of attempts of queue jobs (#3376)
* Increase number of attempts

* Update changelog
2024-05-07 20:48:02 +02:00
053c7e591e Feature/upgrade ionicons to version 7.4.0 (#3356)
* Upgrade ionicons to version 7.4.0

* Update changelog
2024-05-07 19:00:55 +02:00
9b5e350e3b Feature/harmonize log message (#3343) 2024-05-06 17:03:58 +02:00
378e57c3bc Feature/add links to Home Assistant add-on (#3367)
* Add links to Home Assistant add-on
2024-05-06 17:02:18 +02:00
6765191a8c Bugfix/fix position detail dialog open in holding search of assistant (#3374)
* Open position detail dialog (via holding search of assistant)

* Update changelog
2024-05-05 09:09:57 +02:00
8438a45bcf Update changelog (#3372) 2024-05-04 16:32:32 +02:00
30a64e7fc1 Release 2.79.0 (#3371) 2024-05-04 15:54:29 +02:00
f2cb671c7f Feature/optimize get porfolio details endpoint (#3366)
* Eliminate getPerformance() from getSummary() function

* Disable cache for getDetails()

* Add hint to portfolio summary

* Update changelog
2024-05-04 15:53:02 +02:00
3f41e5c5de Bugfix/fix locale in markets overview (#3369)
* Fix locale if no user is logged in

* Update changelog
2024-05-04 15:51:09 +02:00
c1ad483f33 Improve alignment (#3370) 2024-05-04 15:50:47 +02:00
f3d961bc16 Feature/move holdings table to holdings tab of home page (#3368)
* Move holdings table to holdings tab of home page

* Deprecate api/v1/portfolio/positions endpoint

* Update changelog
2024-05-04 14:11:37 +02:00
42b70ef568 Feature/improve performance labels in position detail dialog (#3363)
* Improve performance labels (with and without currency effects)

* Update changelog
2024-05-04 07:49:37 +02:00
77beaaba08 Refactoring portfolio service (#3365) 2024-05-03 21:48:46 +02:00
d9c07456cd Release 2.78.0 (#3361) 2024-05-02 20:32:47 +02:00
0a53df4293 Feature/improve inactive user role (#3360)
* Improve inactive role

* Update changelog
2024-05-02 20:31:20 +02:00
4416ba0c88 Feature/set performance column of holdings table to stick at end (#3353)
* Set up stickyEnd in performance column

* Update changelog
2024-05-02 19:16:59 +02:00
486de968a2 Bugfix/fix division by zero error in dividend yield calculation (#3354)
* Handle division by zero

* Update changelog
2024-05-02 17:53:34 +02:00
a5833566a8 Feature/skip caching in portfolio calculator if active filters (#3348)
* Skip caching if active filters

* Update changelog
2024-05-02 17:52:39 +02:00
261f5844dd Add type column to README.md (#3295) 2024-04-30 14:08:14 +02:00
2173c418a7 Feature/validate forms using DTO for access, asset profile, tag and platform management (#3337)
* Validate forms using DTO for access, asset profile, tag and platform management

* Update changelog
2024-04-30 08:04:45 +02:00
4efd5cefd8 Bugfix/calculation of portfolio summary caused by future liabilities (#3342)
* Adapt date of future activities

* Update changelog
2024-04-29 20:12:12 +02:00
d735e4db75 Release 2.77.1 (#3340) 2024-04-27 19:25:23 +02:00
e10707fde4 Add missing guard to fix public page (#3339) 2024-04-27 19:24:08 +02:00
ac953df809 Release 2.77.0 (#3338) 2024-04-27 15:37:48 +02:00
bb4ee50738 Feature/update browserslist database 20240417 (#3288)
* Update the browserslist database

* Update changelog
2024-04-27 15:35:57 +02:00
4f41bac328 Feature/set up caching in portfolio calculator (#3335)
* Set up caching

* Update changelog
2024-04-27 15:35:28 +02:00
cd07802400 Bugfix/fix historical market data gathering for asset profiles with manual data source (#3336)
* Fix historical market data gathering for asset profiles with MANUAL data source

* Update changelog
2024-04-27 15:29:32 +02:00
b692b7432c Feature/set up event system for portfolio changes (#3333)
* Set up event system for portfolio changes

* Update changelog
2024-04-26 20:13:53 +02:00
a4efbc0131 Feature/migrate UI components to control flow (#3324)
* Migrate to control flow

* Update changelog
2024-04-26 17:40:00 +02:00
55b0fe232c Bugfix/reset form values to null if empty string (#3327)
* Reset form values to null if empty string

* Update changelog
2024-04-25 19:04:28 +02:00
46432edce9 Feature/extend faq by custom asset instructions (#3326)
* Add instructions for custom asset

* Update changelog
2024-04-24 20:35:39 +02:00
990028316e Refactor form controls to form getter (#3325) 2024-04-24 20:20:56 +02:00
37871fbabc Feature/upgrade prisma to version 5.13.0 (#3323)
* Upgrade prisma to version 5.13.0

* Update changelog
2024-04-24 20:20:09 +02:00
0fdeef7953 Release 2.76.0 (#3322) 2024-04-23 18:58:43 +02:00
cdbe6eedeb Feature/change cash to liquidity in asset class enum (#3321)
* Change CASH to LIQUIDITY in asset class enum

* Update changelog
2024-04-23 18:55:37 +02:00
166 changed files with 6294 additions and 5869 deletions

View File

@ -5,6 +5,98 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## 2.81.0 - 2024-05-12
### Added
- Added an indicator for active filters (experimental)
### Changed
- Improved the delete all activities functionality on the portfolio activities page to work with the filters of the assistant
- Improved the language localization for German (`de`)
- Improved the language localization for Türkçe (`tr`)
- Upgraded `Nx` from version `18.3.3` to `19.0.2`
### Fixed
- Fixed the position detail dialog close functionality
## 2.80.0 - 2024-05-08
### Added
- Added the absolute change column to the holdings table on the home page
### Changed
- Increased the spacing around the floating action buttons (FAB)
- Set the icon column of the activities table to stick at the beginning
- Set the icon column of the holdings table to stick at the beginning
- Increased the number of attempts of queue jobs from `10` to `12` (fail later)
- Upgraded `ionicons` from version `7.3.0` to `7.4.0`
### Fixed
- Fixed the position detail dialog open functionality when searching for a holding in the assistant
## 2.79.0 - 2024-05-04
### Changed
- Moved the holdings table to the holdings tab of the home page
- Improved the performance labels (with and without currency effects) in the position detail dialog
- Optimized the calculations of the portfolio details endpoint
### Fixed
- Fixed an issue with the benchmarks in the markets overview
- Fixed an issue with the _Fear & Greed Index_ (market mood) in the markets overview
## 2.78.0 - 2024-05-02
### Added
- Added a form validation against the DTO in the create or update access dialog
- Added a form validation against the DTO in the asset profile details dialog of the admin control
- Added a form validation against the DTO in the platform management of the admin control panel
- Added a form validation against the DTO in the tag management of the admin control panel
### Changed
- Set the performance column of the holdings table to stick at the end
- Skipped the caching in the portfolio calculator if there are active filters (experimental)
- Improved the `INACTIVE` user role
### Fixed
- Fixed an issue in the calculation of the portfolio summary caused by future liabilities
- Fixed a division by zero error in the dividend yield calculation (experimental)
## 2.77.1 - 2024-04-27
### Added
- Extended the content of the _Self-Hosting_ section by the custom asset instructions on the Frequently Asked Questions (FAQ) page
- Added the caching to the portfolio calculator (experimental)
### Changed
- Migrated the `@ghostfolio/ui` components to control flow
- Updated the browserslist database
- Upgraded `prisma` from version `5.12.1` to `5.13.0`
### Fixed
- Fixed the form submit in the asset profile details dialog of the admin control due to the `url` validation
- Fixed the historical market data gathering for asset profiles with `MANUAL` data source
## 2.76.0 - 2024-04-23
### Changed
- Changed `CASH` to `LIQUIDITY` in the asset class enum
## 2.75.1 - 2024-04-21 ## 2.75.1 - 2024-04-21
### Added ### Added

View File

@ -85,23 +85,23 @@ We provide official container images hosted on [Docker Hub](https://hub.docker.c
### Supported Environment Variables ### Supported Environment Variables
| Name | Default Value | Description | | Name | Type | Default Value | Description |
| ------------------------ | ------------- | ----------------------------------------------------------------------------------------------------------------------------------- | | ------------------------ | ------------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `ACCESS_TOKEN_SALT` | | A random string used as salt for access tokens | | `ACCESS_TOKEN_SALT` | string | | A random string used as salt for access tokens |
| `API_KEY_COINGECKO_DEMO` |   | The _CoinGecko_ Demo API key | | `API_KEY_COINGECKO_DEMO` | string (`optional`) |   | The _CoinGecko_ Demo API key |
| `API_KEY_COINGECKO_PRO` |   | The _CoinGecko_ Pro API | | `API_KEY_COINGECKO_PRO` | string (`optional`) | | The _CoinGecko_ Pro API |
| `DATABASE_URL` | | The database connection URL, e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer` | | `DATABASE_URL` | string | | The database connection URL, e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer` |
| `HOST` | `0.0.0.0` | The host where the Ghostfolio application will run on | | `HOST` | string (`optional`) | `0.0.0.0` | The host where the Ghostfolio application will run on |
| `JWT_SECRET_KEY` | | A random string used for _JSON Web Tokens_ (JWT) | | `JWT_SECRET_KEY` | string | | A random string used for _JSON Web Tokens_ (JWT) |
| `PORT` | `3333` | The port where the Ghostfolio application will run on | | `PORT` | number (`optional`) | `3333` | The port where the Ghostfolio application will run on |
| `POSTGRES_DB` | | The name of the _PostgreSQL_ database | | `POSTGRES_DB` | string | | The name of the _PostgreSQL_ database |
| `POSTGRES_PASSWORD` | | The password of the _PostgreSQL_ database | | `POSTGRES_PASSWORD` | string | | The password of the _PostgreSQL_ database |
| `POSTGRES_USER` | | The user of the _PostgreSQL_ database | | `POSTGRES_USER` | string | | The user of the _PostgreSQL_ database |
| `REDIS_DB` | `0` | The database index of _Redis_ | | `REDIS_DB` | number (`optional`) | `0` | The database index of _Redis_ |
| `REDIS_HOST` | | The host where _Redis_ is running | | `REDIS_HOST` | string | | The host where _Redis_ is running |
| `REDIS_PASSWORD` | | The password of _Redis_ | | `REDIS_PASSWORD` | string | | The password of _Redis_ |
| `REDIS_PORT` | | The port where _Redis_ is running | | `REDIS_PORT` | number | | The port where _Redis_ is running |
| `REQUEST_TIMEOUT` | `2000` | The timeout of network requests to data providers in milliseconds | | `REQUEST_TIMEOUT` | number (`optional`) | `2000` | The timeout of network requests to data providers in milliseconds |
### Run with Docker Compose ### Run with Docker Compose
@ -142,7 +142,7 @@ docker compose --env-file ./.env -f docker/docker-compose.build.yml up -d
### Home Server Systems (Community) ### Home Server Systems (Community)
Ghostfolio is available for various home server systems, including [CasaOS](https://github.com/bigbeartechworld/big-bear-casaos), Home Assistant, [Runtipi](https://www.runtipi.io/docs/apps-available), [TrueCharts](https://truecharts.org/charts/stable/ghostfolio), [Umbrel](https://apps.umbrel.com/app/ghostfolio), and [Unraid](https://unraid.net/community/apps?q=ghostfolio). Ghostfolio is available for various home server systems, including [CasaOS](https://github.com/bigbeartechworld/big-bear-casaos), [Home Assistant](https://github.com/lildude/ha-addon-ghostfolio), [Runtipi](https://www.runtipi.io/docs/apps-available), [TrueCharts](https://truecharts.org/charts/stable/ghostfolio), [Umbrel](https://apps.umbrel.com/app/ghostfolio), and [Unraid](https://unraid.net/community/apps?q=ghostfolio).
## Development ## Development

View File

@ -1,7 +1,6 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service'; import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { resetHours } from '@ghostfolio/common/helper';
import { permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
@ -18,7 +17,6 @@ import {
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { AccountBalance } from '@prisma/client'; import { AccountBalance } from '@prisma/client';
import { parseISO } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { AccountBalanceService } from './account-balance.service'; import { AccountBalanceService } from './account-balance.service';
@ -67,10 +65,11 @@ export class AccountBalanceController {
@Param('id') id: string @Param('id') id: string
): Promise<AccountBalance> { ): Promise<AccountBalance> {
const accountBalance = await this.accountBalanceService.accountBalance({ const accountBalance = await this.accountBalanceService.accountBalance({
id id,
userId: this.request.user.id
}); });
if (!accountBalance || accountBalance.userId !== this.request.user.id) { if (!accountBalance) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN), getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN StatusCodes.FORBIDDEN
@ -78,7 +77,8 @@ export class AccountBalanceController {
} }
return this.accountBalanceService.deleteAccountBalance({ return this.accountBalanceService.deleteAccountBalance({
id id: accountBalance.id,
userId: accountBalance.userId
}); });
} }
} }

View File

@ -1,3 +1,4 @@
import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { resetHours } from '@ghostfolio/common/helper'; import { resetHours } from '@ghostfolio/common/helper';
@ -5,6 +6,7 @@ import { AccountBalancesResponse, Filter } from '@ghostfolio/common/interfaces';
import { UserWithSettings } from '@ghostfolio/common/types'; import { UserWithSettings } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { AccountBalance, Prisma } from '@prisma/client'; import { AccountBalance, Prisma } from '@prisma/client';
import { parseISO } from 'date-fns'; import { parseISO } from 'date-fns';
@ -13,6 +15,7 @@ import { CreateAccountBalanceDto } from './create-account-balance.dto';
@Injectable() @Injectable()
export class AccountBalanceService { export class AccountBalanceService {
public constructor( public constructor(
private readonly eventEmitter: EventEmitter2,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly prismaService: PrismaService private readonly prismaService: PrismaService
) {} ) {}
@ -36,7 +39,7 @@ export class AccountBalanceService {
}: CreateAccountBalanceDto & { }: CreateAccountBalanceDto & {
userId: string; userId: string;
}): Promise<AccountBalance> { }): Promise<AccountBalance> {
return this.prismaService.accountBalance.upsert({ const accountBalance = await this.prismaService.accountBalance.upsert({
create: { create: {
Account: { Account: {
connect: { connect: {
@ -59,14 +62,32 @@ export class AccountBalanceService {
} }
} }
}); });
this.eventEmitter.emit(
PortfolioChangedEvent.getName(),
new PortfolioChangedEvent({
userId
})
);
return accountBalance;
} }
public async deleteAccountBalance( public async deleteAccountBalance(
where: Prisma.AccountBalanceWhereUniqueInput where: Prisma.AccountBalanceWhereUniqueInput
): Promise<AccountBalance> { ): Promise<AccountBalance> {
return this.prismaService.accountBalance.delete({ const accountBalance = await this.prismaService.accountBalance.delete({
where where
}); });
this.eventEmitter.emit(
PortfolioChangedEvent.getName(),
new PortfolioChangedEvent({
userId: <string>where.userId
})
);
return accountBalance;
} }
public async getAccountBalances({ public async getAccountBalances({

View File

@ -1,10 +1,12 @@
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service'; import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { Filter } from '@ghostfolio/common/interfaces'; import { Filter } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { Account, Order, Platform, Prisma } from '@prisma/client'; import { Account, Order, Platform, Prisma } from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { format } from 'date-fns'; import { format } from 'date-fns';
@ -16,6 +18,7 @@ import { CashDetails } from './interfaces/cash-details.interface';
export class AccountService { export class AccountService {
public constructor( public constructor(
private readonly accountBalanceService: AccountBalanceService, private readonly accountBalanceService: AccountBalanceService,
private readonly eventEmitter: EventEmitter2,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly prismaService: PrismaService private readonly prismaService: PrismaService
) {} ) {}
@ -94,6 +97,13 @@ export class AccountService {
userId: aUserId userId: aUserId
}); });
this.eventEmitter.emit(
PortfolioChangedEvent.getName(),
new PortfolioChangedEvent({
userId: account.userId
})
);
return account; return account;
} }
@ -101,9 +111,18 @@ export class AccountService {
where: Prisma.AccountWhereUniqueInput, where: Prisma.AccountWhereUniqueInput,
aUserId: string aUserId: string
): Promise<Account> { ): Promise<Account> {
return this.prismaService.account.delete({ const account = await this.prismaService.account.delete({
where where
}); });
this.eventEmitter.emit(
PortfolioChangedEvent.getName(),
new PortfolioChangedEvent({
userId: account.userId
})
);
return account;
} }
public async getAccounts(aUserId: string): Promise<Account[]> { public async getAccounts(aUserId: string): Promise<Account[]> {
@ -201,10 +220,19 @@ export class AccountService {
userId: aUserId userId: aUserId
}); });
return this.prismaService.account.update({ const account = await this.prismaService.account.update({
data, data,
where where
}); });
this.eventEmitter.emit(
PortfolioChangedEvent.getName(),
new PortfolioChangedEvent({
userId: account.userId
})
);
return account;
} }
public async updateAccountBalance({ public async updateAccountBalance({

View File

@ -416,7 +416,7 @@ export class AdminService {
dataSource, dataSource,
marketDataItemCount, marketDataItemCount,
symbol, symbol,
assetClass: 'CASH', assetClass: AssetClass.LIQUIDITY,
countriesCount: 0, countriesCount: 0,
currency: symbol.replace(DEFAULT_CURRENCY, ''), currency: symbol.replace(DEFAULT_CURRENCY, ''),
id: undefined, id: undefined,

View File

@ -1,3 +1,4 @@
import { EventsModule } from '@ghostfolio/api/events/events.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { CronService } from '@ghostfolio/api/services/cron.service'; import { CronService } from '@ghostfolio/api/services/cron.service';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module'; import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
@ -14,6 +15,7 @@ import {
import { BullModule } from '@nestjs/bull'; import { BullModule } from '@nestjs/bull';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config'; import { ConfigModule } from '@nestjs/config';
import { EventEmitterModule } from '@nestjs/event-emitter';
import { ScheduleModule } from '@nestjs/schedule'; import { ScheduleModule } from '@nestjs/schedule';
import { ServeStaticModule } from '@nestjs/serve-static'; import { ServeStaticModule } from '@nestjs/serve-static';
import { StatusCodes } from 'http-status-codes'; import { StatusCodes } from 'http-status-codes';
@ -44,6 +46,7 @@ import { TagModule } from './tag/tag.module';
import { UserModule } from './user/user.module'; import { UserModule } from './user/user.module';
@Module({ @Module({
controllers: [AppController],
imports: [ imports: [
AdminModule, AdminModule,
AccessModule, AccessModule,
@ -64,6 +67,8 @@ import { UserModule } from './user/user.module';
ConfigurationModule, ConfigurationModule,
DataGatheringModule, DataGatheringModule,
DataProviderModule, DataProviderModule,
EventEmitterModule.forRoot(),
EventsModule,
ExchangeRateModule, ExchangeRateModule,
ExchangeRateDataModule, ExchangeRateDataModule,
ExportModule, ExportModule,
@ -109,7 +114,6 @@ import { UserModule } from './user/user.module';
TwitterBotModule, TwitterBotModule,
UserModule UserModule
], ],
controllers: [AppController],
providers: [CronService] providers: [CronService]
}) })
export class AppModule {} export class AppModule {}

View File

@ -2,10 +2,12 @@ import { UserService } from '@ghostfolio/api/app/user/user.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { HEADER_KEY_TIMEZONE } from '@ghostfolio/common/config'; import { HEADER_KEY_TIMEZONE } from '@ghostfolio/common/config';
import { hasRole } from '@ghostfolio/common/permissions';
import { Injectable, UnauthorizedException } from '@nestjs/common'; import { HttpException, Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport'; import { PassportStrategy } from '@nestjs/passport';
import * as countriesAndTimezones from 'countries-and-timezones'; import * as countriesAndTimezones from 'countries-and-timezones';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { ExtractJwt, Strategy } from 'passport-jwt'; import { ExtractJwt, Strategy } from 'passport-jwt';
@Injectable() @Injectable()
@ -29,6 +31,13 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
if (user) { if (user) {
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
if (hasRole(user, 'INACTIVE')) {
throw new HttpException(
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
StatusCodes.TOO_MANY_REQUESTS
);
}
const country = const country =
countriesAndTimezones.getCountryForTimezone(timezone)?.id; countriesAndTimezones.getCountryForTimezone(timezone)?.id;
@ -45,10 +54,20 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
return user; return user;
} else { } else {
throw ''; throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
);
}
} catch (error) {
if (error?.getStatus() === StatusCodes.TOO_MANY_REQUESTS) {
throw error;
} else {
throw new HttpException(
getReasonPhrase(StatusCodes.UNAUTHORIZED),
StatusCodes.UNAUTHORIZED
);
} }
} catch (err) {
throw new UnauthorizedException('unauthorized', err.message);
} }
} }
} }

View File

@ -416,6 +416,11 @@ export class ImportService {
User: { connect: { id: user.id } }, User: { connect: { id: user.id } },
userId: user.id userId: user.id
}); });
if (order.SymbolProfile?.symbol) {
// Update symbol that may have been assigned in createOrder()
assetProfile.symbol = order.SymbolProfile.symbol;
}
} }
const value = new Big(quantity).mul(unitPrice).toNumber(); const value = new Big(quantity).mul(unitPrice).toNumber();

View File

@ -11,7 +11,7 @@ import {
DATA_GATHERING_QUEUE_PRIORITY_HIGH, DATA_GATHERING_QUEUE_PRIORITY_HIGH,
HEADER_KEY_IMPERSONATION HEADER_KEY_IMPERSONATION
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
import type { DateRange, RequestWithUser } from '@ghostfolio/common/types'; import type { DateRange, RequestWithUser } from '@ghostfolio/common/types';
import { import {
@ -53,22 +53,34 @@ export class OrderController {
@Delete() @Delete()
@HasPermission(permissions.deleteOrder) @HasPermission(permissions.deleteOrder)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async deleteOrders(): Promise<number> { public async deleteOrders(
@Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string,
@Query('tags') filterByTags?: string
): Promise<number> {
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts,
filterByAssetClasses,
filterByTags
});
return this.orderService.deleteOrders({ return this.orderService.deleteOrders({
filters,
userCurrency: this.request.user.Settings.settings.baseCurrency,
userId: this.request.user.id userId: this.request.user.id
}); });
} }
@Delete(':id') @Delete(':id')
@HasPermission(permissions.deleteOrder)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async deleteOrder(@Param('id') id: string): Promise<OrderModel> { public async deleteOrder(@Param('id') id: string): Promise<OrderModel> {
const order = await this.orderService.order({ id }); const order = await this.orderService.order({
id,
userId: this.request.user.id
});
if ( if (!order) {
!hasPermission(this.request.user.permissions, permissions.deleteOrder) ||
!order ||
order.userId !== this.request.user.id
) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN), getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN StatusCodes.FORBIDDEN
@ -88,21 +100,26 @@ export class OrderController {
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId, @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId,
@Query('accounts') filterByAccounts?: string, @Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string, @Query('assetClasses') filterByAssetClasses?: string,
@Query('range') dateRange: DateRange = 'max', @Query('range') dateRange?: DateRange,
@Query('skip') skip?: number, @Query('skip') skip?: number,
@Query('sortColumn') sortColumn?: string, @Query('sortColumn') sortColumn?: string,
@Query('sortDirection') sortDirection?: Prisma.SortOrder, @Query('sortDirection') sortDirection?: Prisma.SortOrder,
@Query('tags') filterByTags?: string, @Query('tags') filterByTags?: string,
@Query('take') take?: number @Query('take') take?: number
): Promise<Activities> { ): Promise<Activities> {
let endDate: Date;
let startDate: Date;
if (dateRange) {
({ endDate, startDate } = getInterval(dateRange));
}
const filters = this.apiService.buildFiltersFromQueryParams({ const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts, filterByAccounts,
filterByAssetClasses, filterByAssetClasses,
filterByTags filterByTags
}); });
const { endDate, startDate } = getInterval(dateRange);
const impersonationUserId = const impersonationUserId =
await this.impersonationService.validateImpersonationId(impersonationId); await this.impersonationService.validateImpersonationId(impersonationId);
const userCurrency = this.request.user.Settings.settings.baseCurrency; const userCurrency = this.request.user.Settings.settings.baseCurrency;

View File

@ -1,4 +1,5 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service'; import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service'; import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
@ -13,6 +14,7 @@ import { Filter, UniqueAsset } from '@ghostfolio/common/interfaces';
import { OrderWithAccount } from '@ghostfolio/common/types'; import { OrderWithAccount } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { import {
AssetClass, AssetClass,
AssetSubClass, AssetSubClass,
@ -27,7 +29,6 @@ import { endOfToday, isAfter } from 'date-fns';
import { groupBy, uniqBy } from 'lodash'; import { groupBy, uniqBy } from 'lodash';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { CreateOrderDto } from './create-order.dto';
import { Activities } from './interfaces/activities.interface'; import { Activities } from './interfaces/activities.interface';
@Injectable() @Injectable()
@ -35,6 +36,7 @@ export class OrderService {
public constructor( public constructor(
private readonly accountService: AccountService, private readonly accountService: AccountService,
private readonly dataGatheringService: DataGatheringService, private readonly dataGatheringService: DataGatheringService,
private readonly eventEmitter: EventEmitter2,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
private readonly symbolProfileService: SymbolProfileService private readonly symbolProfileService: SymbolProfileService
@ -138,7 +140,8 @@ export class OrderService {
return { id }; return { id };
}) })
} }
} },
include: { SymbolProfile: true }
}); });
if (updateAccountBalance === true) { if (updateAccountBalance === true) {
@ -160,6 +163,13 @@ export class OrderService {
}); });
} }
this.eventEmitter.emit(
PortfolioChangedEvent.getName(),
new PortfolioChangedEvent({
userId: order.userId
})
);
return order; return order;
} }
@ -174,14 +184,48 @@ export class OrderService {
await this.symbolProfileService.deleteById(order.symbolProfileId); await this.symbolProfileService.deleteById(order.symbolProfileId);
} }
this.eventEmitter.emit(
PortfolioChangedEvent.getName(),
new PortfolioChangedEvent({
userId: order.userId
})
);
return order; return order;
} }
public async deleteOrders(where: Prisma.OrderWhereInput): Promise<number> { public async deleteOrders({
const { count } = await this.prismaService.order.deleteMany({ filters,
where userCurrency,
userId
}: {
filters?: Filter[];
userCurrency: string;
userId: string;
}): Promise<number> {
const { activities } = await this.getOrders({
filters,
userId,
userCurrency,
includeDrafts: true,
withExcludedAccounts: true
}); });
const { count } = await this.prismaService.order.deleteMany({
where: {
id: {
in: activities.map(({ id }) => {
return id;
})
}
}
});
this.eventEmitter.emit(
PortfolioChangedEvent.getName(),
new PortfolioChangedEvent({ userId })
);
return count; return count;
} }
@ -455,7 +499,7 @@ export class OrderService {
where where
}); });
return this.prismaService.order.update({ const order = await this.prismaService.order.update({
data: { data: {
...data, ...data,
isDraft, isDraft,
@ -467,6 +511,15 @@ export class OrderService {
}, },
where where
}); });
this.eventEmitter.emit(
PortfolioChangedEvent.getName(),
new PortfolioChangedEvent({
userId: order.userId
})
);
return order;
} }
private async orders(params: { private async orders(params: {

View File

@ -1,10 +1,6 @@
import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator'; import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator';
import { PortfolioSnapshot } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-snapshot.interface'; import { SymbolMetrics, UniqueAsset } from '@ghostfolio/common/interfaces';
import { import { PortfolioSnapshot, TimelinePosition } from '@ghostfolio/common/models';
SymbolMetrics,
TimelinePosition,
UniqueAsset
} from '@ghostfolio/common/interfaces';
export class MWRPortfolioCalculator extends PortfolioCalculator { export class MWRPortfolioCalculator extends PortfolioCalculator {
protected calculateOverallPerformance( protected calculateOverallPerformance(

View File

@ -24,3 +24,7 @@ export const symbolProfileDummyData = {
sectors: [], sectors: [],
updatedAt: undefined updatedAt: undefined
}; };
export const userDummyData = {
id: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'
};

View File

@ -1,8 +1,10 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { HistoricalDataItem } from '@ghostfolio/common/interfaces'; import { HistoricalDataItem } from '@ghostfolio/common/interfaces';
import { DateRange } from '@ghostfolio/common/types'; import { DateRange, UserWithSettings } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
@ -18,8 +20,10 @@ export enum PerformanceCalculationType {
@Injectable() @Injectable()
export class PortfolioCalculatorFactory { export class PortfolioCalculatorFactory {
public constructor( public constructor(
private readonly configurationService: ConfigurationService,
private readonly currentRateService: CurrentRateService, private readonly currentRateService: CurrentRateService,
private readonly exchangeRateDataService: ExchangeRateDataService private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly redisCacheService: RedisCacheService
) {} ) {}
public createCalculator({ public createCalculator({
@ -27,14 +31,22 @@ export class PortfolioCalculatorFactory {
activities, activities,
calculationType, calculationType,
currency, currency,
dateRange = 'max' dateRange = 'max',
hasFilters,
isExperimentalFeatures = false,
userId
}: { }: {
accountBalanceItems?: HistoricalDataItem[]; accountBalanceItems?: HistoricalDataItem[];
activities: Activity[]; activities: Activity[];
calculationType: PerformanceCalculationType; calculationType: PerformanceCalculationType;
currency: string; currency: string;
dateRange?: DateRange; dateRange?: DateRange;
hasFilters: boolean;
isExperimentalFeatures?: boolean;
userId: string;
}): PortfolioCalculator { }): PortfolioCalculator {
const useCache = !hasFilters && isExperimentalFeatures;
switch (calculationType) { switch (calculationType) {
case PerformanceCalculationType.MWR: case PerformanceCalculationType.MWR:
return new MWRPortfolioCalculator({ return new MWRPortfolioCalculator({
@ -42,8 +54,12 @@ export class PortfolioCalculatorFactory {
activities, activities,
currency, currency,
dateRange, dateRange,
useCache,
userId,
configurationService: this.configurationService,
currentRateService: this.currentRateService, currentRateService: this.currentRateService,
exchangeRateDataService: this.exchangeRateDataService exchangeRateDataService: this.exchangeRateDataService,
redisCacheService: this.redisCacheService
}); });
case PerformanceCalculationType.TWR: case PerformanceCalculationType.TWR:
return new TWRPortfolioCalculator({ return new TWRPortfolioCalculator({
@ -52,7 +68,11 @@ export class PortfolioCalculatorFactory {
currency, currency,
currentRateService: this.currentRateService, currentRateService: this.currentRateService,
dateRange, dateRange,
exchangeRateDataService: this.exchangeRateDataService useCache,
userId,
configurationService: this.configurationService,
exchangeRateDataService: this.exchangeRateDataService,
redisCacheService: this.redisCacheService
}); });
default: default:
throw new Error('Invalid calculation type'); throw new Error('Invalid calculation type');

View File

@ -1,13 +1,14 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { PortfolioOrder } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-order.interface'; import { PortfolioOrder } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-order.interface';
import { PortfolioSnapshot } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-snapshot.interface';
import { TransactionPointSymbol } from '@ghostfolio/api/app/portfolio/interfaces/transaction-point-symbol.interface'; import { TransactionPointSymbol } from '@ghostfolio/api/app/portfolio/interfaces/transaction-point-symbol.interface';
import { TransactionPoint } from '@ghostfolio/api/app/portfolio/interfaces/transaction-point.interface'; import { TransactionPoint } from '@ghostfolio/api/app/portfolio/interfaces/transaction-point.interface';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { import {
getFactor, getFactor,
getInterval getInterval
} from '@ghostfolio/api/helper/portfolio.helper'; } from '@ghostfolio/api/helper/portfolio.helper';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces'; import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
import { MAX_CHART_ITEMS } from '@ghostfolio/common/config'; import { MAX_CHART_ITEMS } from '@ghostfolio/common/config';
@ -23,17 +24,20 @@ import {
InvestmentItem, InvestmentItem,
ResponseError, ResponseError,
SymbolMetrics, SymbolMetrics,
TimelinePosition,
UniqueAsset UniqueAsset
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { PortfolioSnapshot, TimelinePosition } from '@ghostfolio/common/models';
import { DateRange, GroupBy } from '@ghostfolio/common/types'; import { DateRange, GroupBy } from '@ghostfolio/common/types';
import { Logger } from '@nestjs/common';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { plainToClass } from 'class-transformer';
import { import {
differenceInDays, differenceInDays,
eachDayOfInterval, eachDayOfInterval,
endOfDay, endOfDay,
format, format,
isAfter,
isBefore, isBefore,
isSameDay, isSameDay,
max, max,
@ -46,54 +50,88 @@ export abstract class PortfolioCalculator {
protected static readonly ENABLE_LOGGING = false; protected static readonly ENABLE_LOGGING = false;
protected accountBalanceItems: HistoricalDataItem[]; protected accountBalanceItems: HistoricalDataItem[];
protected orders: PortfolioOrder[]; protected activities: PortfolioOrder[];
private configurationService: ConfigurationService;
private currency: string; private currency: string;
private currentRateService: CurrentRateService; private currentRateService: CurrentRateService;
private dataProviderInfos: DataProviderInfo[]; private dataProviderInfos: DataProviderInfo[];
private dateRange: DateRange;
private endDate: Date; private endDate: Date;
private exchangeRateDataService: ExchangeRateDataService; private exchangeRateDataService: ExchangeRateDataService;
private redisCacheService: RedisCacheService;
private snapshot: PortfolioSnapshot; private snapshot: PortfolioSnapshot;
private snapshotPromise: Promise<void>; private snapshotPromise: Promise<void>;
private startDate: Date; private startDate: Date;
private transactionPoints: TransactionPoint[]; private transactionPoints: TransactionPoint[];
private useCache: boolean;
private userId: string;
public constructor({ public constructor({
accountBalanceItems, accountBalanceItems,
activities, activities,
configurationService,
currency, currency,
currentRateService, currentRateService,
dateRange, dateRange,
exchangeRateDataService exchangeRateDataService,
redisCacheService,
useCache,
userId
}: { }: {
accountBalanceItems: HistoricalDataItem[]; accountBalanceItems: HistoricalDataItem[];
activities: Activity[]; activities: Activity[];
configurationService: ConfigurationService;
currency: string; currency: string;
currentRateService: CurrentRateService; currentRateService: CurrentRateService;
dateRange: DateRange; dateRange: DateRange;
exchangeRateDataService: ExchangeRateDataService; exchangeRateDataService: ExchangeRateDataService;
redisCacheService: RedisCacheService;
useCache: boolean;
userId: string;
}) { }) {
this.accountBalanceItems = accountBalanceItems; this.accountBalanceItems = accountBalanceItems;
this.configurationService = configurationService;
this.currency = currency; this.currency = currency;
this.currentRateService = currentRateService; this.currentRateService = currentRateService;
this.dateRange = dateRange;
this.exchangeRateDataService = exchangeRateDataService; this.exchangeRateDataService = exchangeRateDataService;
this.orders = activities.map(
({ date, fee, quantity, SymbolProfile, tags = [], type, unitPrice }) => {
return {
SymbolProfile,
tags,
type,
date: format(date, DATE_FORMAT),
fee: new Big(fee),
quantity: new Big(quantity),
unitPrice: new Big(unitPrice)
};
}
);
this.orders.sort((a, b) => { this.activities = activities
return a.date?.localeCompare(b.date); .map(
}); ({
date,
fee,
quantity,
SymbolProfile,
tags = [],
type,
unitPrice
}) => {
if (isAfter(date, new Date(Date.now()))) {
// Adapt date to today if activity is in future (e.g. liability)
// to include it in the interval
date = endOfDay(new Date(Date.now()));
}
return {
SymbolProfile,
tags,
type,
date: format(date, DATE_FORMAT),
fee: new Big(fee),
quantity: new Big(quantity),
unitPrice: new Big(unitPrice)
};
}
)
.sort((a, b) => {
return a.date?.localeCompare(b.date);
});
this.redisCacheService = redisCacheService;
this.useCache = useCache;
this.userId = userId;
const { endDate, startDate } = getInterval(dateRange); const { endDate, startDate } = getInterval(dateRange);
@ -887,7 +925,7 @@ export abstract class PortfolioCalculator {
tags, tags,
type, type,
unitPrice unitPrice
} of this.orders) { } of this.activities) {
let currentTransactionPointItem: TransactionPointSymbol; let currentTransactionPointItem: TransactionPointSymbol;
const oldAccumulatedSymbol = symbols[SymbolProfile.symbol]; const oldAccumulatedSymbol = symbols[SymbolProfile.symbol];
@ -1011,6 +1049,52 @@ export abstract class PortfolioCalculator {
} }
private async initialize() { private async initialize() {
this.snapshot = await this.computeSnapshot(this.startDate, this.endDate); if (this.useCache) {
const startTimeTotal = performance.now();
const cachedSnapshot = await this.redisCacheService.get(
this.redisCacheService.getPortfolioSnapshotKey({
userId: this.userId
})
);
if (cachedSnapshot) {
this.snapshot = plainToClass(
PortfolioSnapshot,
JSON.parse(cachedSnapshot)
);
Logger.debug(
`Fetched portfolio snapshot from cache in ${(
(performance.now() - startTimeTotal) /
1000
).toFixed(3)} seconds`,
'PortfolioCalculator'
);
} else {
this.snapshot = await this.computeSnapshot(
this.startDate,
this.endDate
);
this.redisCacheService.set(
this.redisCacheService.getPortfolioSnapshotKey({
userId: this.userId
}),
JSON.stringify(this.snapshot),
this.configurationService.get('CACHE_QUOTES_TTL')
);
Logger.debug(
`Computed portfolio snapshot in ${(
(performance.now() - startTimeTotal) /
1000
).toFixed(3)} seconds`,
'PortfolioCalculator'
);
}
} else {
this.snapshot = await this.computeSnapshot(this.startDate, this.endDate);
}
} }
} }

View File

@ -1,7 +1,8 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { import {
activityDummyData, activityDummyData,
symbolProfileDummyData symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { import {
PortfolioCalculatorFactory, PortfolioCalculatorFactory,
@ -9,6 +10,9 @@ import {
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { 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 { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { parseDate } from '@ghostfolio/common/helper'; import { parseDate } from '@ghostfolio/common/helper';
@ -23,12 +27,25 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
}; };
}); });
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', () => { describe('PortfolioCalculator', () => {
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService; let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService; let exchangeRateDataService: ExchangeRateDataService;
let factory: PortfolioCalculatorFactory; let factory: PortfolioCalculatorFactory;
let redisCacheService: RedisCacheService;
beforeEach(() => { beforeEach(() => {
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null); currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService( exchangeRateDataService = new ExchangeRateDataService(
@ -38,9 +55,13 @@ describe('PortfolioCalculator', () => {
null null
); );
redisCacheService = new RedisCacheService(null, null);
factory = new PortfolioCalculatorFactory( factory = new PortfolioCalculatorFactory(
configurationService,
currentRateService, currentRateService,
exchangeRateDataService exchangeRateDataService,
redisCacheService
); );
}); });
@ -101,7 +122,9 @@ describe('PortfolioCalculator', () => {
const portfolioCalculator = factory.createCalculator({ const portfolioCalculator = factory.createCalculator({
activities, activities,
calculationType: PerformanceCalculationType.TWR, calculationType: PerformanceCalculationType.TWR,
currency: 'CHF' currency: 'CHF',
hasFilters: false,
userId: userDummyData.id
}); });
const chartData = await portfolioCalculator.getChartData({ const chartData = await portfolioCalculator.getChartData({

View File

@ -1,7 +1,8 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { import {
activityDummyData, activityDummyData,
symbolProfileDummyData symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { import {
PerformanceCalculationType, PerformanceCalculationType,
@ -9,6 +10,9 @@ import {
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { 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 { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { parseDate } from '@ghostfolio/common/helper'; import { parseDate } from '@ghostfolio/common/helper';
@ -23,12 +27,25 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
}; };
}); });
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', () => { describe('PortfolioCalculator', () => {
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService; let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService; let exchangeRateDataService: ExchangeRateDataService;
let factory: PortfolioCalculatorFactory; let factory: PortfolioCalculatorFactory;
let redisCacheService: RedisCacheService;
beforeEach(() => { beforeEach(() => {
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null); currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService( exchangeRateDataService = new ExchangeRateDataService(
@ -38,9 +55,13 @@ describe('PortfolioCalculator', () => {
null null
); );
redisCacheService = new RedisCacheService(null, null);
factory = new PortfolioCalculatorFactory( factory = new PortfolioCalculatorFactory(
configurationService,
currentRateService, currentRateService,
exchangeRateDataService exchangeRateDataService,
redisCacheService
); );
}); });
@ -86,7 +107,9 @@ describe('PortfolioCalculator', () => {
const portfolioCalculator = factory.createCalculator({ const portfolioCalculator = factory.createCalculator({
activities, activities,
calculationType: PerformanceCalculationType.TWR, calculationType: PerformanceCalculationType.TWR,
currency: 'CHF' currency: 'CHF',
hasFilters: false,
userId: userDummyData.id
}); });
const chartData = await portfolioCalculator.getChartData({ const chartData = await portfolioCalculator.getChartData({

View File

@ -1,7 +1,8 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { import {
activityDummyData, activityDummyData,
symbolProfileDummyData symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { import {
PortfolioCalculatorFactory, PortfolioCalculatorFactory,
@ -9,6 +10,9 @@ import {
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { 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 { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { parseDate } from '@ghostfolio/common/helper'; import { parseDate } from '@ghostfolio/common/helper';
@ -23,12 +27,25 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
}; };
}); });
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', () => { describe('PortfolioCalculator', () => {
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService; let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService; let exchangeRateDataService: ExchangeRateDataService;
let factory: PortfolioCalculatorFactory; let factory: PortfolioCalculatorFactory;
let redisCacheService: RedisCacheService;
beforeEach(() => { beforeEach(() => {
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null); currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService( exchangeRateDataService = new ExchangeRateDataService(
@ -38,9 +55,13 @@ describe('PortfolioCalculator', () => {
null null
); );
redisCacheService = new RedisCacheService(null, null);
factory = new PortfolioCalculatorFactory( factory = new PortfolioCalculatorFactory(
configurationService,
currentRateService, currentRateService,
exchangeRateDataService exchangeRateDataService,
redisCacheService
); );
}); });
@ -71,7 +92,9 @@ describe('PortfolioCalculator', () => {
const portfolioCalculator = factory.createCalculator({ const portfolioCalculator = factory.createCalculator({
activities, activities,
calculationType: PerformanceCalculationType.TWR, calculationType: PerformanceCalculationType.TWR,
currency: 'CHF' currency: 'CHF',
hasFilters: false,
userId: userDummyData.id
}); });
const chartData = await portfolioCalculator.getChartData({ const chartData = await portfolioCalculator.getChartData({

View File

@ -1,7 +1,8 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { import {
activityDummyData, activityDummyData,
symbolProfileDummyData symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { import {
PortfolioCalculatorFactory, PortfolioCalculatorFactory,
@ -9,6 +10,9 @@ import {
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { 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 { 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 { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service.mock';
import { parseDate } from '@ghostfolio/common/helper'; import { parseDate } from '@ghostfolio/common/helper';
@ -24,6 +28,15 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
}; };
}); });
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( jest.mock(
'@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service', '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service',
() => { () => {
@ -37,11 +50,15 @@ jest.mock(
); );
describe('PortfolioCalculator', () => { describe('PortfolioCalculator', () => {
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService; let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService; let exchangeRateDataService: ExchangeRateDataService;
let factory: PortfolioCalculatorFactory; let factory: PortfolioCalculatorFactory;
let redisCacheService: RedisCacheService;
beforeEach(() => { beforeEach(() => {
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null); currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService( exchangeRateDataService = new ExchangeRateDataService(
@ -51,9 +68,13 @@ describe('PortfolioCalculator', () => {
null null
); );
redisCacheService = new RedisCacheService(null, null);
factory = new PortfolioCalculatorFactory( factory = new PortfolioCalculatorFactory(
configurationService,
currentRateService, currentRateService,
exchangeRateDataService exchangeRateDataService,
redisCacheService
); );
}); });
@ -99,7 +120,9 @@ describe('PortfolioCalculator', () => {
const portfolioCalculator = factory.createCalculator({ const portfolioCalculator = factory.createCalculator({
activities, activities,
calculationType: PerformanceCalculationType.TWR, calculationType: PerformanceCalculationType.TWR,
currency: 'CHF' currency: 'CHF',
hasFilters: false,
userId: userDummyData.id
}); });
const chartData = await portfolioCalculator.getChartData({ const chartData = await portfolioCalculator.getChartData({

View File

@ -1,7 +1,8 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { import {
activityDummyData, activityDummyData,
symbolProfileDummyData symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { import {
PortfolioCalculatorFactory, PortfolioCalculatorFactory,
@ -9,6 +10,9 @@ import {
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { 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 { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { parseDate } from '@ghostfolio/common/helper'; import { parseDate } from '@ghostfolio/common/helper';
@ -23,12 +27,25 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
}; };
}); });
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', () => { describe('PortfolioCalculator', () => {
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService; let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService; let exchangeRateDataService: ExchangeRateDataService;
let factory: PortfolioCalculatorFactory; let factory: PortfolioCalculatorFactory;
let redisCacheService: RedisCacheService;
beforeEach(() => { beforeEach(() => {
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null); currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService( exchangeRateDataService = new ExchangeRateDataService(
@ -38,9 +55,13 @@ describe('PortfolioCalculator', () => {
null null
); );
redisCacheService = new RedisCacheService(null, null);
factory = new PortfolioCalculatorFactory( factory = new PortfolioCalculatorFactory(
configurationService,
currentRateService, currentRateService,
exchangeRateDataService exchangeRateDataService,
redisCacheService
); );
}); });
@ -71,7 +92,9 @@ describe('PortfolioCalculator', () => {
const portfolioCalculator = factory.createCalculator({ const portfolioCalculator = factory.createCalculator({
activities, activities,
calculationType: PerformanceCalculationType.TWR, calculationType: PerformanceCalculationType.TWR,
currency: 'USD' currency: 'USD',
hasFilters: false,
userId: userDummyData.id
}); });
const portfolioSnapshot = await portfolioCalculator.computeSnapshot( const portfolioSnapshot = await portfolioCalculator.computeSnapshot(

View File

@ -1,7 +1,8 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { import {
activityDummyData, activityDummyData,
symbolProfileDummyData symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { import {
PortfolioCalculatorFactory, PortfolioCalculatorFactory,
@ -9,6 +10,9 @@ import {
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { 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 { 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 { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service.mock';
import { parseDate } from '@ghostfolio/common/helper'; import { parseDate } from '@ghostfolio/common/helper';
@ -24,6 +28,15 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
}; };
}); });
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( jest.mock(
'@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service', '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service',
() => { () => {
@ -37,11 +50,15 @@ jest.mock(
); );
describe('PortfolioCalculator', () => { describe('PortfolioCalculator', () => {
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService; let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService; let exchangeRateDataService: ExchangeRateDataService;
let factory: PortfolioCalculatorFactory; let factory: PortfolioCalculatorFactory;
let redisCacheService: RedisCacheService;
beforeEach(() => { beforeEach(() => {
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null); currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService( exchangeRateDataService = new ExchangeRateDataService(
@ -51,9 +68,13 @@ describe('PortfolioCalculator', () => {
null null
); );
redisCacheService = new RedisCacheService(null, null);
factory = new PortfolioCalculatorFactory( factory = new PortfolioCalculatorFactory(
configurationService,
currentRateService, currentRateService,
exchangeRateDataService exchangeRateDataService,
redisCacheService
); );
}); });
@ -84,7 +105,9 @@ describe('PortfolioCalculator', () => {
const portfolioCalculator = factory.createCalculator({ const portfolioCalculator = factory.createCalculator({
activities, activities,
calculationType: PerformanceCalculationType.TWR, calculationType: PerformanceCalculationType.TWR,
currency: 'CHF' currency: 'CHF',
hasFilters: false,
userId: userDummyData.id
}); });
const chartData = await portfolioCalculator.getChartData({ const chartData = await portfolioCalculator.getChartData({

View File

@ -1,7 +1,8 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { import {
activityDummyData, activityDummyData,
symbolProfileDummyData symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { import {
PortfolioCalculatorFactory, PortfolioCalculatorFactory,
@ -9,6 +10,9 @@ import {
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { 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 { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { parseDate } from '@ghostfolio/common/helper'; import { parseDate } from '@ghostfolio/common/helper';
@ -23,12 +27,25 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
}; };
}); });
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', () => { describe('PortfolioCalculator', () => {
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService; let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService; let exchangeRateDataService: ExchangeRateDataService;
let factory: PortfolioCalculatorFactory; let factory: PortfolioCalculatorFactory;
let redisCacheService: RedisCacheService;
beforeEach(() => { beforeEach(() => {
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null); currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService( exchangeRateDataService = new ExchangeRateDataService(
@ -38,9 +55,13 @@ describe('PortfolioCalculator', () => {
null null
); );
redisCacheService = new RedisCacheService(null, null);
factory = new PortfolioCalculatorFactory( factory = new PortfolioCalculatorFactory(
configurationService,
currentRateService, currentRateService,
exchangeRateDataService exchangeRateDataService,
redisCacheService
); );
}); });
@ -71,7 +92,9 @@ describe('PortfolioCalculator', () => {
const portfolioCalculator = factory.createCalculator({ const portfolioCalculator = factory.createCalculator({
activities, activities,
calculationType: PerformanceCalculationType.TWR, calculationType: PerformanceCalculationType.TWR,
currency: 'USD' currency: 'USD',
hasFilters: false,
userId: userDummyData.id
}); });
const portfolioSnapshot = await portfolioCalculator.computeSnapshot( const portfolioSnapshot = await portfolioCalculator.computeSnapshot(

View File

@ -1,7 +1,8 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { import {
activityDummyData, activityDummyData,
symbolProfileDummyData symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { import {
PortfolioCalculatorFactory, PortfolioCalculatorFactory,
@ -9,6 +10,9 @@ import {
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { 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 { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { parseDate } from '@ghostfolio/common/helper'; import { parseDate } from '@ghostfolio/common/helper';
@ -23,12 +27,25 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
}; };
}); });
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', () => { describe('PortfolioCalculator', () => {
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService; let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService; let exchangeRateDataService: ExchangeRateDataService;
let factory: PortfolioCalculatorFactory; let factory: PortfolioCalculatorFactory;
let redisCacheService: RedisCacheService;
beforeEach(() => { beforeEach(() => {
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null); currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService( exchangeRateDataService = new ExchangeRateDataService(
@ -38,9 +55,13 @@ describe('PortfolioCalculator', () => {
null null
); );
redisCacheService = new RedisCacheService(null, null);
factory = new PortfolioCalculatorFactory( factory = new PortfolioCalculatorFactory(
configurationService,
currentRateService, currentRateService,
exchangeRateDataService exchangeRateDataService,
redisCacheService
); );
}); });
@ -53,7 +74,7 @@ describe('PortfolioCalculator', () => {
const activities: Activity[] = [ const activities: Activity[] = [
{ {
...activityDummyData, ...activityDummyData,
date: new Date('2022-01-01'), date: new Date('2023-01-01'), // Date in future
fee: 0, fee: 0,
quantity: 1, quantity: 1,
SymbolProfile: { SymbolProfile: {
@ -71,64 +92,17 @@ describe('PortfolioCalculator', () => {
const portfolioCalculator = factory.createCalculator({ const portfolioCalculator = factory.createCalculator({
activities, activities,
calculationType: PerformanceCalculationType.TWR, calculationType: PerformanceCalculationType.TWR,
currency: 'USD' currency: 'USD',
hasFilters: false,
userId: userDummyData.id
}); });
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(
parseDate('2022-01-01')
);
spy.mockRestore(); spy.mockRestore();
expect(portfolioSnapshot).toEqual({ const liabilitiesInBaseCurrency =
currentValueInBaseCurrency: new Big('0'), await portfolioCalculator.getLiabilitiesInBaseCurrency();
errors: [],
grossPerformance: new Big('0'), expect(liabilitiesInBaseCurrency).toEqual(new Big(3000));
grossPerformancePercentage: new Big('0'),
grossPerformancePercentageWithCurrencyEffect: new Big('0'),
grossPerformanceWithCurrencyEffect: new Big('0'),
hasErrors: true,
netPerformance: new Big('0'),
netPerformancePercentage: new Big('0'),
netPerformancePercentageWithCurrencyEffect: new Big('0'),
netPerformanceWithCurrencyEffect: new Big('0'),
positions: [
{
averagePrice: new Big('3000'),
currency: 'USD',
dataSource: 'MANUAL',
dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'),
fee: new Big('0'),
firstBuyDate: '2022-01-01',
grossPerformance: null,
grossPerformancePercentage: null,
grossPerformancePercentageWithCurrencyEffect: null,
grossPerformanceWithCurrencyEffect: null,
investment: new Big('0'),
investmentWithCurrencyEffect: new Big('0'),
marketPrice: null,
marketPriceInBaseCurrency: 3000,
netPerformance: null,
netPerformancePercentage: null,
netPerformancePercentageWithCurrencyEffect: null,
netPerformanceWithCurrencyEffect: null,
quantity: new Big('0'),
symbol: '55196015-1365-4560-aa60-8751ae6d18f8',
tags: [],
timeWeightedInvestment: new Big('0'),
timeWeightedInvestmentWithCurrencyEffect: new Big('0'),
transactionCount: 1,
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')
});
}); });
}); });
}); });

View File

@ -1,7 +1,8 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { import {
activityDummyData, activityDummyData,
symbolProfileDummyData symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { import {
PerformanceCalculationType, PerformanceCalculationType,
@ -9,6 +10,9 @@ import {
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { 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 { 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 { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service.mock';
import { parseDate } from '@ghostfolio/common/helper'; import { parseDate } from '@ghostfolio/common/helper';
@ -24,6 +28,15 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
}; };
}); });
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( jest.mock(
'@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service', '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service',
() => { () => {
@ -37,11 +50,15 @@ jest.mock(
); );
describe('PortfolioCalculator', () => { describe('PortfolioCalculator', () => {
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService; let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService; let exchangeRateDataService: ExchangeRateDataService;
let factory: PortfolioCalculatorFactory; let factory: PortfolioCalculatorFactory;
let redisCacheService: RedisCacheService;
beforeEach(() => { beforeEach(() => {
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null); currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService( exchangeRateDataService = new ExchangeRateDataService(
@ -51,9 +68,13 @@ describe('PortfolioCalculator', () => {
null null
); );
redisCacheService = new RedisCacheService(null, null);
factory = new PortfolioCalculatorFactory( factory = new PortfolioCalculatorFactory(
configurationService,
currentRateService, currentRateService,
exchangeRateDataService exchangeRateDataService,
redisCacheService
); );
}); });
@ -99,7 +120,9 @@ describe('PortfolioCalculator', () => {
const portfolioCalculator = factory.createCalculator({ const portfolioCalculator = factory.createCalculator({
activities, activities,
calculationType: PerformanceCalculationType.TWR, calculationType: PerformanceCalculationType.TWR,
currency: 'USD' currency: 'USD',
hasFilters: false,
userId: userDummyData.id
}); });
const portfolioSnapshot = await portfolioCalculator.computeSnapshot( const portfolioSnapshot = await portfolioCalculator.computeSnapshot(

View File

@ -1,9 +1,13 @@
import { userDummyData } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { import {
PerformanceCalculationType, PerformanceCalculationType,
PortfolioCalculatorFactory PortfolioCalculatorFactory
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { 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 { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { parseDate } from '@ghostfolio/common/helper'; import { parseDate } from '@ghostfolio/common/helper';
@ -19,12 +23,25 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
}; };
}); });
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', () => { describe('PortfolioCalculator', () => {
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService; let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService; let exchangeRateDataService: ExchangeRateDataService;
let factory: PortfolioCalculatorFactory; let factory: PortfolioCalculatorFactory;
let redisCacheService: RedisCacheService;
beforeEach(() => { beforeEach(() => {
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null); currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService( exchangeRateDataService = new ExchangeRateDataService(
@ -34,9 +51,13 @@ describe('PortfolioCalculator', () => {
null null
); );
redisCacheService = new RedisCacheService(null, null);
factory = new PortfolioCalculatorFactory( factory = new PortfolioCalculatorFactory(
configurationService,
currentRateService, currentRateService,
exchangeRateDataService exchangeRateDataService,
redisCacheService
); );
}); });
@ -49,7 +70,9 @@ describe('PortfolioCalculator', () => {
const portfolioCalculator = factory.createCalculator({ const portfolioCalculator = factory.createCalculator({
activities: [], activities: [],
calculationType: PerformanceCalculationType.TWR, calculationType: PerformanceCalculationType.TWR,
currency: 'CHF' currency: 'CHF',
hasFilters: false,
userId: userDummyData.id
}); });
const start = subDays(new Date(Date.now()), 10); const start = subDays(new Date(Date.now()), 10);

View File

@ -1,7 +1,8 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { import {
activityDummyData, activityDummyData,
symbolProfileDummyData symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { import {
PerformanceCalculationType, PerformanceCalculationType,
@ -9,6 +10,9 @@ import {
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { 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 { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { parseDate } from '@ghostfolio/common/helper'; import { parseDate } from '@ghostfolio/common/helper';
@ -23,12 +27,25 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
}; };
}); });
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', () => { describe('PortfolioCalculator', () => {
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService; let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService; let exchangeRateDataService: ExchangeRateDataService;
let factory: PortfolioCalculatorFactory; let factory: PortfolioCalculatorFactory;
let redisCacheService: RedisCacheService;
beforeEach(() => { beforeEach(() => {
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null); currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService( exchangeRateDataService = new ExchangeRateDataService(
@ -38,9 +55,13 @@ describe('PortfolioCalculator', () => {
null null
); );
redisCacheService = new RedisCacheService(null, null);
factory = new PortfolioCalculatorFactory( factory = new PortfolioCalculatorFactory(
configurationService,
currentRateService, currentRateService,
exchangeRateDataService exchangeRateDataService,
redisCacheService
); );
}); });
@ -86,7 +107,9 @@ describe('PortfolioCalculator', () => {
const portfolioCalculator = factory.createCalculator({ const portfolioCalculator = factory.createCalculator({
activities, activities,
calculationType: PerformanceCalculationType.TWR, calculationType: PerformanceCalculationType.TWR,
currency: 'CHF' currency: 'CHF',
hasFilters: false,
userId: userDummyData.id
}); });
const chartData = await portfolioCalculator.getChartData({ const chartData = await portfolioCalculator.getChartData({

View File

@ -1,7 +1,8 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { import {
activityDummyData, activityDummyData,
symbolProfileDummyData symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { import {
PerformanceCalculationType, PerformanceCalculationType,
@ -9,6 +10,9 @@ import {
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { 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 { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { parseDate } from '@ghostfolio/common/helper'; import { parseDate } from '@ghostfolio/common/helper';
@ -23,12 +27,25 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
}; };
}); });
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', () => { describe('PortfolioCalculator', () => {
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService; let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService; let exchangeRateDataService: ExchangeRateDataService;
let factory: PortfolioCalculatorFactory; let factory: PortfolioCalculatorFactory;
let redisCacheService: RedisCacheService;
beforeEach(() => { beforeEach(() => {
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null); currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService( exchangeRateDataService = new ExchangeRateDataService(
@ -38,9 +55,13 @@ describe('PortfolioCalculator', () => {
null null
); );
redisCacheService = new RedisCacheService(null, null);
factory = new PortfolioCalculatorFactory( factory = new PortfolioCalculatorFactory(
configurationService,
currentRateService, currentRateService,
exchangeRateDataService exchangeRateDataService,
redisCacheService
); );
}); });
@ -86,7 +107,9 @@ describe('PortfolioCalculator', () => {
const portfolioCalculator = factory.createCalculator({ const portfolioCalculator = factory.createCalculator({
activities, activities,
calculationType: PerformanceCalculationType.TWR, calculationType: PerformanceCalculationType.TWR,
currency: 'CHF' currency: 'CHF',
hasFilters: false,
userId: userDummyData.id
}); });
const chartData = await portfolioCalculator.getChartData({ const chartData = await portfolioCalculator.getChartData({

View File

@ -1,13 +1,19 @@
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
describe('PortfolioCalculator', () => { describe('PortfolioCalculator', () => {
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService; let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService; let exchangeRateDataService: ExchangeRateDataService;
let factory: PortfolioCalculatorFactory; let factory: PortfolioCalculatorFactory;
let redisCacheService: RedisCacheService;
beforeEach(() => { beforeEach(() => {
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null); currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService( exchangeRateDataService = new ExchangeRateDataService(
@ -17,9 +23,13 @@ describe('PortfolioCalculator', () => {
null null
); );
redisCacheService = new RedisCacheService(null, null);
factory = new PortfolioCalculatorFactory( factory = new PortfolioCalculatorFactory(
configurationService,
currentRateService, currentRateService,
exchangeRateDataService exchangeRateDataService,
redisCacheService
); );
}); });

View File

@ -1,13 +1,9 @@
import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator'; import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator';
import { PortfolioOrderItem } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-order-item.interface'; import { PortfolioOrderItem } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-order-item.interface';
import { PortfolioSnapshot } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-snapshot.interface';
import { getFactor } from '@ghostfolio/api/helper/portfolio.helper'; import { getFactor } from '@ghostfolio/api/helper/portfolio.helper';
import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { import { SymbolMetrics, UniqueAsset } from '@ghostfolio/common/interfaces';
SymbolMetrics, import { PortfolioSnapshot, TimelinePosition } from '@ghostfolio/common/models';
TimelinePosition,
UniqueAsset
} from '@ghostfolio/common/interfaces';
import { Logger } from '@nestjs/common'; import { Logger } from '@nestjs/common';
import { Big } from 'big.js'; import { Big } from 'big.js';
@ -207,7 +203,7 @@ export class TWRPortfolioCalculator extends PortfolioCalculator {
let valueAtStartDateWithCurrencyEffect: Big; let valueAtStartDateWithCurrencyEffect: Big;
// Clone orders to keep the original values in this.orders // Clone orders to keep the original values in this.orders
let orders: PortfolioOrderItem[] = cloneDeep(this.orders).filter( let orders: PortfolioOrderItem[] = cloneDeep(this.activities).filter(
({ SymbolProfile }) => { ({ SymbolProfile }) => {
return SymbolProfile.symbol === symbol; return SymbolProfile.symbol === symbol;
} }

View File

@ -8,6 +8,13 @@ import { GetValuesParams } from './interfaces/get-values-params.interface';
function mockGetValue(symbol: string, date: Date) { function mockGetValue(symbol: string, date: Date) {
switch (symbol) { switch (symbol) {
case '55196015-1365-4560-aa60-8751ae6d18f8':
if (isSameDay(parseDate('2022-01-31'), date)) {
return { marketPrice: 3000 };
}
return { marketPrice: 0 };
case 'BALN.SW': case 'BALN.SW':
if (isSameDay(parseDate('2021-11-12'), date)) { if (isSameDay(parseDate('2021-11-12'), date)) {
return { marketPrice: 146 }; return { marketPrice: 146 };

View File

@ -1,5 +0,0 @@
import { Position } from '@ghostfolio/common/interfaces';
export interface PortfolioPositions {
positions: Position[];
}

View File

@ -1,24 +0,0 @@
import { ResponseError, TimelinePosition } from '@ghostfolio/common/interfaces';
import { Big } from 'big.js';
export interface PortfolioSnapshot extends ResponseError {
currentValueInBaseCurrency: Big;
grossPerformance: Big;
grossPerformanceWithCurrencyEffect: Big;
grossPerformancePercentage: Big;
grossPerformancePercentageWithCurrencyEffect: Big;
netAnnualizedPerformance?: Big;
netAnnualizedPerformanceWithCurrencyEffect?: Big;
netPerformance: Big;
netPerformanceWithCurrencyEffect: Big;
netPerformancePercentage: Big;
netPerformancePercentageWithCurrencyEffect: Big;
positions: TimelinePosition[];
totalFeesWithCurrencyEffect: Big;
totalInterestWithCurrencyEffect: Big;
totalInvestment: Big;
totalInvestmentWithCurrencyEffect: Big;
totalLiabilitiesWithCurrencyEffect: Big;
totalValuablesWithCurrencyEffect: Big;
}

View File

@ -47,11 +47,11 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { AssetClass, AssetSubClass } from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { PortfolioPositionDetail } from './interfaces/portfolio-position-detail.interface'; import { PortfolioPositionDetail } from './interfaces/portfolio-position-detail.interface';
import { PortfolioPositions } from './interfaces/portfolio-positions.interface';
import { PortfolioService } from './portfolio.service'; import { PortfolioService } from './portfolio.service';
@Controller('portfolio') @Controller('portfolio')
@ -128,14 +128,19 @@ export class PortfolioController {
const totalValue = Object.values(holdings) const totalValue = Object.values(holdings)
.filter(({ assetClass, assetSubClass }) => { .filter(({ assetClass, assetSubClass }) => {
return assetClass !== 'CASH' && assetSubClass !== 'CASH'; return (
assetClass !== AssetClass.LIQUIDITY &&
assetSubClass !== AssetSubClass.CASH
);
}) })
.map(({ valueInBaseCurrency }) => { .map(({ valueInBaseCurrency }) => {
return valueInBaseCurrency; return valueInBaseCurrency;
}) })
.reduce((a, b) => a + b, 0); .reduce((a, b) => {
return a + b;
}, 0);
for (const [symbol, portfolioPosition] of Object.entries(holdings)) { for (const [, portfolioPosition] of Object.entries(holdings)) {
portfolioPosition.investment = portfolioPosition.investment =
portfolioPosition.investment / totalInvestment; portfolioPosition.investment / totalInvestment;
portfolioPosition.valueInPercentage = portfolioPosition.valueInPercentage =
@ -159,21 +164,21 @@ export class PortfolioController {
portfolioSummary = nullifyValuesInObject(summary, [ portfolioSummary = nullifyValuesInObject(summary, [
'cash', 'cash',
'committedFunds', 'committedFunds',
'currentGrossPerformance',
'currentGrossPerformanceWithCurrencyEffect',
'currentNetPerformance',
'currentNetPerformanceWithCurrencyEffect',
'currentNetWorth', 'currentNetWorth',
'currentValue', 'currentValueInBaseCurrency',
'dividendInBaseCurrency', 'dividendInBaseCurrency',
'emergencyFund', 'emergencyFund',
'excludedAccountsAndActivities', 'excludedAccountsAndActivities',
'fees', 'fees',
'filteredValueInBaseCurrency', 'filteredValueInBaseCurrency',
'fireWealth', 'fireWealth',
'grossPerformance',
'grossPerformanceWithCurrencyEffect',
'interest', 'interest',
'items', 'items',
'liabilities', 'liabilities',
'netPerformance',
'netPerformanceWithCurrencyEffect',
'totalBuy', 'totalBuy',
'totalInvestment', 'totalInvestment',
'totalSell', 'totalSell',
@ -185,11 +190,11 @@ export class PortfolioController {
holdings[symbol] = { holdings[symbol] = {
...portfolioPosition, ...portfolioPosition,
assetClass: assetClass:
hasDetails || portfolioPosition.assetClass === 'CASH' hasDetails || portfolioPosition.assetClass === AssetClass.LIQUIDITY
? portfolioPosition.assetClass ? portfolioPosition.assetClass
: undefined, : undefined,
assetSubClass: assetSubClass:
hasDetails || portfolioPosition.assetSubClass === 'CASH' hasDetails || portfolioPosition.assetSubClass === AssetSubClass.CASH
? portfolioPosition.assetSubClass ? portfolioPosition.assetSubClass
: undefined, : undefined,
countries: hasDetails ? portfolioPosition.countries : [], countries: hasDetails ? portfolioPosition.countries : [],
@ -443,10 +448,14 @@ export class PortfolioController {
.div(performanceInformation.performance.totalInvestment) .div(performanceInformation.performance.totalInvestment)
.toNumber(), .toNumber(),
valueInPercentage: valueInPercentage:
performanceInformation.performance.currentValue === 0 performanceInformation.performance.currentValueInBaseCurrency ===
0
? 0 ? 0
: new Big(value) : new Big(value)
.div(performanceInformation.performance.currentValue) .div(
performanceInformation.performance
.currentValueInBaseCurrency
)
.toNumber() .toNumber()
}; };
} }
@ -455,12 +464,12 @@ export class PortfolioController {
performanceInformation.performance = nullifyValuesInObject( performanceInformation.performance = nullifyValuesInObject(
performanceInformation.performance, performanceInformation.performance,
[ [
'currentGrossPerformance',
'currentGrossPerformanceWithCurrencyEffect',
'currentNetPerformance',
'currentNetPerformanceWithCurrencyEffect',
'currentNetWorth', 'currentNetWorth',
'currentValue', 'currentValueInBaseCurrency',
'grossPerformance',
'grossPerformanceWithCurrencyEffect',
'netPerformance',
'netPerformanceWithCurrencyEffect',
'totalInvestment' 'totalInvestment'
] ]
); );
@ -477,39 +486,13 @@ export class PortfolioController {
); );
performanceInformation.performance = nullifyValuesInObject( performanceInformation.performance = nullifyValuesInObject(
performanceInformation.performance, performanceInformation.performance,
['currentNetPerformance', 'currentNetPerformancePercent'] ['netPerformance']
); );
} }
return performanceInformation; return performanceInformation;
} }
@Get('positions')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getPositions(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string,
@Query('query') filterBySearchQuery?: string,
@Query('range') dateRange: DateRange = 'max',
@Query('tags') filterByTags?: string
): Promise<PortfolioPositions> {
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts,
filterByAssetClasses,
filterBySearchQuery,
filterByTags
});
return this.portfolioService.getPositions({
dateRange,
filters,
impersonationId
});
}
@Get('public/:accessId') @Get('public/:accessId')
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getPublic( public async getPublic(

View File

@ -2,6 +2,7 @@ import { AccessModule } from '@ghostfolio/api/app/access/access.module';
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service'; import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
import { AccountService } from '@ghostfolio/api/app/account/account.service'; import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { OrderModule } from '@ghostfolio/api/app/order/order.module'; import { OrderModule } from '@ghostfolio/api/app/order/order.module';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { UserModule } from '@ghostfolio/api/app/user/user.module'; import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { ApiModule } from '@ghostfolio/api/services/api/api.module'; import { ApiModule } from '@ghostfolio/api/services/api/api.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
@ -35,6 +36,7 @@ import { RulesService } from './rules.service';
MarketDataModule, MarketDataModule,
OrderModule, OrderModule,
PrismaModule, PrismaModule,
RedisCacheModule,
SymbolProfileModule, SymbolProfileModule,
UserModule UserModule
], ],

View File

@ -27,7 +27,7 @@ describe('PortfolioService', () => {
portfolioService portfolioService
.getAnnualizedPerformancePercent({ .getAnnualizedPerformancePercent({
daysInMarket: NaN, // differenceInDays of date-fns returns NaN for the same day daysInMarket: NaN, // differenceInDays of date-fns returns NaN for the same day
netPerformancePercent: new Big(0) netPerformancePercentage: new Big(0)
}) })
.toNumber() .toNumber()
).toEqual(0); ).toEqual(0);
@ -36,7 +36,7 @@ describe('PortfolioService', () => {
portfolioService portfolioService
.getAnnualizedPerformancePercent({ .getAnnualizedPerformancePercent({
daysInMarket: 0, daysInMarket: 0,
netPerformancePercent: new Big(0) netPerformancePercentage: new Big(0)
}) })
.toNumber() .toNumber()
).toEqual(0); ).toEqual(0);
@ -48,7 +48,7 @@ describe('PortfolioService', () => {
portfolioService portfolioService
.getAnnualizedPerformancePercent({ .getAnnualizedPerformancePercent({
daysInMarket: 65, // < 1 year daysInMarket: 65, // < 1 year
netPerformancePercent: new Big(0.1025) netPerformancePercentage: new Big(0.1025)
}) })
.toNumber() .toNumber()
).toBeCloseTo(0.729705); ).toBeCloseTo(0.729705);
@ -57,7 +57,7 @@ describe('PortfolioService', () => {
portfolioService portfolioService
.getAnnualizedPerformancePercent({ .getAnnualizedPerformancePercent({
daysInMarket: 365, // 1 year daysInMarket: 365, // 1 year
netPerformancePercent: new Big(0.05) netPerformancePercentage: new Big(0.05)
}) })
.toNumber() .toNumber()
).toBeCloseTo(0.05); ).toBeCloseTo(0.05);
@ -69,7 +69,7 @@ describe('PortfolioService', () => {
portfolioService portfolioService
.getAnnualizedPerformancePercent({ .getAnnualizedPerformancePercent({
daysInMarket: 575, // > 1 year daysInMarket: 575, // > 1 year
netPerformancePercent: new Big(0.2374) netPerformancePercentage: new Big(0.2374)
}) })
.toNumber() .toNumber()
).toBeCloseTo(0.145); ).toBeCloseTo(0.145);

View File

@ -29,6 +29,7 @@ import {
EnhancedSymbolProfile, EnhancedSymbolProfile,
Filter, Filter,
HistoricalDataItem, HistoricalDataItem,
InvestmentItem,
PortfolioDetails, PortfolioDetails,
PortfolioInvestments, PortfolioInvestments,
PortfolioPerformanceResponse, PortfolioPerformanceResponse,
@ -36,10 +37,9 @@ import {
PortfolioReport, PortfolioReport,
PortfolioSummary, PortfolioSummary,
Position, Position,
TimelinePosition,
UserSettings UserSettings
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface'; import { TimelinePosition } from '@ghostfolio/common/models';
import type { import type {
AccountWithValue, AccountWithValue,
DateRange, DateRange,
@ -54,6 +54,7 @@ import {
Account, Account,
Type as ActivityType, Type as ActivityType,
AssetClass, AssetClass,
AssetSubClass,
DataSource, DataSource,
Order, Order,
Platform, Platform,
@ -207,16 +208,16 @@ export class PortfolioService {
public getAnnualizedPerformancePercent({ public getAnnualizedPerformancePercent({
daysInMarket, daysInMarket,
netPerformancePercent netPerformancePercentage
}: { }: {
daysInMarket: number; daysInMarket: number;
netPerformancePercent: Big; netPerformancePercentage: Big;
}): Big { }): Big {
if (isNumber(daysInMarket) && daysInMarket > 0) { if (isNumber(daysInMarket) && daysInMarket > 0) {
const exponent = new Big(365).div(daysInMarket).toNumber(); const exponent = new Big(365).div(daysInMarket).toNumber();
return new Big( return new Big(
Math.pow(netPerformancePercent.plus(1).toNumber(), exponent) Math.pow(netPerformancePercentage.plus(1).toNumber(), exponent)
).minus(1); ).minus(1);
} }
@ -276,8 +277,13 @@ export class PortfolioService {
const portfolioCalculator = this.calculatorFactory.createCalculator({ const portfolioCalculator = this.calculatorFactory.createCalculator({
activities, activities,
dateRange,
userId,
calculationType: PerformanceCalculationType.TWR, calculationType: PerformanceCalculationType.TWR,
currency: this.request.user.Settings.settings.baseCurrency currency: this.request.user.Settings.settings.baseCurrency,
hasFilters: filters?.length > 0,
isExperimentalFeatures:
this.request.user.Settings.settings.isExperimentalFeatures
}); });
const items = await portfolioCalculator.getChart({ const items = await portfolioCalculator.getChart({
@ -351,8 +357,12 @@ export class PortfolioService {
const portfolioCalculator = this.calculatorFactory.createCalculator({ const portfolioCalculator = this.calculatorFactory.createCalculator({
activities, activities,
dateRange, dateRange,
userId,
calculationType: PerformanceCalculationType.TWR, calculationType: PerformanceCalculationType.TWR,
currency: userCurrency currency: userCurrency,
hasFilters: true, // disable cache
isExperimentalFeatures:
this.request.user?.Settings.settings.isExperimentalFeatures
}); });
const { currentValueInBaseCurrency, hasErrors, positions } = const { currentValueInBaseCurrency, hasErrors, positions } =
@ -376,7 +386,7 @@ export class PortfolioService {
}) ?? false; }) ?? false;
const isFilteredByCash = filters?.some(({ id, type }) => { const isFilteredByCash = filters?.some(({ id, type }) => {
return id === 'CASH' && type === 'ASSET_CLASS'; return id === AssetClass.LIQUIDITY && type === 'ASSET_CLASS';
}); });
const isFilteredByClosedHoldings = const isFilteredByClosedHoldings =
@ -391,8 +401,8 @@ export class PortfolioService {
if ( if (
filters?.length === 0 || filters?.length === 0 ||
(filters?.length === 1 && (filters?.length === 1 &&
filters[0].type === 'ASSET_CLASS' && filters[0].id === AssetClass.LIQUIDITY &&
filters[0].id === 'CASH') filters[0].type === 'ASSET_CLASS')
) { ) {
filteredValueInBaseCurrency = filteredValueInBaseCurrency.plus( filteredValueInBaseCurrency = filteredValueInBaseCurrency.plus(
cashDetails.balanceInBaseCurrency cashDetails.balanceInBaseCurrency
@ -647,11 +657,15 @@ export class PortfolioService {
]); ]);
const portfolioCalculator = this.calculatorFactory.createCalculator({ const portfolioCalculator = this.calculatorFactory.createCalculator({
userId,
activities: orders.filter((order) => { activities: orders.filter((order) => {
return ['BUY', 'DIVIDEND', 'ITEM', 'SELL'].includes(order.type); return ['BUY', 'DIVIDEND', 'ITEM', 'SELL'].includes(order.type);
}), }),
calculationType: PerformanceCalculationType.TWR, calculationType: PerformanceCalculationType.TWR,
currency: userCurrency currency: userCurrency,
hasFilters: true,
isExperimentalFeatures:
this.request.user.Settings.settings.isExperimentalFeatures
}); });
const portfolioStart = portfolioCalculator.getStartDate(); const portfolioStart = portfolioCalculator.getStartDate();
@ -690,17 +704,21 @@ export class PortfolioService {
const dividendYieldPercent = this.getAnnualizedPerformancePercent({ const dividendYieldPercent = this.getAnnualizedPerformancePercent({
daysInMarket: differenceInDays(new Date(), parseDate(firstBuyDate)), daysInMarket: differenceInDays(new Date(), parseDate(firstBuyDate)),
netPerformancePercent: dividendInBaseCurrency.div( netPerformancePercentage: timeWeightedInvestment.eq(0)
timeWeightedInvestment ? new Big(0)
) : dividendInBaseCurrency.div(timeWeightedInvestment)
}); });
const dividendYieldPercentWithCurrencyEffect = const dividendYieldPercentWithCurrencyEffect =
this.getAnnualizedPerformancePercent({ this.getAnnualizedPerformancePercent({
daysInMarket: differenceInDays(new Date(), parseDate(firstBuyDate)), daysInMarket: differenceInDays(new Date(), parseDate(firstBuyDate)),
netPerformancePercent: dividendInBaseCurrency.div( netPerformancePercentage: timeWeightedInvestmentWithCurrencyEffect.eq(
timeWeightedInvestmentWithCurrencyEffect 0
) )
? new Big(0)
: dividendInBaseCurrency.div(
timeWeightedInvestmentWithCurrencyEffect
)
}); });
const historicalData = await this.dataProviderService.getHistorical( const historicalData = await this.dataProviderService.getHistorical(
@ -918,8 +936,12 @@ export class PortfolioService {
const portfolioCalculator = this.calculatorFactory.createCalculator({ const portfolioCalculator = this.calculatorFactory.createCalculator({
activities, activities,
dateRange, dateRange,
userId,
calculationType: PerformanceCalculationType.TWR, calculationType: PerformanceCalculationType.TWR,
currency: this.request.user.Settings.settings.baseCurrency currency: this.request.user.Settings.settings.baseCurrency,
hasFilters: filters?.length > 0,
isExperimentalFeatures:
this.request.user.Settings.settings.isExperimentalFeatures
}); });
let { hasErrors, positions } = await portfolioCalculator.getSnapshot(); let { hasErrors, positions } = await portfolioCalculator.getSnapshot();
@ -1072,7 +1094,7 @@ export class PortfolioService {
) )
); );
const { endDate, startDate } = getInterval(dateRange); const { endDate } = getInterval(dateRange);
const { activities } = await this.orderService.getOrders({ const { activities } = await this.orderService.getOrders({
endDate, endDate,
@ -1088,16 +1110,16 @@ export class PortfolioService {
firstOrderDate: undefined, firstOrderDate: undefined,
hasErrors: false, hasErrors: false,
performance: { performance: {
currentGrossPerformance: 0,
currentGrossPerformancePercent: 0,
currentGrossPerformancePercentWithCurrencyEffect: 0,
currentGrossPerformanceWithCurrencyEffect: 0,
currentNetPerformance: 0,
currentNetPerformancePercent: 0,
currentNetPerformancePercentWithCurrencyEffect: 0,
currentNetPerformanceWithCurrencyEffect: 0,
currentNetWorth: 0, currentNetWorth: 0,
currentValue: 0, currentValueInBaseCurrency: 0,
grossPerformance: 0,
grossPerformancePercentage: 0,
grossPerformancePercentageWithCurrencyEffect: 0,
grossPerformanceWithCurrencyEffect: 0,
netPerformance: 0,
netPerformancePercentage: 0,
netPerformancePercentageWithCurrencyEffect: 0,
netPerformanceWithCurrencyEffect: 0,
totalInvestment: 0 totalInvestment: 0
} }
}; };
@ -1107,8 +1129,12 @@ export class PortfolioService {
accountBalanceItems, accountBalanceItems,
activities, activities,
dateRange, dateRange,
userId,
calculationType: PerformanceCalculationType.TWR, calculationType: PerformanceCalculationType.TWR,
currency: userCurrency currency: userCurrency,
hasFilters: filters?.length > 0,
isExperimentalFeatures:
this.request.user.Settings.settings.isExperimentalFeatures
}); });
const { const {
@ -1128,9 +1154,9 @@ export class PortfolioService {
let currentNetPerformance = netPerformance; let currentNetPerformance = netPerformance;
let currentNetPerformancePercent = netPerformancePercentage; let currentNetPerformancePercentage = netPerformancePercentage;
let currentNetPerformancePercentWithCurrencyEffect = let currentNetPerformancePercentageWithCurrencyEffect =
netPerformancePercentageWithCurrencyEffect; netPerformancePercentageWithCurrencyEffect;
let currentNetPerformanceWithCurrencyEffect = let currentNetPerformanceWithCurrencyEffect =
@ -1149,11 +1175,11 @@ export class PortfolioService {
if (itemOfToday) { if (itemOfToday) {
currentNetPerformance = new Big(itemOfToday.netPerformance); currentNetPerformance = new Big(itemOfToday.netPerformance);
currentNetPerformancePercent = new Big( currentNetPerformancePercentage = new Big(
itemOfToday.netPerformanceInPercentage itemOfToday.netPerformanceInPercentage
).div(100); ).div(100);
currentNetPerformancePercentWithCurrencyEffect = new Big( currentNetPerformancePercentageWithCurrencyEffect = new Big(
itemOfToday.netPerformanceInPercentageWithCurrencyEffect itemOfToday.netPerformanceInPercentageWithCurrencyEffect
).div(100); ).div(100);
@ -1171,19 +1197,19 @@ export class PortfolioService {
firstOrderDate: parseDate(items[0]?.date), firstOrderDate: parseDate(items[0]?.date),
performance: { performance: {
currentNetWorth, currentNetWorth,
currentGrossPerformance: grossPerformance.toNumber(), currentValueInBaseCurrency: currentValueInBaseCurrency.toNumber(),
currentGrossPerformancePercent: grossPerformancePercentage.toNumber(), grossPerformance: grossPerformance.toNumber(),
currentGrossPerformancePercentWithCurrencyEffect: grossPerformancePercentage: grossPerformancePercentage.toNumber(),
grossPerformancePercentageWithCurrencyEffect:
grossPerformancePercentageWithCurrencyEffect.toNumber(), grossPerformancePercentageWithCurrencyEffect.toNumber(),
currentGrossPerformanceWithCurrencyEffect: grossPerformanceWithCurrencyEffect:
grossPerformanceWithCurrencyEffect.toNumber(), grossPerformanceWithCurrencyEffect.toNumber(),
currentNetPerformance: currentNetPerformance.toNumber(), netPerformance: currentNetPerformance.toNumber(),
currentNetPerformancePercent: currentNetPerformancePercent.toNumber(), netPerformancePercentage: currentNetPerformancePercentage.toNumber(),
currentNetPerformancePercentWithCurrencyEffect: netPerformancePercentageWithCurrencyEffect:
currentNetPerformancePercentWithCurrencyEffect.toNumber(), currentNetPerformancePercentageWithCurrencyEffect.toNumber(),
currentNetPerformanceWithCurrencyEffect: netPerformanceWithCurrencyEffect:
currentNetPerformanceWithCurrencyEffect.toNumber(), currentNetPerformanceWithCurrencyEffect.toNumber(),
currentValue: currentValueInBaseCurrency.toNumber(),
totalInvestment: totalInvestment.toNumber() totalInvestment: totalInvestment.toNumber()
} }
}; };
@ -1201,8 +1227,12 @@ export class PortfolioService {
const portfolioCalculator = this.calculatorFactory.createCalculator({ const portfolioCalculator = this.calculatorFactory.createCalculator({
activities, activities,
userId,
calculationType: PerformanceCalculationType.TWR, calculationType: PerformanceCalculationType.TWR,
currency: this.request.user.Settings.settings.baseCurrency currency: this.request.user.Settings.settings.baseCurrency,
hasFilters: false,
isExperimentalFeatures:
this.request.user.Settings.settings.isExperimentalFeatures
}); });
let { totalFeesWithCurrencyEffect, positions, totalInvestment } = let { totalFeesWithCurrencyEffect, positions, totalInvestment } =
@ -1425,8 +1455,8 @@ export class PortfolioService {
return { return {
currency, currency,
allocationInPercentage: 0, allocationInPercentage: 0,
assetClass: AssetClass.CASH, assetClass: AssetClass.LIQUIDITY,
assetSubClass: AssetClass.CASH, assetSubClass: AssetSubClass.CASH,
countries: [], countries: [],
dataSource: undefined, dataSource: undefined,
dateOfFirstActivity: undefined, dateOfFirstActivity: undefined,
@ -1576,11 +1606,6 @@ export class PortfolioService {
userId = await this.getUserId(impersonationId, userId); userId = await this.getUserId(impersonationId, userId);
const user = await this.userService.user({ id: userId }); const user = await this.userService.user({ id: userId });
const performanceInformation = await this.getPerformance({
impersonationId,
userId
});
const { activities } = await this.orderService.getOrders({ const { activities } = await this.orderService.getOrders({
userCurrency, userCurrency,
userId, userId,
@ -1598,6 +1623,19 @@ export class PortfolioService {
} }
} }
const {
currentValueInBaseCurrency,
grossPerformance,
grossPerformancePercentage,
grossPerformancePercentageWithCurrencyEffect,
grossPerformanceWithCurrencyEffect,
netPerformance,
netPerformancePercentage,
netPerformancePercentageWithCurrencyEffect,
netPerformanceWithCurrencyEffect,
totalInvestment
} = await portfolioCalculator.getSnapshot();
const dividendInBaseCurrency = const dividendInBaseCurrency =
await portfolioCalculator.getDividendInBaseCurrency(); await portfolioCalculator.getDividendInBaseCurrency();
@ -1666,7 +1704,7 @@ export class PortfolioService {
.toNumber(); .toNumber();
const netWorth = new Big(balanceInBaseCurrency) const netWorth = new Big(balanceInBaseCurrency)
.plus(performanceInformation.performance.currentValue) .plus(currentValueInBaseCurrency)
.plus(valuables) .plus(valuables)
.plus(excludedAccountsAndActivities) .plus(excludedAccountsAndActivities)
.minus(liabilities) .minus(liabilities)
@ -1676,21 +1714,18 @@ export class PortfolioService {
const annualizedPerformancePercent = this.getAnnualizedPerformancePercent({ const annualizedPerformancePercent = this.getAnnualizedPerformancePercent({
daysInMarket, daysInMarket,
netPerformancePercent: new Big( netPerformancePercentage: new Big(netPerformancePercentage)
performanceInformation.performance.currentNetPerformancePercent
)
})?.toNumber(); })?.toNumber();
const annualizedPerformancePercentWithCurrencyEffect = const annualizedPerformancePercentWithCurrencyEffect =
this.getAnnualizedPerformancePercent({ this.getAnnualizedPerformancePercent({
daysInMarket, daysInMarket,
netPerformancePercent: new Big( netPerformancePercentage: new Big(
performanceInformation.performance.currentNetPerformancePercentWithCurrencyEffect netPerformancePercentageWithCurrencyEffect
) )
})?.toNumber(); })?.toNumber();
return { return {
...performanceInformation.performance,
annualizedPerformancePercent, annualizedPerformancePercent,
annualizedPerformancePercentWithCurrencyEffect, annualizedPerformancePercentWithCurrencyEffect,
cash, cash,
@ -1699,6 +1734,7 @@ export class PortfolioService {
totalBuy, totalBuy,
totalSell, totalSell,
committedFunds: committedFunds.toNumber(), committedFunds: committedFunds.toNumber(),
currentValueInBaseCurrency: currentValueInBaseCurrency.toNumber(),
dividendInBaseCurrency: dividendInBaseCurrency.toNumber(), dividendInBaseCurrency: dividendInBaseCurrency.toNumber(),
emergencyFund: { emergencyFund: {
assets: emergencyFundPositionsValueInBaseCurrency, assets: emergencyFundPositionsValueInBaseCurrency,
@ -1712,15 +1748,28 @@ export class PortfolioService {
filteredValueInPercentage: netWorth filteredValueInPercentage: netWorth
? filteredValueInBaseCurrency.div(netWorth).toNumber() ? filteredValueInBaseCurrency.div(netWorth).toNumber()
: undefined, : undefined,
fireWealth: new Big(performanceInformation.performance.currentValue) fireWealth: new Big(currentValueInBaseCurrency)
.minus(emergencyFundPositionsValueInBaseCurrency) .minus(emergencyFundPositionsValueInBaseCurrency)
.toNumber(), .toNumber(),
grossPerformance: grossPerformance.toNumber(),
grossPerformancePercentage: grossPerformancePercentage.toNumber(),
grossPerformancePercentageWithCurrencyEffect:
grossPerformancePercentageWithCurrencyEffect.toNumber(),
grossPerformanceWithCurrencyEffect:
grossPerformanceWithCurrencyEffect.toNumber(),
interest: interest.toNumber(), interest: interest.toNumber(),
items: valuables.toNumber(), items: valuables.toNumber(),
liabilities: liabilities.toNumber(), liabilities: liabilities.toNumber(),
netPerformance: netPerformance.toNumber(),
netPerformancePercentage: netPerformancePercentage.toNumber(),
netPerformancePercentageWithCurrencyEffect:
netPerformancePercentageWithCurrencyEffect.toNumber(),
netPerformanceWithCurrencyEffect:
netPerformanceWithCurrencyEffect.toNumber(),
ordersCount: activities.filter(({ type }) => { ordersCount: activities.filter(({ type }) => {
return type === 'BUY' || type === 'SELL'; return ['BUY', 'SELL'].includes(type);
}).length, }).length,
totalInvestment: totalInvestment.toNumber(),
totalValueInBaseCurrency: netWorth totalValueInBaseCurrency: netWorth
}; };
} }

View File

@ -0,0 +1,13 @@
import { RedisCacheService } from './redis-cache.service';
export const RedisCacheServiceMock = {
get: (key: string): Promise<string> => {
return Promise.resolve(null);
},
getPortfolioSnapshotKey: (userId: string): string => {
return `portfolio-snapshot-${userId}`;
},
set: (key: string, value: string, ttlInSeconds?: number): Promise<string> => {
return Promise.resolve(value);
}
};

View File

@ -24,6 +24,10 @@ export class RedisCacheService {
return this.cache.get(key); return this.cache.get(key);
} }
public getPortfolioSnapshotKey({ userId }: { userId: string }) {
return `portfolio-snapshot-${userId}`;
}
public getQuoteKey({ dataSource, symbol }: UniqueAsset) { public getQuoteKey({ dataSource, symbol }: UniqueAsset) {
return `quote-${getAssetProfileIdentifier({ dataSource, symbol })}`; return `quote-${getAssetProfileIdentifier({ dataSource, symbol })}`;
} }

View File

@ -2,11 +2,7 @@ import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorat
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { User, UserSettings } from '@ghostfolio/common/interfaces'; import { User, UserSettings } from '@ghostfolio/common/interfaces';
import { import { hasPermission, permissions } from '@ghostfolio/common/permissions';
hasPermission,
hasRole,
permissions
} from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
@ -63,13 +59,6 @@ export class UserController {
public async getUser( public async getUser(
@Headers('accept-language') acceptLanguage: string @Headers('accept-language') acceptLanguage: string
): Promise<User> { ): Promise<User> {
if (hasRole(this.request.user, 'INACTIVE')) {
throw new HttpException(
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
StatusCodes.TOO_MANY_REQUESTS
);
}
return this.userService.getUser( return this.userService.getUser(
this.request.user, this.request.user,
acceptLanguage?.split(',')?.[0] acceptLanguage?.split(',')?.[0]

View File

@ -1,5 +1,6 @@
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service'; import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
import { environment } from '@ghostfolio/api/environments/environment'; import { environment } from '@ghostfolio/api/environments/environment';
import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service'; import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
@ -25,6 +26,7 @@ import {
import { UserWithSettings } from '@ghostfolio/common/types'; import { UserWithSettings } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { Prisma, Role, User } from '@prisma/client'; import { Prisma, Role, User } from '@prisma/client';
import { differenceInDays } from 'date-fns'; import { differenceInDays } from 'date-fns';
import { sortBy, without } from 'lodash'; import { sortBy, without } from 'lodash';
@ -37,6 +39,7 @@ export class UserService {
public constructor( public constructor(
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly eventEmitter: EventEmitter2,
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService, private readonly propertyService: PropertyService,
private readonly subscriptionService: SubscriptionService, private readonly subscriptionService: SubscriptionService,
@ -437,11 +440,9 @@ export class UserService {
userId: string; userId: string;
userSettings: UserSettings; userSettings: UserSettings;
}) { }) {
const settings = userSettings as unknown as Prisma.JsonObject; const { settings } = await this.prismaService.settings.upsert({
await this.prismaService.settings.upsert({
create: { create: {
settings, settings: userSettings as unknown as Prisma.JsonObject,
User: { User: {
connect: { connect: {
id: userId id: userId
@ -449,14 +450,21 @@ export class UserService {
} }
}, },
update: { update: {
settings settings: userSettings as unknown as Prisma.JsonObject
}, },
where: { where: {
userId userId
} }
}); });
return; this.eventEmitter.emit(
PortfolioChangedEvent.getName(),
new PortfolioChangedEvent({
userId
})
);
return settings;
} }
private getRandomString(length: number) { private getRandomString(length: number) {

View File

@ -0,0 +1,11 @@
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { Module } from '@nestjs/common';
import { PortfolioChangedListener } from './portfolio-changed.listener';
@Module({
imports: [RedisCacheModule],
providers: [PortfolioChangedListener]
})
export class EventsModule {}

View File

@ -0,0 +1,15 @@
export class PortfolioChangedEvent {
private userId: string;
public constructor({ userId }: { userId: string }) {
this.userId = userId;
}
public static getName() {
return 'portfolio.changed';
}
public getUserId() {
return this.userId;
}
}

View File

@ -0,0 +1,25 @@
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { PortfolioChangedEvent } from './portfolio-changed.event';
@Injectable()
export class PortfolioChangedListener {
public constructor(private readonly redisCacheService: RedisCacheService) {}
@OnEvent(PortfolioChangedEvent.getName())
handlePortfolioChangedEvent(event: PortfolioChangedEvent) {
Logger.log(
`Portfolio of user '${event.getUserId()}' has changed`,
'PortfolioChangedListener'
);
this.redisCacheService.remove(
this.redisCacheService.getPortfolioSnapshotKey({
userId: event.getUserId()
})
);
}
}

View File

@ -1,7 +1,8 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface'; import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { groupBy } from '@ghostfolio/common/helper'; import { groupBy } from '@ghostfolio/common/helper';
import { TimelinePosition, UserSettings } from '@ghostfolio/common/interfaces'; import { UserSettings } from '@ghostfolio/common/interfaces';
import { TimelinePosition } from '@ghostfolio/common/models';
import { EvaluationResult } from './interfaces/evaluation-result.interface'; import { EvaluationResult } from './interfaces/evaluation-result.interface';
import { RuleInterface } from './interfaces/rule.interface'; import { RuleInterface } from './interfaces/rule.interface';

View File

@ -1,7 +1,8 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface'; import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { Rule } from '@ghostfolio/api/models/rule'; import { Rule } from '@ghostfolio/api/models/rule';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { TimelinePosition, UserSettings } from '@ghostfolio/common/interfaces'; import { UserSettings } from '@ghostfolio/common/interfaces';
import { TimelinePosition } from '@ghostfolio/common/models';
export class CurrencyClusterRiskBaseCurrencyCurrentInvestment extends Rule<Settings> { export class CurrencyClusterRiskBaseCurrencyCurrentInvestment extends Rule<Settings> {
private positions: TimelinePosition[]; private positions: TimelinePosition[];

View File

@ -1,7 +1,8 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface'; import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { Rule } from '@ghostfolio/api/models/rule'; import { Rule } from '@ghostfolio/api/models/rule';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { TimelinePosition, UserSettings } from '@ghostfolio/common/interfaces'; import { UserSettings } from '@ghostfolio/common/interfaces';
import { TimelinePosition } from '@ghostfolio/common/models';
export class CurrencyClusterRiskCurrentInvestment extends Rule<Settings> { export class CurrencyClusterRiskCurrentInvestment extends Rule<Settings> {
private positions: TimelinePosition[]; private positions: TimelinePosition[];

View File

@ -59,7 +59,7 @@ export class CoinGeckoService implements DataProviderInterface {
}): Promise<Partial<SymbolProfile>> { }): Promise<Partial<SymbolProfile>> {
const response: Partial<SymbolProfile> = { const response: Partial<SymbolProfile> = {
symbol, symbol,
assetClass: AssetClass.CASH, assetClass: AssetClass.LIQUIDITY,
assetSubClass: AssetSubClass.CRYPTOCURRENCY, assetSubClass: AssetSubClass.CRYPTOCURRENCY,
currency: DEFAULT_CURRENCY, currency: DEFAULT_CURRENCY,
dataSource: this.getName() dataSource: this.getName()
@ -243,7 +243,7 @@ export class CoinGeckoService implements DataProviderInterface {
return { return {
name, name,
symbol, symbol,
assetClass: AssetClass.CASH, assetClass: AssetClass.LIQUIDITY,
assetSubClass: AssetSubClass.CRYPTOCURRENCY, assetSubClass: AssetSubClass.CRYPTOCURRENCY,
currency: DEFAULT_CURRENCY, currency: DEFAULT_CURRENCY,
dataProviderInfo: this.getDataProviderInfo(), dataProviderInfo: this.getDataProviderInfo(),

View File

@ -266,7 +266,7 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
switch (quoteType?.toLowerCase()) { switch (quoteType?.toLowerCase()) {
case 'cryptocurrency': case 'cryptocurrency':
assetClass = AssetClass.CASH; assetClass = AssetClass.LIQUIDITY;
assetSubClass = AssetSubClass.CRYPTOCURRENCY; assetSubClass = AssetSubClass.CRYPTOCURRENCY;
break; break;
case 'equity': case 'equity':

View File

@ -399,7 +399,8 @@ export class DataProviderService {
numberOfItemsInCache > 1 ? 's' : '' numberOfItemsInCache > 1 ? 's' : ''
} from cache in ${((performance.now() - startTimeTotal) / 1000).toFixed( } from cache in ${((performance.now() - startTimeTotal) / 1000).toFixed(
3 3
)} seconds` )} seconds`,
'DataProviderService'
); );
} }
@ -505,7 +506,8 @@ export class DataProviderService {
} from ${dataSource} in ${( } from ${dataSource} in ${(
(performance.now() - startTimeDataSource) / (performance.now() - startTimeDataSource) /
1000 1000
).toFixed(3)} seconds` ).toFixed(3)} seconds`,
'DataProviderService'
); );
try { try {
@ -535,14 +537,15 @@ export class DataProviderService {
await Promise.all(promises); await Promise.all(promises);
Logger.debug('------------------------------------------------'); Logger.debug('--------------------------------------------------------');
Logger.debug( Logger.debug(
`Fetched ${items.length} quote${items.length > 1 ? 's' : ''} in ${( `Fetched ${items.length} quote${items.length > 1 ? 's' : ''} in ${(
(performance.now() - startTimeTotal) / (performance.now() - startTimeTotal) /
1000 1000
).toFixed(3)} seconds` ).toFixed(3)} seconds`,
'DataProviderService'
); );
Logger.debug('================================================'); Logger.debug('========================================================');
return response; return response;
} }

View File

@ -468,7 +468,7 @@ export class EodHistoricalDataService implements DataProviderInterface {
assetSubClass = AssetSubClass.STOCK; assetSubClass = AssetSubClass.STOCK;
break; break;
case 'currency': case 'currency':
assetClass = AssetClass.CASH; assetClass = AssetClass.LIQUIDITY;
if (Exchange?.toLowerCase() === 'cc') { if (Exchange?.toLowerCase() === 'cc') {
assetSubClass = AssetSubClass.CRYPTOCURRENCY; assetSubClass = AssetSubClass.CRYPTOCURRENCY;

View File

@ -91,7 +91,7 @@ export class ManualService implements DataProviderInterface {
headers = {}, headers = {},
selector, selector,
url url
} = symbolProfile.scraperConfiguration ?? {}; } = symbolProfile?.scraperConfiguration ?? {};
if (defaultMarketPrice) { if (defaultMarketPrice) {
const historical: { const historical: {

View File

@ -158,11 +158,9 @@
<li> <li>
<a href="../pt" title="Ghostfolio in Português">Português</a> <a href="../pt" title="Ghostfolio in Português">Português</a>
</li> </li>
<!-- <li>
<li> <a href="../tr" title="Ghostfolio in Türkçe">Türkçe</a>
<a href="../tr" title="Ghostfolio in Türkçe">Türkçe</a> </li>
</li>
-->
<!-- <!--
<li> <li>
<a href="../zh" title="Ghostfolio in Chinese">Chinese</a> <a href="../zh" title="Ghostfolio in Chinese">Chinese</a>

View File

@ -13,13 +13,23 @@ import {
OnDestroy, OnDestroy,
OnInit OnInit
} from '@angular/core'; } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { Title } from '@angular/platform-browser'; import { Title } from '@angular/platform-browser';
import { NavigationEnd, PRIMARY_OUTLET, Router } from '@angular/router'; import {
ActivatedRoute,
NavigationEnd,
PRIMARY_OUTLET,
Router
} from '@angular/router';
import { DataSource } from '@prisma/client';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { filter, takeUntil } from 'rxjs/operators'; import { filter, takeUntil } from 'rxjs/operators';
import { PositionDetailDialogParams } from './components/position-detail-dialog/interfaces/interfaces';
import { PositionDetailDialog } from './components/position-detail-dialog/position-detail-dialog.component';
import { DataService } from './services/data.service'; import { DataService } from './services/data.service';
import { ImpersonationStorageService } from './services/impersonation-storage.service';
import { TokenStorageService } from './services/token-storage.service'; import { TokenStorageService } from './services/token-storage.service';
import { UserService } from './services/user/user.service'; import { UserService } from './services/user/user.service';
@ -38,6 +48,7 @@ export class AppComponent implements OnDestroy, OnInit {
public currentRoute: string; public currentRoute: string;
public currentYear = new Date().getFullYear(); public currentYear = new Date().getFullYear();
public deviceType: string; public deviceType: string;
public hasImpersonationId: boolean;
public hasInfoMessage: boolean; public hasInfoMessage: boolean;
public hasPermissionForStatistics: boolean; public hasPermissionForStatistics: boolean;
public hasPermissionForSubscription: boolean; public hasPermissionForSubscription: boolean;
@ -67,7 +78,10 @@ export class AppComponent implements OnDestroy, OnInit {
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService, private dataService: DataService,
private deviceService: DeviceDetectorService, private deviceService: DeviceDetectorService,
private dialog: MatDialog,
@Inject(DOCUMENT) private document: Document, @Inject(DOCUMENT) private document: Document,
private impersonationStorageService: ImpersonationStorageService,
private route: ActivatedRoute,
private router: Router, private router: Router,
private title: Title, private title: Title,
private tokenStorageService: TokenStorageService, private tokenStorageService: TokenStorageService,
@ -75,6 +89,21 @@ export class AppComponent implements OnDestroy, OnInit {
) { ) {
this.initializeTheme(); this.initializeTheme();
this.user = undefined; this.user = undefined;
this.route.queryParams
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((params) => {
if (
params['dataSource'] &&
params['holdingDetailDialog'] &&
params['symbol']
) {
this.openHoldingDetailDialog({
dataSource: params['dataSource'],
symbol: params['symbol']
});
}
});
} }
public ngOnInit() { public ngOnInit() {
@ -96,6 +125,13 @@ export class AppComponent implements OnDestroy, OnInit {
permissions.enableFearAndGreedIndex permissions.enableFearAndGreedIndex
); );
this.impersonationStorageService
.onChangeHasImpersonation()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((impersonationId) => {
this.hasImpersonationId = !!impersonationId;
});
this.router.events this.router.events
.pipe(filter((event) => event instanceof NavigationEnd)) .pipe(filter((event) => event instanceof NavigationEnd))
.subscribe(() => { .subscribe(() => {
@ -197,6 +233,55 @@ export class AppComponent implements OnDestroy, OnInit {
}); });
} }
private openHoldingDetailDialog({
dataSource,
symbol
}: {
dataSource: DataSource;
symbol: string;
}) {
this.userService
.get()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((user) => {
this.user = user;
const dialogRef = this.dialog.open(PositionDetailDialog, {
autoFocus: false,
data: <PositionDetailDialogParams>{
dataSource,
symbol,
baseCurrency: this.user?.settings?.baseCurrency,
colorScheme: this.user?.settings?.colorScheme,
deviceType: this.deviceType,
hasImpersonationId: this.hasImpersonationId,
hasPermissionToReportDataGlitch: hasPermission(
this.user?.permissions,
permissions.reportDataGlitch
),
locale: this.user?.settings?.locale
},
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
});
dialogRef
.afterClosed()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.router.navigate([], {
queryParams: {
dataSource: null,
holdingDetailDialog: null,
symbol: null
},
queryParamsHandling: 'merge',
relativeTo: this.route
});
});
});
}
private toggleTheme(isDarkTheme: boolean) { private toggleTheme(isDarkTheme: boolean) {
const themeColor = getCssVariable( const themeColor = getCssVariable(
isDarkTheme ? '--dark-background' : '--light-background' isDarkTheme ? '--dark-background' : '--light-background'

View File

@ -94,6 +94,7 @@
[dataSource]="dataSource" [dataSource]="dataSource"
[deviceType]="data.deviceType" [deviceType]="data.deviceType"
[hasPermissionToCreateActivity]="false" [hasPermissionToCreateActivity]="false"
[hasPermissionToDeleteActivity]="false"
[hasPermissionToExportActivities]=" [hasPermissionToExportActivities]="
!data.hasImpersonationId && !user.settings.isRestrictedView !data.hasImpersonationId && !user.settings.isRestrictedView
" "

View File

@ -38,6 +38,7 @@ import { CreateAssetProfileDialogParams } from './create-asset-profile-dialog/in
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'has-fab' },
selector: 'gf-admin-market-data', selector: 'gf-admin-market-data',
styleUrls: ['./admin-market-data.scss'], styleUrls: ['./admin-market-data.scss'],
templateUrl: './admin-market-data.html' templateUrl: './admin-market-data.html'

View File

@ -3,6 +3,7 @@ import { UpdateMarketDataDto } from '@ghostfolio/api/app/admin/update-market-dat
import { AdminMarketDataService } from '@ghostfolio/client/components/admin-market-data/admin-market-data.service'; import { AdminMarketDataService } from '@ghostfolio/client/components/admin-market-data/admin-market-data.service';
import { AdminService } from '@ghostfolio/client/services/admin.service'; import { AdminService } from '@ghostfolio/client/services/admin.service';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { validateObjectForForm } from '@ghostfolio/client/util/form.util';
import { ghostfolioScraperApiSymbolPrefix } from '@ghostfolio/common/config'; import { ghostfolioScraperApiSymbolPrefix } from '@ghostfolio/common/config';
import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { import {
@ -258,29 +259,29 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
}); });
} }
public onSubmit() { public async onSubmit() {
let countries = []; let countries = [];
let scraperConfiguration = {}; let scraperConfiguration = {};
let sectors = []; let sectors = [];
let symbolMapping = {}; let symbolMapping = {};
try { try {
countries = JSON.parse(this.assetProfileForm.controls['countries'].value); countries = JSON.parse(this.assetProfileForm.get('countries').value);
} catch {} } catch {}
try { try {
scraperConfiguration = JSON.parse( scraperConfiguration = JSON.parse(
this.assetProfileForm.controls['scraperConfiguration'].value this.assetProfileForm.get('scraperConfiguration').value
); );
} catch {} } catch {}
try { try {
sectors = JSON.parse(this.assetProfileForm.controls['sectors'].value); sectors = JSON.parse(this.assetProfileForm.get('sectors').value);
} catch {} } catch {}
try { try {
symbolMapping = JSON.parse( symbolMapping = JSON.parse(
this.assetProfileForm.controls['symbolMapping'].value this.assetProfileForm.get('symbolMapping').value
); );
} catch {} } catch {}
@ -289,16 +290,27 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
scraperConfiguration, scraperConfiguration,
sectors, sectors,
symbolMapping, symbolMapping,
assetClass: this.assetProfileForm.controls['assetClass'].value, assetClass: this.assetProfileForm.get('assetClass').value,
assetSubClass: this.assetProfileForm.controls['assetSubClass'].value, assetSubClass: this.assetProfileForm.get('assetSubClass').value,
comment: this.assetProfileForm.controls['comment'].value ?? null, comment: this.assetProfileForm.get('comment').value || null,
currency: (<Currency>( currency: (<Currency>(
(<unknown>this.assetProfileForm.controls['currency'].value) (<unknown>this.assetProfileForm.get('currency').value)
))?.value, ))?.value,
name: this.assetProfileForm.controls['name'].value, name: this.assetProfileForm.get('name').value,
url: this.assetProfileForm.controls['url'].value url: this.assetProfileForm.get('url').value || null
}; };
try {
await validateObjectForForm({
classDto: UpdateAssetProfileDto,
form: this.assetProfileForm,
object: assetProfileData
});
} catch (error) {
console.error(error);
return;
}
this.adminService this.adminService
.patchAssetProfile({ .patchAssetProfile({
...assetProfileData, ...assetProfileData,
@ -314,8 +326,8 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
this.adminService this.adminService
.testMarketData({ .testMarketData({
dataSource: this.data.dataSource, dataSource: this.data.dataSource,
scraperConfiguration: scraperConfiguration: this.assetProfileForm.get('scraperConfiguration')
this.assetProfileForm.controls['scraperConfiguration'].value, .value,
symbol: this.data.symbol symbol: this.data.symbol
}) })
.pipe( .pipe(
@ -331,9 +343,8 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
' ' + ' ' +
price + price +
' ' + ' ' +
(<Currency>( (<Currency>(<unknown>this.assetProfileForm.get('currency').value))
(<unknown>this.assetProfileForm.controls['currency'].value) ?.value
))?.value
); );
}); });
} }

View File

@ -302,7 +302,7 @@
mat-flat-button mat-flat-button
type="button" type="button"
[disabled]=" [disabled]="
assetProfileForm.controls['scraperConfiguration'].value === '{}' assetProfileForm.get('scraperConfiguration').value === '{}'
" "
(click)="onTestMarketData()" (click)="onTestMarketData()"
> >
@ -338,11 +338,11 @@
<mat-form-field appearance="outline" class="w-100 without-hint"> <mat-form-field appearance="outline" class="w-100 without-hint">
<mat-label i18n>Url</mat-label> <mat-label i18n>Url</mat-label>
<input formControlName="url" matInput type="text" /> <input formControlName="url" matInput type="text" />
@if (assetProfileForm.controls['url'].value) { @if (assetProfileForm.get('url').value) {
<gf-asset-profile-icon <gf-asset-profile-icon
class="mr-3" class="mr-3"
matSuffix matSuffix
[url]="assetProfileForm.controls['url'].value" [url]="assetProfileForm.get('url').value"
/> />
} }
</mat-form-field> </mat-form-field>

View File

@ -59,14 +59,12 @@ export class CreateAssetProfileDialog implements OnInit, OnDestroy {
this.mode === 'auto' this.mode === 'auto'
? this.dialogRef.close({ ? this.dialogRef.close({
dataSource: dataSource:
this.createAssetProfileForm.controls['searchSymbol'].value this.createAssetProfileForm.get('searchSymbol').value.dataSource,
.dataSource, symbol: this.createAssetProfileForm.get('searchSymbol').value.symbol
symbol:
this.createAssetProfileForm.controls['searchSymbol'].value.symbol
}) })
: this.dialogRef.close({ : this.dialogRef.close({
dataSource: 'MANUAL', dataSource: 'MANUAL',
symbol: this.createAssetProfileForm.controls['addSymbol'].value symbol: this.createAssetProfileForm.get('addSymbol').value
}); });
} }

View File

@ -143,9 +143,7 @@ export class AdminPlatformComponent implements OnInit, OnDestroy {
dialogRef dialogRef
.afterClosed() .afterClosed()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe((data) => { .subscribe((platform: CreatePlatformDto | null) => {
const platform: CreatePlatformDto = data?.platform;
if (platform) { if (platform) {
this.adminService this.adminService
.postPlatform(platform) .postPlatform(platform)
@ -182,9 +180,7 @@ export class AdminPlatformComponent implements OnInit, OnDestroy {
dialogRef dialogRef
.afterClosed() .afterClosed()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe((data) => { .subscribe((platform: UpdatePlatformDto | null) => {
const platform: UpdatePlatformDto = data?.platform;
if (platform) { if (platform) {
this.adminService this.adminService
.putPlatform(platform) .putPlatform(platform)

View File

@ -1,4 +1,14 @@
import { ChangeDetectionStrategy, Component, Inject } from '@angular/core'; import { CreatePlatformDto } from '@ghostfolio/api/app/platform/create-platform.dto';
import { UpdatePlatformDto } from '@ghostfolio/api/app/platform/update-platform.dto';
import { validateObjectForForm } from '@ghostfolio/client/util/form.util';
import {
ChangeDetectionStrategy,
Component,
Inject,
OnDestroy
} from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
@ -11,18 +21,54 @@ import { CreateOrUpdatePlatformDialogParams } from './interfaces/interfaces';
styleUrls: ['./create-or-update-platform-dialog.scss'], styleUrls: ['./create-or-update-platform-dialog.scss'],
templateUrl: 'create-or-update-platform-dialog.html' templateUrl: 'create-or-update-platform-dialog.html'
}) })
export class CreateOrUpdatePlatformDialog { export class CreateOrUpdatePlatformDialog implements OnDestroy {
public platformForm: FormGroup;
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
public constructor( public constructor(
@Inject(MAT_DIALOG_DATA) public data: CreateOrUpdatePlatformDialogParams, @Inject(MAT_DIALOG_DATA) public data: CreateOrUpdatePlatformDialogParams,
public dialogRef: MatDialogRef<CreateOrUpdatePlatformDialog> public dialogRef: MatDialogRef<CreateOrUpdatePlatformDialog>,
) {} private formBuilder: FormBuilder
) {
this.platformForm = this.formBuilder.group({
name: [this.data.platform.name, Validators.required],
url: [this.data.platform.url, Validators.required]
});
}
public onCancel() { public onCancel() {
this.dialogRef.close(); this.dialogRef.close();
} }
public async onSubmit() {
try {
const platform: CreatePlatformDto | UpdatePlatformDto = {
name: this.platformForm.get('name')?.value,
url: this.platformForm.get('url')?.value
};
if (this.data.platform.id) {
(platform as UpdatePlatformDto).id = this.data.platform.id;
await validateObjectForForm({
classDto: UpdatePlatformDto,
form: this.platformForm,
object: platform
});
} else {
await validateObjectForForm({
classDto: CreatePlatformDto,
form: this.platformForm,
object: platform
});
}
this.dialogRef.close(platform);
} catch (error) {
console.error(error);
}
}
public ngOnDestroy() { public ngOnDestroy() {
this.unsubscribeSubject.next(); this.unsubscribeSubject.next();
this.unsubscribeSubject.complete(); this.unsubscribeSubject.complete();

View File

@ -1,17 +1,30 @@
<form #addPlatformForm="ngForm" class="d-flex flex-column h-100"> <form
class="d-flex flex-column h-100"
[formGroup]="platformForm"
(keyup.enter)="platformForm.valid && onSubmit()"
(ngSubmit)="onSubmit()"
>
<h1 *ngIf="data.platform.id" i18n mat-dialog-title>Update platform</h1> <h1 *ngIf="data.platform.id" i18n mat-dialog-title>Update platform</h1>
<h1 *ngIf="!data.platform.id" i18n mat-dialog-title>Add platform</h1> <h1 *ngIf="!data.platform.id" i18n mat-dialog-title>Add platform</h1>
<div class="flex-grow-1 py-3" mat-dialog-content> <div class="flex-grow-1 py-3" mat-dialog-content>
<div> <div>
<mat-form-field appearance="outline" class="w-100"> <mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Name</mat-label> <mat-label i18n>Name</mat-label>
<input matInput name="name" required [(ngModel)]="data.platform.name" /> <input
formControlName="name"
matInput
(keydown.enter)="$event.stopPropagation()"
/>
</mat-form-field> </mat-form-field>
</div> </div>
<div> <div>
<mat-form-field appearance="outline" class="w-100"> <mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Url</mat-label> <mat-label i18n>Url</mat-label>
<input matInput name="url" required [(ngModel)]="data.platform.url" /> <input
formControlName="url"
matInput
(keydown.enter)="$event.stopPropagation()"
/>
@if (data.platform.url) { @if (data.platform.url) {
<gf-asset-profile-icon <gf-asset-profile-icon
class="mr-3" class="mr-3"
@ -23,12 +36,12 @@
</div> </div>
</div> </div>
<div class="justify-content-end" mat-dialog-actions> <div class="justify-content-end" mat-dialog-actions>
<button i18n mat-button (click)="onCancel()">Cancel</button> <button i18n mat-button type="button" (click)="onCancel()">Cancel</button>
<button <button
color="primary" color="primary"
mat-flat-button mat-flat-button
[disabled]="!addPlatformForm.form.valid" type="submit"
[mat-dialog-close]="data" [disabled]="!platformForm.valid"
> >
<ng-container i18n>Save</ng-container> <ng-container i18n>Save</ng-container>
</button> </button>

View File

@ -142,9 +142,7 @@ export class AdminTagComponent implements OnInit, OnDestroy {
dialogRef dialogRef
.afterClosed() .afterClosed()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe((data) => { .subscribe((tag: CreateTagDto | null) => {
const tag: CreateTagDto = data?.tag;
if (tag) { if (tag) {
this.adminService this.adminService
.postTag(tag) .postTag(tag)
@ -180,9 +178,7 @@ export class AdminTagComponent implements OnInit, OnDestroy {
dialogRef dialogRef
.afterClosed() .afterClosed()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe((data) => { .subscribe((tag: UpdateTagDto | null) => {
const tag: UpdateTagDto = data?.tag;
if (tag) { if (tag) {
this.adminService this.adminService
.putTag(tag) .putTag(tag)

View File

@ -1,4 +1,14 @@
import { ChangeDetectionStrategy, Component, Inject } from '@angular/core'; import { CreateTagDto } from '@ghostfolio/api/app/tag/create-tag.dto';
import { UpdateTagDto } from '@ghostfolio/api/app/tag/update-tag.dto';
import { validateObjectForForm } from '@ghostfolio/client/util/form.util';
import {
ChangeDetectionStrategy,
Component,
Inject,
OnDestroy
} from '@angular/core';
import { FormBuilder, FormGroup } from '@angular/forms';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
@ -11,18 +21,52 @@ import { CreateOrUpdateTagDialogParams } from './interfaces/interfaces';
styleUrls: ['./create-or-update-tag-dialog.scss'], styleUrls: ['./create-or-update-tag-dialog.scss'],
templateUrl: 'create-or-update-tag-dialog.html' templateUrl: 'create-or-update-tag-dialog.html'
}) })
export class CreateOrUpdateTagDialog { export class CreateOrUpdateTagDialog implements OnDestroy {
public tagForm: FormGroup;
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
public constructor( public constructor(
@Inject(MAT_DIALOG_DATA) public data: CreateOrUpdateTagDialogParams, @Inject(MAT_DIALOG_DATA) public data: CreateOrUpdateTagDialogParams,
public dialogRef: MatDialogRef<CreateOrUpdateTagDialog> public dialogRef: MatDialogRef<CreateOrUpdateTagDialog>,
) {} private formBuilder: FormBuilder
) {
this.tagForm = this.formBuilder.group({
name: [this.data.tag.name]
});
}
public onCancel() { public onCancel() {
this.dialogRef.close(); this.dialogRef.close();
} }
public async onSubmit() {
try {
const tag: CreateTagDto | UpdateTagDto = {
name: this.tagForm.get('name')?.value
};
if (this.data.tag.id) {
(tag as UpdateTagDto).id = this.data.tag.id;
await validateObjectForForm({
classDto: UpdateTagDto,
form: this.tagForm,
object: tag
});
} else {
await validateObjectForForm({
classDto: CreateTagDto,
form: this.tagForm,
object: tag
});
}
this.dialogRef.close(tag);
} catch (error) {
console.error(error);
}
}
public ngOnDestroy() { public ngOnDestroy() {
this.unsubscribeSubject.next(); this.unsubscribeSubject.next();
this.unsubscribeSubject.complete(); this.unsubscribeSubject.complete();

View File

@ -1,21 +1,30 @@
<form #addTagForm="ngForm" class="d-flex flex-column h-100"> <form
class="d-flex flex-column h-100"
[formGroup]="tagForm"
(keyup.enter)="tagForm.valid && onSubmit()"
(ngSubmit)="onSubmit()"
>
<h1 *ngIf="data.tag.id" i18n mat-dialog-title>Update tag</h1> <h1 *ngIf="data.tag.id" i18n mat-dialog-title>Update tag</h1>
<h1 *ngIf="!data.tag.id" i18n mat-dialog-title>Add tag</h1> <h1 *ngIf="!data.tag.id" i18n mat-dialog-title>Add tag</h1>
<div class="flex-grow-1 py-3" mat-dialog-content> <div class="flex-grow-1 py-3" mat-dialog-content>
<div> <div>
<mat-form-field appearance="outline" class="w-100"> <mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Name</mat-label> <mat-label i18n>Name</mat-label>
<input matInput name="name" required [(ngModel)]="data.tag.name" /> <input
formControlName="name"
matInput
(keydown.enter)="$event.stopPropagation()"
/>
</mat-form-field> </mat-form-field>
</div> </div>
</div> </div>
<div class="justify-content-end" mat-dialog-actions> <div class="justify-content-end" mat-dialog-actions>
<button i18n mat-button (click)="onCancel()">Cancel</button> <button i18n mat-button type="button" (click)="onCancel()">Cancel</button>
<button <button
color="primary" color="primary"
mat-flat-button mat-flat-button
[disabled]="!addTagForm.form.valid" type="submit"
[mat-dialog-close]="data" [disabled]="!tagForm.valid"
> >
<ng-container i18n>Save</ng-container> <ng-container i18n>Save</ng-container>
</button> </button>

View File

@ -116,7 +116,12 @@
#assistantTrigger="matMenuTrigger" #assistantTrigger="matMenuTrigger"
class="h-100 no-min-width px-2" class="h-100 no-min-width px-2"
mat-button mat-button
matBadge="✓"
matBadgeSize="small"
[mat-menu-trigger-for]="assistantMenu" [mat-menu-trigger-for]="assistantMenu"
[matBadgeHidden]="
!hasFilters || !user?.settings?.isExperimentalFeatures
"
[matMenuTriggerRestoreFocus]="false" [matMenuTriggerRestoreFocus]="false"
(menuOpened)="onOpenAssistant()" (menuOpened)="onOpenAssistant()"
> >

View File

@ -28,6 +28,17 @@
text-underline-offset: 0.25rem; text-underline-offset: 0.25rem;
} }
&.mat-badge {
::ng-deep {
.mat-badge-content {
--mat-badge-small-size-container-overlap-offset: -0.9rem;
--mat-badge-small-size-text-size: 0;
transform: scale(0.45);
}
}
}
ion-icon { ion-icon {
font-size: 1.5rem; font-size: 1.5rem;

View File

@ -65,6 +65,7 @@ export class HeaderComponent implements OnChanges {
@ViewChild('assistant') assistantElement: GfAssistantComponent; @ViewChild('assistant') assistantElement: GfAssistantComponent;
@ViewChild('assistantTrigger') assistentMenuTriggerElement: MatMenuTrigger; @ViewChild('assistantTrigger') assistentMenuTriggerElement: MatMenuTrigger;
public hasFilters: boolean;
public hasPermissionForSocialLogin: boolean; public hasPermissionForSocialLogin: boolean;
public hasPermissionForSubscription: boolean; public hasPermissionForSubscription: boolean;
public hasPermissionToAccessAdminControl: boolean; public hasPermissionToAccessAdminControl: boolean;
@ -106,6 +107,8 @@ export class HeaderComponent implements OnChanges {
} }
public ngOnChanges() { public ngOnChanges() {
this.hasFilters = this.userService.hasFilters();
this.hasPermissionForSocialLogin = hasPermission( this.hasPermissionForSocialLogin = hasPermission(
this.info?.globalPermissions, this.info?.globalPermissions,
permissions.enableSocialLogin permissions.enableSocialLogin

View File

@ -5,6 +5,7 @@ import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatBadgeModule } from '@angular/material/badge';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatMenuModule } from '@angular/material/menu'; import { MatMenuModule } from '@angular/material/menu';
import { MatToolbarModule } from '@angular/material/toolbar'; import { MatToolbarModule } from '@angular/material/toolbar';
@ -21,6 +22,7 @@ import { HeaderComponent } from './header.component';
GfLogoComponent, GfLogoComponent,
GfPremiumIndicatorComponent, GfPremiumIndicatorComponent,
LoginWithAccessTokenDialogModule, LoginWithAccessTokenDialogModule,
MatBadgeModule,
MatButtonModule, MatButtonModule,
MatMenuModule, MatMenuModule,
MatToolbarModule, MatToolbarModule,

View File

@ -1,33 +1,30 @@
import { PositionDetailDialog } from '@ghostfolio/client/components/position/position-detail-dialog/position-detail-dialog.component';
import { ToggleComponent } from '@ghostfolio/client/components/toggle/toggle.component';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { Position, User } from '@ghostfolio/common/interfaces'; import { PortfolioPosition, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { DateRange } from '@ghostfolio/common/types'; import { HoldingType, ToggleOption } from '@ghostfolio/common/types';
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { ActivatedRoute, Router } from '@angular/router';
import { DataSource } from '@prisma/client';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators'; import { takeUntil } from 'rxjs/operators';
import { PositionDetailDialogParams } from '../position/position-detail-dialog/interfaces/interfaces';
@Component({ @Component({
selector: 'gf-home-holdings', selector: 'gf-home-holdings',
styleUrls: ['./home-holdings.scss'], styleUrls: ['./home-holdings.scss'],
templateUrl: './home-holdings.html' templateUrl: './home-holdings.html'
}) })
export class HomeHoldingsComponent implements OnDestroy, OnInit { export class HomeHoldingsComponent implements OnDestroy, OnInit {
public dateRangeOptions = ToggleComponent.DEFAULT_DATE_RANGE_OPTIONS;
public deviceType: string; public deviceType: string;
public hasImpersonationId: boolean; public hasImpersonationId: boolean;
public hasPermissionToCreateOrder: boolean; public hasPermissionToCreateOrder: boolean;
public positions: Position[]; public holdings: PortfolioPosition[];
public holdingType: HoldingType = 'ACTIVE';
public holdingTypeOptions: ToggleOption[] = [
{ label: $localize`Active`, value: 'ACTIVE' },
{ label: $localize`Closed`, value: 'CLOSED' }
];
public user: User; public user: User;
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
@ -36,25 +33,18 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService, private dataService: DataService,
private deviceService: DeviceDetectorService, private deviceService: DeviceDetectorService,
private dialog: MatDialog,
private impersonationStorageService: ImpersonationStorageService, private impersonationStorageService: ImpersonationStorageService,
private route: ActivatedRoute,
private router: Router,
private userService: UserService private userService: UserService
) { ) {}
this.route.queryParams
public ngOnInit() {
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
this.impersonationStorageService
.onChangeHasImpersonation()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe((params) => { .subscribe((impersonationId) => {
if ( this.hasImpersonationId = !!impersonationId;
params['dataSource'] &&
params['positionDetailDialog'] &&
params['symbol']
) {
this.openPositionDialog({
dataSource: params['dataSource'],
symbol: params['symbol']
});
}
}); });
this.userService.stateChanged this.userService.stateChanged
@ -68,37 +58,32 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
permissions.createOrder permissions.createOrder
); );
this.update(); this.holdings = undefined;
this.fetchHoldings()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ holdings }) => {
this.holdings = holdings;
this.changeDetectorRef.markForCheck();
});
this.changeDetectorRef.markForCheck();
} }
}); });
} }
public ngOnInit() { public onChangeHoldingType(aHoldingType: HoldingType) {
this.deviceType = this.deviceService.getDeviceInfo().deviceType; this.holdingType = aHoldingType;
this.impersonationStorageService this.holdings = undefined;
.onChangeHasImpersonation()
this.fetchHoldings()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe((impersonationId) => { .subscribe(({ holdings }) => {
this.hasImpersonationId = !!impersonationId; this.holdings = holdings;
});
}
public onChangeDateRange(dateRange: DateRange) { this.changeDetectorRef.markForCheck();
this.dataService
.putUserSetting({ dateRange })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.userService.remove();
this.userService
.get()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((user) => {
this.user = user;
this.changeDetectorRef.markForCheck();
});
}); });
} }
@ -107,59 +92,16 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
this.unsubscribeSubject.complete(); this.unsubscribeSubject.complete();
} }
private openPositionDialog({ private fetchHoldings() {
dataSource, const filters = this.userService.getFilters();
symbol
}: {
dataSource: DataSource;
symbol: string;
}) {
this.userService
.get()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((user) => {
this.user = user;
const dialogRef = this.dialog.open(PositionDetailDialog, { if (this.holdingType === 'CLOSED') {
autoFocus: false, filters.push({ id: 'CLOSED', type: 'HOLDING_TYPE' });
data: <PositionDetailDialogParams>{ }
dataSource,
symbol,
baseCurrency: this.user?.settings?.baseCurrency,
colorScheme: this.user?.settings?.colorScheme,
deviceType: this.deviceType,
hasImpersonationId: this.hasImpersonationId,
hasPermissionToReportDataGlitch: hasPermission(
this.user?.permissions,
permissions.reportDataGlitch
),
locale: this.user?.settings?.locale
},
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
});
dialogRef return this.dataService.fetchPortfolioHoldings({
.afterClosed() filters,
.pipe(takeUntil(this.unsubscribeSubject)) range: this.user?.settings?.dateRange
.subscribe(() => { });
this.router.navigate(['.'], { relativeTo: this.route });
});
});
}
private update() {
this.positions = undefined;
this.dataService
.fetchPositions({ range: this.user?.settings?.dateRange })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ positions }) => {
this.positions = positions;
this.changeDetectorRef.markForCheck();
});
this.changeDetectorRef.markForCheck();
} }
} }

View File

@ -1,27 +1,38 @@
<div class="container justify-content-center p-3"> <div class="container">
<div class="row"> <div class="row">
<div class="align-items-center col-xs-12 col-md-8 offset-md-2"> <div class="col">
<mat-card appearance="outlined"> <h1 class="d-none d-sm-block h3 mb-3 text-center" i18n>Holdings</h1>
<mat-card-content class="p-0"> </div>
<gf-positions </div>
[baseCurrency]="user?.settings?.baseCurrency" <div class="row">
[deviceType]="deviceType" <div class="col-lg">
[hasPermissionToCreateOrder]="hasPermissionToCreateOrder" <div class="d-flex justify-content-end">
[locale]="user?.settings?.locale" <gf-toggle
[positions]="positions" class="d-none d-lg-block"
[range]="user?.settings?.dateRange" [defaultValue]="holdingType"
/> [isLoading]="false"
</mat-card-content> [options]="holdingTypeOptions"
</mat-card> (change)="onChangeHoldingType($event.value)"
<div *ngIf="hasPermissionToCreateOrder" class="text-center"> />
<a
class="mt-3"
i18n
mat-stroked-button
[routerLink]="['/portfolio', 'activities']"
>Manage Activities</a
>
</div> </div>
<gf-holdings-table
[baseCurrency]="user?.settings?.baseCurrency"
[deviceType]="deviceType"
[hasPermissionToCreateActivity]="hasPermissionToCreateOrder"
[holdings]="holdings"
[locale]="user?.settings?.locale"
/>
@if (hasPermissionToCreateOrder && holdings?.length > 0) {
<div class="text-center">
<a
class="mt-3"
i18n
mat-stroked-button
[routerLink]="['/portfolio', 'activities']"
>Manage Activities</a
>
</div>
}
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,11 +1,9 @@
import { GfPositionDetailDialogModule } from '@ghostfolio/client/components/position/position-detail-dialog/position-detail-dialog.module';
import { GfPositionsModule } from '@ghostfolio/client/components/positions/positions.module';
import { GfToggleModule } from '@ghostfolio/client/components/toggle/toggle.module'; import { GfToggleModule } from '@ghostfolio/client/components/toggle/toggle.module';
import { GfHoldingsTableComponent } from '@ghostfolio/ui/holdings-table';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { RouterModule } from '@angular/router'; import { RouterModule } from '@angular/router';
import { HomeHoldingsComponent } from './home-holdings.component'; import { HomeHoldingsComponent } from './home-holdings.component';
@ -14,11 +12,9 @@ import { HomeHoldingsComponent } from './home-holdings.component';
declarations: [HomeHoldingsComponent], declarations: [HomeHoldingsComponent],
imports: [ imports: [
CommonModule, CommonModule,
GfPositionDetailDialogModule, GfHoldingsTableComponent,
GfPositionsModule,
GfToggleModule, GfToggleModule,
MatButtonModule, MatButtonModule,
MatCardModule,
RouterModule RouterModule
], ],
schemas: [CUSTOM_ELEMENTS_SCHEMA] schemas: [CUSTOM_ELEMENTS_SCHEMA]

View File

@ -11,7 +11,7 @@
[colorScheme]="user?.settings?.colorScheme" [colorScheme]="user?.settings?.colorScheme"
[historicalDataItems]="historicalDataItems" [historicalDataItems]="historicalDataItems"
[isAnimated]="true" [isAnimated]="true"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale || undefined"
[showXAxis]="true" [showXAxis]="true"
[showYAxis]="true" [showYAxis]="true"
[yMax]="100" [yMax]="100"
@ -30,7 +30,7 @@
<div class="col-xs-12 col-md-8 offset-md-2"> <div class="col-xs-12 col-md-8 offset-md-2">
<gf-benchmark <gf-benchmark
[benchmarks]="benchmarks" [benchmarks]="benchmarks"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale || undefined"
[user]="user" [user]="user"
/> />
<ngx-skeleton-loader <ngx-skeleton-loader

View File

@ -41,9 +41,7 @@
[isCurrency]="true" [isCurrency]="true"
[locale]="locale" [locale]="locale"
[value]=" [value]="
isLoading isLoading ? undefined : performance?.netPerformanceWithCurrencyEffect
? undefined
: performance?.currentNetPerformanceWithCurrencyEffect
" "
/> />
</div> </div>
@ -55,7 +53,7 @@
[value]=" [value]="
isLoading isLoading
? undefined ? undefined
: performance?.currentNetPerformancePercentWithCurrencyEffect : performance?.netPerformancePercentageWithCurrencyEffect
" "
/> />
</div> </div>

View File

@ -49,12 +49,12 @@ export class PortfolioPerformanceComponent implements OnChanges, OnInit {
this.value.nativeElement.innerHTML = ''; this.value.nativeElement.innerHTML = '';
} }
} else { } else {
if (isNumber(this.performance?.currentValue)) { if (isNumber(this.performance?.currentValueInBaseCurrency)) {
new CountUp('value', this.performance?.currentValue, { new CountUp('value', this.performance?.currentValueInBaseCurrency, {
decimal: getNumberFormatDecimal(this.locale), decimal: getNumberFormatDecimal(this.locale),
decimalPlaces: decimalPlaces:
this.deviceType === 'mobile' && this.deviceType === 'mobile' &&
this.performance?.currentValue >= 100000 this.performance?.currentValueInBaseCurrency >= 100000
? 0 ? 0
: 2, : 2,
duration: 1, duration: 1,
@ -63,8 +63,7 @@ export class PortfolioPerformanceComponent implements OnChanges, OnInit {
} else if (this.showDetails === false) { } else if (this.showDetails === false) {
new CountUp( new CountUp(
'value', 'value',
this.performance?.currentNetPerformancePercentWithCurrencyEffect * this.performance?.netPerformancePercentageWithCurrencyEffect * 100,
100,
{ {
decimal: getNumberFormatDecimal(this.locale), decimal: getNumberFormatDecimal(this.locale),
decimalPlaces: 2, decimalPlaces: 2,

View File

@ -9,9 +9,19 @@
class="flex-nowrap px-3 py-1 row" class="flex-nowrap px-3 py-1 row"
[hidden]="summary?.ordersCount === null" [hidden]="summary?.ordersCount === null"
> >
<div class="flex-grow-1 ml-3 text-truncate" i18n> <div class="d-flex flex-grow-1 ml-3 text-truncate">
{{ summary?.ordersCount }} {{ summary?.ordersCount }}
{summary?.ordersCount, plural, =1 {transaction} other {transactions}} <ng-container i18n>{summary?.ordersCount, plural,
=1 {activity}
other {activities}
}</ng-container>
<span
class="align-items-center d-flex ml-1"
matTooltipPosition="above"
[matTooltip]="buyAndSellActivitiesTooltip"
>
<ion-icon name="information-circle-outline" />
</span>
</div> </div>
</div> </div>
<div class="row"> <div class="row">
@ -65,9 +75,7 @@
[locale]="locale" [locale]="locale"
[unit]="baseCurrency" [unit]="baseCurrency"
[value]=" [value]="
isLoading isLoading ? undefined : summary?.grossPerformanceWithCurrencyEffect
? undefined
: summary?.currentGrossPerformanceWithCurrencyEffect
" "
/> />
</div> </div>
@ -91,7 +99,7 @@
[value]=" [value]="
isLoading isLoading
? undefined ? undefined
: summary?.currentGrossPerformancePercentWithCurrencyEffect : summary?.grossPerformancePercentageWithCurrencyEffect
" "
/> />
</div> </div>
@ -121,9 +129,7 @@
[locale]="locale" [locale]="locale"
[unit]="baseCurrency" [unit]="baseCurrency"
[value]=" [value]="
isLoading isLoading ? undefined : summary?.netPerformanceWithCurrencyEffect
? undefined
: summary?.currentNetPerformanceWithCurrencyEffect
" "
/> />
</div> </div>
@ -147,7 +153,7 @@
[value]=" [value]="
isLoading isLoading
? undefined ? undefined
: summary?.currentNetPerformancePercentWithCurrencyEffect : summary?.netPerformancePercentageWithCurrencyEffect
" "
/> />
</div> </div>
@ -164,7 +170,7 @@
[isCurrency]="true" [isCurrency]="true"
[locale]="locale" [locale]="locale"
[unit]="baseCurrency" [unit]="baseCurrency"
[value]="isLoading ? undefined : summary?.currentValue" [value]="isLoading ? undefined : summary?.currentValueInBaseCurrency"
/> />
</div> </div>
</div> </div>

View File

@ -1,5 +1,6 @@
import { getDateFnsLocale, getLocale } from '@ghostfolio/common/helper'; import { getDateFnsLocale, getLocale } from '@ghostfolio/common/helper';
import { PortfolioSummary } from '@ghostfolio/common/interfaces'; import { PortfolioSummary } from '@ghostfolio/common/interfaces';
import { translate } from '@ghostfolio/ui/i18n';
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
@ -28,6 +29,9 @@ export class PortfolioSummaryComponent implements OnChanges, OnInit {
@Output() emergencyFundChanged = new EventEmitter<number>(); @Output() emergencyFundChanged = new EventEmitter<number>();
public buyAndSellActivitiesTooltip = translate(
'BUY_AND_SELL_ACTIVITIES_TOOLTIP'
);
public timeInMarket: string; public timeInMarket: string;
public constructor() {} public constructor() {}

View File

@ -2,13 +2,14 @@ import { GfValueComponent } from '@ghostfolio/ui/value';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatTooltipModule } from '@angular/material/tooltip';
import { PortfolioSummaryComponent } from './portfolio-summary.component'; import { PortfolioSummaryComponent } from './portfolio-summary.component';
@NgModule({ @NgModule({
declarations: [PortfolioSummaryComponent], declarations: [PortfolioSummaryComponent],
exports: [PortfolioSummaryComponent], exports: [PortfolioSummaryComponent],
imports: [CommonModule, GfValueComponent], imports: [CommonModule, GfValueComponent, MatTooltipModule],
schemas: [CUSTOM_ELEMENTS_SCHEMA] schemas: [CUSTOM_ELEMENTS_SCHEMA]
}) })
export class GfPortfolioSummaryModule {} export class GfPortfolioSummaryModule {}

View File

@ -56,6 +56,8 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
public marketPrice: number; public marketPrice: number;
public maxPrice: number; public maxPrice: number;
public minPrice: number; public minPrice: number;
public netPerformance: number;
public netPerformancePercent: number;
public netPerformancePercentWithCurrencyEffect: number; public netPerformancePercentWithCurrencyEffect: number;
public netPerformanceWithCurrencyEffect: number; public netPerformanceWithCurrencyEffect: number;
public quantity: number; public quantity: number;
@ -104,6 +106,8 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
marketPrice, marketPrice,
maxPrice, maxPrice,
minPrice, minPrice,
netPerformance,
netPerformancePercent,
netPerformancePercentWithCurrencyEffect, netPerformancePercentWithCurrencyEffect,
netPerformanceWithCurrencyEffect, netPerformanceWithCurrencyEffect,
orders, orders,
@ -126,15 +130,15 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
this.feeInBaseCurrency = feeInBaseCurrency; this.feeInBaseCurrency = feeInBaseCurrency;
this.firstBuyDate = firstBuyDate; this.firstBuyDate = firstBuyDate;
this.historicalDataItems = historicalData.map( this.historicalDataItems = historicalData.map(
(historicalDataItem) => { ({ averagePrice, date, marketPrice }) => {
this.benchmarkDataItems.push({ this.benchmarkDataItems.push({
date: historicalDataItem.date, date,
value: historicalDataItem.averagePrice value: averagePrice
}); });
return { return {
date: historicalDataItem.date, date,
value: historicalDataItem.marketPrice value: marketPrice
}; };
} }
); );
@ -142,6 +146,8 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
this.marketPrice = marketPrice; this.marketPrice = marketPrice;
this.maxPrice = maxPrice; this.maxPrice = maxPrice;
this.minPrice = minPrice; this.minPrice = minPrice;
this.netPerformance = netPerformance;
this.netPerformancePercent = netPerformancePercent;
this.netPerformancePercentWithCurrencyEffect = this.netPerformancePercentWithCurrencyEffect =
netPerformancePercentWithCurrencyEffect; netPerformancePercentWithCurrencyEffect;
this.netPerformanceWithCurrencyEffect = this.netPerformanceWithCurrencyEffect =

View File

@ -37,27 +37,58 @@
<div class="row"> <div class="row">
<div class="col-6 mb-3"> <div class="col-6 mb-3">
<gf-value @if (
i18n SymbolProfile?.currency &&
size="medium" data.baseCurrency !== SymbolProfile?.currency
[colorizeSign]="true" ) {
[isCurrency]="true" <gf-value
[locale]="data.locale" i18n
[unit]="data.baseCurrency" size="medium"
[value]="netPerformanceWithCurrencyEffect" [colorizeSign]="true"
>Change</gf-value [isCurrency]="true"
> [locale]="data.locale"
[unit]="data.baseCurrency"
[value]="netPerformanceWithCurrencyEffect"
>Change with currency effect</gf-value
>
} @else {
<gf-value
i18n
size="medium"
[colorizeSign]="true"
[isCurrency]="true"
[locale]="data.locale"
[unit]="data.baseCurrency"
[value]="netPerformance"
>Change</gf-value
>
}
</div> </div>
<div class="col-6 mb-3"> <div class="col-6 mb-3">
<gf-value @if (
i18n SymbolProfile?.currency &&
size="medium" data.baseCurrency !== SymbolProfile?.currency
[colorizeSign]="true" ) {
[isPercent]="true" <gf-value
[locale]="data.locale" i18n
[value]="netPerformancePercentWithCurrencyEffect" size="medium"
>Performance</gf-value [colorizeSign]="true"
> [isPercent]="true"
[locale]="data.locale"
[value]="netPerformancePercentWithCurrencyEffect"
>Performance with currency effect</gf-value
>
} @else {
<gf-value
i18n
size="medium"
[colorizeSign]="true"
[isPercent]="true"
[locale]="data.locale"
[value]="netPerformancePercent"
>Performance</gf-value
>
}
</div> </div>
<div class="col-6 mb-3"> <div class="col-6 mb-3">
<gf-value <gf-value
@ -304,6 +335,7 @@
[dataSource]="dataSource" [dataSource]="dataSource"
[deviceType]="data.deviceType" [deviceType]="data.deviceType"
[hasPermissionToCreateActivity]="false" [hasPermissionToCreateActivity]="false"
[hasPermissionToDeleteActivity]="false"
[hasPermissionToExportActivities]=" [hasPermissionToExportActivities]="
!data.hasImpersonationId && !user?.settings?.isRestrictedView !data.hasImpersonationId && !user?.settings?.isRestrictedView
" "

View File

@ -1,72 +0,0 @@
<div class="container p-0">
<div class="flex-nowrap no-gutters row">
<a
class="d-flex p-3 w-100"
[ngClass]="{ 'cursor-default': isLoading }"
[queryParams]="{
dataSource: position?.dataSource,
positionDetailDialog: true,
symbol: position?.symbol
}"
[routerLink]="[]"
>
<div class="d-flex mr-2">
<gf-trend-indicator
class="d-flex"
size="large"
[isLoading]="isLoading"
[marketState]="position?.marketState"
[range]="range"
[value]="position?.netPerformancePercentageWithCurrencyEffect"
/>
</div>
<div *ngIf="isLoading" class="flex-grow-1">
<ngx-skeleton-loader
animation="pulse"
class="mb-1"
[theme]="{
height: '1.2rem',
width: '12rem'
}"
/>
<ngx-skeleton-loader
animation="pulse"
[theme]="{
height: '1rem',
width: '8rem'
}"
/>
</div>
<div *ngIf="!isLoading" class="flex-grow-1 text-truncate">
<div class="h6 m-0 text-truncate">{{ position?.name }}</div>
<div class="d-flex">
<small class="text-muted">{{ position?.symbol | gfSymbol }}</small>
</div>
<div class="d-flex mt-1">
<gf-value
class="mr-3"
[colorizeSign]="true"
[isCurrency]="true"
[locale]="locale"
[unit]="baseCurrency"
[value]="position?.netPerformanceWithCurrencyEffect"
/>
<gf-value
[colorizeSign]="true"
[isPercent]="true"
[locale]="locale"
[value]="position?.netPerformancePercentageWithCurrencyEffect"
/>
</div>
</div>
<div class="align-items-center d-flex">
<ion-icon
*ngIf="!isLoading"
class="chevron text-muted"
name="chevron-forward-outline"
size="small"
/>
</div>
</a>
</div>
</div>

View File

@ -1,13 +0,0 @@
:host {
display: block;
.container {
gf-trend-indicator {
padding-top: 0.15rem;
}
.chevron {
opacity: 0.33;
}
}
}

View File

@ -1,40 +0,0 @@
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
import { getLocale } from '@ghostfolio/common/helper';
import { Position } from '@ghostfolio/common/interfaces';
import {
ChangeDetectionStrategy,
Component,
Input,
OnDestroy,
OnInit
} from '@angular/core';
import { Subject } from 'rxjs';
@Component({
selector: 'gf-position',
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './position.component.html',
styleUrls: ['./position.component.scss']
})
export class PositionComponent implements OnDestroy, OnInit {
@Input() baseCurrency: string;
@Input() deviceType: string;
@Input() isLoading: boolean;
@Input() locale = getLocale();
@Input() position: Position;
@Input() range: string;
public unknownKey = UNKNOWN_KEY;
private unsubscribeSubject = new Subject<void>();
public constructor() {}
public ngOnInit() {}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
}

View File

@ -1,29 +0,0 @@
import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module';
import { GfTrendIndicatorComponent } from '@ghostfolio/ui/trend-indicator';
import { GfValueComponent } from '@ghostfolio/ui/value';
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatDialogModule } from '@angular/material/dialog';
import { RouterModule } from '@angular/router';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { GfPositionDetailDialogModule } from './position-detail-dialog/position-detail-dialog.module';
import { PositionComponent } from './position.component';
@NgModule({
declarations: [PositionComponent],
exports: [PositionComponent],
imports: [
CommonModule,
GfPositionDetailDialogModule,
GfSymbolModule,
GfTrendIndicatorComponent,
GfValueComponent,
MatDialogModule,
NgxSkeletonLoaderModule,
RouterModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class GfPositionModule {}

View File

@ -1,35 +0,0 @@
<div class="container p-0">
<div class="row no-gutters">
<div class="col">
<ng-container *ngIf="positions === undefined">
<gf-position [isLoading]="true" />
</ng-container>
<ng-container *ngIf="positions !== undefined">
<ng-container *ngIf="hasPositions">
<gf-position
*ngFor="let position of positionsWithPriority"
[baseCurrency]="baseCurrency"
[deviceType]="deviceType"
[locale]="locale"
[position]="position"
[range]="range"
/>
<gf-position
*ngFor="let position of positionsRest"
[baseCurrency]="baseCurrency"
[deviceType]="deviceType"
[locale]="locale"
[position]="position"
[range]="range"
/>
</ng-container>
<div
*ngIf="hasPermissionToCreateOrder && !hasPositions"
class="p-3 text-center"
>
<gf-no-transactions-info-indicator [hasBorder]="false" />
</div>
</ng-container>
</div>
</div>
</div>

View File

@ -1,17 +0,0 @@
:host {
display: block;
gf-position {
&:nth-child(even) {
background-color: rgba(0, 0, 0, var(--gf-theme-alpha-hover));
}
}
}
:host-context(.is-dark-theme) {
gf-position {
&:nth-child(even) {
background-color: rgba(255, 255, 255, var(--gf-theme-alpha-hover));
}
}
}

View File

@ -1,70 +0,0 @@
import { getLocale } from '@ghostfolio/common/helper';
import { Position } from '@ghostfolio/common/interfaces';
import {
ChangeDetectionStrategy,
Component,
Input,
OnChanges,
OnInit
} from '@angular/core';
@Component({
selector: 'gf-positions',
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './positions.component.html',
styleUrls: ['./positions.component.scss']
})
export class PositionsComponent implements OnChanges, OnInit {
@Input() baseCurrency: string;
@Input() deviceType: string;
@Input() hasPermissionToCreateOrder: boolean;
@Input() locale = getLocale();
@Input() positions: Position[];
@Input() range: string;
public hasPositions: boolean;
public positionsRest: Position[] = [];
public positionsWithPriority: Position[] = [];
public constructor() {}
public ngOnInit() {}
public ngOnChanges() {
if (this.positions) {
this.hasPositions = this.positions.length > 0;
if (!this.hasPositions) {
return;
}
this.positionsRest = [];
this.positionsWithPriority = [];
for (const portfolioPosition of this.positions) {
if (portfolioPosition.marketState === 'open' || this.range !== '1d') {
// Only show positions where the market is open in today's view
this.positionsWithPriority.push(portfolioPosition);
} else {
this.positionsRest.push(portfolioPosition);
}
}
this.positionsRest.sort((a, b) =>
(a.name || a.symbol)?.toLowerCase() >
(b.name || b.symbol)?.toLowerCase()
? 1
: -1
);
this.positionsWithPriority.sort((a, b) =>
(a.name || a.symbol)?.toLowerCase() >
(b.name || b.symbol)?.toLowerCase()
? 1
: -1
);
} else {
this.hasPositions = false;
}
}
}

View File

@ -1,21 +0,0 @@
import { GfNoTransactionsInfoComponent } from '@ghostfolio/ui/no-transactions-info';
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { GfPositionModule } from '../position/position.module';
import { PositionsComponent } from './positions.component';
@NgModule({
declarations: [PositionsComponent],
exports: [PositionsComponent],
imports: [
CommonModule,
GfNoTransactionsInfoComponent,
GfPositionModule,
MatButtonModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class GfPositionsModule {}

View File

@ -6,7 +6,6 @@ import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card'; import { MatCardModule } from '@angular/material/card';
import { GfPositionModule } from '../position/position.module';
import { RulesComponent } from './rules.component'; import { RulesComponent } from './rules.component';
@NgModule({ @NgModule({
@ -15,7 +14,6 @@ import { RulesComponent } from './rules.component';
imports: [ imports: [
CommonModule, CommonModule,
GfNoTransactionsInfoComponent, GfNoTransactionsInfoComponent,
GfPositionModule,
GfRuleModule, GfRuleModule,
MatButtonModule, MatButtonModule,
MatCardModule MatCardModule

View File

@ -1,5 +1,6 @@
import { CreateAccessDto } from '@ghostfolio/api/app/access/create-access.dto'; import { CreateAccessDto } from '@ghostfolio/api/app/access/create-access.dto';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { validateObjectForForm } from '@ghostfolio/client/util/form.util';
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
@ -40,22 +41,22 @@ export class CreateOrUpdateAccessDialog implements OnDestroy {
alias: [this.data.access.alias], alias: [this.data.access.alias],
permissions: [this.data.access.permissions[0], Validators.required], permissions: [this.data.access.permissions[0], Validators.required],
type: [this.data.access.type, Validators.required], type: [this.data.access.type, Validators.required],
userId: [this.data.access.grantee, Validators.required] granteeUserId: [this.data.access.grantee, Validators.required]
}); });
this.accessForm.get('type').valueChanges.subscribe((accessType) => { this.accessForm.get('type').valueChanges.subscribe((accessType) => {
const granteeUserIdControl = this.accessForm.get('granteeUserId');
const permissionsControl = this.accessForm.get('permissions'); const permissionsControl = this.accessForm.get('permissions');
const userIdControl = this.accessForm.get('userId');
if (accessType === 'PRIVATE') { if (accessType === 'PRIVATE') {
granteeUserIdControl.setValidators(Validators.required);
permissionsControl.setValidators(Validators.required); permissionsControl.setValidators(Validators.required);
userIdControl.setValidators(Validators.required);
} else { } else {
userIdControl.clearValidators(); granteeUserIdControl.clearValidators();
} }
granteeUserIdControl.updateValueAndValidity();
permissionsControl.updateValueAndValidity(); permissionsControl.updateValueAndValidity();
userIdControl.updateValueAndValidity();
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
}); });
@ -65,28 +66,38 @@ export class CreateOrUpdateAccessDialog implements OnDestroy {
this.dialogRef.close(); this.dialogRef.close();
} }
public onSubmit() { public async onSubmit() {
const access: CreateAccessDto = { const access: CreateAccessDto = {
alias: this.accessForm.controls['alias'].value, alias: this.accessForm.get('alias').value,
granteeUserId: this.accessForm.controls['userId'].value, granteeUserId: this.accessForm.get('granteeUserId').value,
permissions: [this.accessForm.controls['permissions'].value] permissions: [this.accessForm.get('permissions').value]
}; };
this.dataService try {
.postAccess(access) await validateObjectForForm({
.pipe( classDto: CreateAccessDto,
catchError((error) => { form: this.accessForm,
if (error.status === StatusCodes.BAD_REQUEST) { object: access
alert($localize`Oops! Could not grant access.`);
}
return EMPTY;
}),
takeUntil(this.unsubscribeSubject)
)
.subscribe(() => {
this.dialogRef.close({ access });
}); });
this.dataService
.postAccess(access)
.pipe(
catchError((error) => {
if (error.status === StatusCodes.BAD_REQUEST) {
alert($localize`Oops! Could not grant access.`);
}
return EMPTY;
}),
takeUntil(this.unsubscribeSubject)
)
.subscribe(() => {
this.dialogRef.close(access);
});
} catch (error) {
console.error(error);
}
} }
public ngOnDestroy() { public ngOnDestroy() {

View File

@ -27,7 +27,7 @@
</mat-form-field> </mat-form-field>
</div> </div>
@if (accessForm.controls['type'].value === 'PRIVATE') { @if (accessForm.get('type').value === 'PRIVATE') {
<div> <div>
<mat-form-field appearance="outline" class="w-100"> <mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Permission</mat-label> <mat-label i18n>Permission</mat-label>
@ -45,7 +45,7 @@
Ghostfolio <ng-container i18n>User ID</ng-container> Ghostfolio <ng-container i18n>User ID</ng-container>
</mat-label> </mat-label>
<input <input
formControlName="userId" formControlName="granteeUserId"
matInput matInput
type="text" type="text"
(keydown.enter)="$event.stopPropagation()" (keydown.enter)="$event.stopPropagation()"

View File

@ -1,3 +1,4 @@
import { CreateAccessDto } from '@ghostfolio/api/app/access/create-access.dto';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { Access, User } from '@ghostfolio/common/interfaces'; import { Access, User } from '@ghostfolio/common/interfaces';
@ -20,6 +21,7 @@ import { CreateOrUpdateAccessDialog } from './create-or-update-access-dialog/cre
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'has-fab' },
selector: 'gf-user-account-access', selector: 'gf-user-account-access',
styleUrls: ['./user-account-access.scss'], styleUrls: ['./user-account-access.scss'],
templateUrl: './user-account-access.html' templateUrl: './user-account-access.html'
@ -113,7 +115,7 @@ export class UserAccountAccessComponent implements OnDestroy, OnInit {
width: this.deviceType === 'mobile' ? '100vw' : '50rem' width: this.deviceType === 'mobile' ? '100vw' : '50rem'
}); });
dialogRef.afterClosed().subscribe((access) => { dialogRef.afterClosed().subscribe((access: CreateAccessDto | null) => {
if (access) { if (access) {
this.update(); this.update();
} }

View File

@ -54,9 +54,10 @@ export class AuthGuard {
this.router.navigate(['/' + $localize`register`]); this.router.navigate(['/' + $localize`register`]);
resolve(false); resolve(false);
} else if ( } else if (
AuthGuard.PUBLIC_PAGE_ROUTES.filter((publicPageRoute) => AuthGuard.PUBLIC_PAGE_ROUTES.filter((publicPageRoute) => {
state.url.startsWith(publicPageRoute) const [, url] = state.url.split('/');
)?.length > 0 return `/${url}` === publicPageRoute;
})?.length > 0
) { ) {
resolve(true); resolve(true);
return EMPTY; return EMPTY;

View File

@ -77,7 +77,7 @@
mat-icon-button mat-icon-button
title="Follow Ghostfolio on X (formerly Twitter)" title="Follow Ghostfolio on X (formerly Twitter)"
> >
<span class="line-height-1 text-center w-100">𝕏</span> <ion-icon name="logo-x" />
</a> </a>
<a <a
*ngIf="user?.subscription?.type === 'Premium'" *ngIf="user?.subscription?.type === 'Premium'"

View File

@ -21,7 +21,7 @@ import { CreateOrUpdateAccountDialog } from './create-or-update-account-dialog/c
import { TransferBalanceDialog } from './transfer-balance/transfer-balance-dialog.component'; import { TransferBalanceDialog } from './transfer-balance/transfer-balance-dialog.component';
@Component({ @Component({
host: { class: 'page' }, host: { class: 'has-fab page' },
selector: 'gf-accounts-page', selector: 'gf-accounts-page',
styleUrls: ['./accounts-page.scss'], styleUrls: ['./accounts-page.scss'],
templateUrl: './accounts-page.html' templateUrl: './accounts-page.html'
@ -189,9 +189,7 @@ export class AccountsPageComponent implements OnDestroy, OnInit {
dialogRef dialogRef
.afterClosed() .afterClosed()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe((data: any) => { .subscribe((account: UpdateAccountDto | null) => {
const account: UpdateAccountDto = data?.account;
if (account) { if (account) {
this.dataService this.dataService
.putAccount(account) .putAccount(account)
@ -258,9 +256,7 @@ export class AccountsPageComponent implements OnDestroy, OnInit {
dialogRef dialogRef
.afterClosed() .afterClosed()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe((data: any) => { .subscribe((account: CreateAccountDto | null) => {
const account: CreateAccountDto = data?.account;
if (account) { if (account) {
this.dataService this.dataService
.postAccount(account) .postAccount(account)

View File

@ -82,7 +82,7 @@ export class CreateOrUpdateAccountDialog implements OnDestroy {
} }
public autoCompleteCheck() { public autoCompleteCheck() {
const inputValue = this.accountForm.controls['platformId'].value; const inputValue = this.accountForm.get('platformId').value;
if (typeof inputValue === 'string') { if (typeof inputValue === 'string') {
const matchingEntry = this.platforms.find(({ name }) => { const matchingEntry = this.platforms.find(({ name }) => {
@ -90,7 +90,7 @@ export class CreateOrUpdateAccountDialog implements OnDestroy {
}); });
if (matchingEntry) { if (matchingEntry) {
this.accountForm.controls['platformId'].setValue(matchingEntry); this.accountForm.get('platformId').setValue(matchingEntry);
} }
} }
} }
@ -105,13 +105,13 @@ export class CreateOrUpdateAccountDialog implements OnDestroy {
public async onSubmit() { public async onSubmit() {
const account: CreateAccountDto | UpdateAccountDto = { const account: CreateAccountDto | UpdateAccountDto = {
balance: this.accountForm.controls['balance'].value, balance: this.accountForm.get('balance').value,
comment: this.accountForm.controls['comment'].value, comment: this.accountForm.get('comment').value || null,
currency: this.accountForm.controls['currency'].value?.value, currency: this.accountForm.get('currency').value?.value,
id: this.accountForm.controls['accountId'].value, id: this.accountForm.get('accountId').value,
isExcluded: this.accountForm.controls['isExcluded'].value, isExcluded: this.accountForm.get('isExcluded').value,
name: this.accountForm.controls['name'].value, name: this.accountForm.get('name').value,
platformId: this.accountForm.controls['platformId'].value?.id ?? null platformId: this.accountForm.get('platformId').value?.id || null
}; };
try { try {
@ -123,6 +123,8 @@ export class CreateOrUpdateAccountDialog implements OnDestroy {
form: this.accountForm, form: this.accountForm,
object: account object: account
}); });
this.dialogRef.close(account as UpdateAccountDto);
} else { } else {
delete (account as CreateAccountDto).id; delete (account as CreateAccountDto).id;
@ -131,9 +133,9 @@ export class CreateOrUpdateAccountDialog implements OnDestroy {
form: this.accountForm, form: this.accountForm,
object: account object: account
}); });
}
this.dialogRef.close({ account }); this.dialogRef.close(account as CreateAccountDto);
}
} catch (error) { } catch (error) {
console.error(error); console.error(error);
} }

View File

@ -39,7 +39,7 @@
(keydown.enter)="$event.stopPropagation()" (keydown.enter)="$event.stopPropagation()"
/> />
<span class="ml-2" matTextSuffix>{{ <span class="ml-2" matTextSuffix>{{
accountForm.controls['currency']?.value?.value accountForm.get('currency')?.value?.value
}}</span> }}</span>
</mat-form-field> </mat-form-field>
</div> </div>

View File

@ -66,9 +66,9 @@ export class TransferBalanceDialog implements OnDestroy {
public onSubmit() { public onSubmit() {
const account: TransferBalanceDto = { const account: TransferBalanceDto = {
accountIdFrom: this.transferBalanceForm.controls['fromAccount'].value, accountIdFrom: this.transferBalanceForm.get('fromAccount').value,
accountIdTo: this.transferBalanceForm.controls['toAccount'].value, accountIdTo: this.transferBalanceForm.get('toAccount').value,
balance: this.transferBalanceForm.controls['balance'].value balance: this.transferBalanceForm.get('balance').value
}; };
this.dialogRef.close({ account }); this.dialogRef.close({ account });

Some files were not shown because too many files have changed in this diff Show More