Browse Source

Merge branch 'main' into feature/include-cash-in-portfolio-performance

pull/7148/head
Thomas Kaul 1 day ago
committed by GitHub
parent
commit
bc334c8220
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 72
      .agents/skills/karpathy-guidelines/SKILL.md
  2. 1
      .claude/skills/karpathy-guidelines
  3. 89
      CHANGELOG.md
  4. 43
      README.md
  5. 26
      apps/api/project.json
  6. 24
      apps/api/src/app/activities/activities.service.ts
  7. 9
      apps/api/src/app/admin/admin.controller.ts
  8. 12
      apps/api/src/app/admin/admin.service.ts
  9. 29
      apps/api/src/app/admin/pipes/property-key.pipe.ts
  10. 53
      apps/api/src/app/app.module.ts
  11. 5
      apps/api/src/app/auth/auth.controller.ts
  12. 6
      apps/api/src/app/endpoints/asset-profiles/asset-profiles.service.ts
  13. 39
      apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.controller.ts
  14. 2
      apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.module.ts
  15. 33
      apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.service.ts
  16. 41
      apps/api/src/app/endpoints/market-data/market-data.controller.ts
  17. 7
      apps/api/src/app/endpoints/public/public.controller.ts
  18. 12
      apps/api/src/app/export/export.service.ts
  19. 38
      apps/api/src/app/import/import.service.ts
  20. 13
      apps/api/src/app/info/info.service.ts
  21. 2
      apps/api/src/app/portfolio/calculator/portfolio-calculator-test-utils.ts
  22. 20
      apps/api/src/app/portfolio/calculator/portfolio-calculator.ts
  23. 26
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-buy.spec.ts
  24. 462
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell-in-two-activities.spec.ts
  25. 26
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell.spec.ts
  26. 38
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy.spec.ts
  27. 14
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btceur-in-base-currency-eur.spec.ts
  28. 14
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btceur.spec.ts
  29. 26
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts
  30. 12
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-short.spec.ts
  31. 12
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd.spec.ts
  32. 14
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-fee.spec.ts
  33. 14
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-googl-buy.spec.ts
  34. 380
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-jnug-buy-and-sell-and-buy-and-sell.spec.ts
  35. 14
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-liability.spec.ts
  36. 38
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-and-sell.spec.ts
  37. 26
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-with-dividend.spec.ts
  38. 12
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell-partially.spec.ts
  39. 528
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell.spec.ts
  40. 14
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-valuable.spec.ts
  41. 30
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts
  42. 8
      apps/api/src/app/portfolio/interfaces/portfolio-order.interface.ts
  43. 6
      apps/api/src/app/portfolio/portfolio.service.spec.ts
  44. 18
      apps/api/src/app/portfolio/portfolio.service.ts
  45. 11
      apps/api/src/app/redis-cache/redis-cache.module.ts
  46. 46
      apps/api/src/app/symbol/symbol.service.ts
  47. 15
      apps/api/src/app/user/user.controller.ts
  48. 8
      apps/api/src/app/user/user.service.ts
  49. 21
      apps/api/src/guards/custom-throttler.guard.ts
  50. 22
      apps/api/src/helper/redis.helper.ts
  51. 7
      apps/api/src/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor.ts
  52. 20
      apps/api/src/main.ts
  53. 29
      apps/api/src/services/configuration/configuration.service.ts
  54. 6
      apps/api/src/services/data-provider/data-provider.service.ts
  55. 32
      apps/api/src/services/data-provider/rapid-api/rapid-api.service.ts
  56. 3
      apps/api/src/services/interfaces/environment.interface.ts
  57. 24
      apps/api/src/services/symbol-profile/symbol-profile.service.ts
  58. 8
      apps/api/src/services/twitter-bot/twitter-bot.module.ts
  59. 14
      apps/api/src/services/twitter-bot/twitter-bot.service.ts
  60. 51
      apps/api/webpack.config.js
  61. 4
      apps/client/eslint.config.cjs
  62. 1
      apps/client/jest.config.ts
  63. 3
      apps/client/src/app/app.routes.ts
  64. 10
      apps/client/src/app/components/admin-market-data/admin-market-data.component.ts
  65. 8
      apps/client/src/app/components/admin-market-data/admin-market-data.html
  66. 10
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts
  67. 15
      apps/client/src/app/components/admin-overview/admin-overview.component.ts
  68. 33
      apps/client/src/app/components/admin-overview/admin-overview.html
  69. 4
      apps/client/src/app/components/admin-settings/admin-settings.component.html
  70. 8
      apps/client/src/app/components/admin-users/admin-users.component.ts
  71. 7
      apps/client/src/app/components/admin-users/admin-users.html
  72. 2
      apps/client/src/app/components/fear-and-greed-index/fear-and-greed-index.component.html
  73. 3
      apps/client/src/app/components/fear-and-greed-index/fear-and-greed-index.component.ts
  74. 15
      apps/client/src/app/components/header/header.component.ts
  75. 2
      apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts
  76. 61
      apps/client/src/app/components/home-holdings/home-holdings.component.ts
  77. 2
      apps/client/src/app/components/home-market/home-market.component.ts
  78. 1
      apps/client/src/app/components/home-overview/home-overview.html
  79. 22
      apps/client/src/app/components/portfolio-performance/portfolio-performance.component.html
  80. 103
      apps/client/src/app/components/portfolio-performance/portfolio-performance.component.ts
  81. 32
      apps/client/src/app/components/portfolio-summary/portfolio-summary.component.ts
  82. 4
      apps/client/src/app/components/rule/rule-settings-dialog/rule-settings-dialog.html
  83. 64
      apps/client/src/app/components/user-account-membership/user-account-membership.component.ts
  84. 86
      apps/client/src/app/components/user-account-settings/user-account-settings.component.ts
  85. 110
      apps/client/src/app/components/user-account-settings/user-account-settings.html
  86. 55
      apps/client/src/app/components/user-detail-dialog/user-detail-dialog.component.ts
  87. 7
      apps/client/src/app/components/user-detail-dialog/user-detail-dialog.html
  88. 22
      apps/client/src/app/core/http-response.interceptor.ts
  89. 12
      apps/client/src/app/pages/about/about-page.component.ts
  90. 4
      apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.component.ts
  91. 11
      apps/client/src/app/pages/admin/admin-page.component.ts
  92. 22
      apps/client/src/app/pages/api/api-page.component.ts
  93. 52
      apps/client/src/app/pages/api/api-page.html
  94. 3
      apps/client/src/app/pages/blog/2021/07/hallo-ghostfolio/hallo-ghostfolio-page.component.ts
  95. 3
      apps/client/src/app/pages/blog/2021/07/hello-ghostfolio/hello-ghostfolio-page.component.ts
  96. 3
      apps/client/src/app/pages/blog/2022/01/first-months-in-open-source/first-months-in-open-source-page.component.ts
  97. 3
      apps/client/src/app/pages/blog/2022/07/ghostfolio-meets-internet-identity/ghostfolio-meets-internet-identity-page.component.ts
  98. 3
      apps/client/src/app/pages/blog/2022/07/how-do-i-get-my-finances-in-order/how-do-i-get-my-finances-in-order-page.component.ts
  99. 3
      apps/client/src/app/pages/blog/2022/08/500-stars-on-github/500-stars-on-github-page.component.ts
  100. 3
      apps/client/src/app/pages/blog/2022/10/hacktoberfest-2022/hacktoberfest-2022-page.component.ts

72
.agents/skills/karpathy-guidelines/SKILL.md

@ -0,0 +1,72 @@
---
name: karpathy-guidelines
description: Behavioral guidelines to reduce common LLM coding mistakes. Use when writing, reviewing, or refactoring code to avoid overcomplication, make surgical changes, surface assumptions, and define verifiable success criteria.
license: MIT
---
# Karpathy Guidelines
Behavioral guidelines to reduce common LLM coding mistakes, derived from [Andrej Karpathy's observations](https://x.com/karpathy/status/2015883857489522876) on LLM coding pitfalls.
**Tradeoff:** These guidelines bias toward caution over speed. For trivial tasks, use judgment.
## 1. Think Before Coding
**Don't assume. Don't hide confusion. Surface tradeoffs.**
Before implementing:
- State your assumptions explicitly. If uncertain, ask.
- If multiple interpretations exist, present them - don't pick silently.
- If a simpler approach exists, say so. Push back when warranted.
- If something is unclear, stop. Name what's confusing. Ask.
## 2. Simplicity First
**Minimum code that solves the problem. Nothing speculative.**
- No features beyond what was asked.
- No abstractions for single-use code.
- No "flexibility" or "configurability" that wasn't requested.
- No error handling for impossible scenarios.
- If you write 200 lines and it could be 50, rewrite it.
Ask yourself: "Would a senior engineer say this is overcomplicated?" If yes, simplify.
## 3. Surgical Changes
**Touch only what you must. Clean up only your own mess.**
When editing existing code:
- Don't "improve" adjacent code, comments, or formatting.
- Don't refactor things that aren't broken.
- Match existing style, even if you'd do it differently.
- If you notice unrelated dead code, mention it - don't delete it.
When your changes create orphans:
- Remove imports/variables/functions that YOUR changes made unused.
- Don't remove pre-existing dead code unless asked.
The test: Every changed line should trace directly to the user's request.
## 4. Goal-Driven Execution
**Define success criteria. Loop until verified.**
Transform tasks into verifiable goals:
- "Add validation" → "Write tests for invalid inputs, then make them pass"
- "Fix the bug" → "Write a test that reproduces it, then make it pass"
- "Refactor X" → "Ensure tests pass before and after"
For multi-step tasks, state a brief plan:
```
1. [Step] → verify: [check]
2. [Step] → verify: [check]
3. [Step] → verify: [check]
```
Strong success criteria let you loop independently. Weak criteria ("make it work") require constant clarification.

1
.claude/skills/karpathy-guidelines

@ -0,0 +1 @@
../../.agents/skills/karpathy-guidelines

89
CHANGELOG.md

@ -11,6 +11,95 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Included cash in the performance calculation of the portfolio on the home page - Included cash in the performance calculation of the portfolio on the home page
## 3.26.0 - 2026-07-14
### Added
- Added the markets endpoint for the _Fear & Greed Index_ (market mood) to the `GHOSTFOLIO` data provider
### Changed
- Hardened the validation of the countries in the asset profile endpoints
- Hardened the validation of the holdings in the asset profile endpoints
- Hardened the validation of the scraper configuration in the asset profile endpoint
- Hardened the validation of the sectors in the asset profile endpoints
- Rounded the value of the _Fear & Greed Index_ (market mood) in the twitter bot service
- Set the change detection strategy to `OnPush` in the _X-ray_ page
- Deprecated `SymbolProfile` in favor of `assetProfile` in the activity interface
- Upgraded `countries-list` from version `3.3.0` to `3.4.0`
- Upgraded `Nx` from version `23.0.1` to `23.0.2`
## 3.25.0 - 2026-07-12
### Changed
- Changed the default value of the `DATA_SOURCE_FEAR_AND_GREED_INDEX_STOCKS` environment variable from `RAPID_API` to `MANUAL`
- Improved the language localization for Dutch (`nl`)
- Upgraded `helmet` from version `7.0.0` to `8.2.0`
### Fixed
- Fixed the layout of the page tabs component by truncating long labels
- Fixed the display of assets without a currency in the search results of the assistant
- Fixed the display of assets without a currency in the symbol autocomplete component
### Todo
- **Breaking Change**: Set the environment variable `DATA_SOURCE_FEAR_AND_GREED_INDEX_STOCKS=RAPID_API` to keep using _Rapid API_ as the data source of the _Fear & Greed Index_ (market mood)
## 3.24.0 - 2026-07-11
### Added
- Exposed the `DATA_SOURCE_FEAR_AND_GREED_INDEX_STOCKS` environment variable to set the data source of the _Fear & Greed Index_ (market mood)
- Exposed the `ENABLE_FEATURE_RATE_LIMITING` environment variable to control rate limiting for authentication and sign-up endpoints
- Exposed the `TRUST_PROXY` environment variable to determine the client IP address when running behind a reverse proxy
### Changed
- Rounded the value of the _Fear & Greed Index_ (market mood)
- Improved the language localization for Korean (`ko`)
## 3.23.0 - 2026-07-10
### Changed
- Migrated the deprecated `@nx/webpack:webpack` executor to `@nx/webpack/plugin`
- Set the change detection strategy to `OnPush` in the about page
- Set the change detection strategy to `OnPush` in the admin control panel
- Set the change detection strategy to `OnPush` in the blog page components
- Set the change detection strategy to `OnPush` in the Frequently Asked Questions (FAQ) page
- Set the change detection strategy to `OnPush` in the home page
- Set the change detection strategy to `OnPush` in the markets overview
- Set the change detection strategy to `OnPush` in the resources page
- Set the change detection strategy to `OnPush` in the user account page
- Set the change detection strategy to `OnPush` in the _Zen Mode_
- Improved the language localization for Chinese (`zh`)
- Improved the language localization for German (`de`)
## 3.22.0 - 2026-07-08
### Added
- Added support for a copy-to-clipboard action in the alert dialog component
### Changed
- Improved the user account deletion flow in the user settings of the user account page
- Improved the date formatting of the first activity in the historical market data table of the admin control panel
- Set the change detection strategy to `OnPush` in the activities page
- Set the change detection strategy to `OnPush` in the allocations page
- Set the change detection strategy to `OnPush` in the analysis page
- Set the change detection strategy to `OnPush` in the portfolio holdings page
- Set the change detection strategy to `OnPush` in the activities page
- Set the change detection strategy to `OnPush` in the _FIRE_ page
- Set the change detection strategy to `OnPush` in the users section of the admin control panel
- Hardened the endpoint to update a property of the admin control panel by validating the `key` path parameter
- Renamed the `SymbolProfileOverrides` _Prisma_ data model to `AssetProfileOverrides` while keeping the database table name
- Improved the language localization for Dutch (`nl`)
- Improved the language localization for French (`fr`)
- Improved the language localization for German (`de`)
## 3.21.0 - 2026-07-05 ## 3.21.0 - 2026-07-05
### Added ### Added

43
README.md

@ -85,27 +85,28 @@ We provide official container images hosted on [Docker Hub](https://hub.docker.c
### Supported Environment Variables ### Supported Environment Variables
| Name | Type | Default Value | Description | | Name | Type | Default Value | Description |
| --------------------------- | --------------------- | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | --------------------------- | --------------------- | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ACCESS_TOKEN_SALT` | `string` | | A random string used as salt for access tokens | | `ACCESS_TOKEN_SALT` | `string` | | A random string used as salt for access tokens |
| `API_KEY_COINGECKO_DEMO` | `string` (optional) |   | The _CoinGecko_ Demo API key | | `API_KEY_COINGECKO_DEMO` | `string` (optional) |   | The _CoinGecko_ Demo API key |
| `API_KEY_COINGECKO_PRO` | `string` (optional) | | The _CoinGecko_ Pro API key | | `API_KEY_COINGECKO_PRO` | `string` (optional) | | The _CoinGecko_ Pro API key |
| `DATABASE_URL` | `string` | | The database connection URL. If using a connection pooler, use the pooled connection URL here. e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}` | | `DATABASE_URL` | `string` | | The database connection URL. If using a connection pooler, use the pooled connection URL here. e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}` |
| `DIRECT_URL` | `string` (optional) | | The direct database connection URL used by the _Prisma CLI_ (e.g. for schema migrations) and seeding, bypassing any connection poolers (falls back to `DATABASE_URL`) | | `DIRECT_URL` | `string` (optional) | | The direct database connection URL used by the _Prisma CLI_ (e.g. for schema migrations) and seeding, bypassing any connection poolers (falls back to `DATABASE_URL`) |
| `ENABLE_FEATURE_AUTH_TOKEN` | `boolean` (optional) | `true` | Enables authentication via security token | | `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 | | `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) | | `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"]` | | `LOG_LEVELS` | `string[]` (optional) | | The logging levels for the Ghostfolio application, e.g. `["debug","error","log","warn"]` |
| `PORT` | `number` (optional) | `3333` | The port where the Ghostfolio application will run on | | `PORT` | `number` (optional) | `3333` | The port where the Ghostfolio application will run on |
| `POSTGRES_DB` | `string` | | The name of the _PostgreSQL_ database | | `POSTGRES_DB` | `string` | | The name of the _PostgreSQL_ database |
| `POSTGRES_PASSWORD` | `string` | | The password of the _PostgreSQL_ database | | `POSTGRES_PASSWORD` | `string` | | The password of the _PostgreSQL_ database |
| `POSTGRES_USER` | `string` | | The user of the _PostgreSQL_ database | | `POSTGRES_USER` | `string` | | The user of the _PostgreSQL_ database |
| `REDIS_DB` | `number` (optional) | `0` | The database index of _Redis_ | | `REDIS_DB` | `number` (optional) | `0` | The database index of _Redis_ |
| `REDIS_HOST` | `string` | | The host where _Redis_ is running | | `REDIS_HOST` | `string` | | The host where _Redis_ is running |
| `REDIS_PASSWORD` | `string` | | The password of _Redis_ | | `REDIS_PASSWORD` | `string` | | The password of _Redis_ |
| `REDIS_PORT` | `number` | | The port where _Redis_ is running | | `REDIS_PORT` | `number` | | The port where _Redis_ is running |
| `REQUEST_TIMEOUT` | `number` (optional) | `2000` | The timeout of network requests to data providers in milliseconds | | `REQUEST_TIMEOUT` | `number` (optional) | `2000` | The timeout of network requests to data providers in milliseconds |
| `ROOT_URL` | `string` (optional) | `http://0.0.0.0:3333` | The root URL of the Ghostfolio application, used for generating callback URLs and external links. | | `ROOT_URL` | `string` (optional) | `http://0.0.0.0:3333` | The root URL of the Ghostfolio application, used for generating callback URLs and external links. |
| `TRUST_PROXY` | `string` (optional) | | The [trust proxy](https://expressjs.com/en/guide/behind-proxies.html) setting of _Express.js_ to determine the client IP address for rate limiting, e.g. `1` if the Ghostfolio application runs behind a single reverse proxy |
#### OpenID Connect OIDC (experimental) #### OpenID Connect OIDC (experimental)

26
apps/api/project.json

@ -7,32 +7,10 @@
"generators": {}, "generators": {},
"targets": { "targets": {
"build": { "build": {
"executor": "@nx/webpack:webpack",
"options": {
"compiler": "tsc",
"deleteOutputPath": false,
"main": "apps/api/src/main.ts",
"outputPath": "dist/apps/api",
"sourceMap": true,
"target": "node",
"tsConfig": "apps/api/tsconfig.app.json",
"webpackConfig": "apps/api/webpack.config.js"
},
"configurations": { "configurations": {
"production": { "production": {}
"generatePackageJson": true,
"optimization": true,
"extractLicenses": true,
"inspect": false,
"fileReplacements": [
{
"replace": "apps/api/src/environments/environment.ts",
"with": "apps/api/src/environments/environment.prod.ts"
}
]
}
}, },
"outputs": ["{options.outputPath}"] "outputs": ["{workspaceRoot}/dist/apps/api"]
}, },
"copy-assets": { "copy-assets": {
"executor": "nx:run-commands", "executor": "nx:run-commands",

24
apps/api/src/app/activities/activities.service.ts

@ -455,6 +455,23 @@ export class ActivitiesService {
userId, userId,
accountId: account.id, accountId: account.id,
accountUserId: account.userId, accountUserId: account.userId,
assetProfile: {
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)
},
comment: account.name, comment: account.name,
createdAt: new Date(balanceItem.date), createdAt: new Date(balanceItem.date),
currency: account.currency, currency: account.currency,
@ -615,14 +632,14 @@ export class ActivitiesService {
}, },
{ {
OR: [ OR: [
{ SymbolProfileOverrides: { is: null } }, { assetProfileOverrides: { is: null } },
{ SymbolProfileOverrides: { assetClass: null } } { assetProfileOverrides: { assetClass: null } }
] ]
} }
] ]
}, },
{ {
SymbolProfileOverrides: { assetProfileOverrides: {
OR: filtersByAssetClass.map(({ id }) => { OR: filtersByAssetClass.map(({ id }) => {
return { assetClass: AssetClass[id] }; return { assetClass: AssetClass[id] };
}) })
@ -823,6 +840,7 @@ export class ActivitiesService {
return { return {
...order, ...order,
assetProfile,
feeInAssetProfileCurrency, feeInAssetProfileCurrency,
feeInBaseCurrency, feeInBaseCurrency,
unitPriceInAssetProfileCurrency, unitPriceInAssetProfileCurrency,

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

@ -29,7 +29,11 @@ import {
ScraperConfiguration ScraperConfiguration
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
import type { DateRange, RequestWithUser } from '@ghostfolio/common/types'; import type {
DateRange,
PropertyKey,
RequestWithUser
} from '@ghostfolio/common/types';
import { import {
Body, Body,
@ -55,6 +59,7 @@ import { isDate, parseISO } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { AdminService } from './admin.service'; import { AdminService } from './admin.service';
import { PropertyKeyPipe } from './pipes/property-key.pipe';
@Controller('admin') @Controller('admin')
export class AdminController { export class AdminController {
@ -310,7 +315,7 @@ export class AdminController {
@Put('settings/:key') @Put('settings/:key')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async updateProperty( public async updateProperty(
@Param('key') key: string, @Param('key', PropertyKeyPipe) key: PropertyKey,
@Body() data: UpdatePropertyDto @Body() data: UpdatePropertyDto
) { ) {
return this.adminService.putSetting(key, data.value); return this.adminService.putSetting(key, data.value);

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

@ -299,7 +299,7 @@ export class AdminService {
); );
} }
} else { } else {
const symbolProfileOverrides = { const assetProfileOverrides = {
assetClass: assetClass as AssetClass, assetClass: assetClass as AssetClass,
assetSubClass: assetSubClass as AssetSubClass, assetSubClass: assetSubClass as AssetSubClass,
countries: countries as Prisma.JsonArray, countries: countries as Prisma.JsonArray,
@ -320,7 +320,7 @@ export class AdminService {
symbolMapping, symbolMapping,
...this.symbolProfileService.getAssetProfileUpdateInput( ...this.symbolProfileService.getAssetProfileUpdateInput(
{ dataSource, symbol }, { dataSource, symbol },
symbolProfileOverrides assetProfileOverrides
) )
}; };
@ -344,17 +344,17 @@ export class AdminService {
} }
} }
public async putSetting(key: string, value: string) { public async putSetting(key: PropertyKey, value: string) {
let response: Property; let response: Property;
if (value) { if (value) {
response = await this.propertyService.put({ response = await this.propertyService.put({
value, key,
key: key as PropertyKey value
}); });
} else { } else {
response = await this.propertyService.delete({ response = await this.propertyService.delete({
key: key as PropertyKey key
}); });
} }

29
apps/api/src/app/admin/pipes/property-key.pipe.ts

@ -0,0 +1,29 @@
import * as config from '@ghostfolio/common/config';
import type { PropertyKey } from '@ghostfolio/common/types';
import { BadRequestException, Injectable, PipeTransform } from '@nestjs/common';
@Injectable()
export class PropertyKeyPipe implements PipeTransform<string, PropertyKey> {
private readonly allowedKeys: Set<string>;
public constructor() {
this.allowedKeys = new Set<string>(
Object.entries(config)
.filter(([key]) => {
return key.startsWith('PROPERTY_');
})
.map(([, value]) => {
return value as string;
})
);
}
public transform(value: string): PropertyKey {
if (!this.allowedKeys.has(value)) {
throw new BadRequestException(`Invalid property key: ${value}`);
}
return value as PropertyKey;
}
}

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

@ -1,7 +1,9 @@
import { EventsModule } from '@ghostfolio/api/events/events.module'; import { EventsModule } from '@ghostfolio/api/events/events.module';
import { getRedisConnectionOptions } from '@ghostfolio/api/helper/redis.helper';
import { BullBoardAuthMiddleware } from '@ghostfolio/api/middlewares/bull-board-auth.middleware'; import { BullBoardAuthMiddleware } from '@ghostfolio/api/middlewares/bull-board-auth.middleware';
import { HtmlTemplateMiddleware } from '@ghostfolio/api/middlewares/html-template.middleware'; import { HtmlTemplateMiddleware } from '@ghostfolio/api/middlewares/html-template.middleware';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { CronModule } from '@ghostfolio/api/services/cron/cron.module'; import { CronModule } from '@ghostfolio/api/services/cron/cron.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
@ -13,18 +15,22 @@ import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/po
import { import {
BULL_BOARD_ROUTE, BULL_BOARD_ROUTE,
DEFAULT_LANGUAGE_CODE, DEFAULT_LANGUAGE_CODE,
SUPPORTED_LANGUAGE_CODES SUPPORTED_LANGUAGE_CODES,
THROTTLE_DEFAULT_LIMIT,
THROTTLE_DEFAULT_TTL
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { ExpressAdapter } from '@bull-board/express'; import { ExpressAdapter } from '@bull-board/express';
import { BullBoardModule } from '@bull-board/nestjs'; import { BullBoardModule } from '@bull-board/nestjs';
import { ThrottlerStorageRedisService } from '@nest-lab/throttler-storage-redis';
import { BullModule } from '@nestjs/bull'; import { BullModule } from '@nestjs/bull';
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common'; import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config'; import { ConfigModule } from '@nestjs/config';
import { EventEmitterModule } from '@nestjs/event-emitter'; import { EventEmitterModule } from '@nestjs/event-emitter';
import { ScheduleModule } from '@nestjs/schedule'; import { ScheduleModule } from '@nestjs/schedule';
import { ServeStaticModule } from '@nestjs/serve-static'; import { ServeStaticModule } from '@nestjs/serve-static';
import { StatusCodes } from 'http-status-codes'; import { ThrottlerModule } from '@nestjs/throttler';
import { getReasonPhrase, StatusCodes } from 'http-status-codes';
import { join } from 'node:path'; import { join } from 'node:path';
import { AccessModule } from './access/access.module'; import { AccessModule } from './access/access.module';
@ -95,12 +101,13 @@ import { UserModule } from './user/user.module';
middleware: BullBoardAuthMiddleware, middleware: BullBoardAuthMiddleware,
route: BULL_BOARD_ROUTE route: BULL_BOARD_ROUTE
}), }),
BullModule.forRoot({ BullModule.forRootAsync({
redis: { imports: [ConfigurationModule],
db: parseInt(process.env.REDIS_DB ?? '0', 10), inject: [ConfigurationService],
host: process.env.REDIS_HOST, useFactory: (configurationService: ConfigurationService) => {
password: process.env.REDIS_PASSWORD, return {
port: parseInt(process.env.REDIS_PORT ?? '6379', 10) redis: getRedisConnectionOptions(configurationService)
};
} }
}), }),
CacheModule, CacheModule,
@ -168,6 +175,36 @@ import { UserModule } from './user/user.module';
SubscriptionModule, SubscriptionModule,
SymbolModule, SymbolModule,
TagsModule, TagsModule,
ThrottlerModule.forRootAsync({
imports: [ConfigurationModule],
inject: [ConfigurationService],
useFactory: (configurationService: ConfigurationService) => {
const isRateLimitingEnabled = configurationService.get(
'ENABLE_FEATURE_RATE_LIMITING'
);
return {
errorMessage: getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
skipIf: () => {
return !isRateLimitingEnabled;
},
storage: isRateLimitingEnabled
? new ThrottlerStorageRedisService({
...getRedisConnectionOptions(configurationService),
// Reject commands immediately while Redis is unavailable
enableOfflineQueue: false,
maxRetriesPerRequest: 1
})
: undefined,
throttlers: [
{
limit: THROTTLE_DEFAULT_LIMIT,
ttl: THROTTLE_DEFAULT_TTL
}
]
};
}
}),
UserModule, UserModule,
WatchlistModule WatchlistModule
], ],

5
apps/api/src/app/auth/auth.controller.ts

@ -1,4 +1,5 @@
import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service'; import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service';
import { CustomThrottlerGuard } from '@ghostfolio/api/guards/custom-throttler.guard';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config'; import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
@ -39,6 +40,7 @@ export class AuthController {
* @deprecated * @deprecated
*/ */
@Get('anonymous/:accessToken') @Get('anonymous/:accessToken')
@UseGuards(CustomThrottlerGuard)
public async accessTokenLoginGet( public async accessTokenLoginGet(
@Param('accessToken') accessToken: string @Param('accessToken') accessToken: string
): Promise<OAuthResponse> { ): Promise<OAuthResponse> {
@ -55,6 +57,7 @@ export class AuthController {
} }
@Post('anonymous') @Post('anonymous')
@UseGuards(CustomThrottlerGuard)
public async accessTokenLogin( public async accessTokenLogin(
@Body() body: { accessToken: string } @Body() body: { accessToken: string }
): Promise<OAuthResponse> { ): Promise<OAuthResponse> {
@ -135,6 +138,7 @@ export class AuthController {
} }
@Post('webauthn/generate-authentication-options') @Post('webauthn/generate-authentication-options')
@UseGuards(CustomThrottlerGuard)
public async generateAuthenticationOptions( public async generateAuthenticationOptions(
@Body() body: { deviceId: string } @Body() body: { deviceId: string }
) { ) {
@ -156,6 +160,7 @@ export class AuthController {
} }
@Post('webauthn/verify-authentication') @Post('webauthn/verify-authentication')
@UseGuards(CustomThrottlerGuard)
public async verifyAuthentication( public async verifyAuthentication(
@Body() body: { deviceId: string; credential: AssertionCredentialJSON } @Body() body: { deviceId: string; credential: AssertionCredentialJSON }
) { ) {

6
apps/api/src/app/endpoints/asset-profiles/asset-profiles.service.ts

@ -198,6 +198,7 @@ export class AssetProfilesService {
take: 1 take: 1
}, },
assetClass: true, assetClass: true,
assetProfileOverrides: true,
assetSubClass: true, assetSubClass: true,
comment: true, comment: true,
countries: true, countries: true,
@ -210,8 +211,7 @@ export class AssetProfilesService {
name: true, name: true,
scraperConfiguration: true, scraperConfiguration: true,
sectors: true, sectors: true,
symbol: true, symbol: true
SymbolProfileOverrides: true
} }
}), }),
this.prismaService.symbolProfile.count({ where }) this.prismaService.symbolProfile.count({ where })
@ -268,7 +268,7 @@ export class AssetProfilesService {
const { assetClass, assetSubClass, countries, name, sectors } = const { assetClass, assetSubClass, countries, name, sectors } =
applyAssetProfileOverrides( applyAssetProfileOverrides(
assetProfile, assetProfile,
assetProfile.SymbolProfileOverrides assetProfile.assetProfileOverrides
); );
const countriesCount = countries ? Object.keys(countries).length : 0; const countriesCount = countries ? Object.keys(countries).length : 0;

39
apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.controller.ts

@ -8,6 +8,7 @@ import {
DividendsResponse, DividendsResponse,
HistoricalResponse, HistoricalResponse,
LookupResponse, LookupResponse,
MarketDataOfMarketsResponse,
QuotesResponse QuotesResponse
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
@ -19,6 +20,7 @@ import {
HttpException, HttpException,
Inject, Inject,
Param, Param,
ParseIntPipe,
Query, Query,
UseGuards, UseGuards,
Version Version
@ -203,6 +205,43 @@ export class GhostfolioController {
} }
} }
@Get('markets')
@HasPermission(permissions.enableDataProviderGhostfolio)
@UseGuards(AuthGuard('api-key'), HasPermissionGuard)
public async getMarketDataOfMarkets(
@Query('includeHistoricalData', new ParseIntPipe({ optional: true }))
includeHistoricalData = 0
): Promise<MarketDataOfMarketsResponse> {
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests();
if (
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests
) {
throw new HttpException(
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
StatusCodes.TOO_MANY_REQUESTS
);
}
try {
const marketDataOfMarkets =
await this.ghostfolioService.getMarketDataOfMarkets({
includeHistoricalData
});
await this.ghostfolioService.incrementDailyRequests({
userId: this.request.user.id
});
return marketDataOfMarkets;
} catch {
throw new HttpException(
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
StatusCodes.INTERNAL_SERVER_ERROR
);
}
}
@Get('quotes') @Get('quotes')
@HasPermission(permissions.enableDataProviderGhostfolio) @HasPermission(permissions.enableDataProviderGhostfolio)
@UseGuards(AuthGuard('api-key'), HasPermissionGuard) @UseGuards(AuthGuard('api-key'), HasPermissionGuard)

2
apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.module.ts

@ -1,4 +1,5 @@
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { CryptocurrencyModule } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.module'; import { CryptocurrencyModule } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.module';
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service'; import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service';
@ -33,6 +34,7 @@ import { GhostfolioService } from './ghostfolio.service';
PrismaModule, PrismaModule,
PropertyModule, PropertyModule,
RedisCacheModule, RedisCacheModule,
SymbolModule,
SymbolProfileModule SymbolProfileModule
], ],
providers: [ providers: [

33
apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.service.ts

@ -1,3 +1,4 @@
import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { GhostfolioService as GhostfolioDataProviderService } from '@ghostfolio/api/services/data-provider/ghostfolio/ghostfolio.service'; import { GhostfolioService as GhostfolioDataProviderService } from '@ghostfolio/api/services/data-provider/ghostfolio/ghostfolio.service';
@ -25,6 +26,7 @@ import {
HistoricalResponse, HistoricalResponse,
LookupItem, LookupItem,
LookupResponse, LookupResponse,
MarketDataOfMarketsResponse,
QuotesResponse QuotesResponse
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { UserWithSettings } from '@ghostfolio/common/types'; import { UserWithSettings } from '@ghostfolio/common/types';
@ -32,6 +34,7 @@ import { UserWithSettings } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { DataSource, SymbolProfile } from '@prisma/client'; import { DataSource, SymbolProfile } from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { isEmpty } from 'lodash';
@Injectable() @Injectable()
export class GhostfolioService { export class GhostfolioService {
@ -42,7 +45,8 @@ export class GhostfolioService {
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
private readonly fetchService: FetchService, private readonly fetchService: FetchService,
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService private readonly propertyService: PropertyService,
private readonly symbolService: SymbolService
) {} ) {}
public async getAssetProfile({ symbol }: GetAssetProfileParams) { public async getAssetProfile({ symbol }: GetAssetProfileParams) {
@ -198,6 +202,33 @@ export class GhostfolioService {
} }
} }
public async getMarketDataOfMarkets({
includeHistoricalData
}: {
includeHistoricalData: number;
}): Promise<MarketDataOfMarketsResponse> {
try {
const marketDataOfMarkets =
await this.symbolService.getMarketDataOfMarkets({
includeHistoricalData
});
for (const symbolItem of Object.values(
marketDataOfMarkets.fearAndGreedIndex
)) {
if (!isEmpty(symbolItem)) {
symbolItem.dataSource = DataSource.GHOSTFOLIO;
}
}
return marketDataOfMarkets;
} catch (error) {
this.logger.error(error);
throw error;
}
}
public async getMaxDailyRequests() { public async getMaxDailyRequests() {
return parseInt( return parseInt(
(await this.propertyService.getByKey<string>( (await this.propertyService.getByKey<string>(

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

@ -3,12 +3,6 @@ import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorat
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import {
ghostfolioFearAndGreedIndexDataSourceCryptocurrencies,
ghostfolioFearAndGreedIndexDataSourceStocks,
ghostfolioFearAndGreedIndexSymbolCryptocurrencies,
ghostfolioFearAndGreedIndexSymbolStocks
} from '@ghostfolio/common/config';
import { UpdateBulkMarketDataDto } from '@ghostfolio/common/dtos'; import { UpdateBulkMarketDataDto } from '@ghostfolio/common/dtos';
import { getCurrencyFromSymbol, isCurrency } from '@ghostfolio/common/helper'; import { getCurrencyFromSymbol, isCurrency } from '@ghostfolio/common/helper';
import { MarketDataOfMarketsResponse } from '@ghostfolio/common/interfaces'; import { MarketDataOfMarketsResponse } from '@ghostfolio/common/interfaces';
@ -49,38 +43,9 @@ export class MarketDataController {
@Query('includeHistoricalData', new ParseIntPipe({ optional: true })) @Query('includeHistoricalData', new ParseIntPipe({ optional: true }))
includeHistoricalData = 0 includeHistoricalData = 0
): Promise<MarketDataOfMarketsResponse> { ): Promise<MarketDataOfMarketsResponse> {
const [ return this.symbolService.getMarketDataOfMarkets({
marketDataFearAndGreedIndexCryptocurrencies, includeHistoricalData
marketDataFearAndGreedIndexStocks });
] = await Promise.all([
this.symbolService.get({
includeHistoricalData,
dataGatheringItem: {
dataSource: ghostfolioFearAndGreedIndexDataSourceCryptocurrencies,
symbol: ghostfolioFearAndGreedIndexSymbolCryptocurrencies
},
useIntradayData: true
}),
this.symbolService.get({
includeHistoricalData,
dataGatheringItem: {
dataSource: ghostfolioFearAndGreedIndexDataSourceStocks,
symbol: ghostfolioFearAndGreedIndexSymbolStocks
},
useIntradayData: true
})
]);
return {
fearAndGreedIndex: {
CRYPTOCURRENCIES: {
...marketDataFearAndGreedIndexCryptocurrencies
},
STOCKS: {
...marketDataFearAndGreedIndexStocks
}
}
};
} }
@Post(':dataSource/:symbol') @Post(':dataSource/:symbol')

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

@ -110,26 +110,27 @@ export class PublicController {
? [] ? []
: activities.map( : activities.map(
({ ({
assetProfile,
currency, currency,
date, date,
fee, fee,
quantity, quantity,
SymbolProfile,
type, type,
unitPrice, unitPrice,
value, value,
valueInBaseCurrency valueInBaseCurrency
}) => { }) => {
return { return {
assetProfile,
currency, currency,
date, date,
fee, fee,
quantity, quantity,
SymbolProfile,
type, type,
unitPrice, unitPrice,
value, value,
valueInBaseCurrency valueInBaseCurrency,
SymbolProfile: assetProfile
}; };
} }
); );

12
apps/api/src/app/export/export.service.ts

@ -124,8 +124,8 @@ export class ExportService {
const customAssetProfiles = uniqBy( const customAssetProfiles = uniqBy(
activities activities
.map(({ SymbolProfile }) => { .map(({ assetProfile }) => {
return SymbolProfile; return assetProfile;
}) })
.filter(({ userId: assetProfileUserId }) => { .filter(({ userId: assetProfileUserId }) => {
return assetProfileUserId === userId; return assetProfileUserId === userId;
@ -224,13 +224,13 @@ export class ExportService {
activities: activities.map( activities: activities.map(
({ ({
accountId, accountId,
assetProfile,
comment, comment,
currency, currency,
date, date,
fee, fee,
id, id,
quantity, quantity,
SymbolProfile,
tags: currentTags, tags: currentTags,
type, type,
unitPrice unitPrice
@ -243,10 +243,10 @@ export class ExportService {
quantity, quantity,
type, type,
unitPrice, unitPrice,
currency: currency ?? SymbolProfile.currency, currency: currency ?? assetProfile.currency,
dataSource: SymbolProfile.dataSource, dataSource: assetProfile.dataSource,
date: date.toISOString(), date: date.toISOString(),
symbol: SymbolProfile.symbol, symbol: assetProfile.symbol,
tags: currentTags.map(({ id: tagId }) => { tags: currentTags.map(({ id: tagId }) => {
return tagId; return tagId;
}) })

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

@ -122,11 +122,11 @@ export class ImportService {
const isDuplicate = activities.some((activity) => { const isDuplicate = activities.some((activity) => {
return ( return (
activity.accountId === account?.id && activity.accountId === account?.id &&
activity.SymbolProfile.currency === assetProfile.currency && activity.assetProfile.currency === assetProfile.currency &&
activity.SymbolProfile.dataSource === assetProfile.dataSource && activity.assetProfile.dataSource === assetProfile.dataSource &&
isSameSecond(activity.date, date) && isSameSecond(activity.date, date) &&
activity.quantity === quantity && activity.quantity === quantity &&
activity.SymbolProfile.symbol === assetProfile.symbol && activity.assetProfile.symbol === assetProfile.symbol &&
activity.type === 'DIVIDEND' && activity.type === 'DIVIDEND' &&
activity.unitPrice === marketPrice activity.unitPrice === marketPrice
); );
@ -138,6 +138,7 @@ export class ImportService {
return { return {
account, account,
assetProfile,
date, date,
error, error,
quantity, quantity,
@ -463,19 +464,18 @@ export class ImportService {
const error = activity.error; const error = activity.error;
const fee = activity.fee; const fee = activity.fee;
const quantity = activity.quantity; const quantity = activity.quantity;
const SymbolProfile = activity.SymbolProfile;
const tagIds = activity.tagIds ?? []; const tagIds = activity.tagIds ?? [];
const type = activity.type; const type = activity.type;
const unitPrice = activity.unitPrice; const unitPrice = activity.unitPrice;
const assetProfile = assetProfiles[ const assetProfile = assetProfiles[
getAssetProfileIdentifier({ getAssetProfileIdentifier({
dataSource: SymbolProfile.dataSource, dataSource: activity.assetProfile.dataSource,
symbol: SymbolProfile.symbol symbol: activity.assetProfile.symbol
}) })
] ?? { ] ?? {
dataSource: SymbolProfile.dataSource, dataSource: activity.assetProfile.dataSource,
symbol: SymbolProfile.symbol symbol: activity.assetProfile.symbol
}; };
const { const {
assetClass, assetClass,
@ -619,6 +619,8 @@ export class ImportService {
activities.push({ activities.push({
...order, ...order,
// @ts-ignore
assetProfile,
error, error,
value, value,
valueInBaseCurrency, valueInBaseCurrency,
@ -633,19 +635,19 @@ export class ImportService {
if (!isDryRun) { if (!isDryRun) {
// Gather symbol data in the background, if not dry run // Gather symbol data in the background, if not dry run
const uniqueActivities = uniqBy(activities, ({ SymbolProfile }) => { const uniqueActivities = uniqBy(activities, ({ assetProfile }) => {
return getAssetProfileIdentifier({ return getAssetProfileIdentifier({
dataSource: SymbolProfile.dataSource, dataSource: assetProfile.dataSource,
symbol: SymbolProfile.symbol symbol: assetProfile.symbol
}); });
}); });
this.dataGatheringService.gatherSymbols({ this.dataGatheringService.gatherSymbols({
dataGatheringItems: uniqueActivities.map(({ date, SymbolProfile }) => { dataGatheringItems: uniqueActivities.map(({ assetProfile, date }) => {
return { return {
date, date,
dataSource: SymbolProfile.dataSource, dataSource: assetProfile.dataSource,
symbol: SymbolProfile.symbol symbol: assetProfile.symbol
}; };
}), }),
priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH
@ -692,12 +694,12 @@ export class ImportService {
activity.accountId === accountId && activity.accountId === accountId &&
activity.comment === comment && activity.comment === comment &&
(activity.currency === currency || (activity.currency === currency ||
activity.SymbolProfile.currency === currency) && activity.assetProfile.currency === currency) &&
activity.SymbolProfile.dataSource === dataSource && activity.assetProfile.dataSource === dataSource &&
isSameSecond(activity.date, date) && isSameSecond(activity.date, date) &&
activity.fee === fee && activity.fee === fee &&
activity.quantity === quantity && activity.quantity === quantity &&
activity.SymbolProfile.symbol === symbol && activity.assetProfile.symbol === symbol &&
activity.type === type && activity.type === type &&
activity.unitPrice === unitPrice activity.unitPrice === unitPrice
); );
@ -717,7 +719,7 @@ export class ImportService {
quantity, quantity,
type, type,
unitPrice, unitPrice,
SymbolProfile: { assetProfile: {
dataSource, dataSource,
symbol, symbol,
activitiesCount: undefined, activitiesCount: undefined,

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

@ -4,6 +4,7 @@ import { UserService } from '@ghostfolio/api/app/user/user.service';
import { encodeDataSource } from '@ghostfolio/api/helper/data-source.helper'; import { encodeDataSource } from '@ghostfolio/api/helper/data-source.helper';
import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service'; import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; 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 { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { import {
@ -15,8 +16,7 @@ import {
PROPERTY_GITHUB_STARGAZERS, PROPERTY_GITHUB_STARGAZERS,
PROPERTY_IS_READ_ONLY_MODE, PROPERTY_IS_READ_ONLY_MODE,
PROPERTY_SLACK_COMMUNITY_USERS, PROPERTY_SLACK_COMMUNITY_USERS,
PROPERTY_UPTIME, PROPERTY_UPTIME
ghostfolioFearAndGreedIndexDataSourceStocks
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { InfoItem, Statistics } from '@ghostfolio/common/interfaces'; import { InfoItem, Statistics } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
@ -33,6 +33,7 @@ export class InfoService {
public constructor( public constructor(
private readonly benchmarkService: BenchmarkService, private readonly benchmarkService: BenchmarkService,
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly jwtService: JwtService, private readonly jwtService: JwtService,
private readonly propertyService: PropertyService, private readonly propertyService: PropertyService,
@ -60,13 +61,15 @@ export class InfoService {
} }
if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) { if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) {
const fearAndGreedIndexDataSource =
this.dataProviderService.getDataSourceForFearAndGreedIndexStocks();
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
info.fearAndGreedDataSource = encodeDataSource( info.fearAndGreedDataSource = encodeDataSource(
ghostfolioFearAndGreedIndexDataSourceStocks fearAndGreedIndexDataSource
); );
} else { } else {
info.fearAndGreedDataSource = info.fearAndGreedDataSource = fearAndGreedIndexDataSource;
ghostfolioFearAndGreedIndexDataSourceStocks;
} }
globalPermissions.push(permissions.enableFearAndGreedIndex); globalPermissions.push(permissions.enableFearAndGreedIndex);

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

@ -22,7 +22,7 @@ export const activityDummyData = {
valueInBaseCurrency: undefined valueInBaseCurrency: undefined
}; };
export const symbolProfileDummyData = { export const assetProfileDummyData = {
activitiesCount: undefined, activitiesCount: undefined,
assetClass: undefined, assetClass: undefined,
assetSubClass: undefined, assetSubClass: undefined,

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

@ -121,11 +121,11 @@ export abstract class PortfolioCalculator {
this.activities = activities this.activities = activities
.map( .map(
({ ({
assetProfile,
date, date,
feeInAssetProfileCurrency, feeInAssetProfileCurrency,
feeInBaseCurrency, feeInBaseCurrency,
quantity, quantity,
SymbolProfile,
tags = [], tags = [],
type, type,
unitPriceInAssetProfileCurrency unitPriceInAssetProfileCurrency
@ -141,7 +141,7 @@ export abstract class PortfolioCalculator {
} }
return { return {
SymbolProfile, assetProfile,
tags, tags,
type, type,
date: format(date, DATE_FORMAT), date: format(date, DATE_FORMAT),
@ -930,23 +930,23 @@ export abstract class PortfolioCalculator {
let lastTransactionPoint: TransactionPoint = null; let lastTransactionPoint: TransactionPoint = null;
for (const { for (const {
assetProfile,
date, date,
fee, fee,
feeInBaseCurrency, feeInBaseCurrency,
quantity, quantity,
SymbolProfile,
tags, tags,
type, type,
unitPrice unitPrice
} of this.activities) { } of this.activities) {
let currentTransactionPointItem: TransactionPointSymbol; let currentTransactionPointItem: TransactionPointSymbol;
const assetSubClass = SymbolProfile.assetSubClass; const assetSubClass = assetProfile.assetSubClass;
const currency = SymbolProfile.currency; const currency = assetProfile.currency;
const dataSource = SymbolProfile.dataSource; const dataSource = assetProfile.dataSource;
const factor = getFactor(type); const factor = getFactor(type);
const skipErrors = !!SymbolProfile.userId; // Skip errors for custom asset profiles const skipErrors = !!assetProfile.userId; // Skip errors for custom asset profiles
const symbol = SymbolProfile.symbol; const symbol = assetProfile.symbol;
const oldAccumulatedSymbol = symbols[symbol]; const oldAccumulatedSymbol = symbols[symbol];
@ -1030,12 +1030,12 @@ export abstract class PortfolioCalculator {
'id' 'id'
); );
symbols[SymbolProfile.symbol] = currentTransactionPointItem; symbols[symbol] = currentTransactionPointItem;
const items = lastTransactionPoint?.items ?? []; const items = lastTransactionPoint?.items ?? [];
const newItems = items.filter(({ symbol }) => { const newItems = items.filter(({ symbol }) => {
return symbol !== SymbolProfile.symbol; return symbol !== assetProfile.symbol;
}); });
newItems.push(currentTransactionPointItem); newItems.push(currentTransactionPointItem);

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

@ -1,6 +1,6 @@
import { import {
activityDummyData, activityDummyData,
symbolProfileDummyData, assetProfileDummyData,
userDummyData userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
@ -85,33 +85,33 @@ describe('PortfolioCalculator', () => {
const activities: Activity[] = [ const activities: Activity[] = [
{ {
...activityDummyData, ...activityDummyData,
date: new Date('2021-11-22'), assetProfile: {
feeInAssetProfileCurrency: 1.55, ...assetProfileDummyData,
feeInBaseCurrency: 1.55,
quantity: 2,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'CHF', currency: 'CHF',
dataSource: 'YAHOO', dataSource: 'YAHOO',
name: 'Bâloise Holding AG', name: 'Bâloise Holding AG',
symbol: 'BALN.SW' symbol: 'BALN.SW'
}, },
date: new Date('2021-11-22'),
feeInAssetProfileCurrency: 1.55,
feeInBaseCurrency: 1.55,
quantity: 2,
type: 'BUY', type: 'BUY',
unitPriceInAssetProfileCurrency: 142.9 unitPriceInAssetProfileCurrency: 142.9
}, },
{ {
...activityDummyData, ...activityDummyData,
date: new Date('2021-11-30'), assetProfile: {
feeInAssetProfileCurrency: 1.65, ...assetProfileDummyData,
feeInBaseCurrency: 1.65,
quantity: 2,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'CHF', currency: 'CHF',
dataSource: 'YAHOO', dataSource: 'YAHOO',
name: 'Bâloise Holding AG', name: 'Bâloise Holding AG',
symbol: 'BALN.SW' symbol: 'BALN.SW'
}, },
date: new Date('2021-11-30'),
feeInAssetProfileCurrency: 1.65,
feeInBaseCurrency: 1.65,
quantity: 2,
type: 'BUY', type: 'BUY',
unitPriceInAssetProfileCurrency: 136.6 unitPriceInAssetProfileCurrency: 136.6
} }

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

@ -1,231 +1,231 @@
import { import {
activityDummyData, activityDummyData,
symbolProfileDummyData, assetProfileDummyData,
userDummyData userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper'; import { parseDate } from '@ghostfolio/common/helper';
import { Activity } from '@ghostfolio/common/interfaces'; import { Activity } from '@ghostfolio/common/interfaces';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Big } from 'big.js'; import { Big } from 'big.js';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return { return {
CurrentRateService: jest.fn().mockImplementation(() => { CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock; return CurrentRateServiceMock;
}) })
}; };
}); });
jest.mock( jest.mock(
'@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service', '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service',
() => { () => {
return { return {
PortfolioSnapshotService: jest.fn().mockImplementation(() => { PortfolioSnapshotService: jest.fn().mockImplementation(() => {
return PortfolioSnapshotServiceMock; return PortfolioSnapshotServiceMock;
}) })
}; };
} }
); );
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return { return {
RedisCacheService: jest.fn().mockImplementation(() => { RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock; return RedisCacheServiceMock;
}) })
}; };
}); });
describe('PortfolioCalculator', () => { describe('PortfolioCalculator', () => {
let configurationService: ConfigurationService; let configurationService: ConfigurationService;
let currentRateService: CurrentRateService; let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService; let exchangeRateDataService: ExchangeRateDataService;
let portfolioCalculatorFactory: PortfolioCalculatorFactory; let portfolioCalculatorFactory: PortfolioCalculatorFactory;
let portfolioSnapshotService: PortfolioSnapshotService; let portfolioSnapshotService: PortfolioSnapshotService;
let redisCacheService: RedisCacheService; let redisCacheService: RedisCacheService;
beforeEach(() => { beforeEach(() => {
configurationService = new ConfigurationService(); configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null); currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService( exchangeRateDataService = new ExchangeRateDataService(
null, null,
null, null,
null, null,
null null
); );
portfolioSnapshotService = new PortfolioSnapshotService(null, null); portfolioSnapshotService = new PortfolioSnapshotService(null, null);
redisCacheService = new RedisCacheService(null, null); redisCacheService = new RedisCacheService(null, null);
portfolioCalculatorFactory = new PortfolioCalculatorFactory( portfolioCalculatorFactory = new PortfolioCalculatorFactory(
configurationService, configurationService,
currentRateService, currentRateService,
exchangeRateDataService, exchangeRateDataService,
portfolioSnapshotService, portfolioSnapshotService,
redisCacheService redisCacheService
); );
}); });
describe('get current positions', () => { describe('get current positions', () => {
it.only('with BALN.SW buy and sell in two activities', async () => { it.only('with BALN.SW buy and sell in two activities', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2021-12-18').getTime()); jest.useFakeTimers().setSystemTime(parseDate('2021-12-18').getTime());
const activities: Activity[] = [ const activities: Activity[] = [
{ {
...activityDummyData, ...activityDummyData,
date: new Date('2021-11-22'), assetProfile: {
feeInAssetProfileCurrency: 1.55, ...assetProfileDummyData,
feeInBaseCurrency: 1.55, currency: 'CHF',
quantity: 2, dataSource: 'YAHOO',
SymbolProfile: { name: 'Bâloise Holding AG',
...symbolProfileDummyData, symbol: 'BALN.SW'
currency: 'CHF', },
dataSource: 'YAHOO', date: new Date('2021-11-22'),
name: 'Bâloise Holding AG', feeInAssetProfileCurrency: 1.55,
symbol: 'BALN.SW' feeInBaseCurrency: 1.55,
}, quantity: 2,
type: 'BUY', type: 'BUY',
unitPriceInAssetProfileCurrency: 142.9 unitPriceInAssetProfileCurrency: 142.9
}, },
{ {
...activityDummyData, ...activityDummyData,
date: new Date('2021-11-30'), assetProfile: {
feeInAssetProfileCurrency: 1.65, ...assetProfileDummyData,
feeInBaseCurrency: 1.65, currency: 'CHF',
quantity: 1, dataSource: 'YAHOO',
SymbolProfile: { name: 'Bâloise Holding AG',
...symbolProfileDummyData, symbol: 'BALN.SW'
currency: 'CHF', },
dataSource: 'YAHOO', date: new Date('2021-11-30'),
name: 'Bâloise Holding AG', feeInAssetProfileCurrency: 1.65,
symbol: 'BALN.SW' feeInBaseCurrency: 1.65,
}, quantity: 1,
type: 'SELL', type: 'SELL',
unitPriceInAssetProfileCurrency: 136.6 unitPriceInAssetProfileCurrency: 136.6
}, },
{ {
...activityDummyData, ...activityDummyData,
date: new Date('2021-11-30'), assetProfile: {
feeInAssetProfileCurrency: 0, ...assetProfileDummyData,
feeInBaseCurrency: 0, currency: 'CHF',
quantity: 1, dataSource: 'YAHOO',
SymbolProfile: { name: 'Bâloise Holding AG',
...symbolProfileDummyData, symbol: 'BALN.SW'
currency: 'CHF', },
dataSource: 'YAHOO', date: new Date('2021-11-30'),
name: 'Bâloise Holding AG', feeInAssetProfileCurrency: 0,
symbol: 'BALN.SW' feeInBaseCurrency: 0,
}, quantity: 1,
type: 'SELL', type: 'SELL',
unitPriceInAssetProfileCurrency: 136.6 unitPriceInAssetProfileCurrency: 136.6
} }
]; ];
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities, activities,
calculationType: PerformanceCalculationType.ROAI, calculationType: PerformanceCalculationType.ROAI,
currency: 'CHF', currency: 'CHF',
userId: userDummyData.id userId: userDummyData.id
}); });
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
const investments = portfolioCalculator.getInvestments(); const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({ const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
data: portfolioSnapshot.historicalData, data: portfolioSnapshot.historicalData,
groupBy: 'month' groupBy: 'month'
}); });
const investmentsByYear = portfolioCalculator.getInvestmentsByGroup({ const investmentsByYear = portfolioCalculator.getInvestmentsByGroup({
data: portfolioSnapshot.historicalData, data: portfolioSnapshot.historicalData,
groupBy: 'year' groupBy: 'year'
}); });
expect(portfolioSnapshot).toMatchObject({ expect(portfolioSnapshot).toMatchObject({
currentValueInBaseCurrency: new Big('0'), currentValueInBaseCurrency: new Big('0'),
errors: [], errors: [],
hasErrors: false, hasErrors: false,
positions: [ positions: [
{ {
activitiesCount: 3, activitiesCount: 3,
averagePrice: new Big('0'), averagePrice: new Big('0'),
currency: 'CHF', currency: 'CHF',
dataSource: 'YAHOO', dataSource: 'YAHOO',
dateOfFirstActivity: '2021-11-22', dateOfFirstActivity: '2021-11-22',
dividend: new Big('0'), dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'), dividendInBaseCurrency: new Big('0'),
fee: new Big('3.2'), fee: new Big('3.2'),
feeInBaseCurrency: new Big('3.2'), feeInBaseCurrency: new Big('3.2'),
grossPerformance: new Big('-12.6'), grossPerformance: new Big('-12.6'),
grossPerformancePercentage: new Big('-0.04408677396780965649'), grossPerformancePercentage: new Big('-0.04408677396780965649'),
grossPerformancePercentageWithCurrencyEffect: new Big( grossPerformancePercentageWithCurrencyEffect: new Big(
'-0.04408677396780965649' '-0.04408677396780965649'
), ),
grossPerformanceWithCurrencyEffect: new Big('-12.6'), grossPerformanceWithCurrencyEffect: new Big('-12.6'),
investment: new Big('0'), investment: new Big('0'),
investmentWithCurrencyEffect: new Big('0'), investmentWithCurrencyEffect: new Big('0'),
netPerformancePercentageWithCurrencyEffectMap: { netPerformancePercentageWithCurrencyEffectMap: {
max: new Big('-0.0552834149755073478') max: new Big('-0.0552834149755073478')
}, },
netPerformanceWithCurrencyEffectMap: { netPerformanceWithCurrencyEffectMap: {
max: new Big('-15.8') max: new Big('-15.8')
}, },
marketPrice: 148.9, marketPrice: 148.9,
marketPriceInBaseCurrency: 148.9, marketPriceInBaseCurrency: 148.9,
quantity: new Big('0'), quantity: new Big('0'),
symbol: 'BALN.SW', symbol: 'BALN.SW',
tags: [], tags: [],
timeWeightedInvestment: new Big('285.80000000000000396627'), timeWeightedInvestment: new Big('285.80000000000000396627'),
timeWeightedInvestmentWithCurrencyEffect: new Big( timeWeightedInvestmentWithCurrencyEffect: new Big(
'285.80000000000000396627' '285.80000000000000396627'
), ),
valueInBaseCurrency: new Big('0') valueInBaseCurrency: new Big('0')
} }
], ],
totalFeesWithCurrencyEffect: new Big('3.2'), totalFeesWithCurrencyEffect: new Big('3.2'),
totalInterestWithCurrencyEffect: new Big('0'), totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: new Big('0'), totalInvestment: new Big('0'),
totalInvestmentWithCurrencyEffect: new Big('0'), totalInvestmentWithCurrencyEffect: new Big('0'),
totalLiabilitiesWithCurrencyEffect: new Big('0') totalLiabilitiesWithCurrencyEffect: new Big('0')
}); });
expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject( expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject(
expect.objectContaining({ expect.objectContaining({
netPerformance: -15.8, netPerformance: -15.8,
netPerformanceInPercentage: -0.05528341497550734703, netPerformanceInPercentage: -0.05528341497550734703,
netPerformanceInPercentageWithCurrencyEffect: -0.05528341497550734703, netPerformanceInPercentageWithCurrencyEffect: -0.05528341497550734703,
netPerformanceWithCurrencyEffect: -15.8, netPerformanceWithCurrencyEffect: -15.8,
totalInvestment: 0, totalInvestment: 0,
totalInvestmentValueWithCurrencyEffect: 0 totalInvestmentValueWithCurrencyEffect: 0
}) })
); );
expect(investments).toEqual([ expect(investments).toEqual([
{ date: '2021-11-22', investment: new Big('285.8') }, { date: '2021-11-22', investment: new Big('285.8') },
{ date: '2021-11-30', investment: new Big('0') } { date: '2021-11-30', investment: new Big('0') }
]); ]);
expect(investmentsByMonth).toEqual([ expect(investmentsByMonth).toEqual([
{ date: '2021-11-01', investment: 0 }, { date: '2021-11-01', investment: 0 },
{ date: '2021-12-01', investment: 0 } { date: '2021-12-01', investment: 0 }
]); ]);
expect(investmentsByYear).toEqual([ expect(investmentsByYear).toEqual([
{ date: '2021-01-01', investment: 0 } { date: '2021-01-01', investment: 0 }
]); ]);
}); });
}); });
}); });

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

@ -1,6 +1,6 @@
import { import {
activityDummyData, activityDummyData,
symbolProfileDummyData, assetProfileDummyData,
userDummyData userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
@ -85,33 +85,33 @@ describe('PortfolioCalculator', () => {
const activities: Activity[] = [ const activities: Activity[] = [
{ {
...activityDummyData, ...activityDummyData,
date: new Date('2021-11-22'), assetProfile: {
feeInAssetProfileCurrency: 1.55, ...assetProfileDummyData,
feeInBaseCurrency: 1.55,
quantity: 2,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'CHF', currency: 'CHF',
dataSource: 'YAHOO', dataSource: 'YAHOO',
name: 'Bâloise Holding AG', name: 'Bâloise Holding AG',
symbol: 'BALN.SW' symbol: 'BALN.SW'
}, },
date: new Date('2021-11-22'),
feeInAssetProfileCurrency: 1.55,
feeInBaseCurrency: 1.55,
quantity: 2,
type: 'BUY', type: 'BUY',
unitPriceInAssetProfileCurrency: 142.9 unitPriceInAssetProfileCurrency: 142.9
}, },
{ {
...activityDummyData, ...activityDummyData,
date: new Date('2021-11-30'), assetProfile: {
feeInAssetProfileCurrency: 1.65, ...assetProfileDummyData,
feeInBaseCurrency: 1.65,
quantity: 2,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'CHF', currency: 'CHF',
dataSource: 'YAHOO', dataSource: 'YAHOO',
name: 'Bâloise Holding AG', name: 'Bâloise Holding AG',
symbol: 'BALN.SW' symbol: 'BALN.SW'
}, },
date: new Date('2021-11-30'),
feeInAssetProfileCurrency: 1.65,
feeInBaseCurrency: 1.65,
quantity: 2,
type: 'SELL', type: 'SELL',
unitPriceInAssetProfileCurrency: 136.6 unitPriceInAssetProfileCurrency: 136.6
} }

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

@ -1,6 +1,6 @@
import { import {
activityDummyData, activityDummyData,
symbolProfileDummyData, assetProfileDummyData,
userDummyData userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
@ -85,17 +85,17 @@ describe('PortfolioCalculator', () => {
const activities: Activity[] = [ const activities: Activity[] = [
{ {
...activityDummyData, ...activityDummyData,
date: new Date('2021-11-30'), assetProfile: {
feeInAssetProfileCurrency: 1.55, ...assetProfileDummyData,
feeInBaseCurrency: 1.55,
quantity: 2,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'CHF', currency: 'CHF',
dataSource: 'YAHOO', dataSource: 'YAHOO',
name: 'Bâloise Holding AG', name: 'Bâloise Holding AG',
symbol: 'BALN.SW' symbol: 'BALN.SW'
}, },
date: new Date('2021-11-30'),
feeInAssetProfileCurrency: 1.55,
feeInBaseCurrency: 1.55,
quantity: 2,
type: 'BUY', type: 'BUY',
unitPriceInAssetProfileCurrency: 136.6 unitPriceInAssetProfileCurrency: 136.6
} }
@ -217,17 +217,17 @@ describe('PortfolioCalculator', () => {
const activities: Activity[] = [ const activities: Activity[] = [
{ {
...activityDummyData, ...activityDummyData,
date: new Date('2021-11-30'), assetProfile: {
feeInAssetProfileCurrency: 1.55, ...assetProfileDummyData,
feeInBaseCurrency: 1.55,
quantity: 2,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'CHF', currency: 'CHF',
dataSource: 'YAHOO', dataSource: 'YAHOO',
name: 'Bâloise Holding AG', name: 'Bâloise Holding AG',
symbol: 'BALN.SW' symbol: 'BALN.SW'
}, },
date: new Date('2021-11-30'),
feeInAssetProfileCurrency: 1.55,
feeInBaseCurrency: 1.55,
quantity: 2,
type: 'BUY', type: 'BUY',
unitPriceInAssetProfileCurrency: 135.0 unitPriceInAssetProfileCurrency: 135.0
} }
@ -257,17 +257,17 @@ describe('PortfolioCalculator', () => {
const activities: Activity[] = [ const activities: Activity[] = [
{ {
...activityDummyData, ...activityDummyData,
date: new Date('2021-11-30'), assetProfile: {
feeInAssetProfileCurrency: 1.55, ...assetProfileDummyData,
feeInBaseCurrency: 1.55,
quantity: 2,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'CHF', currency: 'CHF',
dataSource: 'YAHOO', dataSource: 'YAHOO',
name: 'Bâloise Holding AG', name: 'Bâloise Holding AG',
symbol: 'BALN.SW' symbol: 'BALN.SW'
}, },
date: new Date('2021-11-30'),
feeInAssetProfileCurrency: 1.55,
feeInBaseCurrency: 1.55,
quantity: 2,
type: 'BUY', type: 'BUY',
unitPriceInAssetProfileCurrency: 135.0 unitPriceInAssetProfileCurrency: 135.0
} }

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

@ -1,7 +1,7 @@
import { import {
activityDummyData, activityDummyData,
assetProfileDummyData,
loadExportFile, loadExportFile,
symbolProfileDummyData,
userDummyData userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
@ -107,17 +107,17 @@ describe('PortfolioCalculator', () => {
const activities: Activity[] = exportResponse.activities.map( const activities: Activity[] = exportResponse.activities.map(
(activity) => ({ (activity) => ({
...activityDummyData, ...activityDummyData,
...activity, assetProfile: {
date: parseDate(activity.date), ...assetProfileDummyData,
feeInAssetProfileCurrency: 4.46,
feeInBaseCurrency: 3.94,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'USD', currency: 'USD',
dataSource: activity.dataSource, dataSource: activity.dataSource,
name: 'Bitcoin', name: 'Bitcoin',
symbol: activity.symbol symbol: activity.symbol
}, },
...activity,
date: parseDate(activity.date),
feeInAssetProfileCurrency: 4.46,
feeInBaseCurrency: 3.94,
unitPriceInAssetProfileCurrency: 44558.42 unitPriceInAssetProfileCurrency: 44558.42
}) })
); );

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

@ -1,7 +1,7 @@
import { import {
activityDummyData, activityDummyData,
assetProfileDummyData,
loadExportFile, loadExportFile,
symbolProfileDummyData,
userDummyData userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
@ -95,17 +95,17 @@ describe('PortfolioCalculator', () => {
const activities: Activity[] = exportResponse.activities.map( const activities: Activity[] = exportResponse.activities.map(
(activity) => ({ (activity) => ({
...activityDummyData, ...activityDummyData,
...activity, assetProfile: {
date: parseDate(activity.date), ...assetProfileDummyData,
feeInAssetProfileCurrency: 4.46,
feeInBaseCurrency: 4.46,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'USD', currency: 'USD',
dataSource: activity.dataSource, dataSource: activity.dataSource,
name: 'Bitcoin', name: 'Bitcoin',
symbol: activity.symbol symbol: activity.symbol
}, },
...activity,
date: parseDate(activity.date),
feeInAssetProfileCurrency: 4.46,
feeInBaseCurrency: 4.46,
unitPriceInAssetProfileCurrency: 44558.42 unitPriceInAssetProfileCurrency: 44558.42
}) })
); );

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

@ -1,6 +1,6 @@
import { import {
activityDummyData, activityDummyData,
symbolProfileDummyData, assetProfileDummyData,
userDummyData userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
@ -98,33 +98,33 @@ describe('PortfolioCalculator', () => {
const activities: Activity[] = [ const activities: Activity[] = [
{ {
...activityDummyData, ...activityDummyData,
date: new Date('2015-01-01'), assetProfile: {
feeInAssetProfileCurrency: 0, ...assetProfileDummyData,
feeInBaseCurrency: 0,
quantity: 2,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'USD', currency: 'USD',
dataSource: 'YAHOO', dataSource: 'YAHOO',
name: 'Bitcoin USD', name: 'Bitcoin USD',
symbol: 'BTCUSD' symbol: 'BTCUSD'
}, },
date: new Date('2015-01-01'),
feeInAssetProfileCurrency: 0,
feeInBaseCurrency: 0,
quantity: 2,
type: 'BUY', type: 'BUY',
unitPriceInAssetProfileCurrency: 320.43 unitPriceInAssetProfileCurrency: 320.43
}, },
{ {
...activityDummyData, ...activityDummyData,
date: new Date('2017-12-31'), assetProfile: {
feeInAssetProfileCurrency: 0, ...assetProfileDummyData,
feeInBaseCurrency: 0,
quantity: 1,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'USD', currency: 'USD',
dataSource: 'YAHOO', dataSource: 'YAHOO',
name: 'Bitcoin USD', name: 'Bitcoin USD',
symbol: 'BTCUSD' symbol: 'BTCUSD'
}, },
date: new Date('2017-12-31'),
feeInAssetProfileCurrency: 0,
feeInBaseCurrency: 0,
quantity: 1,
type: 'SELL', type: 'SELL',
unitPriceInAssetProfileCurrency: 14156.4 unitPriceInAssetProfileCurrency: 14156.4
} }

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

@ -1,7 +1,7 @@
import { import {
activityDummyData, activityDummyData,
assetProfileDummyData,
loadExportFile, loadExportFile,
symbolProfileDummyData,
userDummyData userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
@ -96,16 +96,16 @@ describe('PortfolioCalculator', () => {
(activity) => ({ (activity) => ({
...activityDummyData, ...activityDummyData,
...activity, ...activity,
date: parseDate(activity.date), assetProfile: {
feeInAssetProfileCurrency: activity.fee, ...assetProfileDummyData,
feeInBaseCurrency: activity.fee,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'USD', currency: 'USD',
dataSource: activity.dataSource, dataSource: activity.dataSource,
name: 'Bitcoin', name: 'Bitcoin',
symbol: activity.symbol symbol: activity.symbol
}, },
date: parseDate(activity.date),
feeInAssetProfileCurrency: activity.fee,
feeInBaseCurrency: activity.fee,
unitPriceInAssetProfileCurrency: activity.unitPrice unitPriceInAssetProfileCurrency: activity.unitPrice
}) })
); );

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

@ -1,7 +1,7 @@
import { import {
activityDummyData, activityDummyData,
assetProfileDummyData,
loadExportFile, loadExportFile,
symbolProfileDummyData,
userDummyData userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
@ -96,16 +96,16 @@ describe('PortfolioCalculator', () => {
(activity) => ({ (activity) => ({
...activityDummyData, ...activityDummyData,
...activity, ...activity,
date: parseDate(activity.date), assetProfile: {
feeInAssetProfileCurrency: 4.46, ...assetProfileDummyData,
feeInBaseCurrency: 4.46,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'USD', currency: 'USD',
dataSource: activity.dataSource, dataSource: activity.dataSource,
name: 'Bitcoin', name: 'Bitcoin',
symbol: activity.symbol symbol: activity.symbol
}, },
date: parseDate(activity.date),
feeInAssetProfileCurrency: 4.46,
feeInBaseCurrency: 4.46,
unitPriceInAssetProfileCurrency: 44558.42 unitPriceInAssetProfileCurrency: 44558.42
}) })
); );

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

@ -1,6 +1,6 @@
import { import {
activityDummyData, activityDummyData,
symbolProfileDummyData, assetProfileDummyData,
userDummyData userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
@ -85,17 +85,17 @@ describe('PortfolioCalculator', () => {
const activities: Activity[] = [ const activities: Activity[] = [
{ {
...activityDummyData, ...activityDummyData,
date: new Date('2021-09-01'), assetProfile: {
feeInAssetProfileCurrency: 49, ...assetProfileDummyData,
feeInBaseCurrency: 49,
quantity: 0,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'USD', currency: 'USD',
dataSource: 'MANUAL', dataSource: 'MANUAL',
name: 'Account Opening Fee', name: 'Account Opening Fee',
symbol: '2c463fb3-af07-486e-adb0-8301b3d72141' symbol: '2c463fb3-af07-486e-adb0-8301b3d72141'
}, },
date: new Date('2021-09-01'),
feeInAssetProfileCurrency: 49,
feeInBaseCurrency: 49,
quantity: 0,
type: 'FEE', type: 'FEE',
unitPriceInAssetProfileCurrency: 0 unitPriceInAssetProfileCurrency: 0
} }

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

@ -1,6 +1,6 @@
import { import {
activityDummyData, activityDummyData,
symbolProfileDummyData, assetProfileDummyData,
userDummyData userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
@ -97,17 +97,17 @@ describe('PortfolioCalculator', () => {
const activities: Activity[] = [ const activities: Activity[] = [
{ {
...activityDummyData, ...activityDummyData,
date: new Date('2023-01-03'), assetProfile: {
feeInAssetProfileCurrency: 1, ...assetProfileDummyData,
feeInBaseCurrency: 0.9238,
quantity: 1,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'USD', currency: 'USD',
dataSource: 'YAHOO', dataSource: 'YAHOO',
name: 'Alphabet Inc.', name: 'Alphabet Inc.',
symbol: 'GOOGL' symbol: 'GOOGL'
}, },
date: new Date('2023-01-03'),
feeInAssetProfileCurrency: 1,
feeInBaseCurrency: 0.9238,
quantity: 1,
type: 'BUY', type: 'BUY',
unitPriceInAssetProfileCurrency: 89.12 unitPriceInAssetProfileCurrency: 89.12
} }

380
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-jnug-buy-and-sell-and-buy-and-sell.spec.ts

@ -1,190 +1,190 @@
import { import {
activityDummyData, activityDummyData,
loadExportFile, assetProfileDummyData,
symbolProfileDummyData, loadExportFile,
userDummyData userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper'; import { parseDate } from '@ghostfolio/common/helper';
import { Activity, ExportResponse } from '@ghostfolio/common/interfaces'; import { Activity, ExportResponse } from '@ghostfolio/common/interfaces';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { join } from 'node:path'; import { join } from 'node:path';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return { return {
CurrentRateService: jest.fn().mockImplementation(() => { CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock; return CurrentRateServiceMock;
}) })
}; };
}); });
jest.mock( jest.mock(
'@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service', '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service',
() => { () => {
return { return {
PortfolioSnapshotService: jest.fn().mockImplementation(() => { PortfolioSnapshotService: jest.fn().mockImplementation(() => {
return PortfolioSnapshotServiceMock; return PortfolioSnapshotServiceMock;
}) })
}; };
} }
); );
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return { return {
RedisCacheService: jest.fn().mockImplementation(() => { RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock; return RedisCacheServiceMock;
}) })
}; };
}); });
describe('PortfolioCalculator', () => { describe('PortfolioCalculator', () => {
let exportResponse: ExportResponse; let exportResponse: ExportResponse;
let configurationService: ConfigurationService; let configurationService: ConfigurationService;
let currentRateService: CurrentRateService; let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService; let exchangeRateDataService: ExchangeRateDataService;
let portfolioCalculatorFactory: PortfolioCalculatorFactory; let portfolioCalculatorFactory: PortfolioCalculatorFactory;
let portfolioSnapshotService: PortfolioSnapshotService; let portfolioSnapshotService: PortfolioSnapshotService;
let redisCacheService: RedisCacheService; let redisCacheService: RedisCacheService;
beforeAll(() => { beforeAll(() => {
exportResponse = loadExportFile( exportResponse = loadExportFile(
join( join(
__dirname, __dirname,
'../../../../../../../test/import/ok/jnug-buy-and-sell-and-buy-and-sell.json' '../../../../../../../test/import/ok/jnug-buy-and-sell-and-buy-and-sell.json'
) )
); );
}); });
beforeEach(() => { beforeEach(() => {
configurationService = new ConfigurationService(); configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null); currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService( exchangeRateDataService = new ExchangeRateDataService(
null, null,
null, null,
null, null,
null null
); );
portfolioSnapshotService = new PortfolioSnapshotService(null, null); portfolioSnapshotService = new PortfolioSnapshotService(null, null);
redisCacheService = new RedisCacheService(null, null); redisCacheService = new RedisCacheService(null, null);
portfolioCalculatorFactory = new PortfolioCalculatorFactory( portfolioCalculatorFactory = new PortfolioCalculatorFactory(
configurationService, configurationService,
currentRateService, currentRateService,
exchangeRateDataService, exchangeRateDataService,
portfolioSnapshotService, portfolioSnapshotService,
redisCacheService redisCacheService
); );
}); });
describe('get current positions', () => { describe('get current positions', () => {
it.only('with JNUG buy and sell', async () => { it.only('with JNUG buy and sell', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2025-12-28').getTime()); jest.useFakeTimers().setSystemTime(parseDate('2025-12-28').getTime());
const activities: Activity[] = exportResponse.activities.map( const activities: Activity[] = exportResponse.activities.map(
(activity) => ({ (activity) => ({
...activityDummyData, ...activityDummyData,
...activity, ...activity,
date: parseDate(activity.date), assetProfile: {
feeInAssetProfileCurrency: activity.fee, ...assetProfileDummyData,
feeInBaseCurrency: activity.fee, currency: activity.currency,
SymbolProfile: { dataSource: activity.dataSource,
...symbolProfileDummyData, name: 'Direxion Daily Junior Gold Miners Index Bull 2X Shares',
currency: activity.currency, symbol: activity.symbol
dataSource: activity.dataSource, },
name: 'Direxion Daily Junior Gold Miners Index Bull 2X Shares', date: parseDate(activity.date),
symbol: activity.symbol feeInAssetProfileCurrency: activity.fee,
}, feeInBaseCurrency: activity.fee,
unitPriceInAssetProfileCurrency: activity.unitPrice unitPriceInAssetProfileCurrency: activity.unitPrice
}) })
); );
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities, activities,
calculationType: PerformanceCalculationType.ROAI, calculationType: PerformanceCalculationType.ROAI,
currency: exportResponse.user.settings.currency, currency: exportResponse.user.settings.currency,
userId: userDummyData.id userId: userDummyData.id
}); });
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
const investments = portfolioCalculator.getInvestments(); const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({ const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
data: portfolioSnapshot.historicalData, data: portfolioSnapshot.historicalData,
groupBy: 'month' groupBy: 'month'
}); });
const investmentsByYear = portfolioCalculator.getInvestmentsByGroup({ const investmentsByYear = portfolioCalculator.getInvestmentsByGroup({
data: portfolioSnapshot.historicalData, data: portfolioSnapshot.historicalData,
groupBy: 'year' groupBy: 'year'
}); });
expect(portfolioSnapshot).toMatchObject({ expect(portfolioSnapshot).toMatchObject({
currentValueInBaseCurrency: new Big('0'), currentValueInBaseCurrency: new Big('0'),
errors: [], errors: [],
hasErrors: false, hasErrors: false,
positions: [ positions: [
{ {
activitiesCount: 4, activitiesCount: 4,
averagePrice: new Big('0'), averagePrice: new Big('0'),
currency: 'USD', currency: 'USD',
dataSource: 'YAHOO', dataSource: 'YAHOO',
dateOfFirstActivity: '2025-12-11', dateOfFirstActivity: '2025-12-11',
dividend: new Big('0'), dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'), dividendInBaseCurrency: new Big('0'),
fee: new Big('4'), fee: new Big('4'),
feeInBaseCurrency: new Big('4'), feeInBaseCurrency: new Big('4'),
grossPerformance: new Big('43.95'), // (1890.00 - 1885.05) + (2080.10 - 2041.10) grossPerformance: new Big('43.95'), // (1890.00 - 1885.05) + (2080.10 - 2041.10)
grossPerformanceWithCurrencyEffect: new Big('43.95'), // (1890.00 - 1885.05) + (2080.10 - 2041.10) grossPerformanceWithCurrencyEffect: new Big('43.95'), // (1890.00 - 1885.05) + (2080.10 - 2041.10)
investment: new Big('0'), investment: new Big('0'),
investmentWithCurrencyEffect: new Big('0'), investmentWithCurrencyEffect: new Big('0'),
netPerformance: new Big('39.95'), // (1890.00 - 1885.05) + (2080.10 - 2041.10) - 4 netPerformance: new Big('39.95'), // (1890.00 - 1885.05) + (2080.10 - 2041.10) - 4
netPerformanceWithCurrencyEffectMap: { netPerformanceWithCurrencyEffectMap: {
max: new Big('39.95') // (1890.00 - 1885.05) + (2080.10 - 2041.10) - 4 max: new Big('39.95') // (1890.00 - 1885.05) + (2080.10 - 2041.10) - 4
}, },
marketPrice: 237.8000030517578, marketPrice: 237.8000030517578,
marketPriceInBaseCurrency: 237.8000030517578, marketPriceInBaseCurrency: 237.8000030517578,
quantity: new Big('0'), quantity: new Big('0'),
symbol: 'JNUG', symbol: 'JNUG',
tags: [], tags: [],
valueInBaseCurrency: new Big('0') valueInBaseCurrency: new Big('0')
} }
], ],
totalFeesWithCurrencyEffect: new Big('4'), totalFeesWithCurrencyEffect: new Big('4'),
totalInterestWithCurrencyEffect: new Big('0'), totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: new Big('0'), totalInvestment: new Big('0'),
totalInvestmentWithCurrencyEffect: new Big('0'), totalInvestmentWithCurrencyEffect: new Big('0'),
totalLiabilitiesWithCurrencyEffect: new Big('0') totalLiabilitiesWithCurrencyEffect: new Big('0')
}); });
expect(investments).toEqual([ expect(investments).toEqual([
{ date: '2025-12-11', investment: new Big('1885.05') }, { date: '2025-12-11', investment: new Big('1885.05') },
{ date: '2025-12-18', investment: new Big('2041.1') }, { date: '2025-12-18', investment: new Big('2041.1') },
{ date: '2025-12-28', investment: new Big('0') } { date: '2025-12-28', investment: new Big('0') }
]); ]);
expect(investmentsByMonth).toEqual([ expect(investmentsByMonth).toEqual([
{ date: '2025-12-01', investment: 0 } { date: '2025-12-01', investment: 0 }
]); ]);
expect(investmentsByYear).toEqual([ expect(investmentsByYear).toEqual([
{ date: '2025-01-01', investment: 0 } { date: '2025-01-01', investment: 0 }
]); ]);
}); });
}); });
}); });

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

@ -1,6 +1,6 @@
import { import {
activityDummyData, activityDummyData,
symbolProfileDummyData, assetProfileDummyData,
userDummyData userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
@ -85,17 +85,17 @@ describe('PortfolioCalculator', () => {
const activities: Activity[] = [ const activities: Activity[] = [
{ {
...activityDummyData, ...activityDummyData,
date: new Date('2023-01-01'), // Date in future assetProfile: {
feeInAssetProfileCurrency: 0, ...assetProfileDummyData,
feeInBaseCurrency: 0,
quantity: 1,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'USD', currency: 'USD',
dataSource: 'MANUAL', dataSource: 'MANUAL',
name: 'Loan', name: 'Loan',
symbol: '55196015-1365-4560-aa60-8751ae6d18f8' symbol: '55196015-1365-4560-aa60-8751ae6d18f8'
}, },
date: new Date('2023-01-01'), // Date in future
feeInAssetProfileCurrency: 0,
feeInBaseCurrency: 0,
quantity: 1,
type: 'LIABILITY', type: 'LIABILITY',
unitPriceInAssetProfileCurrency: 3000 unitPriceInAssetProfileCurrency: 3000
} }

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

@ -1,6 +1,6 @@
import { import {
activityDummyData, activityDummyData,
symbolProfileDummyData, assetProfileDummyData,
userDummyData userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
@ -78,49 +78,49 @@ describe('PortfolioCalculator', () => {
const activities: Activity[] = [ const activities: Activity[] = [
{ {
...activityDummyData, ...activityDummyData,
date: new Date('2024-03-08'), assetProfile: {
feeInAssetProfileCurrency: 0, ...assetProfileDummyData,
feeInBaseCurrency: 0,
quantity: 0.3333333333333333,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'USD', currency: 'USD',
dataSource: 'YAHOO', dataSource: 'YAHOO',
name: 'Microsoft Inc.', name: 'Microsoft Inc.',
symbol: 'MSFT' symbol: 'MSFT'
}, },
date: new Date('2024-03-08'),
feeInAssetProfileCurrency: 0,
feeInBaseCurrency: 0,
quantity: 0.3333333333333333,
type: 'BUY', type: 'BUY',
unitPriceInAssetProfileCurrency: 408 unitPriceInAssetProfileCurrency: 408
}, },
{ {
...activityDummyData, ...activityDummyData,
date: new Date('2024-03-13'), assetProfile: {
feeInAssetProfileCurrency: 0, ...assetProfileDummyData,
feeInBaseCurrency: 0,
quantity: 0.6666666666666666,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'USD', currency: 'USD',
dataSource: 'YAHOO', dataSource: 'YAHOO',
name: 'Microsoft Inc.', name: 'Microsoft Inc.',
symbol: 'MSFT' symbol: 'MSFT'
}, },
date: new Date('2024-03-13'),
feeInAssetProfileCurrency: 0,
feeInBaseCurrency: 0,
quantity: 0.6666666666666666,
type: 'BUY', type: 'BUY',
unitPriceInAssetProfileCurrency: 400 unitPriceInAssetProfileCurrency: 400
}, },
{ {
...activityDummyData, ...activityDummyData,
date: new Date('2024-03-14'), assetProfile: {
feeInAssetProfileCurrency: 0, ...assetProfileDummyData,
feeInBaseCurrency: 0,
quantity: 1,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'USD', currency: 'USD',
dataSource: 'YAHOO', dataSource: 'YAHOO',
name: 'Microsoft Inc.', name: 'Microsoft Inc.',
symbol: 'MSFT' symbol: 'MSFT'
}, },
date: new Date('2024-03-14'),
feeInAssetProfileCurrency: 0,
feeInBaseCurrency: 0,
quantity: 1,
type: 'SELL', type: 'SELL',
unitPriceInAssetProfileCurrency: 411 unitPriceInAssetProfileCurrency: 411
} }

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

@ -1,6 +1,6 @@
import { import {
activityDummyData, activityDummyData,
symbolProfileDummyData, assetProfileDummyData,
userDummyData userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
@ -85,33 +85,33 @@ describe('PortfolioCalculator', () => {
const activities: Activity[] = [ const activities: Activity[] = [
{ {
...activityDummyData, ...activityDummyData,
date: new Date('2021-09-16'), assetProfile: {
feeInAssetProfileCurrency: 19, ...assetProfileDummyData,
feeInBaseCurrency: 19,
quantity: 1,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'USD', currency: 'USD',
dataSource: 'YAHOO', dataSource: 'YAHOO',
name: 'Microsoft Inc.', name: 'Microsoft Inc.',
symbol: 'MSFT' symbol: 'MSFT'
}, },
date: new Date('2021-09-16'),
feeInAssetProfileCurrency: 19,
feeInBaseCurrency: 19,
quantity: 1,
type: 'BUY', type: 'BUY',
unitPriceInAssetProfileCurrency: 298.58 unitPriceInAssetProfileCurrency: 298.58
}, },
{ {
...activityDummyData, ...activityDummyData,
date: new Date('2021-11-16'), assetProfile: {
feeInAssetProfileCurrency: 0, ...assetProfileDummyData,
feeInBaseCurrency: 0,
quantity: 1,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'USD', currency: 'USD',
dataSource: 'YAHOO', dataSource: 'YAHOO',
name: 'Microsoft Inc.', name: 'Microsoft Inc.',
symbol: 'MSFT' symbol: 'MSFT'
}, },
date: new Date('2021-11-16'),
feeInAssetProfileCurrency: 0,
feeInBaseCurrency: 0,
quantity: 1,
type: 'DIVIDEND', type: 'DIVIDEND',
unitPriceInAssetProfileCurrency: 0.62 unitPriceInAssetProfileCurrency: 0.62
} }

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

@ -1,7 +1,7 @@
import { import {
activityDummyData, activityDummyData,
assetProfileDummyData,
loadExportFile, loadExportFile,
symbolProfileDummyData,
userDummyData userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
@ -99,16 +99,16 @@ describe('PortfolioCalculator', () => {
(activity) => ({ (activity) => ({
...activityDummyData, ...activityDummyData,
...activity, ...activity,
date: parseDate(activity.date), assetProfile: {
feeInAssetProfileCurrency: activity.fee, ...assetProfileDummyData,
feeInBaseCurrency: activity.fee,
SymbolProfile: {
...symbolProfileDummyData,
currency: activity.currency, currency: activity.currency,
dataSource: activity.dataSource, dataSource: activity.dataSource,
name: 'Novartis AG', name: 'Novartis AG',
symbol: activity.symbol symbol: activity.symbol
}, },
date: parseDate(activity.date),
feeInAssetProfileCurrency: activity.fee,
feeInBaseCurrency: activity.fee,
unitPriceInAssetProfileCurrency: activity.unitPrice unitPriceInAssetProfileCurrency: activity.unitPrice
}) })
); );

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

@ -1,264 +1,264 @@
import { import {
activityDummyData, activityDummyData,
loadExportFile, assetProfileDummyData,
symbolProfileDummyData, loadExportFile,
userDummyData userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper'; import { parseDate } from '@ghostfolio/common/helper';
import { Activity, ExportResponse } from '@ghostfolio/common/interfaces'; import { Activity, ExportResponse } from '@ghostfolio/common/interfaces';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { join } from 'node:path'; import { join } from 'node:path';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return { return {
CurrentRateService: jest.fn().mockImplementation(() => { CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock; return CurrentRateServiceMock;
}) })
}; };
}); });
jest.mock( jest.mock(
'@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service', '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service',
() => { () => {
return { return {
PortfolioSnapshotService: jest.fn().mockImplementation(() => { PortfolioSnapshotService: jest.fn().mockImplementation(() => {
return PortfolioSnapshotServiceMock; return PortfolioSnapshotServiceMock;
}) })
}; };
} }
); );
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return { return {
RedisCacheService: jest.fn().mockImplementation(() => { RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock; return RedisCacheServiceMock;
}) })
}; };
}); });
describe('PortfolioCalculator', () => { describe('PortfolioCalculator', () => {
let exportResponse: ExportResponse; let exportResponse: ExportResponse;
let configurationService: ConfigurationService; let configurationService: ConfigurationService;
let currentRateService: CurrentRateService; let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService; let exchangeRateDataService: ExchangeRateDataService;
let portfolioCalculatorFactory: PortfolioCalculatorFactory; let portfolioCalculatorFactory: PortfolioCalculatorFactory;
let portfolioSnapshotService: PortfolioSnapshotService; let portfolioSnapshotService: PortfolioSnapshotService;
let redisCacheService: RedisCacheService; let redisCacheService: RedisCacheService;
beforeAll(() => { beforeAll(() => {
exportResponse = loadExportFile( exportResponse = loadExportFile(
join( join(
__dirname, __dirname,
'../../../../../../../test/import/ok/novn-buy-and-sell.json' '../../../../../../../test/import/ok/novn-buy-and-sell.json'
) )
); );
}); });
beforeEach(() => { beforeEach(() => {
configurationService = new ConfigurationService(); configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null); currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService( exchangeRateDataService = new ExchangeRateDataService(
null, null,
null, null,
null, null,
null null
); );
portfolioSnapshotService = new PortfolioSnapshotService(null, null); portfolioSnapshotService = new PortfolioSnapshotService(null, null);
redisCacheService = new RedisCacheService(null, null); redisCacheService = new RedisCacheService(null, null);
portfolioCalculatorFactory = new PortfolioCalculatorFactory( portfolioCalculatorFactory = new PortfolioCalculatorFactory(
configurationService, configurationService,
currentRateService, currentRateService,
exchangeRateDataService, exchangeRateDataService,
portfolioSnapshotService, portfolioSnapshotService,
redisCacheService redisCacheService
); );
}); });
describe('get current positions', () => { describe('get current positions', () => {
it.only('with NOVN.SW buy and sell', async () => { it.only('with NOVN.SW buy and sell', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2022-04-11').getTime()); jest.useFakeTimers().setSystemTime(parseDate('2022-04-11').getTime());
const activities: Activity[] = exportResponse.activities.map( const activities: Activity[] = exportResponse.activities.map(
(activity) => ({ (activity) => ({
...activityDummyData, ...activityDummyData,
...activity, ...activity,
date: parseDate(activity.date), assetProfile: {
feeInAssetProfileCurrency: activity.fee, ...assetProfileDummyData,
feeInBaseCurrency: activity.fee, currency: activity.currency,
SymbolProfile: { dataSource: activity.dataSource,
...symbolProfileDummyData, name: 'Novartis AG',
currency: activity.currency, symbol: activity.symbol
dataSource: activity.dataSource, },
name: 'Novartis AG', date: parseDate(activity.date),
symbol: activity.symbol feeInAssetProfileCurrency: activity.fee,
}, feeInBaseCurrency: activity.fee,
unitPriceInAssetProfileCurrency: activity.unitPrice unitPriceInAssetProfileCurrency: activity.unitPrice
}) })
); );
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities, activities,
calculationType: PerformanceCalculationType.ROAI, calculationType: PerformanceCalculationType.ROAI,
currency: exportResponse.user.settings.currency, currency: exportResponse.user.settings.currency,
userId: userDummyData.id userId: userDummyData.id
}); });
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
const investments = portfolioCalculator.getInvestments(); const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({ const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
data: portfolioSnapshot.historicalData, data: portfolioSnapshot.historicalData,
groupBy: 'month' groupBy: 'month'
}); });
const investmentsByYear = portfolioCalculator.getInvestmentsByGroup({ const investmentsByYear = portfolioCalculator.getInvestmentsByGroup({
data: portfolioSnapshot.historicalData, data: portfolioSnapshot.historicalData,
groupBy: 'year' groupBy: 'year'
}); });
expect(portfolioSnapshot.historicalData[0]).toEqual({ expect(portfolioSnapshot.historicalData[0]).toEqual({
date: '2022-03-06', date: '2022-03-06',
investmentValueWithCurrencyEffect: 0, investmentValueWithCurrencyEffect: 0,
netPerformance: 0, netPerformance: 0,
netPerformanceInPercentage: 0, netPerformanceInPercentage: 0,
netPerformanceInPercentageWithCurrencyEffect: 0, netPerformanceInPercentageWithCurrencyEffect: 0,
netPerformanceWithCurrencyEffect: 0, netPerformanceWithCurrencyEffect: 0,
netWorth: 0, netWorth: 0,
totalAccountBalance: 0, totalAccountBalance: 0,
totalInvestment: 0, totalInvestment: 0,
totalInvestmentValueWithCurrencyEffect: 0, totalInvestmentValueWithCurrencyEffect: 0,
value: 0, value: 0,
valueWithCurrencyEffect: 0 valueWithCurrencyEffect: 0
}); });
/** /**
* Closing price on 2022-03-07 is unknown, * Closing price on 2022-03-07 is unknown,
* hence it uses the last unit price (2022-04-11): 87.8 * hence it uses the last unit price (2022-04-11): 87.8
*/ */
expect(portfolioSnapshot.historicalData[1]).toEqual({ expect(portfolioSnapshot.historicalData[1]).toEqual({
date: '2022-03-07', date: '2022-03-07',
investmentValueWithCurrencyEffect: 151.6, investmentValueWithCurrencyEffect: 151.6,
netPerformance: 24, // 2 * (87.8 - 75.8) = 24 netPerformance: 24, // 2 * (87.8 - 75.8) = 24
netPerformanceInPercentage: 0.158311345646438, // 24 ÷ 151.6 = 0.158311345646438 netPerformanceInPercentage: 0.158311345646438, // 24 ÷ 151.6 = 0.158311345646438
netPerformanceInPercentageWithCurrencyEffect: 0.158311345646438, // 24 ÷ 151.6 = 0.158311345646438 netPerformanceInPercentageWithCurrencyEffect: 0.158311345646438, // 24 ÷ 151.6 = 0.158311345646438
netPerformanceWithCurrencyEffect: 24, netPerformanceWithCurrencyEffect: 24,
netWorth: 175.6, // 2 * 87.8 = 175.6 netWorth: 175.6, // 2 * 87.8 = 175.6
totalAccountBalance: 0, totalAccountBalance: 0,
totalInvestment: 151.6, totalInvestment: 151.6,
totalInvestmentValueWithCurrencyEffect: 151.6, totalInvestmentValueWithCurrencyEffect: 151.6,
value: 175.6, // 2 * 87.8 = 175.6 value: 175.6, // 2 * 87.8 = 175.6
valueWithCurrencyEffect: 175.6 valueWithCurrencyEffect: 175.6
}); });
expect( expect(
portfolioSnapshot.historicalData[ portfolioSnapshot.historicalData[
portfolioSnapshot.historicalData.length - 1 portfolioSnapshot.historicalData.length - 1
] ]
).toEqual({ ).toEqual({
date: '2022-04-11', date: '2022-04-11',
investmentValueWithCurrencyEffect: 0, investmentValueWithCurrencyEffect: 0,
netPerformance: 19.86, netPerformance: 19.86,
netPerformanceInPercentage: 0.13100263852242744, netPerformanceInPercentage: 0.13100263852242744,
netPerformanceInPercentageWithCurrencyEffect: 0.13100263852242744, netPerformanceInPercentageWithCurrencyEffect: 0.13100263852242744,
netPerformanceWithCurrencyEffect: 19.86, netPerformanceWithCurrencyEffect: 19.86,
netWorth: 0, netWorth: 0,
totalAccountBalance: 0, totalAccountBalance: 0,
totalInvestment: 0, totalInvestment: 0,
totalInvestmentValueWithCurrencyEffect: 0, totalInvestmentValueWithCurrencyEffect: 0,
value: 0, value: 0,
valueWithCurrencyEffect: 0 valueWithCurrencyEffect: 0
}); });
expect(portfolioSnapshot).toMatchObject({ expect(portfolioSnapshot).toMatchObject({
currentValueInBaseCurrency: new Big('0'), currentValueInBaseCurrency: new Big('0'),
errors: [], errors: [],
hasErrors: false, hasErrors: false,
positions: [ positions: [
{ {
activitiesCount: 2, activitiesCount: 2,
averagePrice: new Big('0'), averagePrice: new Big('0'),
currency: 'CHF', currency: 'CHF',
dataSource: 'YAHOO', dataSource: 'YAHOO',
dateOfFirstActivity: '2022-03-07', dateOfFirstActivity: '2022-03-07',
dividend: new Big('0'), dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'), dividendInBaseCurrency: new Big('0'),
fee: new Big('0'), fee: new Big('0'),
feeInBaseCurrency: new Big('0'), feeInBaseCurrency: new Big('0'),
grossPerformance: new Big('19.86'), grossPerformance: new Big('19.86'),
grossPerformancePercentage: new Big('0.13100263852242744063'), grossPerformancePercentage: new Big('0.13100263852242744063'),
grossPerformancePercentageWithCurrencyEffect: new Big( grossPerformancePercentageWithCurrencyEffect: new Big(
'0.13100263852242744063' '0.13100263852242744063'
), ),
grossPerformanceWithCurrencyEffect: new Big('19.86'), grossPerformanceWithCurrencyEffect: new Big('19.86'),
investment: new Big('0'), investment: new Big('0'),
investmentWithCurrencyEffect: new Big('0'), investmentWithCurrencyEffect: new Big('0'),
netPerformance: new Big('19.86'), netPerformance: new Big('19.86'),
netPerformancePercentage: new Big('0.13100263852242744063'), netPerformancePercentage: new Big('0.13100263852242744063'),
netPerformancePercentageWithCurrencyEffectMap: { netPerformancePercentageWithCurrencyEffectMap: {
max: new Big('0.13100263852242744063') max: new Big('0.13100263852242744063')
}, },
netPerformanceWithCurrencyEffectMap: { netPerformanceWithCurrencyEffectMap: {
max: new Big('19.86') max: new Big('19.86')
}, },
marketPrice: 87.8, marketPrice: 87.8,
marketPriceInBaseCurrency: 87.8, marketPriceInBaseCurrency: 87.8,
quantity: new Big('0'), quantity: new Big('0'),
symbol: 'NOVN.SW', symbol: 'NOVN.SW',
tags: [], tags: [],
timeWeightedInvestment: new Big('151.6'), timeWeightedInvestment: new Big('151.6'),
timeWeightedInvestmentWithCurrencyEffect: new Big('151.6'), timeWeightedInvestmentWithCurrencyEffect: new Big('151.6'),
valueInBaseCurrency: new Big('0') valueInBaseCurrency: new Big('0')
} }
], ],
totalFeesWithCurrencyEffect: new Big('0'), totalFeesWithCurrencyEffect: new Big('0'),
totalInterestWithCurrencyEffect: new Big('0'), totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: new Big('0'), totalInvestment: new Big('0'),
totalInvestmentWithCurrencyEffect: new Big('0'), totalInvestmentWithCurrencyEffect: new Big('0'),
totalLiabilitiesWithCurrencyEffect: new Big('0') totalLiabilitiesWithCurrencyEffect: new Big('0')
}); });
expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject( expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject(
expect.objectContaining({ expect.objectContaining({
netPerformance: 19.86, netPerformance: 19.86,
netPerformanceInPercentage: 0.13100263852242744063, netPerformanceInPercentage: 0.13100263852242744063,
netPerformanceInPercentageWithCurrencyEffect: 0.13100263852242744063, netPerformanceInPercentageWithCurrencyEffect: 0.13100263852242744063,
netPerformanceWithCurrencyEffect: 19.86, netPerformanceWithCurrencyEffect: 19.86,
totalInvestment: 0, totalInvestment: 0,
totalInvestmentValueWithCurrencyEffect: 0 totalInvestmentValueWithCurrencyEffect: 0
}) })
); );
expect(investments).toEqual([ expect(investments).toEqual([
{ date: '2022-03-07', investment: new Big('151.6') }, { date: '2022-03-07', investment: new Big('151.6') },
{ date: '2022-04-08', investment: new Big('0') } { date: '2022-04-08', investment: new Big('0') }
]); ]);
expect(investmentsByMonth).toEqual([ expect(investmentsByMonth).toEqual([
{ date: '2022-03-01', investment: 151.6 }, { date: '2022-03-01', investment: 151.6 },
{ date: '2022-04-01', investment: -151.6 } { date: '2022-04-01', investment: -151.6 }
]); ]);
expect(investmentsByYear).toEqual([ expect(investmentsByYear).toEqual([
{ date: '2022-01-01', investment: 0 } { date: '2022-01-01', investment: 0 }
]); ]);
}); });
}); });
}); });

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

@ -1,6 +1,6 @@
import { import {
activityDummyData, activityDummyData,
symbolProfileDummyData, assetProfileDummyData,
userDummyData userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
@ -85,17 +85,17 @@ describe('PortfolioCalculator', () => {
const activities: Activity[] = [ const activities: Activity[] = [
{ {
...activityDummyData, ...activityDummyData,
date: new Date('2022-01-01'), assetProfile: {
feeInAssetProfileCurrency: 0, ...assetProfileDummyData,
feeInBaseCurrency: 0,
quantity: 1,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'USD', currency: 'USD',
dataSource: 'MANUAL', dataSource: 'MANUAL',
name: 'Penthouse Apartment', name: 'Penthouse Apartment',
symbol: 'dac95060-d4f2-4653-a253-2c45e6fb5cde' symbol: 'dac95060-d4f2-4653-a253-2c45e6fb5cde'
}, },
date: new Date('2022-01-01'),
feeInAssetProfileCurrency: 0,
feeInBaseCurrency: 0,
quantity: 1,
type: 'BUY', type: 'BUY',
unitPriceInAssetProfileCurrency: 500000 unitPriceInAssetProfileCurrency: 500000
} }

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

@ -192,12 +192,12 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
// Clone orders to keep the original values in this.orders // Clone orders to keep the original values in this.orders
let orders: PortfolioOrderItem[] = cloneDeep( let orders: PortfolioOrderItem[] = cloneDeep(
this.activities.filter(({ SymbolProfile }) => { this.activities.filter(({ assetProfile }) => {
return SymbolProfile.symbol === symbol; return assetProfile.symbol === symbol;
}) })
); );
const isCash = orders[0]?.SymbolProfile?.assetSubClass === 'CASH'; const isCash = orders[0]?.assetProfile?.assetSubClass === 'CASH';
if (orders.length <= 0) { if (orders.length <= 0) {
return { return {
@ -299,32 +299,30 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
}; };
} }
const assetProfile: PortfolioOrderItem['assetProfile'] = {
dataSource,
symbol,
assetSubClass: isCash ? 'CASH' : undefined
};
// Add a synthetic order at the start and the end date // Add a synthetic order at the start and the end date
orders.push({ orders.push({
assetProfile,
date: startDateString, date: startDateString,
fee: new Big(0), fee: new Big(0),
feeInBaseCurrency: new Big(0), feeInBaseCurrency: new Big(0),
itemType: 'start', itemType: 'start',
quantity: new Big(0), quantity: new Big(0),
SymbolProfile: {
dataSource,
symbol,
assetSubClass: isCash ? 'CASH' : undefined
},
type: 'BUY', type: 'BUY',
unitPrice: unitPriceAtStartDate unitPrice: unitPriceAtStartDate
}); });
orders.push({ orders.push({
assetProfile,
date: endDateString, date: endDateString,
fee: new Big(0), fee: new Big(0),
feeInBaseCurrency: new Big(0), feeInBaseCurrency: new Big(0),
itemType: 'end', itemType: 'end',
SymbolProfile: {
dataSource,
symbol,
assetSubClass: isCash ? 'CASH' : undefined
},
quantity: new Big(0), quantity: new Big(0),
type: 'BUY', type: 'BUY',
unitPrice: unitPriceAtEndDate unitPrice: unitPriceAtEndDate
@ -357,15 +355,11 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
} }
} else { } else {
orders.push({ orders.push({
assetProfile,
date: dateString, date: dateString,
fee: new Big(0), fee: new Big(0),
feeInBaseCurrency: new Big(0), feeInBaseCurrency: new Big(0),
quantity: new Big(0), quantity: new Big(0),
SymbolProfile: {
dataSource,
symbol,
assetSubClass: isCash ? 'CASH' : undefined
},
type: 'BUY', type: 'BUY',
unitPrice: marketSymbolMap[dateString]?.[symbol] ?? lastUnitPrice, unitPrice: marketSymbolMap[dateString]?.[symbol] ?? lastUnitPrice,
unitPriceFromMarketData: unitPriceFromMarketData:

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

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

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

@ -373,15 +373,15 @@ describe('PortfolioService', () => {
{ {
account, account,
accountId: account.id, accountId: account.id,
assetProfile: { symbol: 'AAPL' },
quantity: 1, quantity: 1,
SymbolProfile: { symbol: 'AAPL' },
type: 'BUY' type: 'BUY'
}, },
{ {
account: null, account: null,
accountId: null, accountId: null,
assetProfile: { symbol: 'BABA' },
quantity: 2, quantity: 2,
SymbolProfile: { symbol: 'BABA' },
type: 'BUY' type: 'BUY'
} }
], ],
@ -409,8 +409,8 @@ describe('PortfolioService', () => {
{ {
account, account,
accountId: account.id, accountId: account.id,
assetProfile: { symbol: 'AAPL' },
quantity: 1, quantity: 1,
SymbolProfile: { symbol: 'AAPL' },
type: 'BUY' type: 'BUY'
} }
], ],

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

@ -829,10 +829,9 @@ export class PortfolioService {
timeWeightedInvestmentWithCurrencyEffect timeWeightedInvestmentWithCurrencyEffect
} = holding; } = holding;
const activitiesOfHolding = activities.filter(({ SymbolProfile }) => { const activitiesOfHolding = activities.filter(({ assetProfile }) => {
return ( return (
SymbolProfile.dataSource === dataSource && assetProfile.dataSource === dataSource && assetProfile.symbol === symbol
SymbolProfile.symbol === symbol
); );
}); });
@ -2056,11 +2055,11 @@ export class PortfolioService {
.filter(({ isDraft, type }) => { .filter(({ isDraft, type }) => {
return isDraft === false && type === activityType; return isDraft === false && type === activityType;
}) })
.map(({ currency, quantity, SymbolProfile, unitPrice }) => { .map(({ assetProfile, currency, quantity, unitPrice }) => {
return new Big( return new Big(
this.exchangeRateDataService.toCurrency( this.exchangeRateDataService.toCurrency(
new Big(quantity).mul(unitPrice).toNumber(), new Big(quantity).mul(unitPrice).toNumber(),
currency ?? SymbolProfile.currency, currency ?? assetProfile.currency,
userCurrency userCurrency
) )
); );
@ -2198,16 +2197,11 @@ export class PortfolioService {
} }
} }
for (const { for (const { account, assetProfile, quantity, type } of ordersByAccount) {
account,
quantity,
SymbolProfile,
type
} of ordersByAccount) {
const currentValueOfSymbolInBaseCurrency = const currentValueOfSymbolInBaseCurrency =
getFactor(type) * getFactor(type) *
quantity * quantity *
(portfolioItemsNow[SymbolProfile.symbol]?.marketPriceInBaseCurrency ?? (portfolioItemsNow[assetProfile.symbol]?.marketPriceInBaseCurrency ??
0); 0);
if (accounts[account?.id || UNKNOWN_KEY]?.valueInBaseCurrency) { if (accounts[account?.id || UNKNOWN_KEY]?.valueInBaseCurrency) {

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

@ -1,3 +1,4 @@
import { getRedisConnectionUrl } from '@ghostfolio/api/helper/redis.helper';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
@ -14,16 +15,8 @@ import { RedisCacheService } from './redis-cache.service';
imports: [ConfigurationModule], imports: [ConfigurationModule],
inject: [ConfigurationService], inject: [ConfigurationService],
useFactory: async (configurationService: ConfigurationService) => { useFactory: async (configurationService: ConfigurationService) => {
const redisPassword = encodeURIComponent(
configurationService.get('REDIS_PASSWORD')
);
return { return {
stores: [ stores: [createKeyv(getRedisConnectionUrl(configurationService))],
createKeyv(
`redis://${redisPassword ? `:${redisPassword}` : ''}@${configurationService.get('REDIS_HOST')}:${configurationService.get('REDIS_PORT')}/${configurationService.get('REDIS_DB')}`
)
],
ttl: configurationService.get('CACHE_TTL') ttl: configurationService.get('CACHE_TTL')
}; };
} }

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

@ -1,6 +1,11 @@
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { DataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces'; import { DataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import {
ghostfolioFearAndGreedIndexDataSourceCryptocurrencies,
ghostfolioFearAndGreedIndexSymbolCryptocurrencies,
ghostfolioFearAndGreedIndexSymbolStocks
} from '@ghostfolio/common/config';
import { import {
DATE_FORMAT, DATE_FORMAT,
getAssetProfileIdentifier getAssetProfileIdentifier
@ -9,6 +14,7 @@ import {
DataProviderHistoricalResponse, DataProviderHistoricalResponse,
HistoricalDataItem, HistoricalDataItem,
LookupResponse, LookupResponse,
MarketDataOfMarketsResponse,
SymbolItem SymbolItem
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { UserWithSettings } from '@ghostfolio/common/types'; import { UserWithSettings } from '@ghostfolio/common/types';
@ -122,6 +128,46 @@ export class SymbolService {
}; };
} }
public async getMarketDataOfMarkets({
includeHistoricalData
}: {
includeHistoricalData: number;
}): Promise<MarketDataOfMarketsResponse> {
const [
marketDataFearAndGreedIndexCryptocurrencies,
marketDataFearAndGreedIndexStocks
] = await Promise.all([
this.get({
includeHistoricalData,
dataGatheringItem: {
dataSource: ghostfolioFearAndGreedIndexDataSourceCryptocurrencies,
symbol: ghostfolioFearAndGreedIndexSymbolCryptocurrencies
},
useIntradayData: true
}),
this.get({
includeHistoricalData,
dataGatheringItem: {
dataSource:
this.dataProviderService.getDataSourceForFearAndGreedIndexStocks(),
symbol: ghostfolioFearAndGreedIndexSymbolStocks
},
useIntradayData: true
})
]);
return {
fearAndGreedIndex: {
CRYPTOCURRENCIES: {
...marketDataFearAndGreedIndexCryptocurrencies
},
STOCKS: {
...marketDataFearAndGreedIndexStocks
}
}
};
}
public async lookup({ public async lookup({
includeIndices = false, includeIndices = false,
query, query,

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

@ -1,11 +1,16 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { CustomThrottlerGuard } from '@ghostfolio/api/guards/custom-throttler.guard';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; 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 { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.interceptor';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service'; import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config'; import {
HEADER_KEY_IMPERSONATION,
THROTTLE_SIGNUP_LIMIT,
THROTTLE_SIGNUP_TTL
} from '@ghostfolio/common/config';
import { import {
DeleteOwnUserDto, DeleteOwnUserDto,
UpdateOwnAccessTokenDto, UpdateOwnAccessTokenDto,
@ -37,6 +42,7 @@ import {
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { Throttle } from '@nestjs/throttler';
import { User as UserModel } from '@prisma/client'; import { User as UserModel } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { merge, size } from 'lodash'; import { merge, size } from 'lodash';
@ -128,6 +134,13 @@ export class UserController {
} }
@Post() @Post()
@Throttle({
default: {
limit: THROTTLE_SIGNUP_LIMIT,
ttl: THROTTLE_SIGNUP_TTL
}
})
@UseGuards(CustomThrottlerGuard)
public async signupUser(): Promise<UserItem> { public async signupUser(): Promise<UserItem> {
const isUserSignupEnabled = const isUserSignupEnabled =
await this.propertyService.isUserSignupEnabled(); await this.propertyService.isUserSignupEnabled();

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

@ -535,6 +535,14 @@ export class UserService {
user.subscription.offer.label = undefined; user.subscription.offer.label = undefined;
} }
if (
!hasRole(user, Role.DEMO) &&
(user.provider !== 'ANONYMOUS' ||
user.subscription?.type === SubscriptionType.Premium)
) {
currentPermissions.push(permissions.requestOwnUserDeletion);
}
if (hasRole(user, Role.ADMIN)) { if (hasRole(user, Role.ADMIN)) {
currentPermissions.push(permissions.syncDemoUserAccount); currentPermissions.push(permissions.syncDemoUserAccount);
} }

21
apps/api/src/guards/custom-throttler.guard.ts

@ -0,0 +1,21 @@
import { ExecutionContext, Injectable, Logger } from '@nestjs/common';
import { ThrottlerException, ThrottlerGuard } from '@nestjs/throttler';
@Injectable()
export class CustomThrottlerGuard extends ThrottlerGuard {
private readonly logger = new Logger(CustomThrottlerGuard.name);
public async canActivate(context: ExecutionContext): Promise<boolean> {
try {
return await super.canActivate(context);
} catch (error) {
if (error instanceof ThrottlerException) {
throw error;
}
this.logger.error(error);
return true;
}
}
}

22
apps/api/src/helper/redis.helper.ts

@ -0,0 +1,22 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
export function getRedisConnectionOptions(
configurationService: ConfigurationService
) {
return {
db: configurationService.get('REDIS_DB'),
host: configurationService.get('REDIS_HOST'),
password: configurationService.get('REDIS_PASSWORD'),
port: configurationService.get('REDIS_PORT')
};
}
export function getRedisConnectionUrl(
configurationService: ConfigurationService
): string {
const { db, host, password, port } =
getRedisConnectionOptions(configurationService);
const encodedPassword = encodeURIComponent(password);
return `redis://${encodedPassword ? `:${encodedPassword}` : ''}@${host}:${port}/${db}`;
}

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

@ -78,8 +78,12 @@ export class TransformDataSourceInResponseInterceptor<
valueMap, valueMap,
object: data, object: data,
paths: [ paths: [
'activities[*].assetProfile.dataSource',
'activities[*].dataSource', 'activities[*].dataSource',
/* @deprecated */
'activities[*].SymbolProfile.dataSource', 'activities[*].SymbolProfile.dataSource',
'assetProfile.dataSource', 'assetProfile.dataSource',
'benchmarks[*].dataSource', 'benchmarks[*].dataSource',
'errors[*].dataSource', 'errors[*].dataSource',
@ -88,7 +92,10 @@ export class TransformDataSourceInResponseInterceptor<
'holdings[*].assetProfile.dataSource', 'holdings[*].assetProfile.dataSource',
'holdings[*].dataSource', 'holdings[*].dataSource',
'items[*].dataSource', 'items[*].dataSource',
/* @deprecated */
'SymbolProfile.dataSource', 'SymbolProfile.dataSource',
'watchlist[*].dataSource' 'watchlist[*].dataSource'
] ]
}); });

20
apps/api/src/main.ts

@ -1,3 +1,4 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { import {
BULL_BOARD_ROUTE, BULL_BOARD_ROUTE,
DEFAULT_HOST, DEFAULT_HOST,
@ -39,6 +40,8 @@ async function bootstrap() {
) as LogLevel[]; ) as LogLevel[];
} catch {} } catch {}
await configApp.close();
const app = await NestFactory.create<NestExpressApplication>(AppModule, { const app = await NestFactory.create<NestExpressApplication>(AppModule, {
logger: logger:
customLogLevels ?? customLogLevels ??
@ -97,6 +100,23 @@ async function bootstrap() {
}); });
} }
const configurationService = app.get(ConfigurationService);
const trustProxy = configurationService.get('TRUST_PROXY');
if (trustProxy) {
app.set('trust proxy', trustProxy);
}
if (
configurationService.get('ENABLE_FEATURE_RATE_LIMITING') &&
trustProxy === ''
) {
logger.warn(
'Rate limiting is enabled, but TRUST_PROXY is not set. If the Ghostfolio application runs behind a reverse proxy, the rate limits are shared across all clients.'
);
}
const HOST = configService.get<string>('HOST') || DEFAULT_HOST; const HOST = configService.get<string>('HOST') || DEFAULT_HOST;
const PORT = configService.get<number>('PORT') || DEFAULT_PORT; const PORT = configService.get<number>('PORT') || DEFAULT_PORT;

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

@ -13,9 +13,31 @@ import {
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
import { bool, cleanEnv, host, json, num, port, str, url } from 'envalid'; import {
bool,
cleanEnv,
host,
json,
makeValidator,
num,
port,
str,
url
} from 'envalid';
import ms from 'ms'; import ms from 'ms';
const trustProxy = makeValidator<boolean | number | string>((input) => {
if (/^\d+$/.test(input)) {
return Number(input);
} else if (input === 'false') {
return false;
} else if (input === 'true') {
return true;
}
return input;
});
@Injectable() @Injectable()
export class ConfigurationService { export class ConfigurationService {
private readonly environmentConfiguration: Environment; private readonly environmentConfiguration: Environment;
@ -34,6 +56,9 @@ export class ConfigurationService {
CACHE_QUOTES_TTL: num({ default: ms('1 minute') }), CACHE_QUOTES_TTL: num({ default: ms('1 minute') }),
CACHE_TTL: num({ default: CACHE_TTL_NO_CACHE }), CACHE_TTL: num({ default: CACHE_TTL_NO_CACHE }),
DATA_SOURCE_EXCHANGE_RATES: str({ default: DataSource.YAHOO }), DATA_SOURCE_EXCHANGE_RATES: str({ default: DataSource.YAHOO }),
DATA_SOURCE_FEAR_AND_GREED_INDEX_STOCKS: str({
default: DataSource.MANUAL
}),
DATA_SOURCE_IMPORT: str({ default: DataSource.YAHOO }), DATA_SOURCE_IMPORT: str({ default: DataSource.YAHOO }),
DATA_SOURCES: json({ DATA_SOURCES: json({
default: [DataSource.COINGECKO, DataSource.MANUAL, DataSource.YAHOO] default: [DataSource.COINGECKO, DataSource.MANUAL, DataSource.YAHOO]
@ -47,6 +72,7 @@ export class ConfigurationService {
ENABLE_FEATURE_CRON: bool({ default: true }), ENABLE_FEATURE_CRON: bool({ default: true }),
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: bool({ default: false }), ENABLE_FEATURE_FEAR_AND_GREED_INDEX: bool({ default: false }),
ENABLE_FEATURE_GATHER_NEW_EXCHANGE_RATES: bool({ default: true }), ENABLE_FEATURE_GATHER_NEW_EXCHANGE_RATES: bool({ default: true }),
ENABLE_FEATURE_RATE_LIMITING: bool({ default: false }),
ENABLE_FEATURE_READ_ONLY_MODE: bool({ default: false }), ENABLE_FEATURE_READ_ONLY_MODE: bool({ default: false }),
ENABLE_FEATURE_STATISTICS: bool({ default: false }), ENABLE_FEATURE_STATISTICS: bool({ default: false }),
ENABLE_FEATURE_SUBSCRIPTION: bool({ default: false }), ENABLE_FEATURE_SUBSCRIPTION: bool({ default: false }),
@ -111,6 +137,7 @@ export class ConfigurationService {
default: environment.rootUrl default: environment.rootUrl
}), }),
STRIPE_SECRET_KEY: str({ default: '' }), STRIPE_SECRET_KEY: str({ default: '' }),
TRUST_PROXY: trustProxy({ default: '' }),
TWITTER_ACCESS_TOKEN: str({ default: 'dummyAccessToken' }), TWITTER_ACCESS_TOKEN: str({ default: 'dummyAccessToken' }),
TWITTER_ACCESS_TOKEN_SECRET: str({ default: 'dummyAccessTokenSecret' }), TWITTER_ACCESS_TOKEN_SECRET: str({ default: 'dummyAccessTokenSecret' }),
TWITTER_API_KEY: str({ default: 'dummyApiKey' }), TWITTER_API_KEY: str({ default: 'dummyApiKey' }),

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

@ -177,6 +177,12 @@ export class DataProviderService implements OnModuleInit {
]; ];
} }
public getDataSourceForFearAndGreedIndexStocks(): DataSource {
return DataSource[
this.configurationService.get('DATA_SOURCE_FEAR_AND_GREED_INDEX_STOCKS')
];
}
public getDataSourceForImport(): DataSource { public getDataSourceForImport(): DataSource {
return DataSource[this.configurationService.get('DATA_SOURCE_IMPORT')]; return DataSource[this.configurationService.get('DATA_SOURCE_IMPORT')];
} }

32
apps/api/src/services/data-provider/rapid-api/rapid-api.service.ts

@ -64,13 +64,15 @@ export class RapidApiService implements DataProviderInterface {
if (symbol === ghostfolioFearAndGreedIndexSymbolStocks) { if (symbol === ghostfolioFearAndGreedIndexSymbolStocks) {
const fgi = await this.getFearAndGreedIndex(); const fgi = await this.getFearAndGreedIndex();
return { if (fgi) {
[symbol]: { return {
[format(getYesterday(), DATE_FORMAT)]: { [symbol]: {
marketPrice: fgi.previousClose.value [format(getYesterday(), DATE_FORMAT)]: {
marketPrice: fgi.previousClose.value
}
} }
} };
}; }
} }
} catch (error) { } catch (error) {
throw new Error( throw new Error(
@ -101,14 +103,16 @@ export class RapidApiService implements DataProviderInterface {
if (symbol === ghostfolioFearAndGreedIndexSymbolStocks) { if (symbol === ghostfolioFearAndGreedIndexSymbolStocks) {
const fgi = await this.getFearAndGreedIndex(); const fgi = await this.getFearAndGreedIndex();
return { if (fgi) {
[symbol]: { return {
currency: undefined, [symbol]: {
dataSource: this.getName(), currency: undefined,
marketPrice: fgi.now.value, dataSource: this.getName(),
marketState: 'open' marketPrice: fgi.now.value,
} marketState: 'open'
}; }
};
}
} }
} catch (error) { } catch (error) {
this.logger.error(error); this.logger.error(error);

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

@ -13,6 +13,7 @@ export interface Environment extends CleanedEnvAccessors {
CACHE_QUOTES_TTL: number; CACHE_QUOTES_TTL: number;
CACHE_TTL: number; CACHE_TTL: number;
DATA_SOURCE_EXCHANGE_RATES: string; DATA_SOURCE_EXCHANGE_RATES: string;
DATA_SOURCE_FEAR_AND_GREED_INDEX_STOCKS: string;
DATA_SOURCE_IMPORT: string; DATA_SOURCE_IMPORT: string;
DATA_SOURCES: string[]; DATA_SOURCES: string[];
DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER: string[]; DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER: string[];
@ -22,6 +23,7 @@ export interface Environment extends CleanedEnvAccessors {
ENABLE_FEATURE_CRON: boolean; ENABLE_FEATURE_CRON: boolean;
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: boolean; ENABLE_FEATURE_FEAR_AND_GREED_INDEX: boolean;
ENABLE_FEATURE_GATHER_NEW_EXCHANGE_RATES: boolean; ENABLE_FEATURE_GATHER_NEW_EXCHANGE_RATES: boolean;
ENABLE_FEATURE_RATE_LIMITING: boolean;
ENABLE_FEATURE_READ_ONLY_MODE: boolean; ENABLE_FEATURE_READ_ONLY_MODE: boolean;
ENABLE_FEATURE_STATISTICS: boolean; ENABLE_FEATURE_STATISTICS: boolean;
ENABLE_FEATURE_SUBSCRIPTION: boolean; ENABLE_FEATURE_SUBSCRIPTION: boolean;
@ -56,6 +58,7 @@ export interface Environment extends CleanedEnvAccessors {
REQUEST_TIMEOUT: number; REQUEST_TIMEOUT: number;
ROOT_URL: string; ROOT_URL: string;
STRIPE_SECRET_KEY: string; STRIPE_SECRET_KEY: string;
TRUST_PROXY: boolean | number | string;
TWITTER_ACCESS_TOKEN: string; TWITTER_ACCESS_TOKEN: string;
TWITTER_ACCESS_TOKEN_SECRET: string; TWITTER_ACCESS_TOKEN_SECRET: string;
TWITTER_API_KEY: string; TWITTER_API_KEY: string;

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

@ -12,10 +12,10 @@ import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { import {
AssetProfileOverrides,
DataSource, DataSource,
Prisma, Prisma,
SymbolProfile, SymbolProfile
SymbolProfileOverrides
} from '@prisma/client'; } from '@prisma/client';
import { continents, countries } from 'countries-list'; import { continents, countries } from 'countries-list';
@ -85,12 +85,12 @@ export class SymbolProfileService {
} }
return { return {
SymbolProfileOverrides: { assetProfileOverrides: {
upsert: { upsert: {
create: create:
data as Prisma.SymbolProfileOverridesCreateWithoutSymbolProfileInput, data as Prisma.AssetProfileOverridesCreateWithoutSymbolProfileInput,
update: update:
data as Prisma.SymbolProfileOverridesUpdateWithoutSymbolProfileInput data as Prisma.AssetProfileOverridesUpdateWithoutSymbolProfileInput
} }
} }
}; };
@ -112,7 +112,7 @@ export class SymbolProfileService {
select: { date: true }, select: { date: true },
take: 1 take: 1
}, },
SymbolProfileOverrides: true assetProfileOverrides: true
}, },
where: { where: {
OR: aAssetProfileIdentifiers.map(({ dataSource, symbol }) => { OR: aAssetProfileIdentifiers.map(({ dataSource, symbol }) => {
@ -137,7 +137,7 @@ export class SymbolProfileService {
_count: { _count: {
select: { activities: true, watchedBy: true } select: { activities: true, watchedBy: true }
}, },
SymbolProfileOverrides: true assetProfileOverrides: true
}, },
where: { where: {
id: { id: {
@ -174,6 +174,7 @@ export class SymbolProfileService {
{ dataSource, symbol }: AssetProfileIdentifier, { dataSource, symbol }: AssetProfileIdentifier,
{ {
assetClass, assetClass,
assetProfileOverrides,
assetSubClass, assetSubClass,
comment, comment,
countries, countries,
@ -185,13 +186,13 @@ export class SymbolProfileService {
scraperConfiguration, scraperConfiguration,
sectors, sectors,
symbolMapping, symbolMapping,
SymbolProfileOverrides,
url url
}: Prisma.SymbolProfileUpdateInput }: Prisma.SymbolProfileUpdateInput
) { ) {
return this.prismaService.symbolProfile.update({ return this.prismaService.symbolProfile.update({
data: { data: {
assetClass, assetClass,
assetProfileOverrides,
assetSubClass, assetSubClass,
comment, comment,
countries, countries,
@ -203,7 +204,6 @@ export class SymbolProfileService {
scraperConfiguration, scraperConfiguration,
sectors, sectors,
symbolMapping, symbolMapping,
SymbolProfileOverrides,
url url
}, },
where: { dataSource_symbol: { dataSource, symbol } } where: { dataSource_symbol: { dataSource, symbol } }
@ -216,13 +216,13 @@ export class SymbolProfileService {
activities?: { activities?: {
date: Date; date: Date;
}[]; }[];
SymbolProfileOverrides: SymbolProfileOverrides; assetProfileOverrides: AssetProfileOverrides;
})[] })[]
): EnhancedSymbolProfile[] { ): EnhancedSymbolProfile[] {
return symbolProfiles.map((symbolProfile) => { return symbolProfiles.map((symbolProfile) => {
const symbolProfileWithOverrides = applyAssetProfileOverrides( const symbolProfileWithOverrides = applyAssetProfileOverrides(
symbolProfile, symbolProfile,
symbolProfile.SymbolProfileOverrides symbolProfile.assetProfileOverrides
); );
const item = { const item = {
@ -252,7 +252,7 @@ export class SymbolProfileService {
item.dateOfFirstActivity = symbolProfile.activities?.[0]?.date; item.dateOfFirstActivity = symbolProfile.activities?.[0]?.date;
delete item.activities; delete item.activities;
delete item.SymbolProfileOverrides; delete item.assetProfileOverrides;
return item; return item;
}); });

8
apps/api/src/services/twitter-bot/twitter-bot.module.ts

@ -1,13 +1,19 @@
import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module'; import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module';
import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module'; import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { TwitterBotService } from '@ghostfolio/api/services/twitter-bot/twitter-bot.service'; import { TwitterBotService } from '@ghostfolio/api/services/twitter-bot/twitter-bot.service';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
@Module({ @Module({
exports: [TwitterBotService], exports: [TwitterBotService],
imports: [BenchmarkModule, ConfigurationModule, SymbolModule], imports: [
BenchmarkModule,
ConfigurationModule,
DataProviderModule,
SymbolModule
],
providers: [TwitterBotService] providers: [TwitterBotService]
}) })
export class TwitterBotModule {} export class TwitterBotModule {}

14
apps/api/src/services/twitter-bot/twitter-bot.service.ts

@ -1,10 +1,8 @@
import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service'; import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service';
import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service'; import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
ghostfolioFearAndGreedIndexDataSourceStocks, import { ghostfolioFearAndGreedIndexSymbolStocks } from '@ghostfolio/common/config';
ghostfolioFearAndGreedIndexSymbolStocks
} from '@ghostfolio/common/config';
import { import {
resolveFearAndGreedIndex, resolveFearAndGreedIndex,
resolveMarketCondition resolveMarketCondition
@ -24,6 +22,7 @@ export class TwitterBotService implements OnModuleInit {
public constructor( public constructor(
private readonly benchmarkService: BenchmarkService, private readonly benchmarkService: BenchmarkService,
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly dataProviderService: DataProviderService,
private readonly symbolService: SymbolService private readonly symbolService: SymbolService
) {} ) {}
@ -49,7 +48,8 @@ export class TwitterBotService implements OnModuleInit {
try { try {
const symbolItem = await this.symbolService.get({ const symbolItem = await this.symbolService.get({
dataGatheringItem: { dataGatheringItem: {
dataSource: ghostfolioFearAndGreedIndexDataSourceStocks, dataSource:
this.dataProviderService.getDataSourceForFearAndGreedIndexStocks(),
symbol: ghostfolioFearAndGreedIndexSymbolStocks symbol: ghostfolioFearAndGreedIndexSymbolStocks
} }
}); });
@ -59,9 +59,9 @@ export class TwitterBotService implements OnModuleInit {
symbolItem.marketPrice symbolItem.marketPrice
); );
let status = `Current market mood is ${emoji} ${text.toLowerCase()} (${ let status = `Current market mood is ${emoji} ${text.toLowerCase()} (${round(
symbolItem.marketPrice symbolItem.marketPrice
}/100)`; )}/100)`;
const benchmarkListing = await this.getBenchmarkListing(); const benchmarkListing = await this.getBenchmarkListing();

51
apps/api/webpack.config.js

@ -1,6 +1,49 @@
const { composePlugins, withNx } = require('@nx/webpack'); const { NxAppWebpackPlugin } = require('@nx/webpack/app-plugin');
const path = require('path');
module.exports = composePlugins(withNx(), (config, { options, context }) => { // These options were migrated by @nx/webpack:convert-to-inferred from
// Customize webpack config here // the project.json file and merged with the options in this file
return config; const configValues = {
build: {
default: {
compiler: 'tsc',
deleteOutputPath: false,
main: './src/main.ts',
outputPath: 'dist/apps/api',
outputHashing: 'none',
sourceMap: true,
target: 'node',
tsConfig: './tsconfig.app.json'
},
production: {
extractLicenses: true,
fileReplacements: [
{
replace: path.resolve(__dirname, './src/environments/environment.ts'),
with: path.resolve(
__dirname,
'./src/environments/environment.prod.ts'
)
}
],
generatePackageJson: true,
inspect: false,
optimization: true
}
}
};
// Determine the correct configValue to use based on the configuration
const configuration = process.env.NX_TASK_TARGET_CONFIGURATION || 'default';
const buildOptions = {
...configValues.build.default,
...configValues.build[configuration]
};
/**
* @type {import('webpack').WebpackOptionsNormalized}
*/
module.exports = async () => ({
plugins: [new NxAppWebpackPlugin(buildOptions)]
}); });

4
apps/client/eslint.config.cjs

@ -47,7 +47,9 @@ module.exports = [
{ {
files: ['**/*.ts', '**/*.tsx'], files: ['**/*.ts', '**/*.tsx'],
// Override or add rules here // Override or add rules here
rules: {} rules: {
'@typescript-eslint/prefer-nullish-coalescing': 'error'
}
}, },
{ {
files: ['**/*.js', '**/*.jsx'], files: ['**/*.js', '**/*.jsx'],

1
apps/client/jest.config.ts

@ -1,4 +1,3 @@
/* eslint-disable */
export default { export default {
displayName: 'client', displayName: 'client',

3
apps/client/src/app/app.routes.ts

@ -128,8 +128,7 @@ export const routes: Routes = [
import('./pages/webauthn/webauthn-page.component').then( import('./pages/webauthn/webauthn-page.component').then(
(c) => c.GfWebauthnPageComponent (c) => c.GfWebauthnPageComponent
), ),
path: internalRoutes.webauthn.path, path: internalRoutes.webauthn.path
title: internalRoutes.webauthn.title
}, },
{ {
path: internalRoutes.zen.path, path: internalRoutes.zen.path,

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

@ -4,10 +4,7 @@ import {
DEFAULT_LOCALE, DEFAULT_LOCALE,
DEFAULT_PAGE_SIZE DEFAULT_PAGE_SIZE
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { import { canDeleteAssetProfile } from '@ghostfolio/common/helper';
canDeleteAssetProfile,
getDateFormatString
} from '@ghostfolio/common/helper';
import { import {
AssetProfileIdentifier, AssetProfileIdentifier,
AssetProfileItem, AssetProfileItem,
@ -153,7 +150,6 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit {
]; ];
protected readonly canDeleteAssetProfile = canDeleteAssetProfile; protected readonly canDeleteAssetProfile = canDeleteAssetProfile;
protected dataSource = new MatTableDataSource<AssetProfileItem>(); protected dataSource = new MatTableDataSource<AssetProfileItem>();
protected defaultDateFormat: string;
protected readonly displayedColumns: string[] = []; protected readonly displayedColumns: string[] = [];
protected readonly filters$ = new Subject<Filter[]>(); protected readonly filters$ = new Subject<Filter[]>();
protected isLoading = true; protected isLoading = true;
@ -236,10 +232,6 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit {
.subscribe((state) => { .subscribe((state) => {
if (state?.user) { if (state?.user) {
this.user = state.user; this.user = state.user;
this.defaultDateFormat = getDateFormatString(
this.user.settings.locale
);
} }
}); });

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

@ -147,7 +147,13 @@
<ng-container i18n>First Activity</ng-container> <ng-container i18n>First Activity</ng-container>
</th> </th>
<td *matCellDef="let element" class="px-1" mat-cell> <td *matCellDef="let element" class="px-1" mat-cell>
{{ (element.date | date: defaultDateFormat) ?? '' }} @if (element.date) {
<gf-value
[isDate]="true"
[locale]="user?.settings?.locale"
[value]="element.date"
/>
}
</td> </td>
</ng-container> </ng-container>

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

@ -550,7 +550,7 @@ export class GfAssetProfileDialogComponent implements OnInit {
) as Record<string, string>, ) as Record<string, string>,
locale: locale:
this.assetProfileForm.controls.scraperConfiguration.controls.locale this.assetProfileForm.controls.scraperConfiguration.controls.locale
?.value || undefined, ?.value ?? undefined,
mode: mode:
this.assetProfileForm.controls.scraperConfiguration.controls.mode this.assetProfileForm.controls.scraperConfiguration.controls.mode
?.value ?? undefined, ?.value ?? undefined,
@ -599,7 +599,7 @@ export class GfAssetProfileDialogComponent implements OnInit {
assetClass: this.assetProfileForm.controls.assetClass.value ?? undefined, assetClass: this.assetProfileForm.controls.assetClass.value ?? undefined,
assetSubClass: assetSubClass:
this.assetProfileForm.controls.assetSubClass.value ?? undefined, this.assetProfileForm.controls.assetSubClass.value ?? undefined,
comment: this.assetProfileForm.controls.comment.value || undefined, comment: this.assetProfileForm.controls.comment.value ?? undefined,
currency: this.assetProfileForm.controls.currency.value ?? undefined, currency: this.assetProfileForm.controls.currency.value ?? undefined,
dataGatheringFrequency: dataGatheringFrequency:
this.assetProfileForm.controls.dataGatheringFrequency.value ?? this.assetProfileForm.controls.dataGatheringFrequency.value ??
@ -607,8 +607,8 @@ export class GfAssetProfileDialogComponent implements OnInit {
isActive: isBoolean(this.assetProfileForm.controls.isActive.value) isActive: isBoolean(this.assetProfileForm.controls.isActive.value)
? this.assetProfileForm.controls.isActive.value ? this.assetProfileForm.controls.isActive.value
: undefined, : undefined,
name: this.assetProfileForm.controls.name.value || undefined, name: this.assetProfileForm.controls.name.value ?? undefined,
url: this.assetProfileForm.controls.url.value || undefined url: this.assetProfileForm.controls.url.value ?? undefined
}; };
try { try {
@ -743,7 +743,7 @@ export class GfAssetProfileDialogComponent implements OnInit {
) as Record<string, string>, ) as Record<string, string>,
locale: locale:
this.assetProfileForm.controls.scraperConfiguration.controls.locale this.assetProfileForm.controls.scraperConfiguration.controls.locale
?.value || undefined, ?.value ?? undefined,
mode: this.assetProfileForm.controls.scraperConfiguration.controls mode: this.assetProfileForm.controls.scraperConfiguration.controls
.mode?.value, .mode?.value,
selector: selector:

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

@ -63,6 +63,7 @@ import {
trashOutline trashOutline
} from 'ionicons/icons'; } from 'ionicons/icons';
import ms, { StringValue } from 'ms'; import ms, { StringValue } from 'ms';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
@ -79,6 +80,7 @@ import ms, { StringValue } from 'ms';
MatSnackBarModule, MatSnackBarModule,
MatSlideToggleModule, MatSlideToggleModule,
MatTableModule, MatTableModule,
NgxSkeletonLoaderModule,
ReactiveFormsModule, ReactiveFormsModule,
RouterModule RouterModule
], ],
@ -90,13 +92,19 @@ export class GfAdminOverviewComponent implements OnInit {
protected activitiesCount: number; protected activitiesCount: number;
protected couponDuration: StringValue = '14 days'; protected couponDuration: StringValue = '14 days';
protected readonly couponsDataSource = new MatTableDataSource<Coupon>(); protected readonly couponsDataSource = new MatTableDataSource<Coupon>();
protected readonly couponsDisplayedColumns = ['code', 'duration', 'actions']; protected readonly couponsDisplayedColumns = [
'code',
'duration',
'createdAt',
'actions'
];
protected hasPermissionForSubscription: boolean; protected hasPermissionForSubscription: boolean;
protected hasPermissionForSystemMessage: boolean; protected hasPermissionForSystemMessage: boolean;
protected hasPermissionToSyncDemoUserAccount: boolean; protected hasPermissionToSyncDemoUserAccount: boolean;
protected hasPermissionToToggleReadOnlyMode: boolean; protected hasPermissionToToggleReadOnlyMode: boolean;
protected readonly info: InfoItem; protected readonly info: InfoItem;
protected isDataGatheringEnabled: boolean; protected isDataGatheringEnabled: boolean;
protected isLoading = false;
protected readonly permissions = permissions; protected readonly permissions = permissions;
protected systemMessage: SystemMessage; protected systemMessage: SystemMessage;
protected userCount: number; protected userCount: number;
@ -201,6 +209,7 @@ export class GfAdminOverviewComponent implements OnInit {
protected onAddCoupon() { protected onAddCoupon() {
const newCoupon: Coupon = { const newCoupon: Coupon = {
code: `${ghostfolioPrefix}${this.generateCouponCode(14)}`, code: `${ghostfolioPrefix}${this.generateCouponCode(14)}`,
createdAt: new Date().toISOString(),
duration: this.couponDuration duration: this.couponDuration
}; };
@ -314,6 +323,8 @@ export class GfAdminOverviewComponent implements OnInit {
} }
private fetchAdminData() { private fetchAdminData() {
this.isLoading = true;
this.adminService this.adminService
.fetchAdminData() .fetchAdminData()
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
@ -330,6 +341,8 @@ export class GfAdminOverviewComponent implements OnInit {
this.userCount = userCount; this.userCount = userCount;
this.version = version; this.version = version;
this.isLoading = false;
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
}); });
} }

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

@ -180,6 +180,28 @@
</td> </td>
</ng-container> </ng-container>
<ng-container matColumnDef="createdAt">
<th *matHeaderCellDef class="px-1 text-right" mat-header-cell>
<ng-container i18n>Creation</ng-container>
</th>
<td
*matCellDef="let element"
class="px-1 text-right"
mat-cell
>
@if (element.createdAt) {
<gf-value
class="d-inline-block justify-content-end"
[isDate]="true"
[locale]="user?.settings?.locale"
[value]="element.createdAt"
/>
} @else {
<span>-</span>
}
</td>
</ng-container>
<ng-container matColumnDef="actions" stickyEnd> <ng-container matColumnDef="actions" stickyEnd>
<th <th
*matHeaderCellDef *matHeaderCellDef
@ -226,6 +248,17 @@
mat-row mat-row
></tr> ></tr>
</table> </table>
@if (isLoading) {
<ngx-skeleton-loader
animation="pulse"
class="px-4 py-3"
[theme]="{
height: '1.5rem',
width: '100%'
}"
/>
}
</div> </div>
</mat-card-content> </mat-card-content>
<mat-card-actions align="end"> <mat-card-actions align="end">

4
apps/client/src/app/components/admin-settings/admin-settings.component.html

@ -21,7 +21,7 @@
<mat-card-actions class="pb-3 pt-0 px-3"> <mat-card-actions class="pb-3 pt-0 px-3">
<a <a
class="special" class="special"
href="mailto:hi@ghostfol.io?Subject=Ghostfolio Premium Data Provider&body=Hello,%0D%0DI am interested in the Ghostfolio Premium data provider. Could you please give me access so I can try it for some time?%0D%0DKind regards" href="mailto:hi@ghostfol.io?subject=Ghostfolio Premium Data Provider&body=Hello,%0D%0DI am interested in the Ghostfolio Premium data provider. Could you please give me access so I can try it for some time?%0D%0DKind regards"
mat-flat-button mat-flat-button
> >
<ng-container i18n>Get Access</ng-container> <ng-container i18n>Get Access</ng-container>
@ -76,7 +76,7 @@
<span <span
class="badge badge-pill badge-secondary ml-2 text-uppercase" class="badge badge-pill badge-secondary ml-2 text-uppercase"
i18n i18n
>new</span >popular</span
> >
} }
</a> </a>

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

@ -8,6 +8,7 @@ import { UserService } from '@ghostfolio/client/services/user/user.service';
import { DEFAULT_LOCALE, DEFAULT_PAGE_SIZE } from '@ghostfolio/common/config'; import { DEFAULT_LOCALE, DEFAULT_PAGE_SIZE } from '@ghostfolio/common/config';
import { ConfirmationDialogType } from '@ghostfolio/common/enums'; import { ConfirmationDialogType } from '@ghostfolio/common/enums';
import { import {
canDeleteUser,
getCountryName, getCountryName,
getDateFnsLocale, getDateFnsLocale,
getDateFormatString, getDateFormatString,
@ -27,6 +28,7 @@ import { GfValueComponent } from '@ghostfolio/ui/value';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { import {
ChangeDetectionStrategy,
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
computed, computed,
@ -67,6 +69,7 @@ import { interval } from 'rxjs';
import { switchMap, tap } from 'rxjs/operators'; import { switchMap, tap } from 'rxjs/operators';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ imports: [
CommonModule, CommonModule,
GfPremiumIndicatorComponent, GfPremiumIndicatorComponent,
@ -89,6 +92,7 @@ export class GfAdminUsersComponent implements OnInit {
>(); >();
protected defaultDateFormat: string; protected defaultDateFormat: string;
protected displayedColumns: string[] = []; protected displayedColumns: string[] = [];
protected readonly canDeleteUser = canDeleteUser;
protected readonly getCountryName = getCountryName; protected readonly getCountryName = getCountryName;
protected readonly getEmojiFlag = getEmojiFlag; protected readonly getEmojiFlag = getEmojiFlag;
protected hasPermissionForSubscription: boolean; protected hasPermissionForSubscription: boolean;
@ -165,6 +169,8 @@ export class GfAdminUsersComponent implements OnInit {
this.user.permissions, this.user.permissions,
permissions.impersonateAllUsers permissions.impersonateAllUsers
); );
this.changeDetectorRef.markForCheck();
} }
}), }),
switchMap(() => this.route.paramMap) switchMap(() => this.route.paramMap)
@ -196,6 +202,8 @@ export class GfAdminUsersComponent implements OnInit {
pageIndex: this.paginator().pageIndex, pageIndex: this.paginator().pageIndex,
showLoading: false showLoading: false
}); });
this.changeDetectorRef.markForCheck();
}); });
} }

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

@ -241,7 +241,12 @@
<hr class="m-0" /> <hr class="m-0" />
<button <button
mat-menu-item mat-menu-item
[disabled]="element.id === user?.id" [disabled]="
!canDeleteUser({
currentUserId: user?.id,
userId: element.id
})
"
(click)="onDeleteUser(element.id)" (click)="onDeleteUser(element.id)"
> >
<span class="align-items-center d-flex"> <span class="align-items-center d-flex">

2
apps/client/src/app/components/fear-and-greed-index/fear-and-greed-index.component.html

@ -5,7 +5,7 @@
<div class="h4 mb-0"> <div class="h4 mb-0">
<span class="mr-2">{{ fearAndGreedIndexText }}</span> <span class="mr-2">{{ fearAndGreedIndexText }}</span>
<small class="text-muted" <small class="text-muted"
><strong>{{ fearAndGreedIndex }}</strong ><strong>{{ fearAndGreedIndex | number: '1.0-0' }}</strong
>/100</small >/100</small
> >
</div> </div>

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

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

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

@ -22,6 +22,7 @@ import { NotificationService } from '@ghostfolio/ui/notifications';
import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator'; import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator';
import { DataService } from '@ghostfolio/ui/services'; import { DataService } from '@ghostfolio/ui/services';
import { HttpErrorResponse } from '@angular/common/http';
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
Component, Component,
@ -42,6 +43,7 @@ import { MatMenuModule, MatMenuTrigger } from '@angular/material/menu';
import { MatToolbarModule } from '@angular/material/toolbar'; import { MatToolbarModule } from '@angular/material/toolbar';
import { Router, RouterModule } from '@angular/router'; import { Router, RouterModule } from '@angular/router';
import { IonIcon } from '@ionic/angular/standalone'; import { IonIcon } from '@ionic/angular/standalone';
import { StatusCodes } from 'http-status-codes';
import { addIcons } from 'ionicons'; import { addIcons } from 'ionicons';
import { import {
closeOutline, closeOutline,
@ -315,10 +317,15 @@ export class GfHeaderComponent implements OnChanges {
this.dataService this.dataService
.loginAnonymous(data?.accessToken) .loginAnonymous(data?.accessToken)
.pipe( .pipe(
catchError(() => { catchError((error: HttpErrorResponse) => {
this.notificationService.alert({ if (error.status !== StatusCodes.TOO_MANY_REQUESTS) {
title: $localize`Oops! Incorrect Security Token.` // The notification for too many requests is handled in the
}); // HttpResponseInterceptor
this.notificationService.alert({
title: $localize`Oops! Incorrect Security Token.`
});
}
return EMPTY; return EMPTY;
}), }),

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

@ -456,7 +456,7 @@ export class GfHoldingDetailDialogComponent implements OnInit {
this.assetProfile?.symbol ? ` (${this.assetProfile.symbol})` : '' this.assetProfile?.symbol ? ` (${this.assetProfile.symbol})` : ''
}`; }`;
this.reportDataGlitchMail = `mailto:hi@ghostfol.io?Subject=${reportDataGlitchSubject}&body=Hello%0D%0DI would like to report a data glitch for%0D%0DSymbol: ${this.assetProfile?.symbol}%0DData Source: ${this.assetProfile?.dataSource}%0D%0DAdditional notes:%0D%0DCan you please take a look?%0D%0DKind regards`; this.reportDataGlitchMail = `mailto:hi@ghostfol.io?subject=${reportDataGlitchSubject}&body=Hello%0D%0DI would like to report a data glitch for%0D%0DSymbol: ${this.assetProfile?.symbol}%0DData Source: ${this.assetProfile?.dataSource}%0D%0DAdditional notes:%0D%0DCan you please take a look?%0D%0DKind regards`;
if (this.assetProfile?.assetClass) { if (this.assetProfile?.assetClass) {
this.assetClass = translate(this.assetProfile?.assetClass); this.assetClass = translate(this.assetProfile?.assetClass);

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

@ -15,10 +15,12 @@ import { GfToggleComponent } from '@ghostfolio/ui/toggle';
import { GfTreemapChartComponent } from '@ghostfolio/ui/treemap-chart'; import { GfTreemapChartComponent } from '@ghostfolio/ui/treemap-chart';
import { import {
ChangeDetectionStrategy,
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
CUSTOM_ELEMENTS_SCHEMA, CUSTOM_ELEMENTS_SCHEMA,
DestroyRef, DestroyRef,
inject,
OnInit OnInit
} from '@angular/core'; } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
@ -32,6 +34,7 @@ import { gridOutline, reorderFourOutline } from 'ionicons/icons';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ imports: [
FormsModule, FormsModule,
GfHoldingsTableComponent, GfHoldingsTableComponent,
@ -51,32 +54,34 @@ import { DeviceDetectorService } from 'ngx-device-detector';
export class GfHomeHoldingsComponent implements OnInit { export class GfHomeHoldingsComponent implements OnInit {
public static DEFAULT_HOLDINGS_VIEW_MODE: HoldingsViewMode = 'TABLE'; public static DEFAULT_HOLDINGS_VIEW_MODE: HoldingsViewMode = 'TABLE';
public deviceType: string; protected deviceType: string;
public hasImpersonationId: boolean; protected hasImpersonationId: boolean;
public hasPermissionToAccessHoldingsChart: boolean; protected hasPermissionToAccessHoldingsChart: boolean;
public hasPermissionToCreateActivity: boolean; protected hasPermissionToCreateActivity: boolean;
public holdings: PortfolioPosition[]; protected holdings: PortfolioPosition[] | undefined;
public holdingType: HoldingType = 'ACTIVE'; protected holdingType: HoldingType = 'ACTIVE';
public holdingTypeOptions: ToggleOption[] = [ protected readonly holdingTypeOptions: ToggleOption[] = [
{ label: $localize`Active`, value: 'ACTIVE' }, { label: $localize`Active`, value: 'ACTIVE' },
{ label: $localize`Closed`, value: 'CLOSED' } { label: $localize`Closed`, value: 'CLOSED' }
]; ];
public routerLinkPortfolioActivities = protected readonly routerLinkPortfolioActivities =
internalRoutes.portfolio.subRoutes.activities.routerLink; internalRoutes.portfolio.subRoutes.activities.routerLink;
public user: User; protected user: User;
public viewModeFormControl = new FormControl<HoldingsViewMode>( protected readonly viewModeFormControl = new FormControl<HoldingsViewMode>(
GfHomeHoldingsComponent.DEFAULT_HOLDINGS_VIEW_MODE GfHomeHoldingsComponent.DEFAULT_HOLDINGS_VIEW_MODE
); );
public constructor( private readonly changeDetectorRef = inject(ChangeDetectorRef);
private changeDetectorRef: ChangeDetectorRef, private readonly dataService = inject(DataService);
private dataService: DataService, private readonly destroyRef = inject(DestroyRef);
private destroyRef: DestroyRef, private readonly deviceDetectorService = inject(DeviceDetectorService);
private deviceDetectorService: DeviceDetectorService, private readonly impersonationStorageService = inject(
private impersonationStorageService: ImpersonationStorageService, ImpersonationStorageService
private router: Router, );
private userService: UserService private readonly router = inject(Router);
) { private readonly userService = inject(UserService);
public constructor() {
addIcons({ gridOutline, reorderFourOutline }); addIcons({ gridOutline, reorderFourOutline });
} }
@ -88,6 +93,8 @@ export class GfHomeHoldingsComponent implements OnInit {
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((impersonationId) => { .subscribe((impersonationId) => {
this.hasImpersonationId = !!impersonationId; this.hasImpersonationId = !!impersonationId;
this.changeDetectorRef.markForCheck();
}); });
this.userService.stateChanged this.userService.stateChanged
@ -107,14 +114,18 @@ export class GfHomeHoldingsComponent implements OnInit {
); );
this.initialize(); this.initialize();
this.changeDetectorRef.markForCheck();
} }
this.changeDetectorRef.markForCheck();
}); });
this.viewModeFormControl.valueChanges this.viewModeFormControl.valueChanges
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((holdingsViewMode) => { .subscribe((holdingsViewMode) => {
if (!holdingsViewMode) {
return;
}
this.dataService this.dataService
.putUserSetting({ holdingsViewMode }) .putUserSetting({ holdingsViewMode })
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
@ -131,13 +142,13 @@ export class GfHomeHoldingsComponent implements OnInit {
}); });
} }
public onChangeHoldingType(aHoldingType: HoldingType) { protected onChangeHoldingType(aHoldingType: HoldingType) {
this.holdingType = aHoldingType; this.holdingType = aHoldingType;
this.initialize(); this.initialize();
} }
public onHoldingClicked({ dataSource, symbol }: AssetProfileIdentifier) { protected onHoldingClicked({ dataSource, symbol }: AssetProfileIdentifier) {
if (dataSource && symbol) { if (dataSource && symbol) {
this.router.navigate([], { this.router.navigate([], {
queryParams: { dataSource, symbol, holdingDetailDialog: true } queryParams: { dataSource, symbol, holdingDetailDialog: true }
@ -170,8 +181,8 @@ export class GfHomeHoldingsComponent implements OnInit {
this.viewModeFormControl.setValue( this.viewModeFormControl.setValue(
this.deviceType === 'mobile' this.deviceType === 'mobile'
? GfHomeHoldingsComponent.DEFAULT_HOLDINGS_VIEW_MODE ? GfHomeHoldingsComponent.DEFAULT_HOLDINGS_VIEW_MODE
: this.user?.settings?.holdingsViewMode || : (this.user?.settings?.holdingsViewMode ??
GfHomeHoldingsComponent.DEFAULT_HOLDINGS_VIEW_MODE, GfHomeHoldingsComponent.DEFAULT_HOLDINGS_VIEW_MODE),
{ emitEvent: false } { emitEvent: false }
); );
} else if (this.holdingType === 'CLOSED') { } else if (this.holdingType === 'CLOSED') {

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

@ -14,6 +14,7 @@ import { GfLineChartComponent } from '@ghostfolio/ui/line-chart';
import { DataService } from '@ghostfolio/ui/services'; import { DataService } from '@ghostfolio/ui/services';
import { import {
ChangeDetectionStrategy,
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
computed, computed,
@ -27,6 +28,7 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ imports: [
GfBenchmarkComponent, GfBenchmarkComponent,
GfFearAndGreedIndexComponent, GfFearAndGreedIndexComponent,

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

@ -86,7 +86,6 @@
<div class="col"> <div class="col">
<gf-portfolio-performance <gf-portfolio-performance
class="pb-4" class="pb-4"
[deviceType]="deviceType()"
[errors]="errors()" [errors]="errors()"
[isLoading]="isLoadingPerformance()" [isLoading]="isLoadingPerformance()"
[locale]="user()?.settings?.locale" [locale]="user()?.settings?.locale"

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

@ -1,7 +1,7 @@
<div class="container p-0"> <div class="container p-0">
<div class="no-gutters row"> <div class="no-gutters row">
<div class="status-container text-muted text-right"> <div class="status-container text-muted text-right">
@if (errors?.length > 0 && !isLoading) { @if (errors()?.length > 0 && !isLoading()) {
<ion-icon <ion-icon
i18n-title i18n-title
name="time-outline" name="time-outline"
@ -10,7 +10,7 @@
/> />
} }
</div> </div>
@if (isLoading) { @if (isLoading()) {
<div class="align-items-center d-flex"> <div class="align-items-center d-flex">
<ngx-skeleton-loader <ngx-skeleton-loader
animation="pulse" animation="pulse"
@ -24,25 +24,25 @@
} }
<div <div
class="display-4 font-weight-bold m-0 text-center value-container" class="display-4 font-weight-bold m-0 text-center value-container"
[hidden]="isLoading" [hidden]="isLoading()"
> >
<span #value id="value"></span> <span #value id="value"></span>
</div> </div>
<div class="currency-container flex-grow-1 px-1"> <div class="currency-container flex-grow-1 px-1">
{{ unit }} {{ unit() }}
</div> </div>
</div> </div>
@if (showDetails) { @if (showDetails()) {
<div class="row"> <div class="row">
<div class="d-flex col justify-content-end"> <div class="d-flex col justify-content-end">
<gf-value <gf-value
[colorizeSign]="true" [colorizeSign]="true"
[isCurrency]="true" [isCurrency]="true"
[locale]="locale" [locale]="locale()"
[value]=" [value]="
isLoading isLoading()
? undefined ? undefined
: performance?.netPerformanceWithCurrencyEffect : performance().netPerformanceWithCurrencyEffect
" "
/> />
</div> </div>
@ -50,11 +50,11 @@
<gf-value <gf-value
[colorizeSign]="true" [colorizeSign]="true"
[isPercent]="true" [isPercent]="true"
[locale]="locale" [locale]="locale()"
[value]=" [value]="
isLoading isLoading()
? undefined ? undefined
: performance?.netPerformancePercentageWithCurrencyEffect : performance().netPerformancePercentageWithCurrencyEffect
" "
/> />
</div> </div>

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

@ -13,10 +13,11 @@ import { GfValueComponent } from '@ghostfolio/ui/value';
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
Component, Component,
effect,
ElementRef, ElementRef,
Input, inject,
OnChanges, input,
ViewChild viewChild
} from '@angular/core'; } from '@angular/core';
import { IonIcon } from '@ionic/angular/standalone'; import { IonIcon } from '@ionic/angular/standalone';
import { CountUp } from 'countup.js'; import { CountUp } from 'countup.js';
@ -32,58 +33,68 @@ import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
styleUrls: ['./portfolio-performance.component.scss'], styleUrls: ['./portfolio-performance.component.scss'],
templateUrl: './portfolio-performance.component.html' templateUrl: './portfolio-performance.component.html'
}) })
export class GfPortfolioPerformanceComponent implements OnChanges { export class GfPortfolioPerformanceComponent {
@Input() deviceType: string; public readonly errors = input<ResponseError['errors']>();
@Input() errors: ResponseError['errors']; public readonly isLoading = input<boolean>();
@Input() isLoading: boolean; public readonly locale = input<string>(getLocale());
@Input() locale = getLocale(); public readonly performance = input.required<PortfolioPerformance>();
@Input() performance: PortfolioPerformance; public readonly precision = input.required<number, number>({
@Input() precision: number; transform: (value) => {
@Input() showDetails: boolean; return value >= 0 ? value : 2;
@Input() unit: string; }
});
public readonly showDetails = input<boolean>(false);
public readonly unit = input.required<string>();
@ViewChild('value') value: ElementRef; private readonly value =
viewChild.required<ElementRef<HTMLSpanElement>>('value');
public constructor(private notificationService: NotificationService) { private readonly notificationService = inject(NotificationService);
addIcons({ timeOutline });
}
public ngOnChanges() { public constructor() {
this.precision = this.precision >= 0 ? this.precision : 2; addIcons({ timeOutline });
if (this.isLoading) { effect(() => {
if (this.value?.nativeElement) { if (this.isLoading()) {
this.value.nativeElement.innerHTML = ''; if (this.value().nativeElement) {
} this.value().nativeElement.innerHTML = '';
} else { }
if (isNumber(this.performance?.currentValueInBaseCurrency)) {
new CountUp('value', this.performance?.currentValueInBaseCurrency, {
decimal: getNumberFormatDecimal(this.locale),
decimalPlaces: this.precision,
duration: 1,
separator: getNumberFormatGroup(this.locale)
}).start();
} else if (this.showDetails === false) {
new CountUp(
'value',
this.performance?.netPerformancePercentageWithCurrencyEffect * 100,
{
decimal: getNumberFormatDecimal(this.locale),
decimalPlaces: 2,
duration: 1,
separator: getNumberFormatGroup(this.locale)
}
).start();
} else { } else {
this.value.nativeElement.innerHTML = '*****'; if (isNumber(this.performance().currentValueInBaseCurrency)) {
new CountUp('value', this.performance().currentValueInBaseCurrency, {
decimal: getNumberFormatDecimal(this.locale()),
decimalPlaces: this.precision(),
duration: 1,
separator: getNumberFormatGroup(this.locale())
}).start();
} else if (this.showDetails() === false) {
new CountUp(
'value',
this.performance().netPerformancePercentageWithCurrencyEffect * 100,
{
decimal: getNumberFormatDecimal(this.locale()),
decimalPlaces: 2,
duration: 1,
separator: getNumberFormatGroup(this.locale())
}
).start();
} else {
this.value().nativeElement.innerHTML = '*****';
}
} }
} });
} }
public onShowErrors() { protected onShowErrors() {
const errorMessageParts = []; const errors = this.errors();
if (!errors?.length) {
return;
}
const errorMessageParts: string[] = [];
for (const error of this.errors) { for (const error of errors) {
errorMessageParts.push(`${error.symbol} (${error.dataSource})`); errorMessageParts.push(`${error.symbol} (${error.dataSource})`);
} }

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

@ -8,10 +8,10 @@ import { GfValueComponent } from '@ghostfolio/ui/value';
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
Component, Component,
EventEmitter, inject,
Input, Input,
OnChanges, OnChanges,
Output output
} from '@angular/core'; } from '@angular/core';
import { MatTooltipModule } from '@angular/material/tooltip'; import { MatTooltipModule } from '@angular/material/tooltip';
import { IonIcon } from '@ionic/angular/standalone'; import { IonIcon } from '@ionic/angular/standalone';
@ -40,44 +40,46 @@ export class GfPortfolioSummaryComponent implements OnChanges {
@Input() summary: PortfolioSummary; @Input() summary: PortfolioSummary;
@Input() user: User; @Input() user: User;
@Output() emergencyFundChanged = new EventEmitter<number>(); public emergencyFundChanged = output<number>();
public buyAndSellActivitiesTooltip = translate( protected readonly buyAndSellActivitiesTooltip = translate(
'BUY_AND_SELL_ACTIVITIES_TOOLTIP' 'BUY_AND_SELL_ACTIVITIES_TOOLTIP'
); );
public precision = 2; protected precision = 2;
public timeInMarket: string; protected timeInMarket: string | undefined;
public get buyingPowerPercentage() { private readonly notificationService = inject(NotificationService);
public constructor() {
addIcons({ ellipsisHorizontalCircleOutline, informationCircleOutline });
}
protected get buyingPowerPercentage() {
return this.summary?.totalValueInBaseCurrency return this.summary?.totalValueInBaseCurrency
? this.summary.cash / this.summary.totalValueInBaseCurrency ? this.summary.cash / this.summary.totalValueInBaseCurrency
: 0; : 0;
} }
public get emergencyFundPercentage() { protected get emergencyFundPercentage() {
return this.summary?.totalValueInBaseCurrency return this.summary?.totalValueInBaseCurrency
? (this.summary.emergencyFund?.total || 0) / ? (this.summary.emergencyFund?.total || 0) /
this.summary.totalValueInBaseCurrency this.summary.totalValueInBaseCurrency
: 0; : 0;
} }
public get excludedFromAnalysisPercentage() { protected get excludedFromAnalysisPercentage() {
return this.summary?.totalValueInBaseCurrency return this.summary?.totalValueInBaseCurrency
? this.summary.excludedAccountsAndActivities / ? this.summary.excludedAccountsAndActivities /
this.summary.totalValueInBaseCurrency this.summary.totalValueInBaseCurrency
: 0; : 0;
} }
public constructor(private notificationService: NotificationService) {
addIcons({ ellipsisHorizontalCircleOutline, informationCircleOutline });
}
public ngOnChanges() { public ngOnChanges() {
if (this.summary) { if (this.summary) {
if ( if (
this.deviceType === 'mobile' && this.deviceType === 'mobile' &&
this.summary.totalValueInBaseCurrency >= (this.summary.totalValueInBaseCurrency ?? 0) >=
NUMERICAL_PRECISION_THRESHOLD_6_FIGURES NUMERICAL_PRECISION_THRESHOLD_6_FIGURES
) { ) {
this.precision = 0; this.precision = 0;
@ -98,7 +100,7 @@ export class GfPortfolioSummaryComponent implements OnChanges {
} }
} }
public onEditEmergencyFund() { protected onEditEmergencyFund() {
this.notificationService.prompt({ this.notificationService.prompt({
confirmFn: (value) => { confirmFn: (value) => {
const emergencyFund = parseFloat(value.trim()) || 0; const emergencyFund = parseFloat(value.trim()) || 0;

4
apps/client/src/app/components/rule/rule-settings-dialog/rule-settings-dialog.html

@ -7,8 +7,8 @@
> >
<div class="py-3" mat-dialog-content> <div class="py-3" mat-dialog-content>
@if ( @if (
data.rule.configuration.thresholdMin && data.rule.configuration?.thresholdMin &&
data.rule.configuration.thresholdMax data.rule.configuration?.thresholdMax
) { ) {
<div class="w-100"> <div class="w-100">
<h6 class="d-flex mb-0"> <h6 class="d-flex mb-0">

64
apps/client/src/app/components/user-account-membership/user-account-membership.component.ts

@ -3,7 +3,6 @@ import { ConfirmationDialogType } from '@ghostfolio/common/enums';
import { getDateFormatString } from '@ghostfolio/common/helper'; import { getDateFormatString } from '@ghostfolio/common/helper';
import { User } from '@ghostfolio/common/interfaces'; import { User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { publicRoutes } from '@ghostfolio/common/routes/routes';
import { GfMembershipCardComponent } from '@ghostfolio/ui/membership-card'; import { GfMembershipCardComponent } from '@ghostfolio/ui/membership-card';
import { NotificationService } from '@ghostfolio/ui/notifications'; import { NotificationService } from '@ghostfolio/ui/notifications';
import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator'; import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator';
@ -14,7 +13,8 @@ import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
DestroyRef DestroyRef,
inject
} from '@angular/core'; } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
@ -40,29 +40,29 @@ import { catchError } from 'rxjs/operators';
templateUrl: './user-account-membership.html' templateUrl: './user-account-membership.html'
}) })
export class GfUserAccountMembershipComponent { export class GfUserAccountMembershipComponent {
public baseCurrency: string; protected readonly baseCurrency: string;
public coupon: number; protected coupon: number | undefined;
public couponId: string; protected defaultDateFormat: string;
public defaultDateFormat: string; protected durationExtension: StringValue | undefined;
public durationExtension: StringValue; protected readonly hasPermissionForSubscription: boolean;
public hasPermissionForSubscription: boolean; protected hasPermissionToCreateApiKey: boolean;
public hasPermissionToCreateApiKey: boolean; protected hasPermissionToUpdateUserSettings: boolean;
public hasPermissionToUpdateUserSettings: boolean; protected price: number;
public price: number; protected readonly trySubscriptionMail =
public priceId: string; 'mailto:hi@ghostfol.io?subject=Ghostfolio Premium Trial&body=Hello%0D%0DI am interested in Ghostfolio Premium. Can you please send me a coupon code to try it for some time?%0D%0DKind regards';
public routerLinkPricing = publicRoutes.pricing.routerLink; protected user: User;
public trySubscriptionMail =
'mailto:hi@ghostfol.io?Subject=Ghostfolio Premium Trial&body=Hello%0D%0DI am interested in Ghostfolio Premium. Can you please send me a coupon code to try it for some time?%0D%0DKind regards'; private couponId: string | undefined;
public user: User; private priceId: string;
public constructor( private readonly changeDetectorRef = inject(ChangeDetectorRef);
private changeDetectorRef: ChangeDetectorRef, private readonly dataService = inject(DataService);
private dataService: DataService, private readonly destroyRef = inject(DestroyRef);
private destroyRef: DestroyRef, private readonly notificationService = inject(NotificationService);
private notificationService: NotificationService, private readonly snackBar = inject(MatSnackBar);
private snackBar: MatSnackBar, private readonly userService = inject(UserService);
private userService: UserService
) { public constructor() {
const { baseCurrency, globalPermissions } = this.dataService.fetchInfo(); const { baseCurrency, globalPermissions } = this.dataService.fetchInfo();
this.baseCurrency = baseCurrency; this.baseCurrency = baseCurrency;
@ -104,7 +104,7 @@ export class GfUserAccountMembershipComponent {
}); });
} }
public onCheckout() { protected onCheckout() {
this.dataService this.dataService
.createStripeCheckoutSession({ .createStripeCheckoutSession({
couponId: this.couponId, couponId: this.couponId,
@ -125,7 +125,7 @@ export class GfUserAccountMembershipComponent {
}); });
} }
public onGenerateApiKey() { protected onGenerateApiKey() {
this.notificationService.confirm({ this.notificationService.confirm({
confirmFn: () => { confirmFn: () => {
this.dataService this.dataService
@ -146,11 +146,9 @@ export class GfUserAccountMembershipComponent {
) )
.subscribe(({ apiKey }) => { .subscribe(({ apiKey }) => {
this.notificationService.alert({ this.notificationService.alert({
discardLabel: $localize`Okay`, copyValue: apiKey,
message: discardLabel: $localize`Close`,
$localize`Set this API key in your self-hosted environment:` + message: $localize`Set this API key in your self-hosted environment:`,
'<br />' +
apiKey,
title: $localize`Ghostfolio Premium Data Provider API Key` title: $localize`Ghostfolio Premium Data Provider API Key`
}); });
}); });
@ -160,7 +158,7 @@ export class GfUserAccountMembershipComponent {
}); });
} }
public onRedeemCoupon() { protected onRedeemCoupon() {
this.notificationService.prompt({ this.notificationService.prompt({
confirmFn: (value) => { confirmFn: (value) => {
const couponCode = value?.trim(); const couponCode = value?.trim();

86
apps/client/src/app/components/user-account-settings/user-account-settings.component.ts

@ -20,11 +20,12 @@ import {
Component, Component,
CUSTOM_ELEMENTS_SCHEMA, CUSTOM_ELEMENTS_SCHEMA,
DestroyRef, DestroyRef,
inject,
OnInit OnInit
} from '@angular/core'; } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { import {
FormBuilder, NonNullableFormBuilder,
FormsModule, FormsModule,
ReactiveFormsModule, ReactiveFormsModule,
Validators Validators
@ -69,20 +70,22 @@ import { catchError } from 'rxjs/operators';
templateUrl: './user-account-settings.html' templateUrl: './user-account-settings.html'
}) })
export class GfUserAccountSettingsComponent implements OnInit { export class GfUserAccountSettingsComponent implements OnInit {
public appearancePlaceholder = $localize`Auto`; protected readonly appearancePlaceholder = $localize`Auto`;
public baseCurrency: string; protected readonly baseCurrency: string;
public currencies: string[] = []; protected closeUserAccountMail: string;
public deleteOwnUserForm = this.formBuilder.group({ protected readonly currencies: string[] = [];
protected readonly deleteOwnUserForm = inject(NonNullableFormBuilder).group({
accessToken: ['', Validators.required] accessToken: ['', Validators.required]
}); });
public hasPermissionToDeleteOwnUser: boolean; protected hasPermissionToDeleteOwnUser: boolean;
public hasPermissionToUpdateViewMode: boolean; protected hasPermissionToRequestOwnUserDeletion: boolean;
public hasPermissionToUpdateUserSettings: boolean; protected hasPermissionToUpdateViewMode: boolean;
public isAccessTokenHidden = true; protected hasPermissionToUpdateUserSettings: boolean;
public isFingerprintSupported = this.doesBrowserSupportAuthn(); protected isAccessTokenHidden = true;
public isWebAuthnEnabled: boolean; protected readonly isFingerprintSupported = this.doesBrowserSupportAuthn();
public language = document.documentElement.lang; protected isWebAuthnEnabled: boolean;
public locales = [ protected readonly language = document.documentElement.lang;
protected locales = [
'ca', 'ca',
'de', 'de',
'de-CH', 'de-CH',
@ -100,19 +103,18 @@ export class GfUserAccountSettingsComponent implements OnInit {
'uk', 'uk',
'zh' 'zh'
]; ];
public user: User; protected user: User;
public constructor( private readonly changeDetectorRef = inject(ChangeDetectorRef);
private changeDetectorRef: ChangeDetectorRef, private readonly dataService = inject(DataService);
private dataService: DataService, private readonly destroyRef = inject(DestroyRef);
private destroyRef: DestroyRef, private readonly notificationService = inject(NotificationService);
private formBuilder: FormBuilder, private readonly settingsStorageService = inject(SettingsStorageService);
private notificationService: NotificationService, private readonly snackBar = inject(MatSnackBar);
private settingsStorageService: SettingsStorageService, private readonly userService = inject(UserService);
private snackBar: MatSnackBar, private readonly webAuthnService = inject(WebAuthnService);
private userService: UserService,
public webAuthnService: WebAuthnService public constructor() {
) {
const { baseCurrency, currencies } = this.dataService.fetchInfo(); const { baseCurrency, currencies } = this.dataService.fetchInfo();
this.baseCurrency = baseCurrency; this.baseCurrency = baseCurrency;
@ -124,11 +126,18 @@ export class GfUserAccountSettingsComponent implements OnInit {
if (state?.user) { if (state?.user) {
this.user = state.user; this.user = state.user;
this.closeUserAccountMail = `mailto:hi@ghostfol.io?subject=Delete Account&body=Hello%0D%0DPlease delete my Ghostfolio account.%0D%0DUser ID: ${this.user.id}%0D%0DKind regards`;
this.hasPermissionToDeleteOwnUser = hasPermission( this.hasPermissionToDeleteOwnUser = hasPermission(
this.user.permissions, this.user.permissions,
permissions.deleteOwnUser permissions.deleteOwnUser
); );
this.hasPermissionToRequestOwnUserDeletion = hasPermission(
this.user.permissions,
permissions.requestOwnUserDeletion
);
this.hasPermissionToUpdateUserSettings = hasPermission( this.hasPermissionToUpdateUserSettings = hasPermission(
this.user.permissions, this.user.permissions,
permissions.updateUserSettings permissions.updateUserSettings
@ -139,7 +148,10 @@ export class GfUserAccountSettingsComponent implements OnInit {
permissions.updateViewMode permissions.updateViewMode
); );
this.locales.push(this.user.settings.locale); if (this.user.settings.locale) {
this.locales.push(this.user.settings.locale);
}
this.locales = Array.from(new Set(this.locales)).sort(); this.locales = Array.from(new Set(this.locales)).sort();
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
@ -153,11 +165,11 @@ export class GfUserAccountSettingsComponent implements OnInit {
this.update(); this.update();
} }
public isCommunityLanguage() { protected isCommunityLanguage() {
return !['de', 'en'].includes(this.language); return !['de', 'en'].includes(this.language);
} }
public onChangeUserSetting(aKey: string, aValue: string) { protected onChangeUserSetting(aKey: string, aValue: string) {
this.dataService this.dataService
.putUserSetting({ [aKey]: aValue }) .putUserSetting({ [aKey]: aValue })
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
@ -181,12 +193,12 @@ export class GfUserAccountSettingsComponent implements OnInit {
}); });
} }
public onCloseAccount() { protected onCloseAccount() {
this.notificationService.confirm({ this.notificationService.confirm({
confirmFn: () => { confirmFn: () => {
this.dataService this.dataService
.deleteOwnUser({ .deleteOwnUser({
accessToken: this.deleteOwnUserForm.get('accessToken').value accessToken: this.deleteOwnUserForm.controls.accessToken.value
}) })
.pipe( .pipe(
catchError(() => { catchError(() => {
@ -209,7 +221,7 @@ export class GfUserAccountSettingsComponent implements OnInit {
}); });
} }
public onExperimentalFeaturesChange(aEvent: MatSlideToggleChange) { protected onExperimentalFeaturesChange(aEvent: MatSlideToggleChange) {
this.dataService this.dataService
.putUserSetting({ isExperimentalFeatures: aEvent.checked }) .putUserSetting({ isExperimentalFeatures: aEvent.checked })
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
@ -225,13 +237,13 @@ export class GfUserAccountSettingsComponent implements OnInit {
}); });
} }
public onExport() { protected onExport() {
this.dataService this.dataService
.fetchExport() .fetchExport()
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((data) => { .subscribe((data) => {
for (const activity of data.activities) { for (const activity of data.activities) {
delete activity.id; delete (activity as Omit<typeof activity, 'id'> & { id?: string }).id;
} }
downloadAsFile({ downloadAsFile({
@ -245,7 +257,7 @@ export class GfUserAccountSettingsComponent implements OnInit {
}); });
} }
public onRestrictedViewChange(aEvent: MatSlideToggleChange) { protected onRestrictedViewChange(aEvent: MatSlideToggleChange) {
this.dataService this.dataService
.putUserSetting({ isRestrictedView: aEvent.checked }) .putUserSetting({ isRestrictedView: aEvent.checked })
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
@ -261,7 +273,7 @@ export class GfUserAccountSettingsComponent implements OnInit {
}); });
} }
public async onSignInWithFingerprintChange(aEvent: MatSlideToggleChange) { protected async onSignInWithFingerprintChange(aEvent: MatSlideToggleChange) {
if (aEvent.checked) { if (aEvent.checked) {
try { try {
await this.registerDevice(); await this.registerDevice();
@ -284,7 +296,7 @@ export class GfUserAccountSettingsComponent implements OnInit {
} }
} }
public onViewModeChange(aEvent: MatSlideToggleChange) { protected onViewModeChange(aEvent: MatSlideToggleChange) {
this.dataService this.dataService
.putUserSetting({ viewMode: aEvent.checked === true ? 'ZEN' : 'DEFAULT' }) .putUserSetting({ viewMode: aEvent.checked === true ? 'ZEN' : 'DEFAULT' })
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))

110
apps/client/src/app/components/user-account-settings/user-account-settings.html

@ -280,61 +280,81 @@
<div class="align-items-center d-flex py-1"> <div class="align-items-center d-flex py-1">
<div class="pr-1 w-50"></div> <div class="pr-1 w-50"></div>
<div class="pl-1 text-monospace w-50"> <div class="pl-1 text-monospace w-50">
<button color="primary" mat-flat-button (click)="onExport()"> <button color="primary" i18n mat-flat-button (click)="onExport()">
<span i18n>Export Data</span> Export Data
</button> </button>
</div> </div>
</div> </div>
@if (hasPermissionToDeleteOwnUser) { @if (
hasPermissionToDeleteOwnUser || hasPermissionToRequestOwnUserDeletion
) {
<hr class="mt-5" /> <hr class="mt-5" />
<form <div class="d-flex py-1">
class="w-100" <div class="pr-1 text-danger w-50" i18n>Danger Zone</div>
[formGroup]="deleteOwnUserForm" <div class="pl-1 w-50">
(ngSubmit)="onCloseAccount()" @if (hasPermissionToDeleteOwnUser) {
> <form
<div class="d-flex py-1"> class="w-100"
<div class="pr-1 text-danger w-50" i18n>Danger Zone</div> [formGroup]="deleteOwnUserForm"
<div class="pl-1 w-50"> (ngSubmit)="onCloseAccount()"
<mat-form-field
appearance="outline"
class="without-hint w-100"
[hideRequiredMarker]="true"
> >
<mat-label i18n>Security Token</mat-label> <mat-form-field
<input appearance="outline"
formControlName="accessToken" class="without-hint w-100"
matInput [hideRequiredMarker]="true"
[type]="isAccessTokenHidden ? 'password' : 'text'"
/>
<button
class="mx-2"
mat-icon-button
matSuffix
type="button"
(click)="isAccessTokenHidden = !isAccessTokenHidden"
> >
<ion-icon <mat-label i18n>Security Token</mat-label>
class="mat-icon" <input
[name]=" formControlName="accessToken"
isAccessTokenHidden ? 'eye-outline' : 'eye-off-outline' matInput
" [type]="isAccessTokenHidden ? 'password' : 'text'"
/> />
<button
class="mx-2"
mat-icon-button
matSuffix
type="button"
(click)="isAccessTokenHidden = !isAccessTokenHidden"
>
<ion-icon
class="mat-icon"
[name]="
isAccessTokenHidden ? 'eye-outline' : 'eye-off-outline'
"
/>
</button>
</mat-form-field>
<button
class="mt-2"
color="warn"
i18n
mat-flat-button
type="submit"
[disabled]="
!(deleteOwnUserForm.dirty && deleteOwnUserForm.valid)
"
>
Close Account
</button> </button>
</mat-form-field> </form>
<button } @else if (
class="mt-2" hasPermissionToRequestOwnUserDeletion &&
color="warn" (user?.accounts?.length > 0 || user?.activitiesCount > 0)
mat-flat-button ) {
type="submit" <button color="warn" i18n mat-flat-button [disabled]="true">
[disabled]=" Close Account
!(deleteOwnUserForm.dirty && deleteOwnUserForm.valid)
"
>
<span i18n>Close Account</span>
</button> </button>
</div> <div class="hint-text mt-2 text-muted" i18n>
For security reasons, please delete all activities and accounts
first before your Ghostfolio account can be closed.
</div>
} @else if (hasPermissionToRequestOwnUserDeletion) {
<a color="warn" i18n mat-flat-button [href]="closeUserAccountMail"
>Close Account</a
>
}
</div> </div>
</form> </div>
} }
</div> </div>
</div> </div>

55
apps/client/src/app/components/user-detail-dialog/user-detail-dialog.component.ts

@ -1,4 +1,8 @@
import { getCountryName, getSum } from '@ghostfolio/common/helper'; import {
canDeleteUser,
getCountryName,
getSum
} from '@ghostfolio/common/helper';
import { AdminUserResponse } from '@ghostfolio/common/interfaces'; import { AdminUserResponse } from '@ghostfolio/common/interfaces';
import { AdminService, DataService } from '@ghostfolio/ui/services'; import { AdminService, DataService } from '@ghostfolio/ui/services';
import { GfValueComponent } from '@ghostfolio/ui/value'; import { GfValueComponent } from '@ghostfolio/ui/value';
@ -9,7 +13,7 @@ import {
Component, Component,
CUSTOM_ELEMENTS_SCHEMA, CUSTOM_ELEMENTS_SCHEMA,
DestroyRef, DestroyRef,
Inject, inject,
OnInit OnInit
} from '@angular/core'; } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
@ -49,28 +53,31 @@ import {
templateUrl: './user-detail-dialog.html' templateUrl: './user-detail-dialog.html'
}) })
export class GfUserDetailDialogComponent implements OnInit { export class GfUserDetailDialogComponent implements OnInit {
public baseCurrency: string; protected readonly baseCurrency: string;
public readonly getCountryName = getCountryName; protected readonly canDeleteUser = canDeleteUser;
public subscriptionsDataSource = new MatTableDataSource<Subscription>(); protected readonly getCountryName = getCountryName;
public subscriptionsDisplayedColumns = [ protected readonly subscriptionsDataSource =
new MatTableDataSource<Subscription>();
protected readonly subscriptionsDisplayedColumns = [
'createdAt', 'createdAt',
'type', 'type',
'price', 'price',
'expiresAt' 'expiresAt'
]; ];
public user: AdminUserResponse; protected user: AdminUserResponse;
public constructor( protected readonly data = inject<UserDetailDialogParams>(MAT_DIALOG_DATA);
private adminService: AdminService,
private changeDetectorRef: ChangeDetectorRef, private readonly adminService = inject(AdminService);
@Inject(MAT_DIALOG_DATA) public data: UserDetailDialogParams, private readonly changeDetectorRef = inject(ChangeDetectorRef);
private dataService: DataService, private readonly dataService = inject(DataService);
private destroyRef: DestroyRef, private readonly destroyRef = inject(DestroyRef);
public dialogRef: MatDialogRef< private readonly dialogRef =
GfUserDetailDialogComponent, inject<MatDialogRef<GfUserDetailDialogComponent, UserDetailDialogResult>>(
UserDetailDialogResult MatDialogRef
> );
) {
public constructor() {
this.baseCurrency = this.dataService.fetchInfo().baseCurrency; this.baseCurrency = this.dataService.fetchInfo().baseCurrency;
addIcons({ addIcons({
@ -98,26 +105,26 @@ export class GfUserDetailDialogComponent implements OnInit {
}); });
} }
public deleteUser() { protected deleteUser() {
this.dialogRef.close({ this.dialogRef.close({
action: 'delete', action: 'delete',
userId: this.data.userId userId: this.data.userId
}); });
} }
public getSum() { protected getSum() {
return getSum( return getSum(
this.subscriptionsDataSource.data this.subscriptionsDataSource.data
.filter(({ price }) => { .filter(({ price }) => {
return price !== null; return price !== null;
}) })
.map(({ price }) => { .map(({ price }) => {
return new Big(price); return new Big(price ?? 0);
}) })
).toNumber(); ).toNumber();
} }
public getType({ createdAt, expiresAt, price }: Subscription) { protected getType({ createdAt, expiresAt, price }: Subscription) {
if (price) { if (price) {
return $localize`Paid`; return $localize`Paid`;
} }
@ -127,7 +134,7 @@ export class GfUserDetailDialogComponent implements OnInit {
: $localize`Coupon`; : $localize`Coupon`;
} }
public onClose() { protected onClose() {
this.dialogRef.close(); this.dialogRef.close();
} }
} }

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

@ -16,7 +16,12 @@
<button <button
mat-menu-item mat-menu-item
type="button" type="button"
[disabled]="this.data.currentUserId === this.data.userId" [disabled]="
!canDeleteUser({
currentUserId: data.currentUserId,
userId: data.userId
})
"
(click)="deleteUser()" (click)="deleteUser()"
> >
<ng-container i18n>Delete</ng-container> <ng-container i18n>Delete</ng-container>

22
apps/client/src/app/core/http-response.interceptor.ts

@ -98,15 +98,23 @@ export class HttpResponseInterceptor implements HttpInterceptor {
}); });
} }
} else if (error.status === StatusCodes.TOO_MANY_REQUESTS) { } else if (error.status === StatusCodes.TOO_MANY_REQUESTS) {
if (!this.snackBarRef) { // Replace an already visible snack bar so that the rate limiting
this.snackBarRef = this.snackBar.open( // feedback is not swallowed
$localize`Oops! It looks like you’re making too many requests. Please slow down a bit.` const snackBarRef = this.snackBar.open(
); $localize`Oops! It looks like you’re making too many requests. Please slow down a bit.`,
undefined,
{
duration: ms('6 seconds')
}
);
this.snackBarRef?.afterDismissed().subscribe(() => { snackBarRef.afterDismissed().subscribe(() => {
if (this.snackBarRef === snackBarRef) {
this.snackBarRef = undefined; this.snackBarRef = undefined;
}); }
} });
this.snackBarRef = snackBarRef;
} else if (error.status === StatusCodes.UNAUTHORIZED) { } else if (error.status === StatusCodes.UNAUTHORIZED) {
if (!error.url?.includes('/data-providers/ghostfolio/status')) { if (!error.url?.includes('/data-providers/ghostfolio/status')) {
if (this.webAuthnService.isEnabled()) { if (this.webAuthnService.isEnabled()) {

12
apps/client/src/app/pages/about/about-page.component.ts

@ -8,7 +8,12 @@ import {
} from '@ghostfolio/ui/page-tabs'; } from '@ghostfolio/ui/page-tabs';
import { DataService } from '@ghostfolio/ui/services'; import { DataService } from '@ghostfolio/ui/services';
import { ChangeDetectorRef, Component, DestroyRef } from '@angular/core'; import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
DestroyRef
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { addIcons } from 'ionicons'; import { addIcons } from 'ionicons';
import { import {
@ -21,6 +26,7 @@ import {
} from 'ionicons/icons'; } from 'ionicons/icons';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'page' }, host: { class: 'page' },
imports: [GfPageTabsComponent], imports: [GfPageTabsComponent],
selector: 'gf-about-page', selector: 'gf-about-page',
@ -83,8 +89,6 @@ export class AboutPageComponent {
}); });
this.user = state.user; this.user = state.user;
this.changeDetectorRef.markForCheck();
} }
this.tabs.push({ this.tabs.push({
@ -92,6 +96,8 @@ export class AboutPageComponent {
label: publicRoutes.about.subRoutes.ossFriends.title, label: publicRoutes.about.subRoutes.ossFriends.title,
routerLink: publicRoutes.about.subRoutes.ossFriends.routerLink routerLink: publicRoutes.about.subRoutes.ossFriends.routerLink
}); });
this.changeDetectorRef.markForCheck();
}); });
addIcons({ addIcons({

4
apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.component.ts

@ -200,12 +200,12 @@ export class GfCreateOrUpdateAccountDialogComponent {
protected async onSubmit() { protected async onSubmit() {
const account: CreateAccountDto | UpdateAccountDto = { const account: CreateAccountDto | UpdateAccountDto = {
balance: this.accountForm.get('balance')?.value, balance: this.accountForm.get('balance')?.value,
comment: this.accountForm.get('comment')?.value || null, comment: this.accountForm.get('comment')?.value ?? null,
currency: this.accountForm.get('currency')?.value, currency: this.accountForm.get('currency')?.value,
id: this.accountForm.get('accountId')?.value, id: this.accountForm.get('accountId')?.value,
isExcluded: this.accountForm.get('isExcluded')?.value, isExcluded: this.accountForm.get('isExcluded')?.value,
name: this.accountForm.get('name')?.value, name: this.accountForm.get('name')?.value,
platformId: this.accountForm.get('platformId')?.value?.id || null, platformId: this.accountForm.get('platformId')?.value?.id ?? null,
tags: this.accountForm tags: this.accountForm
.get('tags') .get('tags')
?.value?.filter(({ id }: Tag) => { ?.value?.filter(({ id }: Tag) => {

11
apps/client/src/app/pages/admin/admin-page.component.ts

@ -12,7 +12,12 @@ import {
TabConfiguration TabConfiguration
} from '@ghostfolio/ui/page-tabs'; } from '@ghostfolio/ui/page-tabs';
import { Component, inject } from '@angular/core'; import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
inject
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { addIcons } from 'ionicons'; import { addIcons } from 'ionicons';
import { import {
@ -24,6 +29,7 @@ import {
} from 'ionicons/icons'; } from 'ionicons/icons';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'page' }, host: { class: 'page' },
imports: [GfPageTabsComponent], imports: [GfPageTabsComponent],
selector: 'gf-admin-page', selector: 'gf-admin-page',
@ -35,6 +41,7 @@ export class AdminPageComponent {
private user: User; private user: User;
private readonly changeDetectorRef = inject(ChangeDetectorRef);
private readonly tokenStorageService = inject(TokenStorageService); private readonly tokenStorageService = inject(TokenStorageService);
private readonly userService = inject(UserService); private readonly userService = inject(UserService);
@ -45,6 +52,8 @@ export class AdminPageComponent {
this.user = state?.user; this.user = state?.user;
this.initializeTabs(); this.initializeTabs();
this.changeDetectorRef.markForCheck();
}); });
addIcons({ addIcons({

22
apps/client/src/app/pages/api/api-page.component.ts

@ -1,3 +1,4 @@
import { GfFearAndGreedIndexComponent } from '@ghostfolio/client/components/fear-and-greed-index/fear-and-greed-index.component';
import { import {
HEADER_KEY_SKIP_INTERCEPTOR, HEADER_KEY_SKIP_INTERCEPTOR,
HEADER_KEY_TOKEN HEADER_KEY_TOKEN
@ -10,6 +11,7 @@ import {
DividendsResponse, DividendsResponse,
HistoricalResponse, HistoricalResponse,
LookupResponse, LookupResponse,
MarketDataOfMarketsResponse,
QuotesResponse QuotesResponse
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
@ -32,7 +34,12 @@ import { FetchFailure, FetchResult } from './interfaces/interfaces';
@Component({ @Component({
host: { class: 'page' }, host: { class: 'page' },
imports: [CommonModule, MatCardModule, NgxSkeletonLoaderModule], imports: [
CommonModule,
GfFearAndGreedIndexComponent,
MatCardModule,
NgxSkeletonLoaderModule
],
selector: 'gf-api-page', selector: 'gf-api-page',
styleUrls: ['./api-page.scss'], styleUrls: ['./api-page.scss'],
templateUrl: './api-page.html' templateUrl: './api-page.html'
@ -48,6 +55,9 @@ export class GfApiPageComponent implements OnInit {
>; >;
protected isinLookupItems$: Observable<FetchResult<LookupResponse['items']>>; protected isinLookupItems$: Observable<FetchResult<LookupResponse['items']>>;
protected lookupItems$: Observable<FetchResult<LookupResponse['items']>>; protected lookupItems$: Observable<FetchResult<LookupResponse['items']>>;
protected marketDataOfMarkets$: Observable<
FetchResult<MarketDataOfMarketsResponse>
>;
protected quotes$: Observable<FetchResult<QuotesResponse['quotes']>>; protected quotes$: Observable<FetchResult<QuotesResponse['quotes']>>;
protected status$: Observable< protected status$: Observable<
FetchResult<DataProviderGhostfolioStatusResponse> FetchResult<DataProviderGhostfolioStatusResponse>
@ -68,6 +78,7 @@ export class GfApiPageComponent implements OnInit {
this.historicalData$ = this.fetchHistoricalData({ symbol: 'AAPL' }); this.historicalData$ = this.fetchHistoricalData({ symbol: 'AAPL' });
this.isinLookupItems$ = this.fetchLookupItems({ query: 'US0378331005' }); this.isinLookupItems$ = this.fetchLookupItems({ query: 'US0378331005' });
this.lookupItems$ = this.fetchLookupItems({ query: 'apple' }); this.lookupItems$ = this.fetchLookupItems({ query: 'apple' });
this.marketDataOfMarkets$ = this.fetchMarketDataOfMarkets();
this.quotes$ = this.fetchQuotes({ symbols: ['AAPL', 'VOO'] }); this.quotes$ = this.fetchQuotes({ symbols: ['AAPL', 'VOO'] });
this.status$ = this.fetchStatus(); this.status$ = this.fetchStatus();
} }
@ -172,6 +183,15 @@ export class GfApiPageComponent implements OnInit {
); );
} }
private fetchMarketDataOfMarkets() {
return this.http
.get<MarketDataOfMarketsResponse>(
'/api/v1/data-providers/ghostfolio/markets',
{ headers: this.getHeaders() }
)
.pipe(this.catchFetchFailure(), takeUntilDestroyed(this.destroyRef));
}
private fetchQuotes({ symbols }: { symbols: string[] }) { private fetchQuotes({ symbols }: { symbols: string[] }) {
const params = new HttpParams().set('symbols', symbols.join(',')); const params = new HttpParams().set('symbols', symbols.join(','));

52
apps/client/src/app/pages/api/api-page.html

@ -168,6 +168,58 @@
} }
</mat-card-content> </mat-card-content>
</mat-card> </mat-card>
<div class="row">
@let marketDataOfMarkets = marketDataOfMarkets$ | async;
<div class="col-xs-12 col-md-6">
<mat-card appearance="outlined" class="mb-3">
<mat-card-header>
<mat-card-title class="mb-0">Markets</mat-card-title>
<mat-card-subtitle>Stocks</mat-card-subtitle>
</mat-card-header>
<mat-card-content class="pt-3">
@if (isFetchFailure(marketDataOfMarkets)) {
🔴 {{ marketDataOfMarkets.fetchError }}
} @else if (marketDataOfMarkets) {
<gf-fear-and-greed-index
[fearAndGreedIndex]="
marketDataOfMarkets.fearAndGreedIndex.STOCKS.marketPrice
"
/>
} @else {
<ngx-skeleton-loader
animation="pulse"
[theme]="{ height: '2.5rem', width: '100%' }"
/>
}
</mat-card-content>
</mat-card>
</div>
<div class="col-xs-12 col-md-6">
<mat-card appearance="outlined" class="mb-3">
<mat-card-header>
<mat-card-title class="mb-0">Markets</mat-card-title>
<mat-card-subtitle>Cryptocurrencies</mat-card-subtitle>
</mat-card-header>
<mat-card-content class="pt-3">
@if (isFetchFailure(marketDataOfMarkets)) {
🔴 {{ marketDataOfMarkets.fetchError }}
} @else if (marketDataOfMarkets) {
<gf-fear-and-greed-index
[fearAndGreedIndex]="
marketDataOfMarkets.fearAndGreedIndex.CRYPTOCURRENCIES
.marketPrice
"
/>
} @else {
<ngx-skeleton-loader
animation="pulse"
[theme]="{ height: '2.5rem', width: '100%' }"
/>
}
</mat-card-content>
</mat-card>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>

3
apps/client/src/app/pages/blog/2021/07/hallo-ghostfolio/hallo-ghostfolio-page.component.ts

@ -1,10 +1,11 @@
import { publicRoutes } from '@ghostfolio/common/routes/routes'; import { publicRoutes } from '@ghostfolio/common/routes/routes';
import { Component } from '@angular/core'; import { ChangeDetectionStrategy, Component } from '@angular/core';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router'; import { RouterModule } from '@angular/router';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'page' }, host: { class: 'page' },
imports: [MatButtonModule, RouterModule], imports: [MatButtonModule, RouterModule],
selector: 'gf-hallo-ghostfolio-page', selector: 'gf-hallo-ghostfolio-page',

3
apps/client/src/app/pages/blog/2021/07/hello-ghostfolio/hello-ghostfolio-page.component.ts

@ -1,10 +1,11 @@
import { publicRoutes } from '@ghostfolio/common/routes/routes'; import { publicRoutes } from '@ghostfolio/common/routes/routes';
import { Component } from '@angular/core'; import { ChangeDetectionStrategy, Component } from '@angular/core';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router'; import { RouterModule } from '@angular/router';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'page' }, host: { class: 'page' },
imports: [MatButtonModule, RouterModule], imports: [MatButtonModule, RouterModule],
selector: 'gf-hello-ghostfolio-page', selector: 'gf-hello-ghostfolio-page',

3
apps/client/src/app/pages/blog/2022/01/first-months-in-open-source/first-months-in-open-source-page.component.ts

@ -1,10 +1,11 @@
import { publicRoutes } from '@ghostfolio/common/routes/routes'; import { publicRoutes } from '@ghostfolio/common/routes/routes';
import { Component } from '@angular/core'; import { ChangeDetectionStrategy, Component } from '@angular/core';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router'; import { RouterModule } from '@angular/router';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'page' }, host: { class: 'page' },
imports: [MatButtonModule, RouterModule], imports: [MatButtonModule, RouterModule],
selector: 'gf-first-months-in-open-source-page', selector: 'gf-first-months-in-open-source-page',

3
apps/client/src/app/pages/blog/2022/07/ghostfolio-meets-internet-identity/ghostfolio-meets-internet-identity-page.component.ts

@ -1,10 +1,11 @@
import { publicRoutes } from '@ghostfolio/common/routes/routes'; import { publicRoutes } from '@ghostfolio/common/routes/routes';
import { Component } from '@angular/core'; import { ChangeDetectionStrategy, Component } from '@angular/core';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router'; import { RouterModule } from '@angular/router';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'page' }, host: { class: 'page' },
imports: [MatButtonModule, RouterModule], imports: [MatButtonModule, RouterModule],
selector: 'gf-ghostfolio-meets-internet-identity-page', selector: 'gf-ghostfolio-meets-internet-identity-page',

3
apps/client/src/app/pages/blog/2022/07/how-do-i-get-my-finances-in-order/how-do-i-get-my-finances-in-order-page.component.ts

@ -1,10 +1,11 @@
import { publicRoutes } from '@ghostfolio/common/routes/routes'; import { publicRoutes } from '@ghostfolio/common/routes/routes';
import { Component } from '@angular/core'; import { ChangeDetectionStrategy, Component } from '@angular/core';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router'; import { RouterModule } from '@angular/router';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'page' }, host: { class: 'page' },
imports: [MatButtonModule, RouterModule], imports: [MatButtonModule, RouterModule],
selector: 'gf-how-do-i-get-my-finances-in-order-page', selector: 'gf-how-do-i-get-my-finances-in-order-page',

3
apps/client/src/app/pages/blog/2022/08/500-stars-on-github/500-stars-on-github-page.component.ts

@ -1,10 +1,11 @@
import { publicRoutes } from '@ghostfolio/common/routes/routes'; import { publicRoutes } from '@ghostfolio/common/routes/routes';
import { Component } from '@angular/core'; import { ChangeDetectionStrategy, Component } from '@angular/core';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router'; import { RouterModule } from '@angular/router';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'page' }, host: { class: 'page' },
imports: [MatButtonModule, RouterModule], imports: [MatButtonModule, RouterModule],
selector: 'gf-500-stars-on-github-page', selector: 'gf-500-stars-on-github-page',

3
apps/client/src/app/pages/blog/2022/10/hacktoberfest-2022/hacktoberfest-2022-page.component.ts

@ -1,10 +1,11 @@
import { publicRoutes } from '@ghostfolio/common/routes/routes'; import { publicRoutes } from '@ghostfolio/common/routes/routes';
import { Component } from '@angular/core'; import { ChangeDetectionStrategy, Component } from '@angular/core';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router'; import { RouterModule } from '@angular/router';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'page' }, host: { class: 'page' },
imports: [MatButtonModule, RouterModule], imports: [MatButtonModule, RouterModule],
selector: 'gf-hacktoberfest-2022-page', selector: 'gf-hacktoberfest-2022-page',

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

Loading…
Cancel
Save