Browse Source

Merge branch 'main' into Task/allow-edit-country-sector-data-#6283

pull/6284/head
Omkar Gujja 3 weeks ago
committed by GitHub
parent
commit
09f01df2cd
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 4
      .github/workflows/extract-locales.yml
  2. 4
      .vscode/extensions.json
  3. 2
      .vscode/settings.json
  4. 180
      CHANGELOG.md
  5. 18
      README.md
  6. 6
      apps/api/src/app/account/account.controller.ts
  7. 6
      apps/api/src/app/account/account.service.ts
  8. 6
      apps/api/src/app/admin/admin.service.ts
  9. 7
      apps/api/src/app/endpoints/market-data/market-data.controller.ts
  10. 6
      apps/api/src/app/endpoints/market-data/market-data.module.ts
  11. 4
      apps/api/src/app/endpoints/platforms/platforms.controller.ts
  12. 1
      apps/api/src/app/import/import.controller.ts
  13. 2
      apps/api/src/app/import/import.module.ts
  14. 49
      apps/api/src/app/import/import.service.ts
  15. 8
      apps/api/src/app/info/info.service.ts
  16. 165
      apps/api/src/app/order/order.service.ts
  17. 61
      apps/api/src/app/portfolio/calculator/portfolio-calculator.ts
  18. 15
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-buy.spec.ts
  19. 16
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell-in-two-activities.spec.ts
  20. 15
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell.spec.ts
  21. 16
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy.spec.ts
  22. 1
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btceur-in-base-currency-eur.spec.ts
  23. 15
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btceur.spec.ts
  24. 18
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts
  25. 1
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-short.spec.ts
  26. 15
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd.spec.ts
  27. 290
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts
  28. 1
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-fee.spec.ts
  29. 14
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-googl-buy.spec.ts
  30. 1
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-liability.spec.ts
  31. 7
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-and-sell.spec.ts
  32. 8
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-with-dividend.spec.ts
  33. 7
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-no-orders.spec.ts
  34. 14
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell-partially.spec.ts
  35. 14
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell.spec.ts
  36. 7
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-valuable.spec.ts
  37. 45
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts
  38. 1
      apps/api/src/app/portfolio/interfaces/portfolio-order-item.interface.ts
  39. 3
      apps/api/src/app/portfolio/interfaces/portfolio-order.interface.ts
  40. 8
      apps/api/src/app/portfolio/interfaces/transaction-point-symbol.interface.ts
  41. 4
      apps/api/src/app/portfolio/portfolio.controller.ts
  42. 128
      apps/api/src/app/portfolio/portfolio.service.ts
  43. 4
      apps/api/src/app/subscription/subscription.service.ts
  44. 23
      apps/api/src/app/user/user.controller.ts
  45. 4
      apps/api/src/app/user/user.module.ts
  46. 51
      apps/api/src/app/user/user.service.ts
  47. 501
      apps/api/src/assets/cryptocurrencies/cryptocurrencies.json
  48. 159
      apps/api/src/helper/object.helper.spec.ts
  49. 78
      apps/api/src/helper/object.helper.ts
  50. 42
      apps/api/src/interceptors/redact-values-in-response/redact-values-in-response.interceptor.ts
  51. 19
      apps/api/src/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor.ts
  52. 1
      apps/api/src/services/configuration/configuration.service.ts
  53. 2
      apps/api/src/services/data-provider/alpha-vantage/alpha-vantage.service.ts
  54. 33
      apps/api/src/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service.ts
  55. 11
      apps/api/src/services/data-provider/manual/manual.service.ts
  56. 12
      apps/api/src/services/exchange-rate-data/exchange-rate-data.service.mock.ts
  57. 4
      apps/api/src/services/exchange-rate-data/exchange-rate-data.service.ts
  58. 5
      apps/api/src/services/exchange-rate-data/interfaces/exchange-rate-data.interface.ts
  59. 1
      apps/api/src/services/interfaces/environment.interface.ts
  60. 3
      apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.processor.ts
  61. 8
      apps/api/src/services/tag/tag.service.ts
  62. 1
      apps/api/tsconfig.app.json
  63. 1
      apps/api/tsconfig.spec.json
  64. 16
      apps/client/project.json
  65. 2
      apps/client/src/app/app.component.ts
  66. 2
      apps/client/src/app/components/access-table/access-table.component.ts
  67. 8
      apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts
  68. 2
      apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html
  69. 2
      apps/client/src/app/components/admin-jobs/admin-jobs.component.ts
  70. 42
      apps/client/src/app/components/admin-market-data/admin-market-data.component.ts
  71. 2
      apps/client/src/app/components/admin-market-data/admin-market-data.service.ts
  72. 16
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.scss
  73. 73
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts
  74. 109
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html
  75. 11
      apps/client/src/app/components/admin-market-data/create-asset-profile-dialog/create-asset-profile-dialog.component.ts
  76. 9
      apps/client/src/app/components/admin-overview/admin-overview.component.ts
  77. 6
      apps/client/src/app/components/admin-overview/admin-overview.html
  78. 3
      apps/client/src/app/components/admin-platform/admin-platform.component.ts
  79. 3
      apps/client/src/app/components/admin-settings/admin-settings.component.ts
  80. 2
      apps/client/src/app/components/admin-tag/admin-tag.component.ts
  81. 41
      apps/client/src/app/components/admin-users/admin-users.component.ts
  82. 20
      apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.ts
  83. 2
      apps/client/src/app/components/data-provider-status/data-provider-status.component.ts
  84. 3
      apps/client/src/app/components/fear-and-greed-index/fear-and-greed-index.component.ts
  85. 15
      apps/client/src/app/components/footer/footer.component.html
  86. 3
      apps/client/src/app/components/footer/footer.component.ts
  87. 2
      apps/client/src/app/components/header/header.component.ts
  88. 16
      apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts
  89. 7
      apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html
  90. 2
      apps/client/src/app/components/home-holdings/home-holdings.component.ts
  91. 2
      apps/client/src/app/components/home-market/home-market.component.ts
  92. 2
      apps/client/src/app/components/home-overview/home-overview.component.ts
  93. 2
      apps/client/src/app/components/home-summary/home-summary.component.ts
  94. 2
      apps/client/src/app/components/home-watchlist/home-watchlist.component.ts
  95. 50
      apps/client/src/app/components/investment-chart/investment-chart.component.ts
  96. 2
      apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.component.ts
  97. 2
      apps/client/src/app/components/markets/markets.component.ts
  98. 2
      apps/client/src/app/components/subscription-interstitial-dialog/subscription-interstitial-dialog.component.ts
  99. 2
      apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts
  100. 2
      apps/client/src/app/components/user-account-access/user-account-access.component.ts

4
.github/workflows/extract-locales.yml

@ -33,8 +33,8 @@ jobs:
uses: peter-evans/create-pull-request@v7
with:
author: 'github-actions[bot] <github-actions[bot]@users.noreply.github.com>'
branch: 'feature/update-locales'
branch: 'task/update-locales'
commit-message: 'Update locales'
delete-branch: true
title: 'Feature/update locales'
title: 'Task/update locales'
token: ${{ secrets.GITHUB_TOKEN }}

4
.vscode/extensions.json

@ -1,8 +1,8 @@
{
"recommendations": [
"angular.ng-template",
"esbenp.prettier-vscode",
"firsttris.vscode-jest-runner",
"nrwl.angular-console",
"prettier.prettier-vscode"
"nrwl.angular-console"
]
}

2
.vscode/settings.json

@ -1,4 +1,4 @@
{
"editor.defaultFormatter": "prettier.prettier-vscode",
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true
}

180
CHANGELOG.md

@ -7,20 +7,196 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Unreleased
### Changed
- Upgraded `twitter-api-v2` from version `1.27.0` to `1.29.0`
## 2.237.0 - 2026-02-08
### Changed
- Removed the deprecated `transactionCount` in the portfolio calculator and service
- Refreshed the cryptocurrencies list
- Upgraded `Nx` from version `22.4.1` to `22.4.5`
### Fixed
- Fixed the accounts of the assistant for the impersonation mode
- Fixed the tags of the assistant for the impersonation mode
## 2.236.0 - 2026-02-05
### Changed
- Removed the deprecated `transactionCount` in the endpoint `GET api/v1/admin`
- Upgraded `stripe` from version `20.1.0` to `20.3.0`
### Fixed
- Fixed an exception when fetching the top holdings for ETF and mutual fund assets from _Yahoo Finance_
## 2.235.0 - 2026-02-03
### Added
- Added the ability to fetch top holdings for ETF and mutual fund assets from _Yahoo Finance_
- Added support for the impersonation mode in the endpoint `GET api/v1/account/:id/balances`
- Added an action menu to the user detail dialog in the users section of the admin control panel
### Changed
- Optimized the value redaction interceptor for the impersonation mode by introducing `fast-redact`
- Refactored `showTransactions` in favor of `showActivitiesCount` in the accounts table component
- Refactored `transactionCount` in favor of `activitiesCount` in the accounts table component
- Deprecated `transactionCount` in favor of `activitiesCount` in the endpoint `GET api/v1/admin`
- Removed the deprecated `firstBuyDate` in the portfolio calculator
- Upgraded `yahoo-finance2` from version `3.11.2` to `3.13.0`
## 2.234.0 - 2026-01-30
### Changed
- Improved the usability of the create asset profile dialog in the market data section of the admin control panel
- Improved the language localization for Chinese (`zh`)
- Improved the language localization for German (`de`)
- Improved the language localization for Spanish (`es`)
- Upgraded `angular` from version `21.0.6` to `21.1.1`
- Upgraded `lodash` from version `4.17.21` to `4.17.23`
- Upgraded `Nx` from version `22.3.3` to `22.4.1`
- Upgraded `prettier` from version `3.8.0` to `3.8.1`
## 2.233.0 - 2026-01-23
### Changed
- Deprecated `firstBuyDate` in favor of `dateOfFirstActivity` in the portfolio calculator
- Deprecated `transactionCount` in favor of `activitiesCount` in the portfolio calculator and service
- Removed the deprecated `firstBuyDate` from the endpoint `GET api/v1/portfolio/holding/:dataSource/:symbol`
- Refreshed the cryptocurrencies list
- Upgraded `prettier` from version `3.7.4` to `3.8.0`
## 2.232.0 - 2026-01-19
### Added
- Extended the analysis page to include the total amount, change and performance with currency effects (experimental)
### Changed
- Deprecated `firstBuyDate` in favor of `dateOfFirstActivity` in the endpoint `GET api/v1/portfolio/holding/:dataSource/:symbol`
- Improved the language localization for German (`de`)
- Upgraded `countries-list` from version `3.2.0` to `3.2.2`
## 2.231.0 - 2026-01-17
### Changed
- Removed the deprecated platforms from the info service
- Removed the deprecated activities from the endpoint `GET api/v1/portfolio/holding/:dataSource/:symbol`
### Fixed
- Fixed a numeric parsing error related to cash positions on the _X-ray_ page
- Fixed the total fee calculation in the holding detail dialog related to activities in a custom currency
- Fixed the total fee calculation in the summary related to activities in a custom currency
## 2.230.0 - 2026-01-14
### Added
- Set up the language localization for Korean (`ko`)
### Changed
- Restored the support for specific calendar year date ranges (`2024`, `2023`, `2022`, etc.) in the holdings table (experimental)
### Fixed
- Fixed the total fee calculation in the holding detail dialog related to activities in a custom currency
- Fixed the total fee calculation in the summary related to activities in a custom currency
## 2.229.0 - 2026-01-11
### Changed
- Set the active sort column in the accounts table component
- Deprecated `activities` in the endpoint `GET api/v1/portfolio/holding/:dataSource/:symbol`
- Moved the admin service to `@ghostfolio/ui/services`
- Moved the data service to `@ghostfolio/ui/services`
- Refactored the dividend import
- Refreshed the cryptocurrencies list
### Fixed
- Fixed the net worth calculation to prevent the double counting of cash positions
- Fixed the filtering by asset class in the endpoint `GET api/v1/portfolio/holdings`
- Fixed the case-insensitive sorting in the accounts table component
- Fixed the case-insensitive sorting in the benchmark component
- Fixed the case-insensitive sorting in the holdings table component
## 2.228.0 - 2026-01-03
### Added
- Extended the portfolio holdings to include performance with currency effects for cash positions
### Changed
- Integrated the endpoint to get all platforms (`GET api/v1/platforms`) into the create or update account dialog
- Extracted the scraper configuration to a dedicated tab in the asset profile details dialog of the admin control panel
- Improved the language localization for German (`de`)
- Upgraded `@date-fns/utc` from version `2.1.0` to `2.1.1`
### Fixed
- Improved the table headers’ alignment of the accounts table on mobile
## 2.227.0 - 2026-01-02
### Changed
- Initialized the input properties in the _FIRE_ calculator
- Removed the deprecated public _Stripe_ key
- Upgraded `stripe` from version `18.5.0` to `20.1.0`
### Fixed
- Fixed the import of `jsonpath` to support REST APIs (`JSON`) via the scraper configuration
## 2.226.0 - 2026-01-01
### Added
- Extended the content of the _Self-Hosting_ section by information about additional data providers on the Frequently Asked Questions (FAQ) page
### Changed
- Upgraded `class-validator` from version `0.14.2` to `0.14.3`
- Upgraded `yahoo-finance2` from version `3.10.2` to `3.11.2`
## 2.225.0 - 2025-12-31
### Added
- Added a new endpoint to get all platforms (`GET api/v1/platforms`)
- Added the session url to the endpoint response of the _Stripe_ checkout
### Changed
- Improved the routing of the user detail dialog in the users section of the admin control panel
- Lifted the asset profile identifier editing restriction for `MANUAL` data sources in the asset profile details dialog of the admin control panel
- Deprecated the public _Stripe_ key
- Improved the language localization for German (`de`)
- Upgraded `angular` from version `20.2.4` to `20.3.9`
- Eliminated `ngx-stripe`
- Upgraded `angular` from version `20.2.4` to `21.0.6`
- Upgraded `marked` from version `15.0.4` to `17.0.1`
- Upgraded `ngx-device-detector` from version `10.1.0` to `11.0.0`
- Upgraded `ng-extract-i18n-merge` from `3.1.0` to `3.2.1`
- Upgraded `Nx` from version `21.5.1` to `22.1.3`
- Upgraded `ngx-markdown` from version `20.0.0` to `21.0.1`
- Upgraded `Nx` from version `21.5.1` to `22.3.3`
- Upgraded `shx` from version `0.3.4` to `0.4.0`
- Upgraded `storybook` from version `9.1.5` to `10.1.10`
- Upgraded `zone.js` from version `0.15.1` to `0.16.0`
### Fixed

18
README.md

@ -86,11 +86,12 @@ 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` |
| `ENABLE_FEATURE_AUTH_TOKEN` | `boolean` (optional) | `true` | Enables authentication via security token |
| `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"]` |
@ -109,7 +110,7 @@ We provide official container images hosted on [Docker Hub](https://hub.docker.c
| Name | Type | Default Value | Description |
| -------------------------- | --------------------- | ------------------------------------ | ---------------------------------------------------------------------------------------------------- |
| `ENABLE_FEATURE_AUTH_OIDC` | `boolean` (optional) | `false` | Enables _OpenID Connect_ authentication |
| `ENABLE_FEATURE_AUTH_OIDC` | `boolean` (optional) | `false` | Enables authentication via _OpenID Connect_ |
| `OIDC_AUTHORIZATION_URL` | `string` (optional) | | Manual override for the OIDC authorization endpoint (falls back to the discovery from the issuer) |
| `OIDC_CALLBACK_URL` | `string` (optional) | `${ROOT_URL}/api/auth/oidc/callback` | The OIDC callback URL |
| `OIDC_CLIENT_ID` | `string` | | The OIDC client ID |
@ -241,7 +242,7 @@ Deprecated: `GET http://localhost:3333/api/v1/auth/anonymous/<INSERT_SECURITY_TO
| `accountId` | `string` (optional) | Id of the account |
| `comment` | `string` (optional) | Comment of the activity |
| `currency` | `string` | `CHF` \| `EUR` \| `USD` etc. |
| `dataSource` | `string` | `COINGECKO` \| `MANUAL` \| `YAHOO` |
| `dataSource` | `string` | `COINGECKO` \| `GHOSTFOLIO` [^1] \| `MANUAL` \| `YAHOO` |
| `date` | `string` | Date in the format `ISO-8601` |
| `fee` | `number` | Fee of the activity |
| `quantity` | `number` | Quantity of the activity |
@ -317,12 +318,9 @@ If you like to support this project, become a [**Sponsor**](https://github.com/s
## Sponsors
<div align="center">
<p>
Browser testing via<br />
<a href="https://www.lambdatest.com?utm_medium=sponsor&utm_source=ghostfolio" target="_blank" title="LambdaTest - AI Powered Testing Tool">
<img alt="LambdaTest Logo" height="45" width="250" src="https://www.lambdatest.com/blue-logo.png" />
<a href="https://www.testmuai.com?utm_medium=sponsor&utm_source=ghostfolio" target="_blank" title="TestMu AI - AI Powered Testing Tool">
<img alt="TestMu AI Logo" height="45" src="https://assets.testmuai.com/resources/images/logos/logo.svg" />
</a>
</p>
</div>
## Analytics
@ -331,6 +329,8 @@ If you like to support this project, become a [**Sponsor**](https://github.com/s
## License
© 2021 - 2025 [Ghostfolio](https://ghostfol.io)
© 2021 - 2026 [Ghostfolio](https://ghostfol.io)
Licensed under the [AGPLv3 License](https://www.gnu.org/licenses/agpl-3.0.html).
[^1]: Available with [**Ghostfolio Premium**](https://ghostfol.io/en/pricing).

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

@ -132,12 +132,16 @@ export class AccountController {
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(RedactValuesInResponseInterceptor)
public async getAccountBalancesById(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Param('id') id: string
): Promise<AccountBalancesResponse> {
const impersonationUserId =
await this.impersonationService.validateImpersonationId(impersonationId);
return this.accountBalanceService.getAccountBalances({
filters: [{ id, type: 'ACCOUNT' }],
userCurrency: this.request.user.settings.settings.baseCurrency,
userId: this.request.user.id
userId: impersonationUserId || this.request.user.id
});
}

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

@ -150,15 +150,15 @@ export class AccountService {
});
return accounts.map((account) => {
let transactionCount = 0;
let activitiesCount = 0;
for (const { isDraft } of account.activities) {
if (!isDraft) {
transactionCount += 1;
activitiesCount += 1;
}
}
const result = { ...account, transactionCount };
const result = { ...account, activitiesCount };
delete result.activities;

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

@ -138,11 +138,11 @@ export class AdminService {
public async get(): Promise<AdminData> {
const dataSources = Object.values(DataSource);
const [enabledDataSources, settings, transactionCount, userCount] =
const [activitiesCount, enabledDataSources, settings, userCount] =
await Promise.all([
this.prismaService.order.count(),
this.dataProviderService.getDataSources(),
this.propertyService.get(),
this.prismaService.order.count(),
this.countUsersWithAnalytics()
]);
@ -182,9 +182,9 @@ export class AdminService {
).filter(Boolean);
return {
activitiesCount,
dataProviders,
settings,
transactionCount,
userCount,
version: environment.version
};

7
apps/api/src/app/endpoints/market-data/market-data.controller.ts

@ -2,6 +2,8 @@ import { AdminService } from '@ghostfolio/api/app/admin/admin.service';
import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service';
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
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 { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import {
@ -28,7 +30,8 @@ import {
Param,
Post,
Query,
UseGuards
UseGuards,
UseInterceptors
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
@ -86,6 +89,8 @@ export class MarketDataController {
@Get(':dataSource/:symbol')
@UseGuards(AuthGuard('jwt'))
@UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getMarketDataBySymbol(
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string

6
apps/api/src/app/endpoints/market-data/market-data.module.ts

@ -1,5 +1,7 @@
import { AdminModule } from '@ghostfolio/api/app/admin/admin.module';
import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.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 { MarketDataModule as MarketDataServiceModule } from '@ghostfolio/api/services/market-data/market-data.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
@ -13,7 +15,9 @@ import { MarketDataController } from './market-data.controller';
AdminModule,
MarketDataServiceModule,
SymbolModule,
SymbolProfileModule
SymbolProfileModule,
TransformDataSourceInRequestModule,
TransformDataSourceInResponseModule
]
})
export class MarketDataModule {}

4
apps/api/src/app/endpoints/platforms/platforms.controller.ts

@ -15,7 +15,9 @@ export class PlatformsController {
@HasPermission(permissions.readPlatforms)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getPlatforms(): Promise<PlatformsResponse> {
const platforms = await this.platformService.getPlatforms();
const platforms = await this.platformService.getPlatforms({
orderBy: { name: 'asc' }
});
return { platforms };
}

1
apps/api/src/app/import/import.controller.ts

@ -103,6 +103,7 @@ export class ImportController {
const activities = await this.importService.getDividends({
dataSource,
symbol,
userCurrency: this.request.user.settings.settings.baseCurrency,
userId: this.request.user.id
});

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

@ -6,6 +6,7 @@ import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module'
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.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 { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
@ -24,6 +25,7 @@ import { ImportService } from './import.service';
controllers: [ImportController],
imports: [
AccountModule,
ApiModule,
CacheModule,
ConfigurationModule,
DataGatheringModule,

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

@ -2,6 +2,7 @@ import { AccountService } from '@ghostfolio/api/app/account/account.service';
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 { ApiService } from '@ghostfolio/api/services/api/api.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
@ -25,7 +26,7 @@ import {
} from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import {
AccountWithPlatform,
AccountWithValue,
OrderWithAccount,
UserWithSettings
} from '@ghostfolio/common/types';
@ -43,6 +44,7 @@ import { ImportDataDto } from './import-data.dto';
export class ImportService {
public constructor(
private readonly accountService: AccountService,
private readonly apiService: ApiService,
private readonly configurationService: ConfigurationService,
private readonly dataGatheringService: DataGatheringService,
private readonly dataProviderService: DataProviderService,
@ -57,8 +59,12 @@ export class ImportService {
public async getDividends({
dataSource,
symbol,
userCurrency,
userId
}: AssetProfileIdentifier & { userId: string }): Promise<Activity[]> {
}: AssetProfileIdentifier & {
userCurrency: string;
userId: string;
}): Promise<Activity[]> {
try {
const holding = await this.portfolioService.getHolding({
dataSource,
@ -71,9 +77,26 @@ export class ImportService {
return [];
}
const { activities, firstBuyDate, historicalData } = holding;
const filters = this.apiService.buildFiltersFromQueryParams({
filterByDataSource: dataSource,
filterBySymbol: symbol
});
const { dateOfFirstActivity, historicalData } = holding;
const [[assetProfile], dividends] = await Promise.all([
const [{ accounts }, { activities }, [assetProfile], dividends] =
await Promise.all([
this.portfolioService.getAccountsWithAggregations({
filters,
userId,
withExcludedAccounts: true
}),
this.orderService.getOrders({
filters,
userCurrency,
userId,
startDate: parseDate(dateOfFirstActivity)
}),
this.symbolProfileService.getSymbolProfiles([
{
dataSource,
@ -83,24 +106,16 @@ export class ImportService {
await this.dataProviderService.getDividends({
dataSource,
symbol,
from: parseDate(firstBuyDate),
from: parseDate(dateOfFirstActivity),
granularity: 'day',
to: new Date()
})
]);
const accounts = activities
.filter(({ account }) => {
return !!account;
})
.map(({ account }) => {
return account;
});
const account = this.isUniqueAccount(accounts) ? accounts[0] : undefined;
return await Promise.all(
Object.entries(dividends).map(async ([dateString, { marketPrice }]) => {
Object.entries(dividends).map(([dateString, { marketPrice }]) => {
const quantity =
historicalData.find((historicalDataItem) => {
return historicalDataItem.date === dateString;
@ -695,11 +710,11 @@ export class ImportService {
);
}
private isUniqueAccount(accounts: AccountWithPlatform[]) {
private isUniqueAccount(accounts: AccountWithValue[]) {
const uniqueAccountIds = new Set<string>();
for (const account of accounts) {
uniqueAccountIds.add(account.id);
for (const { id } of accounts) {
uniqueAccountIds.add(id);
}
return uniqueAccountIds.size === 1;

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

@ -1,4 +1,3 @@
import { PlatformService } from '@ghostfolio/api/app/platform/platform.service';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
import { UserService } from '@ghostfolio/api/app/user/user.service';
@ -38,7 +37,6 @@ export class InfoService {
private readonly configurationService: ConfigurationService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly jwtService: JwtService,
private readonly platformService: PlatformService,
private readonly propertyService: PropertyService,
private readonly redisCacheService: RedisCacheService,
private readonly subscriptionService: SubscriptionService,
@ -93,7 +91,6 @@ export class InfoService {
(await this.propertyService.getByKey<string[]>(
PROPERTY_COUNTRIES_OF_SUBSCRIBERS
)) ?? [];
info.stripePublicKey = this.configurationService.get('STRIPE_PUBLIC_KEY');
}
if (this.configurationService.get('ENABLE_FEATURE_SYSTEM_MESSAGE')) {
@ -104,16 +101,12 @@ export class InfoService {
benchmarks,
demoAuthToken,
isUserSignupEnabled,
platforms,
statistics,
subscriptionOffer
] = await Promise.all([
this.benchmarkService.getBenchmarkAssetProfiles(),
this.getDemoAuthToken(),
this.propertyService.isUserSignupEnabled(),
this.platformService.getPlatforms({
orderBy: { name: 'asc' }
}),
this.getStatistics(),
this.subscriptionService.getSubscriptionOffer({ key: 'default' })
]);
@ -128,7 +121,6 @@ export class InfoService {
demoAuthToken,
globalPermissions,
isReadOnlyMode,
platforms,
statistics,
subscriptionOffer,
baseCurrency: DEFAULT_CURRENCY,

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

@ -1,7 +1,10 @@
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { CashDetails } from '@ghostfolio/api/app/account/interfaces/cash-details.interface';
import { AssetProfileChangedEvent } from '@ghostfolio/api/events/asset-profile-changed.event';
import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event';
import { LogPerformance } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor';
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 { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service';
@ -16,6 +19,7 @@ import {
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
import {
ActivitiesResponse,
Activity,
AssetProfileIdentifier,
EnhancedSymbolProfile,
Filter
@ -42,8 +46,10 @@ import { randomUUID } from 'node:crypto';
@Injectable()
export class OrderService {
public constructor(
private readonly accountBalanceService: AccountBalanceService,
private readonly accountService: AccountService,
private readonly dataGatheringService: DataGatheringService,
private readonly dataProviderService: DataProviderService,
private readonly eventEmitter: EventEmitter2,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly prismaService: PrismaService,
@ -317,6 +323,131 @@ export class OrderService {
return count;
}
/**
* Generates synthetic orders for cash holdings based on account balance history.
* Treat currencies as assets with a fixed unit price of 1.0 (in their own currency) to allow
* performance tracking based on exchange rate fluctuations.
*
* @param cashDetails - The cash balance details.
* @param filters - Optional filters to apply.
* @param userCurrency - The base currency of the user.
* @param userId - The ID of the user.
* @returns A response containing the list of synthetic cash activities.
*/
public async getCashOrders({
cashDetails,
filters = [],
userCurrency,
userId
}: {
cashDetails: CashDetails;
filters?: Filter[];
userCurrency: string;
userId: string;
}): Promise<ActivitiesResponse> {
const filtersByAssetClass = filters.filter(({ type }) => {
return type === 'ASSET_CLASS';
});
if (
filtersByAssetClass.length > 0 &&
!filtersByAssetClass.find(({ id }) => {
return id === AssetClass.LIQUIDITY;
})
) {
// If asset class filters are present and none of them is liquidity, return an empty response
return {
activities: [],
count: 0
};
}
const activities: Activity[] = [];
for (const account of cashDetails.accounts) {
const { balances } = await this.accountBalanceService.getAccountBalances({
userCurrency,
userId,
filters: [{ id: account.id, type: 'ACCOUNT' }]
});
let currentBalance = 0;
let currentBalanceInBaseCurrency = 0;
for (const balanceItem of balances) {
const syntheticActivityTemplate: Activity = {
userId,
accountId: account.id,
accountUserId: account.userId,
comment: account.name,
createdAt: new Date(balanceItem.date),
currency: account.currency,
date: new Date(balanceItem.date),
fee: 0,
feeInAssetProfileCurrency: 0,
feeInBaseCurrency: 0,
id: balanceItem.id,
isDraft: false,
quantity: 1,
SymbolProfile: {
activitiesCount: 0,
assetClass: AssetClass.LIQUIDITY,
assetSubClass: AssetSubClass.CASH,
countries: [],
createdAt: new Date(balanceItem.date),
currency: account.currency,
dataSource:
this.dataProviderService.getDataSourceForExchangeRates(),
holdings: [],
id: account.currency,
isActive: true,
name: account.currency,
sectors: [],
symbol: account.currency,
updatedAt: new Date(balanceItem.date)
},
symbolProfileId: account.currency,
type: ActivityType.BUY,
unitPrice: 1,
unitPriceInAssetProfileCurrency: 1,
updatedAt: new Date(balanceItem.date),
valueInBaseCurrency: 0,
value: 0
};
if (currentBalance < balanceItem.value) {
// BUY
activities.push({
...syntheticActivityTemplate,
quantity: balanceItem.value - currentBalance,
type: ActivityType.BUY,
value: balanceItem.value - currentBalance,
valueInBaseCurrency:
balanceItem.valueInBaseCurrency - currentBalanceInBaseCurrency
});
} else if (currentBalance > balanceItem.value) {
// SELL
activities.push({
...syntheticActivityTemplate,
quantity: currentBalance - balanceItem.value,
type: ActivityType.SELL,
value: currentBalance - balanceItem.value,
valueInBaseCurrency:
currentBalanceInBaseCurrency - balanceItem.valueInBaseCurrency
});
}
currentBalance = balanceItem.value;
currentBalanceInBaseCurrency = balanceItem.valueInBaseCurrency;
}
}
return {
activities,
count: activities.length
};
}
public async getLatestOrder({ dataSource, symbol }: AssetProfileIdentifier) {
return this.prismaService.order.findFirst({
orderBy: {
@ -610,22 +741,52 @@ export class OrderService {
return { activities, count };
}
/**
* Retrieves all orders required for the portfolio calculator, including both standard asset orders
* and optional synthetic orders representing cash activities.
*/
@LogPerformance
public async getOrdersForPortfolioCalculator({
filters,
userCurrency,
userId
userId,
withCash = false
}: {
/** Optional filters to apply to the orders. */
filters?: Filter[];
/** The base currency of the user. */
userCurrency: string;
/** The ID of the user. */
userId: string;
/** Whether to include cash activities in the result. */
withCash?: boolean;
}) {
return this.getOrders({
const orders = await this.getOrders({
filters,
userCurrency,
userId,
withExcludedAccountsAndActivities: false // TODO
});
if (withCash) {
const cashDetails = await this.accountService.getCashDetails({
filters,
userId,
currency: userCurrency
});
const cashOrders = await this.getCashOrders({
cashDetails,
filters,
userCurrency,
userId
});
orders.activities.push(...cashOrders.activities);
orders.count += cashOrders.count;
}
return orders;
}
public async getStatisticsByCurrency(

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

@ -39,6 +39,7 @@ import { GroupBy } from '@ghostfolio/common/types';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Logger } from '@nestjs/common';
import { AssetSubClass } from '@prisma/client';
import { Big } from 'big.js';
import { plainToClass } from 'class-transformer';
import {
@ -119,6 +120,7 @@ export abstract class PortfolioCalculator {
({
date,
feeInAssetProfileCurrency,
feeInBaseCurrency,
quantity,
SymbolProfile,
tags = [],
@ -141,6 +143,7 @@ export abstract class PortfolioCalculator {
type,
date: format(date, DATE_FORMAT),
fee: new Big(feeInAssetProfileCurrency),
feeInBaseCurrency: new Big(feeInBaseCurrency),
quantity: new Big(quantity),
unitPrice: new Big(unitPriceInAssetProfileCurrency)
};
@ -203,13 +206,19 @@ export abstract class PortfolioCalculator {
let totalInterestWithCurrencyEffect = new Big(0);
let totalLiabilitiesWithCurrencyEffect = new Big(0);
for (const { currency, dataSource, symbol } of transactionPoints[
firstIndex - 1
].items) {
for (const {
assetSubClass,
currency,
dataSource,
symbol
} of transactionPoints[firstIndex - 1].items) {
// Gather data for all assets except CASH
if (assetSubClass !== 'CASH') {
dataGatheringItems.push({
dataSource,
symbol
});
}
currencies[symbol] = currency;
}
@ -329,12 +338,6 @@ export abstract class PortfolioCalculator {
} = {};
for (const item of lastTransactionPoint.items) {
const feeInBaseCurrency = item.fee.mul(
exchangeRatesByCurrency[`${item.currency}${this.currency}`]?.[
lastTransactionPoint.date
] ?? 1
);
const marketPriceInBaseCurrency = (
marketSymbolMap[endDateString]?.[item.symbol] ?? item.averagePrice
).mul(
@ -383,6 +386,10 @@ export abstract class PortfolioCalculator {
hasAnySymbolMetricsErrors = hasAnySymbolMetricsErrors || hasErrors;
const includeInTotalAssetValue =
item.assetSubClass !== AssetSubClass.CASH;
if (includeInTotalAssetValue) {
valuesBySymbol[item.symbol] = {
currentValues,
currentValuesWithCurrencyEffect,
@ -394,18 +401,21 @@ export abstract class PortfolioCalculator {
timeWeightedInvestmentValues,
timeWeightedInvestmentValuesWithCurrencyEffect
};
}
positions.push({
feeInBaseCurrency,
includeInTotalAssetValue,
timeWeightedInvestment,
timeWeightedInvestmentWithCurrencyEffect,
dividend: totalDividend,
dividendInBaseCurrency: totalDividendInBaseCurrency,
activitiesCount: item.activitiesCount,
averagePrice: item.averagePrice,
currency: item.currency,
dataSource: item.dataSource,
dateOfFirstActivity: item.dateOfFirstActivity,
dividend: totalDividend,
dividendInBaseCurrency: totalDividendInBaseCurrency,
fee: item.fee,
firstBuyDate: item.firstBuyDate,
feeInBaseCurrency: item.feeInBaseCurrency,
grossPerformance: !hasErrors ? (grossPerformance ?? null) : null,
grossPerformancePercentage: !hasErrors
? (grossPerformancePercentage ?? null)
@ -420,9 +430,8 @@ export abstract class PortfolioCalculator {
investment: totalInvestment,
investmentWithCurrencyEffect: totalInvestmentWithCurrencyEffect,
marketPrice:
marketSymbolMap[endDateString]?.[item.symbol]?.toNumber() ?? null,
marketPriceInBaseCurrency:
marketPriceInBaseCurrency?.toNumber() ?? null,
marketSymbolMap[endDateString]?.[item.symbol]?.toNumber() ?? 1,
marketPriceInBaseCurrency: marketPriceInBaseCurrency?.toNumber() ?? 1,
netPerformance: !hasErrors ? (netPerformance ?? null) : null,
netPerformancePercentage: !hasErrors
? (netPerformancePercentage ?? null)
@ -436,7 +445,6 @@ export abstract class PortfolioCalculator {
quantity: item.quantity,
symbol: item.symbol,
tags: item.tags,
transactionCount: item.transactionCount,
valueInBaseCurrency: new Big(marketPriceInBaseCurrency).mul(
item.quantity
)
@ -925,6 +933,7 @@ export abstract class PortfolioCalculator {
for (const {
date,
fee,
feeInBaseCurrency,
quantity,
SymbolProfile,
tags,
@ -933,6 +942,7 @@ export abstract class PortfolioCalculator {
} of this.activities) {
let currentTransactionPointItem: TransactionPointSymbol;
const assetSubClass = SymbolProfile.assetSubClass;
const currency = SymbolProfile.currency;
const dataSource = SymbolProfile.dataSource;
const factor = getFactor(type);
@ -977,37 +987,42 @@ export abstract class PortfolioCalculator {
}
currentTransactionPointItem = {
assetSubClass,
currency,
dataSource,
investment,
skipErrors,
symbol,
activitiesCount: oldAccumulatedSymbol.activitiesCount + 1,
averagePrice: newQuantity.eq(0)
? new Big(0)
: investment.div(newQuantity).abs(),
dateOfFirstActivity: oldAccumulatedSymbol.dateOfFirstActivity,
dividend: new Big(0),
fee: oldAccumulatedSymbol.fee.plus(fee),
firstBuyDate: oldAccumulatedSymbol.firstBuyDate,
feeInBaseCurrency:
oldAccumulatedSymbol.feeInBaseCurrency.plus(feeInBaseCurrency),
includeInHoldings: oldAccumulatedSymbol.includeInHoldings,
quantity: newQuantity,
tags: oldAccumulatedSymbol.tags.concat(tags),
transactionCount: oldAccumulatedSymbol.transactionCount + 1
tags: oldAccumulatedSymbol.tags.concat(tags)
};
} else {
currentTransactionPointItem = {
assetSubClass,
currency,
dataSource,
fee,
feeInBaseCurrency,
skipErrors,
symbol,
tags,
activitiesCount: 1,
averagePrice: unitPrice,
dateOfFirstActivity: date,
dividend: new Big(0),
firstBuyDate: date,
includeInHoldings: INVESTMENT_ACTIVITY_TYPES.includes(type),
investment: unitPrice.mul(quantity).mul(factor),
quantity: quantity.mul(factor),
transactionCount: 1
quantity: quantity.mul(factor)
};
}

15
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-buy.spec.ts

@ -87,6 +87,7 @@ describe('PortfolioCalculator', () => {
...activityDummyData,
date: new Date('2021-11-22'),
feeInAssetProfileCurrency: 1.55,
feeInBaseCurrency: 1.55,
quantity: 2,
SymbolProfile: {
...symbolProfileDummyData,
@ -102,6 +103,7 @@ describe('PortfolioCalculator', () => {
...activityDummyData,
date: new Date('2021-11-30'),
feeInAssetProfileCurrency: 1.65,
feeInBaseCurrency: 1.65,
quantity: 2,
SymbolProfile: {
...symbolProfileDummyData,
@ -131,20 +133,26 @@ describe('PortfolioCalculator', () => {
groupBy: 'month'
});
const investmentsByYear = portfolioCalculator.getInvestmentsByGroup({
data: portfolioSnapshot.historicalData,
groupBy: 'year'
});
expect(portfolioSnapshot).toMatchObject({
currentValueInBaseCurrency: new Big('595.6'),
errors: [],
hasErrors: false,
positions: [
{
activitiesCount: 2,
averagePrice: new Big('139.75'),
currency: 'CHF',
dataSource: 'YAHOO',
dateOfFirstActivity: '2021-11-22',
dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'),
fee: new Big('3.2'),
feeInBaseCurrency: new Big('3.2'),
firstBuyDate: '2021-11-22',
grossPerformance: new Big('36.6'),
grossPerformancePercentage: new Big('0.07706261539956593567'),
grossPerformancePercentageWithCurrencyEffect: new Big(
@ -170,7 +178,6 @@ describe('PortfolioCalculator', () => {
timeWeightedInvestmentWithCurrencyEffect: new Big(
'474.93846153846153846154'
),
transactionCount: 2,
valueInBaseCurrency: new Big('595.6')
}
],
@ -200,6 +207,10 @@ describe('PortfolioCalculator', () => {
{ date: '2021-11-01', investment: 559 },
{ date: '2021-12-01', investment: 0 }
]);
expect(investmentsByYear).toEqual([
{ date: '2021-01-01', investment: 559 }
]);
});
});
});

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

@ -87,6 +87,7 @@ describe('PortfolioCalculator', () => {
...activityDummyData,
date: new Date('2021-11-22'),
feeInAssetProfileCurrency: 1.55,
feeInBaseCurrency: 1.55,
quantity: 2,
SymbolProfile: {
...symbolProfileDummyData,
@ -102,6 +103,7 @@ describe('PortfolioCalculator', () => {
...activityDummyData,
date: new Date('2021-11-30'),
feeInAssetProfileCurrency: 1.65,
feeInBaseCurrency: 1.65,
quantity: 1,
SymbolProfile: {
...symbolProfileDummyData,
@ -117,6 +119,7 @@ describe('PortfolioCalculator', () => {
...activityDummyData,
date: new Date('2021-11-30'),
feeInAssetProfileCurrency: 0,
feeInBaseCurrency: 0,
quantity: 1,
SymbolProfile: {
...symbolProfileDummyData,
@ -146,20 +149,26 @@ describe('PortfolioCalculator', () => {
groupBy: 'month'
});
const investmentsByYear = portfolioCalculator.getInvestmentsByGroup({
data: portfolioSnapshot.historicalData,
groupBy: 'year'
});
expect(portfolioSnapshot).toMatchObject({
currentValueInBaseCurrency: new Big('0'),
errors: [],
hasErrors: false,
positions: [
{
activitiesCount: 3,
averagePrice: new Big('0'),
currency: 'CHF',
dataSource: 'YAHOO',
dateOfFirstActivity: '2021-11-22',
dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'),
fee: new Big('3.2'),
feeInBaseCurrency: new Big('3.2'),
firstBuyDate: '2021-11-22',
grossPerformance: new Big('-12.6'),
grossPerformancePercentage: new Big('-0.04408677396780965649'),
grossPerformancePercentageWithCurrencyEffect: new Big(
@ -183,7 +192,6 @@ describe('PortfolioCalculator', () => {
timeWeightedInvestmentWithCurrencyEffect: new Big(
'285.80000000000000396627'
),
transactionCount: 3,
valueInBaseCurrency: new Big('0')
}
],
@ -213,6 +221,10 @@ describe('PortfolioCalculator', () => {
{ date: '2021-11-01', investment: 0 },
{ date: '2021-12-01', investment: 0 }
]);
expect(investmentsByYear).toEqual([
{ date: '2021-01-01', investment: 0 }
]);
});
});
});

15
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell.spec.ts

@ -87,6 +87,7 @@ describe('PortfolioCalculator', () => {
...activityDummyData,
date: new Date('2021-11-22'),
feeInAssetProfileCurrency: 1.55,
feeInBaseCurrency: 1.55,
quantity: 2,
SymbolProfile: {
...symbolProfileDummyData,
@ -102,6 +103,7 @@ describe('PortfolioCalculator', () => {
...activityDummyData,
date: new Date('2021-11-30'),
feeInAssetProfileCurrency: 1.65,
feeInBaseCurrency: 1.65,
quantity: 2,
SymbolProfile: {
...symbolProfileDummyData,
@ -131,20 +133,26 @@ describe('PortfolioCalculator', () => {
groupBy: 'month'
});
const investmentsByYear = portfolioCalculator.getInvestmentsByGroup({
data: portfolioSnapshot.historicalData,
groupBy: 'year'
});
expect(portfolioSnapshot).toMatchObject({
currentValueInBaseCurrency: new Big('0'),
errors: [],
hasErrors: false,
positions: [
{
activitiesCount: 2,
averagePrice: new Big('0'),
currency: 'CHF',
dataSource: 'YAHOO',
dateOfFirstActivity: '2021-11-22',
dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'),
fee: new Big('3.2'),
feeInBaseCurrency: new Big('3.2'),
firstBuyDate: '2021-11-22',
grossPerformance: new Big('-12.6'),
grossPerformancePercentage: new Big('-0.0440867739678096571'),
grossPerformancePercentageWithCurrencyEffect: new Big(
@ -168,7 +176,6 @@ describe('PortfolioCalculator', () => {
tags: [],
timeWeightedInvestment: new Big('285.8'),
timeWeightedInvestmentWithCurrencyEffect: new Big('285.8'),
transactionCount: 2,
valueInBaseCurrency: new Big('0')
}
],
@ -198,6 +205,10 @@ describe('PortfolioCalculator', () => {
{ date: '2021-11-01', investment: 0 },
{ date: '2021-12-01', investment: 0 }
]);
expect(investmentsByYear).toEqual([
{ date: '2021-01-01', investment: 0 }
]);
});
});
});

16
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy.spec.ts

@ -87,6 +87,7 @@ describe('PortfolioCalculator', () => {
...activityDummyData,
date: new Date('2021-11-30'),
feeInAssetProfileCurrency: 1.55,
feeInBaseCurrency: 1.55,
quantity: 2,
SymbolProfile: {
...symbolProfileDummyData,
@ -122,20 +123,26 @@ describe('PortfolioCalculator', () => {
groupBy: 'month'
});
const investmentsByYear = portfolioCalculator.getInvestmentsByGroup({
data: portfolioSnapshot.historicalData,
groupBy: 'year'
});
expect(portfolioSnapshot).toMatchObject({
currentValueInBaseCurrency: new Big('297.8'),
errors: [],
hasErrors: false,
positions: [
{
activitiesCount: 1,
averagePrice: new Big('136.6'),
currency: 'CHF',
dataSource: 'YAHOO',
dateOfFirstActivity: '2021-11-30',
dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'),
fee: new Big('1.55'),
feeInBaseCurrency: new Big('1.55'),
firstBuyDate: '2021-11-30',
grossPerformance: new Big('24.6'),
grossPerformancePercentage: new Big('0.09004392386530014641'),
grossPerformancePercentageWithCurrencyEffect: new Big(
@ -165,7 +172,6 @@ describe('PortfolioCalculator', () => {
tags: [],
timeWeightedInvestment: new Big('273.2'),
timeWeightedInvestmentWithCurrencyEffect: new Big('273.2'),
transactionCount: 1,
valueInBaseCurrency: new Big('297.8')
}
],
@ -198,6 +204,10 @@ describe('PortfolioCalculator', () => {
{ date: '2021-11-01', investment: 273.2 },
{ date: '2021-12-01', investment: 0 }
]);
expect(investmentsByYear).toEqual([
{ date: '2021-01-01', investment: 273.2 }
]);
});
it.only('with BALN.SW buy (with unit price lower than closing price)', async () => {
@ -208,6 +218,7 @@ describe('PortfolioCalculator', () => {
...activityDummyData,
date: new Date('2021-11-30'),
feeInAssetProfileCurrency: 1.55,
feeInBaseCurrency: 1.55,
quantity: 2,
SymbolProfile: {
...symbolProfileDummyData,
@ -247,6 +258,7 @@ describe('PortfolioCalculator', () => {
...activityDummyData,
date: new Date('2021-11-30'),
feeInAssetProfileCurrency: 1.55,
feeInBaseCurrency: 1.55,
quantity: 2,
SymbolProfile: {
...symbolProfileDummyData,

1
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btceur-in-base-currency-eur.spec.ts

@ -110,6 +110,7 @@ describe('PortfolioCalculator', () => {
...activity,
date: parseDate(activity.date),
feeInAssetProfileCurrency: 4.46,
feeInBaseCurrency: 3.94,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'USD',

15
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btceur.spec.ts

@ -98,6 +98,7 @@ describe('PortfolioCalculator', () => {
...activity,
date: parseDate(activity.date),
feeInAssetProfileCurrency: 4.46,
feeInBaseCurrency: 4.46,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'USD',
@ -131,6 +132,11 @@ describe('PortfolioCalculator', () => {
groupBy: 'month'
});
const investmentsByYear = portfolioCalculator.getInvestmentsByGroup({
data: portfolioSnapshot.historicalData,
groupBy: 'year'
});
expect(portfolioSnapshot.historicalData[0]).toEqual({
date: '2021-12-11',
investmentValueWithCurrencyEffect: 0,
@ -189,14 +195,15 @@ describe('PortfolioCalculator', () => {
hasErrors: false,
positions: [
{
activitiesCount: 1,
averagePrice: new Big('44558.42'),
currency: 'USD',
dataSource: 'YAHOO',
dateOfFirstActivity: '2021-12-12',
dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'),
fee: new Big('4.46'),
feeInBaseCurrency: new Big('4.46'),
firstBuyDate: '2021-12-12',
grossPerformance: new Big('-1458.72'),
grossPerformancePercentage: new Big('-0.03273724696701543726'),
grossPerformancePercentageWithCurrencyEffect: new Big(
@ -220,7 +227,6 @@ describe('PortfolioCalculator', () => {
tags: [],
timeWeightedInvestment: new Big('44558.42'),
timeWeightedInvestmentWithCurrencyEffect: new Big('44558.42'),
transactionCount: 1,
valueInBaseCurrency: new Big('43099.7')
}
],
@ -244,6 +250,11 @@ describe('PortfolioCalculator', () => {
{ date: '2021-12-01', investment: 44558.42 },
{ date: '2022-01-01', investment: 0 }
]);
expect(investmentsByYear).toEqual([
{ date: '2021-01-01', investment: 44558.42 },
{ date: '2022-01-01', investment: 0 }
]);
});
});
});

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

@ -100,6 +100,7 @@ describe('PortfolioCalculator', () => {
...activityDummyData,
date: new Date('2015-01-01'),
feeInAssetProfileCurrency: 0,
feeInBaseCurrency: 0,
quantity: 2,
SymbolProfile: {
...symbolProfileDummyData,
@ -115,6 +116,7 @@ describe('PortfolioCalculator', () => {
...activityDummyData,
date: new Date('2017-12-31'),
feeInAssetProfileCurrency: 0,
feeInBaseCurrency: 0,
quantity: 1,
SymbolProfile: {
...symbolProfileDummyData,
@ -144,6 +146,11 @@ describe('PortfolioCalculator', () => {
groupBy: 'month'
});
const investmentsByYear = portfolioCalculator.getInvestmentsByGroup({
data: portfolioSnapshot.historicalData,
groupBy: 'year'
});
expect(portfolioSnapshot).toMatchObject({
currentValueInBaseCurrency: new Big('13298.425356'),
errors: [],
@ -151,14 +158,15 @@ describe('PortfolioCalculator', () => {
hasErrors: false,
positions: [
{
activitiesCount: 2,
averagePrice: new Big('320.43'),
currency: 'USD',
dataSource: 'YAHOO',
dateOfFirstActivity: '2015-01-01',
dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'),
fee: new Big('0'),
feeInBaseCurrency: new Big('0'),
firstBuyDate: '2015-01-01',
grossPerformance: new Big('27172.74').mul(0.97373),
grossPerformancePercentage: new Big('0.4241983590271396608571'),
grossPerformancePercentageWithCurrencyEffect: new Big(
@ -186,7 +194,6 @@ describe('PortfolioCalculator', () => {
timeWeightedInvestmentWithCurrencyEffect: new Big(
'636.79389574611155533947'
),
transactionCount: 2,
valueInBaseCurrency: new Big('13298.425356')
}
],
@ -251,6 +258,13 @@ describe('PortfolioCalculator', () => {
{ date: '2017-12-01', investment: -318.54266729999995 },
{ date: '2018-01-01', investment: 0 }
]);
expect(investmentsByYear).toEqual([
{ date: '2015-01-01', investment: 637.0853345999999 },
{ date: '2016-01-01', investment: 0 },
{ date: '2017-01-01', investment: -318.54266729999995 },
{ date: '2018-01-01', investment: 0 }
]);
});
});
});

1
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-short.spec.ts

@ -98,6 +98,7 @@ describe('PortfolioCalculator', () => {
...activity,
date: parseDate(activity.date),
feeInAssetProfileCurrency: activity.fee,
feeInBaseCurrency: activity.fee,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'USD',

15
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd.spec.ts

@ -98,6 +98,7 @@ describe('PortfolioCalculator', () => {
...activity,
date: parseDate(activity.date),
feeInAssetProfileCurrency: 4.46,
feeInBaseCurrency: 4.46,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'USD',
@ -131,6 +132,11 @@ describe('PortfolioCalculator', () => {
groupBy: 'month'
});
const investmentsByYear = portfolioCalculator.getInvestmentsByGroup({
data: portfolioSnapshot.historicalData,
groupBy: 'year'
});
expect(portfolioSnapshot.historicalData[0]).toEqual({
date: '2021-12-11',
investmentValueWithCurrencyEffect: 0,
@ -189,14 +195,15 @@ describe('PortfolioCalculator', () => {
hasErrors: false,
positions: [
{
activitiesCount: 1,
averagePrice: new Big('44558.42'),
currency: 'USD',
dataSource: 'YAHOO',
dateOfFirstActivity: '2021-12-12',
dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'),
fee: new Big('4.46'),
feeInBaseCurrency: new Big('4.46'),
firstBuyDate: '2021-12-12',
grossPerformance: new Big('-1458.72'),
grossPerformancePercentage: new Big('-0.03273724696701543726'),
grossPerformancePercentageWithCurrencyEffect: new Big(
@ -220,7 +227,6 @@ describe('PortfolioCalculator', () => {
tags: [],
timeWeightedInvestment: new Big('44558.42'),
timeWeightedInvestmentWithCurrencyEffect: new Big('44558.42'),
transactionCount: 1,
valueInBaseCurrency: new Big('43099.7')
}
],
@ -244,6 +250,11 @@ describe('PortfolioCalculator', () => {
{ date: '2021-12-01', investment: 44558.42 },
{ date: '2022-01-01', investment: 0 }
]);
expect(investmentsByYear).toEqual([
{ date: '2021-01-01', investment: 44558.42 },
{ date: '2022-01-01', investment: 0 }
]);
});
});
});

290
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts

@ -0,0 +1,290 @@
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { userDummyData } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.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 { TimelinePosition } from '@ghostfolio/common/models';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { DataSource } from '@prisma/client';
import { Big } from 'big.js';
import { randomUUID } from 'node:crypto';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
};
});
jest.mock(
'@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service',
() => {
return {
ExchangeRateDataService: jest.fn().mockImplementation(() => {
return ExchangeRateDataServiceMock;
})
};
}
);
jest.mock(
'@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service',
() => {
return {
PortfolioSnapshotService: jest.fn().mockImplementation(() => {
return PortfolioSnapshotServiceMock;
})
};
}
);
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock;
})
};
});
describe('PortfolioCalculator', () => {
let accountBalanceService: AccountBalanceService;
let accountService: AccountService;
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
let dataProviderService: DataProviderService;
let exchangeRateDataService: ExchangeRateDataService;
let orderService: OrderService;
let portfolioCalculatorFactory: PortfolioCalculatorFactory;
let portfolioSnapshotService: PortfolioSnapshotService;
let redisCacheService: RedisCacheService;
beforeEach(() => {
configurationService = new ConfigurationService();
exchangeRateDataService = new ExchangeRateDataService(
null,
null,
null,
null
);
accountBalanceService = new AccountBalanceService(
null,
exchangeRateDataService,
null
);
accountService = new AccountService(
accountBalanceService,
null,
exchangeRateDataService,
null
);
redisCacheService = new RedisCacheService(null, configurationService);
dataProviderService = new DataProviderService(
configurationService,
null,
null,
null,
null,
redisCacheService
);
currentRateService = new CurrentRateService(
dataProviderService,
null,
null,
null
);
orderService = new OrderService(
accountBalanceService,
accountService,
null,
dataProviderService,
null,
exchangeRateDataService,
null,
null
);
portfolioSnapshotService = new PortfolioSnapshotService(null);
portfolioCalculatorFactory = new PortfolioCalculatorFactory(
configurationService,
currentRateService,
exchangeRateDataService,
portfolioSnapshotService,
redisCacheService
);
});
describe('Cash Performance', () => {
it('should calculate performance for cash assets in CHF default currency', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2025-01-01').getTime());
const accountId = randomUUID();
jest
.spyOn(accountBalanceService, 'getAccountBalances')
.mockResolvedValue({
balances: [
{
accountId,
id: randomUUID(),
date: parseDate('2023-12-31'),
value: 1000,
valueInBaseCurrency: 850
},
{
accountId,
id: randomUUID(),
date: parseDate('2024-12-31'),
value: 2000,
valueInBaseCurrency: 1800
}
]
});
jest.spyOn(accountService, 'getCashDetails').mockResolvedValue({
accounts: [
{
balance: 2000,
comment: null,
createdAt: parseDate('2023-12-31'),
currency: 'USD',
id: accountId,
isExcluded: false,
name: 'USD',
platformId: null,
updatedAt: parseDate('2023-12-31'),
userId: userDummyData.id
}
],
balanceInBaseCurrency: 1820
});
jest
.spyOn(dataProviderService, 'getDataSourceForExchangeRates')
.mockReturnValue(DataSource.YAHOO);
jest.spyOn(orderService, 'getOrders').mockResolvedValue({
activities: [],
count: 0
});
const { activities } = await orderService.getOrdersForPortfolioCalculator(
{
userCurrency: 'CHF',
userId: userDummyData.id,
withCash: true
}
);
jest.spyOn(currentRateService, 'getValues').mockResolvedValue({
dataProviderInfos: [],
errors: [],
values: []
});
const accountBalanceItems =
await accountBalanceService.getAccountBalanceItems({
userCurrency: 'CHF',
userId: userDummyData.id
});
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
accountBalanceItems,
activities,
calculationType: PerformanceCalculationType.ROAI,
currency: 'CHF',
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
const position = portfolioSnapshot.positions.find(({ symbol }) => {
return symbol === 'USD';
});
/**
* Investment: 2000 USD * 0.91 = 1820 CHF
* Investment value with currency effect: (1000 USD * 0.85) + (1000 USD * 0.90) = 1750 CHF
* Net performance: (1000 USD * 1.0) - (1000 USD * 1.0) = 0 CHF
* Total account balance: 2000 USD * 0.85 = 1700 CHF (using the exchange rate on 2024-12-31)
* Value in base currency: 2000 USD * 0.91 = 1820 CHF
*/
expect(position).toMatchObject<TimelinePosition>({
activitiesCount: 2,
averagePrice: new Big(1),
currency: 'USD',
dataSource: DataSource.YAHOO,
dateOfFirstActivity: '2023-12-31',
dividend: new Big(0),
dividendInBaseCurrency: new Big(0),
fee: new Big(0),
feeInBaseCurrency: new Big(0),
grossPerformance: new Big(0),
grossPerformancePercentage: new Big(0),
grossPerformancePercentageWithCurrencyEffect: new Big(
'0.08211603004634809014'
),
grossPerformanceWithCurrencyEffect: new Big(70),
includeInTotalAssetValue: false,
investment: new Big(1820),
investmentWithCurrencyEffect: new Big(1750),
marketPrice: 1,
marketPriceInBaseCurrency: 0.91,
netPerformance: new Big(0),
netPerformancePercentage: new Big(0),
netPerformancePercentageWithCurrencyEffectMap: {
'1d': new Big('0.01111111111111111111'),
'1y': new Big('0.06937181021989792704'),
'5y': new Big('0.0818817546090273363'),
max: new Big('0.0818817546090273363'),
mtd: new Big('0.01111111111111111111'),
wtd: new Big('-0.05517241379310344828'),
ytd: new Big('0.01111111111111111111')
},
netPerformanceWithCurrencyEffectMap: {
'1d': new Big(20),
'1y': new Big(60),
'5y': new Big(70),
max: new Big(70),
mtd: new Big(20),
wtd: new Big(-80),
ytd: new Big(20)
},
quantity: new Big(2000),
symbol: 'USD',
timeWeightedInvestment: new Big('912.47956403269754768392'),
timeWeightedInvestmentWithCurrencyEffect: new Big(
'852.45231607629427792916'
),
valueInBaseCurrency: new Big(1820)
});
expect(portfolioSnapshot).toMatchObject({
hasErrors: false,
totalFeesWithCurrencyEffect: new Big(0),
totalInterestWithCurrencyEffect: new Big(0),
totalLiabilitiesWithCurrencyEffect: new Big(0)
});
});
});
});

1
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-fee.spec.ts

@ -87,6 +87,7 @@ describe('PortfolioCalculator', () => {
...activityDummyData,
date: new Date('2021-09-01'),
feeInAssetProfileCurrency: 49,
feeInBaseCurrency: 49,
quantity: 0,
SymbolProfile: {
...symbolProfileDummyData,

14
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-googl-buy.spec.ts

@ -99,6 +99,7 @@ describe('PortfolioCalculator', () => {
...activityDummyData,
date: new Date('2023-01-03'),
feeInAssetProfileCurrency: 1,
feeInBaseCurrency: 0.9238,
quantity: 1,
SymbolProfile: {
...symbolProfileDummyData,
@ -128,20 +129,26 @@ describe('PortfolioCalculator', () => {
groupBy: 'month'
});
const investmentsByYear = portfolioCalculator.getInvestmentsByGroup({
data: portfolioSnapshot.historicalData,
groupBy: 'year'
});
expect(portfolioSnapshot).toMatchObject({
currentValueInBaseCurrency: new Big('103.10483'),
errors: [],
hasErrors: false,
positions: [
{
activitiesCount: 1,
averagePrice: new Big('89.12'),
currency: 'USD',
dataSource: 'YAHOO',
dateOfFirstActivity: '2023-01-03',
dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'),
fee: new Big('1'),
feeInBaseCurrency: new Big('0.9238'),
firstBuyDate: '2023-01-03',
grossPerformance: new Big('27.33').mul(0.8854),
grossPerformancePercentage: new Big('0.3066651705565529623'),
grossPerformancePercentageWithCurrencyEffect: new Big(
@ -165,7 +172,6 @@ describe('PortfolioCalculator', () => {
tags: [],
timeWeightedInvestment: new Big('89.12').mul(0.8854),
timeWeightedInvestmentWithCurrencyEffect: new Big('82.329056'),
transactionCount: 1,
valueInBaseCurrency: new Big('103.10483')
}
],
@ -217,6 +223,10 @@ describe('PortfolioCalculator', () => {
investment: 0
}
]);
expect(investmentsByYear).toEqual([
{ date: '2023-01-01', investment: 82.329056 }
]);
});
});
});

1
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-liability.spec.ts

@ -87,6 +87,7 @@ describe('PortfolioCalculator', () => {
...activityDummyData,
date: new Date('2023-01-01'), // Date in future
feeInAssetProfileCurrency: 0,
feeInBaseCurrency: 0,
quantity: 1,
SymbolProfile: {
...symbolProfileDummyData,

7
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-and-sell.spec.ts

@ -80,6 +80,7 @@ describe('PortfolioCalculator', () => {
...activityDummyData,
date: new Date('2024-03-08'),
feeInAssetProfileCurrency: 0,
feeInBaseCurrency: 0,
quantity: 0.3333333333333333,
SymbolProfile: {
...symbolProfileDummyData,
@ -94,8 +95,9 @@ describe('PortfolioCalculator', () => {
{
...activityDummyData,
date: new Date('2024-03-13'),
quantity: 0.6666666666666666,
feeInAssetProfileCurrency: 0,
feeInBaseCurrency: 0,
quantity: 0.6666666666666666,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'USD',
@ -109,8 +111,9 @@ describe('PortfolioCalculator', () => {
{
...activityDummyData,
date: new Date('2024-03-14'),
quantity: 1,
feeInAssetProfileCurrency: 0,
feeInBaseCurrency: 0,
quantity: 1,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'USD',

8
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-with-dividend.spec.ts

@ -87,6 +87,7 @@ describe('PortfolioCalculator', () => {
...activityDummyData,
date: new Date('2021-09-16'),
feeInAssetProfileCurrency: 19,
feeInBaseCurrency: 19,
quantity: 1,
SymbolProfile: {
...symbolProfileDummyData,
@ -102,6 +103,7 @@ describe('PortfolioCalculator', () => {
...activityDummyData,
date: new Date('2021-11-16'),
feeInAssetProfileCurrency: 0,
feeInBaseCurrency: 0,
quantity: 1,
SymbolProfile: {
...symbolProfileDummyData,
@ -129,13 +131,14 @@ describe('PortfolioCalculator', () => {
hasErrors: false,
positions: [
{
activitiesCount: 2,
averagePrice: new Big('298.58'),
currency: 'USD',
dataSource: 'YAHOO',
dateOfFirstActivity: '2021-09-16',
dividend: new Big('0.62'),
dividendInBaseCurrency: new Big('0.62'),
fee: new Big('19'),
firstBuyDate: '2021-09-16',
grossPerformance: new Big('33.25'),
grossPerformancePercentage: new Big('0.11136043941322258691'),
grossPerformancePercentageWithCurrencyEffect: new Big(
@ -159,8 +162,7 @@ describe('PortfolioCalculator', () => {
},
quantity: new Big('1'),
symbol: 'MSFT',
tags: [],
transactionCount: 2
tags: []
}
],
totalFeesWithCurrencyEffect: new Big('19'),

7
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-no-orders.spec.ts

@ -93,6 +93,11 @@ describe('PortfolioCalculator', () => {
groupBy: 'month'
});
const investmentsByYear = portfolioCalculator.getInvestmentsByGroup({
data: portfolioSnapshot.historicalData,
groupBy: 'year'
});
expect(portfolioSnapshot).toMatchObject({
currentValueInBaseCurrency: new Big(0),
hasErrors: false,
@ -108,6 +113,8 @@ describe('PortfolioCalculator', () => {
expect(investments).toEqual([]);
expect(investmentsByMonth).toEqual([]);
expect(investmentsByYear).toEqual([]);
});
});
});

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

@ -101,6 +101,7 @@ describe('PortfolioCalculator', () => {
...activity,
date: parseDate(activity.date),
feeInAssetProfileCurrency: activity.fee,
feeInBaseCurrency: activity.fee,
SymbolProfile: {
...symbolProfileDummyData,
currency: activity.currency,
@ -128,20 +129,26 @@ describe('PortfolioCalculator', () => {
groupBy: 'month'
});
const investmentsByYear = portfolioCalculator.getInvestmentsByGroup({
data: portfolioSnapshot.historicalData,
groupBy: 'year'
});
expect(portfolioSnapshot).toMatchObject({
currentValueInBaseCurrency: new Big('87.8'),
errors: [],
hasErrors: false,
positions: [
{
activitiesCount: 2,
averagePrice: new Big('75.80'),
currency: 'CHF',
dataSource: 'YAHOO',
dateOfFirstActivity: '2022-03-07',
dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'),
fee: new Big('4.25'),
feeInBaseCurrency: new Big('4.25'),
firstBuyDate: '2022-03-07',
grossPerformance: new Big('21.93'),
grossPerformancePercentage: new Big('0.15113417083448194384'),
grossPerformancePercentageWithCurrencyEffect: new Big(
@ -167,7 +174,6 @@ describe('PortfolioCalculator', () => {
timeWeightedInvestmentWithCurrencyEffect: new Big(
'145.10285714285714285714'
),
transactionCount: 2,
valueInBaseCurrency: new Big('87.8')
}
],
@ -197,6 +203,10 @@ describe('PortfolioCalculator', () => {
{ date: '2022-03-01', investment: 151.6 },
{ date: '2022-04-01', investment: -75.8 }
]);
expect(investmentsByYear).toEqual([
{ date: '2022-01-01', investment: 75.8 }
]);
});
});
});

14
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell.spec.ts

@ -101,6 +101,7 @@ describe('PortfolioCalculator', () => {
...activity,
date: parseDate(activity.date),
feeInAssetProfileCurrency: activity.fee,
feeInBaseCurrency: activity.fee,
SymbolProfile: {
...symbolProfileDummyData,
currency: activity.currency,
@ -128,6 +129,11 @@ describe('PortfolioCalculator', () => {
groupBy: 'month'
});
const investmentsByYear = portfolioCalculator.getInvestmentsByGroup({
data: portfolioSnapshot.historicalData,
groupBy: 'year'
});
expect(portfolioSnapshot.historicalData[0]).toEqual({
date: '2022-03-06',
investmentValueWithCurrencyEffect: 0,
@ -187,14 +193,15 @@ describe('PortfolioCalculator', () => {
hasErrors: false,
positions: [
{
activitiesCount: 2,
averagePrice: new Big('0'),
currency: 'CHF',
dataSource: 'YAHOO',
dateOfFirstActivity: '2022-03-07',
dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'),
fee: new Big('0'),
feeInBaseCurrency: new Big('0'),
firstBuyDate: '2022-03-07',
grossPerformance: new Big('19.86'),
grossPerformancePercentage: new Big('0.13100263852242744063'),
grossPerformancePercentageWithCurrencyEffect: new Big(
@ -218,7 +225,6 @@ describe('PortfolioCalculator', () => {
tags: [],
timeWeightedInvestment: new Big('151.6'),
timeWeightedInvestmentWithCurrencyEffect: new Big('151.6'),
transactionCount: 2,
valueInBaseCurrency: new Big('0')
}
],
@ -248,6 +254,10 @@ describe('PortfolioCalculator', () => {
{ date: '2022-03-01', investment: 151.6 },
{ date: '2022-04-01', investment: -151.6 }
]);
expect(investmentsByYear).toEqual([
{ date: '2022-01-01', investment: 0 }
]);
});
});
});

7
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-valuable.spec.ts

@ -87,6 +87,7 @@ describe('PortfolioCalculator', () => {
...activityDummyData,
date: new Date('2022-01-01'),
feeInAssetProfileCurrency: 0,
feeInBaseCurrency: 0,
quantity: 1,
SymbolProfile: {
...symbolProfileDummyData,
@ -115,21 +116,22 @@ describe('PortfolioCalculator', () => {
hasErrors: false,
positions: [
{
activitiesCount: 1,
averagePrice: new Big('500000'),
currency: 'USD',
dataSource: 'MANUAL',
dateOfFirstActivity: '2022-01-01',
dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'),
fee: new Big('0'),
feeInBaseCurrency: new Big('0'),
firstBuyDate: '2022-01-01',
grossPerformance: new Big('0'),
grossPerformancePercentage: new Big('0'),
grossPerformancePercentageWithCurrencyEffect: new Big('0'),
grossPerformanceWithCurrencyEffect: new Big('0'),
investment: new Big('500000'),
investmentWithCurrencyEffect: new Big('500000'),
marketPrice: null,
marketPrice: 1,
marketPriceInBaseCurrency: 500000,
netPerformance: new Big('0'),
netPerformancePercentage: new Big('0'),
@ -144,7 +146,6 @@ describe('PortfolioCalculator', () => {
tags: [],
timeWeightedInvestment: new Big('500000'),
timeWeightedInvestmentWithCurrencyEffect: new Big('500000'),
transactionCount: 1,
valueInBaseCurrency: new Big('500000')
}
],

45
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts

@ -13,7 +13,14 @@ import { PerformanceCalculationType } from '@ghostfolio/common/types/performance
import { Logger } from '@nestjs/common';
import { Big } from 'big.js';
import { addMilliseconds, differenceInDays, format, isBefore } from 'date-fns';
import {
addMilliseconds,
differenceInDays,
eachYearOfInterval,
format,
isBefore,
isThisYear
} from 'date-fns';
import { cloneDeep, sortBy } from 'lodash';
export class RoaiPortfolioCalculator extends PortfolioCalculator {
@ -34,7 +41,11 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
let totalTimeWeightedInvestment = new Big(0);
let totalTimeWeightedInvestmentWithCurrencyEffect = new Big(0);
for (const currentPosition of positions) {
for (const currentPosition of positions.filter(
({ includeInTotalAssetValue }) => {
return includeInTotalAssetValue;
}
)) {
if (currentPosition.feeInBaseCurrency) {
totalFeesWithCurrencyEffect = totalFeesWithCurrencyEffect.plus(
currentPosition.feeInBaseCurrency
@ -188,6 +199,8 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
})
);
const isCash = orders[0]?.SymbolProfile?.assetSubClass === 'CASH';
if (orders.length <= 0) {
return {
currentValues: {},
@ -244,6 +257,8 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
// For BUY / SELL activities with a MANUAL data source where no historical market price is available,
// the calculation should fall back to using the activity’s unit price.
unitPriceAtEndDate = latestActivity.unitPrice;
} else if (isCash) {
unitPriceAtEndDate = new Big(1);
}
if (
@ -295,7 +310,8 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
quantity: new Big(0),
SymbolProfile: {
dataSource,
symbol
symbol,
assetSubClass: isCash ? 'CASH' : undefined
},
type: 'BUY',
unitPrice: unitPriceAtStartDate
@ -308,7 +324,8 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
itemType: 'end',
SymbolProfile: {
dataSource,
symbol
symbol,
assetSubClass: isCash ? 'CASH' : undefined
},
quantity: new Big(0),
type: 'BUY',
@ -348,7 +365,8 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
quantity: new Big(0),
SymbolProfile: {
dataSource,
symbol
symbol,
assetSubClass: isCash ? 'CASH' : undefined
},
type: 'BUY',
unitPrice: marketSymbolMap[dateString]?.[symbol] ?? lastUnitPrice,
@ -826,15 +844,14 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
'max',
'mtd',
'wtd',
'ytd'
// TODO:
// ...eachYearOfInterval({ end, start })
// .filter((date) => {
// return !isThisYear(date);
// })
// .map((date) => {
// return format(date, 'yyyy');
// })
'ytd',
...eachYearOfInterval({ end, start })
.filter((date) => {
return !isThisYear(date);
})
.map((date) => {
return format(date, 'yyyy');
})
] as DateRange[]) {
const dateInterval = getIntervalFromDateRange(dateRange);
const endDate = dateInterval.endDate;

1
apps/api/src/app/portfolio/interfaces/portfolio-order-item.interface.ts

@ -3,7 +3,6 @@ import { Big } from 'big.js';
import { PortfolioOrder } from './portfolio-order.interface';
export interface PortfolioOrderItem extends PortfolioOrder {
feeInBaseCurrency?: Big;
feeInBaseCurrencyWithCurrencyEffect?: Big;
itemType?: 'end' | 'start';
unitPriceFromMarketData?: Big;

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

@ -3,10 +3,11 @@ import { Activity } from '@ghostfolio/common/interfaces';
export interface PortfolioOrder extends Pick<Activity, 'tags' | 'type'> {
date: string;
fee: Big;
feeInBaseCurrency: Big;
quantity: Big;
SymbolProfile: Pick<
Activity['SymbolProfile'],
'currency' | 'dataSource' | 'name' | 'symbol' | 'userId'
'assetSubClass' | 'currency' | 'dataSource' | 'name' | 'symbol' | 'userId'
>;
unitPrice: Big;
}

8
apps/api/src/app/portfolio/interfaces/transaction-point-symbol.interface.ts

@ -1,18 +1,20 @@
import { DataSource, Tag } from '@prisma/client';
import { AssetSubClass, DataSource, Tag } from '@prisma/client';
import { Big } from 'big.js';
export interface TransactionPointSymbol {
activitiesCount: number;
assetSubClass: AssetSubClass;
averagePrice: Big;
currency: string;
dataSource: DataSource;
dateOfFirstActivity: string;
dividend: Big;
fee: Big;
firstBuyDate: string;
feeInBaseCurrency: Big;
includeInHoldings: boolean;
investment: Big;
quantity: Big;
skipErrors: boolean;
symbol: string;
tags?: Tag[];
transactionCount: number;
}

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

@ -195,11 +195,13 @@ export class PortfolioController {
'excludedAccountsAndActivities',
'fees',
'filteredValueInBaseCurrency',
'fireWealth',
'grossPerformance',
'grossPerformanceWithCurrencyEffect',
'interestInBaseCurrency',
'items',
'liabilities',
'liabilitiesInBaseCurrency',
'netPerformance',
'netPerformanceWithCurrencyEffect',
'totalBuy',
@ -329,7 +331,7 @@ export class PortfolioController {
types: ['DIVIDEND']
});
let dividends = await this.portfolioService.getDividends({
let dividends = this.portfolioService.getDividends({
activities,
groupBy
});

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

@ -179,9 +179,9 @@ export class PortfolioService {
return Promise.all(
accounts.map(async (account) => {
let activitiesCount = 0;
let dividendInBaseCurrency = 0;
let interestInBaseCurrency = 0;
let transactionCount = 0;
for (const {
currency,
@ -214,7 +214,7 @@ export class PortfolioService {
}
if (!isDraft) {
transactionCount += 1;
activitiesCount += 1;
}
}
@ -223,9 +223,9 @@ export class PortfolioService {
const result = {
...account,
activitiesCount,
dividendInBaseCurrency,
interestInBaseCurrency,
transactionCount,
valueInBaseCurrency,
allocationInPercentage: 0,
balanceInBaseCurrency: this.exchangeRateDataService.toCurrency(
@ -262,6 +262,8 @@ export class PortfolioService {
withExcludedAccounts
});
let activitiesCount = 0;
const searchQuery = filters.find(({ type }) => {
return type === 'SEARCH_QUERY';
})?.id;
@ -281,9 +283,10 @@ export class PortfolioService {
let totalDividendInBaseCurrency = new Big(0);
let totalInterestInBaseCurrency = new Big(0);
let totalValueInBaseCurrency = new Big(0);
let transactionCount = 0;
for (const account of accounts) {
activitiesCount += account.activitiesCount;
totalBalanceInBaseCurrency = totalBalanceInBaseCurrency.plus(
account.balanceInBaseCurrency
);
@ -296,7 +299,6 @@ export class PortfolioService {
totalValueInBaseCurrency = totalValueInBaseCurrency.plus(
account.valueInBaseCurrency
);
transactionCount += account.transactionCount;
}
for (const account of accounts) {
@ -310,7 +312,7 @@ export class PortfolioService {
return {
accounts,
transactionCount,
activitiesCount,
totalBalanceInBaseCurrency: totalBalanceInBaseCurrency.toNumber(),
totalDividendInBaseCurrency: totalDividendInBaseCurrency.toNumber(),
totalInterestInBaseCurrency: totalInterestInBaseCurrency.toNumber(),
@ -318,13 +320,13 @@ export class PortfolioService {
};
}
public async getDividends({
public getDividends({
activities,
groupBy
}: {
activities: Activity[];
groupBy?: GroupBy;
}): Promise<InvestmentItem[]> {
}): InvestmentItem[] {
let dividends = activities.map(({ currency, date, value }) => {
return {
date: format(date, DATE_FORMAT),
@ -522,10 +524,6 @@ export class PortfolioService {
return type === 'ACCOUNT';
}) ?? false;
const isFilteredByCash = filters?.some(({ id, type }) => {
return id === AssetClass.LIQUIDITY && type === 'ASSET_CLASS';
});
const isFilteredByClosedHoldings =
filters?.some(({ id, type }) => {
return id === 'CLOSED' && type === 'HOLDING_TYPE';
@ -557,6 +555,9 @@ export class PortfolioService {
assetProfileIdentifiers
);
const cashSymbolProfiles = this.getCashSymbolProfiles(cashDetails);
symbolProfiles.push(...cashSymbolProfiles);
const symbolProfileMap: { [symbol: string]: EnhancedSymbolProfile } = {};
for (const symbolProfile of symbolProfiles) {
symbolProfileMap[symbolProfile.symbol] = symbolProfile;
@ -568,9 +569,10 @@ export class PortfolioService {
}
for (const {
activitiesCount,
currency,
dateOfFirstActivity,
dividend,
firstBuyDate,
grossPerformance,
grossPerformanceWithCurrencyEffect,
grossPerformancePercentage,
@ -584,7 +586,6 @@ export class PortfolioService {
quantity,
symbol,
tags,
transactionCount,
valueInBaseCurrency
} of positions) {
if (isFilteredByClosedHoldings === true) {
@ -611,13 +612,13 @@ export class PortfolioService {
}
holdings[symbol] = {
activitiesCount,
currency,
markets,
marketsAdvanced,
marketPrice,
symbol,
tags,
transactionCount,
allocationInPercentage: filteredValueInBaseCurrency.eq(0)
? 0
: valueInBaseCurrency.div(filteredValueInBaseCurrency).toNumber(),
@ -625,7 +626,7 @@ export class PortfolioService {
assetSubClass: assetProfile.assetSubClass,
countries: assetProfile.countries,
dataSource: assetProfile.dataSource,
dateOfFirstActivity: parseDate(firstBuyDate),
dateOfFirstActivity: parseDate(dateOfFirstActivity),
dividend: dividend?.toNumber() ?? 0,
grossPerformance: grossPerformance?.toNumber() ?? 0,
grossPerformancePercent: grossPerformancePercentage?.toNumber() ?? 0,
@ -661,18 +662,6 @@ export class PortfolioService {
};
}
if (filters?.length === 0 || isFilteredByAccount || isFilteredByCash) {
const cashPositions = this.getCashPositions({
cashDetails,
userCurrency,
value: filteredValueInBaseCurrency
});
for (const symbol of Object.keys(cashPositions)) {
holdings[symbol] = cashPositions[symbol];
}
}
const { accounts, platforms } = await this.getValueOfAccountsAndPlatforms({
activities,
filters,
@ -802,11 +791,12 @@ export class PortfolioService {
}
const {
activitiesCount,
averagePrice,
currency,
dateOfFirstActivity,
dividendInBaseCurrency,
fee,
firstBuyDate,
feeInBaseCurrency,
grossPerformance,
grossPerformancePercentage,
grossPerformancePercentageWithCurrencyEffect,
@ -820,8 +810,7 @@ export class PortfolioService {
quantity,
tags,
timeWeightedInvestment,
timeWeightedInvestmentWithCurrencyEffect,
transactionCount
timeWeightedInvestmentWithCurrencyEffect
} = holding;
const activitiesOfHolding = activities.filter(({ SymbolProfile }) => {
@ -832,7 +821,10 @@ export class PortfolioService {
});
const dividendYieldPercent = getAnnualizedPerformancePercent({
daysInMarket: differenceInDays(new Date(), parseDate(firstBuyDate)),
daysInMarket: differenceInDays(
new Date(),
parseDate(dateOfFirstActivity)
),
netPerformancePercentage: timeWeightedInvestment.eq(0)
? new Big(0)
: dividendInBaseCurrency.div(timeWeightedInvestment)
@ -840,7 +832,10 @@ export class PortfolioService {
const dividendYieldPercentWithCurrencyEffect =
getAnnualizedPerformancePercent({
daysInMarket: differenceInDays(new Date(), parseDate(firstBuyDate)),
daysInMarket: differenceInDays(
new Date(),
parseDate(dateOfFirstActivity)
),
netPerformancePercentage: timeWeightedInvestmentWithCurrencyEffect.eq(0)
? new Big(0)
: dividendInBaseCurrency.div(timeWeightedInvestmentWithCurrencyEffect)
@ -849,7 +844,7 @@ export class PortfolioService {
const historicalData = await this.dataProviderService.getHistorical(
[{ dataSource, symbol }],
'day',
parseISO(firstBuyDate),
parseISO(dateOfFirstActivity),
new Date()
);
@ -914,7 +909,7 @@ export class PortfolioService {
// Add historical entry for buy date, if no historical data available
historicalDataArray.push({
averagePrice: activitiesOfHolding[0].unitPriceInAssetProfileCurrency,
date: firstBuyDate,
date: dateOfFirstActivity,
marketPrice: activitiesOfHolding[0].unitPriceInAssetProfileCurrency,
quantity: activitiesOfHolding[0].quantity
});
@ -927,25 +922,20 @@ export class PortfolioService {
);
return {
firstBuyDate,
activitiesCount,
dateOfFirstActivity,
marketPrice,
marketPriceMax,
marketPriceMin,
SymbolProfile,
tags,
activities: activitiesOfHolding,
activitiesCount: transactionCount,
averagePrice: averagePrice.toNumber(),
dataProviderInfo: portfolioCalculator.getDataProviderInfos()?.[0],
dividendInBaseCurrency: dividendInBaseCurrency.toNumber(),
dividendYieldPercent: dividendYieldPercent.toNumber(),
dividendYieldPercentWithCurrencyEffect:
dividendYieldPercentWithCurrencyEffect.toNumber(),
feeInBaseCurrency: this.exchangeRateDataService.toCurrency(
fee.toNumber(),
SymbolProfile.currency,
userCurrency
),
feeInBaseCurrency: feeInBaseCurrency.toNumber(),
grossPerformance: grossPerformance?.toNumber(),
grossPerformancePercent: grossPerformancePercentage?.toNumber(),
grossPerformancePercentWithCurrencyEffect:
@ -1548,6 +1538,37 @@ export class PortfolioService {
return cashPositions;
}
private getCashSymbolProfiles(cashDetails: CashDetails) {
const cashSymbols = [
...new Set(cashDetails.accounts.map(({ currency }) => currency))
];
return cashSymbols.map<EnhancedSymbolProfile>((currency) => {
const account = cashDetails.accounts.find(
({ currency: accountCurrency }) => {
return accountCurrency === currency;
}
);
return {
currency,
activitiesCount: 0,
assetClass: AssetClass.LIQUIDITY,
assetSubClass: AssetSubClass.CASH,
countries: [],
createdAt: account.createdAt,
dataSource: DataSource.MANUAL,
holdings: [],
id: currency,
isActive: true,
name: currency,
sectors: [],
symbol: currency,
updatedAt: account.updatedAt
};
});
}
private getDividendsByGroup({
dividends,
groupBy
@ -1644,6 +1665,7 @@ export class PortfolioService {
}): PortfolioPosition {
return {
currency,
activitiesCount: 0,
allocationInPercentage: 0,
assetClass: AssetClass.LIQUIDITY,
assetSubClass: AssetSubClass.CASH,
@ -1667,7 +1689,6 @@ export class PortfolioService {
sectors: [],
symbol: currency,
tags: [],
transactionCount: 0,
valueInBaseCurrency: balance
};
}
@ -1854,18 +1875,17 @@ export class PortfolioService {
netPerformanceWithCurrencyEffect
} = performance;
const dividendInBaseCurrency =
await portfolioCalculator.getDividendInBaseCurrency();
const totalEmergencyFund = this.getTotalEmergencyFund({
emergencyFundHoldingsValueInBaseCurrency,
userSettings: user.settings?.settings as UserSettings
});
const fees = await portfolioCalculator.getFeesInBaseCurrency();
const dateOfFirstActivity = portfolioCalculator.getStartDate();
const firstOrderDate = portfolioCalculator.getStartDate();
const dividendInBaseCurrency =
await portfolioCalculator.getDividendInBaseCurrency();
const fees = await portfolioCalculator.getFeesInBaseCurrency();
const interest = await portfolioCalculator.getInterestInBaseCurrency();
const liabilities =
@ -1923,7 +1943,7 @@ export class PortfolioService {
.minus(liabilities)
.toNumber();
const daysInMarket = differenceInDays(new Date(), firstOrderDate);
const daysInMarket = differenceInDays(new Date(), dateOfFirstActivity);
const annualizedPerformancePercent = getAnnualizedPerformancePercent({
daysInMarket,
@ -1942,6 +1962,7 @@ export class PortfolioService {
annualizedPerformancePercent,
annualizedPerformancePercentWithCurrencyEffect,
cash,
dateOfFirstActivity,
excludedAccountsAndActivities,
netPerformance,
netPerformancePercentage,
@ -1954,7 +1975,6 @@ export class PortfolioService {
}).length,
committedFunds: committedFunds.toNumber(),
currentValueInBaseCurrency: currentValueInBaseCurrency.toNumber(),
dateOfFirstActivity: firstOrderDate,
dividendInBaseCurrency: dividendInBaseCurrency.toNumber(),
emergencyFund: {
assets: emergencyFundHoldingsValueInBaseCurrency,
@ -2158,7 +2178,7 @@ export class PortfolioService {
accounts[account?.id || UNKNOWN_KEY] = {
balance: 0,
currency: account?.currency,
name: account.name,
name: account?.name,
valueInBaseCurrency: currentValueOfSymbolInBaseCurrency
};
}
@ -2172,7 +2192,7 @@ export class PortfolioService {
platforms[account?.platformId || UNKNOWN_KEY] = {
balance: 0,
currency: account?.currency,
name: account.platform?.name,
name: account?.platform?.name,
valueInBaseCurrency: currentValueOfSymbolInBaseCurrency
};
}

4
apps/api/src/app/subscription/subscription.service.ts

@ -35,7 +35,7 @@ export class SubscriptionService {
this.stripe = new Stripe(
this.configurationService.get('STRIPE_SECRET_KEY'),
{
apiVersion: '2025-08-27.basil'
apiVersion: '2026-01-28.clover'
}
);
}
@ -100,7 +100,7 @@ export class SubscriptionService {
);
return {
sessionId: session.id
sessionUrl: session.url
};
}

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

@ -1,8 +1,11 @@
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 { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
import {
DeleteOwnUserDto,
UpdateOwnAccessTokenDto,
@ -28,7 +31,8 @@ import {
Param,
Post,
Put,
UseGuards
UseGuards,
UseInterceptors
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { JwtService } from '@nestjs/jwt';
@ -43,6 +47,7 @@ import { UserService } from './user.service';
export class UserController {
public constructor(
private readonly configurationService: ConfigurationService,
private readonly impersonationService: ImpersonationService,
private readonly jwtService: JwtService,
private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService,
@ -107,13 +112,19 @@ export class UserController {
@Get()
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(RedactValuesInResponseInterceptor)
public async getUser(
@Headers('accept-language') acceptLanguage: string
@Headers('accept-language') acceptLanguage: string,
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string
): Promise<User> {
return this.userService.getUser(
this.request.user,
acceptLanguage?.split(',')?.[0]
);
const impersonationUserId =
await this.impersonationService.validateImpersonationId(impersonationId);
return this.userService.getUser({
impersonationUserId,
locale: acceptLanguage?.split(',')?.[0],
user: this.request.user
});
}
@Post()

4
apps/api/src/app/user/user.module.ts

@ -1,7 +1,9 @@
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
import { RedactValuesInResponseModule } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { I18nModule } from '@ghostfolio/api/services/i18n/i18n.module';
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { TagModule } from '@ghostfolio/api/services/tag/tag.module';
@ -18,6 +20,7 @@ import { UserService } from './user.service';
imports: [
ConfigurationModule,
I18nModule,
ImpersonationModule,
JwtModule.register({
secret: process.env.JWT_SECRET_KEY,
signOptions: { expiresIn: '30 days' }
@ -25,6 +28,7 @@ import { UserService } from './user.service';
OrderModule,
PrismaModule,
PropertyModule,
RedactValuesInResponseModule,
SubscriptionModule,
TagModule
],

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

@ -30,7 +30,7 @@ import {
PROPERTY_IS_READ_ONLY_MODE,
PROPERTY_SYSTEM_MESSAGE,
TAG_ID_EXCLUDE_FROM_ANALYSIS,
locale
locale as defaultLocale
} from '@ghostfolio/common/config';
import {
User as IUser,
@ -49,7 +49,7 @@ import { Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { Prisma, Role, User } from '@prisma/client';
import { differenceInDays, subDays } from 'date-fns';
import { sortBy, without } from 'lodash';
import { without } from 'lodash';
import { createHmac } from 'node:crypto';
@Injectable()
@ -96,10 +96,17 @@ export class UserService {
return { accessToken, hashedAccessToken };
}
public async getUser(
{ accounts, id, permissions, settings, subscription }: UserWithSettings,
aLocale = locale
): Promise<IUser> {
public async getUser({
impersonationUserId,
locale = defaultLocale,
user
}: {
impersonationUserId: string;
locale?: string;
user: UserWithSettings;
}): Promise<IUser> {
const { id, permissions, settings, subscription } = user;
const userData = await Promise.all([
this.prismaService.access.findMany({
include: {
@ -108,22 +115,31 @@ export class UserService {
orderBy: { alias: 'asc' },
where: { granteeUserId: id }
}),
this.prismaService.account.findMany({
orderBy: {
name: 'asc'
},
where: {
userId: impersonationUserId || user.id
}
}),
this.prismaService.order.count({
where: { userId: id }
where: { userId: impersonationUserId || user.id }
}),
this.prismaService.order.findFirst({
orderBy: {
date: 'asc'
},
where: { userId: id }
where: { userId: impersonationUserId || user.id }
}),
this.tagService.getTagsForUser(id)
this.tagService.getTagsForUser(impersonationUserId || user.id)
]);
const access = userData[0];
const activitiesCount = userData[1];
const firstActivity = userData[2];
let tags = userData[3].filter((tag) => {
const accounts = userData[1];
const activitiesCount = userData[2];
const firstActivity = userData[3];
let tags = userData[4].filter((tag) => {
return tag.id !== TAG_ID_EXCLUDE_FROM_ANALYSIS;
});
@ -146,7 +162,6 @@ export class UserService {
}
return {
accounts,
activitiesCount,
id,
permissions,
@ -160,10 +175,13 @@ export class UserService {
permissions: accessItem.permissions
};
}),
accounts: accounts.sort((a, b) => {
return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
}),
dateOfFirstActivity: firstActivity?.date ?? new Date(),
settings: {
...(settings.settings as UserSettings),
locale: (settings.settings as UserSettings)?.locale ?? aLocale
locale: (settings.settings as UserSettings)?.locale ?? locale
}
};
}
@ -516,9 +534,10 @@ export class UserService {
currentPermissions.push(permissions.impersonateAllUsers);
}
user.accounts = sortBy(user.accounts, ({ name }) => {
return name.toLowerCase();
user.accounts = user.accounts.sort((a, b) => {
return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
});
user.permissions = currentPermissions.sort();
return user;

501
apps/api/src/assets/cryptocurrencies/cryptocurrencies.json

File diff suppressed because it is too large

159
apps/api/src/helper/object.helper.spec.ts

@ -1,47 +1,61 @@
import { redactAttributes } from './object.helper';
import { DEFAULT_REDACTED_PATHS } from '@ghostfolio/common/config';
import { query, redactPaths } from './object.helper';
describe('query', () => {
it('should get market price from stock API response', () => {
const object = {
currency: 'USD',
market: {
previousClose: 273.04,
price: 271.86
},
symbol: 'AAPL'
};
const result = query({
object,
pathExpression: '$.market.price'
})[0];
expect(result).toBe(271.86);
});
});
describe('redactAttributes', () => {
it('should redact provided attributes', () => {
expect(redactAttributes({ object: {}, options: [] })).toStrictEqual({});
expect(redactPaths({ object: {}, paths: [] })).toStrictEqual({});
expect(
redactAttributes({ object: { value: 1000 }, options: [] })
).toStrictEqual({ value: 1000 });
expect(redactPaths({ object: { value: 1000 }, paths: [] })).toStrictEqual({
value: 1000
});
expect(
redactAttributes({
redactPaths({
object: { value: 1000 },
options: [{ attribute: 'value', valueMap: { '*': null } }]
paths: ['value']
})
).toStrictEqual({ value: null });
expect(
redactAttributes({
redactPaths({
object: { value: 'abc' },
options: [{ attribute: 'value', valueMap: { abc: 'xyz' } }]
paths: ['value'],
valueMap: { abc: 'xyz' }
})
).toStrictEqual({ value: 'xyz' });
expect(
redactAttributes({
redactPaths({
object: { data: [{ value: 'a' }, { value: 'b' }] },
options: [{ attribute: 'value', valueMap: { a: 1, b: 2 } }]
paths: ['data[*].value'],
valueMap: { a: 1, b: 2 }
})
).toStrictEqual({ data: [{ value: 1 }, { value: 2 }] });
expect(
redactAttributes({
object: { value1: 'a', value2: 'b' },
options: [
{ attribute: 'value1', valueMap: { a: 'x' } },
{ attribute: 'value2', valueMap: { '*': 'y' } }
]
})
).toStrictEqual({ value1: 'x', value2: 'y' });
console.time('redactAttributes execution time');
expect(
redactAttributes({
redactPaths({
object: {
accounts: {
'2e937c05-657c-4de9-8fb3-0813a2245f26': {
@ -97,6 +111,7 @@ describe('redactAttributes', () => {
hasError: false,
holdings: {
'AAPL.US': {
activitiesCount: 1,
currency: 'USD',
markets: {
UNKNOWN: 0,
@ -116,7 +131,6 @@ describe('redactAttributes', () => {
marketPrice: 220.79,
symbol: 'AAPL.US',
tags: [],
transactionCount: 1,
allocationInPercentage: 0.044900865255793135,
assetClass: 'EQUITY',
assetSubClass: 'STOCK',
@ -149,6 +163,7 @@ describe('redactAttributes', () => {
valueInPercentage: 0.0694356974830054
},
'ALV.DE': {
activitiesCount: 2,
currency: 'EUR',
markets: {
UNKNOWN: 0,
@ -168,7 +183,6 @@ describe('redactAttributes', () => {
marketPrice: 296.5,
symbol: 'ALV.DE',
tags: [],
transactionCount: 2,
allocationInPercentage: 0.026912563036519527,
assetClass: 'EQUITY',
assetSubClass: 'STOCK',
@ -196,6 +210,7 @@ describe('redactAttributes', () => {
valueInPercentage: 0.04161818652826481
},
AMZN: {
activitiesCount: 1,
currency: 'USD',
markets: {
UNKNOWN: 0,
@ -215,7 +230,6 @@ describe('redactAttributes', () => {
marketPrice: 187.99,
symbol: 'AMZN',
tags: [],
transactionCount: 1,
allocationInPercentage: 0.07646101417126275,
assetClass: 'EQUITY',
assetSubClass: 'STOCK',
@ -248,6 +262,7 @@ describe('redactAttributes', () => {
valueInPercentage: 0.11824101426541227
},
bitcoin: {
activitiesCount: 1,
currency: 'USD',
markets: {
UNKNOWN: 36985.0332704,
@ -273,7 +288,6 @@ describe('redactAttributes', () => {
userId: null
}
],
transactionCount: 1,
allocationInPercentage: 0.15042891393226654,
assetClass: 'LIQUIDITY',
assetSubClass: 'CRYPTOCURRENCY',
@ -299,6 +313,7 @@ describe('redactAttributes', () => {
valueInPercentage: 0.232626620912395
},
BONDORA_GO_AND_GROW: {
activitiesCount: 5,
currency: 'EUR',
markets: {
UNKNOWN: 2231.644722160232,
@ -324,7 +339,6 @@ describe('redactAttributes', () => {
userId: null
}
],
transactionCount: 5,
allocationInPercentage: 0.009076749759365777,
assetClass: 'FIXED_INCOME',
assetSubClass: 'BOND',
@ -350,6 +364,7 @@ describe('redactAttributes', () => {
valueInPercentage: 0.014036487867880205
},
FRANKLY95P: {
activitiesCount: 6,
currency: 'CHF',
markets: {
UNKNOWN: 0,
@ -375,7 +390,6 @@ describe('redactAttributes', () => {
userId: null
}
],
transactionCount: 6,
allocationInPercentage: 0.09095764645669335,
assetClass: 'EQUITY',
assetSubClass: 'ETF',
@ -474,6 +488,7 @@ describe('redactAttributes', () => {
valueInPercentage: 0.14065892911313693
},
MSFT: {
activitiesCount: 1,
currency: 'USD',
markets: {
UNKNOWN: 0,
@ -493,7 +508,6 @@ describe('redactAttributes', () => {
marketPrice: 428.02,
symbol: 'MSFT',
tags: [],
transactionCount: 1,
allocationInPercentage: 0.05222646409742627,
assetClass: 'EQUITY',
assetSubClass: 'STOCK',
@ -526,6 +540,7 @@ describe('redactAttributes', () => {
valueInPercentage: 0.08076416659271518
},
TSLA: {
activitiesCount: 1,
currency: 'USD',
markets: {
UNKNOWN: 0,
@ -545,7 +560,6 @@ describe('redactAttributes', () => {
marketPrice: 260.46,
symbol: 'TSLA',
tags: [],
transactionCount: 1,
allocationInPercentage: 0.1589050142378352,
assetClass: 'EQUITY',
assetSubClass: 'STOCK',
@ -578,6 +592,7 @@ describe('redactAttributes', () => {
valueInPercentage: 0.2457342510950259
},
VTI: {
activitiesCount: 5,
currency: 'USD',
markets: {
UNKNOWN: 0,
@ -597,7 +612,6 @@ describe('redactAttributes', () => {
marketPrice: 282.05,
symbol: 'VTI',
tags: [],
transactionCount: 5,
allocationInPercentage: 0.057358979326040366,
assetClass: 'EQUITY',
assetSubClass: 'ETF',
@ -750,6 +764,7 @@ describe('redactAttributes', () => {
valueInPercentage: 0.08870120238725339
},
'VWRL.SW': {
activitiesCount: 5,
currency: 'CHF',
markets: {
UNKNOWN: 0,
@ -769,7 +784,6 @@ describe('redactAttributes', () => {
marketPrice: 117.62,
symbol: 'VWRL.SW',
tags: [],
transactionCount: 5,
allocationInPercentage: 0.09386983901959013,
assetClass: 'EQUITY',
assetSubClass: 'ETF',
@ -1158,6 +1172,7 @@ describe('redactAttributes', () => {
valueInPercentage: 0.145162408515095
},
'XDWD.DE': {
activitiesCount: 1,
currency: 'EUR',
markets: {
UNKNOWN: 0,
@ -1177,7 +1192,6 @@ describe('redactAttributes', () => {
marketPrice: 105.72,
symbol: 'XDWD.DE',
tags: [],
transactionCount: 1,
allocationInPercentage: 0.03598477442100562,
assetClass: 'EQUITY',
assetSubClass: 'ETF',
@ -1436,6 +1450,7 @@ describe('redactAttributes', () => {
valueInPercentage: 0.055647656152211074
},
USD: {
activitiesCount: 0,
currency: 'USD',
allocationInPercentage: 0.20291717628620132,
assetClass: 'LIQUIDITY',
@ -1458,7 +1473,6 @@ describe('redactAttributes', () => {
sectors: [],
symbol: 'USD',
tags: [],
transactionCount: 0,
valueInBaseCurrency: 49890,
valueInPercentage: 0.3137956381563603
}
@ -1544,34 +1558,7 @@ describe('redactAttributes', () => {
currentNetWorth: null
}
},
options: [
'balance',
'balanceInBaseCurrency',
'comment',
'convertedBalance',
'dividendInBaseCurrency',
'fee',
'feeInBaseCurrency',
'grossPerformance',
'grossPerformanceWithCurrencyEffect',
'investment',
'netPerformance',
'netPerformanceWithCurrencyEffect',
'quantity',
'symbolMapping',
'totalBalanceInBaseCurrency',
'totalValueInBaseCurrency',
'unitPrice',
'value',
'valueInBaseCurrency'
].map((attribute) => {
return {
attribute,
valueMap: {
'*': null
}
};
})
paths: DEFAULT_REDACTED_PATHS
})
).toStrictEqual({
accounts: {
@ -1628,6 +1615,7 @@ describe('redactAttributes', () => {
hasError: false,
holdings: {
'AAPL.US': {
activitiesCount: 1,
currency: 'USD',
markets: {
UNKNOWN: 0,
@ -1647,7 +1635,6 @@ describe('redactAttributes', () => {
marketPrice: 220.79,
symbol: 'AAPL.US',
tags: [],
transactionCount: 1,
allocationInPercentage: 0.044900865255793135,
assetClass: 'EQUITY',
assetSubClass: 'STOCK',
@ -1661,7 +1648,7 @@ describe('redactAttributes', () => {
],
dataSource: 'EOD_HISTORICAL_DATA',
dateOfFirstActivity: '2021-11-30T23:00:00.000Z',
dividend: 0,
dividend: null,
grossPerformance: null,
grossPerformancePercent: 0.3183066634822068,
grossPerformancePercentWithCurrencyEffect: 0.3183066634822068,
@ -1680,6 +1667,7 @@ describe('redactAttributes', () => {
valueInPercentage: 0.0694356974830054
},
'ALV.DE': {
activitiesCount: 2,
currency: 'EUR',
markets: {
UNKNOWN: 0,
@ -1699,7 +1687,6 @@ describe('redactAttributes', () => {
marketPrice: 296.5,
symbol: 'ALV.DE',
tags: [],
transactionCount: 2,
allocationInPercentage: 0.026912563036519527,
assetClass: 'EQUITY',
assetSubClass: 'STOCK',
@ -1708,7 +1695,7 @@ describe('redactAttributes', () => {
],
dataSource: 'YAHOO',
dateOfFirstActivity: '2021-04-22T22:00:00.000Z',
dividend: 192,
dividend: null,
grossPerformance: null,
grossPerformancePercent: 0.3719230057375532,
grossPerformancePercentWithCurrencyEffect: 0.2650716044872953,
@ -1727,6 +1714,7 @@ describe('redactAttributes', () => {
valueInPercentage: 0.04161818652826481
},
AMZN: {
activitiesCount: 1,
currency: 'USD',
markets: {
UNKNOWN: 0,
@ -1746,7 +1734,6 @@ describe('redactAttributes', () => {
marketPrice: 187.99,
symbol: 'AMZN',
tags: [],
transactionCount: 1,
allocationInPercentage: 0.07646101417126275,
assetClass: 'EQUITY',
assetSubClass: 'STOCK',
@ -1760,7 +1747,7 @@ describe('redactAttributes', () => {
],
dataSource: 'YAHOO',
dateOfFirstActivity: '2018-09-30T22:00:00.000Z',
dividend: 0,
dividend: null,
grossPerformance: null,
grossPerformancePercent: 0.8594552890963852,
grossPerformancePercentWithCurrencyEffect: 0.8594552890963852,
@ -1779,6 +1766,7 @@ describe('redactAttributes', () => {
valueInPercentage: 0.11824101426541227
},
bitcoin: {
activitiesCount: 1,
currency: 'USD',
markets: {
UNKNOWN: 36985.0332704,
@ -1804,14 +1792,13 @@ describe('redactAttributes', () => {
userId: null
}
],
transactionCount: 1,
allocationInPercentage: 0.15042891393226654,
assetClass: 'LIQUIDITY',
assetSubClass: 'CRYPTOCURRENCY',
countries: [],
dataSource: 'COINGECKO',
dateOfFirstActivity: '2017-08-15T22:00:00.000Z',
dividend: 0,
dividend: null,
grossPerformance: null,
grossPerformancePercent: 17.4925166352,
grossPerformancePercentWithCurrencyEffect: 17.4925166352,
@ -1830,6 +1817,7 @@ describe('redactAttributes', () => {
valueInPercentage: 0.232626620912395
},
BONDORA_GO_AND_GROW: {
activitiesCount: 5,
currency: 'EUR',
markets: {
UNKNOWN: 2231.644722160232,
@ -1855,14 +1843,13 @@ describe('redactAttributes', () => {
userId: null
}
],
transactionCount: 5,
allocationInPercentage: 0.009076749759365777,
assetClass: 'FIXED_INCOME',
assetSubClass: 'BOND',
countries: [],
dataSource: 'MANUAL',
dateOfFirstActivity: '2021-01-31T23:00:00.000Z',
dividend: 11.45,
dividend: null,
grossPerformance: null,
grossPerformancePercent: 0,
grossPerformancePercentWithCurrencyEffect: -0.06153834320225245,
@ -1881,6 +1868,7 @@ describe('redactAttributes', () => {
valueInPercentage: 0.014036487867880205
},
FRANKLY95P: {
activitiesCount: 6,
currency: 'CHF',
markets: {
UNKNOWN: 0,
@ -1906,7 +1894,6 @@ describe('redactAttributes', () => {
userId: null
}
],
transactionCount: 6,
allocationInPercentage: 0.09095764645669335,
assetClass: 'EQUITY',
assetSubClass: 'ETF',
@ -1966,7 +1953,7 @@ describe('redactAttributes', () => {
],
dataSource: 'MANUAL',
dateOfFirstActivity: '2021-03-31T22:00:00.000Z',
dividend: 0,
dividend: null,
grossPerformance: null,
grossPerformancePercent: 0.27579517683678895,
grossPerformancePercentWithCurrencyEffect: 0.458553421589667,
@ -1985,6 +1972,7 @@ describe('redactAttributes', () => {
valueInPercentage: 0.14065892911313693
},
MSFT: {
activitiesCount: 1,
currency: 'USD',
markets: {
UNKNOWN: 0,
@ -2004,7 +1992,6 @@ describe('redactAttributes', () => {
marketPrice: 428.02,
symbol: 'MSFT',
tags: [],
transactionCount: 1,
allocationInPercentage: 0.05222646409742627,
assetClass: 'EQUITY',
assetSubClass: 'STOCK',
@ -2018,7 +2005,7 @@ describe('redactAttributes', () => {
],
dataSource: 'YAHOO',
dateOfFirstActivity: '2023-01-02T23:00:00.000Z',
dividend: 0,
dividend: null,
grossPerformance: null,
grossPerformancePercent: 0.7865431171216295,
grossPerformancePercentWithCurrencyEffect: 0.7865431171216295,
@ -2037,6 +2024,7 @@ describe('redactAttributes', () => {
valueInPercentage: 0.08076416659271518
},
TSLA: {
activitiesCount: 1,
currency: 'USD',
markets: {
UNKNOWN: 0,
@ -2056,7 +2044,6 @@ describe('redactAttributes', () => {
marketPrice: 260.46,
symbol: 'TSLA',
tags: [],
transactionCount: 1,
allocationInPercentage: 0.1589050142378352,
assetClass: 'EQUITY',
assetSubClass: 'STOCK',
@ -2070,7 +2057,7 @@ describe('redactAttributes', () => {
],
dataSource: 'YAHOO',
dateOfFirstActivity: '2017-01-02T23:00:00.000Z',
dividend: 0,
dividend: null,
grossPerformance: null,
grossPerformancePercent: 17.184314638161936,
grossPerformancePercentWithCurrencyEffect: 17.184314638161936,
@ -2089,6 +2076,7 @@ describe('redactAttributes', () => {
valueInPercentage: 0.2457342510950259
},
VTI: {
activitiesCount: 5,
currency: 'USD',
markets: {
UNKNOWN: 0,
@ -2108,7 +2096,6 @@ describe('redactAttributes', () => {
marketPrice: 282.05,
symbol: 'VTI',
tags: [],
transactionCount: 5,
allocationInPercentage: 0.057358979326040366,
assetClass: 'EQUITY',
assetSubClass: 'ETF',
@ -2152,7 +2139,7 @@ describe('redactAttributes', () => {
],
dataSource: 'YAHOO',
dateOfFirstActivity: '2019-02-28T23:00:00.000Z',
dividend: 0,
dividend: null,
grossPerformance: null,
grossPerformancePercent: 0.8832083851170418,
grossPerformancePercentWithCurrencyEffect: 0.8832083851170418,
@ -2261,6 +2248,7 @@ describe('redactAttributes', () => {
valueInPercentage: 0.08870120238725339
},
'VWRL.SW': {
activitiesCount: 5,
currency: 'CHF',
markets: {
UNKNOWN: 0,
@ -2280,7 +2268,6 @@ describe('redactAttributes', () => {
marketPrice: 117.62,
symbol: 'VWRL.SW',
tags: [],
transactionCount: 5,
allocationInPercentage: 0.09386983901959013,
assetClass: 'EQUITY',
assetSubClass: 'ETF',
@ -2547,7 +2534,7 @@ describe('redactAttributes', () => {
],
dataSource: 'YAHOO',
dateOfFirstActivity: '2018-02-28T23:00:00.000Z',
dividend: 0,
dividend: null,
grossPerformance: null,
grossPerformancePercent: 0.3683200415015591,
grossPerformancePercentWithCurrencyEffect: 0.5806366182968891,
@ -2661,6 +2648,7 @@ describe('redactAttributes', () => {
valueInPercentage: 0.145162408515095
},
'XDWD.DE': {
activitiesCount: 1,
currency: 'EUR',
markets: {
UNKNOWN: 0,
@ -2680,7 +2668,6 @@ describe('redactAttributes', () => {
marketPrice: 105.72,
symbol: 'XDWD.DE',
tags: [],
transactionCount: 1,
allocationInPercentage: 0.03598477442100562,
assetClass: 'EQUITY',
assetSubClass: 'ETF',
@ -2826,7 +2813,7 @@ describe('redactAttributes', () => {
],
dataSource: 'YAHOO',
dateOfFirstActivity: '2021-08-18T22:00:00.000Z',
dividend: 0,
dividend: null,
grossPerformance: null,
grossPerformancePercent: 0.3474381850624522,
grossPerformancePercentWithCurrencyEffect: 0.28744846894552306,
@ -2939,12 +2926,13 @@ describe('redactAttributes', () => {
valueInPercentage: 0.055647656152211074
},
USD: {
activitiesCount: 0,
currency: 'USD',
allocationInPercentage: 0.20291717628620132,
assetClass: 'LIQUIDITY',
assetSubClass: 'CASH',
countries: [],
dividend: 0,
dividend: null,
grossPerformance: null,
grossPerformancePercent: 0,
grossPerformancePercentWithCurrencyEffect: 0,
@ -2961,7 +2949,6 @@ describe('redactAttributes', () => {
sectors: [],
symbol: 'USD',
tags: [],
transactionCount: 0,
valueInBaseCurrency: null,
valueInPercentage: 0.3137956381563603
}

78
apps/api/src/helper/object.helper.ts

@ -1,5 +1,6 @@
import { Big } from 'big.js';
import { cloneDeep, isArray, isObject } from 'lodash';
import fastRedact from 'fast-redact';
import jsonpath from 'jsonpath';
import { cloneDeep, isObject } from 'lodash';
export function hasNotDefinedValuesInObject(aObject: Object): boolean {
for (const key in aObject) {
@ -31,60 +32,39 @@ export function nullifyValuesInObjects<T>(aObjects: T[], keys: string[]): T[] {
});
}
export function redactAttributes({
isFirstRun = true,
export function query({
object,
options
pathExpression
}: {
object: object;
pathExpression: string;
}) {
return jsonpath.query(object, pathExpression);
}
export function redactPaths({
object,
paths,
valueMap
}: {
isFirstRun?: boolean;
object: any;
options: { attribute: string; valueMap: { [key: string]: any } }[];
paths: fastRedact.RedactOptions['paths'];
valueMap?: { [key: string]: any };
}): any {
if (!object || !options?.length) {
return object;
}
// Create deep clone
const redactedObject = isFirstRun
? JSON.parse(JSON.stringify(object))
: object;
for (const option of options) {
if (redactedObject.hasOwnProperty(option.attribute)) {
if (option.valueMap['*'] || option.valueMap['*'] === null) {
redactedObject[option.attribute] = option.valueMap['*'];
} else if (option.valueMap[redactedObject[option.attribute]]) {
redactedObject[option.attribute] =
option.valueMap[redactedObject[option.attribute]];
}
const redact = fastRedact({
paths,
censor: (value) => {
if (valueMap) {
if (valueMap[value]) {
return valueMap[value];
} else {
// If the attribute is not present on the current object,
// check if it exists on any nested objects
for (const property in redactedObject) {
if (isArray(redactedObject[property])) {
redactedObject[property] = redactedObject[property].map(
(currentObject) => {
return redactAttributes({
options,
isFirstRun: false,
object: currentObject
});
}
);
} else if (
isObject(redactedObject[property]) &&
!(redactedObject[property] instanceof Big)
) {
// Recursively call the function on the nested object
redactedObject[property] = redactAttributes({
options,
isFirstRun: false,
object: redactedObject[property]
});
}
return value;
}
} else {
return null;
}
}
});
return redactedObject;
return JSON.parse(redact(object));
}

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

@ -1,5 +1,8 @@
import { redactAttributes } from '@ghostfolio/api/helper/object.helper';
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
import { redactPaths } from '@ghostfolio/api/helper/object.helper';
import {
DEFAULT_REDACTED_PATHS,
HEADER_KEY_IMPERSONATION
} from '@ghostfolio/common/config';
import {
hasReadRestrictedAccessPermission,
isRestrictedView
@ -39,40 +42,9 @@ export class RedactValuesInResponseInterceptor<T> implements NestInterceptor<
}) ||
isRestrictedView(user)
) {
data = redactAttributes({
data = redactPaths({
object: data,
options: [
'balance',
'balanceInBaseCurrency',
'comment',
'convertedBalance',
'dividendInBaseCurrency',
'fee',
'feeInBaseCurrency',
'grossPerformance',
'grossPerformanceWithCurrencyEffect',
'interestInBaseCurrency',
'investment',
'netPerformance',
'netPerformanceWithCurrencyEffect',
'quantity',
'symbolMapping',
'totalBalanceInBaseCurrency',
'totalDividendInBaseCurrency',
'totalInterestInBaseCurrency',
'totalValueInBaseCurrency',
'unitPrice',
'unitPriceInAssetProfileCurrency',
'value',
'valueInBaseCurrency'
].map((attribute) => {
return {
attribute,
valueMap: {
'*': null
}
};
})
paths: DEFAULT_REDACTED_PATHS
});
}

19
apps/api/src/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor.ts

@ -1,4 +1,4 @@
import { redactAttributes } from '@ghostfolio/api/helper/object.helper';
import { redactPaths } from '@ghostfolio/api/helper/object.helper';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { encodeDataSource } from '@ghostfolio/common/helper';
@ -58,13 +58,18 @@ export class TransformDataSourceInResponseInterceptor<
}
}
data = redactAttributes({
object: data,
options: [
{
data = redactPaths({
valueMap,
attribute: 'dataSource'
}
object: data,
paths: [
'activities[*].SymbolProfile.dataSource',
'benchmarks[*].dataSource',
'fearAndGreedIndex.CRYPTOCURRENCIES.dataSource',
'fearAndGreedIndex.STOCKS.dataSource',
'holdings[*].dataSource',
'items[*].dataSource',
'SymbolProfile.dataSource',
'watchlist[*].dataSource'
]
});
}

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

@ -102,7 +102,6 @@ export class ConfigurationService {
ROOT_URL: url({
default: environment.rootUrl
}),
STRIPE_PUBLIC_KEY: str({ default: '' }),
STRIPE_SECRET_KEY: str({ default: '' }),
TWITTER_ACCESS_TOKEN: str({ default: 'dummyAccessToken' }),
TWITTER_ACCESS_TOKEN_SECRET: str({ default: 'dummyAccessTokenSecret' }),

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

@ -18,7 +18,7 @@ import {
import { Injectable } from '@nestjs/common';
import { DataSource, SymbolProfile } from '@prisma/client';
import * as Alphavantage from 'alphavantage';
import Alphavantage from 'alphavantage';
import { format, isAfter, isBefore, parse } from 'date-fns';
import { AlphaVantageHistoricalResponse } from './interfaces/interfaces';

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

@ -135,10 +135,10 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
shortName,
symbol
}: {
longName: Price['longName'];
quoteType: Price['quoteType'];
shortName: Price['shortName'];
symbol: Price['symbol'];
longName?: Price['longName'];
quoteType?: Price['quoteType'];
shortName?: Price['shortName'];
symbol?: Price['symbol'];
}) {
let name = longName;
@ -206,17 +206,26 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
);
if (['ETF', 'MUTUALFUND'].includes(assetSubClass)) {
response.sectors = [];
for (const sectorWeighting of assetProfile.topHoldings
?.sectorWeightings ?? []) {
for (const [sector, weight] of Object.entries(sectorWeighting)) {
response.sectors.push({
response.holdings =
assetProfile.topHoldings?.holdings?.map(
({ holdingName, holdingPercent }) => {
return {
name: this.formatName({ longName: holdingName }),
weight: holdingPercent
};
}
) ?? [];
response.sectors = (
assetProfile.topHoldings?.sectorWeightings ?? []
).flatMap((sectorWeighting) => {
return Object.entries(sectorWeighting).map(([sector, weight]) => {
return {
name: this.parseSector(sector),
weight: weight as number
};
});
});
}
}
} else if (
assetSubClass === 'STOCK' &&
assetProfile.summaryProfile?.country

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

@ -1,3 +1,4 @@
import { query } from '@ghostfolio/api/helper/object.helper';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import {
DataProviderInterface,
@ -26,7 +27,6 @@ import { Injectable, Logger } from '@nestjs/common';
import { DataSource, SymbolProfile } from '@prisma/client';
import * as cheerio from 'cheerio';
import { addDays, format, isBefore } from 'date-fns';
import * as jsonpath from 'jsonpath';
@Injectable()
export class ManualService implements DataProviderInterface {
@ -286,9 +286,14 @@ export class ManualService implements DataProviderInterface {
let value: string;
if (response.headers.get('content-type')?.includes('application/json')) {
const data = await response.json();
const object = await response.json();
value = String(jsonpath.query(data, scraperConfiguration.selector)[0]);
value = String(
query({
object,
pathExpression: scraperConfiguration.selector
})[0]
);
} else {
const $ = cheerio.load(await response.text());

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

@ -1,5 +1,7 @@
export const ExchangeRateDataServiceMock = {
getExchangeRatesByCurrency: ({ targetCurrency }): Promise<any> => {
import { ExchangeRateDataService } from './exchange-rate-data.service';
export const ExchangeRateDataServiceMock: Partial<ExchangeRateDataService> = {
getExchangeRatesByCurrency: ({ targetCurrency }) => {
if (targetCurrency === 'CHF') {
return Promise.resolve({
CHFCHF: {
@ -14,7 +16,11 @@ export const ExchangeRateDataServiceMock = {
'2017-12-31': 0.9787,
'2018-01-01': 0.97373,
'2023-01-03': 0.9238,
'2023-07-10': 0.8854
'2023-07-10': 0.8854,
'2023-12-31': 0.85,
'2024-01-01': 0.86,
'2024-12-31': 0.9,
'2025-01-01': 0.91
}
});
} else if (targetCurrency === 'EUR') {

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

@ -26,6 +26,8 @@ import {
import { isNumber } from 'lodash';
import ms from 'ms';
import { ExchangeRatesByCurrency } from './interfaces/exchange-rate-data.interface';
@Injectable()
export class ExchangeRateDataService {
private currencies: string[] = [];
@ -59,7 +61,7 @@ export class ExchangeRateDataService {
endDate?: Date;
startDate: Date;
targetCurrency: string;
}) {
}): Promise<ExchangeRatesByCurrency> {
if (!startDate) {
return {};
}

5
apps/api/src/services/exchange-rate-data/interfaces/exchange-rate-data.interface.ts

@ -0,0 +1,5 @@
export interface ExchangeRatesByCurrency {
[currency: string]: {
[dateString: string]: number;
};
}

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

@ -52,7 +52,6 @@ export interface Environment extends CleanedEnvAccessors {
REDIS_PORT: number;
REQUEST_TIMEOUT: number;
ROOT_URL: string;
STRIPE_PUBLIC_KEY: string;
STRIPE_SECRET_KEY: string;
TWITTER_ACCESS_TOKEN: string;
TWITTER_ACCESS_TOKEN_SECRET: string;

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

@ -50,7 +50,8 @@ export class PortfolioSnapshotProcessor {
await this.orderService.getOrdersForPortfolioCalculator({
filters: job.data.filters,
userCurrency: job.data.userCurrency,
userId: job.data.userId
userId: job.data.userId,
withCash: true
});
const accountBalanceItems =

8
apps/api/src/services/tag/tag.service.ts

@ -75,12 +75,16 @@ export class TagService {
}
});
return tags.map(({ _count, id, name, userId }) => ({
return tags
.map(({ _count, id, name, userId }) => ({
id,
name,
userId,
isUsed: _count.activities > 0
}));
}))
.sort((a, b) => {
return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
});
}
public async getTagsWithActivityCount() {

1
apps/api/tsconfig.app.json

@ -4,6 +4,7 @@
"outDir": "../../dist/out-tsc",
"types": ["node"],
"emitDecoratorMetadata": true,
"moduleResolution": "node10",
"target": "es2021",
"module": "commonjs"
},

1
apps/api/tsconfig.spec.json

@ -3,6 +3,7 @@
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"module": "commonjs",
"moduleResolution": "node10",
"types": ["jest", "node"]
},
"include": ["**/*.spec.ts", "**/*.test.ts", "**/*.d.ts", "jest.config.ts"]

16
apps/client/project.json

@ -26,6 +26,10 @@
"baseHref": "/it/",
"translation": "apps/client/src/locales/messages.it.xlf"
},
"ko": {
"baseHref": "/ko/",
"translation": "apps/client/src/locales/messages.ko.xlf"
},
"nl": {
"baseHref": "/nl/",
"translation": "apps/client/src/locales/messages.nl.xlf"
@ -75,7 +79,6 @@
"ngswConfigPath": "apps/client/ngsw-config.json",
"optimization": false,
"polyfills": "apps/client/src/polyfills.ts",
"scripts": ["node_modules/marked/marked.min.js"],
"serviceWorker": true,
"sourceMap": true,
"styles": [
@ -111,6 +114,10 @@
"baseHref": "/it/",
"localize": ["it"]
},
"development-ko": {
"baseHref": "/ko/",
"localize": ["ko"]
},
"development-nl": {
"baseHref": "/nl/",
"localize": ["nl"]
@ -214,6 +221,9 @@
"sslKey": "apps/client/localhost.pem"
},
"configurations": {
"development-ca": {
"buildTarget": "client:build:development-ca"
},
"development-de": {
"buildTarget": "client:build:development-de"
},
@ -229,6 +239,9 @@
"development-it": {
"buildTarget": "client:build:development-it"
},
"development-ko": {
"buildTarget": "client:build:development-ko"
},
"development-nl": {
"buildTarget": "client:build:development-nl"
},
@ -265,6 +278,7 @@
"messages.es.xlf",
"messages.fr.xlf",
"messages.it.xlf",
"messages.ko.xlf",
"messages.nl.xlf",
"messages.pl.xlf",
"messages.pt.xlf",

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

@ -4,6 +4,7 @@ import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { internalRoutes, publicRoutes } from '@ghostfolio/common/routes/routes';
import { ColorScheme } from '@ghostfolio/common/types';
import { NotificationService } from '@ghostfolio/ui/notifications';
import { DataService } from '@ghostfolio/ui/services';
import {
ChangeDetectionStrategy,
@ -36,7 +37,6 @@ import { GfFooterComponent } from './components/footer/footer.component';
import { GfHeaderComponent } from './components/header/header.component';
import { GfHoldingDetailDialogComponent } from './components/holding-detail-dialog/holding-detail-dialog.component';
import { HoldingDetailDialogParams } from './components/holding-detail-dialog/interfaces/interfaces';
import { DataService } from './services/data.service';
import { ImpersonationStorageService } from './services/impersonation-storage.service';
import { TokenStorageService } from './services/token-storage.service';
import { UserService } from './services/user/user.service';

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

@ -4,7 +4,6 @@ import { publicRoutes } from '@ghostfolio/common/routes/routes';
import { NotificationService } from '@ghostfolio/ui/notifications';
import { Clipboard, ClipboardModule } from '@angular/cdk/clipboard';
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
@ -36,7 +35,6 @@ import ms from 'ms';
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
ClipboardModule,
CommonModule,
IonIcon,
MatButtonModule,
MatMenuModule,

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

@ -1,5 +1,4 @@
import { GfInvestmentChartComponent } from '@ghostfolio/client/components/investment-chart/investment-chart.component';
import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { NUMERICAL_PRECISION_THRESHOLD_6_FIGURES } from '@ghostfolio/common/config';
import { CreateAccountBalanceDto } from '@ghostfolio/common/dtos';
@ -19,6 +18,7 @@ import { GfActivitiesTableComponent } from '@ghostfolio/ui/activities-table';
import { GfDialogFooterComponent } from '@ghostfolio/ui/dialog-footer';
import { GfDialogHeaderComponent } from '@ghostfolio/ui/dialog-header';
import { GfHoldingsTableComponent } from '@ghostfolio/ui/holdings-table';
import { DataService } from '@ghostfolio/ui/services';
import { GfValueComponent } from '@ghostfolio/ui/value';
import { CommonModule } from '@angular/common';
@ -80,6 +80,7 @@ import { AccountDetailDialogParams } from './interfaces/interfaces';
export class GfAccountDetailDialogComponent implements OnDestroy, OnInit {
public accountBalances: AccountBalancesResponse['balances'];
public activities: OrderWithAccount[];
public activitiesCount: number;
public balance: number;
public balancePrecision = 2;
public currency: string;
@ -100,7 +101,6 @@ export class GfAccountDetailDialogComponent implements OnDestroy, OnInit {
public sortColumn = 'date';
public sortDirection: SortDirection = 'desc';
public totalItems: number;
public transactionCount: number;
public user: User;
public valueInBaseCurrency: number;
@ -215,16 +215,17 @@ export class GfAccountDetailDialogComponent implements OnDestroy, OnInit {
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(
({
activitiesCount,
balance,
currency,
dividendInBaseCurrency,
interestInBaseCurrency,
name,
platform,
transactionCount,
value,
valueInBaseCurrency
}) => {
this.activitiesCount = activitiesCount;
this.balance = balance;
if (
@ -270,7 +271,6 @@ export class GfAccountDetailDialogComponent implements OnDestroy, OnInit {
this.name = name;
this.platformName = platform?.name ?? '-';
this.transactionCount = transactionCount;
this.valueInBaseCurrency = valueInBaseCurrency;
this.changeDetectorRef.markForCheck();

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

@ -82,7 +82,7 @@
>
</div>
<div class="col-6 mb-3">
<gf-value i18n size="medium" [value]="transactionCount"
<gf-value i18n size="medium" [value]="activitiesCount"
>Activities</gf-value
>
</div>

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

@ -1,4 +1,3 @@
import { AdminService } from '@ghostfolio/client/services/admin.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import {
DATA_GATHERING_QUEUE_PRIORITY_HIGH,
@ -9,6 +8,7 @@ import {
import { getDateWithTimeFormatString } from '@ghostfolio/common/helper';
import { AdminJobs, User } from '@ghostfolio/common/interfaces';
import { NotificationService } from '@ghostfolio/ui/notifications';
import { AdminService } from '@ghostfolio/ui/services';
import { CommonModule } from '@angular/common';
import {

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

@ -1,5 +1,3 @@
import { AdminService } from '@ghostfolio/client/services/admin.service';
import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import {
DEFAULT_PAGE_SIZE,
@ -18,6 +16,7 @@ import { GfSymbolPipe } from '@ghostfolio/common/pipes';
import { GfActivitiesFilterComponent } from '@ghostfolio/ui/activities-filter';
import { translate } from '@ghostfolio/ui/i18n';
import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator';
import { AdminService, DataService } from '@ghostfolio/ui/services';
import { GfValueComponent } from '@ghostfolio/ui/value';
import { SelectionModel } from '@angular/cdk/collections';
@ -64,7 +63,7 @@ import {
import { DeviceDetectorService } from 'ngx-device-detector';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { Subject } from 'rxjs';
import { distinctUntilChanged, switchMap, takeUntil } from 'rxjs/operators';
import { distinctUntilChanged, takeUntil } from 'rxjs/operators';
import { AdminMarketDataService } from './admin-market-data.service';
import { GfAssetProfileDialogComponent } from './asset-profile-dialog/asset-profile-dialog.component';
@ -483,32 +482,27 @@ export class GfAdminMarketDataComponent
dialogRef
.afterClosed()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ dataSource, symbol } = {}) => {
if (dataSource && symbol) {
this.adminService
.addAssetProfile({ dataSource, symbol })
.pipe(
switchMap(() => {
this.isLoading = true;
this.changeDetectorRef.markForCheck();
.subscribe((result) => {
if (!result) {
this.router.navigate(['.'], { relativeTo: this.route });
return this.adminService.fetchAdminMarketData({
filters: this.activeFilters,
take: this.pageSize
});
}),
takeUntil(this.unsubscribeSubject)
)
.subscribe(({ marketData }) => {
this.dataSource = new MatTableDataSource(marketData);
this.dataSource.sort = this.sort;
this.isLoading = false;
return;
}
this.changeDetectorRef.markForCheck();
const { addAssetProfile, dataSource, symbol } = result;
if (addAssetProfile && dataSource && symbol) {
this.adminService
.addAssetProfile({ dataSource, symbol })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.loadData();
});
} else {
this.loadData();
}
this.router.navigate(['.'], { relativeTo: this.route });
this.onOpenAssetProfileDialog({ dataSource, symbol });
});
});
}

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

@ -1,4 +1,3 @@
import { AdminService } from '@ghostfolio/client/services/admin.service';
import { ghostfolioScraperApiSymbolPrefix } from '@ghostfolio/common/config';
import { ConfirmationDialogType } from '@ghostfolio/common/enums';
import {
@ -11,6 +10,7 @@ import {
AdminMarketDataItem
} from '@ghostfolio/common/interfaces';
import { NotificationService } from '@ghostfolio/ui/notifications';
import { AdminService } from '@ghostfolio/ui/services';
import { Injectable } from '@angular/core';
import { EMPTY, catchError, finalize, forkJoin } from 'rxjs';

16
apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.scss

@ -13,21 +13,5 @@
right: 1rem;
top: 0;
}
.mat-expansion-panel {
--mat-expansion-container-background-color: transparent;
::ng-deep {
.mat-expansion-panel-body {
padding: 0;
}
}
.mat-expansion-panel-header {
&:hover {
--mat-expansion-header-hover-state-layer-color: transparent;
}
}
}
}
}

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

@ -1,6 +1,4 @@
import { AdminMarketDataService } from '@ghostfolio/client/components/admin-market-data/admin-market-data.service';
import { AdminService } from '@ghostfolio/client/services/admin.service';
import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import {
ASSET_CLASS_MAPPING,
@ -28,11 +26,11 @@ import { translate } from '@ghostfolio/ui/i18n';
import { GfLineChartComponent } from '@ghostfolio/ui/line-chart';
import { NotificationService } from '@ghostfolio/ui/notifications';
import { GfPortfolioProportionChartComponent } from '@ghostfolio/ui/portfolio-proportion-chart';
import { AdminService, DataService } from '@ghostfolio/ui/services';
import { GfSymbolAutocompleteComponent } from '@ghostfolio/ui/symbol-autocomplete';
import { GfValueComponent } from '@ghostfolio/ui/value';
import { TextFieldModule } from '@angular/cdk/text-field';
import { CommonModule } from '@angular/common';
import { HttpErrorResponse } from '@angular/common/http';
import {
ChangeDetectionStrategy,
@ -42,8 +40,7 @@ import {
Inject,
OnDestroy,
OnInit,
ViewChild,
signal
ViewChild
} from '@angular/core';
import {
AbstractControl,
@ -64,7 +61,6 @@ import {
MatDialogModule,
MatDialogRef
} from '@angular/material/dialog';
import { MatExpansionModule } from '@angular/material/expansion';
import { MatInputModule } from '@angular/material/input';
import { MatMenuModule } from '@angular/material/menu';
import { MatSelectModule } from '@angular/material/select';
@ -83,6 +79,7 @@ import { format } from 'date-fns';
import { StatusCodes } from 'http-status-codes';
import { addIcons } from 'ionicons';
import {
codeSlashOutline,
createOutline,
ellipsisVertical,
readerOutline,
@ -98,7 +95,6 @@ import { AssetProfileDialogParams } from './interfaces/interfaces';
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'd-flex flex-column h-100' },
imports: [
CommonModule,
FormsModule,
GfCurrencySelectorComponent,
GfEntityLogoComponent,
@ -111,7 +107,6 @@ import { AssetProfileDialogParams } from './interfaces/interfaces';
MatButtonModule,
MatCheckboxModule,
MatDialogModule,
MatExpansionModule,
MatInputModule,
MatMenuModule,
MatSelectModule,
@ -238,8 +233,6 @@ export class GfAssetProfileDialogComponent implements OnDestroy, OnInit {
}
];
public scraperConfiguationIsExpanded = signal(false);
public sectors: {
[name: string]: { name: string; value: number };
};
@ -260,7 +253,13 @@ export class GfAssetProfileDialogComponent implements OnDestroy, OnInit {
private snackBar: MatSnackBar,
private userService: UserService
) {
addIcons({ createOutline, ellipsisVertical, readerOutline, serverOutline });
addIcons({
codeSlashOutline,
createOutline,
ellipsisVertical,
readerOutline,
serverOutline
});
}
public get canSaveAssetProfileIdentifier() {
@ -509,7 +508,19 @@ export class GfAssetProfileDialogComponent implements OnDestroy, OnInit {
if (!scraperConfiguration.selector || !scraperConfiguration.url) {
scraperConfiguration = undefined;
}
} catch {}
} catch (error) {
console.error($localize`Could not parse scraper configuration`, error);
this.snackBar.open(
'😞 ' + $localize`Could not parse scraper configuration`,
undefined,
{
duration: ms('3 seconds')
}
);
return;
}
try {
sectors = JSON.parse(this.assetProfileForm.get('sectors').value);
@ -543,7 +554,16 @@ export class GfAssetProfileDialogComponent implements OnDestroy, OnInit {
object: assetProfile
});
} catch (error) {
console.error(error);
console.error($localize`Could not validate form`, error);
this.snackBar.open(
'😞 ' + $localize`Could not validate form`,
undefined,
{
duration: ms('3 seconds')
}
);
return;
}
@ -555,8 +575,29 @@ export class GfAssetProfileDialogComponent implements OnDestroy, OnInit {
},
assetProfile
)
.subscribe(() => {
.subscribe({
next: () => {
this.snackBar.open(
'✅ ' + $localize`Asset profile has been saved`,
undefined,
{
duration: ms('3 seconds')
}
);
this.initialize();
},
error: (error) => {
console.error($localize`Could not save asset profile`, error);
this.snackBar.open(
'😞 ' + $localize`Could not save asset profile`,
undefined,
{
duration: ms('3 seconds')
}
);
}
});
}
@ -707,8 +748,8 @@ export class GfAssetProfileDialogComponent implements OnDestroy, OnInit {
}
public onTriggerSubmitAssetProfileForm() {
if (this.assetProfileForm) {
this.assetProfileFormElement.nativeElement.requestSubmit();
if (this.assetProfileForm.valid) {
this.onSubmitAssetProfileForm();
}
}

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

@ -586,6 +586,115 @@
</div>
</div>
</mat-tab>
@if (assetProfile?.dataSource === 'MANUAL') {
<mat-tab>
<ng-template mat-tab-label>
<ion-icon name="code-slash-outline" />
<div class="d-none d-sm-block ml-2" i18n>Scraper Configuration</div>
</ng-template>
<div class="container mt-3 p-0">
<div [formGroup]="assetProfileForm">
<div formGroupName="scraperConfiguration">
<div class="mt-3">
<mat-form-field
appearance="outline"
class="w-100 without-hint"
>
<mat-label i18n>Default Market Price</mat-label>
<input
formControlName="defaultMarketPrice"
matInput
type="number"
/>
</mat-form-field>
</div>
<div class="mt-3">
<mat-form-field
appearance="outline"
class="w-100 without-hint"
>
<mat-label i18n>HTTP Request Headers</mat-label>
<textarea
cdkTextareaAutosize
formControlName="headers"
matInput
type="text"
[matAutocomplete]="auto"
></textarea>
</mat-form-field>
</div>
<div class="mt-3">
<mat-form-field
appearance="outline"
class="w-100 without-hint"
>
<mat-label i18n>Locale</mat-label>
<input formControlName="locale" matInput type="text" />
</mat-form-field>
</div>
<div class="mt-3">
<mat-form-field
appearance="outline"
class="w-100 without-hint"
>
<mat-label i18n>Mode</mat-label>
<mat-select formControlName="mode">
@for (modeValue of modeValues; track modeValue) {
<mat-option [value]="modeValue.value">{{
modeValue.viewValue
}}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<div class="mt-3">
<mat-form-field
appearance="outline"
class="w-100 without-hint"
>
<mat-label>
<ng-container i18n>Selector</ng-container>*
</mat-label>
<textarea
cdkTextareaAutosize
formControlName="selector"
matInput
type="text"
></textarea>
</mat-form-field>
</div>
<div class="mt-3">
<mat-form-field
appearance="outline"
class="w-100 without-hint"
>
<mat-label>
<ng-container i18n>Url</ng-container>*
</mat-label>
<input formControlName="url" matInput type="text" />
</mat-form-field>
</div>
<div class="my-3 text-right">
<button
color="accent"
mat-flat-button
type="button"
[disabled]="
assetProfileForm.controls.scraperConfiguration.controls
.selector.value === '' ||
assetProfileForm.controls.scraperConfiguration.controls
.url.value === ''
"
(click)="onTestMarketData()"
>
<ng-container i18n>Test</ng-container>
</button>
</div>
</div>
</div>
</div>
</mat-tab>
}
</mat-tab-group>
</div>

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

@ -1,10 +1,9 @@
import { AdminService } from '@ghostfolio/client/services/admin.service';
import { DataService } from '@ghostfolio/client/services/data.service';
import {
DEFAULT_CURRENCY,
ghostfolioPrefix,
PROPERTY_CURRENCIES
} from '@ghostfolio/common/config';
import { AdminService, DataService } from '@ghostfolio/ui/services';
import { GfSymbolAutocompleteComponent } from '@ghostfolio/ui/symbol-autocomplete';
import {
@ -102,6 +101,7 @@ export class GfCreateAssetProfileDialogComponent implements OnDestroy, OnInit {
public onSubmit() {
if (this.mode === 'auto') {
this.dialogRef.close({
addAssetProfile: true,
dataSource:
this.createAssetProfileForm.get('searchSymbol').value.dataSource,
symbol: this.createAssetProfileForm.get('searchSymbol').value.symbol
@ -128,10 +128,15 @@ export class GfCreateAssetProfileDialogComponent implements OnDestroy, OnInit {
takeUntil(this.unsubscribeSubject)
)
.subscribe(() => {
this.dialogRef.close();
this.dialogRef.close({
addAssetProfile: false,
dataSource: this.dataSourceForExchangeRates,
symbol: `${DEFAULT_CURRENCY}${currency}`
});
});
} else if (this.mode === 'manual') {
this.dialogRef.close({
addAssetProfile: true,
dataSource: 'MANUAL',
symbol: `${this.ghostfolioPrefix}${this.createAssetProfileForm.get('addSymbol').value}`
});

9
apps/client/src/app/components/admin-overview/admin-overview.component.ts

@ -1,6 +1,4 @@
import { AdminService } from '@ghostfolio/client/services/admin.service';
import { CacheService } from '@ghostfolio/client/services/cache.service';
import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import {
PROPERTY_COUPONS,
@ -20,6 +18,7 @@ import {
} from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { NotificationService } from '@ghostfolio/ui/notifications';
import { AdminService, DataService } from '@ghostfolio/ui/services';
import { GfValueComponent } from '@ghostfolio/ui/value';
import { CommonModule } from '@angular/common';
@ -74,6 +73,7 @@ import { takeUntil } from 'rxjs/operators';
templateUrl: './admin-overview.html'
})
export class GfAdminOverviewComponent implements OnDestroy, OnInit {
public activitiesCount: number;
public couponDuration: StringValue = '14 days';
public coupons: Coupon[];
public hasPermissionForSubscription: boolean;
@ -84,7 +84,6 @@ export class GfAdminOverviewComponent implements OnDestroy, OnInit {
public isDataGatheringEnabled: boolean;
public permissions = permissions;
public systemMessage: SystemMessage;
public transactionCount: number;
public userCount: number;
public user: User;
public version: string;
@ -290,12 +289,12 @@ export class GfAdminOverviewComponent implements OnDestroy, OnInit {
this.adminService
.fetchAdminData()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ settings, transactionCount, userCount, version }) => {
.subscribe(({ activitiesCount, settings, userCount, version }) => {
this.activitiesCount = activitiesCount;
this.coupons = (settings[PROPERTY_COUPONS] as Coupon[]) ?? [];
this.isDataGatheringEnabled =
settings[PROPERTY_IS_DATA_GATHERING_ENABLED] === false ? false : true;
this.systemMessage = settings[PROPERTY_SYSTEM_MESSAGE] as SystemMessage;
this.transactionCount = transactionCount;
this.userCount = userCount;
this.version = version;

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

@ -20,11 +20,11 @@
<div class="w-50">
<gf-value
[locale]="user?.settings?.locale"
[value]="transactionCount"
[value]="activitiesCount"
/>
@if (transactionCount && userCount) {
@if (activitiesCount && userCount) {
<div>
{{ transactionCount / userCount | number: '1.2-2' }}
{{ activitiesCount / userCount | number: '1.2-2' }}
<span i18n>per User</span>
</div>
}

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

@ -1,10 +1,9 @@
import { AdminService } from '@ghostfolio/client/services/admin.service';
import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { CreatePlatformDto, UpdatePlatformDto } from '@ghostfolio/common/dtos';
import { ConfirmationDialogType } from '@ghostfolio/common/enums';
import { GfEntityLogoComponent } from '@ghostfolio/ui/entity-logo';
import { NotificationService } from '@ghostfolio/ui/notifications';
import { AdminService, DataService } from '@ghostfolio/ui/services';
import {
ChangeDetectionStrategy,

3
apps/client/src/app/components/admin-settings/admin-settings.component.ts

@ -1,8 +1,6 @@
import { GfAdminPlatformComponent } from '@ghostfolio/client/components/admin-platform/admin-platform.component';
import { GfAdminTagComponent } from '@ghostfolio/client/components/admin-tag/admin-tag.component';
import { GfDataProviderStatusComponent } from '@ghostfolio/client/components/data-provider-status/data-provider-status.component';
import { AdminService } from '@ghostfolio/client/services/admin.service';
import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { PROPERTY_API_KEY_GHOSTFOLIO } from '@ghostfolio/common/config';
import { ConfirmationDialogType } from '@ghostfolio/common/enums';
@ -16,6 +14,7 @@ import { publicRoutes } from '@ghostfolio/common/routes/routes';
import { GfEntityLogoComponent } from '@ghostfolio/ui/entity-logo';
import { NotificationService } from '@ghostfolio/ui/notifications';
import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator';
import { AdminService, DataService } from '@ghostfolio/ui/services';
import { GfValueComponent } from '@ghostfolio/ui/value';
import { CommonModule } from '@angular/common';

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

@ -1,8 +1,8 @@
import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { CreateTagDto, UpdateTagDto } from '@ghostfolio/common/dtos';
import { ConfirmationDialogType } from '@ghostfolio/common/enums';
import { NotificationService } from '@ghostfolio/ui/notifications';
import { DataService } from '@ghostfolio/ui/services';
import {
ChangeDetectionStrategy,

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

@ -1,7 +1,5 @@
import { UserDetailDialogParams } from '@ghostfolio/client/components/user-detail-dialog/interfaces/interfaces';
import { GfUserDetailDialogComponent } from '@ghostfolio/client/components/user-detail-dialog/user-detail-dialog.component';
import { AdminService } from '@ghostfolio/client/services/admin.service';
import { DataService } from '@ghostfolio/client/services/data.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
@ -21,6 +19,7 @@ import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { internalRoutes } from '@ghostfolio/common/routes/routes';
import { NotificationService } from '@ghostfolio/ui/notifications';
import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator';
import { AdminService, DataService } from '@ghostfolio/ui/services';
import { GfValueComponent } from '@ghostfolio/ui/value';
import { CommonModule } from '@angular/common';
@ -58,7 +57,7 @@ import {
import { DeviceDetectorService } from 'ngx-device-detector';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { switchMap, takeUntil, tap } from 'rxjs/operators';
@Component({
imports: [
@ -140,19 +139,10 @@ export class GfAdminUsersComponent implements OnDestroy, OnInit {
];
}
this.route.paramMap
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((params) => {
const userId = params.get('userId');
if (userId) {
this.openUserDetailDialog(userId);
}
});
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((state) => {
.pipe(
takeUntil(this.unsubscribeSubject),
tap((state) => {
if (state?.user) {
this.user = state.user;
@ -165,6 +155,15 @@ export class GfAdminUsersComponent implements OnDestroy, OnInit {
permissions.impersonateAllUsers
);
}
}),
switchMap(() => this.route.paramMap)
)
.subscribe((params) => {
const userId = params.get('userId');
if (userId) {
this.openUserDetailDialog(userId);
}
});
addIcons({
@ -209,10 +208,13 @@ export class GfAdminUsersComponent implements OnDestroy, OnInit {
.deleteUser(aId)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.fetchUsers();
this.router.navigate(['..'], { relativeTo: this.route });
});
},
confirmType: ConfirmationDialogType.Warn,
discardFn: () => {
this.router.navigate(['..'], { relativeTo: this.route });
},
title: $localize`Do you really want to delete this user?`
});
}
@ -294,6 +296,7 @@ export class GfAdminUsersComponent implements OnDestroy, OnInit {
>(GfUserDetailDialogComponent, {
autoFocus: false,
data: {
currentUserId: this.user?.id,
deviceType: this.deviceType,
hasPermissionForSubscription: this.hasPermissionForSubscription,
locale: this.user?.settings?.locale,
@ -306,10 +309,14 @@ export class GfAdminUsersComponent implements OnDestroy, OnInit {
dialogRef
.afterClosed()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
.subscribe((data) => {
if (data?.action === 'delete' && data?.userId) {
this.onDeleteUser(data.userId);
} else {
this.router.navigate(
internalRoutes.adminControl.subRoutes.users.routerLink
);
}
});
}
}

20
apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.ts

@ -1,6 +1,5 @@
import {
getTooltipOptions,
getTooltipPositionerMapTop,
getVerticalHoverLinePlugin
} from '@ghostfolio/common/chart-helper';
import { primaryColorRgb, secondaryColorRgb } from '@ghostfolio/common/config';
@ -15,12 +14,14 @@ import { LineChartItem, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { internalRoutes } from '@ghostfolio/common/routes/routes';
import { ColorScheme } from '@ghostfolio/common/types';
import { registerChartConfiguration } from '@ghostfolio/ui/chart';
import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator';
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
type ElementRef,
EventEmitter,
Input,
OnChanges,
@ -42,7 +43,7 @@ import {
PointElement,
TimeScale,
Tooltip,
TooltipPosition
type TooltipOptions
} from 'chart.js';
import 'chartjs-adapter-date-fns';
import annotationPlugin from 'chartjs-plugin-annotation';
@ -78,7 +79,7 @@ export class GfBenchmarkComparatorComponent implements OnChanges, OnDestroy {
@Output() benchmarkChanged = new EventEmitter<string>();
@ViewChild('chartCanvas') chartCanvas;
@ViewChild('chartCanvas') chartCanvas: ElementRef<HTMLCanvasElement>;
public chart: Chart<'line'>;
public hasPermissionToAccessAdminControl: boolean;
@ -96,8 +97,7 @@ export class GfBenchmarkComparatorComponent implements OnChanges, OnDestroy {
Tooltip
);
Tooltip.positioners['top'] = (_elements, position: TooltipPosition) =>
getTooltipPositionerMapTop(this.chart, position);
registerChartConfiguration();
addIcons({ arrowForwardOutline });
}
@ -157,8 +157,10 @@ export class GfBenchmarkComparatorComponent implements OnChanges, OnDestroy {
if (this.chartCanvas) {
if (this.chart) {
this.chart.data = data;
this.chart.options.plugins ??= {};
this.chart.options.plugins.tooltip =
this.getTooltipPluginConfiguration() as unknown;
this.getTooltipPluginConfiguration();
this.chart.update();
} else {
this.chart = new Chart(this.chartCanvas.nativeElement, {
@ -196,7 +198,7 @@ export class GfBenchmarkComparatorComponent implements OnChanges, OnDestroy {
verticalHoverLine: {
color: `rgba(${getTextColor(this.colorScheme)}, 0.1)`
}
} as unknown,
},
responsive: true,
scales: {
x: {
@ -253,7 +255,7 @@ export class GfBenchmarkComparatorComponent implements OnChanges, OnDestroy {
}
}
private getTooltipPluginConfiguration() {
private getTooltipPluginConfiguration(): Partial<TooltipOptions<'line'>> {
return {
...getTooltipOptions({
colorScheme: this.colorScheme,
@ -261,7 +263,7 @@ export class GfBenchmarkComparatorComponent implements OnChanges, OnDestroy {
unit: '%'
}),
mode: 'index',
position: 'top' as unknown,
position: 'top',
xAlign: 'center',
yAlign: 'bottom'
};

2
apps/client/src/app/components/data-provider-status/data-provider-status.component.ts

@ -1,4 +1,4 @@
import { DataService } from '@ghostfolio/client/services/data.service';
import { DataService } from '@ghostfolio/ui/services';
import { CommonModule } from '@angular/common';
import {

3
apps/client/src/app/components/fear-and-greed-index/fear-and-greed-index.component.ts

@ -1,7 +1,6 @@
import { resolveFearAndGreedIndex } from '@ghostfolio/common/helper';
import { translate } from '@ghostfolio/ui/i18n';
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
@ -12,7 +11,7 @@ import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, NgxSkeletonLoaderModule],
imports: [NgxSkeletonLoaderModule],
selector: 'gf-fear-and-greed-index',
styleUrls: ['./fear-and-greed-index.component.scss'],
templateUrl: './fear-and-greed-index.component.html'

15
apps/client/src/app/components/footer/footer.component.html

@ -122,7 +122,9 @@
</li>
-->
<li>
<a href="../zh" title="Ghostfolio in Chinese">Chinese</a>
<a href="../zh" title="Ghostfolio in Chinese (简体中文)"
>Chinese (简体中文)</a
>
</li>
<li>
<a href="../de" title="Ghostfolio in Deutsch">Deutsch</a>
@ -139,6 +141,13 @@
<li>
<a href="../it" title="Ghostfolio in Italiano">Italiano</a>
</li>
<!--
<li>
<a href="../ko" title="Ghostfolio in Korean (한국어)"
>Korean (한국어)</a
>
</li>
-->
<li>
<a href="../nl" title="Ghostfolio in Nederlands">Nederlands</a>
</li>
@ -153,7 +162,9 @@
</li>
<!--
<li>
<a href="../uk" title="Ghostfolio in Українська">Українська</a>
<a href="../uk" title="Ghostfolio in Ukrainian (Українська)"
>Ukrainian (Українська)</a
>
</li>
-->
</ul>

3
apps/client/src/app/components/footer/footer.component.ts

@ -3,7 +3,6 @@ import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { publicRoutes } from '@ghostfolio/common/routes/routes';
import { GfLogoComponent } from '@ghostfolio/ui/logo';
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
@ -18,7 +17,7 @@ import { openOutline } from 'ionicons/icons';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, GfLogoComponent, IonIcon, RouterModule],
imports: [GfLogoComponent, IonIcon, RouterModule],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
selector: 'gf-footer',
styleUrls: ['./footer.component.scss'],

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

@ -1,7 +1,6 @@
import { LoginWithAccessTokenDialogParams } from '@ghostfolio/client/components/login-with-access-token-dialog/interfaces/interfaces';
import { GfLoginWithAccessTokenDialogComponent } from '@ghostfolio/client/components/login-with-access-token-dialog/login-with-access-token-dialog.component';
import { LayoutService } from '@ghostfolio/client/core/layout.service';
import { DataService } from '@ghostfolio/client/services/data.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import {
KEY_STAY_SIGNED_IN,
@ -18,6 +17,7 @@ import { GfAssistantComponent } from '@ghostfolio/ui/assistant/assistant.compone
import { GfLogoComponent } from '@ghostfolio/ui/logo';
import { NotificationService } from '@ghostfolio/ui/notifications';
import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator';
import { DataService } from '@ghostfolio/ui/services';
import { CommonModule } from '@angular/common';
import {

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

@ -1,4 +1,3 @@
import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import {
NUMERICAL_PRECISION_THRESHOLD_3_FIGURES,
@ -26,6 +25,7 @@ import { GfHistoricalMarketDataEditorComponent } from '@ghostfolio/ui/historical
import { translate } from '@ghostfolio/ui/i18n';
import { GfLineChartComponent } from '@ghostfolio/ui/line-chart';
import { GfPortfolioProportionChartComponent } from '@ghostfolio/ui/portfolio-proportion-chart';
import { DataService } from '@ghostfolio/ui/services';
import { GfTagsSelectorComponent } from '@ghostfolio/ui/tags-selector';
import { GfValueComponent } from '@ghostfolio/ui/value';
@ -116,11 +116,11 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
};
public dataProviderInfo: DataProviderInfo;
public dataSource: MatTableDataSource<Activity>;
public dateOfFirstActivity: string;
public dividendInBaseCurrency: number;
public dividendInBaseCurrencyPrecision = 2;
public dividendYieldPercentWithCurrencyEffect: number;
public feeInBaseCurrency: number;
public firstBuyDate: string;
public hasPermissionToCreateOwnTag: boolean;
public hasPermissionToReadMarketDataOfOwnAssetProfile: boolean;
public historicalDataItems: LineChartItem[];
@ -267,10 +267,10 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
activitiesCount,
averagePrice,
dataProviderInfo,
dateOfFirstActivity,
dividendInBaseCurrency,
dividendYieldPercentWithCurrencyEffect,
feeInBaseCurrency,
firstBuyDate,
historicalData,
investmentInBaseCurrencyWithCurrencyEffect,
marketPrice,
@ -298,6 +298,7 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
this.benchmarkDataItems = [];
this.countries = {};
this.dataProviderInfo = dataProviderInfo;
this.dateOfFirstActivity = dateOfFirstActivity;
this.dividendInBaseCurrency = dividendInBaseCurrency;
if (
@ -312,7 +313,6 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
dividendYieldPercentWithCurrencyEffect;
this.feeInBaseCurrency = feeInBaseCurrency;
this.firstBuyDate = firstBuyDate;
this.hasPermissionToReadMarketDataOfOwnAssetProfile =
hasPermission(
@ -461,16 +461,16 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
}
}
if (isToday(parseISO(this.firstBuyDate))) {
if (isToday(parseISO(this.dateOfFirstActivity))) {
// Add average price
this.historicalDataItems.push({
date: this.firstBuyDate,
date: this.dateOfFirstActivity,
value: this.averagePrice
});
// Add benchmark 1
this.benchmarkDataItems.push({
date: this.firstBuyDate,
date: this.dateOfFirstActivity,
value: averagePrice
});
@ -501,7 +501,7 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
if (
this.benchmarkDataItems[0]?.value === undefined &&
isSameMonth(parseISO(this.firstBuyDate), new Date())
isSameMonth(parseISO(this.dateOfFirstActivity), new Date())
) {
this.benchmarkDataItems[0].value = this.averagePrice;
}

7
apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html

@ -215,7 +215,7 @@
[deviceType]="data.deviceType"
[isDate]="true"
[locale]="data.locale"
[value]="firstBuyDate"
[value]="dateOfFirstActivity"
>First Activity</gf-value
>
</div>
@ -377,13 +377,12 @@
<gf-accounts-table
[accounts]="accounts"
[baseCurrency]="user?.settings?.baseCurrency"
[deviceType]="data.deviceType"
[hasPermissionToOpenDetails]="false"
[locale]="user?.settings?.locale"
[showActivitiesCount]="false"
[showAllocationInPercentage]="user?.settings?.isExperimentalFeatures"
[showBalance]="false"
[showFooter]="false"
[showTransactions]="false"
[showValue]="false"
[showValueInBaseCurrency]="false"
/>
@ -400,7 +399,7 @@
<gf-historical-market-data-editor
[currency]="SymbolProfile?.currency"
[dataSource]="SymbolProfile?.dataSource"
[dateOfFirstActivity]="firstBuyDate"
[dateOfFirstActivity]="dateOfFirstActivity"
[locale]="data.locale"
[marketData]="marketDataItems"
[symbol]="SymbolProfile?.symbol"

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

@ -1,4 +1,3 @@
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 {
@ -11,6 +10,7 @@ import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { internalRoutes } from '@ghostfolio/common/routes/routes';
import { HoldingType, HoldingsViewMode } from '@ghostfolio/common/types';
import { GfHoldingsTableComponent } from '@ghostfolio/ui/holdings-table';
import { DataService } from '@ghostfolio/ui/services';
import { GfToggleComponent } from '@ghostfolio/ui/toggle';
import { GfTreemapChartComponent } from '@ghostfolio/ui/treemap-chart';

2
apps/client/src/app/components/home-market/home-market.component.ts

@ -1,5 +1,4 @@
import { GfFearAndGreedIndexComponent } from '@ghostfolio/client/components/fear-and-greed-index/fear-and-greed-index.component';
import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { ghostfolioFearAndGreedIndexSymbol } from '@ghostfolio/common/config';
import { resetHours } from '@ghostfolio/common/helper';
@ -12,6 +11,7 @@ import {
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { GfBenchmarkComponent } from '@ghostfolio/ui/benchmark';
import { GfLineChartComponent } from '@ghostfolio/ui/line-chart';
import { DataService } from '@ghostfolio/ui/services';
import {
ChangeDetectorRef,

2
apps/client/src/app/components/home-overview/home-overview.component.ts

@ -1,6 +1,5 @@
import { GfPortfolioPerformanceComponent } from '@ghostfolio/client/components/portfolio-performance/portfolio-performance.component';
import { LayoutService } from '@ghostfolio/client/core/layout.service';
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 { NUMERICAL_PRECISION_THRESHOLD_6_FIGURES } from '@ghostfolio/common/config';
@ -13,6 +12,7 @@ import {
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { internalRoutes } from '@ghostfolio/common/routes/routes';
import { GfLineChartComponent } from '@ghostfolio/ui/line-chart';
import { DataService } from '@ghostfolio/ui/services';
import { CommonModule } from '@angular/common';
import {

2
apps/client/src/app/components/home-summary/home-summary.component.ts

@ -1,5 +1,4 @@
import { GfPortfolioSummaryComponent } from '@ghostfolio/client/components/portfolio-summary/portfolio-summary.component';
import { DataService } from '@ghostfolio/client/services/data.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import {
@ -8,6 +7,7 @@ import {
User
} from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { DataService } from '@ghostfolio/ui/services';
import {
ChangeDetectorRef,

2
apps/client/src/app/components/home-watchlist/home-watchlist.component.ts

@ -1,4 +1,3 @@
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 {
@ -9,6 +8,7 @@ import {
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { GfBenchmarkComponent } from '@ghostfolio/ui/benchmark';
import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator';
import { DataService } from '@ghostfolio/ui/services';
import {
ChangeDetectionStrategy,

50
apps/client/src/app/components/investment-chart/investment-chart.component.ts

@ -1,6 +1,5 @@
import {
getTooltipOptions,
getTooltipPositionerMapTop,
getVerticalHoverLinePlugin,
transformTickToAbbreviation
} from '@ghostfolio/common/chart-helper';
@ -15,11 +14,13 @@ import {
import { LineChartItem } from '@ghostfolio/common/interfaces';
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
import { ColorScheme, GroupBy } from '@ghostfolio/common/types';
import { registerChartConfiguration } from '@ghostfolio/ui/chart';
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
type ElementRef,
Input,
OnChanges,
OnDestroy,
@ -34,12 +35,15 @@ import {
LineController,
LineElement,
PointElement,
type ScriptableLineSegmentContext,
TimeScale,
Tooltip,
TooltipPosition
type TooltipOptions
} from 'chart.js';
import 'chartjs-adapter-date-fns';
import annotationPlugin from 'chartjs-plugin-annotation';
import annotationPlugin, {
type AnnotationOptions
} from 'chartjs-plugin-annotation';
import { isAfter } from 'date-fns';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
@ -62,7 +66,7 @@ export class GfInvestmentChartComponent implements OnChanges, OnDestroy {
@Input() locale = getLocale();
@Input() savingsRate = 0;
@ViewChild('chartCanvas') chartCanvas;
@ViewChild('chartCanvas') chartCanvas: ElementRef<HTMLCanvasElement>;
public chart: Chart<'bar' | 'line'>;
private investments: InvestmentItem[];
@ -81,8 +85,7 @@ export class GfInvestmentChartComponent implements OnChanges, OnDestroy {
Tooltip
);
Tooltip.positioners['top'] = (_elements, position: TooltipPosition) =>
getTooltipPositionerMapTop(this.chart, position);
registerChartConfiguration();
}
public ngOnChanges() {
@ -121,12 +124,12 @@ export class GfInvestmentChartComponent implements OnChanges, OnDestroy {
}),
label: this.benchmarkDataLabel,
segment: {
borderColor: (context: unknown) =>
borderColor: (context) =>
this.isInFuture(
context,
`rgba(${secondaryColorRgb.r}, ${secondaryColorRgb.g}, ${secondaryColorRgb.b}, 0.67)`
),
borderDash: (context: unknown) => this.isInFuture(context, [2, 2])
borderDash: (context) => this.isInFuture(context, [2, 2])
},
stepped: true
},
@ -143,12 +146,12 @@ export class GfInvestmentChartComponent implements OnChanges, OnDestroy {
label: $localize`Total Amount`,
pointRadius: 0,
segment: {
borderColor: (context: unknown) =>
borderColor: (context) =>
this.isInFuture(
context,
`rgba(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b}, 0.67)`
),
borderDash: (context: unknown) => this.isInFuture(context, [2, 2])
borderDash: (context) => this.isInFuture(context, [2, 2])
}
}
]
@ -157,17 +160,14 @@ export class GfInvestmentChartComponent implements OnChanges, OnDestroy {
if (this.chartCanvas) {
if (this.chart) {
this.chart.data = chartData;
this.chart.options.plugins ??= {};
this.chart.options.plugins.tooltip =
this.getTooltipPluginConfiguration() as unknown;
this.getTooltipPluginConfiguration();
if (
this.savingsRate &&
// @ts-ignore
this.chart.options.plugins.annotation.annotations.savingsRate
) {
// @ts-ignore
this.chart.options.plugins.annotation.annotations.savingsRate.value =
this.savingsRate;
const annotations = this.chart.options.plugins.annotation
.annotations as Record<string, AnnotationOptions<'line'>>;
if (this.savingsRate && annotations.savingsRate) {
annotations.savingsRate.value = this.savingsRate;
}
this.chart.update();
@ -201,7 +201,7 @@ export class GfInvestmentChartComponent implements OnChanges, OnDestroy {
color: 'white',
content: $localize`Savings Rate`,
display: true,
font: { size: '10px', weight: 'normal' },
font: { size: 10, weight: 'normal' },
padding: {
x: 4,
y: 2
@ -229,7 +229,7 @@ export class GfInvestmentChartComponent implements OnChanges, OnDestroy {
verticalHoverLine: {
color: `rgba(${getTextColor(this.colorScheme)}, 0.1)`
}
} as unknown,
},
responsive: true,
scales: {
x: {
@ -286,7 +286,9 @@ export class GfInvestmentChartComponent implements OnChanges, OnDestroy {
}
}
private getTooltipPluginConfiguration() {
private getTooltipPluginConfiguration(): Partial<
TooltipOptions<'bar' | 'line'>
> {
return {
...getTooltipOptions({
colorScheme: this.colorScheme,
@ -296,13 +298,13 @@ export class GfInvestmentChartComponent implements OnChanges, OnDestroy {
unit: this.isInPercent ? '%' : undefined
}),
mode: 'index',
position: 'top' as unknown,
position: 'top',
xAlign: 'center',
yAlign: 'bottom'
};
}
private isInFuture<T>(aContext: any, aValue: T) {
private isInFuture<T>(aContext: ScriptableLineSegmentContext, aValue: T) {
return isAfter(new Date(aContext?.p1?.parsed?.x), new Date())
? aValue
: undefined;

2
apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.component.ts

@ -4,7 +4,6 @@ import {
} from '@ghostfolio/client/services/settings-storage.service';
import { GfDialogHeaderComponent } from '@ghostfolio/ui/dialog-header';
import { CommonModule } from '@angular/common';
import { ChangeDetectionStrategy, Component, Inject } from '@angular/core';
import { FormControl, ReactiveFormsModule, Validators } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
@ -28,7 +27,6 @@ import { LoginWithAccessTokenDialogParams } from './interfaces/interfaces';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
CommonModule,
GfDialogHeaderComponent,
IonIcon,
MatButtonModule,

2
apps/client/src/app/components/markets/markets.component.ts

@ -1,5 +1,4 @@
import { GfFearAndGreedIndexComponent } from '@ghostfolio/client/components/fear-and-greed-index/fear-and-greed-index.component';
import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { resetHours } from '@ghostfolio/common/helper';
import {
@ -12,6 +11,7 @@ import {
import { FearAndGreedIndexMode } from '@ghostfolio/common/types';
import { GfBenchmarkComponent } from '@ghostfolio/ui/benchmark';
import { GfLineChartComponent } from '@ghostfolio/ui/line-chart';
import { DataService } from '@ghostfolio/ui/services';
import { GfToggleComponent } from '@ghostfolio/ui/toggle';
import {

2
apps/client/src/app/components/subscription-interstitial-dialog/subscription-interstitial-dialog.component.ts

@ -2,7 +2,6 @@ import { publicRoutes } from '@ghostfolio/common/routes/routes';
import { GfMembershipCardComponent } from '@ghostfolio/ui/membership-card';
import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator';
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
@ -31,7 +30,6 @@ import { SubscriptionInterstitialDialogParams } from './interfaces/interfaces';
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'd-flex flex-column flex-grow-1 h-100' },
imports: [
CommonModule,
GfMembershipCardComponent,
GfPremiumIndicatorComponent,
IonIcon,

2
apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts

@ -1,7 +1,7 @@
import { DataService } from '@ghostfolio/client/services/data.service';
import { CreateAccessDto, UpdateAccessDto } from '@ghostfolio/common/dtos';
import { validateObjectForForm } from '@ghostfolio/common/utils';
import { NotificationService } from '@ghostfolio/ui/notifications';
import { DataService } from '@ghostfolio/ui/services';
import {
ChangeDetectionStrategy,

2
apps/client/src/app/components/user-account-access/user-account-access.component.ts

@ -1,5 +1,4 @@
import { GfAccessTableComponent } from '@ghostfolio/client/components/access-table/access-table.component';
import { DataService } from '@ghostfolio/client/services/data.service';
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { CreateAccessDto } from '@ghostfolio/common/dtos';
@ -8,6 +7,7 @@ import { Access, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { NotificationService } from '@ghostfolio/ui/notifications';
import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator';
import { DataService } from '@ghostfolio/ui/services';
import {
ChangeDetectionStrategy,

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

Loading…
Cancel
Save