Browse Source

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

pull/7148/head
Thomas Kaul 17 hours 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
## 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
### Added

43
README.md

@ -85,27 +85,28 @@ We provide official container images hosted on [Docker Hub](https://hub.docker.c
### Supported Environment Variables
| Name | Type | Default Value | Description |
| --------------------------- | --------------------- | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ACCESS_TOKEN_SALT` | `string` | | A random string used as salt for access tokens |
| `API_KEY_COINGECKO_DEMO` | `string` (optional) |   | The _CoinGecko_ Demo API key |
| `API_KEY_COINGECKO_PRO` | `string` (optional) | | The _CoinGecko_ Pro API key |
| `DATABASE_URL` | `string` | | The database connection URL. 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`) |
| `ENABLE_FEATURE_AUTH_TOKEN` | `boolean` (optional) | `true` | Enables authentication via security token |
| `HOST` | `string` (optional) | `0.0.0.0` | The host where the Ghostfolio application will run on |
| `JWT_SECRET_KEY` | `string` | | A random string used for _JSON Web Tokens_ (JWT) |
| `LOG_LEVELS` | `string[]` (optional) | | The logging levels for the Ghostfolio application, e.g. `["debug","error","log","warn"]` |
| `PORT` | `number` (optional) | `3333` | The port where the Ghostfolio application will run on |
| `POSTGRES_DB` | `string` | | The name of the _PostgreSQL_ database |
| `POSTGRES_PASSWORD` | `string` | | The password of the _PostgreSQL_ database |
| `POSTGRES_USER` | `string` | | The user of the _PostgreSQL_ database |
| `REDIS_DB` | `number` (optional) | `0` | The database index of _Redis_ |
| `REDIS_HOST` | `string` | | The host where _Redis_ is running |
| `REDIS_PASSWORD` | `string` | | The password of _Redis_ |
| `REDIS_PORT` | `number` | | The port where _Redis_ is running |
| `REQUEST_TIMEOUT` | `number` (optional) | `2000` | The timeout of network requests to data providers in milliseconds |
| `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. |
| Name | Type | Default Value | Description |
| --------------------------- | --------------------- | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ACCESS_TOKEN_SALT` | `string` | | A random string used as salt for access tokens |
| `API_KEY_COINGECKO_DEMO` | `string` (optional) |   | The _CoinGecko_ Demo API key |
| `API_KEY_COINGECKO_PRO` | `string` (optional) | | The _CoinGecko_ Pro API key |
| `DATABASE_URL` | `string` | | The database connection URL. 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`) |
| `ENABLE_FEATURE_AUTH_TOKEN` | `boolean` (optional) | `true` | Enables authentication via security token |
| `HOST` | `string` (optional) | `0.0.0.0` | The host where the Ghostfolio application will run on |
| `JWT_SECRET_KEY` | `string` | | A random string used for _JSON Web Tokens_ (JWT) |
| `LOG_LEVELS` | `string[]` (optional) | | The logging levels for the Ghostfolio application, e.g. `["debug","error","log","warn"]` |
| `PORT` | `number` (optional) | `3333` | The port where the Ghostfolio application will run on |
| `POSTGRES_DB` | `string` | | The name of the _PostgreSQL_ database |
| `POSTGRES_PASSWORD` | `string` | | The password of the _PostgreSQL_ database |
| `POSTGRES_USER` | `string` | | The user of the _PostgreSQL_ database |
| `REDIS_DB` | `number` (optional) | `0` | The database index of _Redis_ |
| `REDIS_HOST` | `string` | | The host where _Redis_ is running |
| `REDIS_PASSWORD` | `string` | | The password of _Redis_ |
| `REDIS_PORT` | `number` | | The port where _Redis_ is running |
| `REQUEST_TIMEOUT` | `number` (optional) | `2000` | The timeout of network requests to data providers in milliseconds |
| `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)

26
apps/api/project.json

@ -7,32 +7,10 @@
"generators": {},
"targets": {
"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": {
"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"
}
]
}
"production": {}
},
"outputs": ["{options.outputPath}"]
"outputs": ["{workspaceRoot}/dist/apps/api"]
},
"copy-assets": {
"executor": "nx:run-commands",

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

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

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

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

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

@ -299,7 +299,7 @@ export class AdminService {
);
}
} else {
const symbolProfileOverrides = {
const assetProfileOverrides = {
assetClass: assetClass as AssetClass,
assetSubClass: assetSubClass as AssetSubClass,
countries: countries as Prisma.JsonArray,
@ -320,7 +320,7 @@ export class AdminService {
symbolMapping,
...this.symbolProfileService.getAssetProfileUpdateInput(
{ 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;
if (value) {
response = await this.propertyService.put({
value,
key: key as PropertyKey
key,
value
});
} else {
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 { getRedisConnectionOptions } from '@ghostfolio/api/helper/redis.helper';
import { BullBoardAuthMiddleware } from '@ghostfolio/api/middlewares/bull-board-auth.middleware';
import { HtmlTemplateMiddleware } from '@ghostfolio/api/middlewares/html-template.middleware';
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 { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.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 {
BULL_BOARD_ROUTE,
DEFAULT_LANGUAGE_CODE,
SUPPORTED_LANGUAGE_CODES
SUPPORTED_LANGUAGE_CODES,
THROTTLE_DEFAULT_LIMIT,
THROTTLE_DEFAULT_TTL
} from '@ghostfolio/common/config';
import { ExpressAdapter } from '@bull-board/express';
import { BullBoardModule } from '@bull-board/nestjs';
import { ThrottlerStorageRedisService } from '@nest-lab/throttler-storage-redis';
import { BullModule } from '@nestjs/bull';
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { EventEmitterModule } from '@nestjs/event-emitter';
import { ScheduleModule } from '@nestjs/schedule';
import { ServeStaticModule } from '@nestjs/serve-static';
import { StatusCodes } from 'http-status-codes';
import { ThrottlerModule } from '@nestjs/throttler';
import { getReasonPhrase, StatusCodes } from 'http-status-codes';
import { join } from 'node:path';
import { AccessModule } from './access/access.module';
@ -95,12 +101,13 @@ import { UserModule } from './user/user.module';
middleware: BullBoardAuthMiddleware,
route: BULL_BOARD_ROUTE
}),
BullModule.forRoot({
redis: {
db: parseInt(process.env.REDIS_DB ?? '0', 10),
host: process.env.REDIS_HOST,
password: process.env.REDIS_PASSWORD,
port: parseInt(process.env.REDIS_PORT ?? '6379', 10)
BullModule.forRootAsync({
imports: [ConfigurationModule],
inject: [ConfigurationService],
useFactory: (configurationService: ConfigurationService) => {
return {
redis: getRedisConnectionOptions(configurationService)
};
}
}),
CacheModule,
@ -168,6 +175,36 @@ import { UserModule } from './user/user.module';
SubscriptionModule,
SymbolModule,
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,
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 { CustomThrottlerGuard } from '@ghostfolio/api/guards/custom-throttler.guard';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
@ -39,6 +40,7 @@ export class AuthController {
* @deprecated
*/
@Get('anonymous/:accessToken')
@UseGuards(CustomThrottlerGuard)
public async accessTokenLoginGet(
@Param('accessToken') accessToken: string
): Promise<OAuthResponse> {
@ -55,6 +57,7 @@ export class AuthController {
}
@Post('anonymous')
@UseGuards(CustomThrottlerGuard)
public async accessTokenLogin(
@Body() body: { accessToken: string }
): Promise<OAuthResponse> {
@ -135,6 +138,7 @@ export class AuthController {
}
@Post('webauthn/generate-authentication-options')
@UseGuards(CustomThrottlerGuard)
public async generateAuthenticationOptions(
@Body() body: { deviceId: string }
) {
@ -156,6 +160,7 @@ export class AuthController {
}
@Post('webauthn/verify-authentication')
@UseGuards(CustomThrottlerGuard)
public async verifyAuthentication(
@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
},
assetClass: true,
assetProfileOverrides: true,
assetSubClass: true,
comment: true,
countries: true,
@ -210,8 +211,7 @@ export class AssetProfilesService {
name: true,
scraperConfiguration: true,
sectors: true,
symbol: true,
SymbolProfileOverrides: true
symbol: true
}
}),
this.prismaService.symbolProfile.count({ where })
@ -268,7 +268,7 @@ export class AssetProfilesService {
const { assetClass, assetSubClass, countries, name, sectors } =
applyAssetProfileOverrides(
assetProfile,
assetProfile.SymbolProfileOverrides
assetProfile.assetProfileOverrides
);
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,
HistoricalResponse,
LookupResponse,
MarketDataOfMarketsResponse,
QuotesResponse
} from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions';
@ -19,6 +20,7 @@ import {
HttpException,
Inject,
Param,
ParseIntPipe,
Query,
UseGuards,
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')
@HasPermission(permissions.enableDataProviderGhostfolio)
@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 { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { CryptocurrencyModule } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.module';
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service';
@ -33,6 +34,7 @@ import { GhostfolioService } from './ghostfolio.service';
PrismaModule,
PropertyModule,
RedisCacheModule,
SymbolModule,
SymbolProfileModule
],
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 { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { GhostfolioService as GhostfolioDataProviderService } from '@ghostfolio/api/services/data-provider/ghostfolio/ghostfolio.service';
@ -25,6 +26,7 @@ import {
HistoricalResponse,
LookupItem,
LookupResponse,
MarketDataOfMarketsResponse,
QuotesResponse
} from '@ghostfolio/common/interfaces';
import { UserWithSettings } from '@ghostfolio/common/types';
@ -32,6 +34,7 @@ import { UserWithSettings } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common';
import { DataSource, SymbolProfile } from '@prisma/client';
import { Big } from 'big.js';
import { isEmpty } from 'lodash';
@Injectable()
export class GhostfolioService {
@ -42,7 +45,8 @@ export class GhostfolioService {
private readonly dataProviderService: DataProviderService,
private readonly fetchService: FetchService,
private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService
private readonly propertyService: PropertyService,
private readonly symbolService: SymbolService
) {}
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() {
return parseInt(
(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 { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.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 { getCurrencyFromSymbol, isCurrency } from '@ghostfolio/common/helper';
import { MarketDataOfMarketsResponse } from '@ghostfolio/common/interfaces';
@ -49,38 +43,9 @@ export class MarketDataController {
@Query('includeHistoricalData', new ParseIntPipe({ optional: true }))
includeHistoricalData = 0
): Promise<MarketDataOfMarketsResponse> {
const [
marketDataFearAndGreedIndexCryptocurrencies,
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
}
}
};
return this.symbolService.getMarketDataOfMarkets({
includeHistoricalData
});
}
@Post(':dataSource/:symbol')

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

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

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

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

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

@ -122,11 +122,11 @@ export class ImportService {
const isDuplicate = activities.some((activity) => {
return (
activity.accountId === account?.id &&
activity.SymbolProfile.currency === assetProfile.currency &&
activity.SymbolProfile.dataSource === assetProfile.dataSource &&
activity.assetProfile.currency === assetProfile.currency &&
activity.assetProfile.dataSource === assetProfile.dataSource &&
isSameSecond(activity.date, date) &&
activity.quantity === quantity &&
activity.SymbolProfile.symbol === assetProfile.symbol &&
activity.assetProfile.symbol === assetProfile.symbol &&
activity.type === 'DIVIDEND' &&
activity.unitPrice === marketPrice
);
@ -138,6 +138,7 @@ export class ImportService {
return {
account,
assetProfile,
date,
error,
quantity,
@ -463,19 +464,18 @@ export class ImportService {
const error = activity.error;
const fee = activity.fee;
const quantity = activity.quantity;
const SymbolProfile = activity.SymbolProfile;
const tagIds = activity.tagIds ?? [];
const type = activity.type;
const unitPrice = activity.unitPrice;
const assetProfile = assetProfiles[
getAssetProfileIdentifier({
dataSource: SymbolProfile.dataSource,
symbol: SymbolProfile.symbol
dataSource: activity.assetProfile.dataSource,
symbol: activity.assetProfile.symbol
})
] ?? {
dataSource: SymbolProfile.dataSource,
symbol: SymbolProfile.symbol
dataSource: activity.assetProfile.dataSource,
symbol: activity.assetProfile.symbol
};
const {
assetClass,
@ -619,6 +619,8 @@ export class ImportService {
activities.push({
...order,
// @ts-ignore
assetProfile,
error,
value,
valueInBaseCurrency,
@ -633,19 +635,19 @@ export class ImportService {
if (!isDryRun) {
// Gather symbol data in the background, if not dry run
const uniqueActivities = uniqBy(activities, ({ SymbolProfile }) => {
const uniqueActivities = uniqBy(activities, ({ assetProfile }) => {
return getAssetProfileIdentifier({
dataSource: SymbolProfile.dataSource,
symbol: SymbolProfile.symbol
dataSource: assetProfile.dataSource,
symbol: assetProfile.symbol
});
});
this.dataGatheringService.gatherSymbols({
dataGatheringItems: uniqueActivities.map(({ date, SymbolProfile }) => {
dataGatheringItems: uniqueActivities.map(({ assetProfile, date }) => {
return {
date,
dataSource: SymbolProfile.dataSource,
symbol: SymbolProfile.symbol
dataSource: assetProfile.dataSource,
symbol: assetProfile.symbol
};
}),
priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH
@ -692,12 +694,12 @@ export class ImportService {
activity.accountId === accountId &&
activity.comment === comment &&
(activity.currency === currency ||
activity.SymbolProfile.currency === currency) &&
activity.SymbolProfile.dataSource === dataSource &&
activity.assetProfile.currency === currency) &&
activity.assetProfile.dataSource === dataSource &&
isSameSecond(activity.date, date) &&
activity.fee === fee &&
activity.quantity === quantity &&
activity.SymbolProfile.symbol === symbol &&
activity.assetProfile.symbol === symbol &&
activity.type === type &&
activity.unitPrice === unitPrice
);
@ -717,7 +719,7 @@ export class ImportService {
quantity,
type,
unitPrice,
SymbolProfile: {
assetProfile: {
dataSource,
symbol,
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 { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.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 { PropertyService } from '@ghostfolio/api/services/property/property.service';
import {
@ -15,8 +16,7 @@ import {
PROPERTY_GITHUB_STARGAZERS,
PROPERTY_IS_READ_ONLY_MODE,
PROPERTY_SLACK_COMMUNITY_USERS,
PROPERTY_UPTIME,
ghostfolioFearAndGreedIndexDataSourceStocks
PROPERTY_UPTIME
} from '@ghostfolio/common/config';
import { InfoItem, Statistics } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions';
@ -33,6 +33,7 @@ export class InfoService {
public constructor(
private readonly benchmarkService: BenchmarkService,
private readonly configurationService: ConfigurationService,
private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly jwtService: JwtService,
private readonly propertyService: PropertyService,
@ -60,13 +61,15 @@ export class InfoService {
}
if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) {
const fearAndGreedIndexDataSource =
this.dataProviderService.getDataSourceForFearAndGreedIndexStocks();
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
info.fearAndGreedDataSource = encodeDataSource(
ghostfolioFearAndGreedIndexDataSourceStocks
fearAndGreedIndexDataSource
);
} else {
info.fearAndGreedDataSource =
ghostfolioFearAndGreedIndexDataSourceStocks;
info.fearAndGreedDataSource = fearAndGreedIndexDataSource;
}
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
};
export const symbolProfileDummyData = {
export const assetProfileDummyData = {
activitiesCount: undefined,
assetClass: undefined,
assetSubClass: undefined,

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@ -1,6 +1,6 @@
import {
activityDummyData,
symbolProfileDummyData,
assetProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
@ -85,17 +85,17 @@ describe('PortfolioCalculator', () => {
const activities: Activity[] = [
{
...activityDummyData,
date: new Date('2022-01-01'),
feeInAssetProfileCurrency: 0,
feeInBaseCurrency: 0,
quantity: 1,
SymbolProfile: {
...symbolProfileDummyData,
assetProfile: {
...assetProfileDummyData,
currency: 'USD',
dataSource: 'MANUAL',
name: 'Penthouse Apartment',
symbol: 'dac95060-d4f2-4653-a253-2c45e6fb5cde'
},
date: new Date('2022-01-01'),
feeInAssetProfileCurrency: 0,
feeInBaseCurrency: 0,
quantity: 1,
type: 'BUY',
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
let orders: PortfolioOrderItem[] = cloneDeep(
this.activities.filter(({ SymbolProfile }) => {
return SymbolProfile.symbol === symbol;
this.activities.filter(({ assetProfile }) => {
return assetProfile.symbol === symbol;
})
);
const isCash = orders[0]?.SymbolProfile?.assetSubClass === 'CASH';
const isCash = orders[0]?.assetProfile?.assetSubClass === 'CASH';
if (orders.length <= 0) {
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
orders.push({
assetProfile,
date: startDateString,
fee: new Big(0),
feeInBaseCurrency: new Big(0),
itemType: 'start',
quantity: new Big(0),
SymbolProfile: {
dataSource,
symbol,
assetSubClass: isCash ? 'CASH' : undefined
},
type: 'BUY',
unitPrice: unitPriceAtStartDate
});
orders.push({
assetProfile,
date: endDateString,
fee: new Big(0),
feeInBaseCurrency: new Big(0),
itemType: 'end',
SymbolProfile: {
dataSource,
symbol,
assetSubClass: isCash ? 'CASH' : undefined
},
quantity: new Big(0),
type: 'BUY',
unitPrice: unitPriceAtEndDate
@ -357,15 +355,11 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
}
} else {
orders.push({
assetProfile,
date: dateString,
fee: new Big(0),
feeInBaseCurrency: new Big(0),
quantity: new Big(0),
SymbolProfile: {
dataSource,
symbol,
assetSubClass: isCash ? 'CASH' : undefined
},
type: 'BUY',
unitPrice: marketSymbolMap[dateString]?.[symbol] ?? lastUnitPrice,
unitPriceFromMarketData:

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

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

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

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

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

@ -829,10 +829,9 @@ export class PortfolioService {
timeWeightedInvestmentWithCurrencyEffect
} = holding;
const activitiesOfHolding = activities.filter(({ SymbolProfile }) => {
const activitiesOfHolding = activities.filter(({ assetProfile }) => {
return (
SymbolProfile.dataSource === dataSource &&
SymbolProfile.symbol === symbol
assetProfile.dataSource === dataSource && assetProfile.symbol === symbol
);
});
@ -2056,11 +2055,11 @@ export class PortfolioService {
.filter(({ isDraft, type }) => {
return isDraft === false && type === activityType;
})
.map(({ currency, quantity, SymbolProfile, unitPrice }) => {
.map(({ assetProfile, currency, quantity, unitPrice }) => {
return new Big(
this.exchangeRateDataService.toCurrency(
new Big(quantity).mul(unitPrice).toNumber(),
currency ?? SymbolProfile.currency,
currency ?? assetProfile.currency,
userCurrency
)
);
@ -2198,16 +2197,11 @@ export class PortfolioService {
}
}
for (const {
account,
quantity,
SymbolProfile,
type
} of ordersByAccount) {
for (const { account, assetProfile, quantity, type } of ordersByAccount) {
const currentValueOfSymbolInBaseCurrency =
getFactor(type) *
quantity *
(portfolioItemsNow[SymbolProfile.symbol]?.marketPriceInBaseCurrency ??
(portfolioItemsNow[assetProfile.symbol]?.marketPriceInBaseCurrency ??
0);
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 { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
@ -14,16 +15,8 @@ import { RedisCacheService } from './redis-cache.service';
imports: [ConfigurationModule],
inject: [ConfigurationService],
useFactory: async (configurationService: ConfigurationService) => {
const redisPassword = encodeURIComponent(
configurationService.get('REDIS_PASSWORD')
);
return {
stores: [
createKeyv(
`redis://${redisPassword ? `:${redisPassword}` : ''}@${configurationService.get('REDIS_HOST')}:${configurationService.get('REDIS_PORT')}/${configurationService.get('REDIS_DB')}`
)
],
stores: [createKeyv(getRedisConnectionUrl(configurationService))],
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 { DataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import {
ghostfolioFearAndGreedIndexDataSourceCryptocurrencies,
ghostfolioFearAndGreedIndexSymbolCryptocurrencies,
ghostfolioFearAndGreedIndexSymbolStocks
} from '@ghostfolio/common/config';
import {
DATE_FORMAT,
getAssetProfileIdentifier
@ -9,6 +14,7 @@ import {
DataProviderHistoricalResponse,
HistoricalDataItem,
LookupResponse,
MarketDataOfMarketsResponse,
SymbolItem
} from '@ghostfolio/common/interfaces';
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({
includeIndices = false,
query,

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

@ -1,11 +1,16 @@
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 { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.interceptor';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
import {
HEADER_KEY_IMPERSONATION,
THROTTLE_SIGNUP_LIMIT,
THROTTLE_SIGNUP_TTL
} from '@ghostfolio/common/config';
import {
DeleteOwnUserDto,
UpdateOwnAccessTokenDto,
@ -37,6 +42,7 @@ import {
import { REQUEST } from '@nestjs/core';
import { JwtService } from '@nestjs/jwt';
import { AuthGuard } from '@nestjs/passport';
import { Throttle } from '@nestjs/throttler';
import { User as UserModel } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { merge, size } from 'lodash';
@ -128,6 +134,13 @@ export class UserController {
}
@Post()
@Throttle({
default: {
limit: THROTTLE_SIGNUP_LIMIT,
ttl: THROTTLE_SIGNUP_TTL
}
})
@UseGuards(CustomThrottlerGuard)
public async signupUser(): Promise<UserItem> {
const 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;
}
if (
!hasRole(user, Role.DEMO) &&
(user.provider !== 'ANONYMOUS' ||
user.subscription?.type === SubscriptionType.Premium)
) {
currentPermissions.push(permissions.requestOwnUserDeletion);
}
if (hasRole(user, Role.ADMIN)) {
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,
object: data,
paths: [
'activities[*].assetProfile.dataSource',
'activities[*].dataSource',
/* @deprecated */
'activities[*].SymbolProfile.dataSource',
'assetProfile.dataSource',
'benchmarks[*].dataSource',
'errors[*].dataSource',
@ -88,7 +92,10 @@ export class TransformDataSourceInResponseInterceptor<
'holdings[*].assetProfile.dataSource',
'holdings[*].dataSource',
'items[*].dataSource',
/* @deprecated */
'SymbolProfile.dataSource',
'watchlist[*].dataSource'
]
});

20
apps/api/src/main.ts

@ -1,3 +1,4 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import {
BULL_BOARD_ROUTE,
DEFAULT_HOST,
@ -39,6 +40,8 @@ async function bootstrap() {
) as LogLevel[];
} catch {}
await configApp.close();
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
logger:
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 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 { 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';
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()
export class ConfigurationService {
private readonly environmentConfiguration: Environment;
@ -34,6 +56,9 @@ export class ConfigurationService {
CACHE_QUOTES_TTL: num({ default: ms('1 minute') }),
CACHE_TTL: num({ default: CACHE_TTL_NO_CACHE }),
DATA_SOURCE_EXCHANGE_RATES: str({ default: DataSource.YAHOO }),
DATA_SOURCE_FEAR_AND_GREED_INDEX_STOCKS: str({
default: DataSource.MANUAL
}),
DATA_SOURCE_IMPORT: str({ default: DataSource.YAHOO }),
DATA_SOURCES: json({
default: [DataSource.COINGECKO, DataSource.MANUAL, DataSource.YAHOO]
@ -47,6 +72,7 @@ export class ConfigurationService {
ENABLE_FEATURE_CRON: bool({ default: true }),
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: bool({ default: false }),
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_STATISTICS: bool({ default: false }),
ENABLE_FEATURE_SUBSCRIPTION: bool({ default: false }),
@ -111,6 +137,7 @@ export class ConfigurationService {
default: environment.rootUrl
}),
STRIPE_SECRET_KEY: str({ default: '' }),
TRUST_PROXY: trustProxy({ default: '' }),
TWITTER_ACCESS_TOKEN: str({ default: 'dummyAccessToken' }),
TWITTER_ACCESS_TOKEN_SECRET: str({ default: 'dummyAccessTokenSecret' }),
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 {
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) {
const fgi = await this.getFearAndGreedIndex();
return {
[symbol]: {
[format(getYesterday(), DATE_FORMAT)]: {
marketPrice: fgi.previousClose.value
if (fgi) {
return {
[symbol]: {
[format(getYesterday(), DATE_FORMAT)]: {
marketPrice: fgi.previousClose.value
}
}
}
};
};
}
}
} catch (error) {
throw new Error(
@ -101,14 +103,16 @@ export class RapidApiService implements DataProviderInterface {
if (symbol === ghostfolioFearAndGreedIndexSymbolStocks) {
const fgi = await this.getFearAndGreedIndex();
return {
[symbol]: {
currency: undefined,
dataSource: this.getName(),
marketPrice: fgi.now.value,
marketState: 'open'
}
};
if (fgi) {
return {
[symbol]: {
currency: undefined,
dataSource: this.getName(),
marketPrice: fgi.now.value,
marketState: 'open'
}
};
}
}
} catch (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_TTL: number;
DATA_SOURCE_EXCHANGE_RATES: string;
DATA_SOURCE_FEAR_AND_GREED_INDEX_STOCKS: string;
DATA_SOURCE_IMPORT: string;
DATA_SOURCES: string[];
DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER: string[];
@ -22,6 +23,7 @@ export interface Environment extends CleanedEnvAccessors {
ENABLE_FEATURE_CRON: boolean;
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: boolean;
ENABLE_FEATURE_GATHER_NEW_EXCHANGE_RATES: boolean;
ENABLE_FEATURE_RATE_LIMITING: boolean;
ENABLE_FEATURE_READ_ONLY_MODE: boolean;
ENABLE_FEATURE_STATISTICS: boolean;
ENABLE_FEATURE_SUBSCRIPTION: boolean;
@ -56,6 +58,7 @@ export interface Environment extends CleanedEnvAccessors {
REQUEST_TIMEOUT: number;
ROOT_URL: string;
STRIPE_SECRET_KEY: string;
TRUST_PROXY: boolean | number | string;
TWITTER_ACCESS_TOKEN: string;
TWITTER_ACCESS_TOKEN_SECRET: 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 {
AssetProfileOverrides,
DataSource,
Prisma,
SymbolProfile,
SymbolProfileOverrides
SymbolProfile
} from '@prisma/client';
import { continents, countries } from 'countries-list';
@ -85,12 +85,12 @@ export class SymbolProfileService {
}
return {
SymbolProfileOverrides: {
assetProfileOverrides: {
upsert: {
create:
data as Prisma.SymbolProfileOverridesCreateWithoutSymbolProfileInput,
data as Prisma.AssetProfileOverridesCreateWithoutSymbolProfileInput,
update:
data as Prisma.SymbolProfileOverridesUpdateWithoutSymbolProfileInput
data as Prisma.AssetProfileOverridesUpdateWithoutSymbolProfileInput
}
}
};
@ -112,7 +112,7 @@ export class SymbolProfileService {
select: { date: true },
take: 1
},
SymbolProfileOverrides: true
assetProfileOverrides: true
},
where: {
OR: aAssetProfileIdentifiers.map(({ dataSource, symbol }) => {
@ -137,7 +137,7 @@ export class SymbolProfileService {
_count: {
select: { activities: true, watchedBy: true }
},
SymbolProfileOverrides: true
assetProfileOverrides: true
},
where: {
id: {
@ -174,6 +174,7 @@ export class SymbolProfileService {
{ dataSource, symbol }: AssetProfileIdentifier,
{
assetClass,
assetProfileOverrides,
assetSubClass,
comment,
countries,
@ -185,13 +186,13 @@ export class SymbolProfileService {
scraperConfiguration,
sectors,
symbolMapping,
SymbolProfileOverrides,
url
}: Prisma.SymbolProfileUpdateInput
) {
return this.prismaService.symbolProfile.update({
data: {
assetClass,
assetProfileOverrides,
assetSubClass,
comment,
countries,
@ -203,7 +204,6 @@ export class SymbolProfileService {
scraperConfiguration,
sectors,
symbolMapping,
SymbolProfileOverrides,
url
},
where: { dataSource_symbol: { dataSource, symbol } }
@ -216,13 +216,13 @@ export class SymbolProfileService {
activities?: {
date: Date;
}[];
SymbolProfileOverrides: SymbolProfileOverrides;
assetProfileOverrides: AssetProfileOverrides;
})[]
): EnhancedSymbolProfile[] {
return symbolProfiles.map((symbolProfile) => {
const symbolProfileWithOverrides = applyAssetProfileOverrides(
symbolProfile,
symbolProfile.SymbolProfileOverrides
symbolProfile.assetProfileOverrides
);
const item = {
@ -252,7 +252,7 @@ export class SymbolProfileService {
item.dateOfFirstActivity = symbolProfile.activities?.[0]?.date;
delete item.activities;
delete item.SymbolProfileOverrides;
delete item.assetProfileOverrides;
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 { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.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 { Module } from '@nestjs/common';
@Module({
exports: [TwitterBotService],
imports: [BenchmarkModule, ConfigurationModule, SymbolModule],
imports: [
BenchmarkModule,
ConfigurationModule,
DataProviderModule,
SymbolModule
],
providers: [TwitterBotService]
})
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 { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import {
ghostfolioFearAndGreedIndexDataSourceStocks,
ghostfolioFearAndGreedIndexSymbolStocks
} from '@ghostfolio/common/config';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ghostfolioFearAndGreedIndexSymbolStocks } from '@ghostfolio/common/config';
import {
resolveFearAndGreedIndex,
resolveMarketCondition
@ -24,6 +22,7 @@ export class TwitterBotService implements OnModuleInit {
public constructor(
private readonly benchmarkService: BenchmarkService,
private readonly configurationService: ConfigurationService,
private readonly dataProviderService: DataProviderService,
private readonly symbolService: SymbolService
) {}
@ -49,7 +48,8 @@ export class TwitterBotService implements OnModuleInit {
try {
const symbolItem = await this.symbolService.get({
dataGatheringItem: {
dataSource: ghostfolioFearAndGreedIndexDataSourceStocks,
dataSource:
this.dataProviderService.getDataSourceForFearAndGreedIndexStocks(),
symbol: ghostfolioFearAndGreedIndexSymbolStocks
}
});
@ -59,9 +59,9 @@ export class TwitterBotService implements OnModuleInit {
symbolItem.marketPrice
);
let status = `Current market mood is ${emoji} ${text.toLowerCase()} (${
let status = `Current market mood is ${emoji} ${text.toLowerCase()} (${round(
symbolItem.marketPrice
}/100)`;
)}/100)`;
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 }) => {
// Customize webpack config here
return config;
// These options were migrated by @nx/webpack:convert-to-inferred from
// the project.json file and merged with the options in this file
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'],
// Override or add rules here
rules: {}
rules: {
'@typescript-eslint/prefer-nullish-coalescing': 'error'
}
},
{
files: ['**/*.js', '**/*.jsx'],

1
apps/client/jest.config.ts

@ -1,4 +1,3 @@
/* eslint-disable */
export default {
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(
(c) => c.GfWebauthnPageComponent
),
path: internalRoutes.webauthn.path,
title: internalRoutes.webauthn.title
path: internalRoutes.webauthn.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_PAGE_SIZE
} from '@ghostfolio/common/config';
import {
canDeleteAssetProfile,
getDateFormatString
} from '@ghostfolio/common/helper';
import { canDeleteAssetProfile } from '@ghostfolio/common/helper';
import {
AssetProfileIdentifier,
AssetProfileItem,
@ -153,7 +150,6 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit {
];
protected readonly canDeleteAssetProfile = canDeleteAssetProfile;
protected dataSource = new MatTableDataSource<AssetProfileItem>();
protected defaultDateFormat: string;
protected readonly displayedColumns: string[] = [];
protected readonly filters$ = new Subject<Filter[]>();
protected isLoading = true;
@ -236,10 +232,6 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit {
.subscribe((state) => {
if (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>
</th>
<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>
</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>,
locale:
this.assetProfileForm.controls.scraperConfiguration.controls.locale
?.value || undefined,
?.value ?? undefined,
mode:
this.assetProfileForm.controls.scraperConfiguration.controls.mode
?.value ?? undefined,
@ -599,7 +599,7 @@ export class GfAssetProfileDialogComponent implements OnInit {
assetClass: this.assetProfileForm.controls.assetClass.value ?? undefined,
assetSubClass:
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,
dataGatheringFrequency:
this.assetProfileForm.controls.dataGatheringFrequency.value ??
@ -607,8 +607,8 @@ export class GfAssetProfileDialogComponent implements OnInit {
isActive: isBoolean(this.assetProfileForm.controls.isActive.value)
? this.assetProfileForm.controls.isActive.value
: undefined,
name: this.assetProfileForm.controls.name.value || undefined,
url: this.assetProfileForm.controls.url.value || undefined
name: this.assetProfileForm.controls.name.value ?? undefined,
url: this.assetProfileForm.controls.url.value ?? undefined
};
try {
@ -743,7 +743,7 @@ export class GfAssetProfileDialogComponent implements OnInit {
) as Record<string, string>,
locale:
this.assetProfileForm.controls.scraperConfiguration.controls.locale
?.value || undefined,
?.value ?? undefined,
mode: this.assetProfileForm.controls.scraperConfiguration.controls
.mode?.value,
selector:

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

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

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

@ -180,6 +180,28 @@
</td>
</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>
<th
*matHeaderCellDef
@ -226,6 +248,17 @@
mat-row
></tr>
</table>
@if (isLoading) {
<ngx-skeleton-loader
animation="pulse"
class="px-4 py-3"
[theme]="{
height: '1.5rem',
width: '100%'
}"
/>
}
</div>
</mat-card-content>
<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">
<a
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
>
<ng-container i18n>Get Access</ng-container>
@ -76,7 +76,7 @@
<span
class="badge badge-pill badge-secondary ml-2 text-uppercase"
i18n
>new</span
>popular</span
>
}
</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 { ConfirmationDialogType } from '@ghostfolio/common/enums';
import {
canDeleteUser,
getCountryName,
getDateFnsLocale,
getDateFormatString,
@ -27,6 +28,7 @@ import { GfValueComponent } from '@ghostfolio/ui/value';
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
computed,
@ -67,6 +69,7 @@ import { interval } from 'rxjs';
import { switchMap, tap } from 'rxjs/operators';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
CommonModule,
GfPremiumIndicatorComponent,
@ -89,6 +92,7 @@ export class GfAdminUsersComponent implements OnInit {
>();
protected defaultDateFormat: string;
protected displayedColumns: string[] = [];
protected readonly canDeleteUser = canDeleteUser;
protected readonly getCountryName = getCountryName;
protected readonly getEmojiFlag = getEmojiFlag;
protected hasPermissionForSubscription: boolean;
@ -165,6 +169,8 @@ export class GfAdminUsersComponent implements OnInit {
this.user.permissions,
permissions.impersonateAllUsers
);
this.changeDetectorRef.markForCheck();
}
}),
switchMap(() => this.route.paramMap)
@ -196,6 +202,8 @@ export class GfAdminUsersComponent implements OnInit {
pageIndex: this.paginator().pageIndex,
showLoading: false
});
this.changeDetectorRef.markForCheck();
});
}

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

@ -241,7 +241,12 @@
<hr class="m-0" />
<button
mat-menu-item
[disabled]="element.id === user?.id"
[disabled]="
!canDeleteUser({
currentUserId: user?.id,
userId: element.id
})
"
(click)="onDeleteUser(element.id)"
>
<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">
<span class="mr-2">{{ fearAndGreedIndexText }}</span>
<small class="text-muted"
><strong>{{ fearAndGreedIndex }}</strong
><strong>{{ fearAndGreedIndex | number: '1.0-0' }}</strong
>/100</small
>
</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 { translate } from '@ghostfolio/ui/i18n';
import { DecimalPipe } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
@ -11,7 +12,7 @@ import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [NgxSkeletonLoaderModule],
imports: [DecimalPipe, NgxSkeletonLoaderModule],
selector: 'gf-fear-and-greed-index',
styleUrls: ['./fear-and-greed-index.component.scss'],
templateUrl: './fear-and-greed-index.component.html'

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

@ -22,6 +22,7 @@ import { NotificationService } from '@ghostfolio/ui/notifications';
import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator';
import { DataService } from '@ghostfolio/ui/services';
import { HttpErrorResponse } from '@angular/common/http';
import {
ChangeDetectionStrategy,
Component,
@ -42,6 +43,7 @@ import { MatMenuModule, MatMenuTrigger } from '@angular/material/menu';
import { MatToolbarModule } from '@angular/material/toolbar';
import { Router, RouterModule } from '@angular/router';
import { IonIcon } from '@ionic/angular/standalone';
import { StatusCodes } from 'http-status-codes';
import { addIcons } from 'ionicons';
import {
closeOutline,
@ -315,10 +317,15 @@ export class GfHeaderComponent implements OnChanges {
this.dataService
.loginAnonymous(data?.accessToken)
.pipe(
catchError(() => {
this.notificationService.alert({
title: $localize`Oops! Incorrect Security Token.`
});
catchError((error: HttpErrorResponse) => {
if (error.status !== StatusCodes.TOO_MANY_REQUESTS) {
// The notification for too many requests is handled in the
// HttpResponseInterceptor
this.notificationService.alert({
title: $localize`Oops! Incorrect Security Token.`
});
}
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.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) {
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 {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
CUSTOM_ELEMENTS_SCHEMA,
DestroyRef,
inject,
OnInit
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
@ -32,6 +34,7 @@ import { gridOutline, reorderFourOutline } from 'ionicons/icons';
import { DeviceDetectorService } from 'ngx-device-detector';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
FormsModule,
GfHoldingsTableComponent,
@ -51,32 +54,34 @@ import { DeviceDetectorService } from 'ngx-device-detector';
export class GfHomeHoldingsComponent implements OnInit {
public static DEFAULT_HOLDINGS_VIEW_MODE: HoldingsViewMode = 'TABLE';
public deviceType: string;
public hasImpersonationId: boolean;
public hasPermissionToAccessHoldingsChart: boolean;
public hasPermissionToCreateActivity: boolean;
public holdings: PortfolioPosition[];
public holdingType: HoldingType = 'ACTIVE';
public holdingTypeOptions: ToggleOption[] = [
protected deviceType: string;
protected hasImpersonationId: boolean;
protected hasPermissionToAccessHoldingsChart: boolean;
protected hasPermissionToCreateActivity: boolean;
protected holdings: PortfolioPosition[] | undefined;
protected holdingType: HoldingType = 'ACTIVE';
protected readonly holdingTypeOptions: ToggleOption[] = [
{ label: $localize`Active`, value: 'ACTIVE' },
{ label: $localize`Closed`, value: 'CLOSED' }
];
public routerLinkPortfolioActivities =
protected readonly routerLinkPortfolioActivities =
internalRoutes.portfolio.subRoutes.activities.routerLink;
public user: User;
public viewModeFormControl = new FormControl<HoldingsViewMode>(
protected user: User;
protected readonly viewModeFormControl = new FormControl<HoldingsViewMode>(
GfHomeHoldingsComponent.DEFAULT_HOLDINGS_VIEW_MODE
);
public constructor(
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private destroyRef: DestroyRef,
private deviceDetectorService: DeviceDetectorService,
private impersonationStorageService: ImpersonationStorageService,
private router: Router,
private userService: UserService
) {
private readonly changeDetectorRef = inject(ChangeDetectorRef);
private readonly dataService = inject(DataService);
private readonly destroyRef = inject(DestroyRef);
private readonly deviceDetectorService = inject(DeviceDetectorService);
private readonly impersonationStorageService = inject(
ImpersonationStorageService
);
private readonly router = inject(Router);
private readonly userService = inject(UserService);
public constructor() {
addIcons({ gridOutline, reorderFourOutline });
}
@ -88,6 +93,8 @@ export class GfHomeHoldingsComponent implements OnInit {
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((impersonationId) => {
this.hasImpersonationId = !!impersonationId;
this.changeDetectorRef.markForCheck();
});
this.userService.stateChanged
@ -107,14 +114,18 @@ export class GfHomeHoldingsComponent implements OnInit {
);
this.initialize();
this.changeDetectorRef.markForCheck();
}
this.changeDetectorRef.markForCheck();
});
this.viewModeFormControl.valueChanges
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((holdingsViewMode) => {
if (!holdingsViewMode) {
return;
}
this.dataService
.putUserSetting({ holdingsViewMode })
.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.initialize();
}
public onHoldingClicked({ dataSource, symbol }: AssetProfileIdentifier) {
protected onHoldingClicked({ dataSource, symbol }: AssetProfileIdentifier) {
if (dataSource && symbol) {
this.router.navigate([], {
queryParams: { dataSource, symbol, holdingDetailDialog: true }
@ -170,8 +181,8 @@ export class GfHomeHoldingsComponent implements OnInit {
this.viewModeFormControl.setValue(
this.deviceType === 'mobile'
? GfHomeHoldingsComponent.DEFAULT_HOLDINGS_VIEW_MODE
: this.user?.settings?.holdingsViewMode ||
GfHomeHoldingsComponent.DEFAULT_HOLDINGS_VIEW_MODE,
: (this.user?.settings?.holdingsViewMode ??
GfHomeHoldingsComponent.DEFAULT_HOLDINGS_VIEW_MODE),
{ emitEvent: false }
);
} 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 {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
computed,
@ -27,6 +28,7 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { DeviceDetectorService } from 'ngx-device-detector';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
GfBenchmarkComponent,
GfFearAndGreedIndexComponent,

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

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

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

@ -13,10 +13,11 @@ import { GfValueComponent } from '@ghostfolio/ui/value';
import {
ChangeDetectionStrategy,
Component,
effect,
ElementRef,
Input,
OnChanges,
ViewChild
inject,
input,
viewChild
} from '@angular/core';
import { IonIcon } from '@ionic/angular/standalone';
import { CountUp } from 'countup.js';
@ -32,58 +33,68 @@ import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
styleUrls: ['./portfolio-performance.component.scss'],
templateUrl: './portfolio-performance.component.html'
})
export class GfPortfolioPerformanceComponent implements OnChanges {
@Input() deviceType: string;
@Input() errors: ResponseError['errors'];
@Input() isLoading: boolean;
@Input() locale = getLocale();
@Input() performance: PortfolioPerformance;
@Input() precision: number;
@Input() showDetails: boolean;
@Input() unit: string;
export class GfPortfolioPerformanceComponent {
public readonly errors = input<ResponseError['errors']>();
public readonly isLoading = input<boolean>();
public readonly locale = input<string>(getLocale());
public readonly performance = input.required<PortfolioPerformance>();
public readonly precision = input.required<number, number>({
transform: (value) => {
return value >= 0 ? value : 2;
}
});
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) {
addIcons({ timeOutline });
}
private readonly notificationService = inject(NotificationService);
public ngOnChanges() {
this.precision = this.precision >= 0 ? this.precision : 2;
public constructor() {
addIcons({ timeOutline });
if (this.isLoading) {
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();
effect(() => {
if (this.isLoading()) {
if (this.value().nativeElement) {
this.value().nativeElement.innerHTML = '';
}
} 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() {
const errorMessageParts = [];
protected onShowErrors() {
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})`);
}

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

@ -8,10 +8,10 @@ import { GfValueComponent } from '@ghostfolio/ui/value';
import {
ChangeDetectionStrategy,
Component,
EventEmitter,
inject,
Input,
OnChanges,
Output
output
} from '@angular/core';
import { MatTooltipModule } from '@angular/material/tooltip';
import { IonIcon } from '@ionic/angular/standalone';
@ -40,44 +40,46 @@ export class GfPortfolioSummaryComponent implements OnChanges {
@Input() summary: PortfolioSummary;
@Input() user: User;
@Output() emergencyFundChanged = new EventEmitter<number>();
public emergencyFundChanged = output<number>();
public buyAndSellActivitiesTooltip = translate(
protected readonly buyAndSellActivitiesTooltip = translate(
'BUY_AND_SELL_ACTIVITIES_TOOLTIP'
);
public precision = 2;
public timeInMarket: string;
protected precision = 2;
protected timeInMarket: string | undefined;
public get buyingPowerPercentage() {
private readonly notificationService = inject(NotificationService);
public constructor() {
addIcons({ ellipsisHorizontalCircleOutline, informationCircleOutline });
}
protected get buyingPowerPercentage() {
return this.summary?.totalValueInBaseCurrency
? this.summary.cash / this.summary.totalValueInBaseCurrency
: 0;
}
public get emergencyFundPercentage() {
protected get emergencyFundPercentage() {
return this.summary?.totalValueInBaseCurrency
? (this.summary.emergencyFund?.total || 0) /
this.summary.totalValueInBaseCurrency
: 0;
}
public get excludedFromAnalysisPercentage() {
protected get excludedFromAnalysisPercentage() {
return this.summary?.totalValueInBaseCurrency
? this.summary.excludedAccountsAndActivities /
this.summary.totalValueInBaseCurrency
: 0;
}
public constructor(private notificationService: NotificationService) {
addIcons({ ellipsisHorizontalCircleOutline, informationCircleOutline });
}
public ngOnChanges() {
if (this.summary) {
if (
this.deviceType === 'mobile' &&
this.summary.totalValueInBaseCurrency >=
(this.summary.totalValueInBaseCurrency ?? 0) >=
NUMERICAL_PRECISION_THRESHOLD_6_FIGURES
) {
this.precision = 0;
@ -98,7 +100,7 @@ export class GfPortfolioSummaryComponent implements OnChanges {
}
}
public onEditEmergencyFund() {
protected onEditEmergencyFund() {
this.notificationService.prompt({
confirmFn: (value) => {
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>
@if (
data.rule.configuration.thresholdMin &&
data.rule.configuration.thresholdMax
data.rule.configuration?.thresholdMin &&
data.rule.configuration?.thresholdMax
) {
<div class="w-100">
<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 { User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { publicRoutes } from '@ghostfolio/common/routes/routes';
import { GfMembershipCardComponent } from '@ghostfolio/ui/membership-card';
import { NotificationService } from '@ghostfolio/ui/notifications';
import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator';
@ -14,7 +13,8 @@ import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
DestroyRef
DestroyRef,
inject
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button';
@ -40,29 +40,29 @@ import { catchError } from 'rxjs/operators';
templateUrl: './user-account-membership.html'
})
export class GfUserAccountMembershipComponent {
public baseCurrency: string;
public coupon: number;
public couponId: string;
public defaultDateFormat: string;
public durationExtension: StringValue;
public hasPermissionForSubscription: boolean;
public hasPermissionToCreateApiKey: boolean;
public hasPermissionToUpdateUserSettings: boolean;
public price: number;
public priceId: string;
public routerLinkPricing = publicRoutes.pricing.routerLink;
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';
public user: User;
public constructor(
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private destroyRef: DestroyRef,
private notificationService: NotificationService,
private snackBar: MatSnackBar,
private userService: UserService
) {
protected readonly baseCurrency: string;
protected coupon: number | undefined;
protected defaultDateFormat: string;
protected durationExtension: StringValue | undefined;
protected readonly hasPermissionForSubscription: boolean;
protected hasPermissionToCreateApiKey: boolean;
protected hasPermissionToUpdateUserSettings: boolean;
protected price: number;
protected readonly 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';
protected user: User;
private couponId: string | undefined;
private priceId: string;
private readonly changeDetectorRef = inject(ChangeDetectorRef);
private readonly dataService = inject(DataService);
private readonly destroyRef = inject(DestroyRef);
private readonly notificationService = inject(NotificationService);
private readonly snackBar = inject(MatSnackBar);
private readonly userService = inject(UserService);
public constructor() {
const { baseCurrency, globalPermissions } = this.dataService.fetchInfo();
this.baseCurrency = baseCurrency;
@ -104,7 +104,7 @@ export class GfUserAccountMembershipComponent {
});
}
public onCheckout() {
protected onCheckout() {
this.dataService
.createStripeCheckoutSession({
couponId: this.couponId,
@ -125,7 +125,7 @@ export class GfUserAccountMembershipComponent {
});
}
public onGenerateApiKey() {
protected onGenerateApiKey() {
this.notificationService.confirm({
confirmFn: () => {
this.dataService
@ -146,11 +146,9 @@ export class GfUserAccountMembershipComponent {
)
.subscribe(({ apiKey }) => {
this.notificationService.alert({
discardLabel: $localize`Okay`,
message:
$localize`Set this API key in your self-hosted environment:` +
'<br />' +
apiKey,
copyValue: apiKey,
discardLabel: $localize`Close`,
message: $localize`Set this API key in your self-hosted environment:`,
title: $localize`Ghostfolio Premium Data Provider API Key`
});
});
@ -160,7 +158,7 @@ export class GfUserAccountMembershipComponent {
});
}
public onRedeemCoupon() {
protected onRedeemCoupon() {
this.notificationService.prompt({
confirmFn: (value) => {
const couponCode = value?.trim();

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

@ -20,11 +20,12 @@ import {
Component,
CUSTOM_ELEMENTS_SCHEMA,
DestroyRef,
inject,
OnInit
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import {
FormBuilder,
NonNullableFormBuilder,
FormsModule,
ReactiveFormsModule,
Validators
@ -69,20 +70,22 @@ import { catchError } from 'rxjs/operators';
templateUrl: './user-account-settings.html'
})
export class GfUserAccountSettingsComponent implements OnInit {
public appearancePlaceholder = $localize`Auto`;
public baseCurrency: string;
public currencies: string[] = [];
public deleteOwnUserForm = this.formBuilder.group({
protected readonly appearancePlaceholder = $localize`Auto`;
protected readonly baseCurrency: string;
protected closeUserAccountMail: string;
protected readonly currencies: string[] = [];
protected readonly deleteOwnUserForm = inject(NonNullableFormBuilder).group({
accessToken: ['', Validators.required]
});
public hasPermissionToDeleteOwnUser: boolean;
public hasPermissionToUpdateViewMode: boolean;
public hasPermissionToUpdateUserSettings: boolean;
public isAccessTokenHidden = true;
public isFingerprintSupported = this.doesBrowserSupportAuthn();
public isWebAuthnEnabled: boolean;
public language = document.documentElement.lang;
public locales = [
protected hasPermissionToDeleteOwnUser: boolean;
protected hasPermissionToRequestOwnUserDeletion: boolean;
protected hasPermissionToUpdateViewMode: boolean;
protected hasPermissionToUpdateUserSettings: boolean;
protected isAccessTokenHidden = true;
protected readonly isFingerprintSupported = this.doesBrowserSupportAuthn();
protected isWebAuthnEnabled: boolean;
protected readonly language = document.documentElement.lang;
protected locales = [
'ca',
'de',
'de-CH',
@ -100,19 +103,18 @@ export class GfUserAccountSettingsComponent implements OnInit {
'uk',
'zh'
];
public user: User;
public constructor(
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private destroyRef: DestroyRef,
private formBuilder: FormBuilder,
private notificationService: NotificationService,
private settingsStorageService: SettingsStorageService,
private snackBar: MatSnackBar,
private userService: UserService,
public webAuthnService: WebAuthnService
) {
protected user: User;
private readonly changeDetectorRef = inject(ChangeDetectorRef);
private readonly dataService = inject(DataService);
private readonly destroyRef = inject(DestroyRef);
private readonly notificationService = inject(NotificationService);
private readonly settingsStorageService = inject(SettingsStorageService);
private readonly snackBar = inject(MatSnackBar);
private readonly userService = inject(UserService);
private readonly webAuthnService = inject(WebAuthnService);
public constructor() {
const { baseCurrency, currencies } = this.dataService.fetchInfo();
this.baseCurrency = baseCurrency;
@ -124,11 +126,18 @@ export class GfUserAccountSettingsComponent implements OnInit {
if (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.user.permissions,
permissions.deleteOwnUser
);
this.hasPermissionToRequestOwnUserDeletion = hasPermission(
this.user.permissions,
permissions.requestOwnUserDeletion
);
this.hasPermissionToUpdateUserSettings = hasPermission(
this.user.permissions,
permissions.updateUserSettings
@ -139,7 +148,10 @@ export class GfUserAccountSettingsComponent implements OnInit {
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.changeDetectorRef.markForCheck();
@ -153,11 +165,11 @@ export class GfUserAccountSettingsComponent implements OnInit {
this.update();
}
public isCommunityLanguage() {
protected isCommunityLanguage() {
return !['de', 'en'].includes(this.language);
}
public onChangeUserSetting(aKey: string, aValue: string) {
protected onChangeUserSetting(aKey: string, aValue: string) {
this.dataService
.putUserSetting({ [aKey]: aValue })
.pipe(takeUntilDestroyed(this.destroyRef))
@ -181,12 +193,12 @@ export class GfUserAccountSettingsComponent implements OnInit {
});
}
public onCloseAccount() {
protected onCloseAccount() {
this.notificationService.confirm({
confirmFn: () => {
this.dataService
.deleteOwnUser({
accessToken: this.deleteOwnUserForm.get('accessToken').value
accessToken: this.deleteOwnUserForm.controls.accessToken.value
})
.pipe(
catchError(() => {
@ -209,7 +221,7 @@ export class GfUserAccountSettingsComponent implements OnInit {
});
}
public onExperimentalFeaturesChange(aEvent: MatSlideToggleChange) {
protected onExperimentalFeaturesChange(aEvent: MatSlideToggleChange) {
this.dataService
.putUserSetting({ isExperimentalFeatures: aEvent.checked })
.pipe(takeUntilDestroyed(this.destroyRef))
@ -225,13 +237,13 @@ export class GfUserAccountSettingsComponent implements OnInit {
});
}
public onExport() {
protected onExport() {
this.dataService
.fetchExport()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((data) => {
for (const activity of data.activities) {
delete activity.id;
delete (activity as Omit<typeof activity, 'id'> & { id?: string }).id;
}
downloadAsFile({
@ -245,7 +257,7 @@ export class GfUserAccountSettingsComponent implements OnInit {
});
}
public onRestrictedViewChange(aEvent: MatSlideToggleChange) {
protected onRestrictedViewChange(aEvent: MatSlideToggleChange) {
this.dataService
.putUserSetting({ isRestrictedView: aEvent.checked })
.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) {
try {
await this.registerDevice();
@ -284,7 +296,7 @@ export class GfUserAccountSettingsComponent implements OnInit {
}
}
public onViewModeChange(aEvent: MatSlideToggleChange) {
protected onViewModeChange(aEvent: MatSlideToggleChange) {
this.dataService
.putUserSetting({ viewMode: aEvent.checked === true ? 'ZEN' : 'DEFAULT' })
.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="pr-1 w-50"></div>
<div class="pl-1 text-monospace w-50">
<button color="primary" mat-flat-button (click)="onExport()">
<span i18n>Export Data</span>
<button color="primary" i18n mat-flat-button (click)="onExport()">
Export Data
</button>
</div>
</div>
@if (hasPermissionToDeleteOwnUser) {
@if (
hasPermissionToDeleteOwnUser || hasPermissionToRequestOwnUserDeletion
) {
<hr class="mt-5" />
<form
class="w-100"
[formGroup]="deleteOwnUserForm"
(ngSubmit)="onCloseAccount()"
>
<div class="d-flex py-1">
<div class="pr-1 text-danger w-50" i18n>Danger Zone</div>
<div class="pl-1 w-50">
<mat-form-field
appearance="outline"
class="without-hint w-100"
[hideRequiredMarker]="true"
<div class="d-flex py-1">
<div class="pr-1 text-danger w-50" i18n>Danger Zone</div>
<div class="pl-1 w-50">
@if (hasPermissionToDeleteOwnUser) {
<form
class="w-100"
[formGroup]="deleteOwnUserForm"
(ngSubmit)="onCloseAccount()"
>
<mat-label i18n>Security Token</mat-label>
<input
formControlName="accessToken"
matInput
[type]="isAccessTokenHidden ? 'password' : 'text'"
/>
<button
class="mx-2"
mat-icon-button
matSuffix
type="button"
(click)="isAccessTokenHidden = !isAccessTokenHidden"
<mat-form-field
appearance="outline"
class="without-hint w-100"
[hideRequiredMarker]="true"
>
<ion-icon
class="mat-icon"
[name]="
isAccessTokenHidden ? 'eye-outline' : 'eye-off-outline'
"
<mat-label i18n>Security Token</mat-label>
<input
formControlName="accessToken"
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>
</mat-form-field>
<button
class="mt-2"
color="warn"
mat-flat-button
type="submit"
[disabled]="
!(deleteOwnUserForm.dirty && deleteOwnUserForm.valid)
"
>
<span i18n>Close Account</span>
</form>
} @else if (
hasPermissionToRequestOwnUserDeletion &&
(user?.accounts?.length > 0 || user?.activitiesCount > 0)
) {
<button color="warn" i18n mat-flat-button [disabled]="true">
Close Account
</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>
</form>
</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 { AdminService, DataService } from '@ghostfolio/ui/services';
import { GfValueComponent } from '@ghostfolio/ui/value';
@ -9,7 +13,7 @@ import {
Component,
CUSTOM_ELEMENTS_SCHEMA,
DestroyRef,
Inject,
inject,
OnInit
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
@ -49,28 +53,31 @@ import {
templateUrl: './user-detail-dialog.html'
})
export class GfUserDetailDialogComponent implements OnInit {
public baseCurrency: string;
public readonly getCountryName = getCountryName;
public subscriptionsDataSource = new MatTableDataSource<Subscription>();
public subscriptionsDisplayedColumns = [
protected readonly baseCurrency: string;
protected readonly canDeleteUser = canDeleteUser;
protected readonly getCountryName = getCountryName;
protected readonly subscriptionsDataSource =
new MatTableDataSource<Subscription>();
protected readonly subscriptionsDisplayedColumns = [
'createdAt',
'type',
'price',
'expiresAt'
];
public user: AdminUserResponse;
public constructor(
private adminService: AdminService,
private changeDetectorRef: ChangeDetectorRef,
@Inject(MAT_DIALOG_DATA) public data: UserDetailDialogParams,
private dataService: DataService,
private destroyRef: DestroyRef,
public dialogRef: MatDialogRef<
GfUserDetailDialogComponent,
UserDetailDialogResult
>
) {
protected user: AdminUserResponse;
protected readonly data = inject<UserDetailDialogParams>(MAT_DIALOG_DATA);
private readonly adminService = inject(AdminService);
private readonly changeDetectorRef = inject(ChangeDetectorRef);
private readonly dataService = inject(DataService);
private readonly destroyRef = inject(DestroyRef);
private readonly dialogRef =
inject<MatDialogRef<GfUserDetailDialogComponent, UserDetailDialogResult>>(
MatDialogRef
);
public constructor() {
this.baseCurrency = this.dataService.fetchInfo().baseCurrency;
addIcons({
@ -98,26 +105,26 @@ export class GfUserDetailDialogComponent implements OnInit {
});
}
public deleteUser() {
protected deleteUser() {
this.dialogRef.close({
action: 'delete',
userId: this.data.userId
});
}
public getSum() {
protected getSum() {
return getSum(
this.subscriptionsDataSource.data
.filter(({ price }) => {
return price !== null;
})
.map(({ price }) => {
return new Big(price);
return new Big(price ?? 0);
})
).toNumber();
}
public getType({ createdAt, expiresAt, price }: Subscription) {
protected getType({ createdAt, expiresAt, price }: Subscription) {
if (price) {
return $localize`Paid`;
}
@ -127,7 +134,7 @@ export class GfUserDetailDialogComponent implements OnInit {
: $localize`Coupon`;
}
public onClose() {
protected onClose() {
this.dialogRef.close();
}
}

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

@ -16,7 +16,12 @@
<button
mat-menu-item
type="button"
[disabled]="this.data.currentUserId === this.data.userId"
[disabled]="
!canDeleteUser({
currentUserId: data.currentUserId,
userId: data.userId
})
"
(click)="deleteUser()"
>
<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) {
if (!this.snackBarRef) {
this.snackBarRef = this.snackBar.open(
$localize`Oops! It looks like you’re making too many requests. Please slow down a bit.`
);
// Replace an already visible snack bar so that the rate limiting
// feedback is not swallowed
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 = snackBarRef;
} else if (error.status === StatusCodes.UNAUTHORIZED) {
if (!error.url?.includes('/data-providers/ghostfolio/status')) {
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';
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 { addIcons } from 'ionicons';
import {
@ -21,6 +26,7 @@ import {
} from 'ionicons/icons';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'page' },
imports: [GfPageTabsComponent],
selector: 'gf-about-page',
@ -83,8 +89,6 @@ export class AboutPageComponent {
});
this.user = state.user;
this.changeDetectorRef.markForCheck();
}
this.tabs.push({
@ -92,6 +96,8 @@ export class AboutPageComponent {
label: publicRoutes.about.subRoutes.ossFriends.title,
routerLink: publicRoutes.about.subRoutes.ossFriends.routerLink
});
this.changeDetectorRef.markForCheck();
});
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() {
const account: CreateAccountDto | UpdateAccountDto = {
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,
id: this.accountForm.get('accountId')?.value,
isExcluded: this.accountForm.get('isExcluded')?.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
.get('tags')
?.value?.filter(({ id }: Tag) => {

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

@ -12,7 +12,12 @@ import {
TabConfiguration
} 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 { addIcons } from 'ionicons';
import {
@ -24,6 +29,7 @@ import {
} from 'ionicons/icons';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'page' },
imports: [GfPageTabsComponent],
selector: 'gf-admin-page',
@ -35,6 +41,7 @@ export class AdminPageComponent {
private user: User;
private readonly changeDetectorRef = inject(ChangeDetectorRef);
private readonly tokenStorageService = inject(TokenStorageService);
private readonly userService = inject(UserService);
@ -45,6 +52,8 @@ export class AdminPageComponent {
this.user = state?.user;
this.initializeTabs();
this.changeDetectorRef.markForCheck();
});
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 {
HEADER_KEY_SKIP_INTERCEPTOR,
HEADER_KEY_TOKEN
@ -10,6 +11,7 @@ import {
DividendsResponse,
HistoricalResponse,
LookupResponse,
MarketDataOfMarketsResponse,
QuotesResponse
} from '@ghostfolio/common/interfaces';
@ -32,7 +34,12 @@ import { FetchFailure, FetchResult } from './interfaces/interfaces';
@Component({
host: { class: 'page' },
imports: [CommonModule, MatCardModule, NgxSkeletonLoaderModule],
imports: [
CommonModule,
GfFearAndGreedIndexComponent,
MatCardModule,
NgxSkeletonLoaderModule
],
selector: 'gf-api-page',
styleUrls: ['./api-page.scss'],
templateUrl: './api-page.html'
@ -48,6 +55,9 @@ export class GfApiPageComponent implements OnInit {
>;
protected isinLookupItems$: Observable<FetchResult<LookupResponse['items']>>;
protected lookupItems$: Observable<FetchResult<LookupResponse['items']>>;
protected marketDataOfMarkets$: Observable<
FetchResult<MarketDataOfMarketsResponse>
>;
protected quotes$: Observable<FetchResult<QuotesResponse['quotes']>>;
protected status$: Observable<
FetchResult<DataProviderGhostfolioStatusResponse>
@ -68,6 +78,7 @@ export class GfApiPageComponent implements OnInit {
this.historicalData$ = this.fetchHistoricalData({ symbol: 'AAPL' });
this.isinLookupItems$ = this.fetchLookupItems({ query: 'US0378331005' });
this.lookupItems$ = this.fetchLookupItems({ query: 'apple' });
this.marketDataOfMarkets$ = this.fetchMarketDataOfMarkets();
this.quotes$ = this.fetchQuotes({ symbols: ['AAPL', 'VOO'] });
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[] }) {
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>
<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>

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 { Component } from '@angular/core';
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'page' },
imports: [MatButtonModule, RouterModule],
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 { Component } from '@angular/core';
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'page' },
imports: [MatButtonModule, RouterModule],
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 { Component } from '@angular/core';
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'page' },
imports: [MatButtonModule, RouterModule],
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 { Component } from '@angular/core';
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'page' },
imports: [MatButtonModule, RouterModule],
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 { Component } from '@angular/core';
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'page' },
imports: [MatButtonModule, RouterModule],
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 { Component } from '@angular/core';
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'page' },
imports: [MatButtonModule, RouterModule],
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 { Component } from '@angular/core';
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'page' },
imports: [MatButtonModule, RouterModule],
selector: 'gf-hacktoberfest-2022-page',

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

Loading…
Cancel
Save