Browse Source

Merge remote-tracking branch 'origin/main' into mr/MR-Upstream-Changes-24-09-27

pull/5027/head
Dan 9 months ago
parent
commit
99685cdec1
  1. 95
      CHANGELOG.md
  2. 43
      README.md
  3. 62
      apps/api/src/app/account-balance/account-balance.service.ts
  4. 18
      apps/api/src/app/account/account.controller.ts
  5. 2
      apps/api/src/app/account/account.module.ts
  6. 16
      apps/api/src/app/admin/admin.controller.ts
  7. 2
      apps/api/src/app/admin/admin.module.ts
  8. 131
      apps/api/src/app/admin/admin.service.ts
  9. 5
      apps/api/src/app/admin/queue/queue.module.ts
  10. 36
      apps/api/src/app/admin/queue/queue.service.ts
  11. 6
      apps/api/src/app/app.module.ts
  12. 7
      apps/api/src/app/benchmark/benchmark.service.ts
  13. 134
      apps/api/src/app/endpoints/public/public.controller.ts
  14. 49
      apps/api/src/app/endpoints/public/public.module.ts
  15. 2
      apps/api/src/app/import/import.module.ts
  16. 2
      apps/api/src/app/import/import.service.ts
  17. 2
      apps/api/src/app/info/info.module.ts
  18. 29
      apps/api/src/app/info/info.service.ts
  19. 7
      apps/api/src/app/order/order.controller.ts
  20. 2
      apps/api/src/app/order/order.module.ts
  21. 35
      apps/api/src/app/order/order.service.ts
  22. 22
      apps/api/src/app/portfolio/calculator/portfolio-calculator.factory.ts
  23. 140
      apps/api/src/app/portfolio/calculator/portfolio-calculator.ts
  24. 29
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-baln-buy-and-sell-in-two-activities.spec.ts
  25. 29
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-baln-buy-and-sell.spec.ts
  26. 29
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-baln-buy.spec.ts
  27. 29
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts
  28. 29
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-fee.spec.ts
  29. 29
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-googl-buy.spec.ts
  30. 29
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-item.spec.ts
  31. 34
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-liability.spec.ts
  32. 29
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-msft-buy-with-dividend.spec.ts
  33. 31
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-no-orders.spec.ts
  34. 29
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-novn-buy-and-sell-partially.spec.ts
  35. 29
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-novn-buy-and-sell.spec.ts
  36. 12
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator.spec.ts
  37. 2
      apps/api/src/app/portfolio/current-rate.service.ts
  38. 3
      apps/api/src/app/portfolio/interfaces/portfolio-holding-detail.interface.ts
  39. 4
      apps/api/src/app/portfolio/interfaces/snapshot-value.interface.ts
  40. 83
      apps/api/src/app/portfolio/portfolio.controller.ts
  41. 6
      apps/api/src/app/portfolio/portfolio.module.ts
  42. 127
      apps/api/src/app/portfolio/portfolio.service.ts
  43. 21
      apps/api/src/app/portfolio/rules.service.ts
  44. 7
      apps/api/src/app/redis-cache/interfaces/redis-cache.interface.ts
  45. 8
      apps/api/src/app/redis-cache/interfaces/redis-store.interface.ts
  46. 20
      apps/api/src/app/redis-cache/redis-cache.module.ts
  47. 25
      apps/api/src/app/redis-cache/redis-cache.service.mock.ts
  48. 10
      apps/api/src/app/redis-cache/redis-cache.service.ts
  49. 2
      apps/api/src/app/user/user.service.ts
  50. 40
      apps/api/src/assets/sitemap.xml
  51. 80
      apps/api/src/interceptors/performance-logging/performance-logging.interceptor.ts
  52. 10
      apps/api/src/interceptors/performance-logging/performance-logging.module.ts
  53. 21
      apps/api/src/interceptors/performance-logging/performance-logging.service.ts
  54. 10
      apps/api/src/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor.ts
  55. 45
      apps/api/src/main.ts
  56. 4
      apps/api/src/middlewares/html-template.middleware.ts
  57. 20
      apps/api/src/services/api/api.service.ts
  58. 23
      apps/api/src/services/configuration/configuration.service.ts
  59. 2
      apps/api/src/services/cron.service.ts
  60. 6
      apps/api/src/services/data-provider/coingecko/coingecko.service.ts
  61. 36
      apps/api/src/services/data-provider/eod-historical-data/eod-historical-data.service.ts
  62. 6
      apps/api/src/services/data-provider/financial-modeling-prep/financial-modeling-prep.service.ts
  63. 35
      apps/api/src/services/data-provider/manual/manual.service.ts
  64. 28
      apps/api/src/services/data-provider/yahoo-finance/yahoo-finance.service.ts
  65. 2
      apps/api/src/services/exchange-rate-data/exchange-rate-data.service.ts
  66. 1
      apps/api/src/services/interfaces/environment.interface.ts
  67. 2
      apps/api/src/services/queues/data-gathering/data-gathering.module.ts
  68. 28
      apps/api/src/services/queues/data-gathering/data-gathering.processor.ts
  69. 8
      apps/api/src/services/queues/data-gathering/data-gathering.service.ts
  70. 7
      apps/api/src/services/queues/portfolio-snapshot/interfaces/portfolio-snapshot-queue-job.interface.ts
  71. 39
      apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.module.ts
  72. 114
      apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.processor.ts
  73. 34
      apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock.ts
  74. 31
      apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.service.ts
  75. 2
      apps/api/src/services/symbol-profile/symbol-profile.service.ts
  76. 2
      apps/client/src/app/app.component.html
  77. 28
      apps/client/src/app/app.component.ts
  78. 21
      apps/client/src/app/components/access-table/access-table.component.html
  79. 20
      apps/client/src/app/components/access-table/access-table.component.ts
  80. 2
      apps/client/src/app/components/access-table/access-table.module.ts
  81. 104
      apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts
  82. 4
      apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html
  83. 1
      apps/client/src/app/components/accounts-table/accounts-table.component.html
  84. 2
      apps/client/src/app/components/accounts-table/accounts-table.component.ts
  85. 5
      apps/client/src/app/components/admin-jobs/admin-jobs.component.ts
  86. 13
      apps/client/src/app/components/admin-jobs/admin-jobs.html
  87. 2
      apps/client/src/app/components/admin-jobs/admin-jobs.module.ts
  88. 2
      apps/client/src/app/components/admin-market-data-detail/admin-market-data-detail.component.ts
  89. 1
      apps/client/src/app/components/admin-market-data/admin-market-data.component.ts
  90. 17
      apps/client/src/app/components/admin-market-data/admin-market-data.html
  91. 2
      apps/client/src/app/components/admin-market-data/admin-market-data.module.ts
  92. 1
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html
  93. 1
      apps/client/src/app/components/admin-overview/admin-overview.html
  94. 1
      apps/client/src/app/components/admin-platform/admin-platform.component.html
  95. 1
      apps/client/src/app/components/admin-tag/admin-tag.component.html
  96. 17
      apps/client/src/app/components/admin-users/admin-users.component.ts
  97. 11
      apps/client/src/app/components/admin-users/admin-users.html
  98. 4
      apps/client/src/app/components/admin-users/admin-users.module.ts
  99. 22
      apps/client/src/app/components/header/header.component.ts
  100. 36
      apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts

95
CHANGELOG.md

@ -5,16 +5,109 @@ 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).
## 2.106.0-beta.2 - 2024-08-26
## Unreleased
### Changed
- Upgraded `webpack-bundle-analyzer` from version `4.10.1` to `4.10.2`
## 2.110.0 - 2024-09-24
### Changed
- Improved the usability of various action menus by introducing horizontal lines to separate the delete action
- Improved the chart in the account detail dialog (experimental)
- Aligned the holdings and regions of the public page with the allocations page
- Considered the user’s language in the link of the access table to share the portfolio
- Improved the language localization for German (`de`)
## 2.109.0 - 2024-09-21
### Added
- Extended the _Public API_ with a new endpoint that provides portfolio performance metrics (experimental)
- Added the portfolio performance metrics to the public page
- Added a blog post: _Hacktoberfest 2024_
### Changed
- Improved the usability of the create or update access dialog
- Improved the loading indicator of the accounts table
- Exposed the concurrency of the asset profile data gathering as an environment variable (`PROCESSOR_CONCURRENCY_GATHER_ASSET_PROFILE`)
- Exposed the concurrency of the historical market data gathering as an environment variable (`PROCESSOR_CONCURRENCY_GATHER_HISTORICAL_MARKET_DATA`)
- Exposed the concurrency of the portfolio snapshot calculation as an environment variable (`PROCESSOR_CONCURRENCY_PORTFOLIO_SNAPSHOT`)
- Improved the language localization for German (`de`)
- Improved the language localization for Polish (`pl`)
- Upgraded `prisma` from version `5.19.0` to `5.19.1`
## 2.108.0 - 2024-09-17
### Added
- Added support for bonds in the import dividends dialog
- Added a _Copy link to clipboard_ action to the access table to share the portfolio
- Added the current market price column to the historical market data table of the admin control
- Introduced filters (`dataSource` and `symbol`) in the accounts endpoint
### Changed
- Improved the usability of the toggle component
- Switched to the accounts endpoint in the holding detail dialog
- Added a fallback in the get quotes functionality of the _EOD Historical Data_ service
## 2.107.1 - 2024-09-12
### Fixed
- Fixed an issue in the activities filters that occurred during destructuring
## 2.107.0 - 2024-09-10
### Added
- Extended the filters of the activities endpoint by `dataSource` and `symbol`
### Changed
- Migrated the portfolio snapshot calculation to the queue design pattern
- Optimized the asynchronous operations using `Promise.all()` in the info service
- Optimized the asynchronous operations using `Promise.all()` in the admin control panel endpoint
- Extracted the users from the admin control panel endpoint to a dedicated endpoint
- Improved the language localization for French (`fr`)
- Improved the language localization for Italian (`it`)
- Upgraded `bull` from version `4.10.4` to `4.16.2`
## 2.106.0 - 2024-09-07
### Added
- Set up a performance logging service
- Added a loading indicator to the queue jobs table in the admin control panel
- Added a loading indicator to the users table in the admin control panel
- Added the attribute `mode` to the scraper configuration to get quotes instantly
### Changed
- Reworked the portfolio calculator
- Improved the caching of the portfolio snapshot in the portfolio calculator by returning cached data and recalculating in the background when it expires
- Exposed the log levels as an environment variable (`LOG_LEVELS`)
- Exposed the maximum of chart data items as an environment variable (`MAX_CHART_ITEMS`)
- Changed the data format of the environment variable `CACHE_QUOTES_TTL` from seconds to milliseconds
- Changed the data format of the environment variable `CACHE_TTL` from seconds to milliseconds
- Removed the environment variable `MAX_ITEM_IN_CACHE`
- Improved the error logs of the scraper configuration test in the asset profile details dialog of the admin control
- Improved the language localization for Polish (`pl`)
- Migrated from `cache-manager-redis-store` to `cache-manager-redis-yet`
- Upgraded `cache-manager` from version `3.4.3` to `5.7.6`
- Upgraded `prisma` from version `5.18.0` to `5.19.0`
### Fixed
- Fixed an issue in the view mode toggle of the holdings tab on the home page (experimental)
- Fixed an issue on the portfolio activities page by loading the data only once
- Fixed an issue in the carousel component for the testimonial section on the landing page
- Fixed the historical market data gathering in the _Yahoo Finance_ service by switching from `historical()` to `chart()`
- Handled an exception in the historical market data component of the asset profile details dialog in the admin control panel
## 2.105.0 - 2024-08-21

43
README.md

@ -10,8 +10,8 @@
[**Blog**](https://ghostfol.io/en/blog) | [**Slack**](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) | [**X**](https://x.com/ghostfolio_)
[![Shield: Buy me a coffee](https://img.shields.io/badge/Buy%20me%20a%20coffee-Support-yellow?logo=buymeacoffee)](https://www.buymeacoffee.com/ghostfolio)
[![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)
[![Shield: Contributions Welcome](https://img.shields.io/badge/Contributions-Welcome-limegreen.svg)](#contributing) [![Shield: Docker Pulls](https://img.shields.io/docker/pulls/ghostfolio/ghostfolio?label=Docker%20Pulls)](https://hub.docker.com/r/ghostfolio/ghostfolio)
[![Shield: License: AGPL v3](https://img.shields.io/badge/License-AGPL%20v3-orange.svg)](https://www.gnu.org/licenses/agpl-3.0)
</div>
@ -86,13 +86,14 @@ We provide official container images hosted on [Docker Hub](https://hub.docker.c
### Supported Environment Variables
| 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 key |
| `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) |
| `LOG_LEVELS` | `string[]` (optional) | | The logging levels for the Ghostfolio application, e.g. `["debug","error","log","warn"]` |
| `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 |
@ -164,6 +165,10 @@ Deprecated: `GET http://localhost:3333/api/v1/auth/anonymous/<INSERT_SECURITY_TO
### Import Activities
#### Prerequisites
[Bearer Token](#authorization-bearer-token) for authorization
#### Request
`POST http://localhost:3333/api/v1/import`
@ -219,6 +224,38 @@ Deprecated: `GET http://localhost:3333/api/v1/auth/anonymous/<INSERT_SECURITY_TO
}
```
### Portfolio (experimental)
#### Prerequisites
Grant access of type _Public_ in the _Access_ tab of _My Ghostfolio_.
#### Request
`GET http://localhost:3333/api/v1/public/<INSERT_ACCESS_ID>/portfolio`
**Info:** No Bearer Token is required for authorization
#### Response
##### Success
```
{
"performance": {
"1d": {
"relativeChange": 0 // normalized from -1 to 1
};
"ytd": {
"relativeChange": 0 // normalized from -1 to 1
},
"max": {
"relativeChange": 0 // normalized from -1 to 1
}
}
}
```
## Community Projects
Discover a variety of community projects for Ghostfolio: https://github.com/topics/ghostfolio

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

@ -1,15 +1,19 @@
import { LogPerformance } from '@ghostfolio/api/aop/logging.interceptor';
import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event';
import { LogPerformance } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor';
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 { DATE_FORMAT, getSum, resetHours } from '@ghostfolio/common/helper';
import {
AccountBalancesResponse,
Filter,
HistoricalDataItem
} from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { AccountBalance, Prisma } from '@prisma/client';
import { parseISO } from 'date-fns';
import { Big } from 'big.js';
import { format, parseISO } from 'date-fns';
import { CreateAccountBalanceDto } from './create-account-balance.dto';
@ -91,17 +95,56 @@ export class AccountBalanceService {
return accountBalance;
}
@LogPerformance
public async getAccountBalanceItems({
filters,
userCurrency,
userId
}: {
filters?: Filter[];
userCurrency: string;
userId: string;
}): Promise<HistoricalDataItem[]> {
const { balances } = await this.getAccountBalances({
filters,
userCurrency,
userId,
withExcludedAccounts: false // TODO
});
const accumulatedBalancesByDate: { [date: string]: HistoricalDataItem } =
{};
const lastBalancesByAccount: { [accountId: string]: Big } = {};
for (const { accountId, date, valueInBaseCurrency } of balances) {
const formattedDate = format(date, DATE_FORMAT);
lastBalancesByAccount[accountId] = new Big(valueInBaseCurrency);
const totalBalance = getSum(Object.values(lastBalancesByAccount));
// Add or update the accumulated balance for this date
accumulatedBalancesByDate[formattedDate] = {
date: formattedDate,
value: totalBalance.toNumber()
};
}
return Object.values(accumulatedBalancesByDate);
}
@LogPerformance
public async getAccountBalances({
filters,
user,
userCurrency,
userId,
withExcludedAccounts
}: {
filters?: Filter[];
user: UserWithSettings;
userCurrency: string;
userId: string;
withExcludedAccounts?: boolean;
}): Promise<AccountBalancesResponse> {
const where: Prisma.AccountBalanceWhereInput = { userId: user.id };
const where: Prisma.AccountBalanceWhereInput = { userId };
const accountFilter = filters?.find(({ type }) => {
return type === 'ACCOUNT';
@ -132,10 +175,11 @@ export class AccountBalanceService {
balances: balances.map((balance) => {
return {
...balance,
accountId: balance.Account.id,
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
balance.value,
balance.Account.currency,
user.Settings.settings.baseCurrency
userCurrency
)
};
})

18
apps/api/src/app/account/account.controller.ts

@ -3,6 +3,8 @@ import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.servic
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.interceptor';
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
import {
@ -26,6 +28,7 @@ import {
Param,
Post,
Put,
Query,
UseGuards,
UseInterceptors
} from '@nestjs/common';
@ -44,6 +47,7 @@ export class AccountController {
public constructor(
private readonly accountBalanceService: AccountBalanceService,
private readonly accountService: AccountService,
private readonly apiService: ApiService,
private readonly impersonationService: ImpersonationService,
private readonly portfolioService: PortfolioService,
@Inject(REQUEST) private readonly request: RequestWithUser
@ -84,13 +88,22 @@ export class AccountController {
@Get()
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInRequestInterceptor)
public async getAllAccounts(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId,
@Query('dataSource') filterByDataSource?: string,
@Query('symbol') filterBySymbol?: string
): Promise<Accounts> {
const impersonationUserId =
await this.impersonationService.validateImpersonationId(impersonationId);
const filters = this.apiService.buildFiltersFromQueryParams({
filterByDataSource,
filterBySymbol
});
return this.portfolioService.getAccountsWithAggregations({
filters,
userId: impersonationUserId || this.request.user.id,
withExcludedAccounts: true
});
@ -124,7 +137,8 @@ export class AccountController {
): Promise<AccountBalancesResponse> {
return this.accountBalanceService.getAccountBalances({
filters: [{ id, type: 'ACCOUNT' }],
user: this.request.user
userCurrency: this.request.user.Settings.settings.baseCurrency,
userId: this.request.user.id
});
}

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

@ -1,6 +1,7 @@
import { AccountBalanceModule } from '@ghostfolio/api/app/account-balance/account-balance.module';
import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module';
import { RedactValuesInResponseModule } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.module';
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module';
@ -16,6 +17,7 @@ import { AccountService } from './account.service';
exports: [AccountService],
imports: [
AccountBalanceModule,
ApiModule,
ConfigurationModule,
ExchangeRateDataModule,
ImpersonationModule,

16
apps/api/src/app/admin/admin.controller.ts

@ -2,10 +2,10 @@ import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorat
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service';
import { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { PropertyDto } from '@ghostfolio/api/services/property/property.dto';
import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service';
import {
DATA_GATHERING_QUEUE_PRIORITY_HIGH,
DATA_GATHERING_QUEUE_PRIORITY_MEDIUM,
@ -17,6 +17,7 @@ import {
AdminData,
AdminMarketData,
AdminMarketDataDetails,
AdminUsers,
EnhancedSymbolProfile
} from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions';
@ -239,9 +240,11 @@ export class AdminController {
return { price };
}
throw new Error('Could not parse the current market price');
throw new Error(
`Could not parse the current market price for ${symbol} (${dataSource})`
);
} catch (error) {
Logger.error(error);
Logger.error(error, 'AdminController');
throw new HttpException(error.message, StatusCodes.BAD_REQUEST);
}
@ -350,4 +353,11 @@ export class AdminController {
) {
return this.adminService.putSetting(key, data.value);
}
@Get('user')
@HasPermission(permissions.accessAdminControl)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getUsers(): Promise<AdminUsers> {
return this.adminService.getUsers();
}
}

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

@ -4,12 +4,12 @@ import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscriptio
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { DataGatheringModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module';
import { SymbolProfileOverwriteModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile-overwrite.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';

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

@ -16,12 +16,17 @@ import {
PROPERTY_IS_READ_ONLY_MODE,
PROPERTY_IS_USER_SIGNUP_ENABLED
} from '@ghostfolio/common/config';
import { isCurrency, getCurrencyFromSymbol } from '@ghostfolio/common/helper';
import {
getAssetProfileIdentifier,
getCurrencyFromSymbol,
isCurrency
} from '@ghostfolio/common/helper';
import {
AdminData,
AdminMarketData,
AdminMarketDataDetails,
AdminMarketDataItem,
AdminUsers,
AssetProfileIdentifier,
EnhancedSymbolProfile,
Filter
@ -111,8 +116,7 @@ export class AdminService {
}
public async get(): Promise<AdminData> {
return {
exchangeRates: this.exchangeRateDataService
const exchangeRates = this.exchangeRateDataService
.getCurrencies()
.filter((currency) => {
return currency !== DEFAULT_CURRENCY;
@ -135,11 +139,19 @@ export class AdminService {
currency
)
};
}),
settings: await this.propertyService.get(),
transactionCount: await this.prismaService.order.count(),
userCount: await this.prismaService.user.count(),
users: await this.getUsersWithAnalytics(),
});
const [settings, transactionCount, userCount] = await Promise.all([
this.propertyService.get(),
this.prismaService.order.count(),
this.prismaService.user.count()
]);
return {
exchangeRates,
settings,
transactionCount,
userCount,
version: environment.version
};
}
@ -258,6 +270,37 @@ export class AdminService {
this.prismaService.symbolProfile.count({ where })
]);
const lastMarketPrices = await this.prismaService.marketData.findMany({
distinct: ['dataSource', 'symbol'],
orderBy: { date: 'desc' },
select: {
dataSource: true,
marketPrice: true,
symbol: true
},
where: {
dataSource: {
in: assetProfiles.map(({ dataSource }) => {
return dataSource;
})
},
symbol: {
in: assetProfiles.map(({ symbol }) => {
return symbol;
})
}
}
});
const lastMarketPriceMap = new Map<string, number>();
for (const { dataSource, marketPrice, symbol } of lastMarketPrices) {
lastMarketPriceMap.set(
getAssetProfileIdentifier({ dataSource, symbol }),
marketPrice
);
}
let marketData: AdminMarketDataItem[] = await Promise.all(
assetProfiles.map(
async ({
@ -279,6 +322,11 @@ export class AdminService {
const countriesCount = countries
? Object.keys(countries).length
: 0;
const lastMarketPrice = lastMarketPriceMap.get(
getAssetProfileIdentifier({ dataSource, symbol })
);
const marketDataItemCount =
marketDataItems.find((marketDataItem) => {
return (
@ -286,6 +334,7 @@ export class AdminService {
marketDataItem.symbol === symbol
);
})?._count ?? 0;
const sectorsCount = sectors ? Object.keys(sectors).length : 0;
return {
@ -296,6 +345,7 @@ export class AdminService {
countriesCount,
dataSource,
id,
lastMarketPrice,
name,
symbol,
marketDataItemCount,
@ -385,6 +435,10 @@ export class AdminService {
};
}
public async getUsers(): Promise<AdminUsers> {
return { users: await this.getUsersWithAnalytics() };
}
public async patchAssetProfileData({
assetClass,
assetSubClass,
@ -509,15 +563,47 @@ export class AdminService {
}
private async getMarketDataForCurrencies(): Promise<AdminMarketData> {
const marketDataItems = await this.prismaService.marketData.groupBy({
const currencyPairs = this.exchangeRateDataService.getCurrencyPairs();
const [lastMarketPrices, marketDataItems] = await Promise.all([
this.prismaService.marketData.findMany({
distinct: ['dataSource', 'symbol'],
orderBy: { date: 'desc' },
select: {
dataSource: true,
marketPrice: true,
symbol: true
},
where: {
dataSource: {
in: currencyPairs.map(({ dataSource }) => {
return dataSource;
})
},
symbol: {
in: currencyPairs.map(({ symbol }) => {
return symbol;
})
}
}
}),
this.prismaService.marketData.groupBy({
_count: true,
by: ['dataSource', 'symbol']
});
})
]);
const marketDataPromise: Promise<AdminMarketDataItem>[] =
this.exchangeRateDataService
.getCurrencyPairs()
.map(async ({ dataSource, symbol }) => {
const lastMarketPriceMap = new Map<string, number>();
for (const { dataSource, marketPrice, symbol } of lastMarketPrices) {
lastMarketPriceMap.set(
getAssetProfileIdentifier({ dataSource, symbol }),
marketPrice
);
}
const marketDataPromise: Promise<AdminMarketDataItem>[] = currencyPairs.map(
async ({ dataSource, symbol }) => {
let activitiesCount: EnhancedSymbolProfile['activitiesCount'] = 0;
let currency: EnhancedSymbolProfile['currency'] = '-';
let dateOfFirstActivity: EnhancedSymbolProfile['dateOfFirstActivity'];
@ -528,6 +614,10 @@ export class AdminService {
await this.orderService.getStatisticsByCurrency(currency));
}
const lastMarketPrice = lastMarketPriceMap.get(
getAssetProfileIdentifier({ dataSource, symbol })
);
const marketDataItemCount =
marketDataItems.find((marketDataItem) => {
return (
@ -538,29 +628,32 @@ export class AdminService {
return {
activitiesCount,
date: dateOfFirstActivity,
currency,
dataSource,
lastMarketPrice,
marketDataItemCount,
symbol,
assetClass: AssetClass.LIQUIDITY,
assetSubClass: AssetSubClass.CASH,
countriesCount: 0,
currency: symbol.replace(DEFAULT_CURRENCY, ''),
date: dateOfFirstActivity,
id: undefined,
name: symbol,
sectorsCount: 0,
tags: []
};
});
}
);
const marketData = await Promise.all(marketDataPromise);
return { marketData, count: marketData.length };
}
private async getUsersWithAnalytics(): Promise<AdminData['users']> {
private async getUsersWithAnalytics(): Promise<AdminUsers['users']> {
let orderBy: any = {
createdAt: 'desc'
};
let where;
let where: Prisma.UserWhereInput;
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
orderBy = {

5
apps/api/src/app/admin/queue/queue.module.ts

@ -1,4 +1,5 @@
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
import { DataGatheringModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module';
import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.module';
import { Module } from '@nestjs/common';
@ -7,7 +8,7 @@ import { QueueService } from './queue.service';
@Module({
controllers: [QueueController],
imports: [DataGatheringModule],
imports: [DataGatheringModule, PortfolioSnapshotQueueModule],
providers: [QueueService]
})
export class QueueModule {}

36
apps/api/src/app/admin/queue/queue.service.ts

@ -1,5 +1,6 @@
import {
DATA_GATHERING_QUEUE,
PORTFOLIO_SNAPSHOT_QUEUE,
QUEUE_JOB_STATUS_LIST
} from '@ghostfolio/common/config';
import { AdminJobs } from '@ghostfolio/common/interfaces';
@ -12,11 +13,19 @@ import { JobStatus, Queue } from 'bull';
export class QueueService {
public constructor(
@InjectQueue(DATA_GATHERING_QUEUE)
private readonly dataGatheringQueue: Queue
private readonly dataGatheringQueue: Queue,
@InjectQueue(PORTFOLIO_SNAPSHOT_QUEUE)
private readonly portfolioSnapshotQueue: Queue
) {}
public async deleteJob(aId: string) {
return (await this.dataGatheringQueue.getJob(aId))?.remove();
let job = await this.dataGatheringQueue.getJob(aId);
if (!job) {
job = await this.portfolioSnapshotQueue.getJob(aId);
}
return job?.remove();
}
public async deleteJobs({
@ -25,15 +34,21 @@ export class QueueService {
status?: JobStatus[];
}) {
for (const statusItem of status) {
await this.dataGatheringQueue.clean(
300,
statusItem === 'waiting' ? 'wait' : statusItem
);
const queueStatus = statusItem === 'waiting' ? 'wait' : statusItem;
await this.dataGatheringQueue.clean(300, queueStatus);
await this.portfolioSnapshotQueue.clean(300, queueStatus);
}
}
public async executeJob(aId: string) {
return (await this.dataGatheringQueue.getJob(aId))?.promote();
let job = await this.dataGatheringQueue.getJob(aId);
if (!job) {
job = await this.portfolioSnapshotQueue.getJob(aId);
}
return job?.promote();
}
public async getJobs({
@ -43,10 +58,13 @@ export class QueueService {
limit?: number;
status?: JobStatus[];
}): Promise<AdminJobs> {
const jobs = await this.dataGatheringQueue.getJobs(status);
const [dataGatheringJobs, portfolioSnapshotJobs] = await Promise.all([
this.dataGatheringQueue.getJobs(status),
this.portfolioSnapshotQueue.getJobs(status)
]);
const jobsWithState = await Promise.all(
jobs
[...dataGatheringJobs, ...portfolioSnapshotJobs]
.filter((job) => {
return job;
})

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

@ -1,11 +1,12 @@
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';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { DataGatheringModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module';
import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.module';
import { TwitterBotModule } from '@ghostfolio/api/services/twitter-bot/twitter-bot.module';
import {
DEFAULT_LANGUAGE_CODE,
@ -30,6 +31,7 @@ import { AuthDeviceModule } from './auth-device/auth-device.module';
import { AuthModule } from './auth/auth.module';
import { BenchmarkModule } from './benchmark/benchmark.module';
import { CacheModule } from './cache/cache.module';
import { PublicModule } from './endpoints/public/public.module';
import { ExchangeRateModule } from './exchange-rate/exchange-rate.module';
import { ExportModule } from './export/export.module';
import { HealthModule } from './health/health.module';
@ -81,8 +83,10 @@ import { UserModule } from './user/user.module';
OrderModule,
PlatformModule,
PortfolioModule,
PortfolioSnapshotQueueModule,
PrismaModule,
PropertyModule,
PublicModule,
RedisCacheModule,
ScheduleModule.forRoot(),
ServeStaticModule.forRoot({

7
apps/api/src/app/benchmark/benchmark.service.ts

@ -7,7 +7,10 @@ import { MarketDataService } from '@ghostfolio/api/services/market-data/market-d
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { PROPERTY_BENCHMARKS } from '@ghostfolio/common/config';
import {
CACHE_TTL_INFINITE,
PROPERTY_BENCHMARKS
} from '@ghostfolio/common/config';
import {
DATE_FORMAT,
calculateBenchmarkTrend,
@ -443,7 +446,7 @@ export class BenchmarkService {
benchmarks,
expiration: expiration.getTime()
}),
ms('12 hours') / 1000
CACHE_TTL_INFINITE
);
}

134
apps/api/src/app/endpoints/public/public.controller.ts

@ -0,0 +1,134 @@
import { AccessService } from '@ghostfolio/api/app/access/access.service';
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
import { UserService } from '@ghostfolio/api/app/user/user.service';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { DEFAULT_CURRENCY } from '@ghostfolio/common/config';
import { getSum } from '@ghostfolio/common/helper';
import { PublicPortfolioResponse } from '@ghostfolio/common/interfaces';
import type { RequestWithUser } from '@ghostfolio/common/types';
import {
Controller,
Get,
HttpException,
Inject,
Param,
UseInterceptors
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { Big } from 'big.js';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
@Controller('public')
export class PublicController {
public constructor(
private readonly accessService: AccessService,
private readonly configurationService: ConfigurationService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly portfolioService: PortfolioService,
@Inject(REQUEST) private readonly request: RequestWithUser,
private readonly userService: UserService
) {}
@Get(':accessId/portfolio')
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getPublicPortfolio(
@Param('accessId') accessId
): Promise<PublicPortfolioResponse> {
const access = await this.accessService.access({ id: accessId });
if (!access) {
throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
);
}
let hasDetails = true;
const user = await this.userService.user({
id: access.userId
});
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
hasDetails = user.subscription.type === 'Premium';
}
const [
{ holdings },
{ performance: performance1d },
{ performance: performanceMax },
{ performance: performanceYtd }
] = await Promise.all([
this.portfolioService.getDetails({
impersonationId: access.userId,
userId: user.id,
withMarkets: true
}),
...['1d', 'max', 'ytd'].map((dateRange) => {
return this.portfolioService.getPerformance({
dateRange,
impersonationId: undefined,
userId: user.id
});
})
]);
const publicPortfolioResponse: PublicPortfolioResponse = {
hasDetails,
alias: access.alias,
holdings: {},
performance: {
'1d': {
relativeChange:
performance1d.netPerformancePercentageWithCurrencyEffect
},
max: {
relativeChange:
performanceMax.netPerformancePercentageWithCurrencyEffect
},
ytd: {
relativeChange:
performanceYtd.netPerformancePercentageWithCurrencyEffect
}
}
};
const totalValue = getSum(
Object.values(holdings).map(({ currency, marketPrice, quantity }) => {
return new Big(
this.exchangeRateDataService.toCurrency(
quantity * marketPrice,
currency,
this.request.user?.Settings?.settings.baseCurrency ??
DEFAULT_CURRENCY
)
);
})
).toNumber();
for (const [symbol, portfolioPosition] of Object.entries(holdings)) {
publicPortfolioResponse.holdings[symbol] = {
allocationInPercentage:
portfolioPosition.valueInBaseCurrency / totalValue,
assetClass: hasDetails ? portfolioPosition.assetClass : undefined,
countries: hasDetails ? portfolioPosition.countries : [],
currency: hasDetails ? portfolioPosition.currency : undefined,
dataSource: portfolioPosition.dataSource,
dateOfFirstActivity: portfolioPosition.dateOfFirstActivity,
markets: hasDetails ? portfolioPosition.markets : undefined,
name: portfolioPosition.name,
netPerformancePercentWithCurrencyEffect:
portfolioPosition.netPerformancePercentWithCurrencyEffect,
sectors: hasDetails ? portfolioPosition.sectors : [],
symbol: portfolioPosition.symbol,
url: portfolioPosition.url,
valueInPercentage: portfolioPosition.valueInBaseCurrency / totalValue
};
}
return publicPortfolioResponse;
}
}

49
apps/api/src/app/endpoints/public/public.module.ts

@ -0,0 +1,49 @@
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 { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
import { RulesService } from '@ghostfolio/api/app/portfolio/rules.service';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module';
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
import { Module } from '@nestjs/common';
import { PublicController } from './public.controller';
@Module({
controllers: [PublicController],
imports: [
AccessModule,
DataProviderModule,
ExchangeRateDataModule,
ImpersonationModule,
MarketDataModule,
OrderModule,
PortfolioSnapshotQueueModule,
PrismaModule,
RedisCacheModule,
SymbolProfileModule,
TransformDataSourceInRequestModule,
UserModule
],
providers: [
AccountBalanceService,
AccountService,
CurrentRateService,
PortfolioCalculatorFactory,
PortfolioService,
RulesService
]
})
export class PublicModule {}

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

@ -7,10 +7,10 @@ import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.mo
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { DataGatheringModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
import { Module } from '@nestjs/common';

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

@ -9,9 +9,9 @@ import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { PlatformService } from '@ghostfolio/api/app/platform/platform.service';
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { DATA_GATHERING_QUEUE_PRIORITY_HIGH } from '@ghostfolio/common/config';
import {

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

@ -4,10 +4,10 @@ import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.mo
import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { DataGatheringModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
import { TagModule } from '@ghostfolio/api/services/tag/tag.module';

29
apps/api/src/app/info/info.service.ts

@ -54,9 +54,6 @@ export class InfoService {
public async get(): Promise<InfoItem> {
const info: Partial<InfoItem> = {};
let isReadOnlyMode: boolean;
const platforms = await this.platformService.getPlatforms({
orderBy: { name: 'asc' }
});
const globalPermissions: string[] = [];
@ -100,22 +97,30 @@ export class InfoService {
globalPermissions.push(permissions.enableSystemMessage);
}
const isUserSignupEnabled =
await this.propertyService.isUserSignupEnabled();
if (isUserSignupEnabled) {
globalPermissions.push(permissions.createUserAccount);
}
const [benchmarks, demoAuthToken, statistics, subscriptions, tags] =
await Promise.all([
const [
benchmarks,
demoAuthToken,
isUserSignupEnabled,
platforms,
statistics,
subscriptions,
tags
] = await Promise.all([
this.benchmarkService.getBenchmarkAssetProfiles(),
this.getDemoAuthToken(),
this.propertyService.isUserSignupEnabled(),
this.platformService.getPlatforms({
orderBy: { name: 'asc' }
}),
this.getStatistics(),
this.getSubscriptions(),
this.tagService.get()
]);
if (isUserSignupEnabled) {
globalPermissions.push(permissions.createUserAccount);
}
return {
...info,
benchmarks,

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

@ -4,8 +4,8 @@ import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service';
import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper';
import {
DATA_GATHERING_QUEUE_PRIORITY_HIGH,
@ -94,15 +94,18 @@ export class OrderController {
@Get()
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getAllOrders(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId,
@Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string,
@Query('dataSource') filterByDataSource?: string,
@Query('range') dateRange?: DateRange,
@Query('skip') skip?: number,
@Query('sortColumn') sortColumn?: string,
@Query('sortDirection') sortDirection?: Prisma.SortOrder,
@Query('symbol') filterBySymbol?: string,
@Query('tags') filterByTags?: string,
@Query('take') take?: number
): Promise<Activities> {
@ -116,6 +119,8 @@ export class OrderController {
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts,
filterByAssetClasses,
filterByDataSource,
filterBySymbol,
filterByTags
});

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

@ -6,11 +6,11 @@ import { RedactValuesInResponseModule } from '@ghostfolio/api/interceptors/redac
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module';
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { DataGatheringModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
import { Module } from '@nestjs/common';

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

@ -1,9 +1,10 @@
import { LogPerformance } from '@ghostfolio/api/aop/logging.interceptor';
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 { LogPerformance } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import {
DATA_GATHERING_QUEUE_PRIORITY_HIGH,
@ -351,6 +352,14 @@ export class OrderService {
return type;
});
const filterByDataSource = filters?.find(({ type }) => {
return type === 'DATA_SOURCE';
})?.id;
const filterBySymbol = filters?.find(({ type }) => {
return type === 'SYMBOL';
})?.id;
const searchQuery = filters?.find(({ type }) => {
return type === 'SEARCH_QUERY';
})?.id;
@ -396,6 +405,29 @@ export class OrderService {
};
}
if (filterByDataSource && filterBySymbol) {
if (where.SymbolProfile) {
where.SymbolProfile = {
AND: [
where.SymbolProfile,
{
AND: [
{ dataSource: <DataSource>filterByDataSource },
{ symbol: filterBySymbol }
]
}
]
};
} else {
where.SymbolProfile = {
AND: [
{ dataSource: <DataSource>filterByDataSource },
{ symbol: filterBySymbol }
]
};
}
}
if (searchQuery) {
const searchQueryWhereInput: Prisma.SymbolProfileWhereInput[] = [
{ id: { mode: 'insensitive', startsWith: searchQuery } },
@ -546,6 +578,7 @@ export class OrderService {
return { activities, count };
}
@LogPerformance
public async getOrdersForPortfolioCalculator({
filters,
userCurrency,

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

@ -4,6 +4,7 @@ import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.s
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 { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { Filter, HistoricalDataItem } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common';
@ -26,8 +27,8 @@ export class PortfolioCalculatorFactory {
private readonly configurationService: ConfigurationService,
private readonly currentRateService: CurrentRateService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly redisCacheService: RedisCacheService,
private readonly orderservice: OrderService
private readonly portfolioSnapshotService: PortfolioSnapshotService,
private readonly redisCacheService: RedisCacheService
) {}
@LogPerformance
@ -57,11 +58,11 @@ export class PortfolioCalculatorFactory {
configurationService: this.configurationService,
currentRateService: this.currentRateService,
exchangeRateDataService: this.exchangeRateDataService,
portfolioSnapshotService: this.portfolioSnapshotService,
redisCacheService: this.redisCacheService
});
case PerformanceCalculationType.TWR:
return new CPRPortfolioCalculator(
{
return new CPRPortfolioCalculator({
accountBalanceItems,
activities,
currency,
@ -69,14 +70,12 @@ export class PortfolioCalculatorFactory {
userId,
configurationService: this.configurationService,
exchangeRateDataService: this.exchangeRateDataService,
portfolioSnapshotService: this.portfolioSnapshotService,
redisCacheService: this.redisCacheService,
filters
},
this.orderservice
);
});
case PerformanceCalculationType.CPR:
return new CPRPortfolioCalculator(
{
return new CPRPortfolioCalculator({
accountBalanceItems,
activities,
currency,
@ -84,11 +83,10 @@ export class PortfolioCalculatorFactory {
userId,
configurationService: this.configurationService,
exchangeRateDataService: this.exchangeRateDataService,
portfolioSnapshotService: this.portfolioSnapshotService,
redisCacheService: this.redisCacheService,
filters
},
this.orderservice
);
});
default:
throw new Error('Invalid calculation type');
}

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

@ -1,15 +1,23 @@
import { LogPerformance } from '@ghostfolio/api/aop/logging.interceptor';
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 { PortfolioSnapshotValue } from '@ghostfolio/api/app/portfolio/interfaces/snapshot-value.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 } from '@ghostfolio/api/helper/portfolio.helper';
import { LogPerformance } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor';
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 { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper';
import {
PORTFOLIO_SNAPSHOT_PROCESS_JOB_NAME,
PORTFOLIO_SNAPSHOT_PROCESS_JOB_OPTIONS,
PORTFOLIO_SNAPSHOT_QUEUE_PRIORITY_HIGH,
PORTFOLIO_SNAPSHOT_QUEUE_PRIORITY_LOW
} from '@ghostfolio/common/config';
import {
DATE_FORMAT,
getSum,
@ -56,6 +64,7 @@ export abstract class PortfolioCalculator {
private endDate: Date;
protected exchangeRateDataService: ExchangeRateDataService;
private filters: Filter[];
private portfolioSnapshotService: PortfolioSnapshotService;
private redisCacheService: RedisCacheService;
private snapshot: PortfolioSnapshot;
private snapshotPromise: Promise<void>;
@ -72,6 +81,7 @@ export abstract class PortfolioCalculator {
currentRateService,
exchangeRateDataService,
filters,
portfolioSnapshotService,
redisCacheService,
userId
}: {
@ -82,6 +92,7 @@ export abstract class PortfolioCalculator {
currentRateService: CurrentRateService;
exchangeRateDataService: ExchangeRateDataService;
filters: Filter[];
portfolioSnapshotService: PortfolioSnapshotService;
redisCacheService: RedisCacheService;
userId: string;
}) {
@ -94,6 +105,10 @@ export abstract class PortfolioCalculator {
let dateOfFirstActivity = new Date();
if (this.accountBalanceItems[0]) {
dateOfFirstActivity = parseDate(this.accountBalanceItems[0].date);
}
this.activities = activities
.map(
({
@ -130,6 +145,7 @@ export abstract class PortfolioCalculator {
return a.date?.localeCompare(b.date);
});
this.portfolioSnapshotService = portfolioSnapshotService;
this.redisCacheService = redisCacheService;
this.userId = userId;
@ -151,7 +167,7 @@ export abstract class PortfolioCalculator {
): PortfolioSnapshot;
@LogPerformance
protected async computeSnapshot(): Promise<PortfolioSnapshot> {
public async computeSnapshot(): Promise<PortfolioSnapshot> {
const lastTransactionPoint = last(this.transactionPoints);
const transactionPoints = this.transactionPoints?.filter(({ date }) => {
@ -258,6 +274,10 @@ export abstract class PortfolioCalculator {
)
});
for (const accountBalanceItem of this.accountBalanceItems) {
chartDateMap[accountBalanceItem.date] = true;
}
const chartDates = sortBy(Object.keys(chartDateMap), (chartDate) => {
return chartDate;
});
@ -436,9 +456,28 @@ export abstract class PortfolioCalculator {
}
}
let lastDate = chartDates[0];
const accountBalanceItemsMap = this.accountBalanceItems.reduce(
(map, { date, value }) => {
map[date] = new Big(value);
return map;
},
{} as { [date: string]: Big }
);
const accountBalanceMap: { [date: string]: Big } = {};
let lastKnownBalance = new Big(0);
for (const dateString of chartDates) {
if (accountBalanceItemsMap[dateString] !== undefined) {
// If there's an exact balance for this date, update lastKnownBalance
lastKnownBalance = accountBalanceItemsMap[dateString];
}
// Add the most recent balance to the accountBalanceMap
accountBalanceMap[dateString] = lastKnownBalance;
for (const symbol of Object.keys(valuesBySymbol)) {
const symbolValues = valuesBySymbol[symbol];
@ -481,18 +520,7 @@ export abstract class PortfolioCalculator {
accumulatedValuesByDate[dateString]
?.investmentValueWithCurrencyEffect ?? new Big(0)
).add(investmentValueWithCurrencyEffect),
totalAccountBalanceWithCurrencyEffect: this.accountBalanceItems.some(
({ date }) => {
return date === dateString;
}
)
? new Big(
this.accountBalanceItems.find(({ date }) => {
return date === dateString;
}).value
)
: (accumulatedValuesByDate[lastDate]
?.totalAccountBalanceWithCurrencyEffect ?? new Big(0)),
totalAccountBalanceWithCurrencyEffect: accountBalanceMap[dateString],
totalCurrentValue: (
accumulatedValuesByDate[dateString]?.totalCurrentValue ?? new Big(0)
).add(currentValue),
@ -526,8 +554,6 @@ export abstract class PortfolioCalculator {
).add(timeWeightedInvestmentValueWithCurrencyEffect)
};
}
lastDate = dateString;
}
const historicalData: HistoricalDataItem[] = Object.entries(
@ -730,12 +756,12 @@ export abstract class PortfolioCalculator {
timeWeightedInvestmentValue === 0
? 0
: netPerformanceWithCurrencyEffectSinceStartDate /
timeWeightedInvestmentValue,
timeWeightedInvestmentValue
// TODO: Add net worth with valuables
// netWorth: totalCurrentValueWithCurrencyEffect
// .plus(totalAccountBalanceWithCurrencyEffect)
// .toNumber()
netWorth: 0
// netWorth: 0
});
}
}
@ -814,7 +840,7 @@ export abstract class PortfolioCalculator {
endDate: Date;
startDate: Date;
step: number;
}) {
}): { [date: string]: true } {
// Create a map of all relevant chart dates:
// 1. Add transaction point dates
let chartDateMap = this.transactionPoints.reduce((result, { date }) => {
@ -1017,19 +1043,34 @@ export abstract class PortfolioCalculator {
protected async initialize() {
const startTimeTotal = performance.now();
const cachedSnapshot = await this.redisCacheService.get(
let cachedPortfolioSnapshot: PortfolioSnapshot;
let isCachedPortfolioSnapshotExpired = false;
const jobId = this.userId;
try {
const cachedPortfolioSnapshotValue = await this.redisCacheService.get(
this.redisCacheService.getPortfolioSnapshotKey({
filters: this.filters,
userId: this.userId
})
);
if (cachedSnapshot) {
this.snapshot = plainToClass(
const { expiration, portfolioSnapshot }: PortfolioSnapshotValue =
JSON.parse(cachedPortfolioSnapshotValue);
cachedPortfolioSnapshot = plainToClass(
PortfolioSnapshot,
JSON.parse(cachedSnapshot)
portfolioSnapshot
);
if (isAfter(new Date(), new Date(expiration))) {
isCachedPortfolioSnapshotExpired = true;
}
} catch {}
if (cachedPortfolioSnapshot) {
this.snapshot = cachedPortfolioSnapshot;
Logger.debug(
`Fetched portfolio snapshot from cache in ${(
(performance.now() - startTimeTotal) /
@ -1037,25 +1078,46 @@ export abstract class PortfolioCalculator {
).toFixed(3)} seconds`,
'PortfolioCalculator'
);
} else {
this.snapshot = await this.computeSnapshot();
this.redisCacheService.set(
this.redisCacheService.getPortfolioSnapshotKey({
if (isCachedPortfolioSnapshotExpired) {
// Compute in the background
this.portfolioSnapshotService.addJobToQueue({
data: {
filters: this.filters,
userCurrency: this.currency,
userId: this.userId
}),
JSON.stringify(this.snapshot),
this.configurationService.get('CACHE_QUOTES_TTL')
);
},
name: PORTFOLIO_SNAPSHOT_PROCESS_JOB_NAME,
opts: {
...PORTFOLIO_SNAPSHOT_PROCESS_JOB_OPTIONS,
jobId,
priority: PORTFOLIO_SNAPSHOT_QUEUE_PRIORITY_LOW
}
});
}
} else {
// Wait for computation
await this.portfolioSnapshotService.addJobToQueue({
data: {
filters: this.filters,
userCurrency: this.currency,
userId: this.userId
},
name: PORTFOLIO_SNAPSHOT_PROCESS_JOB_NAME,
opts: {
...PORTFOLIO_SNAPSHOT_PROCESS_JOB_OPTIONS,
jobId,
priority: PORTFOLIO_SNAPSHOT_QUEUE_PRIORITY_HIGH
}
});
Logger.debug(
`Computed portfolio snapshot in ${(
(performance.now() - startTimeTotal) /
1000
).toFixed(3)} seconds`,
'PortfolioCalculator'
);
const job = await this.portfolioSnapshotService.getJob(jobId);
if (job) {
await job.finished();
}
await this.initialize();
}
}
}

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

@ -14,6 +14,8 @@ import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.s
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper';
import { Big } from 'big.js';
@ -28,6 +30,18 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
};
});
jest.mock(
'@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service',
() => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
PortfolioSnapshotService: jest.fn().mockImplementation(() => {
return PortfolioSnapshotServiceMock;
})
};
}
);
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
@ -41,7 +55,8 @@ describe('PortfolioCalculator', () => {
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
let factory: PortfolioCalculatorFactory;
let portfolioCalculatorFactory: PortfolioCalculatorFactory;
let portfolioSnapshotService: PortfolioSnapshotService;
let redisCacheService: RedisCacheService;
beforeEach(() => {
@ -56,14 +71,16 @@ describe('PortfolioCalculator', () => {
null
);
portfolioSnapshotService = new PortfolioSnapshotService(null);
redisCacheService = new RedisCacheService(null, null);
factory = new PortfolioCalculatorFactory(
portfolioCalculatorFactory = new PortfolioCalculatorFactory(
configurationService,
currentRateService,
exchangeRateDataService,
redisCacheService,
null
portfolioSnapshotService,
redisCacheService
);
});
@ -119,14 +136,14 @@ describe('PortfolioCalculator', () => {
}
];
const portfolioCalculator = factory.createCalculator({
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.TWR,
currency: 'CHF',
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.getSnapshot();
const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
const investments = portfolioCalculator.getInvestments();

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

@ -14,6 +14,8 @@ import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.s
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper';
import { Big } from 'big.js';
@ -28,6 +30,18 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
};
});
jest.mock(
'@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service',
() => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
PortfolioSnapshotService: jest.fn().mockImplementation(() => {
return PortfolioSnapshotServiceMock;
})
};
}
);
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
@ -41,7 +55,8 @@ describe('PortfolioCalculator', () => {
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
let factory: PortfolioCalculatorFactory;
let portfolioCalculatorFactory: PortfolioCalculatorFactory;
let portfolioSnapshotService: PortfolioSnapshotService;
let redisCacheService: RedisCacheService;
beforeEach(() => {
@ -56,14 +71,16 @@ describe('PortfolioCalculator', () => {
null
);
portfolioSnapshotService = new PortfolioSnapshotService(null);
redisCacheService = new RedisCacheService(null, null);
factory = new PortfolioCalculatorFactory(
portfolioCalculatorFactory = new PortfolioCalculatorFactory(
configurationService,
currentRateService,
exchangeRateDataService,
redisCacheService,
null
portfolioSnapshotService,
redisCacheService
);
});
@ -104,14 +121,14 @@ describe('PortfolioCalculator', () => {
}
];
const portfolioCalculator = factory.createCalculator({
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.TWR,
currency: 'CHF',
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.getSnapshot();
const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
const investments = portfolioCalculator.getInvestments();

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

@ -14,6 +14,8 @@ import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.s
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper';
import { Big } from 'big.js';
@ -28,6 +30,18 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
};
});
jest.mock(
'@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service',
() => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
PortfolioSnapshotService: jest.fn().mockImplementation(() => {
return PortfolioSnapshotServiceMock;
})
};
}
);
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
@ -41,7 +55,8 @@ describe('PortfolioCalculator', () => {
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
let factory: PortfolioCalculatorFactory;
let portfolioCalculatorFactory: PortfolioCalculatorFactory;
let portfolioSnapshotService: PortfolioSnapshotService;
let redisCacheService: RedisCacheService;
beforeEach(() => {
@ -56,14 +71,16 @@ describe('PortfolioCalculator', () => {
null
);
portfolioSnapshotService = new PortfolioSnapshotService(null);
redisCacheService = new RedisCacheService(null, null);
factory = new PortfolioCalculatorFactory(
portfolioCalculatorFactory = new PortfolioCalculatorFactory(
configurationService,
currentRateService,
exchangeRateDataService,
redisCacheService,
null
portfolioSnapshotService,
redisCacheService
);
});
@ -89,14 +106,14 @@ describe('PortfolioCalculator', () => {
}
];
const portfolioCalculator = factory.createCalculator({
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.TWR,
currency: 'CHF',
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.getSnapshot();
const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
const investments = portfolioCalculator.getInvestments();

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

@ -15,6 +15,8 @@ import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cac
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 { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper';
import { Big } from 'big.js';
@ -29,6 +31,18 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
};
});
jest.mock(
'@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service',
() => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
PortfolioSnapshotService: jest.fn().mockImplementation(() => {
return PortfolioSnapshotServiceMock;
})
};
}
);
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
@ -54,7 +68,8 @@ describe('PortfolioCalculator', () => {
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
let factory: PortfolioCalculatorFactory;
let portfolioCalculatorFactory: PortfolioCalculatorFactory;
let portfolioSnapshotService: PortfolioSnapshotService;
let redisCacheService: RedisCacheService;
beforeEach(() => {
@ -69,14 +84,16 @@ describe('PortfolioCalculator', () => {
null
);
portfolioSnapshotService = new PortfolioSnapshotService(null);
redisCacheService = new RedisCacheService(null, null);
factory = new PortfolioCalculatorFactory(
portfolioCalculatorFactory = new PortfolioCalculatorFactory(
configurationService,
currentRateService,
exchangeRateDataService,
redisCacheService,
null
portfolioSnapshotService,
redisCacheService
);
});
@ -118,14 +135,14 @@ describe('PortfolioCalculator', () => {
}
];
const portfolioCalculator = factory.createCalculator({
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.TWR,
currency: 'CHF',
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.getSnapshot();
const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
const investments = portfolioCalculator.getInvestments();

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

@ -14,6 +14,8 @@ import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.s
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper';
import { Big } from 'big.js';
@ -28,6 +30,18 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
};
});
jest.mock(
'@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service',
() => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
PortfolioSnapshotService: jest.fn().mockImplementation(() => {
return PortfolioSnapshotServiceMock;
})
};
}
);
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
@ -41,7 +55,8 @@ describe('PortfolioCalculator', () => {
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
let factory: PortfolioCalculatorFactory;
let portfolioCalculatorFactory: PortfolioCalculatorFactory;
let portfolioSnapshotService: PortfolioSnapshotService;
let redisCacheService: RedisCacheService;
beforeEach(() => {
@ -56,14 +71,16 @@ describe('PortfolioCalculator', () => {
null
);
portfolioSnapshotService = new PortfolioSnapshotService(null);
redisCacheService = new RedisCacheService(null, null);
factory = new PortfolioCalculatorFactory(
portfolioCalculatorFactory = new PortfolioCalculatorFactory(
configurationService,
currentRateService,
exchangeRateDataService,
redisCacheService,
null
portfolioSnapshotService,
redisCacheService
);
});
@ -89,14 +106,14 @@ describe('PortfolioCalculator', () => {
}
];
const portfolioCalculator = factory.createCalculator({
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.TWR,
currency: 'USD',
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.getSnapshot();
const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
expect(portfolioSnapshot).toMatchObject({
currentValueInBaseCurrency: new Big('0'),

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

@ -15,6 +15,8 @@ import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cac
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 { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper';
import { Big } from 'big.js';
@ -29,6 +31,18 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
};
});
jest.mock(
'@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service',
() => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
PortfolioSnapshotService: jest.fn().mockImplementation(() => {
return PortfolioSnapshotServiceMock;
})
};
}
);
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
@ -54,7 +68,8 @@ describe('PortfolioCalculator', () => {
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
let factory: PortfolioCalculatorFactory;
let portfolioCalculatorFactory: PortfolioCalculatorFactory;
let portfolioSnapshotService: PortfolioSnapshotService;
let redisCacheService: RedisCacheService;
beforeEach(() => {
@ -69,14 +84,16 @@ describe('PortfolioCalculator', () => {
null
);
portfolioSnapshotService = new PortfolioSnapshotService(null);
redisCacheService = new RedisCacheService(null, null);
factory = new PortfolioCalculatorFactory(
portfolioCalculatorFactory = new PortfolioCalculatorFactory(
configurationService,
currentRateService,
exchangeRateDataService,
redisCacheService,
null
portfolioSnapshotService,
redisCacheService
);
});
@ -102,14 +119,14 @@ describe('PortfolioCalculator', () => {
}
];
const portfolioCalculator = factory.createCalculator({
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.TWR,
currency: 'CHF',
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.getSnapshot();
const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
const investments = portfolioCalculator.getInvestments();

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

@ -14,6 +14,8 @@ import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.s
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper';
import { Big } from 'big.js';
@ -28,6 +30,18 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
};
});
jest.mock(
'@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service',
() => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
PortfolioSnapshotService: jest.fn().mockImplementation(() => {
return PortfolioSnapshotServiceMock;
})
};
}
);
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
@ -41,7 +55,8 @@ describe('PortfolioCalculator', () => {
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
let factory: PortfolioCalculatorFactory;
let portfolioCalculatorFactory: PortfolioCalculatorFactory;
let portfolioSnapshotService: PortfolioSnapshotService;
let redisCacheService: RedisCacheService;
beforeEach(() => {
@ -56,14 +71,16 @@ describe('PortfolioCalculator', () => {
null
);
portfolioSnapshotService = new PortfolioSnapshotService(null);
redisCacheService = new RedisCacheService(null, null);
factory = new PortfolioCalculatorFactory(
portfolioCalculatorFactory = new PortfolioCalculatorFactory(
configurationService,
currentRateService,
exchangeRateDataService,
redisCacheService,
null
portfolioSnapshotService,
redisCacheService
);
});
@ -89,14 +106,14 @@ describe('PortfolioCalculator', () => {
}
];
const portfolioCalculator = factory.createCalculator({
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.TWR,
currency: 'USD',
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.getSnapshot();
const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
expect(portfolioSnapshot).toMatchObject({
currentValueInBaseCurrency: new Big('0'),

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

@ -14,6 +14,8 @@ import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.s
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper';
import { Big } from 'big.js';
@ -27,6 +29,18 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
};
});
jest.mock(
'@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service',
() => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
PortfolioSnapshotService: jest.fn().mockImplementation(() => {
return PortfolioSnapshotServiceMock;
})
};
}
);
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
@ -40,7 +54,8 @@ describe('PortfolioCalculator', () => {
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
let factory: PortfolioCalculatorFactory;
let portfolioCalculatorFactory: PortfolioCalculatorFactory;
let portfolioSnapshotService: PortfolioSnapshotService;
let redisCacheService: RedisCacheService;
beforeEach(() => {
@ -55,14 +70,16 @@ describe('PortfolioCalculator', () => {
null
);
portfolioSnapshotService = new PortfolioSnapshotService(null);
redisCacheService = new RedisCacheService(null, null);
factory = new PortfolioCalculatorFactory(
portfolioCalculatorFactory = new PortfolioCalculatorFactory(
configurationService,
currentRateService,
exchangeRateDataService,
redisCacheService,
null
portfolioSnapshotService,
redisCacheService
);
});
@ -88,17 +105,18 @@ describe('PortfolioCalculator', () => {
}
];
const portfolioCalculator = factory.createCalculator({
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.TWR,
currency: 'USD',
userId: userDummyData.id
});
const liabilitiesInBaseCurrency =
await portfolioCalculator.getLiabilitiesInBaseCurrency();
const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
expect(liabilitiesInBaseCurrency).toEqual(new Big(3000));
expect(portfolioSnapshot.totalLiabilitiesWithCurrencyEffect).toEqual(
new Big(3000)
);
});
});
});

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

@ -15,6 +15,8 @@ import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cac
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 { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper';
import { Big } from 'big.js';
@ -29,6 +31,18 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
};
});
jest.mock(
'@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service',
() => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
PortfolioSnapshotService: jest.fn().mockImplementation(() => {
return PortfolioSnapshotServiceMock;
})
};
}
);
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
@ -54,7 +68,8 @@ describe('PortfolioCalculator', () => {
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
let factory: PortfolioCalculatorFactory;
let portfolioCalculatorFactory: PortfolioCalculatorFactory;
let portfolioSnapshotService: PortfolioSnapshotService;
let redisCacheService: RedisCacheService;
beforeEach(() => {
@ -69,14 +84,16 @@ describe('PortfolioCalculator', () => {
null
);
portfolioSnapshotService = new PortfolioSnapshotService(null);
redisCacheService = new RedisCacheService(null, null);
factory = new PortfolioCalculatorFactory(
portfolioCalculatorFactory = new PortfolioCalculatorFactory(
configurationService,
currentRateService,
exchangeRateDataService,
redisCacheService,
null
portfolioSnapshotService,
redisCacheService
);
});
@ -117,14 +134,14 @@ describe('PortfolioCalculator', () => {
}
];
const portfolioCalculator = factory.createCalculator({
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.TWR,
currency: 'USD',
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.getSnapshot();
const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
expect(portfolioSnapshot).toMatchObject({
errors: [],

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

@ -9,11 +9,11 @@ import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.s
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper';
import { Big } from 'big.js';
import { subDays } from 'date-fns';
import { last } from 'lodash';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
@ -24,6 +24,18 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
};
});
jest.mock(
'@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service',
() => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
PortfolioSnapshotService: jest.fn().mockImplementation(() => {
return PortfolioSnapshotServiceMock;
})
};
}
);
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
@ -37,7 +49,8 @@ describe('PortfolioCalculator', () => {
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
let factory: PortfolioCalculatorFactory;
let portfolioCalculatorFactory: PortfolioCalculatorFactory;
let portfolioSnapshotService: PortfolioSnapshotService;
let redisCacheService: RedisCacheService;
beforeEach(() => {
@ -52,14 +65,16 @@ describe('PortfolioCalculator', () => {
null
);
portfolioSnapshotService = new PortfolioSnapshotService(null);
redisCacheService = new RedisCacheService(null, null);
factory = new PortfolioCalculatorFactory(
portfolioCalculatorFactory = new PortfolioCalculatorFactory(
configurationService,
currentRateService,
exchangeRateDataService,
redisCacheService,
null
portfolioSnapshotService,
redisCacheService
);
});
@ -67,14 +82,14 @@ describe('PortfolioCalculator', () => {
it('with no orders', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2021-12-18').getTime());
const portfolioCalculator = factory.createCalculator({
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities: [],
calculationType: PerformanceCalculationType.TWR,
currency: 'CHF',
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.getSnapshot();
const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
const investments = portfolioCalculator.getInvestments();

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

@ -14,6 +14,8 @@ import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.s
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper';
import { Big } from 'big.js';
@ -28,6 +30,18 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
};
});
jest.mock(
'@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service',
() => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
PortfolioSnapshotService: jest.fn().mockImplementation(() => {
return PortfolioSnapshotServiceMock;
})
};
}
);
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
@ -41,7 +55,8 @@ describe('PortfolioCalculator', () => {
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
let factory: PortfolioCalculatorFactory;
let portfolioCalculatorFactory: PortfolioCalculatorFactory;
let portfolioSnapshotService: PortfolioSnapshotService;
let redisCacheService: RedisCacheService;
beforeEach(() => {
@ -56,14 +71,16 @@ describe('PortfolioCalculator', () => {
null
);
portfolioSnapshotService = new PortfolioSnapshotService(null);
redisCacheService = new RedisCacheService(null, null);
factory = new PortfolioCalculatorFactory(
portfolioCalculatorFactory = new PortfolioCalculatorFactory(
configurationService,
currentRateService,
exchangeRateDataService,
redisCacheService,
null
portfolioSnapshotService,
redisCacheService
);
});
@ -104,14 +121,14 @@ describe('PortfolioCalculator', () => {
}
];
const portfolioCalculator = factory.createCalculator({
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.TWR,
currency: 'CHF',
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.getSnapshot();
const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
const investments = portfolioCalculator.getInvestments();

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

@ -14,6 +14,8 @@ import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.s
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper';
import { Big } from 'big.js';
@ -28,6 +30,18 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
};
});
jest.mock(
'@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service',
() => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
PortfolioSnapshotService: jest.fn().mockImplementation(() => {
return PortfolioSnapshotServiceMock;
})
};
}
);
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
@ -41,7 +55,8 @@ describe('PortfolioCalculator', () => {
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
let factory: PortfolioCalculatorFactory;
let portfolioCalculatorFactory: PortfolioCalculatorFactory;
let portfolioSnapshotService: PortfolioSnapshotService;
let redisCacheService: RedisCacheService;
beforeEach(() => {
@ -56,14 +71,16 @@ describe('PortfolioCalculator', () => {
null
);
portfolioSnapshotService = new PortfolioSnapshotService(null);
redisCacheService = new RedisCacheService(null, null);
factory = new PortfolioCalculatorFactory(
portfolioCalculatorFactory = new PortfolioCalculatorFactory(
configurationService,
currentRateService,
exchangeRateDataService,
redisCacheService,
null
portfolioSnapshotService,
redisCacheService
);
});
@ -104,14 +121,14 @@ describe('PortfolioCalculator', () => {
}
];
const portfolioCalculator = factory.createCalculator({
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.TWR,
currency: 'CHF',
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.getSnapshot();
const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
const investments = portfolioCalculator.getInvestments();

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

@ -3,12 +3,14 @@ import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.s
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 { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
describe('PortfolioCalculator', () => {
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
let factory: PortfolioCalculatorFactory;
let portfolioCalculatorFactory: PortfolioCalculatorFactory;
let portfolioSnapshotService: PortfolioSnapshotService;
let redisCacheService: RedisCacheService;
beforeEach(() => {
@ -23,14 +25,16 @@ describe('PortfolioCalculator', () => {
null
);
portfolioSnapshotService = new PortfolioSnapshotService(null);
redisCacheService = new RedisCacheService(null, null);
factory = new PortfolioCalculatorFactory(
portfolioCalculatorFactory = new PortfolioCalculatorFactory(
configurationService,
currentRateService,
exchangeRateDataService,
redisCacheService,
null
portfolioSnapshotService,
redisCacheService
);
});

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

@ -1,4 +1,5 @@
import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { LogPerformance } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { resetHours } from '@ghostfolio/common/helper';
@ -27,6 +28,7 @@ export class CurrentRateService {
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
@LogPerformance
// TODO: Pass user instead of using this.request.user
public async getValues({
dataGatheringItems,

3
apps/api/src/app/portfolio/interfaces/portfolio-holding-detail.interface.ts

@ -5,10 +5,9 @@ import {
HistoricalDataItem
} from '@ghostfolio/common/interfaces';
import { Account, Tag } from '@prisma/client';
import { Tag } from '@prisma/client';
export interface PortfolioHoldingDetail {
accounts: Account[];
averagePrice: number;
dataProviderInfo: DataProviderInfo;
dividendInBaseCurrency: number;

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

@ -0,0 +1,4 @@
export interface PortfolioSnapshotValue {
expiration: number;
portfolioSnapshot: string;
}

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

@ -1,32 +1,27 @@
import { LogPerformance } from '@ghostfolio/api/aop/logging.interceptor';
import { AccessService } from '@ghostfolio/api/app/access/access.service';
import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { UserService } from '@ghostfolio/api/app/user/user.service';
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import {
hasNotDefinedValuesInObject,
nullifyValuesInObject
} from '@ghostfolio/api/helper/object.helper';
import { PerformanceLoggingInterceptor } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor';
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.interceptor';
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper';
import {
DEFAULT_CURRENCY,
HEADER_KEY_IMPERSONATION
} from '@ghostfolio/common/config';
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
import {
PortfolioDetails,
PortfolioDividends,
PortfolioHoldingsResponse,
PortfolioInvestments,
PortfolioPerformanceResponse,
PortfolioPublicDetails,
PortfolioReport
} from '@ghostfolio/common/interfaces';
import {
@ -70,12 +65,10 @@ export class PortfolioController {
private readonly accessService: AccessService,
private readonly apiService: ApiService,
private readonly configurationService: ConfigurationService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly impersonationService: ImpersonationService,
private readonly orderService: OrderService,
private readonly portfolioService: PortfolioService,
@Inject(REQUEST) private readonly request: RequestWithUser,
private readonly userService: UserService
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
@Get('details')
@ -410,6 +403,7 @@ export class PortfolioController {
@Get('performance')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(PerformanceLoggingInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
@Version('2')
@LogPerformance
@ -517,75 +511,6 @@ export class PortfolioController {
return performanceInformation;
}
@Get('public/:accessId')
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getPublic(
@Param('accessId') accessId
): Promise<PortfolioPublicDetails> {
const access = await this.accessService.access({ id: accessId });
if (!access) {
throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
);
}
let hasDetails = true;
const user = await this.userService.user({
id: access.userId
});
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
hasDetails = user.subscription.type === 'Premium';
}
const { holdings } = await this.portfolioService.getDetails({
filters: [{ id: 'EQUITY', type: 'ASSET_CLASS' }],
impersonationId: access.userId,
userId: user.id,
withMarkets: true
});
const portfolioPublicDetails: PortfolioPublicDetails = {
hasDetails,
alias: access.alias,
holdings: {}
};
const totalValue = Object.values(holdings)
.map((portfolioPosition) => {
return this.exchangeRateDataService.toCurrency(
portfolioPosition.quantity * portfolioPosition.marketPrice,
portfolioPosition.currency,
this.request.user?.Settings?.settings.baseCurrency ?? DEFAULT_CURRENCY
);
})
.reduce((a, b) => a + b, 0);
for (const [symbol, portfolioPosition] of Object.entries(holdings)) {
portfolioPublicDetails.holdings[symbol] = {
allocationInPercentage:
portfolioPosition.valueInBaseCurrency / totalValue,
countries: hasDetails ? portfolioPosition.countries : [],
currency: hasDetails ? portfolioPosition.currency : undefined,
dataSource: portfolioPosition.dataSource,
dateOfFirstActivity: portfolioPosition.dateOfFirstActivity,
markets: hasDetails ? portfolioPosition.markets : undefined,
name: portfolioPosition.name,
netPerformancePercentWithCurrencyEffect:
portfolioPosition.netPerformancePercentWithCurrencyEffect,
sectors: hasDetails ? portfolioPosition.sectors : [],
symbol: portfolioPosition.symbol,
url: portfolioPosition.url,
valueInPercentage: portfolioPosition.valueInBaseCurrency / totalValue
};
}
return portfolioPublicDetails;
}
@Get('position/:dataSource/:symbol')
@UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInRequestInterceptor)

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

@ -4,17 +4,19 @@ 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 { PerformanceLoggingModule } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.module';
import { RedactValuesInResponseModule } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.module';
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module';
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module';
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { DataGatheringModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module';
import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
import { Module } from '@nestjs/common';
@ -38,6 +40,8 @@ import { RulesService } from './rules.service';
ImpersonationModule,
MarketDataModule,
OrderModule,
PerformanceLoggingModule,
PortfolioSnapshotQueueModule,
PrismaModule,
RedactValuesInResponseModule,
RedisCacheModule,

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

@ -75,7 +75,7 @@ import {
parseISO,
set
} from 'date-fns';
import { isEmpty, last, uniq, uniqBy } from 'lodash';
import { isEmpty, last, uniq } from 'lodash';
import { CPRPortfolioCalculator } from './calculator/constantPortfolioReturn/portfolio-calculator';
import { PortfolioCalculator } from './calculator/portfolio-calculator';
@ -119,12 +119,33 @@ export class PortfolioService {
}): Promise<AccountWithValue[]> {
const where: Prisma.AccountWhereInput = { userId };
const accountFilter = filters?.find(({ type }) => {
const filterByAccount = filters?.find(({ type }) => {
return type === 'ACCOUNT';
});
})?.id;
const filterByDataSource = filters?.find(({ type }) => {
return type === 'DATA_SOURCE';
})?.id;
const filterBySymbol = filters?.find(({ type }) => {
return type === 'SYMBOL';
})?.id;
if (accountFilter) {
where.id = accountFilter.id;
if (filterByAccount) {
where.id = filterByAccount;
}
if (filterByDataSource && filterBySymbol) {
where.Order = {
some: {
SymbolProfile: {
AND: [
{ dataSource: <DataSource>filterByDataSource },
{ symbol: filterBySymbol }
]
}
}
};
}
const [accounts, details] = await Promise.all([
@ -684,16 +705,8 @@ export class PortfolioService {
userId
});
const orders = activities.filter(({ SymbolProfile }) => {
return (
SymbolProfile.dataSource === aDataSource &&
SymbolProfile.symbol === aSymbol
);
});
if (orders.length <= 0) {
if (activities.length === 0) {
return {
accounts: [],
averagePrice: undefined,
dataProviderInfo: undefined,
stakeRewards: undefined,
@ -729,12 +742,8 @@ export class PortfolioService {
]);
const portfolioCalculator = this.calculatorFactory.createCalculator({
activities,
userId,
activities: orders.filter((order) => {
return ['BUY', 'DIVIDEND', 'ITEM', 'SELL', 'STAKE'].includes(
order.type
);
}),
calculationType: PerformanceCalculationType.TWR,
currency: userCurrency
});
@ -744,8 +753,8 @@ export class PortfolioService {
const { positions } = await portfolioCalculator.getSnapshot();
const position = positions.find(({ symbol }) => {
return symbol === aSymbol;
const position = positions.find(({ dataSource, symbol }) => {
return dataSource === aDataSource && symbol === aSymbol;
});
if (position) {
@ -758,19 +767,18 @@ export class PortfolioService {
firstBuyDate,
marketPrice,
quantity,
symbol,
tags,
timeWeightedInvestment,
timeWeightedInvestmentWithCurrencyEffect,
transactionCount
} = position;
const accounts: PortfolioHoldingDetail['accounts'] = uniqBy(
orders.filter(({ Account }) => {
return Account;
}),
'Account.id'
).map(({ Account }) => {
return Account;
const activitiesOfPosition = activities.filter(({ SymbolProfile }) => {
return (
SymbolProfile.dataSource === dataSource &&
SymbolProfile.symbol === symbol
);
});
const dividendYieldPercent = getAnnualizedPerformancePercent({
@ -827,8 +835,8 @@ export class PortfolioService {
);
const historicalDataArray: HistoricalDataItem[] = [];
let maxPrice = Math.max(orders[0].unitPrice, marketPrice);
let minPrice = Math.min(orders[0].unitPrice, marketPrice);
let maxPrice = Math.max(activitiesOfPosition[0].unitPrice, marketPrice);
let minPrice = Math.min(activitiesOfPosition[0].unitPrice, marketPrice);
if (historicalData[aSymbol]) {
let j = -1;
@ -872,20 +880,18 @@ export class PortfolioService {
} else {
// Add historical entry for buy date, if no historical data available
historicalDataArray.push({
averagePrice: orders[0].unitPrice,
averagePrice: activitiesOfPosition[0].unitPrice,
date: firstBuyDate,
marketPrice: orders[0].unitPrice,
quantity: orders[0].quantity
marketPrice: activitiesOfPosition[0].unitPrice,
quantity: activitiesOfPosition[0].quantity
});
}
return {
accounts,
firstBuyDate,
marketPrice,
maxPrice,
minPrice,
orders,
SymbolProfile,
tags,
transactionCount,
@ -918,6 +924,7 @@ export class PortfolioService {
]?.toNumber(),
netPerformanceWithCurrencyEffect:
position.netPerformanceWithCurrencyEffectMap?.['max']?.toNumber(),
orders: activitiesOfPosition,
quantity: quantity.toNumber(),
value: this.exchangeRateDataService.toCurrency(
quantity.mul(marketPrice ?? 0).toNumber(),
@ -975,9 +982,7 @@ export class PortfolioService {
marketPrice,
maxPrice,
minPrice,
orders,
SymbolProfile,
accounts: [],
averagePrice: 0,
dataProviderInfo: undefined,
stakeRewards: 0,
@ -996,6 +1001,7 @@ export class PortfolioService {
netPerformancePercent: undefined,
netPerformancePercentWithCurrencyEffect: undefined,
netPerformanceWithCurrencyEffect: undefined,
orders: [],
quantity: 0,
tags: [],
transactionCount: undefined,
@ -1027,7 +1033,7 @@ export class PortfolioService {
userCurrency: this.getUserCurrency()
});
if (activities?.length <= 0) {
if (activities.length === 0) {
return {
hasErrors: false,
positions: []
@ -1154,7 +1160,6 @@ export class PortfolioService {
dateRange = 'max',
filters,
impersonationId,
portfolioCalculator,
userId,
withExcludedAccounts = false,
calculateTimeWeightedPerformance = false
@ -1162,7 +1167,6 @@ export class PortfolioService {
dateRange?: DateRange;
filters?: Filter[];
impersonationId: string;
portfolioCalculator?: PortfolioCalculator;
userId: string;
withExcludedAccounts?: boolean;
calculateTimeWeightedPerformance?: boolean;
@ -1171,35 +1175,12 @@ export class PortfolioService {
const user = await this.userService.user({ id: userId });
const userCurrency = this.getUserCurrency(user);
const accountBalances = await this.accountBalanceService.getAccountBalances(
{ filters, user, withExcludedAccounts }
);
let accountBalanceItems: HistoricalDataItem[] = Object.values(
// Reduce the array to a map with unique dates as keys
accountBalances.balances.reduce(
(
map: { [date: string]: HistoricalDataItem },
{ date, valueInBaseCurrency }
) => {
const formattedDate = format(date, DATE_FORMAT);
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;
},
{}
)
);
const accountBalanceItems =
await this.accountBalanceService.getAccountBalanceItems({
filters,
userId,
userCurrency
});
const { activities } =
await this.orderService.getOrdersForPortfolioCalculator({
@ -1208,7 +1189,7 @@ export class PortfolioService {
userId
});
if (accountBalanceItems?.length <= 0 && activities?.length <= 0) {
if (accountBalanceItems.length === 0 && activities.length === 0) {
return {
chart: [],
firstOrderDate: undefined,
@ -1225,9 +1206,7 @@ export class PortfolioService {
};
}
portfolioCalculator =
portfolioCalculator ??
this.calculatorFactory.createCalculator({
const portfolioCalculator = this.calculatorFactory.createCalculator({
accountBalanceItems,
activities,
filters,
@ -1544,6 +1523,8 @@ export class PortfolioService {
}: {
holdings: PortfolioDetails['holdings'];
}) {
// TODO: Use current value of activities instead of holdings
// tagged with EMERGENCY_FUND_TAG_ID
const emergencyFundHoldings = Object.values(holdings).filter(({ tags }) => {
return (
tags?.some(({ id }) => {

21
apps/api/src/app/portfolio/rules.service.ts

@ -1,6 +1,9 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { Rule } from '@ghostfolio/api/models/rule';
import { UserSettings } from '@ghostfolio/common/interfaces';
import {
PortfolioReportRule,
UserSettings
} from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common';
@ -11,19 +14,23 @@ export class RulesService {
public async evaluate<T extends RuleSettings>(
aRules: Rule<T>[],
aUserSettings: UserSettings
) {
): Promise<PortfolioReportRule[]> {
return aRules.map((rule) => {
if (rule.getSettings(aUserSettings)?.isActive) {
const { evaluation, value } = rule.evaluate(
rule.getSettings(aUserSettings)
);
const settings = rule.getSettings(aUserSettings);
if (settings?.isActive) {
const { evaluation, value } = rule.evaluate(settings);
return {
evaluation,
value,
isActive: true,
key: rule.getKey(),
name: rule.getName()
name: rule.getName(),
settings: <PortfolioReportRule['settings']>{
thresholdMax: settings['thresholdMax'],
thresholdMin: settings['thresholdMin']
}
};
} else {
return {

7
apps/api/src/app/redis-cache/interfaces/redis-cache.interface.ts

@ -1,7 +0,0 @@
import { Cache } from 'cache-manager';
import type { RedisStore } from './redis-store.interface';
export interface RedisCache extends Cache {
store: RedisStore;
}

8
apps/api/src/app/redis-cache/interfaces/redis-store.interface.ts

@ -1,8 +0,0 @@
import { Store } from 'cache-manager';
import { createClient } from 'redis';
export interface RedisStore extends Store {
getClient: () => ReturnType<typeof createClient>;
isCacheableValue: (value: any) => boolean;
name: 'redis';
}

20
apps/api/src/app/redis-cache/redis-cache.module.ts

@ -3,31 +3,31 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration/con
import { CacheModule } from '@nestjs/cache-manager';
import { Module } from '@nestjs/common';
import * as redisStore from 'cache-manager-redis-store';
import { redisStore } from 'cache-manager-redis-yet';
import type { RedisClientOptions } from 'redis';
import { RedisCacheService } from './redis-cache.service';
@Module({
exports: [RedisCacheService],
imports: [
CacheModule.registerAsync({
CacheModule.registerAsync<RedisClientOptions>({
imports: [ConfigurationModule],
inject: [ConfigurationService],
useFactory: async (configurationService: ConfigurationService) => {
const redisPassword = encodeURIComponent(
configurationService.get('REDIS_PASSWORD')
);
return <RedisClientOptions>{
db: configurationService.get('REDIS_DB'),
host: configurationService.get('REDIS_HOST'),
max: configurationService.get('MAX_ITEM_IN_CACHE'),
password: configurationService.get('REDIS_PASSWORD'),
port: configurationService.get('REDIS_PORT'),
store: redisStore,
ttl: configurationService.get('CACHE_TTL')
ttl: configurationService.get('CACHE_TTL'),
url: `redis://${redisPassword ? `:${redisPassword}` : ''}@${configurationService.get('REDIS_HOST')}:${configurationService.get('REDIS_PORT')}/${configurationService.get('REDIS_DB')}`
};
}
}),
ConfigurationModule
],
providers: [RedisCacheService],
exports: [RedisCacheService]
providers: [RedisCacheService]
})
export class RedisCacheModule {}

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

@ -1,13 +1,28 @@
import { RedisCacheService } from './redis-cache.service';
import { Filter } from '@ghostfolio/common/interfaces';
import { Milliseconds } from 'cache-manager';
export const RedisCacheServiceMock = {
cache: new Map<string, string>(),
get: (key: string): Promise<string> => {
return Promise.resolve(null);
const value = RedisCacheServiceMock.cache.get(key) || null;
return Promise.resolve(value);
},
getPortfolioSnapshotKey: (userId: string): string => {
return `portfolio-snapshot-${userId}`;
getPortfolioSnapshotKey: ({
filters,
userId
}: {
filters?: Filter[];
userId: string;
}): string => {
const filtersHash = filters?.length;
return `portfolio-snapshot-${userId}${filtersHash > 0 ? `-${filtersHash}` : ''}`;
},
set: (key: string, value: string, ttlInSeconds?: number): Promise<string> => {
set: (key: string, value: string, ttl?: Milliseconds): Promise<string> => {
RedisCacheServiceMock.cache.set(key, value);
return Promise.resolve(value);
}
};

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

@ -4,17 +4,17 @@ import { AssetProfileIdentifier, Filter } from '@ghostfolio/common/interfaces';
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Inject, Injectable, Logger } from '@nestjs/common';
import { Milliseconds } from 'cache-manager';
import { RedisCache } from 'cache-manager-redis-yet';
import { createHash } from 'crypto';
import type { RedisCache } from './interfaces/redis-cache.interface';
@Injectable()
export class RedisCacheService {
public constructor(
@Inject(CACHE_MANAGER) private readonly cache: RedisCache,
private readonly configurationService: ConfigurationService
) {
const client = cache.store.getClient();
const client = cache.store.client;
client.on('error', (error) => {
Logger.error(error, 'RedisCacheService');
@ -81,11 +81,11 @@ export class RedisCacheService {
return this.cache.reset();
}
public async set(key: string, value: string, ttlInSeconds?: number) {
public async set(key: string, value: string, ttl?: Milliseconds) {
return this.cache.set(
key,
value,
ttlInSeconds ?? this.configurationService.get('CACHE_TTL')
ttl ?? this.configurationService.get('CACHE_TTL')
);
}
}

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

@ -371,7 +371,7 @@ export class UserService {
const hashedAccessToken = this.createAccessToken(
accessToken,
process.env.ACCESS_TOKEN_SALT
this.configurationService.get('ACCESS_TOKEN_SALT')
);
user = await this.prismaService.user.update({

40
apps/api/src/assets/sitemap.xml

@ -172,6 +172,10 @@
<loc>https://ghostfol.io/en/blog/2023/11/hacktoberfest-2023-debriefing</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/blog/2024/09/hacktoberfest-2024</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/faq</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -446,12 +450,46 @@
<loc>https://ghostfol.io/nl/veelgestelde-vragen</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<!--
<url>
<loc>https://ghostfol.io/pl</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/pl/blog</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/pl/cennik</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<!--
<url>
<loc>https://ghostfol.io/pl/faq</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
-->
<url>
<loc>https://ghostfol.io/pl/funkcje</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/pl/o-ghostfolio</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<!--
<url>
<loc>https://ghostfol.io/pl/open</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
-->
<url>
<loc>https://ghostfol.io/pl/rynki</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/pl/zarejestruj</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/pt</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>

80
apps/api/src/interceptors/performance-logging/performance-logging.interceptor.ts

@ -0,0 +1,80 @@
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
import { PerformanceLoggingService } from './performance-logging.service';
@Injectable()
export class PerformanceLoggingInterceptor implements NestInterceptor {
public constructor(
private readonly performanceLoggingService: PerformanceLoggingService
) {}
public intercept(
context: ExecutionContext,
next: CallHandler
): Observable<any> {
const startTime = performance.now();
const className = context.getClass().name;
const methodName = context.getHandler().name;
return next.handle().pipe(
tap(() => {
return this.performanceLoggingService.logPerformance({
className,
methodName,
startTime
});
})
);
}
}
export function LogPerformance(
target: any,
propertyKey: string,
descriptor: PropertyDescriptor
) {
const originalMethod = descriptor.value;
descriptor.value = async function (...args: any[]) {
const startTime = performance.now();
const performanceLoggingService = new PerformanceLoggingService();
const result = originalMethod.apply(this, args);
if (result instanceof Promise) {
// Handle async method
return result
.then((res: any) => {
performanceLoggingService.logPerformance({
startTime,
className: target.constructor.name,
methodName: propertyKey
});
return res;
})
.catch((error: any) => {
throw error;
});
} else {
// Handle sync method
performanceLoggingService.logPerformance({
startTime,
className: target.constructor.name,
methodName: propertyKey
});
return result;
}
};
return descriptor;
}

10
apps/api/src/interceptors/performance-logging/performance-logging.module.ts

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { PerformanceLoggingInterceptor } from './performance-logging.interceptor';
import { PerformanceLoggingService } from './performance-logging.service';
@Module({
exports: [PerformanceLoggingInterceptor, PerformanceLoggingService],
providers: [PerformanceLoggingInterceptor, PerformanceLoggingService]
})
export class PerformanceLoggingModule {}

21
apps/api/src/interceptors/performance-logging/performance-logging.service.ts

@ -0,0 +1,21 @@
import { Injectable, Logger } from '@nestjs/common';
@Injectable()
export class PerformanceLoggingService {
public logPerformance({
className,
methodName,
startTime
}: {
className: string;
methodName: string;
startTime: number;
}) {
const endTime = performance.now();
Logger.debug(
`Completed execution of ${methodName}() in ${((endTime - startTime) / 1000).toFixed(3)} seconds`,
className
);
}
}

10
apps/api/src/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor.ts

@ -39,12 +39,12 @@ export class TransformDataSourceInRequestInterceptor<T>
});
}
if (request.body.dataSource && !DataSource[request.body.dataSource]) {
request.body.dataSource = decodeDataSource(request.body.dataSource);
}
for (const type of ['body', 'params', 'query']) {
const dataSourceValue = request[type]?.dataSource;
if (request.params.dataSource && !DataSource[request.params.dataSource]) {
request.params.dataSource = decodeDataSource(request.params.dataSource);
if (dataSourceValue && !DataSource[dataSourceValue]) {
request[type].dataSource = decodeDataSource(dataSourceValue);
}
}
}

45
apps/api/src/main.ts

@ -1,4 +1,9 @@
import { Logger, ValidationPipe, VersioningType } from '@nestjs/common';
import {
Logger,
LogLevel,
ValidationPipe,
VersioningType
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { NestFactory } from '@nestjs/core';
import type { NestExpressApplication } from '@nestjs/platform-express';
@ -13,36 +18,20 @@ import { HtmlTemplateMiddleware } from './middlewares/html-template.middleware';
async function bootstrap() {
const configApp = await NestFactory.create(AppModule);
const configService = configApp.get<ConfigService>(ConfigService);
let customLogLevels: LogLevel[];
let logLevelArray = [];
let logLevel = configService.get<string>('LOG_LEVEL');
Logger.log(`Log-Level: ${logLevel}`);
switch (logLevel?.toLowerCase()) {
case 'verbose':
logLevelArray = ['debug', 'error', 'log', 'verbose', 'warn'];
break;
case 'debug':
logLevelArray = ['debug', 'error', 'log', 'warn'];
break;
case 'log':
logLevelArray = ['error', 'log', 'warn'];
break;
case 'warn':
logLevelArray = ['error', 'warn'];
break;
case 'error':
logLevelArray = ['error'];
break;
default:
logLevelArray = environment.production
? ['error', 'log', 'warn']
: ['debug', 'error', 'log', 'verbose', 'warn'];
break;
}
try {
customLogLevels = JSON.parse(
configService.get<string>('LOG_LEVELS')
) as LogLevel[];
} catch {}
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
logger: logLevelArray
logger:
customLogLevels ??
(environment.production
? ['error', 'log', 'warn']
: ['debug', 'error', 'log', 'verbose', 'warn'])
});
app.enableCors();

4
apps/api/src/middlewares/html-template.middleware.ts

@ -83,6 +83,10 @@ const locales = {
'/en/blog/2023/11/hacktoberfest-2023-debriefing': {
featureGraphicPath: 'assets/images/blog/hacktoberfest-2023.png',
title: `Hacktoberfest 2023 Debriefing - ${title}`
},
'/en/blog/2024/09/hacktoberfest-2024': {
featureGraphicPath: 'assets/images/blog/hacktoberfest-2024.png',
title: `Hacktoberfest 2024 - ${title}`
}
};

20
apps/api/src/services/api/api.service.ts

@ -10,22 +10,28 @@ export class ApiService {
filterByAccounts,
filterByAssetClasses,
filterByAssetSubClasses,
filterByDataSource,
filterByHoldingType,
filterBySearchQuery,
filterBySymbol,
filterByTags
}: {
filterByAccounts?: string;
filterByAssetClasses?: string;
filterByAssetSubClasses?: string;
filterByDataSource?: string;
filterByHoldingType?: string;
filterBySearchQuery?: string;
filterBySymbol?: string;
filterByTags?: string;
}): Filter[] {
const accountIds = filterByAccounts?.split(',') ?? [];
const assetClasses = filterByAssetClasses?.split(',') ?? [];
const assetSubClasses = filterByAssetSubClasses?.split(',') ?? [];
const dataSource = filterByDataSource;
const holdingType = filterByHoldingType;
const searchQuery = filterBySearchQuery?.toLowerCase();
const symbol = filterBySymbol;
const tagIds = filterByTags?.split(',') ?? [];
const filters = [
@ -55,6 +61,13 @@ export class ApiService {
})
];
if (dataSource) {
filters.push({
id: dataSource,
type: 'DATA_SOURCE'
});
}
if (holdingType) {
filters.push({
id: holdingType,
@ -69,6 +82,13 @@ export class ApiService {
});
}
if (symbol) {
filters.push({
id: symbol,
type: 'SYMBOL'
});
}
return filters;
}
}

23
apps/api/src/services/configuration/configuration.service.ts

@ -1,5 +1,11 @@
import { Environment } from '@ghostfolio/api/services/interfaces/environment.interface';
import { DEFAULT_ROOT_URL } from '@ghostfolio/common/config';
import {
CACHE_TTL_NO_CACHE,
DEFAULT_PROCESSOR_CONCURRENCY_GATHER_ASSET_PROFILE,
DEFAULT_PROCESSOR_CONCURRENCY_GATHER_HISTORICAL_MARKET_DATA,
DEFAULT_PROCESSOR_CONCURRENCY_PORTFOLIO_SNAPSHOT,
DEFAULT_ROOT_URL
} from '@ghostfolio/common/config';
import { Injectable } from '@nestjs/common';
import { DataSource } from '@prisma/client';
@ -21,9 +27,8 @@ export class ConfigurationService {
API_KEY_FINANCIAL_MODELING_PREP: str({ default: '' }),
API_KEY_OPEN_FIGI: str({ default: '' }),
API_KEY_RAPID_API: str({ default: '' }),
CACHE_QUOTES_TTL: num({ default: ms('1 minute') / 1000 }),
CACHE_TTL: num({ default: 1 }),
LOG_LEVEL: str({ default: '' }),
CACHE_QUOTES_TTL: num({ default: ms('1 minute') }),
CACHE_TTL: num({ default: CACHE_TTL_NO_CACHE }),
DATA_SOURCE_EXCHANGE_RATES: str({ default: DataSource.YAHOO }),
DATA_SOURCE_IMPORT: str({ default: DataSource.YAHOO }),
DATA_SOURCES: json({
@ -44,8 +49,16 @@ export class ConfigurationService {
JWT_SECRET_KEY: str({}),
MAX_ACTIVITIES_TO_IMPORT: num({ default: Number.MAX_SAFE_INTEGER }),
MAX_CHART_ITEMS: num({ default: 365 }),
MAX_ITEM_IN_CACHE: num({ default: 9999 }),
PORT: port({ default: 3333 }),
PROCESSOR_CONCURRENCY_GATHER_ASSET_PROFILE: num({
default: DEFAULT_PROCESSOR_CONCURRENCY_GATHER_ASSET_PROFILE
}),
PROCESSOR_CONCURRENCY_GATHER_HISTORICAL_MARKET_DATA: num({
default: DEFAULT_PROCESSOR_CONCURRENCY_GATHER_HISTORICAL_MARKET_DATA
}),
PROCESSOR_CONCURRENCY_PORTFOLIO_SNAPSHOT: num({
default: DEFAULT_PROCESSOR_CONCURRENCY_PORTFOLIO_SNAPSHOT
}),
REDIS_DB: num({ default: 0 }),
REDIS_HOST: str({ default: 'localhost' }),
REDIS_PASSWORD: str({ default: '' }),

2
apps/api/src/services/cron.service.ts

@ -9,9 +9,9 @@ import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
import { Injectable } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { DataGatheringService } from './data-gathering/data-gathering.service';
import { ExchangeRateDataService } from './exchange-rate-data/exchange-rate-data.service';
import { PropertyService } from './property/property.service';
import { DataGatheringService } from './queues/data-gathering/data-gathering.service';
import { TwitterBotService } from './twitter-bot/twitter-bot.service';
@Injectable()

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

@ -206,9 +206,9 @@ export class CoinGeckoService implements DataProviderInterface {
let message = error;
if (error?.code === 'ABORT_ERR') {
message = `RequestError: The operation to get the quotes was aborted because the request to the data provider took more than ${this.configurationService.get(
'REQUEST_TIMEOUT'
)}ms`;
message = `RequestError: The operation to get the quotes was aborted because the request to the data provider took more than ${(
this.configurationService.get('REQUEST_TIMEOUT') / 1000
).toFixed(3)} seconds`;
}
Logger.error(message, 'CoinGeckoService');

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

@ -18,6 +18,7 @@ import {
} from '@ghostfolio/common/config';
import { DATE_FORMAT, isCurrency } from '@ghostfolio/common/helper';
import { DataProviderInfo } from '@ghostfolio/common/interfaces';
import { MarketState } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common';
import {
@ -229,7 +230,12 @@ export class EodHistoricalDataService implements DataProviderInterface {
}
).json<any>();
const quotes =
const quotes: {
close: number;
code: string;
previousClose: number;
timestamp: number;
}[] =
eodHistoricalDataSymbols.length === 1
? [realTimeResponse]
: realTimeResponse;
@ -243,7 +249,7 @@ export class EodHistoricalDataService implements DataProviderInterface {
})
);
for (const { close, code, timestamp } of quotes) {
for (const { close, code, previousClose, timestamp } of quotes) {
let currency: string;
if (this.isForex(code)) {
@ -267,15 +273,21 @@ export class EodHistoricalDataService implements DataProviderInterface {
}
}
if (isNumber(close)) {
if (isNumber(close) || isNumber(previousClose)) {
const marketPrice: number = isNumber(close) ? close : previousClose;
let marketState: MarketState = 'closed';
if (this.isForex(code) || isToday(new Date(timestamp * 1000))) {
marketState = 'open';
} else if (!isNumber(close)) {
marketState = 'delayed';
}
response[this.convertFromEodSymbol(code)] = {
currency,
dataSource: this.getName(),
marketPrice: close,
marketState:
this.isForex(code) || isToday(new Date(timestamp * 1000))
? 'open'
: 'closed'
marketPrice,
marketState,
dataSource: this.getName()
};
} else {
Logger.error(
@ -290,9 +302,9 @@ export class EodHistoricalDataService implements DataProviderInterface {
let message = error;
if (error?.code === 'ABORT_ERR') {
message = `RequestError: The operation to get the quotes was aborted because the request to the data provider took more than ${this.configurationService.get(
'REQUEST_TIMEOUT'
)}ms`;
message = `RequestError: The operation to get the quotes was aborted because the request to the data provider took more than ${(
this.configurationService.get('REQUEST_TIMEOUT') / 1000
).toFixed(3)} seconds`;
}
Logger.error(message, 'EodHistoricalDataService');

6
apps/api/src/services/data-provider/financial-modeling-prep/financial-modeling-prep.service.ts

@ -154,9 +154,9 @@ export class FinancialModelingPrepService implements DataProviderInterface {
let message = error;
if (error?.code === 'ABORT_ERR') {
message = `RequestError: The operation to get the quotes was aborted because the request to the data provider took more than ${this.configurationService.get(
'REQUEST_TIMEOUT'
)}ms`;
message = `RequestError: The operation to get the quotes was aborted because the request to the data provider took more than ${(
this.configurationService.get('REQUEST_TIMEOUT') / 1000
).toFixed(3)} seconds`;
}
Logger.error(message, 'FinancialModelingPrepService');

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

@ -174,11 +174,42 @@ export class ManualService implements DataProviderInterface {
)
.then((_result) => _result.flat());
const symbolProfilesWithScraperConfigurationAndInstantMode =
symbolProfiles.filter(({ scraperConfiguration }) => {
return scraperConfiguration?.mode === 'instant';
});
const scraperResultPromises =
symbolProfilesWithScraperConfigurationAndInstantMode.map(
async ({ scraperConfiguration, symbol }) => {
try {
const marketPrice = await this.scrape(scraperConfiguration);
return { marketPrice, symbol };
} catch (error) {
Logger.error(
`Could not get quote for ${symbol} (${this.getName()}): [${error.name}] ${error.message}`,
'ManualService'
);
return { symbol, marketPrice: undefined };
}
}
);
// Wait for all scraping requests to complete concurrently
const scraperResults = await Promise.all(scraperResultPromises);
for (const { currency, symbol } of symbolProfiles) {
let marketPrice =
let { marketPrice } =
scraperResults.find((result) => {
return result.symbol === symbol;
}) ?? {};
marketPrice =
marketPrice ??
marketData.find((marketDataItem) => {
return marketDataItem.symbol === symbol;
})?.marketPrice ?? 0;
})?.marketPrice ??
0;
response[symbol] = {
currency,

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

@ -20,6 +20,11 @@ import { Injectable, Logger } from '@nestjs/common';
import { DataSource, SymbolProfile } from '@prisma/client';
import { addDays, format, isSameDay } from 'date-fns';
import yahooFinance from 'yahoo-finance2';
import { ChartResultArray } from 'yahoo-finance2/dist/esm/src/modules/chart';
import {
HistoricalDividendsResult,
HistoricalHistoryResult
} from 'yahoo-finance2/dist/esm/src/modules/historical';
import { Quote } from 'yahoo-finance2/dist/esm/src/modules/quote';
@Injectable()
@ -60,7 +65,8 @@ export class YahooFinanceService implements DataProviderInterface {
}
try {
const historicalResult = await yahooFinance.historical(
const historicalResult = this.convertToDividendResult(
await yahooFinance.chart(
this.yahooFinanceDataEnhancerService.convertToYahooFinanceSymbol(
symbol
),
@ -70,8 +76,8 @@ export class YahooFinanceService implements DataProviderInterface {
period1: format(from, DATE_FORMAT),
period2: format(to, DATE_FORMAT)
}
)
);
const response: {
[date: string]: IDataProviderHistoricalResponse;
} = {};
@ -108,7 +114,8 @@ export class YahooFinanceService implements DataProviderInterface {
}
try {
const historicalResult = await yahooFinance.historical(
const historicalResult = this.convertToHistoricalResult(
await yahooFinance.chart(
this.yahooFinanceDataEnhancerService.convertToYahooFinanceSymbol(
symbol
),
@ -117,6 +124,7 @@ export class YahooFinanceService implements DataProviderInterface {
period1: format(from, DATE_FORMAT),
period2: format(to, DATE_FORMAT)
}
)
);
const response: {
@ -302,6 +310,20 @@ export class YahooFinanceService implements DataProviderInterface {
return { items };
}
private convertToDividendResult(
result: ChartResultArray
): HistoricalDividendsResult {
return result.events.dividends.map(({ amount: dividends, date }) => {
return { date, dividends };
});
}
private convertToHistoricalResult(
result: ChartResultArray
): HistoricalHistoryResult {
return result.quotes;
}
private async getQuotesWithQuoteSummary(aYahooFinanceSymbols: string[]) {
const quoteSummaryPromises = aYahooFinanceSymbols.map((symbol) => {
return yahooFinance.quoteSummary(symbol).catch(() => {

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

@ -1,3 +1,4 @@
import { LogPerformance } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
@ -46,6 +47,7 @@ export class ExchangeRateDataService {
return this.currencyPairs;
}
@LogPerformance
public async getExchangeRatesByCurrency({
currencies,
endDate = new Date(),

1
apps/api/src/services/interfaces/environment.interface.ts

@ -29,7 +29,6 @@ export interface Environment extends CleanedEnvAccessors {
JWT_SECRET_KEY: string;
MAX_ACTIVITIES_TO_IMPORT: number;
MAX_CHART_ITEMS: number;
MAX_ITEM_IN_CACHE: number;
PORT: number;
REDIS_DB: number;
REDIS_HOST: string;

2
apps/api/src/services/data-gathering/data-gathering.module.ts → apps/api/src/services/queues/data-gathering/data-gathering.module.ts

@ -1,11 +1,11 @@
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service';
import { DataEnhancerModule } from '@ghostfolio/api/services/data-provider/data-enhancer/data-enhancer.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
import { DATA_GATHERING_QUEUE } from '@ghostfolio/common/config';

28
apps/api/src/services/data-gathering/data-gathering.processor.ts → apps/api/src/services/queues/data-gathering/data-gathering.processor.ts

@ -3,8 +3,10 @@ import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfac
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import {
DATA_GATHERING_QUEUE,
DEFAULT_PROCESSOR_CONCURRENCY_GATHER_ASSET_PROFILE,
DEFAULT_PROCESSOR_CONCURRENCY_GATHER_HISTORICAL_MARKET_DATA,
GATHER_ASSET_PROFILE_PROCESS,
GATHER_HISTORICAL_MARKET_DATA_PROCESS
GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME
} from '@ghostfolio/common/config';
import { DATE_FORMAT, getStartOfUtcDate } from '@ghostfolio/common/helper';
import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
@ -34,7 +36,14 @@ export class DataGatheringProcessor {
private readonly marketDataService: MarketDataService
) {}
@Process({ concurrency: 1, name: GATHER_ASSET_PROFILE_PROCESS })
@Process({
concurrency: parseInt(
process.env.PROCESSOR_CONCURRENCY_GATHER_ASSET_PROFILE ??
DEFAULT_PROCESSOR_CONCURRENCY_GATHER_ASSET_PROFILE.toString(),
10
),
name: GATHER_ASSET_PROFILE_PROCESS
})
public async gatherAssetProfile(job: Job<AssetProfileIdentifier>) {
try {
Logger.log(
@ -58,7 +67,14 @@ export class DataGatheringProcessor {
}
}
@Process({ concurrency: 1, name: GATHER_HISTORICAL_MARKET_DATA_PROCESS })
@Process({
concurrency: parseInt(
process.env.PROCESSOR_CONCURRENCY_GATHER_HISTORICAL_MARKET_DATA ??
DEFAULT_PROCESSOR_CONCURRENCY_GATHER_HISTORICAL_MARKET_DATA.toString(),
10
),
name: GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME
})
public async gatherHistoricalMarketData(job: Job<IDataGatheringItem>) {
try {
const { dataSource, date, symbol } = job.data;
@ -69,7 +85,7 @@ export class DataGatheringProcessor {
currentDate,
DATE_FORMAT
)}`,
`DataGatheringProcessor (${GATHER_HISTORICAL_MARKET_DATA_PROCESS})`
`DataGatheringProcessor (${GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME})`
);
const historicalData = await this.dataProviderService.getHistoricalRaw({
@ -123,12 +139,12 @@ export class DataGatheringProcessor {
currentDate,
DATE_FORMAT
)}`,
`DataGatheringProcessor (${GATHER_HISTORICAL_MARKET_DATA_PROCESS})`
`DataGatheringProcessor (${GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME})`
);
} catch (error) {
Logger.error(
error,
`DataGatheringProcessor (${GATHER_HISTORICAL_MARKET_DATA_PROCESS})`
`DataGatheringProcessor (${GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME})`
);
throw new Error(error);

8
apps/api/src/services/data-gathering/data-gathering.service.ts → apps/api/src/services/queues/data-gathering/data-gathering.service.ts

@ -11,8 +11,8 @@ import {
DATA_GATHERING_QUEUE_PRIORITY_HIGH,
DATA_GATHERING_QUEUE_PRIORITY_LOW,
DATA_GATHERING_QUEUE_PRIORITY_MEDIUM,
GATHER_HISTORICAL_MARKET_DATA_PROCESS,
GATHER_HISTORICAL_MARKET_DATA_PROCESS_OPTIONS,
GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME,
GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_OPTIONS,
PROPERTY_BENCHMARKS
} from '@ghostfolio/common/config';
import {
@ -282,9 +282,9 @@ export class DataGatheringService {
date,
symbol
},
name: GATHER_HISTORICAL_MARKET_DATA_PROCESS,
name: GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME,
opts: {
...GATHER_HISTORICAL_MARKET_DATA_PROCESS_OPTIONS,
...GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_OPTIONS,
priority,
jobId: `${getAssetProfileIdentifier({
dataSource,

7
apps/api/src/services/queues/portfolio-snapshot/interfaces/portfolio-snapshot-queue-job.interface.ts

@ -0,0 +1,7 @@
import { Filter } from '@ghostfolio/common/interfaces';
export interface IPortfolioSnapshotQueueJob {
filters: Filter[];
userCurrency: string;
userId: string;
}

39
apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.module.ts

@ -0,0 +1,39 @@
import { AccountBalanceModule } from '@ghostfolio/api/app/account-balance/account-balance.module';
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PORTFOLIO_SNAPSHOT_QUEUE } from '@ghostfolio/common/config';
import { BullModule } from '@nestjs/bull';
import { Module } from '@nestjs/common';
import { PortfolioSnapshotProcessor } from './portfolio-snapshot.processor';
@Module({
exports: [BullModule, PortfolioSnapshotService],
imports: [
AccountBalanceModule,
BullModule.registerQueue({
name: PORTFOLIO_SNAPSHOT_QUEUE
}),
ConfigurationModule,
DataProviderModule,
ExchangeRateDataModule,
MarketDataModule,
OrderModule,
RedisCacheModule
],
providers: [
CurrentRateService,
PortfolioCalculatorFactory,
PortfolioSnapshotProcessor,
PortfolioSnapshotService
]
})
export class PortfolioSnapshotQueueModule {}

114
apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.processor.ts

@ -0,0 +1,114 @@
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
import { OrderService } from '@ghostfolio/api/app/order/order.service';
import {
PerformanceCalculationType,
PortfolioCalculatorFactory
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { PortfolioSnapshotValue } from '@ghostfolio/api/app/portfolio/interfaces/snapshot-value.interface';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import {
CACHE_TTL_INFINITE,
DEFAULT_PROCESSOR_CONCURRENCY_PORTFOLIO_SNAPSHOT,
PORTFOLIO_SNAPSHOT_PROCESS_JOB_NAME,
PORTFOLIO_SNAPSHOT_QUEUE
} from '@ghostfolio/common/config';
import { Process, Processor } from '@nestjs/bull';
import { Injectable, Logger } from '@nestjs/common';
import { Job } from 'bull';
import { addMilliseconds } from 'date-fns';
import { IPortfolioSnapshotQueueJob } from './interfaces/portfolio-snapshot-queue-job.interface';
@Injectable()
@Processor(PORTFOLIO_SNAPSHOT_QUEUE)
export class PortfolioSnapshotProcessor {
public constructor(
private readonly accountBalanceService: AccountBalanceService,
private readonly calculatorFactory: PortfolioCalculatorFactory,
private readonly configurationService: ConfigurationService,
private readonly orderService: OrderService,
private readonly redisCacheService: RedisCacheService
) {}
@Process({
concurrency: parseInt(
process.env.PROCESSOR_CONCURRENCY_PORTFOLIO_SNAPSHOT ??
DEFAULT_PROCESSOR_CONCURRENCY_PORTFOLIO_SNAPSHOT.toString(),
10
),
name: PORTFOLIO_SNAPSHOT_PROCESS_JOB_NAME
})
public async calculatePortfolioSnapshot(
job: Job<IPortfolioSnapshotQueueJob>
) {
try {
const startTime = performance.now();
Logger.log(
`Portfolio snapshot calculation of user '${job.data.userId}' has been started`,
`PortfolioSnapshotProcessor (${PORTFOLIO_SNAPSHOT_PROCESS_JOB_NAME})`
);
const { activities } =
await this.orderService.getOrdersForPortfolioCalculator({
filters: job.data.filters,
userCurrency: job.data.userCurrency,
userId: job.data.userId
});
const accountBalanceItems =
await this.accountBalanceService.getAccountBalanceItems({
filters: job.data.filters,
userCurrency: job.data.userCurrency,
userId: job.data.userId
});
const portfolioCalculator = this.calculatorFactory.createCalculator({
accountBalanceItems,
activities,
calculationType: PerformanceCalculationType.TWR,
currency: job.data.userCurrency,
filters: job.data.filters,
userId: job.data.userId
});
const snapshot = await portfolioCalculator.computeSnapshot();
Logger.log(
`Portfolio snapshot calculation of user '${job.data.userId}' has been completed in ${(
(performance.now() - startTime) /
1000
).toFixed(3)} seconds`,
`PortfolioSnapshotProcessor (${PORTFOLIO_SNAPSHOT_PROCESS_JOB_NAME})`
);
const expiration = addMilliseconds(
new Date(),
this.configurationService.get('CACHE_QUOTES_TTL')
);
this.redisCacheService.set(
this.redisCacheService.getPortfolioSnapshotKey({
filters: job.data.filters,
userId: job.data.userId
}),
JSON.stringify(<PortfolioSnapshotValue>(<unknown>{
expiration: expiration.getTime(),
portfolioSnapshot: snapshot
})),
CACHE_TTL_INFINITE
);
return snapshot;
} catch (error) {
Logger.error(
error,
`PortfolioSnapshotProcessor (${PORTFOLIO_SNAPSHOT_PROCESS_JOB_NAME})`
);
throw new Error(error);
}
}
}

34
apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock.ts

@ -0,0 +1,34 @@
import { Job, JobOptions } from 'bull';
import { setTimeout } from 'timers/promises';
import { IPortfolioSnapshotQueueJob } from './interfaces/portfolio-snapshot-queue-job.interface';
export const PortfolioSnapshotServiceMock = {
addJobToQueue({
data,
name,
opts
}: {
data: IPortfolioSnapshotQueueJob;
name: string;
opts?: JobOptions;
}): Promise<Job<any>> {
const mockJob: Partial<Job<any>> = {
finished: async () => {
await setTimeout(100);
return Promise.resolve();
}
};
this.jobsStore.set(opts?.jobId, mockJob);
return Promise.resolve(mockJob as Job<any>);
},
getJob(jobId: string): Promise<Job<any>> {
const job = this.jobsStore.get(jobId);
return Promise.resolve(job as Job<any>);
},
jobsStore: new Map<string, Partial<Job<any>>>()
};

31
apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.service.ts

@ -0,0 +1,31 @@
import { PORTFOLIO_SNAPSHOT_QUEUE } from '@ghostfolio/common/config';
import { InjectQueue } from '@nestjs/bull';
import { Injectable } from '@nestjs/common';
import { JobOptions, Queue } from 'bull';
import { IPortfolioSnapshotQueueJob } from './interfaces/portfolio-snapshot-queue-job.interface';
@Injectable()
export class PortfolioSnapshotService {
public constructor(
@InjectQueue(PORTFOLIO_SNAPSHOT_QUEUE)
private readonly portfolioSnapshotQueue: Queue
) {}
public async addJobToQueue({
data,
name,
opts
}: {
data: IPortfolioSnapshotQueueJob;
name: string;
opts?: JobOptions;
}) {
return this.portfolioSnapshotQueue.add(name, data, opts);
}
public async getJob(jobId: string) {
return this.portfolioSnapshotQueue.getJob(jobId);
}
}

2
apps/api/src/services/symbol-profile/symbol-profile.service.ts

@ -288,6 +288,8 @@ export class SymbolProfileService {
headers:
scraperConfiguration.headers as ScraperConfiguration['headers'],
locale: scraperConfiguration.locale as string,
mode:
(scraperConfiguration.mode as ScraperConfiguration['mode']) ?? 'lazy',
selector: scraperConfiguration.selector as string,
url: scraperConfiguration.url as string
};

2
apps/client/src/app/app.component.html

@ -168,11 +168,9 @@
<li>
<a href="../nl" title="Ghostfolio in Nederlands">Nederlands</a>
</li>
<!--
<li>
<a href="../pl" title="Ghostfolio in Polski">Polski</a>
</li>
-->
<li>
<a href="../pt" title="Ghostfolio in Português">Português</a>
</li>

28
apps/client/src/app/app.component.ts

@ -57,19 +57,25 @@ export class AppComponent implements OnDestroy, OnInit {
public hasTabs = false;
public info: InfoItem;
public pageTitle: string;
public routerLinkAbout = ['/' + $localize`about`];
public routerLinkAboutChangelog = ['/' + $localize`about`, 'changelog'];
public routerLinkAboutLicense = ['/' + $localize`about`, $localize`license`];
public routerLinkAbout = ['/' + $localize`:snake-case:about`];
public routerLinkAboutChangelog = [
'/' + $localize`:snake-case:about`,
'changelog'
];
public routerLinkAboutLicense = [
'/' + $localize`:snake-case:about`,
$localize`:snake-case:license`
];
public routerLinkAboutPrivacyPolicy = [
'/' + $localize`about`,
$localize`privacy-policy`
'/' + $localize`:snake-case:about`,
$localize`:snake-case:privacy-policy`
];
public routerLinkFaq = ['/' + $localize`faq`];
public routerLinkFeatures = ['/' + $localize`features`];
public routerLinkMarkets = ['/' + $localize`markets`];
public routerLinkPricing = ['/' + $localize`pricing`];
public routerLinkRegister = ['/' + $localize`register`];
public routerLinkResources = ['/' + $localize`resources`];
public routerLinkFaq = ['/' + $localize`:snake-case:faq`];
public routerLinkFeatures = ['/' + $localize`:snake-case:features`];
public routerLinkMarkets = ['/' + $localize`:snake-case:markets`];
public routerLinkPricing = ['/' + $localize`:snake-case:pricing`];
public routerLinkRegister = ['/' + $localize`:snake-case:register`];
public routerLinkResources = ['/' + $localize`:snake-case:resources`];
public showFooter = false;
public user: User;

21
apps/client/src/app/components/access-table/access-table.component.html

@ -35,13 +35,20 @@
@if (element.type === 'PUBLIC') {
<div class="align-items-center d-flex">
<ion-icon class="mr-1" name="link-outline" />
<a
href="{{ baseUrl }}/{{ defaultLanguageCode }}/p/{{ element.id }}"
target="_blank"
>{{ baseUrl }}/{{ defaultLanguageCode }}/p/{{ element.id }}</a
<a target="_blank" [href]="getPublicUrl(element.id)">{{
getPublicUrl(element.id)
}}</a>
</div>
@if (user?.settings?.isExperimentalFeatures) {
<div>
<code
>GET {{ baseUrl }}/api/v1/public/{{
element.id
}}/portfolio</code
>
</div>
}
}
</td>
</ng-container>
@ -58,6 +65,12 @@
<ion-icon name="ellipsis-horizontal" />
</button>
<mat-menu #transactionMenu="matMenu" xPosition="before">
@if (element.type === 'PUBLIC') {
<button mat-menu-item (click)="onCopyToClipboard(element.id)">
<ng-container i18n>Copy link to clipboard</ng-container>
</button>
<hr class="my-0" />
}
<button mat-menu-item (click)="onDeleteAccess(element.id)">
<ng-container i18n>Revoke</ng-container>
</button>

20
apps/client/src/app/components/access-table/access-table.component.ts

@ -1,8 +1,9 @@
import { ConfirmationDialogType } from '@ghostfolio/client/core/notification/confirmation-dialog/confirmation-dialog.type';
import { NotificationService } from '@ghostfolio/client/core/notification/notification.service';
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
import { Access } from '@ghostfolio/common/interfaces';
import { Access, User } from '@ghostfolio/common/interfaces';
import { Clipboard } from '@angular/cdk/clipboard';
import {
ChangeDetectionStrategy,
Component,
@ -23,15 +24,18 @@ import { MatTableDataSource } from '@angular/material/table';
export class AccessTableComponent implements OnChanges, OnInit {
@Input() accesses: Access[];
@Input() showActions: boolean;
@Input() user: User;
@Output() accessDeleted = new EventEmitter<string>();
public baseUrl = window.location.origin;
public dataSource: MatTableDataSource<Access>;
public defaultLanguageCode = DEFAULT_LANGUAGE_CODE;
public displayedColumns = [];
public constructor(private notificationService: NotificationService) {}
public constructor(
private clipboard: Clipboard,
private notificationService: NotificationService
) {}
public ngOnInit() {}
@ -47,6 +51,16 @@ export class AccessTableComponent implements OnChanges, OnInit {
}
}
public getPublicUrl(aId: string): string {
const languageCode = this.user?.settings?.language ?? DEFAULT_LANGUAGE_CODE;
return `${this.baseUrl}/${languageCode}/p/${aId}`;
}
public onCopyToClipboard(aId: string): void {
this.clipboard.copy(this.getPublicUrl(aId));
}
public onDeleteAccess(aId: string) {
this.notificationService.confirm({
confirmFn: () => {

2
apps/client/src/app/components/access-table/access-table.module.ts

@ -1,3 +1,4 @@
import { ClipboardModule } from '@angular/cdk/clipboard';
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
@ -11,6 +12,7 @@ import { AccessTableComponent } from './access-table.component';
declarations: [AccessTableComponent],
exports: [AccessTableComponent],
imports: [
ClipboardModule,
CommonModule,
MatButtonModule,
MatMenuModule,

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

@ -2,7 +2,7 @@ import { CreateAccountBalanceDto } from '@ghostfolio/api/app/account-balance/cre
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { downloadAsFile } from '@ghostfolio/common/helper';
import { DATE_FORMAT, downloadAsFile } from '@ghostfolio/common/helper';
import {
AccountBalancesResponse,
HistoricalDataItem,
@ -27,7 +27,7 @@ import { Router } from '@angular/router';
import { Big } from 'big.js';
import { format, parseISO } from 'date-fns';
import { isNumber } from 'lodash';
import { Subject } from 'rxjs';
import { forkJoin, Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { AccountDetailDialogParams } from './interfaces/interfaces';
@ -87,11 +87,7 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
}
public ngOnInit() {
this.fetchAccount();
this.fetchAccountBalances();
this.fetchActivities();
this.fetchPortfolioHoldings();
this.fetchPortfolioPerformance();
this.initialize();
}
public onCloneActivity(aActivity: Activity) {
@ -111,9 +107,7 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
.postAccountBalance(accountBalance)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.fetchAccount();
this.fetchAccountBalances();
this.fetchPortfolioPerformance();
this.initialize();
});
}
@ -122,9 +116,7 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
.deleteAccountBalance(aId)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.fetchAccount();
this.fetchAccountBalances();
this.fetchPortfolioPerformance();
this.initialize();
});
}
@ -198,17 +190,6 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
);
}
private fetchAccountBalances() {
this.dataService
.fetchAccountBalances(this.data.accountId)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ balances }) => {
this.accountBalances = balances;
this.changeDetectorRef.markForCheck();
});
}
private fetchActivities() {
this.isLoadingActivities = true;
@ -229,28 +210,14 @@ 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() {
private fetchChart() {
this.isLoadingChart = true;
this.dataService
forkJoin({
accountBalances: this.dataService
.fetchAccountBalances(this.data.accountId)
.pipe(takeUntil(this.unsubscribeSubject)),
portfolioPerformance: this.dataService
.fetchPortfolioPerformance({
filters: [
{
@ -263,22 +230,63 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
withItems: true
})
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ chart }) => {
this.historicalDataItems = chart.map(
({ date, netWorth, netWorthInPercentage }) => {
return {
}).subscribe({
error: () => {
this.isLoadingChart = false;
},
next: ({ accountBalances, portfolioPerformance }) => {
this.accountBalances = accountBalances.balances;
if (portfolioPerformance.chart.length > 0) {
this.historicalDataItems = portfolioPerformance.chart.map(
({ date, netWorth, netWorthInPercentage }) => ({
date,
value: isNumber(netWorth) ? netWorth : netWorthInPercentage
})
);
} else {
this.historicalDataItems = this.accountBalances.map(
({ date, valueInBaseCurrency }) => {
return {
date: format(date, DATE_FORMAT),
value: valueInBaseCurrency
};
}
);
}
this.isLoadingChart = false;
this.changeDetectorRef.markForCheck();
}
});
}
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 initialize() {
this.fetchAccount();
this.fetchActivities();
this.fetchChart();
this.fetchPortfolioHoldings();
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();

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

@ -20,7 +20,7 @@
</div>
</div>
<!-- TODO
@if (user?.settings?.isExperimentalFeatures) {
<div class="chart-container mb-3">
<gf-investment-chart
class="h-100"
@ -33,7 +33,7 @@
[locale]="user?.settings?.locale"
/>
</div>
-->
}
<div class="mb-3 row">
<div class="col-6 mb-3">

1
apps/client/src/app/components/accounts-table/accounts-table.component.html

@ -278,6 +278,7 @@
<span i18n>Edit</span>
</span>
</button>
<hr class="m-0" />
<button
mat-menu-item
[disabled]="element.transactionCount > 0"

2
apps/client/src/app/components/accounts-table/accounts-table.component.ts

@ -92,11 +92,11 @@ export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit {
this.isLoading = true;
if (this.accounts) {
this.dataSource = new MatTableDataSource(this.accounts);
this.dataSource.sort = this.sort;
this.dataSource.sortingDataAccessor = get;
if (this.accounts) {
this.isLoading = false;
}
}

5
apps/client/src/app/components/admin-jobs/admin-jobs.component.ts

@ -51,6 +51,7 @@ export class AdminJobsComponent implements OnDestroy, OnInit {
'status',
'actions'
];
public isLoading = false;
public statusFilterOptions = QUEUE_JOB_STATUS_LIST;
public user: User;
@ -138,12 +139,16 @@ export class AdminJobsComponent implements OnDestroy, OnInit {
}
private fetchJobs(aStatus?: JobStatus[]) {
this.isLoading = true;
this.adminService
.fetchJobs({ status: aStatus })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ jobs }) => {
this.dataSource = new MatTableDataSource(jobs);
this.isLoading = false;
this.changeDetectorRef.markForCheck();
});
}

13
apps/client/src/app/components/admin-jobs/admin-jobs.html

@ -35,6 +35,8 @@
<ng-container i18n>Asset Profile</ng-container>
} @else if (element.name === 'GATHER_HISTORICAL_MARKET_DATA') {
<ng-container i18n>Historical Market Data</ng-container>
} @else if (element.name === 'PORTFOLIO') {
<ng-container i18n>Portfolio Snapshot</ng-container>
}
</td>
</ng-container>
@ -173,6 +175,7 @@
<button mat-menu-item (click)="onExecuteJob(element.id)">
<ng-container i18n>Execute Job</ng-container>
</button>
<hr class="m-0" />
<button mat-menu-item (click)="onDeleteJob(element.id)">
<ng-container i18n>Delete Job</ng-container>
</button>
@ -183,6 +186,16 @@
<tr *matHeaderRowDef="displayedColumns" mat-header-row></tr>
<tr *matRowDef="let row; columns: displayedColumns" mat-row></tr>
</table>
@if (isLoading) {
<ngx-skeleton-loader
animation="pulse"
class="px-4 py-3"
[theme]="{
height: '1.5rem',
width: '100%'
}"
/>
}
</div>
</div>
</div>

2
apps/client/src/app/components/admin-jobs/admin-jobs.module.ts

@ -5,6 +5,7 @@ import { MatButtonModule } from '@angular/material/button';
import { MatMenuModule } from '@angular/material/menu';
import { MatSelectModule } from '@angular/material/select';
import { MatTableModule } from '@angular/material/table';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { AdminJobsComponent } from './admin-jobs.component';
@ -17,6 +18,7 @@ import { AdminJobsComponent } from './admin-jobs.component';
MatMenuModule,
MatSelectModule,
MatTableModule,
NgxSkeletonLoaderModule,
ReactiveFormsModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]

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

@ -93,6 +93,7 @@ export class AdminMarketDataDetailComponent implements OnChanges, OnInit {
};
});
if (this.dateOfFirstActivity) {
let date = parseISO(this.dateOfFirstActivity);
const missingMarketData: Partial<MarketData>[] = [];
@ -138,7 +139,6 @@ export class AdminMarketDataDetailComponent implements OnChanges, OnInit {
};
}
if (this.dateOfFirstActivity) {
// Fill up missing months
const dates = Object.keys(this.marketDataByMonth).sort();
const startDate = min([

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

@ -142,6 +142,7 @@ export class AdminMarketDataComponent
'dataSource',
'assetClass',
'assetSubClass',
'lastMarketPrice',
'date',
'activitiesCount',
'marketDataItemCount',

17
apps/client/src/app/components/admin-market-data/admin-market-data.html

@ -99,6 +99,21 @@
</td>
</ng-container>
<ng-container matColumnDef="lastMarketPrice">
<th *matHeaderCellDef class="px-1" mat-header-cell>
<ng-container i18n>Market Price</ng-container>
</th>
<td *matCellDef="let element" class="px-1" mat-cell>
<div class="d-flex justify-content-end">
<gf-value
[isCurrency]="true"
[locale]="user?.settings?.locale"
[value]="element.lastMarketPrice ?? ''"
/>
</div>
</td>
</ng-container>
<ng-container matColumnDef="date">
<th *matHeaderCellDef class="px-1" mat-header-cell>
<ng-container i18n>First Activity</ng-container>
@ -182,6 +197,7 @@
<button mat-menu-item (click)="onGatherProfileData()">
<ng-container i18n>Gather Profile Data</ng-container>
</button>
<hr class="m-0" />
<button
mat-menu-item
[disabled]="!selection.hasValue()"
@ -215,6 +231,7 @@
<span i18n>Edit</span>
</span>
</a>
<hr class="m-0" />
<button
mat-menu-item
[disabled]="

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

@ -1,6 +1,7 @@
import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module';
import { GfActivitiesFilterComponent } from '@ghostfolio/ui/activities-filter';
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';
@ -27,6 +28,7 @@ import { GfCreateAssetProfileDialogModule } from './create-asset-profile-dialog/
GfCreateAssetProfileDialogModule,
GfPremiumIndicatorComponent,
GfSymbolModule,
GfValueComponent,
MatButtonModule,
MatCheckboxModule,
MatMenuModule,

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

@ -44,6 +44,7 @@
>
<ng-container i18n>Gather Profile Data</ng-container>
</button>
<hr class="m-0" />
<button
mat-menu-item
type="button"

1
apps/client/src/app/components/admin-overview/admin-overview.html

@ -79,6 +79,7 @@
</span>
</a>
@if (customCurrencies.includes(exchangeRate.label2)) {
<hr class="m-0" />
<button
mat-menu-item
(click)="onDeleteCurrency(exchangeRate.label2)"

1
apps/client/src/app/components/admin-platform/admin-platform.component.html

@ -92,6 +92,7 @@
<span i18n>Edit</span>
</span>
</button>
<hr class="m-0" />
<button
mat-menu-item
[disabled]="element.accountCount > 0"

1
apps/client/src/app/components/admin-tag/admin-tag.component.html

@ -84,6 +84,7 @@
<span i18n>Edit</span>
</span>
</button>
<hr class="m-0" />
<button
mat-menu-item
[disabled]="element.activityCount > 0"

17
apps/client/src/app/components/admin-users/admin-users.component.ts

@ -5,7 +5,7 @@ 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 { getDateFormatString, getEmojiFlag } from '@ghostfolio/common/helper';
import { AdminData, InfoItem, User } from '@ghostfolio/common/interfaces';
import { AdminUsers, InfoItem, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
@ -24,7 +24,7 @@ import { takeUntil } from 'rxjs/operators';
templateUrl: './admin-users.html'
})
export class AdminUsersComponent implements OnDestroy, OnInit {
public dataSource: MatTableDataSource<AdminData['users'][0]> =
public dataSource: MatTableDataSource<AdminUsers['users'][0]> =
new MatTableDataSource();
public defaultDateFormat: string;
public displayedColumns: string[] = [];
@ -32,6 +32,7 @@ export class AdminUsersComponent implements OnDestroy, OnInit {
public hasPermissionForSubscription: boolean;
public hasPermissionToImpersonateAllUsers: boolean;
public info: InfoItem;
public isLoading = false;
public user: User;
private unsubscribeSubject = new Subject<void>();
@ -93,7 +94,7 @@ export class AdminUsersComponent implements OnDestroy, OnInit {
}
public ngOnInit() {
this.fetchAdminData();
this.fetchUsers();
}
public formatDistanceToNow(aDateString: string) {
@ -118,7 +119,7 @@ export class AdminUsersComponent implements OnDestroy, OnInit {
.deleteUser(aId)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.fetchAdminData();
this.fetchUsers();
});
},
confirmType: ConfirmationDialogType.Warn,
@ -141,13 +142,17 @@ export class AdminUsersComponent implements OnDestroy, OnInit {
this.unsubscribeSubject.complete();
}
private fetchAdminData() {
private fetchUsers() {
this.isLoading = true;
this.adminService
.fetchAdminData()
.fetchUsers()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ users }) => {
this.dataSource = new MatTableDataSource(users);
this.isLoading = false;
this.changeDetectorRef.markForCheck();
});
}

11
apps/client/src/app/components/admin-users/admin-users.html

@ -218,6 +218,7 @@
<span i18n>Impersonate User</span>
</span>
</button>
<hr class="m-0" />
}
<button
mat-menu-item
@ -245,6 +246,16 @@
></tr>
</table>
</div>
@if (isLoading) {
<ngx-skeleton-loader
animation="pulse"
class="px-4 py-3"
[theme]="{
height: '1.5rem',
width: '100%'
}"
/>
}
</div>
</div>
</div>

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

@ -6,6 +6,7 @@ import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatMenuModule } from '@angular/material/menu';
import { MatTableModule } from '@angular/material/table';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { AdminUsersComponent } from './admin-users.component';
@ -18,7 +19,8 @@ import { AdminUsersComponent } from './admin-users.component';
GfValueComponent,
MatButtonModule,
MatMenuModule,
MatTableModule
MatTableModule,
NgxSkeletonLoaderModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})

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

@ -75,17 +75,17 @@ export class HeaderComponent implements OnChanges {
public hasPermissionToCreateUser: boolean;
public impersonationId: string;
public isMenuOpen: boolean;
public routeAbout = $localize`about`;
public routeFeatures = $localize`features`;
public routeMarkets = $localize`markets`;
public routePricing = $localize`pricing`;
public routeResources = $localize`resources`;
public routerLinkAbout = ['/' + $localize`about`];
public routerLinkFeatures = ['/' + $localize`features`];
public routerLinkMarkets = ['/' + $localize`markets`];
public routerLinkPricing = ['/' + $localize`pricing`];
public routerLinkRegister = ['/' + $localize`register`];
public routerLinkResources = ['/' + $localize`resources`];
public routeAbout = $localize`:snake-case:about`;
public routeFeatures = $localize`:snake-case:features`;
public routeMarkets = $localize`:snake-case:markets`;
public routePricing = $localize`:snake-case:pricing`;
public routeResources = $localize`:snake-case:resources`;
public routerLinkAbout = ['/' + $localize`:snake-case:about`];
public routerLinkFeatures = ['/' + $localize`:snake-case:features`];
public routerLinkMarkets = ['/' + $localize`:snake-case:markets`];
public routerLinkPricing = ['/' + $localize`:snake-case:pricing`];
public routerLinkRegister = ['/' + $localize`:snake-case:register`];
public routerLinkResources = ['/' + $localize`:snake-case:resources`];
private unsubscribeSubject = new Subject<void>();

36
apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts

@ -9,6 +9,7 @@ import { DATE_FORMAT, downloadAsFile } from '@ghostfolio/common/helper';
import {
DataProviderInfo,
EnhancedSymbolProfile,
Filter,
LineChartItem,
User
} from '@ghostfolio/common/interfaces';
@ -89,7 +90,6 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
public activityForm: FormGroup;
public accounts: Account[];
public activities: Activity[];
public assetClass: string;
public assetSubClass: string;
public averagePrice: number;
@ -155,6 +155,11 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
tags: <string[]>[]
});
const filters: Filter[] = [
{ id: this.data.dataSource, type: 'DATA_SOURCE' },
{ id: this.data.symbol, type: 'SYMBOL' }
];
this.tagsAvailable = tags.map(({ id, name }) => {
return {
id,
@ -176,6 +181,30 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
.subscribe();
});
this.dataService
.fetchAccounts({
filters
})
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ accounts }) => {
this.accounts = accounts;
this.changeDetectorRef.markForCheck();
});
this.dataService
.fetchActivities({
filters,
sortColumn: this.sortColumn,
sortDirection: this.sortDirection
})
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ activities }) => {
this.dataSource = new MatTableDataSource(activities);
this.changeDetectorRef.markForCheck();
});
this.dataService
.fetchHoldingDetail({
dataSource: this.data.dataSource,
@ -184,7 +213,6 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(
({
accounts,
averagePrice,
dataProviderInfo,
dividendInBaseCurrency,
@ -197,7 +225,6 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
netPerformancePercent,
netPerformancePercentWithCurrencyEffect,
netPerformanceWithCurrencyEffect,
orders,
quantity,
SymbolProfile,
tags,
@ -208,13 +235,10 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
firstBuyDate,
historicalData
}) => {
this.accounts = accounts;
this.activities = orders;
this.averagePrice = averagePrice;
this.benchmarkDataItems = [];
this.countries = {};
this.dataProviderInfo = dataProviderInfo;
this.dataSource = new MatTableDataSource(orders.reverse());
this.dividendInBaseCurrency = dividendInBaseCurrency;
this.stakeRewards = stakeRewards;

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

Loading…
Cancel
Save