Browse Source

Merge branch 'main' into merge/MR-Branch-Upstream-in-dockerpush-04-24

pull/5027/head
Dan 1 year ago
parent
commit
8782ef4c46
  1. 113
      CHANGELOG.md
  2. 38
      README.md
  3. 41
      apps/api/src/app/account-balance/account-balance.controller.ts
  4. 3
      apps/api/src/app/account-balance/account-balance.module.ts
  5. 62
      apps/api/src/app/account-balance/account-balance.service.ts
  6. 12
      apps/api/src/app/account-balance/create-account-balance.dto.ts
  7. 76
      apps/api/src/app/account/account.service.ts
  8. 2
      apps/api/src/app/admin/admin.service.ts
  9. 6
      apps/api/src/app/app.module.ts
  10. 27
      apps/api/src/app/auth/jwt.strategy.ts
  11. 5
      apps/api/src/app/import/import.service.ts
  12. 23
      apps/api/src/app/order/order.controller.ts
  13. 39
      apps/api/src/app/order/order.service.ts
  14. 8
      apps/api/src/app/portfolio/calculator/mwr/portfolio-calculator.ts
  15. 4
      apps/api/src/app/portfolio/calculator/portfolio-calculator-test-utils.ts
  16. 35
      apps/api/src/app/portfolio/calculator/portfolio-calculator.factory.ts
  17. 280
      apps/api/src/app/portfolio/calculator/portfolio-calculator.ts
  18. 29
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-baln-buy-and-sell-in-two-activities.spec.ts
  19. 29
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-baln-buy-and-sell.spec.ts
  20. 29
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-baln-buy.spec.ts
  21. 29
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts
  22. 29
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-fee.spec.ts
  23. 29
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-googl-buy.spec.ts
  24. 29
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-item.spec.ts
  25. 88
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-liability.spec.ts
  26. 29
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-msft-buy-with-dividend.spec.ts
  27. 34
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-no-orders.spec.ts
  28. 29
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-novn-buy-and-sell-partially.spec.ts
  29. 33
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-novn-buy-and-sell.spec.ts
  30. 12
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator.spec.ts
  31. 64
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator.ts
  32. 7
      apps/api/src/app/portfolio/current-rate.service.mock.ts
  33. 5
      apps/api/src/app/portfolio/interfaces/portfolio-positions.interface.ts
  34. 24
      apps/api/src/app/portfolio/interfaces/portfolio-snapshot.interface.ts
  35. 115
      apps/api/src/app/portfolio/portfolio.controller.ts
  36. 2
      apps/api/src/app/portfolio/portfolio.module.ts
  37. 10
      apps/api/src/app/portfolio/portfolio.service.spec.ts
  38. 280
      apps/api/src/app/portfolio/portfolio.service.ts
  39. 13
      apps/api/src/app/redis-cache/redis-cache.service.mock.ts
  40. 4
      apps/api/src/app/redis-cache/redis-cache.service.ts
  41. 20
      apps/api/src/app/symbol/symbol.service.ts
  42. 13
      apps/api/src/app/user/user.controller.ts
  43. 20
      apps/api/src/app/user/user.service.ts
  44. 11
      apps/api/src/events/events.module.ts
  45. 15
      apps/api/src/events/portfolio-changed.event.ts
  46. 25
      apps/api/src/events/portfolio-changed.listener.ts
  47. 1
      apps/api/src/interceptors/redact-values-in-response.interceptor.ts
  48. 3
      apps/api/src/models/rule.ts
  49. 3
      apps/api/src/models/rules/currency-cluster-risk/base-currency-current-investment.ts
  50. 3
      apps/api/src/models/rules/currency-cluster-risk/current-investment.ts
  51. 20
      apps/api/src/services/data-gathering/data-gathering.processor.ts
  52. 23
      apps/api/src/services/data-gathering/data-gathering.service.ts
  53. 4
      apps/api/src/services/data-provider/coingecko/coingecko.service.ts
  54. 2
      apps/api/src/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service.ts
  55. 31
      apps/api/src/services/data-provider/data-provider.service.ts
  56. 2
      apps/api/src/services/data-provider/eod-historical-data/eod-historical-data.service.ts
  57. 2
      apps/api/src/services/data-provider/manual/manual.service.ts
  58. 13
      apps/api/src/services/exchange-rate-data/exchange-rate-data.service.ts
  59. 4
      apps/client/src/app/app.module.ts
  60. 128
      apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts
  61. 2
      apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html
  62. 16
      apps/client/src/app/components/account-detail-dialog/account-detail-dialog.module.ts
  63. 4
      apps/client/src/app/components/accounts-table/accounts-table.module.ts
  64. 4
      apps/client/src/app/components/admin-market-data-detail/admin-market-data-detail.module.ts
  65. 1
      apps/client/src/app/components/admin-market-data/admin-market-data.component.ts
  66. 4
      apps/client/src/app/components/admin-market-data/admin-market-data.module.ts
  67. 44
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts
  68. 6
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html
  69. 12
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.module.ts
  70. 8
      apps/client/src/app/components/admin-market-data/create-asset-profile-dialog/create-asset-profile-dialog.component.ts
  71. 4
      apps/client/src/app/components/admin-market-data/create-asset-profile-dialog/create-asset-profile-dialog.module.ts
  72. 4
      apps/client/src/app/components/admin-overview/admin-overview.module.ts
  73. 8
      apps/client/src/app/components/admin-platform/admin-platform.component.ts
  74. 54
      apps/client/src/app/components/admin-platform/create-or-update-platform-dialog/create-or-update-platform-dialog.component.ts
  75. 25
      apps/client/src/app/components/admin-platform/create-or-update-platform-dialog/create-or-update-platform-dialog.html
  76. 8
      apps/client/src/app/components/admin-tag/admin-tag.component.ts
  77. 52
      apps/client/src/app/components/admin-tag/create-or-update-tag-dialog/create-or-update-tag-dialog.component.ts
  78. 19
      apps/client/src/app/components/admin-tag/create-or-update-tag-dialog/create-or-update-tag-dialog.html
  79. 8
      apps/client/src/app/components/admin-users/admin-users.module.ts
  80. 4
      apps/client/src/app/components/benchmark-comparator/benchmark-comparator.module.ts
  81. 4
      apps/client/src/app/components/header/header.component.ts
  82. 12
      apps/client/src/app/components/header/header.module.ts
  83. 96
      apps/client/src/app/components/home-holdings/home-holdings.component.ts
  84. 33
      apps/client/src/app/components/home-holdings/home-holdings.html
  85. 8
      apps/client/src/app/components/home-holdings/home-holdings.module.ts
  86. 4
      apps/client/src/app/components/home-market/home-market.html
  87. 8
      apps/client/src/app/components/home-market/home-market.module.ts
  88. 8
      apps/client/src/app/components/home-overview/home-overview.module.ts
  89. 6
      apps/client/src/app/components/portfolio-performance/portfolio-performance.component.html
  90. 9
      apps/client/src/app/components/portfolio-performance/portfolio-performance.component.ts
  91. 4
      apps/client/src/app/components/portfolio-performance/portfolio-performance.module.ts
  92. 28
      apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html
  93. 4
      apps/client/src/app/components/portfolio-summary/portfolio-summary.component.ts
  94. 5
      apps/client/src/app/components/portfolio-summary/portfolio-summary.module.ts
  95. 0
      apps/client/src/app/components/position-detail-dialog/interfaces/interfaces.ts
  96. 0
      apps/client/src/app/components/position-detail-dialog/position-detail-dialog.component.scss
  97. 16
      apps/client/src/app/components/position-detail-dialog/position-detail-dialog.component.ts
  98. 31
      apps/client/src/app/components/position-detail-dialog/position-detail-dialog.html
  99. 20
      apps/client/src/app/components/position-detail-dialog/position-detail-dialog.module.ts
  100. 72
      apps/client/src/app/components/position/position.component.html

113
CHANGELOG.md

@ -5,7 +5,115 @@ 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/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## Unreleased
## 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
### Added
- Added `accountId` and `date` as a unique constraint to the `AccountBalance` database schema
### Changed
- Improved the chart in the account detail dialog
- Improved the account balance management
### Fixed
- Fixed an issue with `totalValueInBaseCurrency` in the value redaction interceptor for the impersonation mode
## 2.74.0 - 2024-04-20
### Added
- Added the date range support to the portfolio holdings page
- Added support to create an account balance
### Changed
- Removed the date range support in the activities table on the portfolio activities page (experimental)
- Improved the language localization for German (`de`)
- Upgraded `angular` from version `17.3.3` to `17.3.5`
- Upgraded `Nx` from version `18.2.3` to `18.3.3`
### Fixed
- Fixed gaps in the portfolio performance charts by considering `BUY` and `SELL` activities
## 2.73.0 - 2024-04-17
### Added
@ -19,6 +127,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Moved the interest calculations into the portfolio calculator
- Moved the liability calculations into the portfolio calculator
- Moved the (wealth) item calculations into the portfolio calculator
- Let queue jobs for asset profile data gathering fail by throwing an error
- Let queue jobs for historical market data gathering fail by throwing an error
- Upgraded `yahoo-finance2` from version `2.11.1` to `2.11.2`
## 2.72.0 - 2024-04-13

38
README.md

@ -13,8 +13,6 @@
[![Shield: Contributions Welcome](https://img.shields.io/badge/Contributions-Welcome-orange.svg)](#contributing)
[![Shield: License: AGPL v3](https://img.shields.io/badge/License-AGPL%20v3-blue.svg)](https://www.gnu.org/licenses/agpl-3.0)
New: [Ghostfolio 2.0](https://ghostfol.io/en/blog/2023/09/ghostfolio-2)
</div>
**Ghostfolio** is an open source wealth management software built with web technology. The application empowers busy people to keep track of stocks, ETFs or cryptocurrencies and make solid, data-driven investment decisions. The software is designed for personal use in continuous operation.
@ -87,23 +85,23 @@ We provide official container images hosted on [Docker Hub](https://hub.docker.c
### Supported Environment Variables
| Name | Default Value | Description |
| ------------------------ | ------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `ACCESS_TOKEN_SALT` | | A random string used as salt for access tokens |
| `API_KEY_COINGECKO_DEMO` |   | The _CoinGecko_ Demo API key |
| `API_KEY_COINGECKO_PRO` |   | The _CoinGecko_ Pro API |
| `DATABASE_URL` | | 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 |
| `JWT_SECRET_KEY` | | A random string used for _JSON Web Tokens_ (JWT) |
| `PORT` | `3333` | The port where the Ghostfolio application will run on |
| `POSTGRES_DB` | | The name of the _PostgreSQL_ database |
| `POSTGRES_PASSWORD` | | The password of the _PostgreSQL_ database |
| `POSTGRES_USER` | | The user of the _PostgreSQL_ database |
| `REDIS_DB` | `0` | The database index of _Redis_ |
| `REDIS_HOST` | | The host where _Redis_ is running |
| `REDIS_PASSWORD` | | The password of _Redis_ |
| `REDIS_PORT` | | The port where _Redis_ is running |
| `REQUEST_TIMEOUT` | `2000` | The timeout of network requests to data providers in milliseconds |
| Name | Type | Default Value | Description |
| ------------------------ | ------------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `ACCESS_TOKEN_SALT` | string | | A random string used as salt for access tokens |
| `API_KEY_COINGECKO_DEMO` | string (`optional`) |   | The _CoinGecko_ Demo API key |
| `API_KEY_COINGECKO_PRO` | string (`optional`) | | The _CoinGecko_ Pro API |
| `DATABASE_URL` | string | | The database connection URL, e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer` |
| `HOST` | string (`optional`) | `0.0.0.0` | The host where the Ghostfolio application will run on |
| `JWT_SECRET_KEY` | string | | A random string used for _JSON Web Tokens_ (JWT) |
| `PORT` | number (`optional`) | `3333` | The port where the Ghostfolio application will run on |
| `POSTGRES_DB` | string | | The name of the _PostgreSQL_ database |
| `POSTGRES_PASSWORD` | string | | The password of the _PostgreSQL_ database |
| `POSTGRES_USER` | string | | The user of the _PostgreSQL_ database |
| `REDIS_DB` | number (`optional`) | `0` | The database index of _Redis_ |
| `REDIS_HOST` | string | | The host where _Redis_ is running |
| `REDIS_PASSWORD` | string | | The password of _Redis_ |
| `REDIS_PORT` | number | | The port where _Redis_ is running |
| `REQUEST_TIMEOUT` | number (`optional`) | `2000` | The timeout of network requests to data providers in milliseconds |
### Run with Docker Compose
@ -144,7 +142,7 @@ docker compose --env-file ./.env -f docker/docker-compose.build.yml up -d
### Home Server Systems (Community)
Ghostfolio is available for various home server systems, including [CasaOS](https://github.com/bigbeartechworld/big-bear-casaos), [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

41
apps/api/src/app/account-balance/account-balance.controller.ts

@ -1,3 +1,4 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { permissions } from '@ghostfolio/common/permissions';
@ -5,6 +6,8 @@ import type { RequestWithUser } from '@ghostfolio/common/types';
import {
Controller,
Body,
Post,
Delete,
HttpException,
Inject,
@ -17,14 +20,44 @@ import { AccountBalance } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { AccountBalanceService } from './account-balance.service';
import { CreateAccountBalanceDto } from './create-account-balance.dto';
@Controller('account-balance')
export class AccountBalanceController {
public constructor(
private readonly accountBalanceService: AccountBalanceService,
private readonly accountService: AccountService,
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
@HasPermission(permissions.createAccountBalance)
@Post()
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async createAccountBalance(
@Body() data: CreateAccountBalanceDto
): Promise<AccountBalance> {
const account = await this.accountService.account({
id_userId: {
id: data.accountId,
userId: this.request.user.id
}
});
if (!account) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return this.accountBalanceService.createOrUpdateAccountBalance({
accountId: account.id,
balance: data.balance,
date: data.date,
userId: account.userId
});
}
@HasPermission(permissions.deleteAccountBalance)
@Delete(':id')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@ -32,10 +65,11 @@ export class AccountBalanceController {
@Param('id') id: string
): Promise<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(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
@ -43,7 +77,8 @@ export class AccountBalanceController {
}
return this.accountBalanceService.deleteAccountBalance({
id
id: accountBalance.id,
userId: accountBalance.userId
});
}
}

3
apps/api/src/app/account-balance/account-balance.module.ts

@ -1,3 +1,4 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
@ -10,6 +11,6 @@ import { AccountBalanceService } from './account-balance.service';
controllers: [AccountBalanceController],
exports: [AccountBalanceService],
imports: [ExchangeRateDataModule, PrismaModule],
providers: [AccountBalanceService]
providers: [AccountBalanceService, AccountService]
})
export class AccountBalanceModule {}

62
apps/api/src/app/account-balance/account-balance.service.ts

@ -1,15 +1,22 @@
import { LogPerformance } from '@ghostfolio/api/aop/logging.interceptor';
import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { resetHours } from '@ghostfolio/common/helper';
import { AccountBalancesResponse, Filter } from '@ghostfolio/common/interfaces';
import { UserWithSettings } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { AccountBalance, Prisma } from '@prisma/client';
import { parseISO } from 'date-fns';
import { CreateAccountBalanceDto } from './create-account-balance.dto';
@Injectable()
export class AccountBalanceService {
public constructor(
private readonly eventEmitter: EventEmitter2,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly prismaService: PrismaService
) {}
@ -25,20 +32,63 @@ export class AccountBalanceService {
});
}
public async createAccountBalance(
data: Prisma.AccountBalanceCreateInput
): Promise<AccountBalance> {
return this.prismaService.accountBalance.create({
data
public async createOrUpdateAccountBalance({
accountId,
balance,
date,
userId
}: CreateAccountBalanceDto & {
userId: string;
}): Promise<AccountBalance> {
const accountBalance = await this.prismaService.accountBalance.upsert({
create: {
Account: {
connect: {
id_userId: {
userId,
id: accountId
}
}
},
date: resetHours(parseISO(date)),
value: balance
},
update: {
value: balance
},
where: {
accountId_date: {
accountId,
date: resetHours(parseISO(date))
}
}
});
this.eventEmitter.emit(
PortfolioChangedEvent.getName(),
new PortfolioChangedEvent({
userId
})
);
return accountBalance;
}
public async deleteAccountBalance(
where: Prisma.AccountBalanceWhereUniqueInput
): Promise<AccountBalance> {
return this.prismaService.accountBalance.delete({
const accountBalance = await this.prismaService.accountBalance.delete({
where
});
this.eventEmitter.emit(
PortfolioChangedEvent.getName(),
new PortfolioChangedEvent({
userId: <string>where.userId
})
);
return accountBalance;
}
@LogPerformance

12
apps/api/src/app/account-balance/create-account-balance.dto.ts

@ -0,0 +1,12 @@
import { IsISO8601, IsNumber, IsUUID } from 'class-validator';
export class CreateAccountBalanceDto {
@IsUUID()
accountId: string;
@IsNumber()
balance: number;
@IsISO8601()
date: string;
}

76
apps/api/src/app/account/account.service.ts

@ -1,11 +1,15 @@
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 { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { Filter } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { Account, Order, Platform, Prisma } from '@prisma/client';
import { Big } from 'big.js';
import { format } from 'date-fns';
import { groupBy } from 'lodash';
import { CashDetails } from './interfaces/cash-details.interface';
@ -14,6 +18,7 @@ import { CashDetails } from './interfaces/cash-details.interface';
export class AccountService {
public constructor(
private readonly accountBalanceService: AccountBalanceService,
private readonly eventEmitter: EventEmitter2,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly prismaService: PrismaService
) {}
@ -85,17 +90,20 @@ export class AccountService {
data
});
await this.prismaService.accountBalance.create({
data: {
Account: {
connect: {
id_userId: { id: account.id, userId: aUserId }
}
},
value: data.balance
}
await this.accountBalanceService.createOrUpdateAccountBalance({
accountId: account.id,
balance: data.balance,
date: format(new Date(), DATE_FORMAT),
userId: aUserId
});
this.eventEmitter.emit(
PortfolioChangedEvent.getName(),
new PortfolioChangedEvent({
userId: account.userId
})
);
return account;
}
@ -103,9 +111,18 @@ export class AccountService {
where: Prisma.AccountWhereUniqueInput,
aUserId: string
): Promise<Account> {
return this.prismaService.account.delete({
const account = await this.prismaService.account.delete({
where
});
this.eventEmitter.emit(
PortfolioChangedEvent.getName(),
new PortfolioChangedEvent({
userId: account.userId
})
);
return account;
}
public async getAccounts(aUserId: string): Promise<Account[]> {
@ -196,21 +213,26 @@ export class AccountService {
): Promise<Account> {
const { data, where } = params;
await this.prismaService.accountBalance.create({
data: {
Account: {
connect: {
id_userId: where.id_userId
}
},
value: <number>data.balance
}
await this.accountBalanceService.createOrUpdateAccountBalance({
accountId: <string>data.id,
balance: <number>data.balance,
date: format(new Date(), DATE_FORMAT),
userId: aUserId
});
return this.prismaService.account.update({
const account = await this.prismaService.account.update({
data,
where
});
this.eventEmitter.emit(
PortfolioChangedEvent.getName(),
new PortfolioChangedEvent({
userId: account.userId
})
);
return account;
}
public async updateAccountBalance({
@ -242,17 +264,11 @@ export class AccountService {
);
if (amountInCurrencyOfAccount) {
await this.accountBalanceService.createAccountBalance({
date,
Account: {
connect: {
id_userId: {
await this.accountBalanceService.createOrUpdateAccountBalance({
accountId,
userId,
id: accountId
}
}
},
value: new Big(balance).plus(amountInCurrencyOfAccount).toNumber()
balance: new Big(balance).plus(amountInCurrencyOfAccount).toNumber(),
date: date.toISOString()
});
}
}

2
apps/api/src/app/admin/admin.service.ts

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

6
apps/api/src/app/app.module.ts

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

27
apps/api/src/app/auth/jwt.strategy.ts

@ -2,10 +2,12 @@ import { UserService } from '@ghostfolio/api/app/user/user.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
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 * as countriesAndTimezones from 'countries-and-timezones';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { ExtractJwt, Strategy } from 'passport-jwt';
@Injectable()
@ -29,6 +31,13 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
if (user) {
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 =
countriesAndTimezones.getCountryForTimezone(timezone)?.id;
@ -45,10 +54,20 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
return user;
} 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);
}
}
}

5
apps/api/src/app/import/import.service.ts

@ -416,6 +416,11 @@ export class ImportService {
User: { connect: { id: 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();

23
apps/api/src/app/order/order.controller.ts

@ -60,15 +60,15 @@ export class OrderController {
}
@Delete(':id')
@HasPermission(permissions.deleteOrder)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
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 (
!hasPermission(this.request.user.permissions, permissions.deleteOrder) ||
!order ||
order.userId !== this.request.user.id
) {
if (!order) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
@ -88,21 +88,26 @@ export class OrderController {
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId,
@Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string,
@Query('range') dateRange: DateRange = 'max',
@Query('range') dateRange?: DateRange,
@Query('skip') skip?: number,
@Query('sortColumn') sortColumn?: string,
@Query('sortDirection') sortDirection?: Prisma.SortOrder,
@Query('tags') filterByTags?: string,
@Query('take') take?: number
): Promise<Activities> {
let endDate: Date;
let startDate: Date;
if (dateRange) {
({ endDate, startDate } = getInterval(dateRange));
}
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts,
filterByAssetClasses,
filterByTags
});
const { endDate, startDate } = getInterval(dateRange);
const impersonationUserId =
await this.impersonationService.validateImpersonationId(impersonationId);
const userCurrency = this.request.user.Settings.settings.baseCurrency;

39
apps/api/src/app/order/order.service.ts

@ -1,4 +1,5 @@
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 { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.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 { Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import {
AssetClass,
AssetSubClass,
@ -27,7 +29,6 @@ import { endOfToday, isAfter } from 'date-fns';
import { groupBy, uniqBy } from 'lodash';
import { v4 as uuidv4 } from 'uuid';
import { CreateOrderDto } from './create-order.dto';
import { Activities } from './interfaces/activities.interface';
@Injectable()
@ -35,6 +36,7 @@ export class OrderService {
public constructor(
private readonly accountService: AccountService,
private readonly dataGatheringService: DataGatheringService,
private readonly eventEmitter: EventEmitter2,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly prismaService: PrismaService,
private readonly symbolProfileService: SymbolProfileService
@ -138,7 +140,8 @@ export class OrderService {
return { id };
})
}
}
},
include: { SymbolProfile: true }
});
if (updateAccountBalance === true) {
@ -160,6 +163,13 @@ export class OrderService {
});
}
this.eventEmitter.emit(
PortfolioChangedEvent.getName(),
new PortfolioChangedEvent({
userId: order.userId
})
);
return order;
}
@ -174,6 +184,13 @@ export class OrderService {
await this.symbolProfileService.deleteById(order.symbolProfileId);
}
this.eventEmitter.emit(
PortfolioChangedEvent.getName(),
new PortfolioChangedEvent({
userId: order.userId
})
);
return order;
}
@ -182,6 +199,13 @@ export class OrderService {
where
});
this.eventEmitter.emit(
PortfolioChangedEvent.getName(),
new PortfolioChangedEvent({
userId: <string>where.userId
})
);
return count;
}
@ -480,7 +504,7 @@ export class OrderService {
where
});
return this.prismaService.order.update({
const order = await this.prismaService.order.update({
data: {
...data,
isDraft,
@ -492,6 +516,15 @@ export class OrderService {
},
where
});
this.eventEmitter.emit(
PortfolioChangedEvent.getName(),
new PortfolioChangedEvent({
userId: order.userId
})
);
return order;
}
private async orders(params: {

8
apps/api/src/app/portfolio/calculator/mwr/portfolio-calculator.ts

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

4
apps/api/src/app/portfolio/calculator/portfolio-calculator-test-utils.ts

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

35
apps/api/src/app/portfolio/calculator/portfolio-calculator.factory.ts

@ -1,7 +1,10 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
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 { DateRange } from '@ghostfolio/common/types';
import { HistoricalDataItem } from '@ghostfolio/common/interfaces';
import { DateRange, UserWithSettings } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common';
@ -17,37 +20,59 @@ export enum PerformanceCalculationType {
@Injectable()
export class PortfolioCalculatorFactory {
public constructor(
private readonly configurationService: ConfigurationService,
private readonly currentRateService: CurrentRateService,
private readonly exchangeRateDataService: ExchangeRateDataService
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly redisCacheService: RedisCacheService
) {}
public createCalculator({
accountBalanceItems = [],
activities,
calculationType,
currency,
dateRange = 'max'
dateRange = 'max',
hasFilters,
isExperimentalFeatures = false,
userId
}: {
accountBalanceItems?: HistoricalDataItem[];
activities: Activity[];
calculationType: PerformanceCalculationType;
currency: string;
dateRange?: DateRange;
hasFilters: boolean;
isExperimentalFeatures?: boolean;
userId: string;
}): PortfolioCalculator {
const useCache = !hasFilters && isExperimentalFeatures;
switch (calculationType) {
case PerformanceCalculationType.MWR:
return new MWRPortfolioCalculator({
accountBalanceItems,
activities,
currency,
dateRange,
useCache,
userId,
configurationService: this.configurationService,
currentRateService: this.currentRateService,
exchangeRateDataService: this.exchangeRateDataService
exchangeRateDataService: this.exchangeRateDataService,
redisCacheService: this.redisCacheService
});
case PerformanceCalculationType.TWR:
return new TWRPortfolioCalculator({
accountBalanceItems,
activities,
currency,
currentRateService: this.currentRateService,
dateRange,
exchangeRateDataService: this.exchangeRateDataService
useCache,
userId,
configurationService: this.configurationService,
exchangeRateDataService: this.exchangeRateDataService,
redisCacheService: this.redisCacheService
});
default:
throw new Error('Invalid calculation type');

280
apps/api/src/app/portfolio/calculator/portfolio-calculator.ts

@ -1,13 +1,14 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
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 { TransactionPoint } from '@ghostfolio/api/app/portfolio/interfaces/transaction-point.interface';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import {
getFactor,
getInterval
} 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 { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
import { MAX_CHART_ITEMS } from '@ghostfolio/common/config';
@ -23,57 +24,96 @@ import {
InvestmentItem,
ResponseError,
SymbolMetrics,
TimelinePosition,
UniqueAsset
} from '@ghostfolio/common/interfaces';
import { PortfolioSnapshot, TimelinePosition } from '@ghostfolio/common/models';
import { DateRange, GroupBy } from '@ghostfolio/common/types';
import { Logger } from '@nestjs/common';
import { Big } from 'big.js';
import { plainToClass } from 'class-transformer';
import {
differenceInDays,
eachDayOfInterval,
endOfDay,
format,
isAfter,
isBefore,
isSameDay,
max,
min,
subDays
} from 'date-fns';
import { last, uniq, uniqBy } from 'lodash';
import { first, last, uniq, uniqBy } from 'lodash';
export abstract class PortfolioCalculator {
protected static readonly ENABLE_LOGGING = false;
protected orders: PortfolioOrder[];
protected accountBalanceItems: HistoricalDataItem[];
protected activities: PortfolioOrder[];
private configurationService: ConfigurationService;
private currency: string;
private currentRateService: CurrentRateService;
private dataProviderInfos: DataProviderInfo[];
private dateRange: DateRange;
private endDate: Date;
private exchangeRateDataService: ExchangeRateDataService;
private redisCacheService: RedisCacheService;
private snapshot: PortfolioSnapshot;
private snapshotPromise: Promise<void>;
private startDate: Date;
private transactionPoints: TransactionPoint[];
private useCache: boolean;
private userId: string;
public constructor({
accountBalanceItems,
activities,
configurationService,
currency,
currentRateService,
dateRange,
exchangeRateDataService
exchangeRateDataService,
redisCacheService,
useCache,
userId
}: {
accountBalanceItems: HistoricalDataItem[];
activities: Activity[];
configurationService: ConfigurationService;
currency: string;
currentRateService: CurrentRateService;
dateRange: DateRange;
exchangeRateDataService: ExchangeRateDataService;
redisCacheService: RedisCacheService;
useCache: boolean;
userId: string;
}) {
this.accountBalanceItems = accountBalanceItems;
this.configurationService = configurationService;
this.currency = currency;
this.currentRateService = currentRateService;
this.dateRange = dateRange;
this.exchangeRateDataService = exchangeRateDataService;
this.orders = activities.map(
({ date, fee, quantity, SymbolProfile, tags = [], type, unitPrice }) => {
this.activities = activities
.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,
@ -84,12 +124,15 @@ export abstract class PortfolioCalculator {
unitPrice: new Big(unitPrice)
};
}
);
this.orders.sort((a, b) => {
)
.sort((a, b) => {
return a.date?.localeCompare(b.date);
});
this.redisCacheService = redisCacheService;
this.useCache = useCache;
this.userId = userId;
const { endDate, startDate } = getInterval(dateRange);
this.endDate = endDate;
@ -383,10 +426,6 @@ export abstract class PortfolioCalculator {
dateRange?: DateRange;
withDataDecimation?: boolean;
}): Promise<HistoricalDataItem[]> {
if (this.getTransactionPoints().length === 0) {
return [];
}
const { endDate, startDate } = getInterval(dateRange, this.getStartDate());
const daysInMarket = differenceInDays(endDate, startDate) + 1;
@ -490,6 +529,7 @@ export abstract class PortfolioCalculator {
investmentValueWithCurrencyEffect: Big;
totalCurrentValue: Big;
totalCurrentValueWithCurrencyEffect: Big;
totalAccountBalanceWithCurrencyEffect: Big;
totalInvestmentValue: Big;
totalInvestmentValueWithCurrencyEffect: Big;
totalNetPerformanceValue: Big;
@ -549,9 +589,24 @@ export abstract class PortfolioCalculator {
};
}
let lastDate = format(this.startDate, DATE_FORMAT);
for (const currentDate of dates) {
const dateString = format(currentDate, DATE_FORMAT);
accumulatedValuesByDate[dateString] = {
investmentValueWithCurrencyEffect: new Big(0),
totalAccountBalanceWithCurrencyEffect: new Big(0),
totalCurrentValue: new Big(0),
totalCurrentValueWithCurrencyEffect: new Big(0),
totalInvestmentValue: new Big(0),
totalInvestmentValueWithCurrencyEffect: new Big(0),
totalNetPerformanceValue: new Big(0),
totalNetPerformanceValueWithCurrencyEffect: new Big(0),
totalTimeWeightedInvestmentValue: new Big(0),
totalTimeWeightedInvestmentValueWithCurrencyEffect: new Big(0)
};
for (const symbol of Object.keys(valuesBySymbol)) {
const symbolValues = valuesBySymbol[symbol];
@ -589,49 +644,94 @@ export abstract class PortfolioCalculator {
dateString
] ?? new Big(0);
accumulatedValuesByDate[dateString] = {
investmentValueWithCurrencyEffect: (
accumulatedValuesByDate[dateString]
?.investmentValueWithCurrencyEffect ?? new Big(0)
).add(investmentValueWithCurrencyEffect),
totalCurrentValue: (
accumulatedValuesByDate[dateString]?.totalCurrentValue ?? new Big(0)
).add(currentValue),
totalCurrentValueWithCurrencyEffect: (
accumulatedValuesByDate[dateString]
?.totalCurrentValueWithCurrencyEffect ?? new Big(0)
).add(currentValueWithCurrencyEffect),
totalInvestmentValue: (
accumulatedValuesByDate[dateString]?.totalInvestmentValue ??
new Big(0)
).add(investmentValueAccumulated),
totalInvestmentValueWithCurrencyEffect: (
accumulatedValuesByDate[dateString]
?.totalInvestmentValueWithCurrencyEffect ?? new Big(0)
).add(investmentValueAccumulatedWithCurrencyEffect),
totalNetPerformanceValue: (
accumulatedValuesByDate[dateString]?.totalNetPerformanceValue ??
new Big(0)
).add(netPerformanceValue),
totalNetPerformanceValueWithCurrencyEffect: (
accumulatedValuesByDate[dateString]
?.totalNetPerformanceValueWithCurrencyEffect ?? new Big(0)
).add(netPerformanceValueWithCurrencyEffect),
totalTimeWeightedInvestmentValue: (
accumulatedValuesByDate[dateString]
?.totalTimeWeightedInvestmentValue ?? new Big(0)
).add(timeWeightedInvestmentValue),
totalTimeWeightedInvestmentValueWithCurrencyEffect: (
accumulatedValuesByDate[dateString]
?.totalTimeWeightedInvestmentValueWithCurrencyEffect ?? new Big(0)
).add(timeWeightedInvestmentValueWithCurrencyEffect)
};
accumulatedValuesByDate[dateString].investmentValueWithCurrencyEffect =
accumulatedValuesByDate[
dateString
].investmentValueWithCurrencyEffect.add(
investmentValueWithCurrencyEffect
);
accumulatedValuesByDate[dateString].totalCurrentValue =
accumulatedValuesByDate[dateString].totalCurrentValue.add(
currentValue
);
accumulatedValuesByDate[
dateString
].totalCurrentValueWithCurrencyEffect = accumulatedValuesByDate[
dateString
].totalCurrentValueWithCurrencyEffect.add(
currentValueWithCurrencyEffect
);
accumulatedValuesByDate[dateString].totalInvestmentValue =
accumulatedValuesByDate[dateString].totalInvestmentValue.add(
investmentValueAccumulated
);
accumulatedValuesByDate[
dateString
].totalInvestmentValueWithCurrencyEffect = accumulatedValuesByDate[
dateString
].totalInvestmentValueWithCurrencyEffect.add(
investmentValueAccumulatedWithCurrencyEffect
);
accumulatedValuesByDate[dateString].totalNetPerformanceValue =
accumulatedValuesByDate[dateString].totalNetPerformanceValue.add(
netPerformanceValue
);
accumulatedValuesByDate[
dateString
].totalNetPerformanceValueWithCurrencyEffect = accumulatedValuesByDate[
dateString
].totalNetPerformanceValueWithCurrencyEffect.add(
netPerformanceValueWithCurrencyEffect
);
accumulatedValuesByDate[dateString].totalTimeWeightedInvestmentValue =
accumulatedValuesByDate[
dateString
].totalTimeWeightedInvestmentValue.add(timeWeightedInvestmentValue);
accumulatedValuesByDate[
dateString
].totalTimeWeightedInvestmentValueWithCurrencyEffect =
accumulatedValuesByDate[
dateString
].totalTimeWeightedInvestmentValueWithCurrencyEffect.add(
timeWeightedInvestmentValueWithCurrencyEffect
);
}
if (
this.accountBalanceItems.some(({ date }) => {
return date === dateString;
})
) {
accumulatedValuesByDate[
dateString
].totalAccountBalanceWithCurrencyEffect = new Big(
this.accountBalanceItems.find(({ date }) => {
return date === dateString;
}).value
);
} else {
accumulatedValuesByDate[
dateString
].totalAccountBalanceWithCurrencyEffect =
accumulatedValuesByDate[lastDate]
?.totalAccountBalanceWithCurrencyEffect ?? new Big(0);
}
lastDate = dateString;
}
return Object.entries(accumulatedValuesByDate).map(([date, values]) => {
const {
investmentValueWithCurrencyEffect,
totalAccountBalanceWithCurrencyEffect,
totalCurrentValue,
totalCurrentValueWithCurrencyEffect,
totalInvestmentValue,
@ -666,6 +766,11 @@ export abstract class PortfolioCalculator {
netPerformance: totalNetPerformanceValue.toNumber(),
netPerformanceWithCurrencyEffect:
totalNetPerformanceValueWithCurrencyEffect.toNumber(),
// TODO: Add valuables
netWorth: totalCurrentValueWithCurrencyEffect
.plus(totalAccountBalanceWithCurrencyEffect)
.toNumber(),
totalAccountBalance: totalAccountBalanceWithCurrencyEffect.toNumber(),
totalInvestment: totalInvestmentValue.toNumber(),
totalInvestmentValueWithCurrencyEffect:
totalInvestmentValueWithCurrencyEffect.toNumber(),
@ -754,9 +859,30 @@ export abstract class PortfolioCalculator {
}
public getStartDate() {
return this.transactionPoints.length > 0
? parseDate(this.transactionPoints[0].date)
let firstAccountBalanceDate: Date;
let firstActivityDate: Date;
try {
const firstAccountBalanceDateString = first(
this.accountBalanceItems
)?.date;
firstAccountBalanceDate = firstAccountBalanceDateString
? parseDate(firstAccountBalanceDateString)
: new Date();
} catch (error) {
firstAccountBalanceDate = new Date();
}
try {
const firstActivityDateString = this.transactionPoints[0].date;
firstActivityDate = firstActivityDateString
? parseDate(firstActivityDateString)
: new Date();
} catch (error) {
firstActivityDate = new Date();
}
return min([firstAccountBalanceDate, firstActivityDate]);
}
protected abstract getSymbolMetrics({
@ -804,7 +930,7 @@ export abstract class PortfolioCalculator {
tags,
type,
unitPrice
} of this.orders) {
} of this.activities) {
let currentTransactionPointItem: TransactionPointSymbol;
const oldAccumulatedSymbol = symbols[SymbolProfile.symbol];
@ -928,6 +1054,52 @@ export abstract class PortfolioCalculator {
}
private async initialize() {
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);
}
}
}

29
apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-baln-buy-and-sell-in-two-activities.spec.ts

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

29
apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-baln-buy-and-sell.spec.ts

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

29
apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-baln-buy.spec.ts

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

29
apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts

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

29
apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-fee.spec.ts

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

29
apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-googl-buy.spec.ts

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

29
apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-item.spec.ts

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

88
apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-liability.spec.ts

@ -1,7 +1,8 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import {
activityDummyData,
symbolProfileDummyData
symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import {
PortfolioCalculatorFactory,
@ -9,6 +10,9 @@ import {
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { 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', () => {
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
let factory: PortfolioCalculatorFactory;
let redisCacheService: RedisCacheService;
beforeEach(() => {
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
@ -38,9 +55,13 @@ describe('PortfolioCalculator', () => {
null
);
redisCacheService = new RedisCacheService(null, null);
factory = new PortfolioCalculatorFactory(
configurationService,
currentRateService,
exchangeRateDataService
exchangeRateDataService,
redisCacheService
);
});
@ -53,7 +74,7 @@ describe('PortfolioCalculator', () => {
const activities: Activity[] = [
{
...activityDummyData,
date: new Date('2022-01-01'),
date: new Date('2023-01-01'), // Date in future
fee: 0,
quantity: 1,
SymbolProfile: {
@ -71,64 +92,17 @@ describe('PortfolioCalculator', () => {
const portfolioCalculator = factory.createCalculator({
activities,
calculationType: PerformanceCalculationType.TWR,
currency: 'USD'
currency: 'USD',
hasFilters: false,
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(
parseDate('2022-01-01')
);
spy.mockRestore();
expect(portfolioSnapshot).toEqual({
currentValueInBaseCurrency: new Big('0'),
errors: [],
grossPerformance: new Big('0'),
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')
});
const liabilitiesInBaseCurrency =
await portfolioCalculator.getLiabilitiesInBaseCurrency();
expect(liabilitiesInBaseCurrency).toEqual(new Big(3000));
});
});
});

29
apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-msft-buy-with-dividend.spec.ts

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

34
apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-no-orders.spec.ts

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

29
apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-novn-buy-and-sell-partially.spec.ts

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

33
apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-novn-buy-and-sell.spec.ts

@ -1,7 +1,8 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import {
activityDummyData,
symbolProfileDummyData
symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import {
PerformanceCalculationType,
@ -9,6 +10,9 @@ import {
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { 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', () => {
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
let factory: PortfolioCalculatorFactory;
let redisCacheService: RedisCacheService;
beforeEach(() => {
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
@ -38,9 +55,13 @@ describe('PortfolioCalculator', () => {
null
);
redisCacheService = new RedisCacheService(null, null);
factory = new PortfolioCalculatorFactory(
configurationService,
currentRateService,
exchangeRateDataService
exchangeRateDataService,
redisCacheService
);
});
@ -86,7 +107,9 @@ describe('PortfolioCalculator', () => {
const portfolioCalculator = factory.createCalculator({
activities,
calculationType: PerformanceCalculationType.TWR,
currency: 'CHF'
currency: 'CHF',
hasFilters: false,
userId: userDummyData.id
});
const chartData = await portfolioCalculator.getChartData({
@ -114,6 +137,8 @@ describe('PortfolioCalculator', () => {
netPerformanceInPercentageWithCurrencyEffect: 0,
netPerformanceWithCurrencyEffect: 0,
timeWeightedPerformance: 0,
netWorth: 151.6,
totalAccountBalance: 0,
totalInvestment: 151.6,
totalInvestmentValueWithCurrencyEffect: 151.6,
value: 151.6,
@ -128,6 +153,8 @@ describe('PortfolioCalculator', () => {
netPerformanceInPercentageWithCurrencyEffect: 13.100263852242744,
netPerformanceWithCurrencyEffect: 19.86,
timeWeightedPerformance: 0,
netWorth: 0,
totalAccountBalance: 0,
totalInvestment: 0,
totalInvestmentValueWithCurrencyEffect: 0,
value: 0,

12
apps/api/src/app/portfolio/calculator/twr/portfolio-calculator.spec.ts

@ -1,13 +1,19 @@
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
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';
describe('PortfolioCalculator', () => {
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
let factory: PortfolioCalculatorFactory;
let redisCacheService: RedisCacheService;
beforeEach(() => {
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
@ -17,9 +23,13 @@ describe('PortfolioCalculator', () => {
null
);
redisCacheService = new RedisCacheService(null, null);
factory = new PortfolioCalculatorFactory(
configurationService,
currentRateService,
exchangeRateDataService
exchangeRateDataService,
redisCacheService
);
});

64
apps/api/src/app/portfolio/calculator/twr/portfolio-calculator.ts

@ -1,13 +1,9 @@
import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator';
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 { DATE_FORMAT } from '@ghostfolio/common/helper';
import {
SymbolMetrics,
TimelinePosition,
UniqueAsset
} from '@ghostfolio/common/interfaces';
import { SymbolMetrics, UniqueAsset } from '@ghostfolio/common/interfaces';
import { PortfolioSnapshot, TimelinePosition } from '@ghostfolio/common/models';
import { Logger } from '@nestjs/common';
import { Big } from 'big.js';
@ -188,6 +184,7 @@ export class TWRPortfolioCalculator extends PortfolioCalculator {
[date: string]: Big;
} = {};
let totalAccountBalanceInBaseCurrency = new Big(0);
let totalDividend = new Big(0);
let totalStakeRewards = new Big(0);
let totalDividendInBaseCurrency = new Big(0);
@ -207,7 +204,7 @@ export class TWRPortfolioCalculator extends PortfolioCalculator {
let valueAtStartDateWithCurrencyEffect: Big;
// 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 }) => {
return SymbolProfile.symbol === symbol;
}
@ -240,6 +237,7 @@ export class TWRPortfolioCalculator extends PortfolioCalculator {
timeWeightedInvestmentValues: {},
timeWeightedInvestmentValuesWithCurrencyEffect: {},
timeWeightedInvestmentWithCurrencyEffect: new Big(0),
totalAccountBalanceInBaseCurrency: new Big(0),
totalDividend: new Big(0),
totalDividendInBaseCurrency: new Big(0),
totalInterest: new Big(0),
@ -291,6 +289,7 @@ export class TWRPortfolioCalculator extends PortfolioCalculator {
timeWeightedInvestmentValues: {},
timeWeightedInvestmentValuesWithCurrencyEffect: {},
timeWeightedInvestmentWithCurrencyEffect: new Big(0),
totalAccountBalanceInBaseCurrency: new Big(0),
totalDividend: new Big(0),
totalDividendInBaseCurrency: new Big(0),
totalInterest: new Big(0),
@ -339,8 +338,10 @@ export class TWRPortfolioCalculator extends PortfolioCalculator {
if (isChartMode) {
const datesWithOrders = {};
for (const order of orders) {
datesWithOrders[order.date] = true;
for (const { date, type } of orders) {
if (['BUY', 'SELL'].includes(type)) {
datesWithOrders[date] = true;
}
}
while (isBefore(day, end)) {
@ -401,11 +402,46 @@ export class TWRPortfolioCalculator extends PortfolioCalculator {
if (PortfolioCalculator.ENABLE_LOGGING) {
console.log();
console.log();
console.log(i + 1, order.type, order.itemType);
console.log(
i + 1,
order.date,
order.type,
order.itemType ? `(${order.itemType})` : ''
);
}
const exchangeRateAtOrderDate = exchangeRates[order.date];
if (order.type === 'DIVIDEND') {
const dividend = order.quantity.mul(order.unitPrice);
totalDividend = totalDividend.plus(dividend);
totalDividendInBaseCurrency = totalDividendInBaseCurrency.plus(
dividend.mul(exchangeRateAtOrderDate ?? 1)
);
} else if (order.type === 'INTEREST') {
const interest = order.quantity.mul(order.unitPrice);
totalInterest = totalInterest.plus(interest);
totalInterestInBaseCurrency = totalInterestInBaseCurrency.plus(
interest.mul(exchangeRateAtOrderDate ?? 1)
);
} else if (order.type === 'ITEM') {
const valuables = order.quantity.mul(order.unitPrice);
totalValuables = totalValuables.plus(valuables);
totalValuablesInBaseCurrency = totalValuablesInBaseCurrency.plus(
valuables.mul(exchangeRateAtOrderDate ?? 1)
);
} else if (order.type === 'LIABILITY') {
const liabilities = order.quantity.mul(order.unitPrice);
totalLiabilities = totalLiabilities.plus(liabilities);
totalLiabilitiesInBaseCurrency = totalLiabilitiesInBaseCurrency.plus(
liabilities.mul(exchangeRateAtOrderDate ?? 1)
);
}
if (order.itemType === 'start') {
// Take the unit price of the order as the market price if there are no
// orders of this symbol before the start date
@ -492,13 +528,6 @@ export class TWRPortfolioCalculator extends PortfolioCalculator {
}
if (PortfolioCalculator.ENABLE_LOGGING) {
console.log('totalInvestment', totalInvestment.toNumber());
console.log(
'totalInvestmentWithCurrencyEffect',
totalInvestmentWithCurrencyEffect.toNumber()
);
console.log('order.quantity', order.quantity.toNumber());
console.log('transactionInvestment', transactionInvestment.toNumber());
@ -899,6 +928,7 @@ export class TWRPortfolioCalculator extends PortfolioCalculator {
netPerformanceValuesPercentage: {},
timeWeightedInvestmentValues,
timeWeightedInvestmentValuesWithCurrencyEffect,
totalAccountBalanceInBaseCurrency,
totalDividend,
totalDividendInBaseCurrency,
totalInterest,

7
apps/api/src/app/portfolio/current-rate.service.mock.ts

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

5
apps/api/src/app/portfolio/interfaces/portfolio-positions.interface.ts

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

24
apps/api/src/app/portfolio/interfaces/portfolio-snapshot.interface.ts

@ -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;
}

115
apps/api/src/app/portfolio/portfolio.controller.ts

@ -48,11 +48,11 @@ import {
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { AssetClass, AssetSubClass } from '@prisma/client';
import { Big } from 'big.js';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { PortfolioPositionDetail } from './interfaces/portfolio-position-detail.interface';
import { PortfolioPositions } from './interfaces/portfolio-positions.interface';
import { PortfolioService } from './portfolio.service';
@Controller('portfolio')
@ -122,40 +122,27 @@ export class PortfolioController {
hasReadRestrictedAccessPermission ||
this.userService.isRestrictedView(this.request.user)
) {
let investmentTuple: [number, number] = [0, 0];
for (let holding of Object.entries(holdings)) {
var portfolioPosition = holding[1];
investmentTuple[0] += portfolioPosition.investment;
investmentTuple[1] += this.exchangeRateDataService.toCurrency(
portfolioPosition.quantity * portfolioPosition.marketPrice,
portfolioPosition.currency,
this.request.user.Settings.settings.baseCurrency
);
}
const totalInvestment = investmentTuple[0];
const totalValue = investmentTuple[1];
const totalInvestment = Object.values(holdings)
.map(({ investment }) => {
return investment;
})
.reduce((a, b) => a + b, 0);
if (hasDetails === false) {
portfolioSummary = nullifyValuesInObject(summary, [
'cash',
'committedFunds',
'currentGrossPerformance',
'currentNetPerformance',
'currentValue',
'dividend',
'emergencyFund',
'excludedAccountsAndActivities',
'fees',
'items',
'liabilities',
'netWorth',
'totalBuy',
'totalSell'
]);
}
const totalValue = Object.values(holdings)
.filter(({ assetClass, assetSubClass }) => {
return (
assetClass !== AssetClass.LIQUIDITY &&
assetSubClass !== AssetSubClass.CASH
);
})
.map(({ valueInBaseCurrency }) => {
return valueInBaseCurrency;
})
.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 / totalInvestment;
portfolioPosition.valueInPercentage =
@ -197,21 +184,21 @@ export class PortfolioController {
portfolioSummary = nullifyValuesInObject(summary, [
'cash',
'committedFunds',
'currentGrossPerformance',
'currentGrossPerformanceWithCurrencyEffect',
'currentNetPerformance',
'currentNetPerformanceWithCurrencyEffect',
'currentNetWorth',
'currentValue',
'currentValueInBaseCurrency',
'dividendInBaseCurrency',
'emergencyFund',
'excludedAccountsAndActivities',
'fees',
'filteredValueInBaseCurrency',
'fireWealth',
'grossPerformance',
'grossPerformanceWithCurrencyEffect',
'interest',
'items',
'liabilities',
'netPerformance',
'netPerformanceWithCurrencyEffect',
'totalBuy',
'totalInvestment',
'totalSell',
@ -223,11 +210,11 @@ export class PortfolioController {
holdings[symbol] = {
...portfolioPosition,
assetClass:
hasDetails || portfolioPosition.assetClass === 'CASH'
hasDetails || portfolioPosition.assetClass === AssetClass.LIQUIDITY
? portfolioPosition.assetClass
: undefined,
assetSubClass:
hasDetails || portfolioPosition.assetSubClass === 'CASH'
hasDetails || portfolioPosition.assetSubClass === AssetSubClass.CASH
? portfolioPosition.assetSubClass
: undefined,
countries: hasDetails ? portfolioPosition.countries : [],
@ -328,6 +315,7 @@ export class PortfolioController {
@Query('assetClasses') filterByAssetClasses?: string,
@Query('holdingType') filterByHoldingType?: string,
@Query('query') filterBySearchQuery?: string,
@Query('range') dateRange: DateRange = 'max',
@Query('tags') filterByTags?: string
): Promise<PortfolioHoldingsResponse> {
const filters = this.apiService.buildFiltersFromQueryParams({
@ -339,6 +327,7 @@ export class PortfolioController {
});
const { holdings } = await this.portfolioService.getDetails({
dateRange,
filters,
impersonationId,
userId: this.request.user.id
@ -481,10 +470,14 @@ export class PortfolioController {
.div(performanceInformation.performance.totalInvestment)
.toNumber(),
valueInPercentage:
performanceInformation.performance.currentValue === 0
performanceInformation.performance.currentValueInBaseCurrency ===
0
? 0
: new Big(value)
.div(performanceInformation.performance.currentValue)
.div(
performanceInformation.performance
.currentValueInBaseCurrency
)
.toNumber()
};
}
@ -493,12 +486,12 @@ export class PortfolioController {
performanceInformation.performance = nullifyValuesInObject(
performanceInformation.performance,
[
'currentGrossPerformance',
'currentGrossPerformanceWithCurrencyEffect',
'currentNetPerformance',
'currentNetPerformanceWithCurrencyEffect',
'currentNetWorth',
'currentValue',
'currentValueInBaseCurrency',
'grossPerformance',
'grossPerformanceWithCurrencyEffect',
'netPerformance',
'netPerformanceWithCurrencyEffect',
'totalInvestment'
]
);
@ -515,39 +508,13 @@ export class PortfolioController {
);
performanceInformation.performance = nullifyValuesInObject(
performanceInformation.performance,
['currentNetPerformance', 'currentNetPerformancePercent']
['netPerformance']
);
}
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')
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getPublic(

2
apps/api/src/app/portfolio/portfolio.module.ts

@ -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 { AccountService } from '@ghostfolio/api/app/account/account.service';
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 { ApiModule } from '@ghostfolio/api/services/api/api.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
@ -35,6 +36,7 @@ import { RulesService } from './rules.service';
MarketDataModule,
OrderModule,
PrismaModule,
RedisCacheModule,
SymbolProfileModule,
UserModule
],

10
apps/api/src/app/portfolio/portfolio.service.spec.ts

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

280
apps/api/src/app/portfolio/portfolio.service.ts

@ -31,6 +31,7 @@ import {
EnhancedSymbolProfile,
Filter,
HistoricalDataItem,
InvestmentItem,
PortfolioDetails,
PortfolioInvestments,
PortfolioPerformanceResponse,
@ -38,10 +39,9 @@ import {
PortfolioReport,
PortfolioSummary,
Position,
TimelinePosition,
UserSettings
} from '@ghostfolio/common/interfaces';
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
import { TimelinePosition } from '@ghostfolio/common/models';
import type {
AccountWithValue,
DateRange,
@ -56,6 +56,7 @@ import {
Account,
Type as ActivityType,
AssetClass,
AssetSubClass,
DataSource,
Order,
Platform,
@ -66,7 +67,6 @@ import {
differenceInDays,
format,
isAfter,
isBefore,
isSameMonth,
isSameYear,
parseISO,
@ -212,16 +212,16 @@ export class PortfolioService {
public getAnnualizedPerformancePercent({
daysInMarket,
netPerformancePercent
netPerformancePercentage
}: {
daysInMarket: number;
netPerformancePercent: Big;
netPerformancePercentage: Big;
}): Big {
if (isNumber(daysInMarket) && daysInMarket > 0) {
const exponent = new Big(365).div(daysInMarket).toNumber();
return new Big(
Math.pow(netPerformancePercent.plus(1).toNumber(), exponent)
Math.pow(netPerformancePercentage.plus(1).toNumber(), exponent)
).minus(1);
}
@ -282,8 +282,13 @@ export class PortfolioService {
const portfolioCalculator = this.calculatorFactory.createCalculator({
activities,
dateRange,
userId,
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({
@ -358,8 +363,12 @@ export class PortfolioService {
const portfolioCalculator = this.calculatorFactory.createCalculator({
activities,
dateRange,
userId,
calculationType: PerformanceCalculationType.TWR,
currency: userCurrency
currency: userCurrency,
hasFilters: true, // disable cache
isExperimentalFeatures:
this.request.user?.Settings.settings.isExperimentalFeatures
});
const { currentValueInBaseCurrency, hasErrors, positions } =
@ -383,7 +392,7 @@ export class PortfolioService {
}) ?? false;
const isFilteredByCash = filters?.some(({ id, type }) => {
return id === 'CASH' && type === 'ASSET_CLASS';
return id === AssetClass.LIQUIDITY && type === 'ASSET_CLASS';
});
const isFilteredByClosedHoldings =
@ -398,8 +407,8 @@ export class PortfolioService {
if (
filters?.length === 0 ||
(filters?.length === 1 &&
filters[0].type === 'ASSET_CLASS' &&
filters[0].id === 'CASH')
filters[0].id === AssetClass.LIQUIDITY &&
filters[0].type === 'ASSET_CLASS')
) {
filteredValueInBaseCurrency = filteredValueInBaseCurrency.plus(
cashDetails.balanceInBaseCurrency
@ -729,11 +738,15 @@ export class PortfolioService {
]);
const portfolioCalculator = this.calculatorFactory.createCalculator({
userId,
activities: orders.filter((order) => {
return ['BUY', 'DIVIDEND', 'ITEM', 'SELL'].includes(order.type);
}),
calculationType: PerformanceCalculationType.TWR,
currency: userCurrency
currency: userCurrency,
hasFilters: true,
isExperimentalFeatures:
this.request.user.Settings.settings.isExperimentalFeatures
});
const portfolioStart = portfolioCalculator.getStartDate();
@ -772,15 +785,19 @@ export class PortfolioService {
const dividendYieldPercent = this.getAnnualizedPerformancePercent({
daysInMarket: differenceInDays(new Date(), parseDate(firstBuyDate)),
netPerformancePercent: dividendInBaseCurrency.div(
timeWeightedInvestment
)
netPerformancePercentage: timeWeightedInvestment.eq(0)
? new Big(0)
: dividendInBaseCurrency.div(timeWeightedInvestment)
});
const dividendYieldPercentWithCurrencyEffect =
this.getAnnualizedPerformancePercent({
daysInMarket: differenceInDays(new Date(), parseDate(firstBuyDate)),
netPerformancePercent: dividendInBaseCurrency.div(
netPerformancePercentage: timeWeightedInvestmentWithCurrencyEffect.eq(
0
)
? new Big(0)
: dividendInBaseCurrency.div(
timeWeightedInvestmentWithCurrencyEffect
)
});
@ -931,11 +948,19 @@ export class PortfolioService {
);
if (isEmpty(historicalData)) {
historicalData = await this.dataProviderService.getHistoricalRaw(
[{ dataSource: DataSource.YAHOO, symbol: aSymbol }],
portfolioStart,
new Date()
);
try {
historicalData = await this.dataProviderService.getHistoricalRaw({
dataGatheringItems: [
{ dataSource: DataSource.YAHOO, symbol: aSymbol }
],
from: portfolioStart,
to: new Date()
});
} catch {
historicalData = {
[aSymbol]: {}
};
}
}
const historicalDataArray: HistoricalDataItem[] = [];
@ -1022,8 +1047,12 @@ export class PortfolioService {
const portfolioCalculator = this.calculatorFactory.createCalculator({
activities,
dateRange,
userId,
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();
@ -1163,11 +1192,16 @@ export class PortfolioService {
) => {
const formattedDate = format(date, DATE_FORMAT);
// Store the item in the map, overwriting if the date already exists
if (map[formattedDate]) {
// If the value exists, add the current value to the existing one
map[formattedDate].value += valueInBaseCurrency;
} else {
// Otherwise, initialize the value for that date
map[formattedDate] = {
date: formattedDate,
value: valueInBaseCurrency
};
}
return map;
},
@ -1175,7 +1209,7 @@ export class PortfolioService {
)
);
const { endDate, startDate } = getInterval(dateRange);
const { endDate } = getInterval(dateRange);
const { activities } = await this.orderService.getOrders({
endDate,
@ -1191,26 +1225,31 @@ export class PortfolioService {
firstOrderDate: undefined,
hasErrors: false,
performance: {
currentGrossPerformance: 0,
currentGrossPerformancePercent: 0,
currentGrossPerformancePercentWithCurrencyEffect: 0,
currentGrossPerformanceWithCurrencyEffect: 0,
currentNetPerformance: 0,
currentNetPerformancePercent: 0,
currentNetPerformancePercentWithCurrencyEffect: 0,
currentNetPerformanceWithCurrencyEffect: 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
}
};
}
const portfolioCalculator = this.calculatorFactory.createCalculator({
accountBalanceItems,
activities,
dateRange,
userId,
calculationType: PerformanceCalculationType.TWR,
currency: userCurrency
currency: userCurrency,
hasFilters: filters?.length > 0,
isExperimentalFeatures:
this.request.user.Settings.settings.isExperimentalFeatures
});
const {
@ -1230,14 +1269,16 @@ export class PortfolioService {
let currentNetPerformance = netPerformance;
let currentNetPerformancePercent = netPerformancePercentage;
let currentNetPerformancePercentage = netPerformancePercentage;
let currentNetPerformancePercentWithCurrencyEffect =
let currentNetPerformancePercentageWithCurrencyEffect =
netPerformancePercentageWithCurrencyEffect;
let currentNetPerformanceWithCurrencyEffect =
netPerformanceWithCurrencyEffect;
let currentNetWorth = 0;
const items = await portfolioCalculator.getChart({
dateRange
});
@ -1249,62 +1290,41 @@ export class PortfolioService {
if (itemOfToday) {
currentNetPerformance = new Big(itemOfToday.netPerformance);
currentNetPerformancePercent = new Big(
currentNetPerformancePercentage = new Big(
itemOfToday.netPerformanceInPercentage
).div(100);
currentNetPerformancePercentWithCurrencyEffect = new Big(
currentNetPerformancePercentageWithCurrencyEffect = new Big(
itemOfToday.netPerformanceInPercentageWithCurrencyEffect
).div(100);
currentNetPerformanceWithCurrencyEffect = new Big(
itemOfToday.netPerformanceWithCurrencyEffect
);
}
accountBalanceItems = accountBalanceItems.filter(({ date }) => {
return !isBefore(parseDate(date), startDate);
});
const accountBalanceItemOfToday = accountBalanceItems.find(({ date }) => {
return date === format(new Date(), DATE_FORMAT);
});
if (!accountBalanceItemOfToday) {
accountBalanceItems.push({
date: format(new Date(), DATE_FORMAT),
value: last(accountBalanceItems)?.value ?? 0
});
currentNetWorth = itemOfToday.netWorth;
}
const mergedHistoricalDataItems = this.mergeHistoricalDataItems(
accountBalanceItems,
items
);
const currentHistoricalDataItem = last(mergedHistoricalDataItems);
const currentNetWorth = currentHistoricalDataItem?.netWorth ?? 0;
return {
errors,
hasErrors,
chart: mergedHistoricalDataItems,
chart: items,
firstOrderDate: parseDate(items[0]?.date),
performance: {
currentNetWorth,
currentGrossPerformance: grossPerformance.toNumber(),
currentGrossPerformancePercent: grossPerformancePercentage.toNumber(),
currentGrossPerformancePercentWithCurrencyEffect:
currentValueInBaseCurrency: currentValueInBaseCurrency.toNumber(),
grossPerformance: grossPerformance.toNumber(),
grossPerformancePercentage: grossPerformancePercentage.toNumber(),
grossPerformancePercentageWithCurrencyEffect:
grossPerformancePercentageWithCurrencyEffect.toNumber(),
currentGrossPerformanceWithCurrencyEffect:
grossPerformanceWithCurrencyEffect:
grossPerformanceWithCurrencyEffect.toNumber(),
currentNetPerformance: currentNetPerformance.toNumber(),
currentNetPerformancePercent: currentNetPerformancePercent.toNumber(),
currentNetPerformancePercentWithCurrencyEffect:
currentNetPerformancePercentWithCurrencyEffect.toNumber(),
currentNetPerformanceWithCurrencyEffect:
netPerformance: currentNetPerformance.toNumber(),
netPerformancePercentage: currentNetPerformancePercentage.toNumber(),
netPerformancePercentageWithCurrencyEffect:
currentNetPerformancePercentageWithCurrencyEffect.toNumber(),
netPerformanceWithCurrencyEffect:
currentNetPerformanceWithCurrencyEffect.toNumber(),
currentValue: currentValueInBaseCurrency.toNumber(),
totalInvestment: totalInvestment.toNumber()
}
};
@ -1323,8 +1343,12 @@ export class PortfolioService {
const portfolioCalculator = this.calculatorFactory.createCalculator({
activities,
userId,
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 } =
@ -1549,8 +1573,8 @@ export class PortfolioService {
return {
currency,
allocationInPercentage: 0,
assetClass: AssetClass.CASH,
assetSubClass: AssetClass.CASH,
assetClass: AssetClass.LIQUIDITY,
assetSubClass: AssetSubClass.CASH,
countries: [],
dataSource: undefined,
dateOfFirstActivity: undefined,
@ -1701,26 +1725,6 @@ export class PortfolioService {
}): Promise<PortfolioSummary> {
userId = await this.getUserId(impersonationId, userId);
const user = await this.userService.user({ id: userId });
let performanceInformation: PortfolioPerformanceResponse = {
chart: [],
firstOrderDate: undefined,
performance: {
annualizedPerformancePercent: 0,
currentGrossPerformance: 0,
currentGrossPerformancePercent: 0,
currentGrossPerformancePercentWithCurrencyEffect: 0,
currentGrossPerformanceWithCurrencyEffect: 0,
currentNetPerformance: 0,
currentNetPerformancePercent: 0,
currentNetPerformancePercentWithCurrencyEffect: 0,
currentNetPerformanceWithCurrencyEffect: 0,
currentNetWorth: 0,
currentValue: 0,
totalInvestment: 0
},
errors: [],
hasErrors: false
};
const { activities } = await this.orderService.getOrders({
userCurrency,
@ -1738,12 +1742,18 @@ export class PortfolioService {
}
}
let excludedAccountsAndActivities = 0;
performanceInformation = await this.getPerformance({
impersonationId,
userId
});
const {
currentValueInBaseCurrency,
grossPerformance,
grossPerformancePercentage,
grossPerformancePercentageWithCurrencyEffect,
grossPerformanceWithCurrencyEffect,
netPerformance,
netPerformancePercentage,
netPerformancePercentageWithCurrencyEffect,
netPerformanceWithCurrencyEffect,
totalInvestment
} = await portfolioCalculator.getSnapshot();
const dividendInBaseCurrency =
await portfolioCalculator.getDividendInBaseCurrency();
@ -1812,7 +1822,7 @@ export class PortfolioService {
.toNumber();
const netWorth = new Big(balanceInBaseCurrency)
.plus(performanceInformation.performance.currentValue)
.plus(currentValueInBaseCurrency)
.plus(valuables)
.plus(excludedAccountsAndActivities)
.minus(liabilities)
@ -1822,21 +1832,18 @@ export class PortfolioService {
const annualizedPerformancePercent = this.getAnnualizedPerformancePercent({
daysInMarket,
netPerformancePercent: new Big(
performanceInformation.performance.currentNetPerformancePercent
)
netPerformancePercentage: new Big(netPerformancePercentage)
})?.toNumber();
const annualizedPerformancePercentWithCurrencyEffect =
this.getAnnualizedPerformancePercent({
daysInMarket,
netPerformancePercent: new Big(
performanceInformation.performance.currentNetPerformancePercentWithCurrencyEffect
netPerformancePercentage: new Big(
netPerformancePercentageWithCurrencyEffect
)
})?.toNumber();
return {
...performanceInformation.performance,
annualizedPerformancePercent,
annualizedPerformancePercentWithCurrencyEffect,
cash,
@ -1845,6 +1852,7 @@ export class PortfolioService {
totalBuy,
totalSell,
committedFunds: committedFunds.toNumber(),
currentValueInBaseCurrency: currentValueInBaseCurrency.toNumber(),
dividendInBaseCurrency: dividendInBaseCurrency.toNumber(),
emergencyFund: {
assets: emergencyFundPositionsValueInBaseCurrency,
@ -1858,15 +1866,28 @@ export class PortfolioService {
filteredValueInPercentage: netWorth
? filteredValueInBaseCurrency.div(netWorth).toNumber()
: undefined,
fireWealth: new Big(performanceInformation.performance.currentValue)
fireWealth: new Big(currentValueInBaseCurrency)
.minus(emergencyFundPositionsValueInBaseCurrency)
.toNumber(),
grossPerformance: grossPerformance.toNumber(),
grossPerformancePercentage: grossPerformancePercentage.toNumber(),
grossPerformancePercentageWithCurrencyEffect:
grossPerformancePercentageWithCurrencyEffect.toNumber(),
grossPerformanceWithCurrencyEffect:
grossPerformanceWithCurrencyEffect.toNumber(),
interest: interest.toNumber(),
items: valuables.toNumber(),
liabilities: liabilities.toNumber(),
netPerformance: netPerformance.toNumber(),
netPerformancePercentage: netPerformancePercentage.toNumber(),
netPerformancePercentageWithCurrencyEffect:
netPerformancePercentageWithCurrencyEffect.toNumber(),
netPerformanceWithCurrencyEffect:
netPerformanceWithCurrencyEffect.toNumber(),
ordersCount: activities.filter(({ type }) => {
return type === 'BUY' || type === 'SELL';
return ['BUY', 'SELL'].includes(type);
}).length,
totalInvestment: totalInvestment.toNumber(),
totalValueInBaseCurrency: netWorth
};
}
@ -2043,45 +2064,4 @@ export class PortfolioService {
return { accounts, platforms };
}
@LogPerformance
private mergeHistoricalDataItems(
accountBalanceItems: HistoricalDataItem[],
performanceChartItems: HistoricalDataItem[]
): HistoricalDataItem[] {
const historicalDataItemsMap: { [date: string]: HistoricalDataItem } = {};
let latestAccountBalance = 0;
for (const item of accountBalanceItems.concat(performanceChartItems)) {
const isAccountBalanceItem = accountBalanceItems.includes(item);
const totalAccountBalance = isAccountBalanceItem
? item.value
: latestAccountBalance;
if (isAccountBalanceItem && performanceChartItems.length > 0) {
latestAccountBalance = item.value;
} else {
historicalDataItemsMap[item.date] = {
...item,
totalAccountBalance,
netWorth:
(isAccountBalanceItem ? 0 : item.value) + totalAccountBalance
};
}
}
// Convert to an array and sort by date in ascending order
const historicalDataItems = Object.keys(historicalDataItemsMap).map(
(date) => {
return historicalDataItemsMap[date];
}
);
historicalDataItems.sort((a, b) => {
return new Date(a.date).getTime() - new Date(b.date).getTime();
});
return historicalDataItems;
}
}

13
apps/api/src/app/redis-cache/redis-cache.service.mock.ts

@ -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);
}
};

4
apps/api/src/app/redis-cache/redis-cache.service.ts

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

20
apps/api/src/app/symbol/symbol.service.ts

@ -74,11 +74,21 @@ export class SymbolService {
date = new Date(),
symbol
}: IDataGatheringItem): Promise<IDataProviderHistoricalResponse> {
const historicalData = await this.dataProviderService.getHistoricalRaw(
[{ dataSource, symbol }],
date,
date
);
let historicalData: {
[symbol: string]: {
[date: string]: IDataProviderHistoricalResponse;
};
} = {
[symbol]: {}
};
try {
historicalData = await this.dataProviderService.getHistoricalRaw({
dataGatheringItems: [{ dataSource, symbol }],
from: date,
to: date
});
} catch {}
return {
marketPrice:

13
apps/api/src/app/user/user.controller.ts

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

20
apps/api/src/app/user/user.service.ts

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

11
apps/api/src/events/events.module.ts

@ -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 {}

15
apps/api/src/events/portfolio-changed.event.ts

@ -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;
}
}

25
apps/api/src/events/portfolio-changed.listener.ts

@ -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()
})
);
}
}

1
apps/api/src/interceptors/redact-values-in-response.interceptor.ts

@ -57,6 +57,7 @@ export class RedactValuesInResponseInterceptor<T>
'quantity',
'symbolMapping',
'totalBalanceInBaseCurrency',
'totalValueInBaseCurrency',
'unitPrice',
'value',
'valueInBaseCurrency'

3
apps/api/src/models/rule.ts

@ -1,7 +1,8 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
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 { RuleInterface } from './interfaces/rule.interface';

3
apps/api/src/models/rules/currency-cluster-risk/base-currency-current-investment.ts

@ -1,7 +1,8 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { Rule } from '@ghostfolio/api/models/rule';
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> {
private positions: TimelinePosition[];

3
apps/api/src/models/rules/currency-cluster-risk/current-investment.ts

@ -1,7 +1,8 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { Rule } from '@ghostfolio/api/models/rule';
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> {
private positions: TimelinePosition[];

20
apps/api/src/services/data-gathering/data-gathering.processor.ts

@ -37,7 +37,17 @@ export class DataGatheringProcessor {
@Process({ concurrency: 1, name: GATHER_ASSET_PROFILE_PROCESS })
public async gatherAssetProfile(job: Job<UniqueAsset>) {
try {
Logger.log(
`Asset profile data gathering has been started for ${job.data.symbol} (${job.data.dataSource})`,
`DataGatheringProcessor (${GATHER_ASSET_PROFILE_PROCESS})`
);
await this.dataGatheringService.gatherAssetProfiles([job.data]);
Logger.log(
`Asset profile data gathering has been completed for ${job.data.symbol} (${job.data.dataSource})`,
`DataGatheringProcessor (${GATHER_ASSET_PROFILE_PROCESS})`
);
} catch (error) {
Logger.error(
error,
@ -62,11 +72,11 @@ export class DataGatheringProcessor {
`DataGatheringProcessor (${GATHER_HISTORICAL_MARKET_DATA_PROCESS})`
);
const historicalData = await this.dataProviderService.getHistoricalRaw(
[{ dataSource, symbol }],
currentDate,
new Date()
);
const historicalData = await this.dataProviderService.getHistoricalRaw({
dataGatheringItems: [{ dataSource, symbol }],
from: currentDate,
to: new Date()
});
const data: Prisma.MarketDataUpdateInput[] = [];
let lastMarketPrice: number;

23
apps/api/src/services/data-gathering/data-gathering.service.ts

@ -107,11 +107,11 @@ export class DataGatheringService {
symbol: string;
}) {
try {
const historicalData = await this.dataProviderService.getHistoricalRaw(
[{ dataSource, symbol }],
date,
date
);
const historicalData = await this.dataProviderService.getHistoricalRaw({
dataGatheringItems: [{ dataSource, symbol }],
from: date,
to: date
});
const marketPrice =
historicalData[symbol][format(date, DATE_FORMAT)].marketPrice;
@ -238,17 +238,12 @@ export class DataGatheringService {
error,
'DataGatheringService'
);
if (uniqueAssets.length === 1) {
throw error;
}
}
}
Logger.log(
`Asset profile data gathering has been completed for ${uniqueAssets
.map(({ dataSource, symbol }) => {
return `${symbol} (${dataSource})`;
})
.join(',')}.`,
'DataGatheringService'
);
}
public async gatherSymbols({

4
apps/api/src/services/data-provider/coingecko/coingecko.service.ts

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

2
apps/api/src/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service.ts

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

31
apps/api/src/services/data-provider/data-provider.service.ts

@ -234,15 +234,17 @@ export class DataProviderService {
}
}
public async getHistoricalRaw(
aDataGatheringItems: UniqueAsset[],
from: Date,
to: Date
): Promise<{
public async getHistoricalRaw({
dataGatheringItems,
from,
to
}: {
dataGatheringItems: UniqueAsset[];
from: Date;
to: Date;
}): Promise<{
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
}> {
let dataGatheringItems = aDataGatheringItems;
for (const { currency, rootCurrency } of DERIVED_CURRENCIES) {
if (
this.hasCurrency({
@ -331,6 +333,8 @@ export class DataProviderService {
}
} catch (error) {
Logger.error(error, 'DataProviderService');
throw error;
}
return result;
@ -397,7 +401,8 @@ export class DataProviderService {
numberOfItemsInCache > 1 ? 's' : ''
} from cache in ${((performance.now() - startTimeTotal) / 1000).toFixed(
3
)} seconds`
)} seconds`,
'DataProviderService'
);
}
@ -505,7 +510,8 @@ export class DataProviderService {
} from ${dataSource} in ${(
(performance.now() - startTimeDataSource) /
1000
).toFixed(3)} seconds`
).toFixed(3)} seconds`,
'DataProviderService'
);
try {
@ -535,14 +541,15 @@ export class DataProviderService {
await Promise.all(promises);
Logger.debug('------------------------------------------------');
Logger.debug('--------------------------------------------------------');
Logger.debug(
`Fetched ${items.length} quote${items.length > 1 ? 's' : ''} in ${(
(performance.now() - startTimeTotal) /
1000
).toFixed(3)} seconds`
).toFixed(3)} seconds`,
'DataProviderService'
);
Logger.debug('================================================');
Logger.debug('========================================================');
return response;
}

2
apps/api/src/services/data-provider/eod-historical-data/eod-historical-data.service.ts

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

2
apps/api/src/services/data-provider/manual/manual.service.ts

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

13
apps/api/src/services/exchange-rate-data/exchange-rate-data.service.ts

@ -449,13 +449,16 @@ export class ExchangeRateDataService {
factors[format(date, DATE_FORMAT)] = factor;
}
} catch {
Logger.error(
`No exchange rate has been found for ${currencyFrom}${currencyTo} at ${format(
let errorMessage = `No exchange rate has been found for ${currencyFrom}${currencyTo} at ${format(
date,
DATE_FORMAT
)}. Please complement market data for ${DEFAULT_CURRENCY}${currencyFrom} and ${DEFAULT_CURRENCY}${currencyTo}.`,
'ExchangeRateDataService'
);
)}. Please complement market data for ${DEFAULT_CURRENCY}${currencyFrom}`;
if (DEFAULT_CURRENCY !== currencyTo) {
errorMessage = `${errorMessage} and ${DEFAULT_CURRENCY}${currencyTo}`;
}
Logger.error(`${errorMessage}.`, 'ExchangeRateDataService');
}
}
}

4
apps/client/src/app/app.module.ts

@ -1,4 +1,4 @@
import { GfLogoModule } from '@ghostfolio/ui/logo';
import { GfLogoComponent } from '@ghostfolio/ui/logo';
import { Platform } from '@angular/cdk/platform';
import { HttpClientModule } from '@angular/common/http';
@ -43,7 +43,7 @@ export function NgxStripeFactory(): string {
BrowserAnimationsModule,
BrowserModule,
GfHeaderModule,
GfLogoModule,
GfLogoComponent,
GfSubscriptionInterstitialDialogModule,
HttpClientModule,
MarkdownModule.forRoot(),

128
apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts

@ -84,71 +84,46 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
}
public ngOnInit() {
this.dataService
.fetchAccount(this.data.accountId)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(
({
balance,
currency,
name,
Platform,
transactionCount,
value,
valueInBaseCurrency
}) => {
this.balance = balance;
this.currency = currency;
if (isNumber(balance) && isNumber(value)) {
this.equity = new Big(value).minus(balance).toNumber();
} else {
this.equity = null;
this.fetchAccount();
this.fetchAccountBalances();
this.fetchActivities();
this.fetchPortfolioHoldings();
this.fetchPortfolioPerformance();
}
this.name = name;
this.platformName = Platform?.name ?? '-';
this.transactionCount = transactionCount;
this.valueInBaseCurrency = valueInBaseCurrency;
this.changeDetectorRef.markForCheck();
public onClose() {
this.dialogRef.close();
}
);
public onAddAccountBalance({
balance,
date
}: {
balance: number;
date: Date;
}) {
this.dataService
.fetchPortfolioHoldings({
filters: [
{
type: 'ACCOUNT',
id: this.data.accountId
}
]
.postAccountBalance({
balance,
date,
accountId: this.data.accountId
})
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ holdings }) => {
this.holdings = holdings;
this.changeDetectorRef.markForCheck();
});
.subscribe(() => {
this.fetchAccount();
this.fetchAccountBalances();
this.fetchActivities();
this.fetchPortfolioPerformance();
}
public onClose() {
this.dialogRef.close();
});
}
public onDeleteAccountBalance(aId: string) {
this.dataService
.deleteAccountBalance(aId)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe({
next: () => {
.subscribe(() => {
this.fetchAccount();
this.fetchAccountBalances();
this.fetchPortfolioPerformance();
}
});
}
@ -181,6 +156,39 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
this.fetchActivities();
}
private fetchAccount() {
this.dataService
.fetchAccount(this.data.accountId)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(
({
balance,
currency,
name,
Platform,
transactionCount,
value,
valueInBaseCurrency
}) => {
this.balance = balance;
this.currency = currency;
if (isNumber(balance) && isNumber(value)) {
this.equity = new Big(value).minus(balance).toNumber();
} else {
this.equity = null;
}
this.name = name;
this.platformName = Platform?.name ?? '-';
this.transactionCount = transactionCount;
this.valueInBaseCurrency = valueInBaseCurrency;
this.changeDetectorRef.markForCheck();
}
);
}
private fetchAccountBalances() {
this.dataService
.fetchAccountBalances(this.data.accountId)
@ -212,6 +220,24 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
});
}
private fetchPortfolioHoldings() {
this.dataService
.fetchPortfolioHoldings({
filters: [
{
type: 'ACCOUNT',
id: this.data.accountId
}
]
})
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ holdings }) => {
this.holdings = holdings;
this.changeDetectorRef.markForCheck();
});
}
private fetchPortfolioPerformance() {
this.isLoadingChart = true;
@ -233,11 +259,7 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
({ date, netWorth, netWorthInPercentage }) => {
return {
date,
value:
this.data.hasImpersonationId ||
this.user.settings.isRestrictedView
? netWorthInPercentage
: netWorth
value: isNumber(netWorth) ? netWorth : netWorthInPercentage
};
}
);

2
apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html

@ -115,6 +115,7 @@
</ng-template>
<gf-account-balances
[accountBalances]="accountBalances"
[accountCurrency]="currency"
[accountId]="data.accountId"
[locale]="user?.settings?.locale"
[showActions]="
@ -122,6 +123,7 @@
hasPermissionToDeleteAccountBalance &&
!user.settings.isRestrictedView
"
(accountBalanceCreated)="onAddAccountBalance($event)"
(accountBalanceDeleted)="onDeleteAccountBalance($event)"
/>
</mat-tab>

16
apps/client/src/app/components/account-detail-dialog/account-detail-dialog.module.ts

@ -1,10 +1,10 @@
import { GfDialogFooterModule } from '@ghostfolio/client/components/dialog-footer/dialog-footer.module';
import { GfDialogHeaderModule } from '@ghostfolio/client/components/dialog-header/dialog-header.module';
import { GfInvestmentChartModule } from '@ghostfolio/client/components/investment-chart/investment-chart.module';
import { GfAccountBalancesModule } from '@ghostfolio/ui/account-balances/account-balances.module';
import { GfActivitiesTableModule } from '@ghostfolio/ui/activities-table/activities-table.module';
import { GfHoldingsTableModule } from '@ghostfolio/ui/holdings-table/holdings-table.module';
import { GfValueModule } from '@ghostfolio/ui/value';
import { GfAccountBalancesComponent } from '@ghostfolio/ui/account-balances';
import { GfActivitiesTableComponent } from '@ghostfolio/ui/activities-table';
import { GfHoldingsTableComponent } from '@ghostfolio/ui/holdings-table';
import { GfValueComponent } from '@ghostfolio/ui/value';
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
@ -19,13 +19,13 @@ import { AccountDetailDialog } from './account-detail-dialog.component';
declarations: [AccountDetailDialog],
imports: [
CommonModule,
GfAccountBalancesModule,
GfActivitiesTableModule,
GfAccountBalancesComponent,
GfActivitiesTableComponent,
GfDialogFooterModule,
GfDialogHeaderModule,
GfHoldingsTableModule,
GfHoldingsTableComponent,
GfInvestmentChartModule,
GfValueModule,
GfValueComponent,
MatButtonModule,
MatDialogModule,
MatTabsModule,

4
apps/client/src/app/components/accounts-table/accounts-table.module.ts

@ -1,5 +1,5 @@
import { GfAssetProfileIconComponent } from '@ghostfolio/client/components/asset-profile-icon/asset-profile-icon.component';
import { GfValueModule } from '@ghostfolio/ui/value';
import { GfValueComponent } from '@ghostfolio/ui/value';
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
@ -18,7 +18,7 @@ import { AccountsTableComponent } from './accounts-table.component';
imports: [
CommonModule,
GfAssetProfileIconComponent,
GfValueModule,
GfValueComponent,
MatButtonModule,
MatMenuModule,
MatSortModule,

4
apps/client/src/app/components/admin-market-data-detail/admin-market-data-detail.module.ts

@ -1,4 +1,4 @@
import { GfLineChartModule } from '@ghostfolio/ui/line-chart/line-chart.module';
import { GfLineChartComponent } from '@ghostfolio/ui/line-chart';
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
@ -9,7 +9,7 @@ import { GfMarketDataDetailDialogModule } from './market-data-detail-dialog/mark
@NgModule({
declarations: [AdminMarketDataDetailComponent],
exports: [AdminMarketDataDetailComponent],
imports: [CommonModule, GfLineChartModule, GfMarketDataDetailDialogModule],
imports: [CommonModule, GfLineChartComponent, GfMarketDataDetailDialogModule],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class GfAdminMarketDataDetailModule {}

1
apps/client/src/app/components/admin-market-data/admin-market-data.component.ts

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

4
apps/client/src/app/components/admin-market-data/admin-market-data.module.ts

@ -1,5 +1,5 @@
import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module';
import { GfActivitiesFilterModule } from '@ghostfolio/ui/activities-filter/activities-filter.module';
import { GfActivitiesFilterComponent } from '@ghostfolio/ui/activities-filter';
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
@ -20,7 +20,7 @@ import { GfCreateAssetProfileDialogModule } from './create-asset-profile-dialog/
declarations: [AdminMarketDataComponent],
imports: [
CommonModule,
GfActivitiesFilterModule,
GfActivitiesFilterComponent,
GfAssetProfileDialogModule,
GfCreateAssetProfileDialogModule,
GfSymbolModule,

44
apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts

@ -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 { AdminService } from '@ghostfolio/client/services/admin.service';
import { DataService } from '@ghostfolio/client/services/data.service';
import { validateObjectForForm } from '@ghostfolio/client/util/form.util';
import { ghostfolioScraperApiSymbolPrefix } from '@ghostfolio/common/config';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import {
@ -281,29 +282,29 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
});
}
public onSubmit() {
public async onSubmit() {
let countries = [];
let scraperConfiguration = {};
let sectors = [];
let symbolMapping = {};
try {
countries = JSON.parse(this.assetProfileForm.controls['countries'].value);
countries = JSON.parse(this.assetProfileForm.get('countries').value);
} catch {}
try {
scraperConfiguration = JSON.parse(
this.assetProfileForm.controls['scraperConfiguration'].value
this.assetProfileForm.get('scraperConfiguration').value
);
} catch {}
try {
sectors = JSON.parse(this.assetProfileForm.controls['sectors'].value);
sectors = JSON.parse(this.assetProfileForm.get('sectors').value);
} catch {}
try {
symbolMapping = JSON.parse(
this.assetProfileForm.controls['symbolMapping'].value
this.assetProfileForm.get('symbolMapping').value
);
} catch {}
@ -312,19 +313,27 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
scraperConfiguration,
sectors,
symbolMapping,
assetClass: this.assetProfileForm.controls['assetClass'].value,
assetSubClass: this.assetProfileForm.controls['assetSubClass'].value,
comment: this.assetProfileForm.controls['comment'].value ?? null,
name: this.assetProfileForm.controls['name'].value,
tags: this.assetProfileForm.controls['tags'].value,
assetClass: this.assetProfileForm.get('assetClass').value,
assetSubClass: this.assetProfileForm.get('assetSubClass').value,
comment: this.assetProfileForm.get('comment').value || null,
tags: this.assetProfileForm.get('tags').value,
currency: (<Currency>(
(<unknown>this.assetProfileForm.controls['currency'].value)
(<unknown>this.assetProfileForm.get('currency').value)
))?.value,
name: this.assetProfileForm.controls['name'].value,
url: this.assetProfileForm.controls['url'].value
};
assetProfileData.url =
assetProfileData.url?.length > 0 ? assetProfileData.url : null;
try {
await validateObjectForForm({
classDto: UpdateAssetProfileDto,
form: this.assetProfileForm,
object: assetProfileData
});
} catch (error) {
console.error(error);
return;
}
this.adminService
.patchAssetProfile({
@ -341,8 +350,8 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
this.adminService
.testMarketData({
dataSource: this.data.dataSource,
scraperConfiguration:
this.assetProfileForm.controls['scraperConfiguration'].value,
scraperConfiguration: this.assetProfileForm.get('scraperConfiguration')
.value,
symbol: this.data.symbol
})
.pipe(
@ -358,9 +367,8 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
' ' +
price +
' ' +
(<Currency>(
(<unknown>this.assetProfileForm.controls['currency'].value)
))?.value
(<Currency>(<unknown>this.assetProfileForm.get('currency').value))
?.value
);
});
}

6
apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html

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

12
apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.module.ts

@ -1,9 +1,9 @@
import { GfAdminMarketDataDetailModule } from '@ghostfolio/client/components/admin-market-data-detail/admin-market-data-detail.module';
import { AdminMarketDataService } from '@ghostfolio/client/components/admin-market-data/admin-market-data.service';
import { GfAssetProfileIconComponent } from '@ghostfolio/client/components/asset-profile-icon/asset-profile-icon.component';
import { GfCurrencySelectorModule } from '@ghostfolio/ui/currency-selector/currency-selector.module';
import { GfPortfolioProportionChartModule } from '@ghostfolio/ui/portfolio-proportion-chart/portfolio-proportion-chart.module';
import { GfValueModule } from '@ghostfolio/ui/value';
import { GfCurrencySelectorComponent } from '@ghostfolio/ui/currency-selector';
import { GfPortfolioProportionChartComponent } from '@ghostfolio/ui/portfolio-proportion-chart';
import { GfValueComponent } from '@ghostfolio/ui/value';
import { TextFieldModule } from '@angular/cdk/text-field';
import { CommonModule } from '@angular/common';
@ -28,11 +28,11 @@ import { AssetProfileDialog } from './asset-profile-dialog.component';
FormsModule,
GfAdminMarketDataDetailModule,
GfAssetProfileIconComponent,
GfCurrencySelectorModule,
GfPortfolioProportionChartModule,
GfCurrencySelectorComponent,
GfPortfolioProportionChartComponent,
MatAutocompleteModule,
MatChipsModule,
GfValueModule,
GfValueComponent,
MatButtonModule,
MatCheckboxModule,
MatDialogModule,

8
apps/client/src/app/components/admin-market-data/create-asset-profile-dialog/create-asset-profile-dialog.component.ts

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

4
apps/client/src/app/components/admin-market-data/create-asset-profile-dialog/create-asset-profile-dialog.module.ts

@ -1,4 +1,4 @@
import { GfSymbolAutocompleteModule } from '@ghostfolio/ui/symbol-autocomplete';
import { GfSymbolAutocompleteComponent } from '@ghostfolio/ui/symbol-autocomplete';
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
@ -16,7 +16,7 @@ import { CreateAssetProfileDialog } from './create-asset-profile-dialog.componen
imports: [
CommonModule,
FormsModule,
GfSymbolAutocompleteModule,
GfSymbolAutocompleteComponent,
MatDialogModule,
MatButtonModule,
MatFormFieldModule,

4
apps/client/src/app/components/admin-overview/admin-overview.module.ts

@ -1,5 +1,5 @@
import { CacheService } from '@ghostfolio/client/services/cache.service';
import { GfValueModule } from '@ghostfolio/ui/value';
import { GfValueComponent } from '@ghostfolio/ui/value';
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
@ -19,7 +19,7 @@ import { AdminOverviewComponent } from './admin-overview.component';
imports: [
CommonModule,
FormsModule,
GfValueModule,
GfValueComponent,
MatButtonModule,
MatCardModule,
MatMenuModule,

8
apps/client/src/app/components/admin-platform/admin-platform.component.ts

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

54
apps/client/src/app/components/admin-platform/create-or-update-platform-dialog/create-or-update-platform-dialog.component.ts

@ -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 { Subject } from 'rxjs';
@ -11,18 +21,54 @@ import { CreateOrUpdatePlatformDialogParams } from './interfaces/interfaces';
styleUrls: ['./create-or-update-platform-dialog.scss'],
templateUrl: 'create-or-update-platform-dialog.html'
})
export class CreateOrUpdatePlatformDialog {
export class CreateOrUpdatePlatformDialog implements OnDestroy {
public platformForm: FormGroup;
private unsubscribeSubject = new Subject<void>();
public constructor(
@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() {
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() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();

25
apps/client/src/app/components/admin-platform/create-or-update-platform-dialog/create-or-update-platform-dialog.html

@ -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>Add platform</h1>
<div class="flex-grow-1 py-3" mat-dialog-content>
<div>
<mat-form-field appearance="outline" class="w-100">
<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>
</div>
<div>
<mat-form-field appearance="outline" class="w-100">
<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) {
<gf-asset-profile-icon
class="mr-3"
@ -23,12 +36,12 @@
</div>
</div>
<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
color="primary"
mat-flat-button
[disabled]="!addPlatformForm.form.valid"
[mat-dialog-close]="data"
type="submit"
[disabled]="!platformForm.valid"
>
<ng-container i18n>Save</ng-container>
</button>

8
apps/client/src/app/components/admin-tag/admin-tag.component.ts

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

52
apps/client/src/app/components/admin-tag/create-or-update-tag-dialog/create-or-update-tag-dialog.component.ts

@ -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 { Subject } from 'rxjs';
@ -11,18 +21,52 @@ import { CreateOrUpdateTagDialogParams } from './interfaces/interfaces';
styleUrls: ['./create-or-update-tag-dialog.scss'],
templateUrl: 'create-or-update-tag-dialog.html'
})
export class CreateOrUpdateTagDialog {
export class CreateOrUpdateTagDialog implements OnDestroy {
public tagForm: FormGroup;
private unsubscribeSubject = new Subject<void>();
public constructor(
@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() {
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() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();

19
apps/client/src/app/components/admin-tag/create-or-update-tag-dialog/create-or-update-tag-dialog.html

@ -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>Add tag</h1>
<div class="flex-grow-1 py-3" mat-dialog-content>
<div>
<mat-form-field appearance="outline" class="w-100">
<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>
</div>
</div>
<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
color="primary"
mat-flat-button
[disabled]="!addTagForm.form.valid"
[mat-dialog-close]="data"
type="submit"
[disabled]="!tagForm.valid"
>
<ng-container i18n>Save</ng-container>
</button>

8
apps/client/src/app/components/admin-users/admin-users.module.ts

@ -1,5 +1,5 @@
import { GfPremiumIndicatorModule } from '@ghostfolio/ui/premium-indicator';
import { GfValueModule } from '@ghostfolio/ui/value';
import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator';
import { GfValueComponent } from '@ghostfolio/ui/value';
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
@ -14,8 +14,8 @@ import { AdminUsersComponent } from './admin-users.component';
exports: [],
imports: [
CommonModule,
GfPremiumIndicatorModule,
GfValueModule,
GfPremiumIndicatorComponent,
GfValueComponent,
MatButtonModule,
MatMenuModule,
MatTableModule

4
apps/client/src/app/components/benchmark-comparator/benchmark-comparator.module.ts

@ -1,4 +1,4 @@
import { GfPremiumIndicatorModule } from '@ghostfolio/ui/premium-indicator';
import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator';
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
@ -15,7 +15,7 @@ import { BenchmarkComparatorComponent } from './benchmark-comparator.component';
imports: [
CommonModule,
FormsModule,
GfPremiumIndicatorModule,
GfPremiumIndicatorComponent,
MatSelectModule,
NgxSkeletonLoaderModule,
ReactiveFormsModule,

4
apps/client/src/app/components/header/header.component.ts

@ -12,7 +12,7 @@ import { UserService } from '@ghostfolio/client/services/user/user.service';
import { Filter, InfoItem, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { DateRange } from '@ghostfolio/common/types';
import { AssistantComponent } from '@ghostfolio/ui/assistant/assistant.component';
import { GfAssistantComponent } from '@ghostfolio/ui/assistant/assistant.component';
import {
ChangeDetectionStrategy,
@ -62,7 +62,7 @@ export class HeaderComponent implements OnChanges {
@Output() signOut = new EventEmitter<void>();
@ViewChild('assistant') assistantElement: AssistantComponent;
@ViewChild('assistant') assistantElement: GfAssistantComponent;
@ViewChild('assistantTrigger') assistentMenuTriggerElement: MatMenuTrigger;
public hasPermissionForSocialLogin: boolean;

12
apps/client/src/app/components/header/header.module.ts

@ -1,7 +1,7 @@
import { LoginWithAccessTokenDialogModule } from '@ghostfolio/client/components/login-with-access-token-dialog/login-with-access-token-dialog.module';
import { GfAssistantModule } from '@ghostfolio/ui/assistant';
import { GfLogoModule } from '@ghostfolio/ui/logo';
import { GfPremiumIndicatorModule } from '@ghostfolio/ui/premium-indicator';
import { GfAssistantComponent } from '@ghostfolio/ui/assistant';
import { GfLogoComponent } from '@ghostfolio/ui/logo';
import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator';
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
@ -17,9 +17,9 @@ import { HeaderComponent } from './header.component';
exports: [HeaderComponent],
imports: [
CommonModule,
GfAssistantModule,
GfLogoModule,
GfPremiumIndicatorModule,
GfAssistantComponent,
GfLogoComponent,
GfPremiumIndicatorComponent,
LoginWithAccessTokenDialogModule,
MatButtonModule,
MatMenuModule,

96
apps/client/src/app/components/home-holdings/home-holdings.component.ts

@ -1,11 +1,11 @@
import { PositionDetailDialog } from '@ghostfolio/client/components/position/position-detail-dialog/position-detail-dialog.component';
import { ToggleComponent } from '@ghostfolio/client/components/toggle/toggle.component';
import { PositionDetailDialogParams } from '@ghostfolio/client/components/position-detail-dialog/interfaces/interfaces';
import { PositionDetailDialog } from '@ghostfolio/client/components/position-detail-dialog/position-detail-dialog.component';
import { DataService } from '@ghostfolio/client/services/data.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.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 { DateRange } from '@ghostfolio/common/types';
import { HoldingType, ToggleOption } from '@ghostfolio/common/types';
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
@ -15,19 +15,21 @@ import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { PositionDetailDialogParams } from '../position/position-detail-dialog/interfaces/interfaces';
@Component({
selector: 'gf-home-holdings',
styleUrls: ['./home-holdings.scss'],
templateUrl: './home-holdings.html'
})
export class HomeHoldingsComponent implements OnDestroy, OnInit {
public dateRangeOptions = ToggleComponent.DEFAULT_DATE_RANGE_OPTIONS;
public deviceType: string;
public hasImpersonationId: 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;
private unsubscribeSubject = new Subject<void>();
@ -56,6 +58,17 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
});
}
});
}
public ngOnInit() {
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
this.impersonationStorageService
.onChangeHasImpersonation()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((impersonationId) => {
this.hasImpersonationId = !!impersonationId;
});
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
@ -68,38 +81,33 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
permissions.createOrder
);
this.update();
}
});
}
public ngOnInit() {
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
this.holdings = undefined;
this.impersonationStorageService
.onChangeHasImpersonation()
this.fetchHoldings()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((impersonationId) => {
this.hasImpersonationId = !!impersonationId;
.subscribe(({ holdings }) => {
this.holdings = holdings;
this.changeDetectorRef.markForCheck();
});
this.changeDetectorRef.markForCheck();
}
});
}
public onChangeDateRange(dateRange: DateRange) {
this.dataService
.putUserSetting({ dateRange })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.userService.remove();
public onChangeHoldingType(aHoldingType: HoldingType) {
this.holdingType = aHoldingType;
this.userService
.get()
this.holdings = undefined;
this.fetchHoldings()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((user) => {
this.user = user;
.subscribe(({ holdings }) => {
this.holdings = holdings;
this.changeDetectorRef.markForCheck();
});
});
}
public ngOnDestroy() {
@ -107,6 +115,19 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
this.unsubscribeSubject.complete();
}
private fetchHoldings() {
const filters = this.userService.getFilters();
if (this.holdingType === 'CLOSED') {
filters.push({ id: 'CLOSED', type: 'HOLDING_TYPE' });
}
return this.dataService.fetchPortfolioHoldings({
filters,
range: this.user?.settings?.dateRange
});
}
private openPositionDialog({
dataSource,
symbol
@ -147,19 +168,4 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
});
});
}
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();
}
}

33
apps/client/src/app/components/home-holdings/home-holdings.html

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

8
apps/client/src/app/components/home-holdings/home-holdings.module.ts

@ -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 { GfHoldingsTableComponent } from '@ghostfolio/ui/holdings-table';
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { RouterModule } from '@angular/router';
import { HomeHoldingsComponent } from './home-holdings.component';
@ -14,11 +12,9 @@ import { HomeHoldingsComponent } from './home-holdings.component';
declarations: [HomeHoldingsComponent],
imports: [
CommonModule,
GfPositionDetailDialogModule,
GfPositionsModule,
GfHoldingsTableComponent,
GfToggleModule,
MatButtonModule,
MatCardModule,
RouterModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]

4
apps/client/src/app/components/home-market/home-market.html

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

8
apps/client/src/app/components/home-market/home-market.module.ts

@ -1,6 +1,6 @@
import { GfFearAndGreedIndexModule } from '@ghostfolio/client/components/fear-and-greed-index/fear-and-greed-index.module';
import { GfBenchmarkModule } from '@ghostfolio/ui/benchmark/benchmark.module';
import { GfLineChartModule } from '@ghostfolio/ui/line-chart/line-chart.module';
import { GfBenchmarkComponent } from '@ghostfolio/ui/benchmark';
import { GfLineChartComponent } from '@ghostfolio/ui/line-chart';
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
@ -13,9 +13,9 @@ import { HomeMarketComponent } from './home-market.component';
exports: [HomeMarketComponent],
imports: [
CommonModule,
GfBenchmarkModule,
GfBenchmarkComponent,
GfFearAndGreedIndexModule,
GfLineChartModule,
GfLineChartComponent,
NgxSkeletonLoaderModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]

8
apps/client/src/app/components/home-overview/home-overview.module.ts

@ -1,6 +1,6 @@
import { GfPortfolioPerformanceModule } from '@ghostfolio/client/components/portfolio-performance/portfolio-performance.module';
import { GfLineChartModule } from '@ghostfolio/ui/line-chart/line-chart.module';
import { GfNoTransactionsInfoModule } from '@ghostfolio/ui/no-transactions-info';
import { GfLineChartComponent } from '@ghostfolio/ui/line-chart';
import { GfNoTransactionsInfoComponent } from '@ghostfolio/ui/no-transactions-info';
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
@ -13,8 +13,8 @@ import { HomeOverviewComponent } from './home-overview.component';
declarations: [HomeOverviewComponent],
imports: [
CommonModule,
GfLineChartModule,
GfNoTransactionsInfoModule,
GfLineChartComponent,
GfNoTransactionsInfoComponent,
GfPortfolioPerformanceModule,
MatButtonModule,
RouterModule

6
apps/client/src/app/components/portfolio-performance/portfolio-performance.component.html

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

9
apps/client/src/app/components/portfolio-performance/portfolio-performance.component.ts

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

4
apps/client/src/app/components/portfolio-performance/portfolio-performance.module.ts

@ -1,4 +1,4 @@
import { GfValueModule } from '@ghostfolio/ui/value';
import { GfValueComponent } from '@ghostfolio/ui/value';
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
@ -9,7 +9,7 @@ import { PortfolioPerformanceComponent } from './portfolio-performance.component
@NgModule({
declarations: [PortfolioPerformanceComponent],
exports: [PortfolioPerformanceComponent],
imports: [CommonModule, GfValueModule, NgxSkeletonLoaderModule],
imports: [CommonModule, GfValueComponent, NgxSkeletonLoaderModule],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class GfPortfolioPerformanceModule {}

28
apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html

@ -9,9 +9,19 @@
class="flex-nowrap px-3 py-1 row"
[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, 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 class="row">
@ -65,9 +75,7 @@
[locale]="locale"
[unit]="baseCurrency"
[value]="
isLoading
? undefined
: summary?.currentGrossPerformanceWithCurrencyEffect
isLoading ? undefined : summary?.grossPerformanceWithCurrencyEffect
"
/>
</div>
@ -91,7 +99,7 @@
[value]="
isLoading
? undefined
: summary?.currentGrossPerformancePercentWithCurrencyEffect
: summary?.grossPerformancePercentageWithCurrencyEffect
"
/>
</div>
@ -121,9 +129,7 @@
[locale]="locale"
[unit]="baseCurrency"
[value]="
isLoading
? undefined
: summary?.currentNetPerformanceWithCurrencyEffect
isLoading ? undefined : summary?.netPerformanceWithCurrencyEffect
"
/>
</div>
@ -147,7 +153,7 @@
[value]="
isLoading
? undefined
: summary?.currentNetPerformancePercentWithCurrencyEffect
: summary?.netPerformancePercentageWithCurrencyEffect
"
/>
</div>
@ -164,7 +170,7 @@
[isCurrency]="true"
[locale]="locale"
[unit]="baseCurrency"
[value]="isLoading ? undefined : summary?.currentValue"
[value]="isLoading ? undefined : summary?.currentValueInBaseCurrency"
/>
</div>
</div>

4
apps/client/src/app/components/portfolio-summary/portfolio-summary.component.ts

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

5
apps/client/src/app/components/portfolio-summary/portfolio-summary.module.ts

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

0
apps/client/src/app/components/position/position-detail-dialog/interfaces/interfaces.ts → apps/client/src/app/components/position-detail-dialog/interfaces/interfaces.ts

0
apps/client/src/app/components/position/position-detail-dialog/position-detail-dialog.component.scss → apps/client/src/app/components/position-detail-dialog/position-detail-dialog.component.scss

16
apps/client/src/app/components/position/position-detail-dialog/position-detail-dialog.component.ts → apps/client/src/app/components/position-detail-dialog/position-detail-dialog.component.ts

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

31
apps/client/src/app/components/position/position-detail-dialog/position-detail-dialog.html → apps/client/src/app/components/position-detail-dialog/position-detail-dialog.html

@ -37,6 +37,10 @@
<div class="row">
<div class="col-6 mb-3">
@if (
SymbolProfile?.currency &&
data.baseCurrency !== SymbolProfile?.currency
) {
<gf-value
i18n
size="medium"
@ -45,10 +49,26 @@
[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 class="col-6 mb-3">
@if (
SymbolProfile?.currency &&
data.baseCurrency !== SymbolProfile?.currency
) {
<gf-value
i18n
size="medium"
@ -56,8 +76,19 @@
[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 class="col-6 mb-3">
<gf-value

20
apps/client/src/app/components/position/position-detail-dialog/position-detail-dialog.module.ts → apps/client/src/app/components/position-detail-dialog/position-detail-dialog.module.ts

@ -1,11 +1,11 @@
import { GfAccountsTableModule } from '@ghostfolio/client/components/accounts-table/accounts-table.module';
import { GfDialogFooterModule } from '@ghostfolio/client/components/dialog-footer/dialog-footer.module';
import { GfDialogHeaderModule } from '@ghostfolio/client/components/dialog-header/dialog-header.module';
import { GfActivitiesTableModule } from '@ghostfolio/ui/activities-table/activities-table.module';
import { GfDataProviderCreditsModule } from '@ghostfolio/ui/data-provider-credits/data-provider-credits.module';
import { GfLineChartModule } from '@ghostfolio/ui/line-chart/line-chart.module';
import { GfPortfolioProportionChartModule } from '@ghostfolio/ui/portfolio-proportion-chart/portfolio-proportion-chart.module';
import { GfValueModule } from '@ghostfolio/ui/value';
import { GfActivitiesTableComponent } from '@ghostfolio/ui/activities-table';
import { GfDataProviderCreditsComponent } from '@ghostfolio/ui/data-provider-credits';
import { GfLineChartComponent } from '@ghostfolio/ui/line-chart';
import { GfPortfolioProportionChartComponent } from '@ghostfolio/ui/portfolio-proportion-chart';
import { GfValueComponent } from '@ghostfolio/ui/value';
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
@ -22,13 +22,13 @@ import { PositionDetailDialog } from './position-detail-dialog.component';
imports: [
CommonModule,
GfAccountsTableModule,
GfActivitiesTableModule,
GfDataProviderCreditsModule,
GfActivitiesTableComponent,
GfDataProviderCreditsComponent,
GfDialogFooterModule,
GfDialogHeaderModule,
GfLineChartModule,
GfPortfolioProportionChartModule,
GfValueModule,
GfLineChartComponent,
GfPortfolioProportionChartComponent,
GfValueComponent,
MatButtonModule,
MatChipsModule,
MatDialogModule,

72
apps/client/src/app/components/position/position.component.html

@ -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>

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

Loading…
Cancel
Save