Browse Source

Resolve package-lock.json conflict with upstream

pull/7007/head
Ankit Singh 3 months ago
parent
commit
c29d564386
  1. 12
      .vscode/launch.json
  2. 55
      CHANGELOG.md
  3. 11
      apps/api/src/app/account/account.controller.ts
  4. 4
      apps/api/src/app/account/account.module.ts
  5. 4
      apps/api/src/app/activities/activities.module.ts
  6. 57
      apps/api/src/app/activities/activities.service.ts
  7. 42
      apps/api/src/app/admin/admin.controller.ts
  8. 110
      apps/api/src/app/admin/admin.service.ts
  9. 14
      apps/api/src/app/auth/auth.module.ts
  10. 4
      apps/api/src/app/auth/google.strategy.ts
  11. 9
      apps/api/src/app/auth/oidc.strategy.ts
  12. 6
      apps/api/src/app/auth/web-auth.service.ts
  13. 7
      apps/api/src/app/endpoints/benchmarks/benchmarks.service.ts
  14. 2
      apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.module.ts
  15. 15
      apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.service.ts
  16. 4
      apps/api/src/app/endpoints/market-data/market-data.controller.ts
  17. 4
      apps/api/src/app/health/health.controller.ts
  18. 4
      apps/api/src/app/import/import.controller.ts
  19. 2
      apps/api/src/app/logo/logo.module.ts
  20. 22
      apps/api/src/app/logo/logo.service.ts
  21. 7
      apps/api/src/app/portfolio/calculator/portfolio-calculator.ts
  22. 2
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts
  23. 6
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts
  24. 55
      apps/api/src/app/portfolio/portfolio.controller.ts
  25. 274
      apps/api/src/app/portfolio/portfolio.service.spec.ts
  26. 48
      apps/api/src/app/portfolio/portfolio.service.ts
  27. 6
      apps/api/src/app/redis-cache/redis-cache.service.ts
  28. 14
      apps/api/src/app/subscription/subscription.controller.ts
  29. 9
      apps/api/src/app/subscription/subscription.service.ts
  30. 4
      apps/api/src/app/symbol/symbol.service.ts
  31. 31
      apps/api/src/app/user/user.service.ts
  32. 12
      apps/api/src/events/asset-profile-changed.listener.ts
  33. 7
      apps/api/src/events/portfolio-changed.listener.ts
  34. 17
      apps/api/src/helper/country.helper.ts
  35. 28
      apps/api/src/helper/sector.helper.ts
  36. 4
      apps/api/src/interceptors/performance-logging/performance-logging.service.ts
  37. 22
      apps/api/src/main.ts
  38. 8
      apps/api/src/middlewares/html-template.middleware.ts
  39. 6
      apps/api/src/services/benchmark/benchmark.service.ts
  40. 60
      apps/api/src/services/data-provider/coingecko/coingecko.service.ts
  41. 3
      apps/api/src/services/data-provider/data-enhancer/data-enhancer.module.ts
  42. 13
      apps/api/src/services/data-provider/data-enhancer/openfigi/openfigi.service.ts
  43. 88
      apps/api/src/services/data-provider/data-enhancer/trackinsight/trackinsight.service.ts
  44. 72
      apps/api/src/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service.ts
  45. 2
      apps/api/src/services/data-provider/data-provider.module.ts
  46. 31
      apps/api/src/services/data-provider/data-provider.service.ts
  47. 64
      apps/api/src/services/data-provider/eod-historical-data/eod-historical-data.service.ts
  48. 208
      apps/api/src/services/data-provider/financial-modeling-prep/financial-modeling-prep.service.ts
  49. 24
      apps/api/src/services/data-provider/ghostfolio/ghostfolio.service.ts
  50. 4
      apps/api/src/services/data-provider/google-sheets/google-sheets.service.ts
  51. 13
      apps/api/src/services/data-provider/manual/manual.service.ts
  52. 19
      apps/api/src/services/data-provider/rapid-api/rapid-api.service.ts
  53. 23
      apps/api/src/services/data-provider/yahoo-finance/yahoo-finance.service.ts
  54. 19
      apps/api/src/services/exchange-rate-data/exchange-rate-data.service.ts
  55. 11
      apps/api/src/services/fetch/fetch.module.ts
  56. 199
      apps/api/src/services/fetch/fetch.service.ts
  57. 19
      apps/api/src/services/fetch/interfaces/web-fetch-route.interface.ts
  58. 8
      apps/api/src/services/i18n/i18n.service.ts
  59. 4
      apps/api/src/services/prisma/prisma.service.ts
  60. 42
      apps/api/src/services/queues/data-gathering/data-gathering.processor.ts
  61. 44
      apps/api/src/services/queues/data-gathering/data-gathering.service.ts
  62. 17
      apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.processor.ts
  63. 2
      apps/api/src/services/queues/statistics-gathering/statistics-gathering.module.ts
  64. 121
      apps/api/src/services/queues/statistics-gathering/statistics-gathering.processor.ts
  65. 60
      apps/api/src/services/symbol-profile/symbol-profile.service.ts
  66. 9
      apps/api/src/services/twitter-bot/twitter-bot.service.ts
  67. 1
      apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html
  68. 3
      apps/client/src/app/components/admin-market-data/admin-market-data.component.ts
  69. 12
      apps/client/src/app/components/admin-market-data/admin-market-data.html
  70. 4
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.scss
  71. 10
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts
  72. 19
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html
  73. 22
      apps/client/src/app/components/admin-users/admin-users.component.ts
  74. 15
      apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts
  75. 9
      apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html
  76. 10
      apps/client/src/app/components/home-watchlist/home-watchlist.component.ts
  77. 12
      apps/client/src/app/components/home-watchlist/home-watchlist.html
  78. 3
      apps/client/src/app/components/user-account-access/user-account-access.component.ts
  79. 12
      apps/client/src/app/components/user-account-access/user-account-access.html
  80. 4
      apps/client/src/app/components/user-detail-dialog/user-detail-dialog.component.scss
  81. 4
      apps/client/src/app/components/user-detail-dialog/user-detail-dialog.html
  82. 12
      apps/client/src/app/core/auth.guard.ts
  83. 16
      apps/client/src/app/core/auth.interceptor.ts
  84. 45
      apps/client/src/app/core/http-response.interceptor.ts
  85. 24
      apps/client/src/app/directives/file-drop/file-drop.directive.ts
  86. 10
      apps/client/src/app/pages/accounts/accounts-page.component.ts
  87. 12
      apps/client/src/app/pages/accounts/accounts-page.html
  88. 11
      apps/client/src/app/pages/portfolio/activities/activities-page.component.ts
  89. 14
      apps/client/src/app/pages/portfolio/activities/activities-page.html
  90. 2
      apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts
  91. 3
      apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.component.ts
  92. 63
      apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts
  93. 153
      apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts
  94. 24
      apps/client/src/app/pages/portfolio/analysis/analysis-page.html
  95. 16
      apps/client/src/app/pages/public/public-page.component.ts
  96. 17
      apps/client/src/app/pages/resources/personal-finance-tools/product-page.component.ts
  97. 770
      apps/client/src/locales/messages.ca.xlf
  98. 770
      apps/client/src/locales/messages.de.xlf
  99. 770
      apps/client/src/locales/messages.es.xlf
  100. 770
      apps/client/src/locales/messages.fr.xlf

12
.vscode/launch.json

@ -18,12 +18,20 @@
"autoAttachChildProcesses": true, "autoAttachChildProcesses": true,
"console": "integratedTerminal", "console": "integratedTerminal",
"cwd": "${workspaceFolder}/apps/api", "cwd": "${workspaceFolder}/apps/api",
"envFile": "${workspaceFolder}/.env", "env": {
"GHOSTFOLIO_ENV_FILE": "${workspaceFolder}/.env"
},
"name": "Debug API", "name": "Debug API",
"outFiles": ["${workspaceFolder}/dist/apps/api/**/*.js"], "outFiles": ["${workspaceFolder}/dist/apps/api/**/*.js"],
"program": "${workspaceFolder}/apps/api/src/main.ts", "program": "${workspaceFolder}/apps/api/src/main.ts",
"request": "launch", "request": "launch",
"runtimeArgs": ["--nolazy", "-r", "ts-node/register"], "runtimeArgs": [
"--nolazy",
"-r",
"ts-node/register",
"-r",
"${workspaceFolder}/tools/load-env.ts"
],
"skipFiles": [ "skipFiles": [
"${workspaceFolder}/node_modules/**/*.js", "${workspaceFolder}/node_modules/**/*.js",
"<node_internals>/**/*.js" "<node_internals>/**/*.js"

55
CHANGELOG.md

@ -9,7 +9,62 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
- Prefilled the form in the account balance management with the current cash balance
## 3.8.0 - 2026-06-07
### Added
- Added an automatic refresh every 30 seconds to the users table in the admin control panel
### Changed
- Harmonized the sector names across the data providers
- Localized the country names
- Localized the sector names
- Centralized the asset profile override logic for manual adjustments
- Improved the styling in the user detail dialog of the admin control panel’s users section
- Prevented the deletion of asset profiles that are currently in use
- Ensured market data is correctly removed when an asset profile with no remaining activities is deleted
- Refactored the backend logging to use the instance-based `Logger`
- Improved the language localization for German (`de`)
- Improved the language localization for Ukrainian (`uk`)
### Fixed
- Prevented the floating action button from overlapping the paginator on mobile
- Fixed an issue where the asset profile override (asset class and asset sub class) was not applied to the data enhancers when gathering asset profiles
- Fixed a layout issue in the asset profile dialog of the admin control panel by truncating long titles
## 3.7.0 - 2026-06-02
### Added
- Added support for routing selected requests through the _OpenRouter_ `web_fetch` tool in the `FetchService`
### Changed
- Extended the countries mapping in the data enhancer for asset profile data via _Trackinsight_
- Removed the deprecated attributes (`assetClass`, `assetClassLabel`, `assetSubClass`, `assetSubClassLabel`, `countries`, `currency`, `dataSource`, `holdings`, `name`, `sectors`, `symbol` and `url`) from the holdings of the portfolio details endpoint response
- Upgraded `Nx` from version `22.7.2` to `22.7.5`
### Fixed
- Resolved an issue in the impersonation mode where the values did not match the owner’s currency
- Fixed the environment variable expansion in the `.env` file when debugging via _Visual Studio Code_
## 3.6.0 - 2026-05-28
### Added
- Added `HTTP_PROXY`, `HTTPS_PROXY`, and `NO_PROXY` environment variable support to outbound HTTP requests
- Added the `FetchService` to centralize outbound HTTP requests
### Changed
- Extracted the floating action buttons (FAB) to a reusable component
- Upgraded `nestjs` from version `11.1.19` to `11.1.21` - Upgraded `nestjs` from version `11.1.19` to `11.1.21`
- Upgraded `yahoo-finance2` from version `3.14.0` to `3.14.2`
## 3.5.0 - 2026-05-24 ## 3.5.0 - 2026-05-24

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

@ -1,5 +1,6 @@
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service'; import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
import { UserService } from '@ghostfolio/api/app/user/user.service';
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.interceptor'; import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.interceptor';
@ -50,7 +51,8 @@ export class AccountController {
private readonly apiService: ApiService, private readonly apiService: ApiService,
private readonly impersonationService: ImpersonationService, private readonly impersonationService: ImpersonationService,
private readonly portfolioService: PortfolioService, private readonly portfolioService: PortfolioService,
@Inject(REQUEST) private readonly request: RequestWithUser @Inject(REQUEST) private readonly request: RequestWithUser,
private readonly userService: UserService
) {} ) {}
@Delete(':id') @Delete(':id')
@ -137,11 +139,14 @@ export class AccountController {
): Promise<AccountBalancesResponse> { ): Promise<AccountBalancesResponse> {
const impersonationUserId = const impersonationUserId =
await this.impersonationService.validateImpersonationId(impersonationId); await this.impersonationService.validateImpersonationId(impersonationId);
const userId = impersonationUserId || this.request.user.id;
const { settings } = await this.userService.user({ id: userId });
return this.accountBalanceService.getAccountBalances({ return this.accountBalanceService.getAccountBalances({
userId,
filters: [{ id, type: 'ACCOUNT' }], filters: [{ id, type: 'ACCOUNT' }],
userCurrency: this.request.user.settings.settings.baseCurrency, userCurrency: settings.settings.baseCurrency
userId: impersonationUserId || this.request.user.id
}); });
} }

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

@ -1,5 +1,6 @@
import { AccountBalanceModule } from '@ghostfolio/api/app/account-balance/account-balance.module'; import { AccountBalanceModule } from '@ghostfolio/api/app/account-balance/account-balance.module';
import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module'; import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module';
import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { RedactValuesInResponseModule } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.module'; import { RedactValuesInResponseModule } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.module';
import { ApiModule } from '@ghostfolio/api/services/api/api.module'; import { ApiModule } from '@ghostfolio/api/services/api/api.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
@ -23,7 +24,8 @@ import { AccountService } from './account.service';
ImpersonationModule, ImpersonationModule,
PortfolioModule, PortfolioModule,
PrismaModule, PrismaModule,
RedactValuesInResponseModule RedactValuesInResponseModule,
UserModule
], ],
providers: [AccountService] providers: [AccountService]
}) })

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

@ -6,9 +6,11 @@ import { RedactValuesInResponseModule } from '@ghostfolio/api/interceptors/redac
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module'; import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module'; import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module';
import { ApiModule } from '@ghostfolio/api/services/api/api.module'; import { ApiModule } from '@ghostfolio/api/services/api/api.module';
import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module'; import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module';
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { DataGatheringQueueModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module'; import { DataGatheringQueueModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
@ -23,11 +25,13 @@ import { ActivitiesService } from './activities.service';
exports: [ActivitiesService], exports: [ActivitiesService],
imports: [ imports: [
ApiModule, ApiModule,
BenchmarkModule,
CacheModule, CacheModule,
DataGatheringQueueModule, DataGatheringQueueModule,
DataProviderModule, DataProviderModule,
ExchangeRateDataModule, ExchangeRateDataModule,
ImpersonationModule, ImpersonationModule,
MarketDataModule,
PrismaModule, PrismaModule,
RedactValuesInResponseModule, RedactValuesInResponseModule,
RedisCacheModule, RedisCacheModule,

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

@ -4,8 +4,10 @@ import { CashDetails } from '@ghostfolio/api/app/account/interfaces/cash-details
import { AssetProfileChangedEvent } from '@ghostfolio/api/events/asset-profile-changed.event'; import { AssetProfileChangedEvent } from '@ghostfolio/api/events/asset-profile-changed.event';
import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event'; import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event';
import { LogPerformance } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor'; import { LogPerformance } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor';
import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service'; import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
@ -16,7 +18,10 @@ import {
ghostfolioPrefix, ghostfolioPrefix,
TAG_ID_EXCLUDE_FROM_ANALYSIS TAG_ID_EXCLUDE_FROM_ANALYSIS
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper'; import {
canDeleteAssetProfile,
getAssetProfileIdentifier
} from '@ghostfolio/common/helper';
import { import {
ActivitiesResponse, ActivitiesResponse,
Activity, Activity,
@ -48,10 +53,12 @@ export class ActivitiesService {
public constructor( public constructor(
private readonly accountBalanceService: AccountBalanceService, private readonly accountBalanceService: AccountBalanceService,
private readonly accountService: AccountService, private readonly accountService: AccountService,
private readonly benchmarkService: BenchmarkService,
private readonly dataGatheringService: DataGatheringService, private readonly dataGatheringService: DataGatheringService,
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
private readonly eventEmitter: EventEmitter2, private readonly eventEmitter: EventEmitter2,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly marketDataService: MarketDataService,
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
private readonly symbolProfileService: SymbolProfileService private readonly symbolProfileService: SymbolProfileService
) {} ) {}
@ -262,7 +269,26 @@ export class ActivitiesService {
activity.symbolProfileId activity.symbolProfileId
]); ]);
if (symbolProfile.activitiesCount === 0) { const benchmarkAssetProfiles =
await this.benchmarkService.getBenchmarkAssetProfiles();
const isBenchmark = benchmarkAssetProfiles.some(({ id }) => {
return id === symbolProfile.id;
});
if (
canDeleteAssetProfile({
isBenchmark,
activitiesCount: symbolProfile.activitiesCount,
symbol: symbolProfile.symbol,
watchedByCount: symbolProfile.watchedByCount
})
) {
await this.marketDataService.deleteMany({
dataSource: symbolProfile.dataSource,
symbol: symbolProfile.symbol
});
await this.symbolProfileService.deleteById(activity.symbolProfileId); await this.symbolProfileService.deleteById(activity.symbolProfileId);
} }
@ -308,8 +334,31 @@ export class ActivitiesService {
}) })
); );
for (const { activitiesCount, id } of symbolProfiles) { const benchmarkAssetProfiles =
if (activitiesCount === 0) { await this.benchmarkService.getBenchmarkAssetProfiles();
for (const {
activitiesCount,
dataSource,
id,
symbol,
watchedByCount
} of symbolProfiles) {
const isBenchmark = benchmarkAssetProfiles.some(
(benchmarkAssetProfile) => {
return benchmarkAssetProfile.id === id;
}
);
if (
canDeleteAssetProfile({
activitiesCount,
isBenchmark,
symbol,
watchedByCount
})
) {
await this.marketDataService.deleteMany({ dataSource, symbol });
await this.symbolProfileService.deleteById(id); await this.symbolProfileService.deleteById(id);
} }
} }

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

@ -2,9 +2,11 @@ import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorat
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor'; import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
import { ApiService } from '@ghostfolio/api/services/api/api.service'; import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service';
import { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.service'; import { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.service';
import { DemoService } from '@ghostfolio/api/services/demo/demo.service'; import { DemoService } from '@ghostfolio/api/services/demo/demo.service';
import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service'; import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper'; import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper';
import { import {
DATA_GATHERING_QUEUE_PRIORITY_HIGH, DATA_GATHERING_QUEUE_PRIORITY_HIGH,
@ -16,7 +18,10 @@ import {
UpdateAssetProfileDto, UpdateAssetProfileDto,
UpdatePropertyDto UpdatePropertyDto
} from '@ghostfolio/common/dtos'; } from '@ghostfolio/common/dtos';
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper'; import {
canDeleteAssetProfile,
getAssetProfileIdentifier
} from '@ghostfolio/common/helper';
import { import {
AdminData, AdminData,
AdminMarketData, AdminMarketData,
@ -58,13 +63,17 @@ import { AdminService } from './admin.service';
@Controller('admin') @Controller('admin')
export class AdminController { export class AdminController {
private readonly logger = new Logger(AdminController.name);
public constructor( public constructor(
private readonly adminService: AdminService, private readonly adminService: AdminService,
private readonly apiService: ApiService, private readonly apiService: ApiService,
private readonly benchmarkService: BenchmarkService,
private readonly dataGatheringService: DataGatheringService, private readonly dataGatheringService: DataGatheringService,
private readonly demoService: DemoService, private readonly demoService: DemoService,
private readonly manualService: ManualService, private readonly manualService: ManualService,
@Inject(REQUEST) private readonly request: RequestWithUser @Inject(REQUEST) private readonly request: RequestWithUser,
private readonly symbolProfileService: SymbolProfileService
) {} ) {}
@Get() @Get()
@ -260,7 +269,7 @@ export class AdminController {
`Could not parse the market price for ${symbol} (${dataSource})` `Could not parse the market price for ${symbol} (${dataSource})`
); );
} catch (error) { } catch (error) {
Logger.error(error, 'AdminController'); this.logger.error(error);
throw new HttpException(error.message, StatusCodes.BAD_REQUEST); throw new HttpException(error.message, StatusCodes.BAD_REQUEST);
} }
@ -288,6 +297,33 @@ export class AdminController {
@Param('dataSource') dataSource: DataSource, @Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string @Param('symbol') symbol: string
): Promise<void> { ): Promise<void> {
const [assetProfile] = await this.symbolProfileService.getSymbolProfiles([
{ dataSource, symbol }
]);
if (assetProfile) {
const benchmarkAssetProfiles =
await this.benchmarkService.getBenchmarkAssetProfiles();
const isBenchmark = benchmarkAssetProfiles.some(({ id }) => {
return id === assetProfile.id;
});
if (
!canDeleteAssetProfile({
isBenchmark,
activitiesCount: assetProfile.activitiesCount,
symbol: assetProfile.symbol,
watchedByCount: assetProfile.watchedByCount
})
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
}
return this.adminService.deleteProfileData({ dataSource, symbol }); return this.adminService.deleteProfileData({ dataSource, symbol });
} }

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

@ -14,6 +14,7 @@ import {
PROPERTY_IS_USER_SIGNUP_ENABLED PROPERTY_IS_USER_SIGNUP_ENABLED
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { import {
applyAssetProfileOverrides,
getAssetProfileIdentifier, getAssetProfileIdentifier,
getCurrencyFromSymbol, getCurrencyFromSymbol,
isCurrency isCurrency
@ -29,7 +30,6 @@ import {
EnhancedSymbolProfile, EnhancedSymbolProfile,
Filter Filter
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
import { MarketDataPreset } from '@ghostfolio/common/types'; import { MarketDataPreset } from '@ghostfolio/common/types';
import { import {
@ -349,87 +349,61 @@ export class AdminService {
} }
let marketData: AdminMarketDataItem[] = await Promise.all( let marketData: AdminMarketDataItem[] = await Promise.all(
assetProfiles.map( assetProfiles.map(async (assetProfile) => {
async ({ const {
_count, _count,
activities, activities,
assetClass,
assetSubClass,
comment, comment,
countries,
currency, currency,
dataSource, dataSource,
id, id,
isActive, isActive,
isUsedByUsersWithSubscription, isUsedByUsersWithSubscription,
name, symbol
sectors, } = assetProfile;
symbol,
SymbolProfileOverrides
}) => {
let countriesCount = countries ? Object.keys(countries).length : 0;
const lastMarketPrice = lastMarketPriceMap.get( const { assetClass, assetSubClass, countries, name, sectors } =
getAssetProfileIdentifier({ dataSource, symbol }) applyAssetProfileOverrides(
assetProfile,
assetProfile.SymbolProfileOverrides
); );
const marketDataItemCount = const countriesCount = countries ? Object.keys(countries).length : 0;
marketDataItems.find((marketDataItem) => {
return (
marketDataItem.dataSource === dataSource &&
marketDataItem.symbol === symbol
);
})?._count ?? 0;
let sectorsCount = sectors ? Object.keys(sectors).length : 0;
if (SymbolProfileOverrides) {
assetClass = SymbolProfileOverrides.assetClass ?? assetClass;
assetSubClass =
SymbolProfileOverrides.assetSubClass ?? assetSubClass;
if (
(SymbolProfileOverrides.countries as unknown as Prisma.JsonArray)
?.length > 0
) {
countriesCount = (
SymbolProfileOverrides.countries as unknown as Prisma.JsonArray
).length;
}
name = SymbolProfileOverrides.name ?? name; const lastMarketPrice = lastMarketPriceMap.get(
getAssetProfileIdentifier({ dataSource, symbol })
);
if ( const marketDataItemCount =
(SymbolProfileOverrides.sectors as unknown as Sector[])?.length > marketDataItems.find((marketDataItem) => {
0 return (
) { marketDataItem.dataSource === dataSource &&
sectorsCount = ( marketDataItem.symbol === symbol
SymbolProfileOverrides.sectors as unknown as Prisma.JsonArray );
).length; })?._count ?? 0;
}
}
return { const sectorsCount = sectors ? Object.keys(sectors).length : 0;
assetClass,
assetSubClass, return {
comment, assetClass,
countriesCount, assetSubClass,
currency, comment,
dataSource, countriesCount,
id, currency,
isActive, dataSource,
lastMarketPrice, id,
marketDataItemCount, isActive,
name, lastMarketPrice,
sectorsCount, marketDataItemCount,
symbol, name,
activitiesCount: _count.activities, sectorsCount,
date: activities?.[0]?.date, symbol,
isUsedByUsersWithSubscription: await isUsedByUsersWithSubscription, activitiesCount: _count.activities,
watchedByCount: _count.watchedBy date: activities?.[0]?.date,
}; isUsedByUsersWithSubscription: await isUsedByUsersWithSubscription,
} watchedByCount: _count.watchedBy
) };
})
); );
if (presetId) { if (presetId) {

14
apps/api/src/app/auth/auth.module.ts

@ -5,6 +5,8 @@ import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { ApiKeyService } from '@ghostfolio/api/services/api-key/api-key.service'; import { ApiKeyService } from '@ghostfolio/api/services/api-key/api-key.service';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { FetchModule } from '@ghostfolio/api/services/fetch/fetch.module';
import { FetchService } from '@ghostfolio/api/services/fetch/fetch.service';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
@ -23,6 +25,7 @@ import { OidcStrategy } from './oidc.strategy';
controllers: [AuthController], controllers: [AuthController],
imports: [ imports: [
ConfigurationModule, ConfigurationModule,
FetchModule,
JwtModule.register({ JwtModule.register({
secret: process.env.JWT_SECRET_KEY, secret: process.env.JWT_SECRET_KEY,
signOptions: { expiresIn: '180 days' } signOptions: { expiresIn: '180 days' }
@ -40,12 +43,15 @@ import { OidcStrategy } from './oidc.strategy';
GoogleStrategy, GoogleStrategy,
JwtStrategy, JwtStrategy,
{ {
inject: [AuthService, ConfigurationService], inject: [AuthService, ConfigurationService, FetchService],
provide: OidcStrategy, provide: OidcStrategy,
useFactory: async ( useFactory: async (
authService: AuthService, authService: AuthService,
configurationService: ConfigurationService configurationService: ConfigurationService,
fetchService: FetchService
) => { ) => {
const logger = new Logger('OidcStrategy');
const isOidcEnabled = configurationService.get( const isOidcEnabled = configurationService.get(
'ENABLE_FEATURE_AUTH_OIDC' 'ENABLE_FEATURE_AUTH_OIDC'
); );
@ -81,7 +87,7 @@ import { OidcStrategy } from './oidc.strategy';
} else { } else {
// Fetch OIDC configuration from discovery endpoint // Fetch OIDC configuration from discovery endpoint
try { try {
const response = await fetch( const response = await fetchService.fetch(
`${issuer}/.well-known/openid-configuration` `${issuer}/.well-known/openid-configuration`
); );
@ -97,7 +103,7 @@ import { OidcStrategy } from './oidc.strategy';
tokenURL = manualTokenUrl || config.token_endpoint; tokenURL = manualTokenUrl || config.token_endpoint;
userInfoURL = manualUserInfoUrl || config.userinfo_endpoint; userInfoURL = manualUserInfoUrl || config.userinfo_endpoint;
} catch (error) { } catch (error) {
Logger.error(error, 'OidcStrategy'); logger.error(error);
throw new Error('Failed to fetch OIDC configuration from issuer'); throw new Error('Failed to fetch OIDC configuration from issuer');
} }
} }

4
apps/api/src/app/auth/google.strategy.ts

@ -10,6 +10,8 @@ import { AuthService } from './auth.service';
@Injectable() @Injectable()
export class GoogleStrategy extends PassportStrategy(Strategy, 'google') { export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
private readonly logger = new Logger(GoogleStrategy.name);
public constructor( public constructor(
private readonly authService: AuthService, private readonly authService: AuthService,
configurationService: ConfigurationService configurationService: ConfigurationService
@ -40,7 +42,7 @@ export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
done(null, { jwt }); done(null, { jwt });
} catch (error) { } catch (error) {
Logger.error(error, 'GoogleStrategy'); this.logger.error(error);
done(error, false); done(error, false);
} }
} }

9
apps/api/src/app/auth/oidc.strategy.ts

@ -15,6 +15,8 @@ import { OidcStateStore } from './oidc-state.store';
@Injectable() @Injectable()
export class OidcStrategy extends PassportStrategy(Strategy, 'oidc') { export class OidcStrategy extends PassportStrategy(Strategy, 'oidc') {
private readonly logger = new Logger(OidcStrategy.name);
private static readonly stateStore = new OidcStateStore(); private static readonly stateStore = new OidcStateStore();
public constructor( public constructor(
@ -52,9 +54,8 @@ export class OidcStrategy extends PassportStrategy(Strategy, 'oidc') {
}); });
if (!thirdPartyId) { if (!thirdPartyId) {
Logger.error( this.logger.error(
`Missing subject identifier in OIDC response from ${issuer}`, `Missing subject identifier in OIDC response from ${issuer}`
'OidcStrategy'
); );
throw new Error('Missing subject identifier in OIDC response'); throw new Error('Missing subject identifier in OIDC response');
@ -62,7 +63,7 @@ export class OidcStrategy extends PassportStrategy(Strategy, 'oidc') {
return { jwt }; return { jwt };
} catch (error) { } catch (error) {
Logger.error(error, 'OidcStrategy'); this.logger.error(error);
throw error; throw error;
} }
} }

6
apps/api/src/app/auth/web-auth.service.ts

@ -33,6 +33,8 @@ import ms from 'ms';
@Injectable() @Injectable()
export class WebAuthService { export class WebAuthService {
private readonly logger = new Logger(WebAuthService.name);
public constructor( public constructor(
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly deviceService: AuthDeviceService, private readonly deviceService: AuthDeviceService,
@ -103,7 +105,7 @@ export class WebAuthService {
verification = await verifyRegistrationResponse(opts); verification = await verifyRegistrationResponse(opts);
} catch (error) { } catch (error) {
Logger.error(error, 'WebAuthService'); this.logger.error(error);
throw new InternalServerErrorException(error.message); throw new InternalServerErrorException(error.message);
} }
@ -210,7 +212,7 @@ export class WebAuthService {
verification = await verifyAuthenticationResponse(opts); verification = await verifyAuthenticationResponse(opts);
} catch (error) { } catch (error) {
Logger.error(error, 'WebAuthService'); this.logger.error(error);
throw new InternalServerErrorException({ error: error.message }); throw new InternalServerErrorException({ error: error.message });
} }

7
apps/api/src/app/endpoints/benchmarks/benchmarks.service.ts

@ -17,6 +17,8 @@ import { isNumber } from 'lodash';
@Injectable() @Injectable()
export class BenchmarksService { export class BenchmarksService {
private readonly logger = new Logger(BenchmarksService.name);
public constructor( public constructor(
private readonly benchmarkService: BenchmarkService, private readonly benchmarkService: BenchmarkService,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
@ -96,12 +98,11 @@ export class BenchmarksService {
})?.marketPrice; })?.marketPrice;
if (!marketPriceAtStartDate) { if (!marketPriceAtStartDate) {
Logger.error( this.logger.error(
`No historical market data has been found for ${symbol} (${dataSource}) at ${format( `No historical market data has been found for ${symbol} (${dataSource}) at ${format(
startDate, startDate,
DATE_FORMAT DATE_FORMAT
)}`, )}`
'BenchmarkService'
); );
return { marketData }; return { marketData };

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

@ -12,6 +12,7 @@ import { GoogleSheetsService } from '@ghostfolio/api/services/data-provider/goog
import { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.service'; import { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.service';
import { RapidApiService } from '@ghostfolio/api/services/data-provider/rapid-api/rapid-api.service'; import { RapidApiService } from '@ghostfolio/api/services/data-provider/rapid-api/rapid-api.service';
import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service'; import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service';
import { FetchModule } from '@ghostfolio/api/services/fetch/fetch.module';
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module'; import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
@ -27,6 +28,7 @@ import { GhostfolioService } from './ghostfolio.service';
imports: [ imports: [
CryptocurrencyModule, CryptocurrencyModule,
DataProviderModule, DataProviderModule,
FetchModule,
MarketDataModule, MarketDataModule,
PrismaModule, PrismaModule,
PropertyModule, PropertyModule,

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

@ -8,6 +8,7 @@ import {
GetQuotesParams, GetQuotesParams,
GetSearchParams GetSearchParams
} from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface'; } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
import { FetchService } from '@ghostfolio/api/services/fetch/fetch.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { import {
@ -33,9 +34,12 @@ import { Big } from 'big.js';
@Injectable() @Injectable()
export class GhostfolioService { export class GhostfolioService {
private readonly logger = new Logger(GhostfolioService.name);
public constructor( public constructor(
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
private readonly fetchService: FetchService,
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService private readonly propertyService: PropertyService
) {} ) {}
@ -97,7 +101,7 @@ export class GhostfolioService {
return result; return result;
} catch (error) { } catch (error) {
Logger.error(error, 'GhostfolioService'); this.logger.error(error);
throw error; throw error;
} }
@ -139,7 +143,7 @@ export class GhostfolioService {
return result; return result;
} catch (error) { } catch (error) {
Logger.error(error, 'GhostfolioService'); this.logger.error(error);
throw error; throw error;
} }
@ -181,7 +185,7 @@ export class GhostfolioService {
return result; return result;
} catch (error) { } catch (error) {
Logger.error(error, 'GhostfolioService'); this.logger.error(error);
throw error; throw error;
} }
@ -269,7 +273,7 @@ export class GhostfolioService {
return results; return results;
} catch (error) { } catch (error) {
Logger.error(error, 'GhostfolioService'); this.logger.error(error);
throw error; throw error;
} }
@ -346,7 +350,7 @@ export class GhostfolioService {
return results; return results;
} catch (error) { } catch (error) {
Logger.error(error, 'GhostfolioService'); this.logger.error(error);
throw error; throw error;
} }
@ -355,6 +359,7 @@ export class GhostfolioService {
private getDataProviderInfo(): DataProviderInfo { private getDataProviderInfo(): DataProviderInfo {
const ghostfolioDataProviderService = new GhostfolioDataProviderService( const ghostfolioDataProviderService = new GhostfolioDataProviderService(
this.configurationService, this.configurationService,
this.fetchService,
this.propertyService this.propertyService
); );

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

@ -120,10 +120,10 @@ export class MarketDataController {
if (!canReadAllAssetProfiles && !canReadOwnAssetProfile) { if (!canReadAllAssetProfiles && !canReadOwnAssetProfile) {
throw new HttpException( throw new HttpException(
assetProfile.userId assetProfile?.userId
? getReasonPhrase(StatusCodes.NOT_FOUND) ? getReasonPhrase(StatusCodes.NOT_FOUND)
: getReasonPhrase(StatusCodes.FORBIDDEN), : getReasonPhrase(StatusCodes.FORBIDDEN),
assetProfile.userId ? StatusCodes.NOT_FOUND : StatusCodes.FORBIDDEN assetProfile?.userId ? StatusCodes.NOT_FOUND : StatusCodes.FORBIDDEN
); );
} }

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

@ -24,6 +24,8 @@ import { HealthService } from './health.service';
@Controller('health') @Controller('health')
export class HealthController { export class HealthController {
private readonly logger = new Logger(HealthController.name);
public constructor( public constructor(
private readonly aiService: AiService, private readonly aiService: AiService,
private readonly healthService: HealthService private readonly healthService: HealthService
@ -61,7 +63,7 @@ export class HealthController {
.json({ status: getReasonPhrase(StatusCodes.OK) }); .json({ status: getReasonPhrase(StatusCodes.OK) });
} }
} catch (error) { } catch (error) {
Logger.error(error, 'HealthController'); this.logger.error(error);
} }
return response return response

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

@ -31,6 +31,8 @@ import { ImportService } from './import.service';
@Controller('import') @Controller('import')
export class ImportController { export class ImportController {
private readonly logger = new Logger(ImportController.name);
public constructor( public constructor(
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly importService: ImportService, private readonly importService: ImportService,
@ -81,7 +83,7 @@ export class ImportController {
return { activities }; return { activities };
} catch (error) { } catch (error) {
Logger.error(error, ImportController); this.logger.error(error);
throw new HttpException( throw new HttpException(
{ {

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

@ -1,5 +1,6 @@
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module'; import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { FetchModule } from '@ghostfolio/api/services/fetch/fetch.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
@ -11,6 +12,7 @@ import { LogoService } from './logo.service';
controllers: [LogoController], controllers: [LogoController],
imports: [ imports: [
ConfigurationModule, ConfigurationModule,
FetchModule,
SymbolProfileModule, SymbolProfileModule,
TransformDataSourceInRequestModule TransformDataSourceInRequestModule
], ],

22
apps/api/src/app/logo/logo.service.ts

@ -1,4 +1,5 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { FetchService } from '@ghostfolio/api/services/fetch/fetch.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces'; import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
@ -10,6 +11,7 @@ import { StatusCodes, getReasonPhrase } from 'http-status-codes';
export class LogoService { export class LogoService {
public constructor( public constructor(
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly fetchService: FetchService,
private readonly symbolProfileService: SymbolProfileService private readonly symbolProfileService: SymbolProfileService
) {} ) {}
@ -43,15 +45,17 @@ export class LogoService {
} }
private async getBuffer(aUrl: string) { private async getBuffer(aUrl: string) {
const blob = await fetch( const blob = await this.fetchService
`https://t0.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${aUrl}&size=64`, .fetch(
{ `https://t0.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${aUrl}&size=64`,
headers: { 'User-Agent': 'request' }, {
signal: AbortSignal.timeout( headers: { 'User-Agent': 'request' },
this.configurationService.get('REQUEST_TIMEOUT') signal: AbortSignal.timeout(
) this.configurationService.get('REQUEST_TIMEOUT')
} )
).then((res) => res.blob()); }
)
.then((res) => res.blob());
return { return {
buffer: await blob.arrayBuffer().then((arrayBuffer) => { buffer: await blob.arrayBuffer().then((arrayBuffer) => {

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

@ -62,6 +62,8 @@ import { isNumber, sortBy, sum, uniqBy } from 'lodash';
export abstract class PortfolioCalculator { export abstract class PortfolioCalculator {
protected static readonly ENABLE_LOGGING = false; protected static readonly ENABLE_LOGGING = false;
protected readonly logger = new Logger(PortfolioCalculator.name);
protected accountBalanceItems: HistoricalDataItem[]; protected accountBalanceItems: HistoricalDataItem[];
protected activities: PortfolioOrder[]; protected activities: PortfolioOrder[];
@ -1119,12 +1121,11 @@ export abstract class PortfolioCalculator {
if (cachedPortfolioSnapshot) { if (cachedPortfolioSnapshot) {
this.snapshot = cachedPortfolioSnapshot; this.snapshot = cachedPortfolioSnapshot;
Logger.debug( this.logger.debug(
`Fetched portfolio snapshot from cache in ${( `Fetched portfolio snapshot from cache in ${(
(performance.now() - startTimeTotal) / (performance.now() - startTimeTotal) /
1000 1000
).toFixed(3)} seconds`, ).toFixed(3)} seconds`
'PortfolioCalculator'
); );
if (isCachedPortfolioSnapshotExpired) { if (isCachedPortfolioSnapshotExpired) {

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

@ -116,10 +116,12 @@ describe('PortfolioCalculator', () => {
accountBalanceService, accountBalanceService,
accountService, accountService,
null, null,
null,
dataProviderService, dataProviderService,
null, null,
exchangeRateDataService, exchangeRateDataService,
null, null,
null,
null null
); );

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

@ -11,7 +11,6 @@ import { PortfolioSnapshot, TimelinePosition } from '@ghostfolio/common/models';
import { DateRange } from '@ghostfolio/common/types'; import { DateRange } from '@ghostfolio/common/types';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Logger } from '@nestjs/common';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { import {
addMilliseconds, addMilliseconds,
@ -96,9 +95,8 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
currentPosition.timeWeightedInvestmentWithCurrencyEffect currentPosition.timeWeightedInvestmentWithCurrencyEffect
); );
} else if (!currentPosition.quantity.eq(0)) { } else if (!currentPosition.quantity.eq(0)) {
Logger.warn( this.logger.warn(
`Missing historical market data for ${currentPosition.symbol} (${currentPosition.dataSource})`, `Missing historical market data for ${currentPosition.symbol} (${currentPosition.dataSource})`
'PortfolioCalculator'
); );
hasErrors = true; hasErrors = true;

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

@ -1,4 +1,5 @@
import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service'; import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service';
import { UserService } from '@ghostfolio/api/app/user/user.service';
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { import {
@ -70,7 +71,8 @@ export class PortfolioController {
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly impersonationService: ImpersonationService, private readonly impersonationService: ImpersonationService,
private readonly portfolioService: PortfolioService, private readonly portfolioService: PortfolioService,
@Inject(REQUEST) private readonly request: RequestWithUser @Inject(REQUEST) private readonly request: RequestWithUser,
private readonly userService: UserService
) {} ) {}
@Get('details') @Get('details')
@ -144,10 +146,10 @@ export class PortfolioController {
.reduce((a, b) => a + b, 0); .reduce((a, b) => a + b, 0);
const totalValue = Object.values(holdings) const totalValue = Object.values(holdings)
.filter(({ assetClass, assetSubClass }) => { .filter(({ assetProfile }) => {
return ( return (
assetClass !== AssetClass.LIQUIDITY && assetProfile.assetClass !== AssetClass.LIQUIDITY &&
assetSubClass !== AssetSubClass.CASH assetProfile.assetSubClass !== AssetSubClass.CASH
); );
}) })
.map(({ valueInBaseCurrency }) => { .map(({ valueInBaseCurrency }) => {
@ -217,37 +219,41 @@ export class PortfolioController {
for (const [symbol, portfolioPosition] of Object.entries(holdings)) { for (const [symbol, portfolioPosition] of Object.entries(holdings)) {
holdings[symbol] = { holdings[symbol] = {
...portfolioPosition, ...portfolioPosition,
assetClass:
hasDetails || portfolioPosition.assetClass === AssetClass.LIQUIDITY
? portfolioPosition.assetClass
: undefined,
assetProfile: { assetProfile: {
...portfolioPosition.assetProfile, ...portfolioPosition.assetProfile,
assetClass:
hasDetails ||
portfolioPosition.assetProfile.assetClass === AssetClass.LIQUIDITY
? portfolioPosition.assetProfile.assetClass
: undefined,
assetClassLabel:
hasDetails ||
portfolioPosition.assetProfile.assetClass === AssetClass.LIQUIDITY
? portfolioPosition.assetProfile.assetClassLabel
: undefined,
assetSubClass:
hasDetails ||
portfolioPosition.assetProfile.assetSubClass === AssetSubClass.CASH
? portfolioPosition.assetProfile.assetSubClass
: undefined,
assetSubClassLabel:
hasDetails ||
portfolioPosition.assetProfile.assetSubClass === AssetSubClass.CASH
? portfolioPosition.assetProfile.assetSubClassLabel
: undefined,
...(hasDetails ...(hasDetails
? {} ? {}
: { : {
assetClass: undefined,
assetClassLabel: undefined,
assetSubClass: undefined,
assetSubClassLabel: undefined,
countries: [], countries: [],
currency: undefined, currency: undefined,
holdings: [], holdings: [],
sectors: [] sectors: []
}) })
}, },
assetSubClass:
hasDetails || portfolioPosition.assetSubClass === AssetSubClass.CASH
? portfolioPosition.assetSubClass
: undefined,
countries: hasDetails ? portfolioPosition.countries : [],
currency: hasDetails ? portfolioPosition.currency : undefined,
holdings: hasDetails ? portfolioPosition.holdings : [],
markets: hasDetails ? portfolioPosition.markets : undefined, markets: hasDetails ? portfolioPosition.markets : undefined,
marketsAdvanced: hasDetails marketsAdvanced: hasDetails
? portfolioPosition.marketsAdvanced ? portfolioPosition.marketsAdvanced
: undefined, : undefined
sectors: hasDetails ? portfolioPosition.sectors : []
}; };
} }
@ -336,7 +342,10 @@ export class PortfolioController {
const impersonationUserId = const impersonationUserId =
await this.impersonationService.validateImpersonationId(impersonationId); await this.impersonationService.validateImpersonationId(impersonationId);
const userCurrency = this.request.user.settings.settings.baseCurrency; const userId = impersonationUserId || this.request.user.id;
const { settings } = await this.userService.user({ id: userId });
const userCurrency = settings.settings.baseCurrency;
const { endDate, startDate } = getIntervalFromDateRange({ dateRange }); const { endDate, startDate } = getIntervalFromDateRange({ dateRange });
@ -345,7 +354,7 @@ export class PortfolioController {
filters, filters,
startDate, startDate,
userCurrency, userCurrency,
userId: impersonationUserId || this.request.user.id, userId,
types: ['DIVIDEND'] types: ['DIVIDEND']
}); });

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

@ -0,0 +1,274 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { CashDetails } from '@ghostfolio/api/app/account/interfaces/cash-details.interface';
import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service';
import { userDummyData } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { UserService } from '@ghostfolio/api/app/user/user.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 { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { parseDate } from '@ghostfolio/common/helper';
import { Account, DataSource } from '@prisma/client';
import { Big } from 'big.js';
import { randomUUID } from 'node:crypto';
import { PortfolioService } from './portfolio.service';
describe('PortfolioService', () => {
let accountService: AccountService;
let activitiesService: ActivitiesService;
let configurationService: ConfigurationService;
let dataProviderService: DataProviderService;
let exchangeRateDataService: ExchangeRateDataService;
let impersonationService: ImpersonationService;
let portfolioCalculatorFactory: PortfolioCalculatorFactory;
let portfolioService: PortfolioService;
let symbolProfileService: SymbolProfileService;
let userService: UserService;
beforeEach(() => {
configurationService = new ConfigurationService();
dataProviderService = new DataProviderService(
configurationService,
null,
null,
null,
null,
null
);
exchangeRateDataService = new ExchangeRateDataService(
null,
null,
null,
null
);
accountService = new AccountService(
null,
null,
exchangeRateDataService,
null
);
activitiesService = new ActivitiesService(
null,
accountService,
null,
null,
dataProviderService,
null,
exchangeRateDataService,
null,
null,
null
);
impersonationService = new ImpersonationService(null, null);
portfolioCalculatorFactory = new PortfolioCalculatorFactory(
configurationService,
null,
exchangeRateDataService,
null,
null
);
symbolProfileService = new SymbolProfileService(null);
userService = new UserService(
null,
null,
null,
null,
null,
null,
null,
null
);
portfolioService = new PortfolioService(
null,
accountService,
activitiesService,
null,
portfolioCalculatorFactory,
dataProviderService,
exchangeRateDataService,
null,
impersonationService,
null,
null,
symbolProfileService,
userService
);
});
describe('getCashSymbolProfiles', () => {
it('should use the exchange-rate data source so the symbol-profile join in getDetails matches the calculator positions', () => {
jest
.spyOn(dataProviderService, 'getDataSourceForExchangeRates')
.mockReturnValue(DataSource.YAHOO);
const cashDetails: CashDetails = {
accounts: [
{
balance: 2000,
comment: null,
createdAt: parseDate('2024-01-01'),
currency: 'USD',
id: randomUUID(),
isExcluded: false,
name: 'USD',
platformId: null,
updatedAt: parseDate('2024-01-01'),
userId: userDummyData.id
}
],
balanceInBaseCurrency: 1820
};
const assetProfiles = (
portfolioService as unknown as {
getCashSymbolProfiles: (
aCashDetails: CashDetails
) => { dataSource: DataSource; symbol: string }[];
}
).getCashSymbolProfiles(cashDetails);
expect(assetProfiles).toHaveLength(1);
expect(assetProfiles[0].dataSource).toBe(DataSource.YAHOO);
expect(assetProfiles[0].symbol).toBe('USD');
});
});
describe('getDetails', () => {
it('should return cash holdings when the calculator emits cash positions with the exchange-rate data source', async () => {
const accountId = randomUUID();
const cashAccount: Account = {
balance: 2000,
comment: null,
createdAt: parseDate('2024-01-01'),
currency: 'USD',
id: accountId,
isExcluded: false,
name: 'USD',
platformId: null,
updatedAt: parseDate('2024-01-01'),
userId: userDummyData.id
};
jest.spyOn(accountService, 'getCashDetails').mockResolvedValue({
accounts: [cashAccount],
balanceInBaseCurrency: 1820
});
jest
.spyOn(activitiesService, 'getActivitiesForPortfolioCalculator')
.mockResolvedValue({ activities: [], count: 0 });
jest
.spyOn(dataProviderService, 'getDataSourceForExchangeRates')
.mockReturnValue(DataSource.YAHOO);
jest
.spyOn(impersonationService, 'validateImpersonationId')
.mockResolvedValue(null);
jest
.spyOn(symbolProfileService, 'getSymbolProfiles')
.mockResolvedValue([]);
jest.spyOn(userService, 'user').mockResolvedValue({
accessesGet: [],
accounts: [],
activityCount: 0,
dataProviderGhostfolioDailyRequests: 0,
id: userDummyData.id,
settings: {
settings: {
baseCurrency: 'CHF'
}
}
} as unknown as Awaited<ReturnType<typeof userService.user>>);
const usdPosition = {
activitiesCount: 1,
averagePrice: new Big(1),
currency: 'USD',
dataSource: DataSource.YAHOO,
dateOfFirstActivity: '2024-01-01',
dividend: new Big(0),
dividendInBaseCurrency: new Big(0),
fee: new Big(0),
feeInBaseCurrency: new Big(0),
grossPerformance: new Big(0),
grossPerformancePercentage: new Big(0),
grossPerformancePercentageWithCurrencyEffect: new Big(0),
grossPerformanceWithCurrencyEffect: new Big(0),
investment: new Big(1820),
investmentWithCurrencyEffect: new Big(1820),
marketPrice: 1,
marketPriceInBaseCurrency: 0.91,
netPerformance: new Big(0),
netPerformancePercentage: new Big(0),
netPerformancePercentageWithCurrencyEffectMap: {},
netPerformanceWithCurrencyEffectMap: {},
quantity: new Big(2000),
symbol: 'USD',
tags: [],
timeWeightedInvestment: new Big(0),
timeWeightedInvestmentWithCurrencyEffect: new Big(0),
valueInBaseCurrency: new Big(1820)
};
jest
.spyOn(portfolioCalculatorFactory, 'createCalculator')
.mockReturnValue({
getSnapshot: jest.fn().mockResolvedValue({
activitiesCount: 1,
createdAt: parseDate('2024-01-01'),
currentValueInBaseCurrency: new Big(1820),
errors: [],
hasErrors: false,
historicalData: [],
positions: [usdPosition],
totalFeesWithCurrencyEffect: new Big(0),
totalInterestWithCurrencyEffect: new Big(0),
totalInvestment: new Big(1820),
totalInvestmentWithCurrencyEffect: new Big(1820),
totalLiabilitiesWithCurrencyEffect: new Big(0)
})
} as unknown as ReturnType<
typeof portfolioCalculatorFactory.createCalculator
>);
jest
.spyOn(
portfolioService as unknown as {
getValueOfAccountsAndPlatforms: () => Promise<{
accounts: object;
platforms: object;
}>;
},
'getValueOfAccountsAndPlatforms'
)
.mockResolvedValue({ accounts: {}, platforms: {} });
const { holdings } = await portfolioService.getDetails({
filters: [],
impersonationId: userDummyData.id,
userId: userDummyData.id
});
expect(holdings['USD']).toBeDefined();
expect(holdings['USD'].assetProfile.dataSource).toBe(DataSource.YAHOO);
expect(holdings['USD'].assetProfile.symbol).toBe('USD');
});
});
});

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

@ -108,6 +108,8 @@ const europeMarkets = require('../../assets/countries/europe-markets.json');
@Injectable() @Injectable()
export class PortfolioService { export class PortfolioService {
private readonly logger = new Logger(PortfolioService.name);
public constructor( public constructor(
private readonly accountBalanceService: AccountBalanceService, private readonly accountBalanceService: AccountBalanceService,
private readonly accountService: AccountService, private readonly accountService: AccountService,
@ -164,7 +166,7 @@ export class PortfolioService {
}; };
} }
const [accounts, details] = await Promise.all([ const [accounts, details, user] = await Promise.all([
this.accountService.accounts({ this.accountService.accounts({
where, where,
include: { include: {
@ -178,10 +180,11 @@ export class PortfolioService {
withExcludedAccounts, withExcludedAccounts,
impersonationId: userId, impersonationId: userId,
userId: this.request.user.id userId: this.request.user.id
}) }),
this.userService.user({ id: userId })
]); ]);
const userCurrency = this.request.user.settings.settings.baseCurrency; const userCurrency = this.getUserCurrency(user);
return Promise.all( return Promise.all(
accounts.map(async (account) => { accounts.map(async (account) => {
@ -584,7 +587,6 @@ export class PortfolioService {
for (const { for (const {
activitiesCount, activitiesCount,
currency,
dataSource, dataSource,
dateOfFirstActivity, dateOfFirstActivity,
dividend, dividend,
@ -619,9 +621,8 @@ export class PortfolioService {
symbolProfileMap[getAssetProfileIdentifier({ dataSource, symbol })]; symbolProfileMap[getAssetProfileIdentifier({ dataSource, symbol })];
if (!assetProfile) { if (!assetProfile) {
Logger.warn( this.logger.warn(
`Asset profile not found for ${symbol} (${dataSource})`, `Asset profile not found for ${symbol} (${dataSource})`
'PortfolioService'
); );
continue; continue;
@ -638,16 +639,13 @@ export class PortfolioService {
holdings[symbol] = { holdings[symbol] = {
activitiesCount, activitiesCount,
currency,
markets, markets,
marketsAdvanced, marketsAdvanced,
marketPrice, marketPrice,
symbol,
tags, tags,
allocationInPercentage: filteredValueInBaseCurrency.eq(0) allocationInPercentage: filteredValueInBaseCurrency.eq(0)
? 0 ? 0
: valueInBaseCurrency.div(filteredValueInBaseCurrency).toNumber(), : valueInBaseCurrency.div(filteredValueInBaseCurrency).toNumber(),
assetClass: assetProfile.assetClass,
assetProfile: { assetProfile: {
assetClass: assetProfile.assetClass, assetClass: assetProfile.assetClass,
assetSubClass: assetProfile.assetSubClass, assetSubClass: assetProfile.assetSubClass,
@ -670,9 +668,6 @@ export class PortfolioService {
symbol: assetProfile.symbol, symbol: assetProfile.symbol,
url: assetProfile.url url: assetProfile.url
}, },
assetSubClass: assetProfile.assetSubClass,
countries: assetProfile.countries,
dataSource: assetProfile.dataSource,
dateOfFirstActivity: parseDate(dateOfFirstActivity), dateOfFirstActivity: parseDate(dateOfFirstActivity),
dividend: dividend?.toNumber() ?? 0, dividend: dividend?.toNumber() ?? 0,
grossPerformance: grossPerformance?.toNumber() ?? 0, grossPerformance: grossPerformance?.toNumber() ?? 0,
@ -681,19 +676,7 @@ export class PortfolioService {
grossPerformancePercentageWithCurrencyEffect?.toNumber() ?? 0, grossPerformancePercentageWithCurrencyEffect?.toNumber() ?? 0,
grossPerformanceWithCurrencyEffect: grossPerformanceWithCurrencyEffect:
grossPerformanceWithCurrencyEffect?.toNumber() ?? 0, grossPerformanceWithCurrencyEffect?.toNumber() ?? 0,
holdings: assetProfile.holdings.map(
({ allocationInPercentage, name }) => {
return {
allocationInPercentage,
name,
valueInBaseCurrency: valueInBaseCurrency
.mul(allocationInPercentage)
.toNumber()
};
}
),
investment: investment.toNumber(), investment: investment.toNumber(),
name: assetProfile.name,
netPerformance: netPerformance?.toNumber() ?? 0, netPerformance: netPerformance?.toNumber() ?? 0,
netPerformancePercent: netPerformancePercentage?.toNumber() ?? 0, netPerformancePercent: netPerformancePercentage?.toNumber() ?? 0,
netPerformancePercentWithCurrencyEffect: netPerformancePercentWithCurrencyEffect:
@ -703,8 +686,6 @@ export class PortfolioService {
netPerformanceWithCurrencyEffect: netPerformanceWithCurrencyEffect:
netPerformanceWithCurrencyEffectMap?.[dateRange]?.toNumber() ?? 0, netPerformanceWithCurrencyEffectMap?.[dateRange]?.toNumber() ?? 0,
quantity: quantity.toNumber(), quantity: quantity.toNumber(),
sectors: assetProfile.sectors,
url: assetProfile.url,
valueInBaseCurrency: valueInBaseCurrency.toNumber() valueInBaseCurrency: valueInBaseCurrency.toNumber()
}; };
} }
@ -1472,8 +1453,8 @@ export class PortfolioService {
for (const [, position] of Object.entries(holdings)) { for (const [, position] of Object.entries(holdings)) {
const value = position.valueInBaseCurrency; const value = position.valueInBaseCurrency;
if (position.assetClass !== AssetClass.LIQUIDITY) { if (position.assetProfile.assetClass !== AssetClass.LIQUIDITY) {
if (position.countries.length > 0) { if (position.assetProfile.countries.length > 0) {
markets.developedMarkets.valueInBaseCurrency += markets.developedMarkets.valueInBaseCurrency +=
position.markets.developedMarkets * value; position.markets.developedMarkets * value;
markets.emergingMarkets.valueInBaseCurrency += markets.emergingMarkets.valueInBaseCurrency +=
@ -1719,11 +1700,8 @@ export class PortfolioService {
currency: string; currency: string;
}): PortfolioPosition { }): PortfolioPosition {
return { return {
currency,
activitiesCount: 0, activitiesCount: 0,
allocationInPercentage: 0, allocationInPercentage: 0,
assetClass: AssetClass.LIQUIDITY,
assetSubClass: AssetSubClass.CASH,
assetProfile: { assetProfile: {
currency, currency,
assetClass: AssetClass.LIQUIDITY, assetClass: AssetClass.LIQUIDITY,
@ -1735,25 +1713,19 @@ export class PortfolioService {
sectors: [], sectors: [],
symbol: currency symbol: currency
}, },
countries: [],
dataSource: undefined,
dateOfFirstActivity: undefined, dateOfFirstActivity: undefined,
dividend: 0, dividend: 0,
grossPerformance: 0, grossPerformance: 0,
grossPerformancePercent: 0, grossPerformancePercent: 0,
grossPerformancePercentWithCurrencyEffect: 0, grossPerformancePercentWithCurrencyEffect: 0,
grossPerformanceWithCurrencyEffect: 0, grossPerformanceWithCurrencyEffect: 0,
holdings: [],
investment: balance, investment: balance,
marketPrice: 0, marketPrice: 0,
name: currency,
netPerformance: 0, netPerformance: 0,
netPerformancePercent: 0, netPerformancePercent: 0,
netPerformancePercentWithCurrencyEffect: 0, netPerformancePercentWithCurrencyEffect: 0,
netPerformanceWithCurrencyEffect: 0, netPerformanceWithCurrencyEffect: 0,
quantity: 0, quantity: 0,
sectors: [],
symbol: currency,
tags: [], tags: [],
valueInBaseCurrency: balance valueInBaseCurrency: balance
}; };

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

@ -10,6 +10,8 @@ import { createHash, randomUUID } from 'node:crypto';
@Injectable() @Injectable()
export class RedisCacheService { export class RedisCacheService {
private readonly logger = new Logger(RedisCacheService.name);
private client: Keyv; private client: Keyv;
public constructor( public constructor(
@ -27,7 +29,7 @@ export class RedisCacheService {
}; };
this.client.on('error', (error) => { this.client.on('error', (error) => {
Logger.error(error, 'RedisCacheService'); this.logger.error(error);
}); });
} }
@ -101,7 +103,7 @@ export class RedisCacheService {
return true; return true;
} catch (error) { } catch (error) {
Logger.error(error?.message, 'RedisCacheService'); this.logger.error(error?.message);
return false; return false;
} finally { } finally {

14
apps/api/src/app/subscription/subscription.controller.ts

@ -33,6 +33,8 @@ import { SubscriptionService } from './subscription.service';
@Controller('subscription') @Controller('subscription')
export class SubscriptionController { export class SubscriptionController {
private readonly logger = new Logger(SubscriptionController.name);
public constructor( public constructor(
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly propertyService: PropertyService, private readonly propertyService: PropertyService,
@ -80,9 +82,8 @@ export class SubscriptionController {
value: JSON.stringify(coupons) value: JSON.stringify(coupons)
}); });
Logger.log( this.logger.log(
`Subscription for user '${this.request.user.id}' has been created with a coupon for ${coupon.duration}`, `Subscription for user '${this.request.user.id}' has been created with a coupon for ${coupon.duration}`
'SubscriptionController'
); );
return { return {
@ -101,9 +102,8 @@ export class SubscriptionController {
); );
if (userId) { if (userId) {
Logger.log( this.logger.log(
`Subscription for user '${userId}' has been created via Stripe`, `Subscription for user '${userId}' has been created via Stripe`
'SubscriptionController'
); );
} }
@ -126,7 +126,7 @@ export class SubscriptionController {
user: this.request.user user: this.request.user
}); });
} catch (error) { } catch (error) {
Logger.error(error, 'SubscriptionController'); this.logger.error(error);
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.BAD_REQUEST), getReasonPhrase(StatusCodes.BAD_REQUEST),

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

@ -24,6 +24,8 @@ import Stripe from 'stripe';
@Injectable() @Injectable()
export class SubscriptionService { export class SubscriptionService {
private readonly logger = new Logger(SubscriptionService.name);
private stripe: Stripe; private stripe: Stripe;
public constructor( public constructor(
@ -166,9 +168,8 @@ export class SubscriptionService {
error instanceof Prisma.PrismaClientKnownRequestError && error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === 'P2002' error.code === 'P2002'
) { ) {
Logger.log( this.logger.log(
`Stripe Checkout Session '${session.id}' has already been redeemed`, `Stripe Checkout Session '${session.id}' has already been redeemed`
'SubscriptionService'
); );
} else { } else {
throw error; throw error;
@ -177,7 +178,7 @@ export class SubscriptionService {
return session.client_reference_id; return session.client_reference_id;
} catch (error) { } catch (error) {
Logger.error(error, 'SubscriptionService'); this.logger.error(error);
} }
} }

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

@ -15,6 +15,8 @@ import { format, subDays } from 'date-fns';
@Injectable() @Injectable()
export class SymbolService { export class SymbolService {
private readonly logger = new Logger(SymbolService.name);
public constructor( public constructor(
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
private readonly marketDataService: MarketDataService private readonly marketDataService: MarketDataService
@ -119,7 +121,7 @@ export class SymbolService {
results.items = items; results.items = items;
return results; return results;
} catch (error) { } catch (error) {
Logger.error(error, 'SymbolService'); this.logger.error(error);
throw error; throw error;
} }

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

@ -49,7 +49,7 @@ import { PerformanceCalculationType } from '@ghostfolio/common/types/performance
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter'; import { EventEmitter2 } from '@nestjs/event-emitter';
import { Prisma, Role, User } from '@prisma/client'; import { Prisma, Role, Settings, User } from '@prisma/client';
import { differenceInDays, subDays } from 'date-fns'; import { differenceInDays, subDays } from 'date-fns';
import { without } from 'lodash'; import { without } from 'lodash';
import { createHmac } from 'node:crypto'; import { createHmac } from 'node:crypto';
@ -109,7 +109,14 @@ export class UserService {
}): Promise<IUser> { }): Promise<IUser> {
const { id, permissions, settings, subscription } = user; const { id, permissions, settings, subscription } = user;
const userData = await Promise.all([ const [
access,
accounts,
activitiesCount,
firstActivity,
impersonationUserSettings,
tagsForUser
] = await Promise.all([
this.prismaService.access.findMany({ this.prismaService.access.findMany({
include: { include: {
user: true user: true
@ -134,16 +141,17 @@ export class UserService {
}, },
where: { userId: impersonationUserId || user.id } where: { userId: impersonationUserId || user.id }
}), }),
impersonationUserId
? this.prismaService.settings.findUnique({
where: { userId: impersonationUserId }
})
: Promise.resolve<Settings>(null),
this.tagService.getTagsForUser(impersonationUserId || user.id) this.tagService.getTagsForUser(impersonationUserId || user.id)
]); ]);
const access = userData[0]; const baseCurrency =
const accounts = userData[1]; (impersonationUserSettings?.settings as UserSettings)?.baseCurrency ??
const activitiesCount = userData[2]; (settings.settings as UserSettings)?.baseCurrency;
const firstActivity = userData[3];
let tags = userData[4].filter((tag) => {
return tag.id !== TAG_ID_EXCLUDE_FROM_ANALYSIS;
});
let systemMessage: SystemMessage; let systemMessage: SystemMessage;
@ -156,6 +164,10 @@ export class UserService {
systemMessage = systemMessageProperty; systemMessage = systemMessageProperty;
} }
let tags = tagsForUser.filter((tag) => {
return tag.id !== TAG_ID_EXCLUDE_FROM_ANALYSIS;
});
if ( if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
subscription.type === SubscriptionType.Basic subscription.type === SubscriptionType.Basic
@ -183,6 +195,7 @@ export class UserService {
dateOfFirstActivity: firstActivity?.date ?? new Date(), dateOfFirstActivity: firstActivity?.date ?? new Date(),
settings: { settings: {
...(settings.settings as UserSettings), ...(settings.settings as UserSettings),
baseCurrency,
locale: (settings.settings as UserSettings)?.locale ?? locale locale: (settings.settings as UserSettings)?.locale ?? locale
} }
}; };

12
apps/api/src/events/asset-profile-changed.listener.ts

@ -15,6 +15,8 @@ import { AssetProfileChangedEvent } from './asset-profile-changed.event';
@Injectable() @Injectable()
export class AssetProfileChangedListener { export class AssetProfileChangedListener {
private readonly logger = new Logger(AssetProfileChangedListener.name);
private static readonly DEBOUNCE_DELAY = ms('5 seconds'); private static readonly DEBOUNCE_DELAY = ms('5 seconds');
private debounceTimers = new Map<string, NodeJS.Timeout>(); private debounceTimers = new Map<string, NodeJS.Timeout>();
@ -67,10 +69,7 @@ export class AssetProfileChangedListener {
dataSource: DataSource; dataSource: DataSource;
symbol: string; symbol: string;
}) { }) {
Logger.log( this.logger.log(`Asset profile of ${symbol} (${dataSource}) has changed`);
`Asset profile of ${symbol} (${dataSource}) has changed`,
'AssetProfileChangedListener'
);
if ( if (
this.configurationService.get( this.configurationService.get(
@ -84,10 +83,7 @@ export class AssetProfileChangedListener {
const existingCurrencies = this.exchangeRateDataService.getCurrencies(); const existingCurrencies = this.exchangeRateDataService.getCurrencies();
if (!existingCurrencies.includes(currency)) { if (!existingCurrencies.includes(currency)) {
Logger.log( this.logger.log(`New currency ${currency} has been detected`);
`New currency ${currency} has been detected`,
'AssetProfileChangedListener'
);
await this.exchangeRateDataService.initialize(); await this.exchangeRateDataService.initialize();
} }

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

@ -8,6 +8,8 @@ import { PortfolioChangedEvent } from './portfolio-changed.event';
@Injectable() @Injectable()
export class PortfolioChangedListener { export class PortfolioChangedListener {
private readonly logger = new Logger(PortfolioChangedListener.name);
private static readonly DEBOUNCE_DELAY = ms('5 seconds'); private static readonly DEBOUNCE_DELAY = ms('5 seconds');
private debounceTimers = new Map<string, NodeJS.Timeout>(); private debounceTimers = new Map<string, NodeJS.Timeout>();
@ -35,10 +37,7 @@ export class PortfolioChangedListener {
} }
private async processPortfolioChanged({ userId }: { userId: string }) { private async processPortfolioChanged({ userId }: { userId: string }) {
Logger.log( this.logger.log(`Portfolio of user '${userId}' has changed`);
`Portfolio of user '${userId}' has changed`,
'PortfolioChangedListener'
);
await this.redisCacheService.removePortfolioSnapshotsByUserId({ userId }); await this.redisCacheService.removePortfolioSnapshotsByUserId({ userId });
} }

17
apps/api/src/helper/country.helper.ts

@ -0,0 +1,17 @@
import { countries } from 'countries-list';
export function getCountryCodeByName({
aliases = {},
name
}: {
aliases?: Record<string, string>;
name: string;
}): string {
for (const [code, country] of Object.entries(countries)) {
if (country.name === name || country.name === aliases[name]) {
return code;
}
}
return undefined;
}

28
apps/api/src/helper/sector.helper.ts

@ -0,0 +1,28 @@
import { SECTORS } from '@ghostfolio/common/config';
import { SectorName } from '@ghostfolio/common/types';
import { Logger } from '@nestjs/common';
export function getSectorName({
aliases = {},
name
}: {
aliases?: Record<string, SectorName>;
name: string;
}): SectorName {
if (aliases[name]) {
return aliases[name];
}
if ((SECTORS as readonly string[]).includes(name)) {
return name as SectorName;
}
if (name) {
const logger = new Logger('getSectorName');
logger.warn(`Could not map the sector "${name}" to the ontology`);
}
return 'Other';
}

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

@ -2,6 +2,8 @@ import { Injectable, Logger } from '@nestjs/common';
@Injectable() @Injectable()
export class PerformanceLoggingService { export class PerformanceLoggingService {
private readonly logger = new Logger(PerformanceLoggingService.name);
public logPerformance({ public logPerformance({
className, className,
methodName, methodName,
@ -13,7 +15,7 @@ export class PerformanceLoggingService {
}) { }) {
const endTime = performance.now(); const endTime = performance.now();
Logger.debug( this.logger.debug(
`Completed execution of ${methodName}() in ${((endTime - startTime) / 1000).toFixed(3)} seconds`, `Completed execution of ${methodName}() in ${((endTime - startTime) / 1000).toFixed(3)} seconds`,
className className
); );

22
apps/api/src/main.ts

@ -18,11 +18,17 @@ import type { NestExpressApplication } from '@nestjs/platform-express';
import cookieParser from 'cookie-parser'; import cookieParser from 'cookie-parser';
import { NextFunction, Request, Response } from 'express'; import { NextFunction, Request, Response } from 'express';
import helmet from 'helmet'; import helmet from 'helmet';
import { EnvHttpProxyAgent, setGlobalDispatcher } from 'undici';
import { AppModule } from './app/app.module'; import { AppModule } from './app/app.module';
import { environment } from './environments/environment'; import { environment } from './environments/environment';
const logger = new Logger('Bootstrap');
async function bootstrap() { async function bootstrap() {
// Respect HTTP_PROXY / HTTPS_PROXY / NO_PROXY for outbound HTTP requests
setGlobalDispatcher(new EnvHttpProxyAgent());
const configApp = await NestFactory.create(AppModule); const configApp = await NestFactory.create(AppModule);
const configService = configApp.get<ConfigService>(ConfigService); const configService = configApp.get<ConfigService>(ConfigService);
let customLogLevels: LogLevel[]; let customLogLevels: LogLevel[];
@ -110,20 +116,20 @@ async function bootstrap() {
address = `${host}:${addressObject.port}`; address = `${host}:${addressObject.port}`;
} }
Logger.log(`Listening at http://${address}`); logger.log(`Listening at http://${address}`);
Logger.log(''); logger.log('');
}); });
} }
function logLogo() { function logLogo() {
Logger.log(' ________ __ ____ ___'); logger.log(' ________ __ ____ ___');
Logger.log(' / ____/ /_ ____ _____/ /_/ __/___ / (_)___'); logger.log(' / ____/ /_ ____ _____/ /_/ __/___ / (_)___');
Logger.log(' / / __/ __ \\/ __ \\/ ___/ __/ /_/ __ \\/ / / __ \\'); logger.log(' / / __/ __ \\/ __ \\/ ___/ __/ /_/ __ \\/ / / __ \\');
Logger.log('/ /_/ / / / / /_/ (__ ) /_/ __/ /_/ / / / /_/ /'); logger.log('/ /_/ / / / / /_/ (__ ) /_/ __/ /_/ / / / /_/ /');
Logger.log( logger.log(
`\\____/_/ /_/\\____/____/\\__/_/ \\____/_/_/\\____/ ${environment.version}` `\\____/_/ /_/\\____/____/\\__/_/ \\____/_/_/\\____/ ${environment.version}`
); );
Logger.log(''); logger.log('');
} }
bootstrap(); bootstrap();

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

@ -92,6 +92,8 @@ const locales = {
@Injectable() @Injectable()
export class HtmlTemplateMiddleware implements NestMiddleware { export class HtmlTemplateMiddleware implements NestMiddleware {
private readonly logger = new Logger(HtmlTemplateMiddleware.name);
private indexHtmlMap: { [languageCode: string]: string } = {}; private indexHtmlMap: { [languageCode: string]: string } = {};
public constructor(private readonly i18nService: I18nService) { public constructor(private readonly i18nService: I18nService) {
@ -107,11 +109,7 @@ export class HtmlTemplateMiddleware implements NestMiddleware {
{} {}
); );
} catch (error) { } catch (error) {
Logger.error( this.logger.error('Failed to initialize index HTML map', error);
'Failed to initialize index HTML map',
error,
'HTMLTemplateMiddleware'
);
} }
} }

6
apps/api/src/services/benchmark/benchmark.service.ts

@ -28,6 +28,8 @@ import { BenchmarkValue } from './interfaces/benchmark-value.interface';
@Injectable() @Injectable()
export class BenchmarkService { export class BenchmarkService {
private readonly logger = new Logger(BenchmarkService.name);
private readonly CACHE_KEY_BENCHMARKS = 'BENCHMARKS'; private readonly CACHE_KEY_BENCHMARKS = 'BENCHMARKS';
public constructor( public constructor(
@ -87,7 +89,7 @@ export class BenchmarkService {
const { benchmarks, expiration }: BenchmarkValue = const { benchmarks, expiration }: BenchmarkValue =
JSON.parse(cachedBenchmarkValue); JSON.parse(cachedBenchmarkValue);
Logger.debug('Fetched benchmarks from cache', 'BenchmarkService'); this.logger.debug('Fetched benchmarks from cache');
if (isAfter(new Date(), new Date(expiration))) { if (isAfter(new Date(), new Date(expiration))) {
this.calculateAndCacheBenchmarks({ this.calculateAndCacheBenchmarks({
@ -227,7 +229,7 @@ export class BenchmarkService {
private async calculateAndCacheBenchmarks({ private async calculateAndCacheBenchmarks({
enableSharing = false enableSharing = false
}): Promise<BenchmarkResponse['benchmarks']> { }): Promise<BenchmarkResponse['benchmarks']> {
Logger.debug('Calculate benchmarks', 'BenchmarkService'); this.logger.debug('Calculate benchmarks');
const benchmarkAssetProfiles = await this.getBenchmarkAssetProfiles({ const benchmarkAssetProfiles = await this.getBenchmarkAssetProfiles({
enableSharing enableSharing

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

@ -7,6 +7,7 @@ import {
GetQuotesParams, GetQuotesParams,
GetSearchParams GetSearchParams
} from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface'; } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
import { FetchService } from '@ghostfolio/api/services/fetch/fetch.service';
import { DEFAULT_CURRENCY } from '@ghostfolio/common/config'; import { DEFAULT_CURRENCY } from '@ghostfolio/common/config';
import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { import {
@ -28,11 +29,14 @@ import { format, fromUnixTime, getUnixTime } from 'date-fns';
@Injectable() @Injectable()
export class CoinGeckoService implements DataProviderInterface, OnModuleInit { export class CoinGeckoService implements DataProviderInterface, OnModuleInit {
private readonly logger = new Logger(CoinGeckoService.name);
private apiUrl: string; private apiUrl: string;
private headers: HeadersInit = {}; private headers: HeadersInit = {};
public constructor( public constructor(
private readonly configurationService: ConfigurationService private readonly configurationService: ConfigurationService,
private readonly fetchService: FetchService
) {} ) {}
public onModuleInit() { public onModuleInit() {
@ -67,12 +71,14 @@ export class CoinGeckoService implements DataProviderInterface, OnModuleInit {
}; };
try { try {
const { name } = await fetch(`${this.apiUrl}/coins/${symbol}`, { const { name } = await this.fetchService
headers: this.headers, .fetch(`${this.apiUrl}/coins/${symbol}`, {
signal: AbortSignal.timeout( headers: this.headers,
this.configurationService.get('REQUEST_TIMEOUT') signal: AbortSignal.timeout(
) this.configurationService.get('REQUEST_TIMEOUT')
}).then((res) => res.json()); )
})
.then((res) => res.json());
response.name = name; response.name = name;
} catch (error) { } catch (error) {
@ -84,7 +90,7 @@ export class CoinGeckoService implements DataProviderInterface, OnModuleInit {
).toFixed(3)} seconds`; ).toFixed(3)} seconds`;
} }
Logger.error(message, 'CoinGeckoService'); this.logger.error(message);
} }
return response; return response;
@ -118,13 +124,15 @@ export class CoinGeckoService implements DataProviderInterface, OnModuleInit {
vs_currency: DEFAULT_CURRENCY.toLowerCase() vs_currency: DEFAULT_CURRENCY.toLowerCase()
}); });
const { error, prices, status } = await fetch( const { error, prices, status } = await this.fetchService
`${this.apiUrl}/coins/${symbol}/market_chart/range?${queryParams.toString()}`, .fetch(
{ `${this.apiUrl}/coins/${symbol}/market_chart/range?${queryParams.toString()}`,
headers: this.headers, {
signal: AbortSignal.timeout(requestTimeout) headers: this.headers,
} signal: AbortSignal.timeout(requestTimeout)
).then((res) => res.json()); }
)
.then((res) => res.json());
if (error?.status) { if (error?.status) {
throw new Error(error.status.error_message); throw new Error(error.status.error_message);
@ -181,13 +189,12 @@ export class CoinGeckoService implements DataProviderInterface, OnModuleInit {
vs_currencies: DEFAULT_CURRENCY.toLowerCase() vs_currencies: DEFAULT_CURRENCY.toLowerCase()
}); });
const quotes = await fetch( const quotes = await this.fetchService
`${this.apiUrl}/simple/price?${queryParams.toString()}`, .fetch(`${this.apiUrl}/simple/price?${queryParams.toString()}`, {
{
headers: this.headers, headers: this.headers,
signal: AbortSignal.timeout(requestTimeout) signal: AbortSignal.timeout(requestTimeout)
} })
).then((res) => res.json()); .then((res) => res.json());
for (const symbol in quotes) { for (const symbol in quotes) {
response[symbol] = { response[symbol] = {
@ -209,7 +216,7 @@ export class CoinGeckoService implements DataProviderInterface, OnModuleInit {
).toFixed(3)} seconds`; ).toFixed(3)} seconds`;
} }
Logger.error(message, 'CoinGeckoService'); this.logger.error(message);
} }
return response; return response;
@ -230,13 +237,12 @@ export class CoinGeckoService implements DataProviderInterface, OnModuleInit {
query query
}); });
const { coins } = await fetch( const { coins } = await this.fetchService
`${this.apiUrl}/search?${queryParams.toString()}`, .fetch(`${this.apiUrl}/search?${queryParams.toString()}`, {
{
headers: this.headers, headers: this.headers,
signal: AbortSignal.timeout(requestTimeout) signal: AbortSignal.timeout(requestTimeout)
} })
).then((res) => res.json()); .then((res) => res.json());
items = coins.map(({ id: symbol, name }) => { items = coins.map(({ id: symbol, name }) => {
return { return {
@ -258,7 +264,7 @@ export class CoinGeckoService implements DataProviderInterface, OnModuleInit {
).toFixed(3)} seconds`; ).toFixed(3)} seconds`;
} }
Logger.error(message, 'CoinGeckoService'); this.logger.error(message);
} }
return { items }; return { items };

3
apps/api/src/services/data-provider/data-enhancer/data-enhancer.module.ts

@ -3,6 +3,7 @@ import { CryptocurrencyModule } from '@ghostfolio/api/services/cryptocurrency/cr
import { OpenFigiDataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/openfigi/openfigi.service'; import { OpenFigiDataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/openfigi/openfigi.service';
import { TrackinsightDataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/trackinsight/trackinsight.service'; import { TrackinsightDataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/trackinsight/trackinsight.service';
import { YahooFinanceDataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service'; import { YahooFinanceDataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service';
import { FetchModule } from '@ghostfolio/api/services/fetch/fetch.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
@ -16,7 +17,7 @@ import { DataEnhancerService } from './data-enhancer.service';
YahooFinanceDataEnhancerService, YahooFinanceDataEnhancerService,
'DataEnhancers' 'DataEnhancers'
], ],
imports: [ConfigurationModule, CryptocurrencyModule], imports: [ConfigurationModule, CryptocurrencyModule, FetchModule],
providers: [ providers: [
DataEnhancerService, DataEnhancerService,
OpenFigiDataEnhancerService, OpenFigiDataEnhancerService,

13
apps/api/src/services/data-provider/data-enhancer/openfigi/openfigi.service.ts

@ -1,5 +1,6 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface'; import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface';
import { FetchService } from '@ghostfolio/api/services/fetch/fetch.service';
import { parseSymbol } from '@ghostfolio/common/helper'; import { parseSymbol } from '@ghostfolio/common/helper';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
@ -10,7 +11,8 @@ export class OpenFigiDataEnhancerService implements DataEnhancerInterface {
private static baseUrl = 'https://api.openfigi.com'; private static baseUrl = 'https://api.openfigi.com';
public constructor( public constructor(
private readonly configurationService: ConfigurationService private readonly configurationService: ConfigurationService,
private readonly fetchService: FetchService
) {} ) {}
public async enhance({ public async enhance({
@ -42,9 +44,8 @@ export class OpenFigiDataEnhancerService implements DataEnhancerInterface {
this.configurationService.get('API_KEY_OPEN_FIGI'); this.configurationService.get('API_KEY_OPEN_FIGI');
} }
const mappings = (await fetch( const mappings = (await this.fetchService
`${OpenFigiDataEnhancerService.baseUrl}/v3/mapping`, .fetch(`${OpenFigiDataEnhancerService.baseUrl}/v3/mapping`, {
{
body: JSON.stringify([ body: JSON.stringify([
{ exchCode: exchange, idType: 'TICKER', idValue: ticker } { exchCode: exchange, idType: 'TICKER', idValue: ticker }
]), ]),
@ -54,8 +55,8 @@ export class OpenFigiDataEnhancerService implements DataEnhancerInterface {
}, },
method: 'POST', method: 'POST',
signal: AbortSignal.timeout(requestTimeout) signal: AbortSignal.timeout(requestTimeout)
} })
).then((res) => res.json())) as any[]; .then((res) => res.json())) as any[];
if (mappings?.length === 1 && mappings[0].data?.length === 1) { if (mappings?.length === 1 && mappings[0].data?.length === 1) {
const { compositeFIGI, figi, shareClassFIGI } = mappings[0].data[0]; const { compositeFIGI, figi, shareClassFIGI } = mappings[0].data[0];

88
apps/api/src/services/data-provider/data-enhancer/trackinsight/trackinsight.service.ts

@ -1,29 +1,38 @@
import { getCountryCodeByName } from '@ghostfolio/api/helper/country.helper';
import { getSectorName } from '@ghostfolio/api/helper/sector.helper';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface'; import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface';
import { FetchService } from '@ghostfolio/api/services/fetch/fetch.service';
import { Holding } from '@ghostfolio/common/interfaces'; import { Holding } from '@ghostfolio/common/interfaces';
import { Country } from '@ghostfolio/common/interfaces/country.interface'; import { Country } from '@ghostfolio/common/interfaces/country.interface';
import { Sector } from '@ghostfolio/common/interfaces/sector.interface'; import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
import { SectorName } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { SymbolProfile } from '@prisma/client'; import { SymbolProfile } from '@prisma/client';
import { countries } from 'countries-list';
@Injectable() @Injectable()
export class TrackinsightDataEnhancerService implements DataEnhancerInterface { export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
private static baseUrl = 'https://www.trackinsight.com/data-api'; private static baseUrl = 'https://www.trackinsight.com/data-api';
private static countriesMapping = { private static countriesMapping = {
'Russian Federation': 'Russia' 'Russian Federation': 'Russia',
USA: 'United States'
}; };
private static holdingsWeightTreshold = 0.85; private static holdingsWeightTreshold = 0.85;
private static sectorsMapping = { private static sectorsMapping: Record<string, SectorName> = {
'Consumer Discretionary': 'Consumer Cyclical', 'Consumer Discretionary': 'Consumer Cyclical',
'Consumer Defensive': 'Consumer Staples', 'Consumer Staples': 'Consumer Defensive',
Financials: 'Financial Services',
'Health Care': 'Healthcare', 'Health Care': 'Healthcare',
'Information Technology': 'Technology' 'Information Technology': 'Technology',
Materials: 'Basic Materials'
}; };
private readonly logger = new Logger(TrackinsightDataEnhancerService.name);
public constructor( public constructor(
private readonly configurationService: ConfigurationService private readonly configurationService: ConfigurationService,
private readonly fetchService: FetchService
) {} ) {}
public async enhance({ public async enhance({
@ -60,12 +69,13 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
return response; return response;
} }
const profile = await fetch( const profile = await this.fetchService
`${TrackinsightDataEnhancerService.baseUrl}/funds/${trackinsightSymbol}.json`, .fetch(
{ `${TrackinsightDataEnhancerService.baseUrl}/funds/${trackinsightSymbol}.json`,
signal: AbortSignal.timeout(requestTimeout) {
} signal: AbortSignal.timeout(requestTimeout)
) }
)
.then((res) => res.json()) .then((res) => res.json())
.catch(() => { .catch(() => {
return {}; return {};
@ -83,12 +93,13 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
response.isin = isin; response.isin = isin;
} }
const holdings = await fetch( const holdings = await this.fetchService
`${TrackinsightDataEnhancerService.baseUrl}/holdings/${trackinsightSymbol}.json`, .fetch(
{ `${TrackinsightDataEnhancerService.baseUrl}/holdings/${trackinsightSymbol}.json`,
signal: AbortSignal.timeout(requestTimeout) {
} signal: AbortSignal.timeout(requestTimeout)
) }
)
.then((res) => res.json()) .then((res) => res.json())
.catch(() => { .catch(() => {
return {}; return {};
@ -110,21 +121,11 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
for (const [name, value] of Object.entries<any>( for (const [name, value] of Object.entries<any>(
holdings?.countries ?? {} holdings?.countries ?? {}
)) { )) {
let countryCode: string;
for (const [code, country] of Object.entries(countries)) {
if (
country.name === name ||
country.name ===
TrackinsightDataEnhancerService.countriesMapping[name]
) {
countryCode = code;
break;
}
}
response.countries.push({ response.countries.push({
code: countryCode, code: getCountryCodeByName({
name,
aliases: TrackinsightDataEnhancerService.countriesMapping
}),
weight: value.weight weight: value.weight
}); });
} }
@ -158,7 +159,10 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
holdings?.sectors ?? {} holdings?.sectors ?? {}
)) { )) {
response.sectors.push({ response.sectors.push({
name: TrackinsightDataEnhancerService.sectorsMapping[name] ?? name, name: getSectorName({
name,
aliases: TrackinsightDataEnhancerService.sectorsMapping
}),
weight: value.weight weight: value.weight
}); });
} }
@ -182,12 +186,13 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
requestTimeout: number; requestTimeout: number;
symbol: string; symbol: string;
}) { }) {
return fetch( return this.fetchService
`https://www.trackinsight.com/search-api/search_v2/${symbol}/_/ticker/default/0/3`, .fetch(
{ `https://www.trackinsight.com/search-api/search_v2/${symbol}/_/ticker/default/0/3`,
signal: AbortSignal.timeout(requestTimeout) {
} signal: AbortSignal.timeout(requestTimeout)
) }
)
.then((res) => res.json()) .then((res) => res.json())
.then((jsonRes) => { .then((jsonRes) => {
if ( if (
@ -203,9 +208,8 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
return undefined; return undefined;
}) })
.catch(({ message }) => { .catch(({ message }) => {
Logger.error( this.logger.error(
`Failed to search Trackinsight symbol for ${symbol} (${message})`, `Failed to search Trackinsight symbol for ${symbol} (${message})`
'TrackinsightDataEnhancerService'
); );
return undefined; return undefined;

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

@ -1,12 +1,13 @@
import { getSectorName } from '@ghostfolio/api/helper/sector.helper';
import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service'; import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service';
import { AssetProfileDelistedError } from '@ghostfolio/api/services/data-provider/errors/asset-profile-delisted.error'; import { AssetProfileDelistedError } from '@ghostfolio/api/services/data-provider/errors/asset-profile-delisted.error';
import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface'; import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface';
import { import {
DEFAULT_CURRENCY, DEFAULT_CURRENCY,
REPLACE_NAME_PARTS, REPLACE_NAME_PARTS
UNKNOWN_KEY
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { isCurrency } from '@ghostfolio/common/helper'; import { isCurrency } from '@ghostfolio/common/helper';
import { SectorName } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { import {
@ -23,6 +24,22 @@ import type { Price } from 'yahoo-finance2/esm/src/modules/quoteSummary-iface';
@Injectable() @Injectable()
export class YahooFinanceDataEnhancerService implements DataEnhancerInterface { export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
private static sectorsMapping: Record<string, SectorName> = {
basic_materials: 'Basic Materials',
communication_services: 'Communication Services',
consumer_cyclical: 'Consumer Cyclical',
consumer_defensive: 'Consumer Defensive',
energy: 'Energy',
financial_services: 'Financial Services',
healthcare: 'Healthcare',
industrials: 'Industrials',
realestate: 'Real Estate',
technology: 'Technology',
utilities: 'Utilities'
};
private readonly logger = new Logger(YahooFinanceDataEnhancerService.name);
private readonly yahooFinance = new YahooFinance({ private readonly yahooFinance = new YahooFinance({
suppressNotices: ['yahooSurvey'] suppressNotices: ['yahooSurvey']
}); });
@ -123,7 +140,7 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
response.url = url; response.url = url;
} }
} catch (error) { } catch (error) {
Logger.error(error, 'YahooFinanceDataEnhancerService'); this.logger.error(error);
} }
return response; return response;
@ -222,7 +239,10 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
.flatMap((sectorWeighting) => { .flatMap((sectorWeighting) => {
return Object.entries(sectorWeighting).map(([sector, weight]) => { return Object.entries(sectorWeighting).map(([sector, weight]) => {
return { return {
name: this.parseSector(sector), name: getSectorName({
aliases: YahooFinanceDataEnhancerService.sectorsMapping,
name: sector
}),
weight: weight as number weight: weight as number
}; };
}); });
@ -266,7 +286,7 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
`No data found, ${aSymbol} (${this.getName()}) may be delisted` `No data found, ${aSymbol} (${this.getName()}) may be delisted`
); );
} else { } else {
Logger.error(error, 'YahooFinanceService'); this.logger.error(error);
} }
} }
@ -329,46 +349,4 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
return { assetClass, assetSubClass }; return { assetClass, assetSubClass };
} }
private parseSector(aString: string) {
let sector = UNKNOWN_KEY;
switch (aString) {
case 'basic_materials':
sector = 'Basic Materials';
break;
case 'communication_services':
sector = 'Communication Services';
break;
case 'consumer_cyclical':
sector = 'Consumer Cyclical';
break;
case 'consumer_defensive':
sector = 'Consumer Staples';
break;
case 'energy':
sector = 'Energy';
break;
case 'financial_services':
sector = 'Financial Services';
break;
case 'healthcare':
sector = 'Healthcare';
break;
case 'industrials':
sector = 'Industrials';
break;
case 'realestate':
sector = 'Real Estate';
break;
case 'technology':
sector = 'Technology';
break;
case 'utilities':
sector = 'Utilities';
break;
}
return sector;
}
} }

2
apps/api/src/services/data-provider/data-provider.module.ts

@ -10,6 +10,7 @@ import { GoogleSheetsService } from '@ghostfolio/api/services/data-provider/goog
import { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.service'; import { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.service';
import { RapidApiService } from '@ghostfolio/api/services/data-provider/rapid-api/rapid-api.service'; import { RapidApiService } from '@ghostfolio/api/services/data-provider/rapid-api/rapid-api.service';
import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service'; import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service';
import { FetchModule } from '@ghostfolio/api/services/fetch/fetch.module';
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module'; import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
@ -26,6 +27,7 @@ import { DataProviderService } from './data-provider.service';
ConfigurationModule, ConfigurationModule,
CryptocurrencyModule, CryptocurrencyModule,
DataEnhancerModule, DataEnhancerModule,
FetchModule,
MarketDataModule, MarketDataModule,
PrismaModule, PrismaModule,
PropertyModule, PropertyModule,

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

@ -41,6 +41,8 @@ import { AssetProfileInvalidError } from './errors/asset-profile-invalid.error';
@Injectable() @Injectable()
export class DataProviderService implements OnModuleInit { export class DataProviderService implements OnModuleInit {
private readonly logger = new Logger(DataProviderService.name);
private dataProviderMapping: { [dataProviderName: string]: string }; private dataProviderMapping: { [dataProviderName: string]: string };
public constructor( public constructor(
@ -129,7 +131,7 @@ export class DataProviderService implements OnModuleInit {
); );
} }
} catch (error) { } catch (error) {
Logger.error(error, 'DataProviderService'); this.logger.error(error);
throw error; throw error;
} }
@ -391,7 +393,7 @@ export class DataProviderService implements OnModuleInit {
return r; return r;
}, {}); }, {});
} catch (error) { } catch (error) {
Logger.error(error, 'DataProviderService'); this.logger.error(error);
} finally { } finally {
return response; return response;
} }
@ -503,7 +505,7 @@ export class DataProviderService implements OnModuleInit {
result[symbol] = data; result[symbol] = data;
} }
} catch (error) { } catch (error) {
Logger.error(error, 'DataProviderService'); this.logger.error(error);
throw error; throw error;
} }
@ -567,13 +569,12 @@ export class DataProviderService implements OnModuleInit {
const numberOfItemsInCache = Object.keys(response)?.length; const numberOfItemsInCache = Object.keys(response)?.length;
if (numberOfItemsInCache) { if (numberOfItemsInCache) {
Logger.debug( this.logger.debug(
`Fetched ${numberOfItemsInCache} quote${ `Fetched ${numberOfItemsInCache} quote${
numberOfItemsInCache > 1 ? 's' : '' numberOfItemsInCache > 1 ? 's' : ''
} from cache in ${((performance.now() - startTimeTotal) / 1000).toFixed( } from cache in ${((performance.now() - startTimeTotal) / 1000).toFixed(
3 3
)} seconds`, )} seconds`
'DataProviderService'
); );
} }
@ -684,14 +685,13 @@ export class DataProviderService implements OnModuleInit {
} }
} }
Logger.debug( this.logger.debug(
`Fetched ${symbolsChunk.length} quote${ `Fetched ${symbolsChunk.length} quote${
symbolsChunk.length > 1 ? 's' : '' symbolsChunk.length > 1 ? 's' : ''
} from ${dataSource} in ${( } from ${dataSource} in ${(
(performance.now() - startTimeDataSource) / (performance.now() - startTimeDataSource) /
1000 1000
).toFixed(3)} seconds`, ).toFixed(3)} seconds`
'DataProviderService'
); );
try { try {
@ -722,15 +722,18 @@ export class DataProviderService implements OnModuleInit {
await Promise.all(promises); await Promise.all(promises);
Logger.debug('--------------------------------------------------------'); this.logger.debug(
Logger.debug( '--------------------------------------------------------'
);
this.logger.debug(
`Fetched ${items.length} quote${items.length > 1 ? 's' : ''} in ${( `Fetched ${items.length} quote${items.length > 1 ? 's' : ''} in ${(
(performance.now() - startTimeTotal) / (performance.now() - startTimeTotal) /
1000 1000
).toFixed(3)} seconds`, ).toFixed(3)} seconds`
'DataProviderService' );
this.logger.debug(
'========================================================'
); );
Logger.debug('========================================================');
return response; return response;
} }

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

@ -7,6 +7,7 @@ import {
GetQuotesParams, GetQuotesParams,
GetSearchParams GetSearchParams
} from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface'; } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
import { FetchService } from '@ghostfolio/api/services/fetch/fetch.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { import {
DEFAULT_CURRENCY, DEFAULT_CURRENCY,
@ -36,11 +37,14 @@ import { isNumber } from 'lodash';
export class EodHistoricalDataService export class EodHistoricalDataService
implements DataProviderInterface, OnModuleInit implements DataProviderInterface, OnModuleInit
{ {
private readonly logger = new Logger(EodHistoricalDataService.name);
private apiKey: string; private apiKey: string;
private readonly URL = 'https://eodhistoricaldata.com/api'; private readonly URL = 'https://eodhistoricaldata.com/api';
public constructor( public constructor(
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly fetchService: FetchService,
private readonly symbolProfileService: SymbolProfileService private readonly symbolProfileService: SymbolProfileService
) {} ) {}
@ -111,12 +115,11 @@ export class EodHistoricalDataService
[date: string]: DataProviderHistoricalResponse; [date: string]: DataProviderHistoricalResponse;
} = {}; } = {};
const historicalResult = await fetch( const historicalResult = await this.fetchService
`${this.URL}/div/${symbol}?${queryParams.toString()}`, .fetch(`${this.URL}/div/${symbol}?${queryParams.toString()}`, {
{
signal: AbortSignal.timeout(requestTimeout) signal: AbortSignal.timeout(requestTimeout)
} })
).then((res) => res.json()); .then((res) => res.json());
for (const { date, value } of historicalResult) { for (const { date, value } of historicalResult) {
response[date] = { response[date] = {
@ -126,12 +129,11 @@ export class EodHistoricalDataService
return response; return response;
} catch (error) { } catch (error) {
Logger.error( this.logger.error(
`Could not get dividends for ${symbol} (${this.getName()}) from ${format( `Could not get dividends for ${symbol} (${this.getName()}) from ${format(
from, from,
DATE_FORMAT DATE_FORMAT
)} to ${format(to, DATE_FORMAT)}: [${error.name}] ${error.message}`, )} to ${format(to, DATE_FORMAT)}: [${error.name}] ${error.message}`
'EodHistoricalDataService'
); );
return {}; return {};
@ -158,12 +160,11 @@ export class EodHistoricalDataService
to: format(to, DATE_FORMAT) to: format(to, DATE_FORMAT)
}); });
const response = await fetch( const response = await this.fetchService
`${this.URL}/eod/${symbol}?${queryParams.toString()}`, .fetch(`${this.URL}/eod/${symbol}?${queryParams.toString()}`, {
{
signal: AbortSignal.timeout(requestTimeout) signal: AbortSignal.timeout(requestTimeout)
} })
).then((res) => res.json()); .then((res) => res.json());
return response.reduce( return response.reduce(
(result, { adjusted_close, date }) => { (result, { adjusted_close, date }) => {
@ -172,9 +173,8 @@ export class EodHistoricalDataService
marketPrice: adjusted_close marketPrice: adjusted_close
}; };
} else { } else {
Logger.error( this.logger.error(
`Could not get historical market data for ${symbol} (${this.getName()}) at ${date}`, `Could not get historical market data for ${symbol} (${this.getName()}) at ${date}`
'EodHistoricalDataService'
); );
} }
@ -223,12 +223,14 @@ export class EodHistoricalDataService
s: eodHistoricalDataSymbols.join(',') s: eodHistoricalDataSymbols.join(',')
}); });
const realTimeResponse = await fetch( const realTimeResponse = await this.fetchService
`${this.URL}/real-time/${eodHistoricalDataSymbols[0]}?${queryParams.toString()}`, .fetch(
{ `${this.URL}/real-time/${eodHistoricalDataSymbols[0]}?${queryParams.toString()}`,
signal: AbortSignal.timeout(requestTimeout) {
} signal: AbortSignal.timeout(requestTimeout)
).then((res) => res.json()); }
)
.then((res) => res.json());
const quotes: { const quotes: {
close: number; close: number;
@ -290,9 +292,8 @@ export class EodHistoricalDataService
dataSource: this.getName() dataSource: this.getName()
}; };
} else { } else {
Logger.error( this.logger.error(
`Could not get quote for ${this.convertFromEodSymbol(code)} (${this.getName()})`, `Could not get quote for ${this.convertFromEodSymbol(code)} (${this.getName()})`
'EodHistoricalDataService'
); );
} }
} }
@ -309,7 +310,7 @@ export class EodHistoricalDataService
).toFixed(3)} seconds`; ).toFixed(3)} seconds`;
} }
Logger.error(message, 'EodHistoricalDataService'); this.logger.error(message);
} }
return {}; return {};
@ -430,12 +431,11 @@ export class EodHistoricalDataService
api_token: this.apiKey api_token: this.apiKey
}); });
const response = await fetch( const response = await this.fetchService
`${this.URL}/search/${query}?${queryParams.toString()}`, .fetch(`${this.URL}/search/${query}?${queryParams.toString()}`, {
{
signal: AbortSignal.timeout(requestTimeout) signal: AbortSignal.timeout(requestTimeout)
} })
).then((res) => res.json()); .then((res) => res.json());
searchResult = response.map( searchResult = response.map(
({ Code, Currency, Exchange, ISIN: isin, Name: name, Type }) => { ({ Code, Currency, Exchange, ISIN: isin, Name: name, Type }) => {
@ -464,7 +464,7 @@ export class EodHistoricalDataService
).toFixed(3)} seconds`; ).toFixed(3)} seconds`;
} }
Logger.error(message, 'EodHistoricalDataService'); this.logger.error(message);
} }
return searchResult; return searchResult;

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

@ -1,3 +1,4 @@
import { getCountryCodeByName } from '@ghostfolio/api/helper/country.helper';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service'; import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service';
import { AssetProfileDelistedError } from '@ghostfolio/api/services/data-provider/errors/asset-profile-delisted.error'; import { AssetProfileDelistedError } from '@ghostfolio/api/services/data-provider/errors/asset-profile-delisted.error';
@ -9,6 +10,7 @@ import {
GetQuotesParams, GetQuotesParams,
GetSearchParams GetSearchParams
} from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface'; } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
import { FetchService } from '@ghostfolio/api/services/fetch/fetch.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { import {
DEFAULT_CURRENCY, DEFAULT_CURRENCY,
@ -32,7 +34,6 @@ import {
SymbolProfile SymbolProfile
} from '@prisma/client'; } from '@prisma/client';
import { isISIN } from 'class-validator'; import { isISIN } from 'class-validator';
import { countries } from 'countries-list';
import { import {
addDays, addDays,
addYears, addYears,
@ -54,11 +55,14 @@ export class FinancialModelingPrepService
'Taiwan (Province of China)': 'Taiwan' 'Taiwan (Province of China)': 'Taiwan'
}; };
private readonly logger = new Logger(FinancialModelingPrepService.name);
private apiKey: string; private apiKey: string;
public constructor( public constructor(
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly cryptocurrencyService: CryptocurrencyService, private readonly cryptocurrencyService: CryptocurrencyService,
private readonly fetchService: FetchService,
private readonly prismaService: PrismaService private readonly prismaService: PrismaService
) {} ) {}
@ -96,12 +100,14 @@ export class FinancialModelingPrepService
apikey: this.apiKey apikey: this.apiKey
}); });
const [quote] = await fetch( const [quote] = await this.fetchService
`${this.getUrl({ version: 'stable' })}/quote?${queryParams.toString()}`, .fetch(
{ `${this.getUrl({ version: 'stable' })}/quote?${queryParams.toString()}`,
signal: AbortSignal.timeout(requestTimeout) {
} signal: AbortSignal.timeout(requestTimeout)
).then((res) => res.json()); }
)
.then((res) => res.json());
response.assetClass = AssetClass.LIQUIDITY; response.assetClass = AssetClass.LIQUIDITY;
response.assetSubClass = AssetSubClass.CRYPTOCURRENCY; response.assetSubClass = AssetSubClass.CRYPTOCURRENCY;
@ -115,12 +121,14 @@ export class FinancialModelingPrepService
apikey: this.apiKey apikey: this.apiKey
}); });
const [assetProfile] = await fetch( const [assetProfile] = await this.fetchService
`${this.getUrl({ version: 'stable' })}/profile?${queryParams.toString()}`, .fetch(
{ `${this.getUrl({ version: 'stable' })}/profile?${queryParams.toString()}`,
signal: AbortSignal.timeout(requestTimeout) {
} signal: AbortSignal.timeout(requestTimeout)
).then((res) => res.json()); }
)
.then((res) => res.json());
if (!assetProfile) { if (!assetProfile) {
throw new AssetProfileDelistedError( throw new AssetProfileDelistedError(
@ -143,43 +151,37 @@ export class FinancialModelingPrepService
apikey: this.apiKey apikey: this.apiKey
}); });
const etfCountryWeightings = await fetch( const etfCountryWeightings = await this.fetchService
`${this.getUrl({ version: 'stable' })}/etf/country-weightings?${queryParams.toString()}`, .fetch(
{ `${this.getUrl({ version: 'stable' })}/etf/country-weightings?${queryParams.toString()}`,
signal: AbortSignal.timeout(requestTimeout) {
} signal: AbortSignal.timeout(requestTimeout)
).then((res) => res.json()); }
)
.then((res) => res.json());
response.countries = etfCountryWeightings response.countries = etfCountryWeightings
.filter(({ country: countryName }) => { .filter(({ country: countryName }) => {
return countryName.toLowerCase() !== 'other'; return countryName.toLowerCase() !== 'other';
}) })
.map(({ country: countryName, weightPercentage }) => { .map(({ country: countryName, weightPercentage }) => {
let countryCode: string;
for (const [code, country] of Object.entries(countries)) {
if (
country.name === countryName ||
country.name ===
FinancialModelingPrepService.countriesMapping[countryName]
) {
countryCode = code;
break;
}
}
return { return {
code: countryCode, code: getCountryCodeByName({
aliases: FinancialModelingPrepService.countriesMapping,
name: countryName
}),
weight: parseFloat(weightPercentage.slice(0, -1)) / 100 weight: parseFloat(weightPercentage.slice(0, -1)) / 100
}; };
}); });
const etfHoldings = await fetch( const etfHoldings = await this.fetchService
`${this.getUrl({ version: 'stable' })}/etf/holdings?${queryParams.toString()}`, .fetch(
{ `${this.getUrl({ version: 'stable' })}/etf/holdings?${queryParams.toString()}`,
signal: AbortSignal.timeout(requestTimeout) {
} signal: AbortSignal.timeout(requestTimeout)
).then((res) => res.json()); }
)
.then((res) => res.json());
const sortedTopHoldings = etfHoldings const sortedTopHoldings = etfHoldings
.sort((a, b) => { .sort((a, b) => {
@ -193,23 +195,27 @@ export class FinancialModelingPrepService
} }
); );
const [etfInformation] = await fetch( const [etfInformation] = await this.fetchService
`${this.getUrl({ version: 'stable' })}/etf/info?${queryParams.toString()}`, .fetch(
{ `${this.getUrl({ version: 'stable' })}/etf/info?${queryParams.toString()}`,
signal: AbortSignal.timeout(requestTimeout) {
} signal: AbortSignal.timeout(requestTimeout)
).then((res) => res.json()); }
)
.then((res) => res.json());
if (etfInformation?.website) { if (etfInformation?.website) {
response.url = etfInformation.website; response.url = etfInformation.website;
} }
const etfSectorWeightings = await fetch( const etfSectorWeightings = await this.fetchService
`${this.getUrl({ version: 'stable' })}/etf/sector-weightings?${queryParams.toString()}`, .fetch(
{ `${this.getUrl({ version: 'stable' })}/etf/sector-weightings?${queryParams.toString()}`,
signal: AbortSignal.timeout(requestTimeout) {
} signal: AbortSignal.timeout(requestTimeout)
).then((res) => res.json()); }
)
.then((res) => res.json());
response.sectors = etfSectorWeightings.map( response.sectors = etfSectorWeightings.map(
({ sector, weightPercentage }) => { ({ sector, weightPercentage }) => {
@ -251,7 +257,7 @@ export class FinancialModelingPrepService
).toFixed(3)} seconds`; ).toFixed(3)} seconds`;
} }
Logger.error(message, 'FinancialModelingPrepService'); this.logger.error(message);
} }
return response; return response;
@ -286,12 +292,14 @@ export class FinancialModelingPrepService
[date: string]: DataProviderHistoricalResponse; [date: string]: DataProviderHistoricalResponse;
} = {}; } = {};
const dividends = await fetch( const dividends = await this.fetchService
`${this.getUrl({ version: 'stable' })}/dividends?${queryParams.toString()}`, .fetch(
{ `${this.getUrl({ version: 'stable' })}/dividends?${queryParams.toString()}`,
signal: AbortSignal.timeout(requestTimeout) {
} signal: AbortSignal.timeout(requestTimeout)
).then((res) => res.json()); }
)
.then((res) => res.json());
dividends dividends
.filter(({ date }) => { .filter(({ date }) => {
@ -309,12 +317,11 @@ export class FinancialModelingPrepService
return response; return response;
} catch (error) { } catch (error) {
Logger.error( this.logger.error(
`Could not get dividends for ${symbol} (${this.getName()}) from ${format( `Could not get dividends for ${symbol} (${this.getName()}) from ${format(
from, from,
DATE_FORMAT DATE_FORMAT
)} to ${format(to, DATE_FORMAT)}: [${error.name}] ${error.message}`, )} to ${format(to, DATE_FORMAT)}: [${error.name}] ${error.message}`
'FinancialModelingPrepService'
); );
return {}; return {};
@ -354,12 +361,14 @@ export class FinancialModelingPrepService
to: format(currentTo, DATE_FORMAT) to: format(currentTo, DATE_FORMAT)
}); });
const historical = await fetch( const historical = await this.fetchService
`${this.getUrl({ version: 'stable' })}/historical-price-eod/full?${queryParams.toString()}`, .fetch(
{ `${this.getUrl({ version: 'stable' })}/historical-price-eod/full?${queryParams.toString()}`,
signal: AbortSignal.timeout(requestTimeout) {
} signal: AbortSignal.timeout(requestTimeout)
).then((res) => res.json()); }
)
.then((res) => res.json());
for (const { close, date } of historical) { for (const { close, date } of historical) {
if ( if (
@ -422,14 +431,17 @@ export class FinancialModelingPrepService
symbolTarget: { in: symbols } symbolTarget: { in: symbols }
} }
}), }),
fetch( this.fetchService
`${this.getUrl({ version: 'stable' })}/batch-quote-short?${queryParams.toString()}`, .fetch(
{ `${this.getUrl({ version: 'stable' })}/batch-quote-short?${queryParams.toString()}`,
signal: AbortSignal.timeout(requestTimeout) {
} signal: AbortSignal.timeout(requestTimeout)
).then( }
(res) => res.json() as unknown as { price: number; symbol: string }[] )
) .then(
(res) =>
res.json() as unknown as { price: number; symbol: string }[]
)
]); ]);
for (const { currency, symbolTarget } of assetProfileResolutions) { for (const { currency, symbolTarget } of assetProfileResolutions) {
@ -497,7 +509,7 @@ export class FinancialModelingPrepService
).toFixed(3)} seconds`; ).toFixed(3)} seconds`;
} }
Logger.error(message, 'FinancialModelingPrepService'); this.logger.error(message);
} }
return response; return response;
@ -525,12 +537,14 @@ export class FinancialModelingPrepService
isin: query.toUpperCase() isin: query.toUpperCase()
}); });
const result = await fetch( const result = await this.fetchService
`${this.getUrl({ version: 'stable' })}/search-isin?${queryParams.toString()}`, .fetch(
{ `${this.getUrl({ version: 'stable' })}/search-isin?${queryParams.toString()}`,
signal: AbortSignal.timeout(requestTimeout) {
} signal: AbortSignal.timeout(requestTimeout)
).then((res) => res.json()); }
)
.then((res) => res.json());
await Promise.all( await Promise.all(
result.map(({ symbol }) => { result.map(({ symbol }) => {
@ -558,18 +572,22 @@ export class FinancialModelingPrepService
}); });
const [nameResults, symbolResults] = await Promise.all([ const [nameResults, symbolResults] = await Promise.all([
fetch( this.fetchService
`${this.getUrl({ version: 'stable' })}/search-name?${queryParams.toString()}`, .fetch(
{ `${this.getUrl({ version: 'stable' })}/search-name?${queryParams.toString()}`,
signal: AbortSignal.timeout(requestTimeout) {
} signal: AbortSignal.timeout(requestTimeout)
).then((res) => res.json()), }
fetch( )
`${this.getUrl({ version: 'stable' })}/search-symbol?${queryParams.toString()}`, .then((res) => res.json()),
{ this.fetchService
signal: AbortSignal.timeout(requestTimeout) .fetch(
} `${this.getUrl({ version: 'stable' })}/search-symbol?${queryParams.toString()}`,
).then((res) => res.json()) {
signal: AbortSignal.timeout(requestTimeout)
}
)
.then((res) => res.json())
]); ]);
const result = uniqBy( const result = uniqBy(
@ -611,7 +629,7 @@ export class FinancialModelingPrepService
).toFixed(3)} seconds`; ).toFixed(3)} seconds`;
} }
Logger.error(message, 'FinancialModelingPrepService'); this.logger.error(message);
} }
return { items }; return { items };

24
apps/api/src/services/data-provider/ghostfolio/ghostfolio.service.ts

@ -8,6 +8,7 @@ import {
GetQuotesParams, GetQuotesParams,
GetSearchParams GetSearchParams
} from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface'; } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
import { FetchService } from '@ghostfolio/api/services/fetch/fetch.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { import {
HEADER_KEY_TOKEN, HEADER_KEY_TOKEN,
@ -32,12 +33,15 @@ import { StatusCodes } from 'http-status-codes';
@Injectable() @Injectable()
export class GhostfolioService implements DataProviderInterface { export class GhostfolioService implements DataProviderInterface {
private readonly logger = new Logger(GhostfolioService.name);
private readonly URL = environment.production private readonly URL = environment.production
? 'https://ghostfol.io/api' ? 'https://ghostfol.io/api'
: `${this.configurationService.get('ROOT_URL')}/api`; : `${this.configurationService.get('ROOT_URL')}/api`;
public constructor( public constructor(
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly fetchService: FetchService,
private readonly propertyService: PropertyService private readonly propertyService: PropertyService
) {} ) {}
@ -52,7 +56,7 @@ export class GhostfolioService implements DataProviderInterface {
let assetProfile: DataProviderGhostfolioAssetProfileResponse; let assetProfile: DataProviderGhostfolioAssetProfileResponse;
try { try {
const response = await fetch( const response = await this.fetchService.fetch(
`${this.URL}/v1/data-providers/ghostfolio/asset-profile/${symbol}`, `${this.URL}/v1/data-providers/ghostfolio/asset-profile/${symbol}`,
{ {
headers: await this.getRequestHeaders(), headers: await this.getRequestHeaders(),
@ -87,7 +91,7 @@ export class GhostfolioService implements DataProviderInterface {
'RequestError: The API key is invalid. Please update it in the Settings section of the Admin Control panel.'; 'RequestError: The API key is invalid. Please update it in the Settings section of the Admin Control panel.';
} }
Logger.error(message, 'GhostfolioService'); this.logger.error(message);
} }
return assetProfile; return assetProfile;
@ -122,7 +126,7 @@ export class GhostfolioService implements DataProviderInterface {
to: format(to, DATE_FORMAT) to: format(to, DATE_FORMAT)
}); });
const response = await fetch( const response = await this.fetchService.fetch(
`${this.URL}/v2/data-providers/ghostfolio/dividends/${symbol}?${queryParams.toString()}`, `${this.URL}/v2/data-providers/ghostfolio/dividends/${symbol}?${queryParams.toString()}`,
{ {
headers: await this.getRequestHeaders(), headers: await this.getRequestHeaders(),
@ -152,7 +156,7 @@ export class GhostfolioService implements DataProviderInterface {
'RequestError: The API key is invalid. Please update it in the Settings section of the Admin Control panel.'; 'RequestError: The API key is invalid. Please update it in the Settings section of the Admin Control panel.';
} }
Logger.error(message, 'GhostfolioService'); this.logger.error(message);
} }
return dividends; return dividends;
@ -174,7 +178,7 @@ export class GhostfolioService implements DataProviderInterface {
to: format(to, DATE_FORMAT) to: format(to, DATE_FORMAT)
}); });
const response = await fetch( const response = await this.fetchService.fetch(
`${this.URL}/v2/data-providers/ghostfolio/historical/${symbol}?${queryParams.toString()}`, `${this.URL}/v2/data-providers/ghostfolio/historical/${symbol}?${queryParams.toString()}`,
{ {
headers: await this.getRequestHeaders(), headers: await this.getRequestHeaders(),
@ -209,7 +213,7 @@ export class GhostfolioService implements DataProviderInterface {
'RequestError: The API key is invalid. Please update it in the Settings section of the Admin Control panel.'; 'RequestError: The API key is invalid. Please update it in the Settings section of the Admin Control panel.';
} }
Logger.error(error.message, 'GhostfolioService'); this.logger.error(error.message);
throw new Error( throw new Error(
`Could not get historical market data for ${symbol} (${this.getName()}) from ${format( `Could not get historical market data for ${symbol} (${this.getName()}) from ${format(
@ -245,7 +249,7 @@ export class GhostfolioService implements DataProviderInterface {
symbols: symbols.join(',') symbols: symbols.join(',')
}); });
const response = await fetch( const response = await this.fetchService.fetch(
`${this.URL}/v2/data-providers/ghostfolio/quotes?${queryParams.toString()}`, `${this.URL}/v2/data-providers/ghostfolio/quotes?${queryParams.toString()}`,
{ {
headers: await this.getRequestHeaders(), headers: await this.getRequestHeaders(),
@ -281,7 +285,7 @@ export class GhostfolioService implements DataProviderInterface {
'RequestError: The API key is invalid. Please update it in the Settings section of the Admin Control panel.'; 'RequestError: The API key is invalid. Please update it in the Settings section of the Admin Control panel.';
} }
Logger.error(message, 'GhostfolioService'); this.logger.error(message);
} }
return quotes; return quotes;
@ -302,7 +306,7 @@ export class GhostfolioService implements DataProviderInterface {
query query
}); });
const response = await fetch( const response = await this.fetchService.fetch(
`${this.URL}/v2/data-providers/ghostfolio/lookup?${queryParams.toString()}`, `${this.URL}/v2/data-providers/ghostfolio/lookup?${queryParams.toString()}`,
{ {
headers: await this.getRequestHeaders(), headers: await this.getRequestHeaders(),
@ -336,7 +340,7 @@ export class GhostfolioService implements DataProviderInterface {
'RequestError: The API key is invalid. Please update it in the Settings section of the Admin Control panel.'; 'RequestError: The API key is invalid. Please update it in the Settings section of the Admin Control panel.';
} }
Logger.error(message, 'GhostfolioService'); this.logger.error(message);
} }
return searchResult; return searchResult;

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

@ -24,6 +24,8 @@ import { GoogleSpreadsheet } from 'google-spreadsheet';
@Injectable() @Injectable()
export class GoogleSheetsService implements DataProviderInterface { export class GoogleSheetsService implements DataProviderInterface {
private readonly logger = new Logger(GoogleSheetsService.name);
public constructor( public constructor(
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
@ -144,7 +146,7 @@ export class GoogleSheetsService implements DataProviderInterface {
return response; return response;
} catch (error) { } catch (error) {
Logger.error(error, 'GoogleSheetsService'); this.logger.error(error);
} }
return {}; return {};

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

@ -8,6 +8,7 @@ import {
GetQuotesParams, GetQuotesParams,
GetSearchParams GetSearchParams
} from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface'; } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
import { FetchService } from '@ghostfolio/api/services/fetch/fetch.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { import {
@ -30,8 +31,11 @@ import { addDays, format, isBefore } from 'date-fns';
@Injectable() @Injectable()
export class ManualService implements DataProviderInterface { export class ManualService implements DataProviderInterface {
private readonly logger = new Logger(ManualService.name);
public constructor( public constructor(
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly fetchService: FetchService,
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
private readonly symbolProfileService: SymbolProfileService private readonly symbolProfileService: SymbolProfileService
) {} ) {}
@ -179,9 +183,8 @@ export class ManualService implements DataProviderInterface {
}); });
return { marketPrice, symbol }; return { marketPrice, symbol };
} catch (error) { } catch (error) {
Logger.error( this.logger.error(
`Could not get quote for ${symbol} (${this.getName()}): [${error.name}] ${error.message}`, `Could not get quote for ${symbol} (${this.getName()}): [${error.name}] ${error.message}`
'ManualService'
); );
return { symbol, marketPrice: undefined }; return { symbol, marketPrice: undefined };
} }
@ -214,7 +217,7 @@ export class ManualService implements DataProviderInterface {
return response; return response;
} catch (error) { } catch (error) {
Logger.error(error, 'ManualService'); this.logger.error(error);
} }
return {}; return {};
@ -292,7 +295,7 @@ export class ManualService implements DataProviderInterface {
}): Promise<number> { }): Promise<number> {
let locale = scraperConfiguration.locale; let locale = scraperConfiguration.locale;
const response = await fetch(scraperConfiguration.url, { const response = await this.fetchService.fetch(scraperConfiguration.url, {
headers: scraperConfiguration.headers as HeadersInit, headers: scraperConfiguration.headers as HeadersInit,
signal: AbortSignal.timeout( signal: AbortSignal.timeout(
this.configurationService.get('REQUEST_TIMEOUT') this.configurationService.get('REQUEST_TIMEOUT')

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

@ -7,6 +7,7 @@ import {
GetQuotesParams, GetQuotesParams,
GetSearchParams GetSearchParams
} from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface'; } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
import { FetchService } from '@ghostfolio/api/services/fetch/fetch.service';
import { import {
ghostfolioFearAndGreedIndexSymbol, ghostfolioFearAndGreedIndexSymbol,
ghostfolioFearAndGreedIndexSymbolStocks ghostfolioFearAndGreedIndexSymbolStocks
@ -25,8 +26,11 @@ import { format } from 'date-fns';
@Injectable() @Injectable()
export class RapidApiService implements DataProviderInterface { export class RapidApiService implements DataProviderInterface {
private readonly logger = new Logger(RapidApiService.name);
public constructor( public constructor(
private readonly configurationService: ConfigurationService private readonly configurationService: ConfigurationService,
private readonly fetchService: FetchService
) {} ) {}
public canHandle() { public canHandle() {
@ -120,7 +124,7 @@ export class RapidApiService implements DataProviderInterface {
}; };
} }
} catch (error) { } catch (error) {
Logger.error(error, 'RapidApiService'); this.logger.error(error);
} }
return {}; return {};
@ -142,9 +146,8 @@ export class RapidApiService implements DataProviderInterface {
oneYearAgo: { value: number; valueText: string }; oneYearAgo: { value: number; valueText: string };
}> { }> {
try { try {
const { fgi } = await fetch( const { fgi } = await this.fetchService
`https://fear-and-greed-index.p.rapidapi.com/v1/fgi`, .fetch(`https://fear-and-greed-index.p.rapidapi.com/v1/fgi`, {
{
headers: { headers: {
useQueryString: 'true', useQueryString: 'true',
'x-rapidapi-host': 'fear-and-greed-index.p.rapidapi.com', 'x-rapidapi-host': 'fear-and-greed-index.p.rapidapi.com',
@ -153,8 +156,8 @@ export class RapidApiService implements DataProviderInterface {
signal: AbortSignal.timeout( signal: AbortSignal.timeout(
this.configurationService.get('REQUEST_TIMEOUT') this.configurationService.get('REQUEST_TIMEOUT')
) )
} })
).then((res) => res.json()); .then((res) => res.json());
return fgi; return fgi;
} catch (error) { } catch (error) {
@ -166,7 +169,7 @@ export class RapidApiService implements DataProviderInterface {
).toFixed(3)} seconds`; ).toFixed(3)} seconds`;
} }
Logger.error(message, 'RapidApiService'); this.logger.error(message);
return undefined; return undefined;
} }

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

@ -41,6 +41,8 @@ import { SearchQuoteNonYahoo } from 'yahoo-finance2/esm/src/modules/search';
@Injectable() @Injectable()
export class YahooFinanceService implements DataProviderInterface { export class YahooFinanceService implements DataProviderInterface {
private readonly logger = new Logger(YahooFinanceService.name);
private readonly yahooFinance = new YahooFinance({ private readonly yahooFinance = new YahooFinance({
suppressNotices: ['yahooSurvey'] suppressNotices: ['yahooSurvey']
}); });
@ -105,12 +107,11 @@ export class YahooFinanceService implements DataProviderInterface {
return response; return response;
} catch (error) { } catch (error) {
Logger.error( this.logger.error(
`Could not get dividends for ${symbol} (${this.getName()}) from ${format( `Could not get dividends for ${symbol} (${this.getName()}) from ${format(
from, from,
DATE_FORMAT DATE_FORMAT
)} to ${format(to, DATE_FORMAT)}: [${error.name}] ${error.message}`, )} to ${format(to, DATE_FORMAT)}: [${error.name}] ${error.message}`
'YahooFinanceService'
); );
return {}; return {};
@ -198,12 +199,9 @@ export class YahooFinanceService implements DataProviderInterface {
try { try {
quotes = await this.yahooFinance.quote(yahooFinanceSymbols); quotes = await this.yahooFinance.quote(yahooFinanceSymbols);
} catch (error) { } catch (error) {
Logger.error(error, 'YahooFinanceService'); this.logger.error(error);
Logger.warn( this.logger.warn('Fallback to yahooFinance.quoteSummary()');
'Fallback to yahooFinance.quoteSummary()',
'YahooFinanceService'
);
quotes = await this.getQuotesWithQuoteSummary(yahooFinanceSymbols); quotes = await this.getQuotesWithQuoteSummary(yahooFinanceSymbols);
} }
@ -229,7 +227,7 @@ export class YahooFinanceService implements DataProviderInterface {
return response; return response;
} catch (error) { } catch (error) {
Logger.error(error, 'YahooFinanceService'); this.logger.error(error);
return {}; return {};
} }
@ -334,7 +332,7 @@ export class YahooFinanceService implements DataProviderInterface {
}); });
} }
} catch (error) { } catch (error) {
Logger.error(error, 'YahooFinanceService'); this.logger.error(error);
} }
return { items }; return { items };
@ -365,10 +363,7 @@ export class YahooFinanceService implements DataProviderInterface {
.filter( .filter(
(result): result is PromiseFulfilledResult<QuoteSummaryResult> => { (result): result is PromiseFulfilledResult<QuoteSummaryResult> => {
if (result.status === 'rejected') { if (result.status === 'rejected') {
Logger.error( this.logger.error(`Could not get quote summary: ${result.reason}`);
`Could not get quote summary: ${result.reason}`,
'YahooFinanceService'
);
return false; return false;
} }

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

@ -30,6 +30,8 @@ import { ExchangeRatesByCurrency } from './interfaces/exchange-rate-data.interfa
@Injectable() @Injectable()
export class ExchangeRateDataService { export class ExchangeRateDataService {
private readonly logger = new Logger(ExchangeRateDataService.name);
private currencies: string[] = []; private currencies: string[] = [];
private currencyPairs: DataGatheringItem[] = []; private currencyPairs: DataGatheringItem[] = [];
private derivedCurrencyFactors: { [currencyPair: string]: number } = {}; private derivedCurrencyFactors: { [currencyPair: string]: number } = {};
@ -110,9 +112,8 @@ export class ExchangeRateDataService {
previousExchangeRate; previousExchangeRate;
if (currency === DEFAULT_CURRENCY && isBefore(date, new Date())) { if (currency === DEFAULT_CURRENCY && isBefore(date, new Date())) {
Logger.error( this.logger.error(
`No exchange rate has been found for ${currency}${targetCurrency} at ${dateString}`, `No exchange rate has been found for ${currency}${targetCurrency} at ${dateString}`
'ExchangeRateDataService'
); );
} }
} else { } else {
@ -253,9 +254,8 @@ export class ExchangeRateDataService {
} }
// Fallback with error, if currencies are not available // Fallback with error, if currencies are not available
Logger.error( this.logger.error(
`No exchange rate has been found for ${aFromCurrency}${aToCurrency}`, `No exchange rate has been found for ${aFromCurrency}${aToCurrency}`
'ExchangeRateDataService'
); );
return aValue; return aValue;
@ -341,12 +341,11 @@ export class ExchangeRateDataService {
return factor * aValue; return factor * aValue;
} }
Logger.error( this.logger.error(
`No exchange rate has been found for ${aFromCurrency}${aToCurrency} at ${format( `No exchange rate has been found for ${aFromCurrency}${aToCurrency} at ${format(
aDate, aDate,
DATE_FORMAT DATE_FORMAT
)}`, )}`
'ExchangeRateDataService'
); );
return undefined; return undefined;
@ -483,7 +482,7 @@ export class ExchangeRateDataService {
errorMessage = `${errorMessage} and ${DEFAULT_CURRENCY}${currencyTo}`; errorMessage = `${errorMessage} and ${DEFAULT_CURRENCY}${currencyTo}`;
} }
Logger.error(`${errorMessage}.`, 'ExchangeRateDataService'); this.logger.error(`${errorMessage}.`);
} }
} }
} }

11
apps/api/src/services/fetch/fetch.module.ts

@ -0,0 +1,11 @@
import { FetchService } from '@ghostfolio/api/services/fetch/fetch.service';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { Module } from '@nestjs/common';
@Module({
exports: [FetchService],
imports: [PropertyModule],
providers: [FetchService]
})
export class FetchModule {}

199
apps/api/src/services/fetch/fetch.service.ts

@ -0,0 +1,199 @@
import { redactPaths } from '@ghostfolio/api/helper/object.helper';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import {
PROPERTY_API_KEY_OPENROUTER,
PROPERTY_OPENROUTER_MODEL,
PROPERTY_WEB_FETCH_ROUTES
} from '@ghostfolio/common/config';
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { createOpenRouter } from '@openrouter/ai-sdk-provider';
import { generateText, jsonSchema, tool } from 'ai';
import ms from 'ms';
import { WebFetchRoute } from './interfaces/web-fetch-route.interface';
@Injectable()
export class FetchService implements OnModuleInit {
private readonly logger = new Logger(FetchService.name);
private static readonly REDACTED_QUERY_PARAM_NAMES = ['apikey', 'api_token'];
private static readonly WEB_FETCH_TIMEOUT = ms('30 seconds');
private webFetchRoutes: WebFetchRoute[] = [];
public constructor(private readonly propertyService: PropertyService) {}
public async onModuleInit() {
this.webFetchRoutes =
(await this.propertyService.getByKey<WebFetchRoute[]>(
PROPERTY_WEB_FETCH_ROUTES
)) ?? [];
}
public async fetch(input: RequestInfo | URL, init?: RequestInit) {
const method = (
init?.method ??
(input instanceof Request ? input.method : undefined) ??
'GET'
).toUpperCase();
const url = input instanceof Request ? input.url : input.toString();
const urlRedacted = this.redactUrl(url);
this.logger.debug(`${method} ${urlRedacted}`);
if (method === 'GET') {
const webFetchRoute = this.getMatchingWebFetchRoute(url);
if (webFetchRoute) {
const response = await this.fetchViaWebFetchTool({
url,
webFetchRoute
});
if (response) {
return response;
}
}
}
try {
return await globalThis.fetch(input, init);
} catch (error) {
if (error instanceof Error) {
this.logger.error(
`${method} ${urlRedacted} failed: [${error.name}] ${error.message}`
);
} else {
this.logger.error(`${method} ${urlRedacted} failed: ${String(error)}`);
}
throw error;
}
}
private async fetchViaWebFetchTool({
url,
webFetchRoute
}: {
url: string;
webFetchRoute: WebFetchRoute;
}) {
const [openRouterApiKey, openRouterModel] = await Promise.all([
this.propertyService.getByKey<string>(PROPERTY_API_KEY_OPENROUTER),
this.propertyService.getByKey<string>(PROPERTY_OPENROUTER_MODEL)
]);
if (!openRouterApiKey || !openRouterModel) {
return undefined;
}
try {
const openRouterService = createOpenRouter({ apiKey: openRouterApiKey });
const { sources, text } = await generateText({
model: openRouterService.chat(openRouterModel),
prompt: [
'You have access to a web_fetch tool. You MUST call it to retrieve the URL below, do not answer from prior knowledge.',
'Return the fetched response body exactly as received: raw body only, no commentary, no Markdown, and no code fences.',
`URL: ${url}`
].join('\n'),
timeout: FetchService.WEB_FETCH_TIMEOUT,
tools: {
// Provider-defined tool: lets OpenRouter perform the actual web
// request server-side via its `web_fetch` engine. `id` and `args`
// are the OpenRouter-specific identifiers; the input schema is left
// open as the arguments are supplied by the model.
web_fetch: tool({
args: { engine: 'openrouter' },
id: 'openrouter.web_fetch',
inputSchema: jsonSchema({
additionalProperties: true,
type: 'object'
}),
type: 'provider'
})
}
});
const candidates = [
...(sources ?? []).map((source) => {
return source.providerMetadata?.openrouter?.content;
}),
text
];
for (const candidate of candidates) {
if (typeof candidate !== 'string') {
continue;
}
const body = candidate.trim();
if (!body) {
continue;
}
if (webFetchRoute.responseContentType?.includes('application/json')) {
try {
JSON.parse(body);
} catch {
continue;
}
}
this.logger.debug(`Routed ${this.redactUrl(url)} via web fetch tool`);
return new Response(body, {
headers: webFetchRoute.responseContentType
? { 'content-type': webFetchRoute.responseContentType }
: undefined
});
}
return undefined;
} catch (error) {
this.logger.error(
`Web fetch tool failed for ${this.redactUrl(url)}: ${
error instanceof Error ? error.message : String(error)
}`
);
return undefined;
}
}
private getMatchingWebFetchRoute(url: string) {
try {
const { hostname } = new URL(url);
return this.webFetchRoutes.find(({ domain }) => {
return hostname === domain || hostname.endsWith(`.${domain}`);
});
} catch {
return undefined;
}
}
private redactUrl(rawUrl: string): string {
try {
const url = new URL(rawUrl);
const redacted = redactPaths({
object: Object.fromEntries(url.searchParams),
paths: FetchService.REDACTED_QUERY_PARAM_NAMES
});
for (const [key, value] of Object.entries(redacted)) {
if (value === null) {
url.searchParams.set(key, '*******');
}
}
return url.toString();
} catch {
return rawUrl;
}
}
}

19
apps/api/src/services/fetch/interfaces/web-fetch-route.interface.ts

@ -0,0 +1,19 @@
/**
* Routes outgoing GET requests for a given domain through the OpenRouter
* `web_fetch` tool instead of a direct network request.
*
* Configured via the `WEB_FETCH_ROUTES` property as a JSON array, e.g.
*
* [
* {
* "domain": "example.com",
* "responseContentType": "application/json"
* }
* ]
*
* Matches the domain itself and its subdomains (e.g. `api.example.com`).
*/
export interface WebFetchRoute {
domain: string;
responseContentType?: string;
}

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

@ -7,6 +7,8 @@ import { join } from 'node:path';
@Injectable() @Injectable()
export class I18nService implements OnModuleInit { export class I18nService implements OnModuleInit {
private readonly logger = new Logger(I18nService.name);
private localesPath = join(__dirname, 'assets', 'locales'); private localesPath = join(__dirname, 'assets', 'locales');
private translations: { [locale: string]: cheerio.CheerioAPI } = {}; private translations: { [locale: string]: cheerio.CheerioAPI } = {};
@ -26,7 +28,7 @@ export class I18nService implements OnModuleInit {
const $ = this.translations[languageCode]; const $ = this.translations[languageCode];
if (!$) { if (!$) {
Logger.warn(`Translation not found for locale '${languageCode}'`); this.logger.warn(`Translation not found for locale '${languageCode}'`);
} }
let translatedText = $( let translatedText = $(
@ -36,7 +38,7 @@ export class I18nService implements OnModuleInit {
).text(); ).text();
if (!translatedText) { if (!translatedText) {
Logger.warn( this.logger.warn(
`Translation not found for id '${id}' in locale '${languageCode}'` `Translation not found for id '${id}' in locale '${languageCode}'`
); );
} }
@ -60,7 +62,7 @@ export class I18nService implements OnModuleInit {
this.parseXml(xmlData); this.parseXml(xmlData);
} }
} catch (error) { } catch (error) {
Logger.error(error, 'I18nService'); this.logger.error(error);
} }
} }

4
apps/api/src/services/prisma/prisma.service.ts

@ -14,6 +14,8 @@ export class PrismaService
extends PrismaClient extends PrismaClient
implements OnModuleInit, OnModuleDestroy implements OnModuleInit, OnModuleDestroy
{ {
private readonly logger = new Logger(PrismaService.name);
public constructor(configService: ConfigService) { public constructor(configService: ConfigService) {
const adapter = new PrismaPg({ const adapter = new PrismaPg({
connectionString: configService.get<string>('DATABASE_URL') connectionString: configService.get<string>('DATABASE_URL')
@ -43,7 +45,7 @@ export class PrismaService
try { try {
await this.$connect(); await this.$connect();
} catch (error) { } catch (error) {
Logger.error(error, 'PrismaService'); this.logger.error(error);
} }
} }

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

@ -32,6 +32,8 @@ import { DataGatheringService } from './data-gathering.service';
@Injectable() @Injectable()
@Processor(DATA_GATHERING_QUEUE) @Processor(DATA_GATHERING_QUEUE)
export class DataGatheringProcessor { export class DataGatheringProcessor {
private readonly logger = new Logger(DataGatheringProcessor.name);
public constructor( public constructor(
private readonly dataGatheringService: DataGatheringService, private readonly dataGatheringService: DataGatheringService,
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
@ -51,16 +53,14 @@ export class DataGatheringProcessor {
const { dataSource, symbol } = job.data; const { dataSource, symbol } = job.data;
try { try {
Logger.log( this.logger.log(
`Asset profile data gathering has been started for ${symbol} (${dataSource})`, `Asset profile data gathering has been started for ${symbol} (${dataSource})`
`DataGatheringProcessor (${GATHER_ASSET_PROFILE_PROCESS_JOB_NAME})`
); );
await this.dataGatheringService.gatherAssetProfiles([job.data]); await this.dataGatheringService.gatherAssetProfiles([job.data]);
Logger.log( this.logger.log(
`Asset profile data gathering has been completed for ${symbol} (${dataSource})`, `Asset profile data gathering has been completed for ${symbol} (${dataSource})`
`DataGatheringProcessor (${GATHER_ASSET_PROFILE_PROCESS_JOB_NAME})`
); );
} catch (error) { } catch (error) {
if (error instanceof AssetProfileDelistedError) { if (error instanceof AssetProfileDelistedError) {
@ -74,18 +74,14 @@ export class DataGatheringProcessor {
} }
); );
Logger.log( this.logger.log(
`Asset profile data gathering has been discarded for ${symbol} (${dataSource})`, `Asset profile data gathering has been discarded for ${symbol} (${dataSource})`
`DataGatheringProcessor (${GATHER_ASSET_PROFILE_PROCESS_JOB_NAME})`
); );
return job.discard(); return job.discard();
} }
Logger.error( this.logger.error(error);
error,
`DataGatheringProcessor (${GATHER_ASSET_PROFILE_PROCESS_JOB_NAME})`
);
throw error; throw error;
} }
@ -105,12 +101,11 @@ export class DataGatheringProcessor {
try { try {
let currentDate = parseISO(date as unknown as string); let currentDate = parseISO(date as unknown as string);
Logger.log( this.logger.log(
`Historical market data gathering has been started for ${symbol} (${dataSource}) at ${format( `Historical market data gathering has been started for ${symbol} (${dataSource}) at ${format(
currentDate, currentDate,
DATE_FORMAT DATE_FORMAT
)}${force ? ' (forced update)' : ''}`, )}${force ? ' (forced update)' : ''}`
`DataGatheringProcessor (${GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME})`
); );
const historicalData = await this.dataProviderService.getHistoricalRaw({ const historicalData = await this.dataProviderService.getHistoricalRaw({
@ -167,12 +162,11 @@ export class DataGatheringProcessor {
await this.marketDataService.updateMany({ data }); await this.marketDataService.updateMany({ data });
} }
Logger.log( this.logger.log(
`Historical market data gathering has been completed for ${symbol} (${dataSource}) at ${format( `Historical market data gathering has been completed for ${symbol} (${dataSource}) at ${format(
currentDate, currentDate,
DATE_FORMAT DATE_FORMAT
)}`, )}`
`DataGatheringProcessor (${GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME})`
); );
} catch (error) { } catch (error) {
if (error instanceof AssetProfileDelistedError) { if (error instanceof AssetProfileDelistedError) {
@ -186,18 +180,14 @@ export class DataGatheringProcessor {
} }
); );
Logger.log( this.logger.log(
`Historical market data gathering has been discarded for ${symbol} (${dataSource})`, `Historical market data gathering has been discarded for ${symbol} (${dataSource})`
`DataGatheringProcessor (${GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME})`
); );
return job.discard(); return job.discard();
} }
Logger.error( this.logger.error(error);
error,
`DataGatheringProcessor (${GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME})`
);
throw error; throw error;
} }

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

@ -34,6 +34,8 @@ import ms, { StringValue } from 'ms';
@Injectable() @Injectable()
export class DataGatheringService { export class DataGatheringService {
private readonly logger = new Logger(DataGatheringService.name);
public constructor( public constructor(
@Inject('DataEnhancers') @Inject('DataEnhancers')
private readonly dataEnhancers: DataEnhancerInterface[], private readonly dataEnhancers: DataEnhancerInterface[],
@ -145,7 +147,7 @@ export class DataGatheringService {
}); });
} }
} catch (error) { } catch (error) {
Logger.error(error, 'DataGatheringService'); this.logger.error(error);
} finally { } finally {
return undefined; return undefined;
} }
@ -176,30 +178,42 @@ export class DataGatheringService {
); );
for (const [symbol, assetProfile] of Object.entries(assetProfiles)) { for (const [symbol, assetProfile] of Object.entries(assetProfiles)) {
const symbolMapping = symbolProfiles.find((symbolProfile) => { const symbolProfile = symbolProfiles.find(
return symbolProfile.symbol === symbol; ({ symbol: symbolProfileSymbol }) => {
})?.symbolMapping; return symbolProfileSymbol === symbol;
}
);
const symbolMapping = symbolProfile?.symbolMapping;
let enhancedAssetProfile = symbolProfile
? {
...assetProfile,
assetClass: symbolProfile.assetClass ?? assetProfile.assetClass,
assetSubClass:
symbolProfile.assetSubClass ?? assetProfile.assetSubClass
}
: assetProfile;
for (const dataEnhancer of this.dataEnhancers) { for (const dataEnhancer of this.dataEnhancers) {
try { try {
assetProfiles[symbol] = await dataEnhancer.enhance({ enhancedAssetProfile = await dataEnhancer.enhance({
response: assetProfile, response: enhancedAssetProfile,
symbol: symbolMapping?.[dataEnhancer.getName()] ?? symbol symbol: symbolMapping?.[dataEnhancer.getName()] ?? symbol
}); });
} catch (error) { } catch (error) {
Logger.error( this.logger.error(
`Failed to enhance data for ${symbol} (${ `Failed to enhance data for ${symbol} (${
assetProfile.dataSource assetProfile.dataSource
}) by ${dataEnhancer.getName()}`, }) by ${dataEnhancer.getName()}`,
error, error
'DataGatheringService'
); );
} }
} }
const { assetClass, assetSubClass } = assetProfile;
const { const {
assetClass,
assetSubClass,
countries, countries,
currency, currency,
cusip, cusip,
@ -212,7 +226,7 @@ export class DataGatheringService {
name, name,
sectors, sectors,
url url
} = assetProfile; } = enhancedAssetProfile;
try { try {
await this.prismaService.symbolProfile.upsert({ await this.prismaService.symbolProfile.upsert({
@ -256,11 +270,7 @@ export class DataGatheringService {
} }
}); });
} catch (error) { } catch (error) {
Logger.error( this.logger.error(`${symbol}: ${error?.meta?.cause}`, error);
`${symbol}: ${error?.meta?.cause}`,
error,
'DataGatheringService'
);
if (assetProfileIdentifiers.length === 1) { if (assetProfileIdentifiers.length === 1) {
throw error; throw error;

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

@ -21,6 +21,8 @@ import { PortfolioSnapshotQueueJob } from './interfaces/portfolio-snapshot-queue
@Injectable() @Injectable()
@Processor(PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE) @Processor(PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE)
export class PortfolioSnapshotProcessor { export class PortfolioSnapshotProcessor {
private readonly logger = new Logger(PortfolioSnapshotProcessor.name);
public constructor( public constructor(
private readonly accountBalanceService: AccountBalanceService, private readonly accountBalanceService: AccountBalanceService,
private readonly activitiesService: ActivitiesService, private readonly activitiesService: ActivitiesService,
@ -41,9 +43,8 @@ export class PortfolioSnapshotProcessor {
try { try {
const startTime = performance.now(); const startTime = performance.now();
Logger.log( this.logger.log(
`Portfolio snapshot calculation of user '${job.data.userId}' has been started`, `Portfolio snapshot calculation of user '${job.data.userId}' has been started`
`PortfolioSnapshotProcessor (${PORTFOLIO_SNAPSHOT_PROCESS_JOB_NAME})`
); );
const { activities } = const { activities } =
@ -72,12 +73,11 @@ export class PortfolioSnapshotProcessor {
const snapshot = await portfolioCalculator.computeSnapshot(); const snapshot = await portfolioCalculator.computeSnapshot();
Logger.log( this.logger.log(
`Portfolio snapshot calculation of user '${job.data.userId}' has been completed in ${( `Portfolio snapshot calculation of user '${job.data.userId}' has been completed in ${(
(performance.now() - startTime) / (performance.now() - startTime) /
1000 1000
).toFixed(3)} seconds`, ).toFixed(3)} seconds`
`PortfolioSnapshotProcessor (${PORTFOLIO_SNAPSHOT_PROCESS_JOB_NAME})`
); );
const expiration = addMilliseconds( const expiration = addMilliseconds(
@ -101,10 +101,7 @@ export class PortfolioSnapshotProcessor {
return snapshot; return snapshot;
} catch (error) { } catch (error) {
Logger.error( this.logger.error(error);
error,
`PortfolioSnapshotProcessor (${PORTFOLIO_SNAPSHOT_PROCESS_JOB_NAME})`
);
throw new Error(error); throw new Error(error);
} }

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

@ -1,4 +1,5 @@
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { FetchModule } from '@ghostfolio/api/services/fetch/fetch.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { STATISTICS_GATHERING_QUEUE } from '@ghostfolio/common/config'; import { STATISTICS_GATHERING_QUEUE } from '@ghostfolio/common/config';
@ -29,6 +30,7 @@ import { StatisticsGatheringService } from './statistics-gathering.service';
name: STATISTICS_GATHERING_QUEUE name: STATISTICS_GATHERING_QUEUE
}), }),
ConfigurationModule, ConfigurationModule,
FetchModule,
PropertyModule PropertyModule
], ],
providers: [StatisticsGatheringProcessor, StatisticsGatheringService] providers: [StatisticsGatheringProcessor, StatisticsGatheringService]

121
apps/api/src/services/queues/statistics-gathering/statistics-gathering.processor.ts

@ -1,4 +1,5 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { FetchService } from '@ghostfolio/api/services/fetch/fetch.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { import {
GATHER_STATISTICS_DOCKER_HUB_PULLS_PROCESS_JOB_NAME, GATHER_STATISTICS_DOCKER_HUB_PULLS_PROCESS_JOB_NAME,
@ -26,17 +27,17 @@ import { format, subDays } from 'date-fns';
@Injectable() @Injectable()
@Processor(STATISTICS_GATHERING_QUEUE) @Processor(STATISTICS_GATHERING_QUEUE)
export class StatisticsGatheringProcessor { export class StatisticsGatheringProcessor {
private readonly logger = new Logger(StatisticsGatheringProcessor.name);
public constructor( public constructor(
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly fetchService: FetchService,
private readonly propertyService: PropertyService private readonly propertyService: PropertyService
) {} ) {}
@Process(GATHER_STATISTICS_DOCKER_HUB_PULLS_PROCESS_JOB_NAME) @Process(GATHER_STATISTICS_DOCKER_HUB_PULLS_PROCESS_JOB_NAME)
public async gatherDockerHubPullsStatistics() { public async gatherDockerHubPullsStatistics() {
Logger.log( this.logger.log('Docker Hub pulls statistics gathering has been started');
'Docker Hub pulls statistics gathering has been started',
'StatisticsGatheringProcessor'
);
const dockerHubPulls = await this.countDockerHubPulls(); const dockerHubPulls = await this.countDockerHubPulls();
@ -45,17 +46,13 @@ export class StatisticsGatheringProcessor {
value: String(dockerHubPulls) value: String(dockerHubPulls)
}); });
Logger.log( this.logger.log('Docker Hub pulls statistics gathering has been completed');
'Docker Hub pulls statistics gathering has been completed',
'StatisticsGatheringProcessor'
);
} }
@Process(GATHER_STATISTICS_GITHUB_CONTRIBUTORS_PROCESS_JOB_NAME) @Process(GATHER_STATISTICS_GITHUB_CONTRIBUTORS_PROCESS_JOB_NAME)
public async gatherGitHubContributorsStatistics() { public async gatherGitHubContributorsStatistics() {
Logger.log( this.logger.log(
'GitHub contributors statistics gathering has been started', 'GitHub contributors statistics gathering has been started'
'StatisticsGatheringProcessor'
); );
const gitHubContributors = await this.countGitHubContributors(); const gitHubContributors = await this.countGitHubContributors();
@ -65,18 +62,14 @@ export class StatisticsGatheringProcessor {
value: String(gitHubContributors) value: String(gitHubContributors)
}); });
Logger.log( this.logger.log(
'GitHub contributors statistics gathering has been completed', 'GitHub contributors statistics gathering has been completed'
'StatisticsGatheringProcessor'
); );
} }
@Process(GATHER_STATISTICS_GITHUB_STARGAZERS_PROCESS_JOB_NAME) @Process(GATHER_STATISTICS_GITHUB_STARGAZERS_PROCESS_JOB_NAME)
public async gatherGitHubStargazersStatistics() { public async gatherGitHubStargazersStatistics() {
Logger.log( this.logger.log('GitHub stargazers statistics gathering has been started');
'GitHub stargazers statistics gathering has been started',
'StatisticsGatheringProcessor'
);
const gitHubStargazers = await this.countGitHubStargazers(); const gitHubStargazers = await this.countGitHubStargazers();
@ -85,9 +78,8 @@ export class StatisticsGatheringProcessor {
value: String(gitHubStargazers) value: String(gitHubStargazers)
}); });
Logger.log( this.logger.log(
'GitHub stargazers statistics gathering has been completed', 'GitHub stargazers statistics gathering has been completed'
'StatisticsGatheringProcessor'
); );
} }
@ -98,18 +90,14 @@ export class StatisticsGatheringProcessor {
); );
if (!monitorId) { if (!monitorId) {
Logger.log( this.logger.log(
`Uptime statistics gathering has been skipped as no ${PROPERTY_BETTER_UPTIME_MONITOR_ID} is configured`, `Uptime statistics gathering has been skipped as no ${PROPERTY_BETTER_UPTIME_MONITOR_ID} is configured`
'StatisticsGatheringProcessor'
); );
return; return;
} }
Logger.log( this.logger.log('Uptime statistics gathering has been started');
'Uptime statistics gathering has been started',
'StatisticsGatheringProcessor'
);
const uptime = await this.getUptime(monitorId); const uptime = await this.getUptime(monitorId);
@ -118,27 +106,23 @@ export class StatisticsGatheringProcessor {
value: String(uptime) value: String(uptime)
}); });
Logger.log( this.logger.log('Uptime statistics gathering has been completed');
'Uptime statistics gathering has been completed',
'StatisticsGatheringProcessor'
);
} }
private async countDockerHubPulls(): Promise<number> { private async countDockerHubPulls(): Promise<number> {
try { try {
const { pull_count } = (await fetch( const { pull_count } = (await this.fetchService
'https://hub.docker.com/v2/repositories/ghostfolio/ghostfolio', .fetch('https://hub.docker.com/v2/repositories/ghostfolio/ghostfolio', {
{
headers: { 'User-Agent': 'request' }, headers: { 'User-Agent': 'request' },
signal: AbortSignal.timeout( signal: AbortSignal.timeout(
this.configurationService.get('REQUEST_TIMEOUT') this.configurationService.get('REQUEST_TIMEOUT')
) )
} })
).then((res) => res.json())) as { pull_count: number }; .then((res) => res.json())) as { pull_count: number };
return pull_count; return pull_count;
} catch (error) { } catch (error) {
Logger.error(error, 'StatisticsGatheringProcessor - DockerHub'); this.logger.error(error);
throw error; throw error;
} }
@ -146,11 +130,13 @@ export class StatisticsGatheringProcessor {
private async countGitHubContributors(): Promise<number> { private async countGitHubContributors(): Promise<number> {
try { try {
const body = await fetch('https://github.com/ghostfolio/ghostfolio', { const body = await this.fetchService
signal: AbortSignal.timeout( .fetch('https://github.com/ghostfolio/ghostfolio', {
this.configurationService.get('REQUEST_TIMEOUT') signal: AbortSignal.timeout(
) this.configurationService.get('REQUEST_TIMEOUT')
}).then((res) => res.text()); )
})
.then((res) => res.text());
const $ = cheerio.load(body); const $ = cheerio.load(body);
@ -166,7 +152,7 @@ export class StatisticsGatheringProcessor {
value value
}); });
} catch (error) { } catch (error) {
Logger.error(error, 'StatisticsGatheringProcessor - GitHub'); this.logger.error(error);
throw error; throw error;
} }
@ -174,19 +160,18 @@ export class StatisticsGatheringProcessor {
private async countGitHubStargazers(): Promise<number> { private async countGitHubStargazers(): Promise<number> {
try { try {
const { stargazers_count } = (await fetch( const { stargazers_count } = (await this.fetchService
'https://api.github.com/repos/ghostfolio/ghostfolio', .fetch('https://api.github.com/repos/ghostfolio/ghostfolio', {
{
headers: { 'User-Agent': 'request' }, headers: { 'User-Agent': 'request' },
signal: AbortSignal.timeout( signal: AbortSignal.timeout(
this.configurationService.get('REQUEST_TIMEOUT') this.configurationService.get('REQUEST_TIMEOUT')
) )
} })
).then((res) => res.json())) as { stargazers_count: number }; .then((res) => res.json())) as { stargazers_count: number };
return stargazers_count; return stargazers_count;
} catch (error) { } catch (error) {
Logger.error(error, 'StatisticsGatheringProcessor - GitHub'); this.logger.error(error);
throw error; throw error;
} }
@ -194,26 +179,28 @@ export class StatisticsGatheringProcessor {
private async getUptime(monitorId: string): Promise<number> { private async getUptime(monitorId: string): Promise<number> {
try { try {
const { data } = await fetch( const { data } = await this.fetchService
`https://uptime.betterstack.com/api/v2/monitors/${monitorId}/sla?from=${format( .fetch(
subDays(new Date(), 90), `https://uptime.betterstack.com/api/v2/monitors/${monitorId}/sla?from=${format(
DATE_FORMAT subDays(new Date(), 90),
)}&to${format(new Date(), DATE_FORMAT)}`, DATE_FORMAT
{ )}&to${format(new Date(), DATE_FORMAT)}`,
headers: { {
[HEADER_KEY_TOKEN]: `Bearer ${this.configurationService.get( headers: {
'API_KEY_BETTER_UPTIME' [HEADER_KEY_TOKEN]: `Bearer ${this.configurationService.get(
)}` 'API_KEY_BETTER_UPTIME'
}, )}`
signal: AbortSignal.timeout( },
this.configurationService.get('REQUEST_TIMEOUT') signal: AbortSignal.timeout(
) this.configurationService.get('REQUEST_TIMEOUT')
} )
).then((res) => res.json()); }
)
.then((res) => res.json());
return data.attributes.availability / 100; return data.attributes.availability / 100;
} catch (error) { } catch (error) {
Logger.error(error, 'StatisticsGatheringProcessor - Better Stack'); this.logger.error(error);
throw error; throw error;
} }

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

@ -1,5 +1,6 @@
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { UNKNOWN_KEY } from '@ghostfolio/common/config'; import { UNKNOWN_KEY } from '@ghostfolio/common/config';
import { applyAssetProfileOverrides } from '@ghostfolio/common/helper';
import { import {
AssetProfileIdentifier, AssetProfileIdentifier,
EnhancedSymbolProfile, EnhancedSymbolProfile,
@ -192,21 +193,28 @@ export class SymbolProfileService {
})[] })[]
): EnhancedSymbolProfile[] { ): EnhancedSymbolProfile[] {
return symbolProfiles.map((symbolProfile) => { return symbolProfiles.map((symbolProfile) => {
const symbolProfileWithOverrides = applyAssetProfileOverrides(
symbolProfile,
symbolProfile.SymbolProfileOverrides
);
const item = { const item = {
...symbolProfile, ...symbolProfileWithOverrides,
activitiesCount: 0, activitiesCount: 0,
countries: this.getCountries( countries: this.getCountries(
symbolProfile?.countries as unknown as Prisma.JsonArray symbolProfileWithOverrides?.countries as unknown as Prisma.JsonArray
), ),
dateOfFirstActivity: undefined as Date, dateOfFirstActivity: undefined as Date,
holdings: this.getHoldings( holdings: this.getHoldings(
symbolProfile?.holdings as unknown as Prisma.JsonArray symbolProfileWithOverrides?.holdings as unknown as Prisma.JsonArray
),
scraperConfiguration: this.getScraperConfiguration(
symbolProfileWithOverrides
), ),
scraperConfiguration: this.getScraperConfiguration(symbolProfile),
sectors: this.getSectors( sectors: this.getSectors(
symbolProfile?.sectors as unknown as Prisma.JsonArray symbolProfileWithOverrides?.sectors as unknown as Prisma.JsonArray
), ),
symbolMapping: this.getSymbolMapping(symbolProfile), symbolMapping: this.getSymbolMapping(symbolProfileWithOverrides),
watchedByCount: 0 watchedByCount: 0
}; };
@ -217,45 +225,7 @@ export class SymbolProfileService {
item.dateOfFirstActivity = symbolProfile.activities?.[0]?.date; item.dateOfFirstActivity = symbolProfile.activities?.[0]?.date;
delete item.activities; delete item.activities;
if (item.SymbolProfileOverrides) { delete item.SymbolProfileOverrides;
item.assetClass =
item.SymbolProfileOverrides.assetClass ?? item.assetClass;
item.assetSubClass =
item.SymbolProfileOverrides.assetSubClass ?? item.assetSubClass;
if (
(item.SymbolProfileOverrides.countries as unknown as Prisma.JsonArray)
?.length > 0
) {
item.countries = this.getCountries(
item.SymbolProfileOverrides.countries as unknown as Prisma.JsonArray
);
}
if (
(item.SymbolProfileOverrides.holdings as unknown as Holding[])
?.length > 0
) {
item.holdings = this.getHoldings(
item.SymbolProfileOverrides.holdings as unknown as Prisma.JsonArray
);
}
item.name = item.SymbolProfileOverrides.name ?? item.name;
if (
(item.SymbolProfileOverrides.sectors as unknown as Sector[])?.length >
0
) {
item.sectors = this.getSectors(
item.SymbolProfileOverrides.sectors as unknown as Prisma.JsonArray
);
}
item.url = item.SymbolProfileOverrides.url ?? item.url;
delete item.SymbolProfileOverrides;
}
return item; return item;
}); });

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

@ -16,6 +16,8 @@ import { TwitterApi, TwitterApiReadWrite } from 'twitter-api-v2';
@Injectable() @Injectable()
export class TwitterBotService implements OnModuleInit { export class TwitterBotService implements OnModuleInit {
private readonly logger = new Logger(TwitterBotService.name);
private twitterClient: TwitterApiReadWrite; private twitterClient: TwitterApiReadWrite;
public constructor( public constructor(
@ -71,13 +73,12 @@ export class TwitterBotService implements OnModuleInit {
const { data: createdTweet } = const { data: createdTweet } =
await this.twitterClient.v2.tweet(status); await this.twitterClient.v2.tweet(status);
Logger.log( this.logger.log(
`Fear & Greed Index has been posted: https://x.com/ghostfolio_/status/${createdTweet.id}`, `Fear & Greed Index has been posted: https://x.com/ghostfolio_/status/${createdTweet.id}`
'TwitterBotService'
); );
} }
} catch (error) { } catch (error) {
Logger.error(error, 'TwitterBotService'); this.logger.error(error);
} }
} }

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

@ -148,6 +148,7 @@
[accountBalances]="accountBalances" [accountBalances]="accountBalances"
[accountCurrency]="currency" [accountCurrency]="currency"
[accountId]="data.accountId" [accountId]="data.accountId"
[currentBalance]="balance"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[showActions]=" [showActions]="
!data.hasImpersonationId && !data.hasImpersonationId &&

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

@ -18,6 +18,7 @@ import { AdminMarketDataItem } from '@ghostfolio/common/interfaces/admin-market-
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { GfSymbolPipe } from '@ghostfolio/common/pipes'; import { GfSymbolPipe } from '@ghostfolio/common/pipes';
import { GfActivitiesFilterComponent } from '@ghostfolio/ui/activities-filter'; import { GfActivitiesFilterComponent } from '@ghostfolio/ui/activities-filter';
import { GfFabComponent } from '@ghostfolio/ui/fab';
import { translate } from '@ghostfolio/ui/i18n'; import { translate } from '@ghostfolio/ui/i18n';
import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator'; import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator';
import { AdminService, DataService } from '@ghostfolio/ui/services'; import { AdminService, DataService } from '@ghostfolio/ui/services';
@ -80,10 +81,10 @@ import { CreateAssetProfileDialogParams } from './create-asset-profile-dialog/in
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'has-fab' },
imports: [ imports: [
CommonModule, CommonModule,
GfActivitiesFilterComponent, GfActivitiesFilterComponent,
GfFabComponent,
GfPremiumIndicatorComponent, GfPremiumIndicatorComponent,
GfSymbolPipe, GfSymbolPipe,
GfValueComponent, GfValueComponent,

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

@ -332,15 +332,5 @@
</div> </div>
</div> </div>
<div class="fab-container"> <gf-fab [queryParams]="{ createAssetProfileDialog: true }" />
<a
class="align-items-center d-flex justify-content-center"
color="primary"
mat-fab
[queryParams]="{ createAssetProfileDialog: true }"
[routerLink]="[]"
>
<ion-icon name="add-outline" size="large" />
</a>
</div>
</div> </div>

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

@ -14,4 +14,8 @@
top: 0; top: 0;
} }
} }
.mat-mdc-dialog-title {
padding-right: 0.5rem !important;
}
} }

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

@ -8,6 +8,7 @@ import { UpdateAssetProfileDto } from '@ghostfolio/common/dtos';
import { import {
canDeleteAssetProfile, canDeleteAssetProfile,
DATE_FORMAT, DATE_FORMAT,
getCountryName,
getCurrencyFromSymbol, getCurrencyFromSymbol,
isCurrency isCurrency
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
@ -224,6 +225,7 @@ export class GfAssetProfileDialogComponent implements OnInit {
value: 'max' value: 'max'
} }
]; ];
protected readonly getCountryName = getCountryName;
protected historicalDataItems: LineChartItem[]; protected historicalDataItems: LineChartItem[];
protected isBenchmark = false; protected isBenchmark = false;
protected isDataGatheringEnabled: boolean; protected isDataGatheringEnabled: boolean;
@ -246,6 +248,8 @@ export class GfAssetProfileDialogComponent implements OnInit {
[name: string]: { name: string; value: number }; [name: string]: { name: string; value: number };
}; };
protected readonly translate = translate;
protected user: User; protected user: User;
private benchmarks: Partial<SymbolProfile>[]; private benchmarks: Partial<SymbolProfile>[];
@ -367,9 +371,9 @@ export class GfAssetProfileDialogComponent implements OnInit {
this.assetProfile?.countries && this.assetProfile?.countries &&
this.assetProfile.countries.length > 0 this.assetProfile.countries.length > 0
) { ) {
for (const { code, name, weight } of this.assetProfile.countries) { for (const { code, weight } of this.assetProfile.countries) {
this.countries[code] = { this.countries[code] = {
name, name: getCountryName({ code, locale: this.data.locale }),
value: weight value: weight
}; };
} }
@ -381,7 +385,7 @@ export class GfAssetProfileDialogComponent implements OnInit {
) { ) {
for (const { name, weight } of this.assetProfile.sectors) { for (const { name, weight } of this.assetProfile.sectors) {
this.sectors[name] = { this.sectors[name] = {
name, name: translate(name),
value: weight value: weight
}; };
} }

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

@ -1,10 +1,10 @@
<div class="d-flex flex-column h-100"> <div class="d-flex flex-column h-100">
<div class="d-flex mb-3"> <div class="align-items-center d-flex mb-3" mat-dialog-title>
<h1 class="flex-grow-1 m-0" mat-dialog-title> <span class="flex-grow-1 text-truncate">{{
<span>{{ assetProfile?.name ?? data.symbol }}</span> assetProfile?.name ?? data.symbol
</h1> }}</span>
<button <button
class="mx-1 no-min-width px-2" class="ml-1 no-min-width px-2"
mat-button mat-button
type="button" type="button"
[matMenuTriggerFor]="assetProfileActionsMenu" [matMenuTriggerFor]="assetProfileActionsMenu"
@ -258,7 +258,7 @@
i18n i18n
size="medium" size="medium"
[locale]="data.locale" [locale]="data.locale"
[value]="assetProfile?.sectors[0].name" [value]="translate(assetProfile?.sectors[0].name)"
>Sector</gf-value >Sector</gf-value
> >
</div> </div>
@ -269,7 +269,12 @@
i18n i18n
size="medium" size="medium"
[locale]="data.locale" [locale]="data.locale"
[value]="assetProfile?.countries[0].name" [value]="
getCountryName({
code: assetProfile?.countries[0].code,
locale: data.locale
})
"
>Country</gf-value >Country</gf-value
> >
</div> </div>

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

@ -59,8 +59,10 @@ import {
personOutline, personOutline,
trashOutline trashOutline
} from 'ionicons/icons'; } from 'ionicons/icons';
import ms from 'ms';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { interval } from 'rxjs';
import { switchMap, tap } from 'rxjs/operators'; import { switchMap, tap } from 'rxjs/operators';
@Component({ @Component({
@ -184,6 +186,15 @@ export class GfAdminUsersComponent implements OnInit {
public ngOnInit() { public ngOnInit() {
this.fetchUsers(); this.fetchUsers();
interval(ms('30 seconds'))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
this.fetchUsers({
pageIndex: this.paginator().pageIndex,
showLoading: false
});
});
} }
protected formatDistanceToNow(aDateString: string) { protected formatDistanceToNow(aDateString: string) {
@ -267,8 +278,13 @@ export class GfAdminUsersComponent implements OnInit {
); );
} }
private fetchUsers({ pageIndex }: { pageIndex: number } = { pageIndex: 0 }) { private fetchUsers({
this.isLoading = true; pageIndex = 0,
showLoading = true
}: { pageIndex?: number; showLoading?: boolean } = {}) {
if (showLoading) {
this.isLoading = true;
}
if (pageIndex === 0 && this.paginator()) { if (pageIndex === 0 && this.paginator()) {
this.paginator().pageIndex = 0; this.paginator().pageIndex = 0;
@ -281,7 +297,7 @@ export class GfAdminUsersComponent implements OnInit {
}) })
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ count, users }) => { .subscribe(({ count, users }) => {
this.dataSource = new MatTableDataSource(users); this.dataSource.data = users;
this.totalItems = count; this.totalItems = count;
this.isLoading = false; this.isLoading = false;

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

@ -6,7 +6,11 @@ import {
NUMERICAL_PRECISION_THRESHOLD_6_FIGURES NUMERICAL_PRECISION_THRESHOLD_6_FIGURES
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { CreateOrderDto } from '@ghostfolio/common/dtos'; import { CreateOrderDto } from '@ghostfolio/common/dtos';
import { DATE_FORMAT, downloadAsFile } from '@ghostfolio/common/helper'; import {
DATE_FORMAT,
downloadAsFile,
getCountryName
} from '@ghostfolio/common/helper';
import { import {
Activity, Activity,
DataProviderInfo, DataProviderInfo,
@ -121,6 +125,7 @@ export class GfHoldingDetailDialogComponent implements OnInit {
public dividendInBaseCurrencyPrecision = 2; public dividendInBaseCurrencyPrecision = 2;
public dividendYieldPercentWithCurrencyEffect: number; public dividendYieldPercentWithCurrencyEffect: number;
public feeInBaseCurrency: number; public feeInBaseCurrency: number;
public getCountryName = getCountryName;
public hasPermissionToCreateOwnTag: boolean; public hasPermissionToCreateOwnTag: boolean;
public hasPermissionToReadMarketDataOfOwnAssetProfile: boolean; public hasPermissionToReadMarketDataOfOwnAssetProfile: boolean;
public historicalDataItems: LineChartItem[]; public historicalDataItems: LineChartItem[];
@ -157,6 +162,7 @@ export class GfHoldingDetailDialogComponent implements OnInit {
public SymbolProfile: EnhancedSymbolProfile; public SymbolProfile: EnhancedSymbolProfile;
public tags: Tag[]; public tags: Tag[];
public tagsAvailable: Tag[]; public tagsAvailable: Tag[];
public translate = translate;
public user: User; public user: User;
public value: number; public value: number;
@ -433,7 +439,10 @@ export class GfHoldingDetailDialogComponent implements OnInit {
if (SymbolProfile?.countries?.length > 0) { if (SymbolProfile?.countries?.length > 0) {
for (const country of SymbolProfile.countries) { for (const country of SymbolProfile.countries) {
this.countries[country.code] = { this.countries[country.code] = {
name: country.name, name: getCountryName({
code: country.code,
locale: this.data.locale
}),
value: country.weight value: country.weight
}; };
} }
@ -442,7 +451,7 @@ export class GfHoldingDetailDialogComponent implements OnInit {
if (SymbolProfile?.sectors?.length > 0) { if (SymbolProfile?.sectors?.length > 0) {
for (const sector of SymbolProfile.sectors) { for (const sector of SymbolProfile.sectors) {
this.sectors[sector.name] = { this.sectors[sector.name] = {
name: sector.name, name: translate(sector.name),
value: sector.weight value: sector.weight
}; };
} }

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

@ -262,7 +262,7 @@
i18n i18n
size="medium" size="medium"
[locale]="data.locale" [locale]="data.locale"
[value]="SymbolProfile.sectors[0].name" [value]="translate(SymbolProfile.sectors[0].name)"
>Sector</gf-value >Sector</gf-value
> >
</div> </div>
@ -272,7 +272,12 @@
i18n i18n
size="medium" size="medium"
[locale]="data.locale" [locale]="data.locale"
[value]="SymbolProfile.countries[0].name" [value]="
getCountryName({
code: SymbolProfile.countries[0].code,
locale: data.locale
})
"
>Country</gf-value >Country</gf-value
> >
</div> </div>

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

@ -8,6 +8,7 @@ import {
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { GfBenchmarkComponent } from '@ghostfolio/ui/benchmark'; import { GfBenchmarkComponent } from '@ghostfolio/ui/benchmark';
import { GfFabComponent } from '@ghostfolio/ui/fab';
import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator'; import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator';
import { DataService } from '@ghostfolio/ui/services'; import { DataService } from '@ghostfolio/ui/services';
@ -22,12 +23,8 @@ import {
OnInit OnInit
} from '@angular/core'; } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog'; import { MatDialog } from '@angular/material/dialog';
import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { IonIcon } from '@ionic/angular/standalone';
import { addIcons } from 'ionicons';
import { addOutline } from 'ionicons/icons';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
import { GfCreateWatchlistItemDialogComponent } from './create-watchlist-item-dialog/create-watchlist-item-dialog.component'; import { GfCreateWatchlistItemDialogComponent } from './create-watchlist-item-dialog/create-watchlist-item-dialog.component';
@ -37,9 +34,8 @@ import { CreateWatchlistItemDialogParams } from './create-watchlist-item-dialog/
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ imports: [
GfBenchmarkComponent, GfBenchmarkComponent,
GfFabComponent,
GfPremiumIndicatorComponent, GfPremiumIndicatorComponent,
IonIcon,
MatButtonModule,
RouterModule RouterModule
], ],
schemas: [CUSTOM_ELEMENTS_SCHEMA], schemas: [CUSTOM_ELEMENTS_SCHEMA],
@ -108,8 +104,6 @@ export class GfHomeWatchlistComponent implements OnInit {
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
} }
}); });
addIcons({ addOutline });
} }
public ngOnInit() { public ngOnInit() {

12
apps/client/src/app/components/home-watchlist/home-watchlist.html

@ -22,15 +22,5 @@
</div> </div>
</div> </div>
@if (!hasImpersonationId && hasPermissionToCreateWatchlistItem) { @if (!hasImpersonationId && hasPermissionToCreateWatchlistItem) {
<div class="fab-container"> <gf-fab [queryParams]="{ createWatchlistItemDialog: true }" />
<a
class="align-items-center d-flex justify-content-center"
color="primary"
mat-fab
[queryParams]="{ createWatchlistItemDialog: true }"
[routerLink]="[]"
>
<ion-icon name="add-outline" size="large" />
</a>
</div>
} }

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

@ -4,6 +4,7 @@ import { CreateAccessDto } from '@ghostfolio/common/dtos';
import { ConfirmationDialogType } from '@ghostfolio/common/enums'; import { ConfirmationDialogType } from '@ghostfolio/common/enums';
import { Access, User } from '@ghostfolio/common/interfaces'; import { Access, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { GfFabComponent } from '@ghostfolio/ui/fab';
import { NotificationService } from '@ghostfolio/ui/notifications'; import { NotificationService } from '@ghostfolio/ui/notifications';
import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator'; import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator';
import { DataService } from '@ghostfolio/ui/services'; import { DataService } from '@ghostfolio/ui/services';
@ -42,9 +43,9 @@ import { CreateOrUpdateAccessDialogParams } from './create-or-update-access-dial
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'has-fab' },
imports: [ imports: [
GfAccessTableComponent, GfAccessTableComponent,
GfFabComponent,
GfPremiumIndicatorComponent, GfPremiumIndicatorComponent,
IonIcon, IonIcon,
MatButtonModule, MatButtonModule,

12
apps/client/src/app/components/user-account-access/user-account-access.html

@ -69,16 +69,6 @@
(accessToUpdate)="onUpdateAccess($event)" (accessToUpdate)="onUpdateAccess($event)"
/> />
@if (hasPermissionToCreateAccess) { @if (hasPermissionToCreateAccess) {
<div class="fab-container"> <gf-fab [queryParams]="{ createDialog: true }" />
<a
class="align-items-center d-flex justify-content-center"
color="primary"
mat-fab
[queryParams]="{ createDialog: true }"
[routerLink]="[]"
>
<ion-icon name="add-outline" size="large" />
</a>
</div>
} }
</div> </div>

4
apps/client/src/app/components/user-detail-dialog/user-detail-dialog.component.scss

@ -4,4 +4,8 @@
.mat-mdc-dialog-content { .mat-mdc-dialog-content {
max-height: unset; max-height: unset;
} }
.mat-mdc-dialog-title {
padding-right: 0.5rem !important;
}
} }

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

@ -1,6 +1,6 @@
<div class="d-flex justify-content-end"> <div class="align-items-center d-flex justify-content-end" mat-dialog-title>
<button <button
class="mx-1 no-min-width px-2" class="ml-1 no-min-width px-2"
mat-button mat-button
type="button" type="button"
[matMenuTriggerFor]="userDetailActionsMenu" [matMenuTriggerFor]="userDetailActionsMenu"

12
apps/client/src/app/core/auth.guard.ts

@ -3,7 +3,7 @@ import { UserService } from '@ghostfolio/client/services/user/user.service';
import { internalRoutes, publicRoutes } from '@ghostfolio/common/routes/routes'; import { internalRoutes, publicRoutes } from '@ghostfolio/common/routes/routes';
import { DataService } from '@ghostfolio/ui/services'; import { DataService } from '@ghostfolio/ui/services';
import { Injectable } from '@angular/core'; import { inject, Injectable } from '@angular/core';
import { import {
ActivatedRouteSnapshot, ActivatedRouteSnapshot,
Router, Router,
@ -14,12 +14,10 @@ import { catchError } from 'rxjs/operators';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class AuthGuard { export class AuthGuard {
public constructor( private readonly dataService = inject(DataService);
private dataService: DataService, private readonly router = inject(Router);
private router: Router, private readonly settingsStorageService = inject(SettingsStorageService);
private settingsStorageService: SettingsStorageService, private readonly userService = inject(UserService);
private userService: UserService
) {}
canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot) { canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot) {
const utmSource = route.queryParams?.utm_source; const utmSource = route.queryParams?.utm_source;

16
apps/client/src/app/core/auth.interceptor.ts

@ -13,20 +13,20 @@ import {
HttpInterceptor, HttpInterceptor,
HttpRequest HttpRequest
} from '@angular/common/http'; } from '@angular/common/http';
import { Injectable } from '@angular/core'; import { inject, Injectable } from '@angular/core';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
@Injectable() @Injectable()
export class AuthInterceptor implements HttpInterceptor { export class AuthInterceptor implements HttpInterceptor {
public constructor( private readonly impersonationStorageService = inject(
private impersonationStorageService: ImpersonationStorageService, ImpersonationStorageService
private tokenStorageService: TokenStorageService );
) {} private readonly tokenStorageService = inject(TokenStorageService);
public intercept( public intercept<T>(
req: HttpRequest<any>, req: HttpRequest<T>,
next: HttpHandler next: HttpHandler
): Observable<HttpEvent<any>> { ): Observable<HttpEvent<T>> {
let request = req; let request = req;
if (request.headers.has(HEADER_KEY_SKIP_INTERCEPTOR)) { if (request.headers.has(HEADER_KEY_SKIP_INTERCEPTOR)) {

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

@ -12,7 +12,7 @@ import {
HttpInterceptor, HttpInterceptor,
HttpRequest HttpRequest
} from '@angular/common/http'; } from '@angular/common/http';
import { Injectable } from '@angular/core'; import { inject, Injectable } from '@angular/core';
import { import {
MatSnackBar, MatSnackBar,
MatSnackBarRef, MatSnackBarRef,
@ -22,31 +22,28 @@ import { Router } from '@angular/router';
import { StatusCodes } from 'http-status-codes'; import { StatusCodes } from 'http-status-codes';
import ms from 'ms'; import ms from 'ms';
import { Observable, throwError } from 'rxjs'; import { Observable, throwError } from 'rxjs';
import { catchError, tap } from 'rxjs/operators'; import { catchError } from 'rxjs/operators';
@Injectable() @Injectable()
export class HttpResponseInterceptor implements HttpInterceptor { export class HttpResponseInterceptor implements HttpInterceptor {
public info: InfoItem; private readonly info: InfoItem;
public snackBarRef: MatSnackBarRef<TextOnlySnackBar>; private snackBarRef: MatSnackBarRef<TextOnlySnackBar> | undefined;
public constructor( private readonly dataService = inject(DataService);
private dataService: DataService, private readonly router = inject(Router);
private router: Router, private readonly snackBar = inject(MatSnackBar);
private snackBar: MatSnackBar, private readonly userService = inject(UserService);
private userService: UserService, private readonly webAuthnService = inject(WebAuthnService);
private webAuthnService: WebAuthnService
) { public constructor() {
this.info = this.dataService.fetchInfo(); this.info = this.dataService.fetchInfo();
} }
public intercept( public intercept<T>(
request: HttpRequest<any>, request: HttpRequest<T>,
next: HttpHandler next: HttpHandler
): Observable<HttpEvent<any>> { ): Observable<HttpEvent<T>> {
return next.handle(request).pipe( return next.handle(request).pipe(
tap((event: HttpEvent<any>) => {
return event;
}),
catchError((error: HttpErrorResponse) => { catchError((error: HttpErrorResponse) => {
if (error.status === StatusCodes.FORBIDDEN) { if (error.status === StatusCodes.FORBIDDEN) {
if (!this.snackBarRef) { if (!this.snackBarRef) {
@ -61,7 +58,7 @@ export class HttpResponseInterceptor implements HttpInterceptor {
} }
); );
} else if ( } else if (
!error.url.includes(internalRoutes.auth.routerLink.join('')) !error.url?.includes(internalRoutes.auth.routerLink.join(''))
) { ) {
this.snackBarRef = this.snackBar.open( this.snackBarRef = this.snackBar.open(
$localize`This action is not allowed.`, $localize`This action is not allowed.`,
@ -72,11 +69,11 @@ export class HttpResponseInterceptor implements HttpInterceptor {
); );
} }
this.snackBarRef.afterDismissed().subscribe(() => { this.snackBarRef?.afterDismissed().subscribe(() => {
this.snackBarRef = undefined; this.snackBarRef = undefined;
}); });
this.snackBarRef.onAction().subscribe(() => { this.snackBarRef?.onAction().subscribe(() => {
this.router.navigate(publicRoutes.pricing.routerLink); this.router.navigate(publicRoutes.pricing.routerLink);
}); });
} }
@ -92,11 +89,11 @@ export class HttpResponseInterceptor implements HttpInterceptor {
} }
); );
this.snackBarRef.afterDismissed().subscribe(() => { this.snackBarRef?.afterDismissed().subscribe(() => {
this.snackBarRef = undefined; this.snackBarRef = undefined;
}); });
this.snackBarRef.onAction().subscribe(() => { this.snackBarRef?.onAction().subscribe(() => {
window.location.reload(); window.location.reload();
}); });
} }
@ -106,12 +103,12 @@ export class HttpResponseInterceptor implements HttpInterceptor {
$localize`Oops! It looks like you’re making too many requests. Please slow down a bit.` $localize`Oops! It looks like you’re making too many requests. Please slow down a bit.`
); );
this.snackBarRef.afterDismissed().subscribe(() => { this.snackBarRef?.afterDismissed().subscribe(() => {
this.snackBarRef = undefined; this.snackBarRef = undefined;
}); });
} }
} else if (error.status === StatusCodes.UNAUTHORIZED) { } else if (error.status === StatusCodes.UNAUTHORIZED) {
if (!error.url.includes('/data-providers/ghostfolio/status')) { if (!error.url?.includes('/data-providers/ghostfolio/status')) {
if (this.webAuthnService.isEnabled()) { if (this.webAuthnService.isEnabled()) {
this.router.navigate(internalRoutes.webauthn.routerLink); this.router.navigate(internalRoutes.webauthn.routerLink);
} else { } else {

24
apps/client/src/app/directives/file-drop/file-drop.directive.ts

@ -1,28 +1,34 @@
import { Directive, EventEmitter, HostListener, Output } from '@angular/core'; import { Directive, output } from '@angular/core';
@Directive({ @Directive({
host: {
'(dragenter)': 'onDragEnter($event)',
'(dragover)': 'onDragOver($event)',
'(drop)': 'onDrop($event)'
},
selector: '[gfFileDrop]' selector: '[gfFileDrop]'
}) })
export class GfFileDropDirective { export class GfFileDropDirective {
@Output() filesDropped = new EventEmitter<FileList>(); public readonly filesDropped = output<FileList>();
@HostListener('dragenter', ['$event']) onDragEnter(event: DragEvent) { public onDragEnter(event: DragEvent) {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
} }
@HostListener('dragover', ['$event']) onDragOver(event: DragEvent) { public onDragOver(event: DragEvent) {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
} }
@HostListener('drop', ['$event']) onDrop(event: DragEvent) { public onDrop(event: DragEvent) {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
// Prevent the browser's default behavior for handling the file drop if (event.dataTransfer) {
event.dataTransfer.dropEffect = 'copy'; // Prevent the browser's default behavior for handling the file drop
event.dataTransfer.dropEffect = 'copy';
this.filesDropped.emit(event.dataTransfer.files); this.filesDropped.emit(event.dataTransfer.files);
}
} }
} }

10
apps/client/src/app/pages/accounts/accounts-page.component.ts

@ -10,6 +10,7 @@ import {
import { User } from '@ghostfolio/common/interfaces'; import { User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { GfAccountsTableComponent } from '@ghostfolio/ui/accounts-table'; import { GfAccountsTableComponent } from '@ghostfolio/ui/accounts-table';
import { GfFabComponent } from '@ghostfolio/ui/fab';
import { NotificationService } from '@ghostfolio/ui/notifications'; import { NotificationService } from '@ghostfolio/ui/notifications';
import { DataService } from '@ghostfolio/ui/services'; import { DataService } from '@ghostfolio/ui/services';
@ -20,12 +21,9 @@ import {
OnInit OnInit
} from '@angular/core'; } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog'; import { MatDialog } from '@angular/material/dialog';
import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { Account as AccountModel } from '@prisma/client'; import { Account as AccountModel } from '@prisma/client';
import { addIcons } from 'ionicons';
import { addOutline } from 'ionicons/icons';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
import { EMPTY, Subscription } from 'rxjs'; import { EMPTY, Subscription } from 'rxjs';
import { catchError } from 'rxjs/operators'; import { catchError } from 'rxjs/operators';
@ -36,8 +34,8 @@ import { TransferBalanceDialogParams } from './transfer-balance/interfaces/inter
import { GfTransferBalanceDialogComponent } from './transfer-balance/transfer-balance-dialog.component'; import { GfTransferBalanceDialogComponent } from './transfer-balance/transfer-balance-dialog.component';
@Component({ @Component({
host: { class: 'has-fab page' }, host: { class: 'page' },
imports: [GfAccountsTableComponent, MatButtonModule, RouterModule], imports: [GfAccountsTableComponent, GfFabComponent, RouterModule],
selector: 'gf-accounts-page', selector: 'gf-accounts-page',
styleUrls: ['./accounts-page.scss'], styleUrls: ['./accounts-page.scss'],
templateUrl: './accounts-page.html' templateUrl: './accounts-page.html'
@ -90,8 +88,6 @@ export class GfAccountsPageComponent implements OnInit {
this.openTransferBalanceDialog(); this.openTransferBalanceDialog();
} }
}); });
addIcons({ addOutline });
} }
public ngOnInit() { public ngOnInit() {

12
apps/client/src/app/pages/accounts/accounts-page.html

@ -26,16 +26,6 @@
hasPermissionToCreateAccount && hasPermissionToCreateAccount &&
!user.settings.isRestrictedView !user.settings.isRestrictedView
) { ) {
<div class="fab-container"> <gf-fab [queryParams]="{ createDialog: true }" />
<a
class="align-items-center d-flex justify-content-center"
color="primary"
mat-fab
[queryParams]="{ createDialog: true }"
[routerLink]="[]"
>
<ion-icon name="add-outline" size="large" />
</a>
</div>
} }
</div> </div>

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

@ -12,6 +12,7 @@ import {
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { DateRange } from '@ghostfolio/common/types'; import { DateRange } from '@ghostfolio/common/types';
import { GfActivitiesTableComponent } from '@ghostfolio/ui/activities-table'; import { GfActivitiesTableComponent } from '@ghostfolio/ui/activities-table';
import { GfFabComponent } from '@ghostfolio/ui/fab';
import { DataService } from '@ghostfolio/ui/services'; import { DataService } from '@ghostfolio/ui/services';
import { import {
@ -21,17 +22,13 @@ import {
OnInit OnInit
} from '@angular/core'; } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog'; import { MatDialog } from '@angular/material/dialog';
import { PageEvent } from '@angular/material/paginator'; import { PageEvent } from '@angular/material/paginator';
import { MatSnackBarModule } from '@angular/material/snack-bar'; import { MatSnackBarModule } from '@angular/material/snack-bar';
import { Sort, SortDirection } from '@angular/material/sort'; import { Sort, SortDirection } from '@angular/material/sort';
import { MatTableDataSource } from '@angular/material/table'; import { MatTableDataSource } from '@angular/material/table';
import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { IonIcon } from '@ionic/angular/standalone';
import { format, parseISO } from 'date-fns'; import { format, parseISO } from 'date-fns';
import { addIcons } from 'ionicons';
import { addOutline } from 'ionicons/icons';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
import { Subscription } from 'rxjs'; import { Subscription } from 'rxjs';
@ -41,11 +38,9 @@ import { GfImportActivitiesDialogComponent } from './import-activities-dialog/im
import { ImportActivitiesDialogParams } from './import-activities-dialog/interfaces/interfaces'; import { ImportActivitiesDialogParams } from './import-activities-dialog/interfaces/interfaces';
@Component({ @Component({
host: { class: 'has-fab' },
imports: [ imports: [
GfActivitiesTableComponent, GfActivitiesTableComponent,
IonIcon, GfFabComponent,
MatButtonModule,
MatSnackBarModule, MatSnackBarModule,
RouterModule RouterModule
], ],
@ -107,8 +102,6 @@ export class GfActivitiesPageComponent implements OnInit {
} }
} }
}); });
addIcons({ addOutline });
} }
public ngOnInit() { public ngOnInit() {

14
apps/client/src/app/pages/portfolio/activities/activities-page.html

@ -1,5 +1,5 @@
<div class="container"> <div class="container">
<div class="mb-3 row"> <div class="row">
<div class="col"> <div class="col">
<h1 class="d-none d-sm-block h3 mb-3 text-center" i18n>Activities</h1> <h1 class="d-none d-sm-block h3 mb-3 text-center" i18n>Activities</h1>
<gf-activities-table <gf-activities-table
@ -43,16 +43,6 @@
hasPermissionToCreateActivity && hasPermissionToCreateActivity &&
!user.settings.isRestrictedView !user.settings.isRestrictedView
) { ) {
<div class="fab-container"> <gf-fab [queryParams]="{ createDialog: true }" />
<a
class="align-items-center d-flex justify-content-center"
color="primary"
mat-fab
[queryParams]="{ createDialog: true }"
[routerLink]="[]"
>
<ion-icon name="add-outline" size="large" />
</a>
</div>
} }
</div> </div>

2
apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts

@ -139,7 +139,7 @@ export class GfCreateOrUpdateActivityDialogComponent {
return !['CASH'].includes(assetProfile.assetSubClass); return !['CASH'].includes(assetProfile.assetSubClass);
}) })
.sort((a, b) => { .sort((a, b) => {
return a.name?.localeCompare(b.name); return a.assetProfile.name?.localeCompare(b.assetProfile.name);
}) })
.map(({ assetProfile }) => { .map(({ assetProfile }) => {
return { return {

3
apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.component.ts

@ -226,7 +226,8 @@ export class GfImportActivitiesDialogComponent {
this.assetProfileForm.controls.assetProfileIdentifier.disable(); this.assetProfileForm.controls.assetProfileIdentifier.disable();
const { dataSource, symbol } = const { dataSource, symbol } =
this.assetProfileForm.controls.assetProfileIdentifier.value ?? {}; this.assetProfileForm.controls.assetProfileIdentifier.value
?.assetProfile ?? {};
if (!dataSource || !symbol) { if (!dataSource || !symbol) {
return; return;

63
apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts

@ -3,7 +3,7 @@ import { AccountDetailDialogParams } from '@ghostfolio/client/components/account
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { MAX_TOP_HOLDINGS, UNKNOWN_KEY } from '@ghostfolio/common/config'; import { MAX_TOP_HOLDINGS, UNKNOWN_KEY } from '@ghostfolio/common/config';
import { prettifySymbol } from '@ghostfolio/common/helper'; import { getCountryName, prettifySymbol } from '@ghostfolio/common/helper';
import { import {
AssetProfileIdentifier, AssetProfileIdentifier,
HoldingWithParents, HoldingWithParents,
@ -73,15 +73,14 @@ export class GfAllocationsPageComponent implements OnInit {
public hasImpersonationId: boolean; public hasImpersonationId: boolean;
public holdings: { public holdings: {
[symbol: string]: Pick< [symbol: string]: Pick<
PortfolioPosition, PortfolioPosition['assetProfile'],
| 'assetClass' | 'assetClass'
| 'assetClassLabel' | 'assetClassLabel'
| 'assetSubClass' | 'assetSubClass'
| 'assetSubClassLabel' | 'assetSubClassLabel'
| 'currency' | 'currency'
| 'exchange'
| 'name' | 'name'
> & { etfProvider: string; value: number }; > & { etfProvider: string; exchange?: string; value: number };
}; };
public isLoading = false; public isLoading = false;
public markets: { public markets: {
@ -206,7 +205,7 @@ export class GfAllocationsPageComponent implements OnInit {
assetSubClass, assetSubClass,
name name
}: { }: {
assetSubClass: PortfolioPosition['assetSubClass']; assetSubClass: PortfolioPosition['assetProfile']['assetSubClass'];
name: string; name: string;
}) { }) {
if (assetSubClass === 'ETF') { if (assetSubClass === 'ETF') {
@ -333,25 +332,28 @@ export class GfAllocationsPageComponent implements OnInit {
this.holdings[symbol] = { this.holdings[symbol] = {
value, value,
assetClass: position.assetClass || (UNKNOWN_KEY as AssetClass), assetClass:
assetClassLabel: position.assetClassLabel || UNKNOWN_KEY, position.assetProfile.assetClass || (UNKNOWN_KEY as AssetClass),
assetSubClass: position.assetSubClass || (UNKNOWN_KEY as AssetSubClass), assetClassLabel: position.assetProfile.assetClassLabel || UNKNOWN_KEY,
assetSubClassLabel: position.assetSubClassLabel || UNKNOWN_KEY, assetSubClass:
currency: position.currency, position.assetProfile.assetSubClass || (UNKNOWN_KEY as AssetSubClass),
assetSubClassLabel:
position.assetProfile.assetSubClassLabel || UNKNOWN_KEY,
currency: position.assetProfile.currency,
etfProvider: this.extractEtfProvider({ etfProvider: this.extractEtfProvider({
assetSubClass: position.assetSubClass, assetSubClass: position.assetProfile.assetSubClass,
name: position.name name: position.assetProfile.name
}), }),
exchange: position.exchange, exchange: position.exchange,
name: position.name name: position.assetProfile.name
}; };
if (position.assetClass !== AssetClass.LIQUIDITY) { if (position.assetProfile.assetClass !== AssetClass.LIQUIDITY) {
// Prepare analysis data by continents, countries, holdings and sectors except for liquidity // Prepare analysis data by continents, countries, holdings and sectors except for liquidity
if (position.countries.length > 0) { if (position.assetProfile.countries.length > 0) {
for (const country of position.countries) { for (const country of position.assetProfile.countries) {
const { code, continent, name, weight } = country; const { code, continent, weight } = country;
if (this.continents[continent]?.value) { if (this.continents[continent]?.value) {
this.continents[continent].value += this.continents[continent].value +=
@ -361,7 +363,7 @@ export class GfAllocationsPageComponent implements OnInit {
: position.valueInPercentage); : position.valueInPercentage);
} else { } else {
this.continents[continent] = { this.continents[continent] = {
name: continent, name: translate(continent),
value: value:
weight * weight *
(isNumber(position.valueInBaseCurrency) (isNumber(position.valueInBaseCurrency)
@ -378,7 +380,10 @@ export class GfAllocationsPageComponent implements OnInit {
: position.valueInPercentage); : position.valueInPercentage);
} else { } else {
this.countries[code] = { this.countries[code] = {
name, name: getCountryName({
code,
locale: this.user?.settings?.locale
}),
value: value:
weight * weight *
(isNumber(position.valueInBaseCurrency) (isNumber(position.valueInBaseCurrency)
@ -401,12 +406,12 @@ export class GfAllocationsPageComponent implements OnInit {
: this.portfolioDetails.holdings[symbol].valueInPercentage; : this.portfolioDetails.holdings[symbol].valueInPercentage;
} }
if (position.holdings.length > 0) { if (position.assetProfile.holdings.length > 0) {
for (const { for (const {
allocationInPercentage, allocationInPercentage,
name, name,
valueInBaseCurrency valueInBaseCurrency
} of position.holdings) { } of position.assetProfile.holdings) {
const normalizedAssetName = this.normalizeAssetName(name); const normalizedAssetName = this.normalizeAssetName(name);
if (this.topHoldingsMap[normalizedAssetName]?.value) { if (this.topHoldingsMap[normalizedAssetName]?.value) {
@ -428,8 +433,8 @@ export class GfAllocationsPageComponent implements OnInit {
} }
} }
if (position.sectors.length > 0) { if (position.assetProfile.sectors.length > 0) {
for (const sector of position.sectors) { for (const sector of position.assetProfile.sectors) {
const { name, weight } = sector; const { name, weight } = sector;
if (this.sectors[name]?.value) { if (this.sectors[name]?.value) {
@ -440,7 +445,7 @@ export class GfAllocationsPageComponent implements OnInit {
: position.valueInPercentage); : position.valueInPercentage);
} else { } else {
this.sectors[name] = { this.sectors[name] = {
name, name: translate(name),
value: value:
weight * weight *
(isNumber(position.valueInBaseCurrency) (isNumber(position.valueInBaseCurrency)
@ -463,8 +468,8 @@ export class GfAllocationsPageComponent implements OnInit {
} }
this.symbols[prettifySymbol(symbol)] = { this.symbols[prettifySymbol(symbol)] = {
dataSource: position.dataSource, dataSource: position.assetProfile.dataSource,
name: position.name, name: position.assetProfile.name,
symbol: prettifySymbol(symbol), symbol: prettifySymbol(symbol),
value: isNumber(position.valueInBaseCurrency) value: isNumber(position.valueInBaseCurrency)
? position.valueInBaseCurrency ? position.valueInBaseCurrency
@ -517,8 +522,8 @@ export class GfAllocationsPageComponent implements OnInit {
this.totalValueInEtf > 0 ? value / this.totalValueInEtf : 0, this.totalValueInEtf > 0 ? value / this.totalValueInEtf : 0,
parents: Object.entries(this.portfolioDetails.holdings) parents: Object.entries(this.portfolioDetails.holdings)
.map(([symbol, holding]) => { .map(([symbol, holding]) => {
if (holding.holdings.length > 0) { if (holding.assetProfile.holdings.length > 0) {
const currentParentHolding = holding.holdings.find( const currentParentHolding = holding.assetProfile.holdings.find(
(parentHolding) => { (parentHolding) => {
return ( return (
this.normalizeAssetName(parentHolding.name) === this.normalizeAssetName(parentHolding.name) ===
@ -531,7 +536,7 @@ export class GfAllocationsPageComponent implements OnInit {
? { ? {
allocationInPercentage: allocationInPercentage:
currentParentHolding.valueInBaseCurrency / value, currentParentHolding.valueInBaseCurrency / value,
name: holding.name, name: holding.assetProfile.name,
position: holding, position: holding,
symbol: prettifySymbol(symbol), symbol: prettifySymbol(symbol),
valueInBaseCurrency: valueInBaseCurrency:

153
apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts

@ -2,7 +2,10 @@ import { GfBenchmarkComparatorComponent } from '@ghostfolio/client/components/be
import { GfInvestmentChartComponent } from '@ghostfolio/client/components/investment-chart/investment-chart.component'; import { GfInvestmentChartComponent } from '@ghostfolio/client/components/investment-chart/investment-chart.component';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { NUMERICAL_PRECISION_THRESHOLD_6_FIGURES } from '@ghostfolio/common/config'; import {
DEFAULT_DATE_RANGE,
NUMERICAL_PRECISION_THRESHOLD_6_FIGURES
} from '@ghostfolio/common/config';
import { import {
HistoricalDataItem, HistoricalDataItem,
InvestmentItem, InvestmentItem,
@ -24,9 +27,12 @@ import { Clipboard } from '@angular/cdk/clipboard';
import { import {
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
computed,
DestroyRef, DestroyRef,
inject,
OnInit, OnInit,
ViewChild signal,
viewChild
} from '@angular/core'; } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
@ -64,53 +70,57 @@ import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
templateUrl: './analysis-page.html' templateUrl: './analysis-page.html'
}) })
export class GfAnalysisPageComponent implements OnInit { export class GfAnalysisPageComponent implements OnInit {
@ViewChild(MatMenuTrigger) actionsMenuButton!: MatMenuTrigger; protected benchmark?: Partial<SymbolProfile>;
protected benchmarkDataItems: HistoricalDataItem[] = [];
public benchmark: Partial<SymbolProfile>; protected readonly benchmarks: Partial<SymbolProfile>[];
public benchmarkDataItems: HistoricalDataItem[] = []; protected bottom3: PortfolioPosition[];
public benchmarks: Partial<SymbolProfile>[]; protected dividendsByGroup: InvestmentItem[];
public bottom3: PortfolioPosition[]; protected readonly dividendTimelineDataLabel = $localize`Dividend`;
public deviceType: string; protected hasImpersonationId: boolean;
public dividendsByGroup: InvestmentItem[]; protected hasPermissionToReadAiPrompt: boolean;
public dividendTimelineDataLabel = $localize`Dividend`; protected investments: InvestmentItem[];
public firstOrderDate: Date; protected readonly investmentTimelineDataLabel = $localize`Investment`;
public hasImpersonationId: boolean; protected investmentsByGroup: InvestmentItem[];
public hasPermissionToReadAiPrompt: boolean; protected isLoadingAnalysisPrompt: boolean;
public investments: InvestmentItem[]; protected isLoadingBenchmarkComparator: boolean;
public investmentTimelineDataLabel = $localize`Investment`; protected isLoadingDividendTimelineChart: boolean;
public investmentsByGroup: InvestmentItem[]; protected isLoadingInvestmentChart: boolean;
public isLoadingAnalysisPrompt: boolean; protected isLoadingInvestmentTimelineChart: boolean;
public isLoadingBenchmarkComparator: boolean; protected isLoadingPortfolioPrompt: boolean;
public isLoadingDividendTimelineChart: boolean; protected readonly mode = signal<GroupBy>('month');
public isLoadingInvestmentChart: boolean; protected readonly modeOptions: ToggleOption[] = [
public isLoadingInvestmentTimelineChart: boolean;
public isLoadingPortfolioPrompt: boolean;
public mode: GroupBy = 'month';
public modeOptions: ToggleOption[] = [
{ label: $localize`Monthly`, value: 'month' }, { label: $localize`Monthly`, value: 'month' },
{ label: $localize`Yearly`, value: 'year' } { label: $localize`Yearly`, value: 'year' }
]; ];
public performance: PortfolioPerformance; protected performance: PortfolioPerformance;
public performanceDataItems: HistoricalDataItem[]; protected performanceDataItems: HistoricalDataItem[];
public performanceDataItemsInPercentage: HistoricalDataItem[]; protected performanceDataItemsInPercentage: HistoricalDataItem[];
public portfolioEvolutionDataLabel = $localize`Investment`; protected readonly portfolioEvolutionDataLabel = $localize`Investment`;
public precision = 2; protected precision = 2;
public streaks: PortfolioInvestmentsResponse['streaks']; protected streaks: PortfolioInvestmentsResponse['streaks'];
public top3: PortfolioPosition[]; protected top3: PortfolioPosition[];
public unitCurrentStreak: string; protected unitCurrentStreak: string;
public unitLongestStreak: string; protected unitLongestStreak: string;
public user: User; protected user: User;
public constructor( private readonly actionsMenuButton = viewChild.required(MatMenuTrigger);
private changeDetectorRef: ChangeDetectorRef, private readonly deviceType = computed(
private clipboard: Clipboard, () => this.deviceDetectorService.deviceInfo().deviceType
private dataService: DataService, );
private destroyRef: DestroyRef, private firstOrderDate: Date;
private deviceDetectorService: DeviceDetectorService,
private impersonationStorageService: ImpersonationStorageService, private readonly changeDetectorRef = inject(ChangeDetectorRef);
private snackBar: MatSnackBar, private readonly clipboard = inject(Clipboard);
private userService: UserService private readonly dataService = inject(DataService);
) { private readonly destroyRef = inject(DestroyRef);
private readonly deviceDetectorService = inject(DeviceDetectorService);
private readonly impersonationStorageService = inject(
ImpersonationStorageService
);
private readonly snackBar = inject(MatSnackBar);
private readonly userService = inject(UserService);
public constructor() {
const { benchmarks } = this.dataService.fetchInfo(); const { benchmarks } = this.dataService.fetchInfo();
this.benchmarks = benchmarks; this.benchmarks = benchmarks;
@ -123,14 +133,16 @@ export class GfAnalysisPageComponent implements OnInit {
? undefined ? undefined
: this.user?.settings?.savingsRate; : this.user?.settings?.savingsRate;
return this.mode === 'year' if (savingsRatePerMonth === undefined) {
return undefined;
}
return this.mode() === 'year'
? savingsRatePerMonth * 12 ? savingsRatePerMonth * 12
: savingsRatePerMonth; : savingsRatePerMonth;
} }
public ngOnInit() { public ngOnInit() {
this.deviceType = this.deviceDetectorService.getDeviceInfo().deviceType;
this.impersonationStorageService this.impersonationStorageService
.onChangeHasImpersonation() .onChangeHasImpersonation()
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
@ -158,7 +170,7 @@ export class GfAnalysisPageComponent implements OnInit {
}); });
} }
public onChangeBenchmark(symbolProfileId: string) { protected onChangeBenchmark(symbolProfileId: string) {
this.dataService this.dataService
.putUserSetting({ benchmark: symbolProfileId }) .putUserSetting({ benchmark: symbolProfileId })
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
@ -174,12 +186,12 @@ export class GfAnalysisPageComponent implements OnInit {
}); });
} }
public onChangeGroupBy(aMode: GroupBy) { protected onChangeGroupBy(aMode: GroupBy) {
this.mode = aMode; this.mode.set(aMode);
this.fetchDividendsAndInvestments(); this.fetchDividendsAndInvestments();
} }
public onCopyPromptToClipboard(mode: AiPromptMode) { protected onCopyPromptToClipboard(mode: AiPromptMode) {
if (mode === 'analysis') { if (mode === 'analysis') {
this.isLoadingAnalysisPrompt = true; this.isLoadingAnalysisPrompt = true;
} else if (mode === 'portfolio') { } else if (mode === 'portfolio') {
@ -210,7 +222,7 @@ export class GfAnalysisPageComponent implements OnInit {
window.open('https://duck.ai', '_blank'); window.open('https://duck.ai', '_blank');
}); });
this.actionsMenuButton.closeMenu(); this.actionsMenuButton().closeMenu();
if (mode === 'analysis') { if (mode === 'analysis') {
this.isLoadingAnalysisPrompt = false; this.isLoadingAnalysisPrompt = false;
@ -227,8 +239,8 @@ export class GfAnalysisPageComponent implements OnInit {
this.dataService this.dataService
.fetchDividends({ .fetchDividends({
filters: this.userService.getFilters(), filters: this.userService.getFilters(),
groupBy: this.mode, groupBy: this.mode(),
range: this.user?.settings?.dateRange range: this.user?.settings?.dateRange ?? DEFAULT_DATE_RANGE
}) })
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ dividends }) => { .subscribe(({ dividends }) => {
@ -242,15 +254,15 @@ export class GfAnalysisPageComponent implements OnInit {
this.dataService this.dataService
.fetchInvestments({ .fetchInvestments({
filters: this.userService.getFilters(), filters: this.userService.getFilters(),
groupBy: this.mode, groupBy: this.mode(),
range: this.user?.settings?.dateRange range: this.user?.settings?.dateRange ?? DEFAULT_DATE_RANGE
}) })
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ investments, streaks }) => { .subscribe(({ investments, streaks }) => {
this.investmentsByGroup = investments; this.investmentsByGroup = investments;
this.streaks = streaks; this.streaks = streaks;
this.unitCurrentStreak = this.unitCurrentStreak =
this.mode === 'year' this.mode() === 'year'
? this.streaks?.currentStreak === 1 ? this.streaks?.currentStreak === 1
? translate('YEAR') ? translate('YEAR')
: translate('YEARS') : translate('YEARS')
@ -258,7 +270,7 @@ export class GfAnalysisPageComponent implements OnInit {
? translate('MONTH') ? translate('MONTH')
: translate('MONTHS'); : translate('MONTHS');
this.unitLongestStreak = this.unitLongestStreak =
this.mode === 'year' this.mode() === 'year'
? this.streaks?.longestStreak === 1 ? this.streaks?.longestStreak === 1
? translate('YEAR') ? translate('YEAR')
: translate('YEARS') : translate('YEARS')
@ -278,7 +290,7 @@ export class GfAnalysisPageComponent implements OnInit {
this.dataService this.dataService
.fetchPortfolioPerformance({ .fetchPortfolioPerformance({
filters: this.userService.getFilters(), filters: this.userService.getFilters(),
range: this.user?.settings?.dateRange range: this.user?.settings?.dateRange ?? DEFAULT_DATE_RANGE
}) })
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ chart, firstOrderDate, performance }) => { .subscribe(({ chart, firstOrderDate, performance }) => {
@ -298,13 +310,16 @@ export class GfAnalysisPageComponent implements OnInit {
valueInPercentage, valueInPercentage,
valueWithCurrencyEffect valueWithCurrencyEffect
} }
] of chart.entries()) { ] of (chart ?? []).entries()) {
// Ignore first item where value is 0
if (index > 0 || this.user?.settings?.dateRange === 'max') { if (index > 0 || this.user?.settings?.dateRange === 'max') {
// Ignore first item where value is 0 if (totalInvestmentValueWithCurrencyEffect !== undefined) {
this.investments.push({ this.investments.push({
date, date,
investment: totalInvestmentValueWithCurrencyEffect investment: totalInvestmentValueWithCurrencyEffect
}); });
}
this.performanceDataItems.push({ this.performanceDataItems.push({
date, date,
value: isNumber(valueWithCurrencyEffect) value: isNumber(valueWithCurrencyEffect)
@ -320,7 +335,7 @@ export class GfAnalysisPageComponent implements OnInit {
} }
if ( if (
this.deviceType === 'mobile' && this.deviceType() === 'mobile' &&
this.performance.currentValueInBaseCurrency >= this.performance.currentValueInBaseCurrency >=
NUMERICAL_PRECISION_THRESHOLD_6_FIGURES NUMERICAL_PRECISION_THRESHOLD_6_FIGURES
) { ) {
@ -387,7 +402,7 @@ export class GfAnalysisPageComponent implements OnInit {
dataSource, dataSource,
symbol, symbol,
filters: this.userService.getFilters(), filters: this.userService.getFilters(),
range: this.user?.settings?.dateRange, range: this.user?.settings?.dateRange ?? DEFAULT_DATE_RANGE,
startDate: this.firstOrderDate startDate: this.firstOrderDate
}) })
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))

24
apps/client/src/app/pages/portfolio/analysis/analysis-page.html

@ -310,13 +310,15 @@
<a <a
class="d-flex" class="d-flex"
[queryParams]="{ [queryParams]="{
dataSource: holding.dataSource, dataSource: holding.assetProfile.dataSource,
holdingDetailDialog: true, holdingDetailDialog: true,
symbol: holding.symbol symbol: holding.assetProfile.symbol
}" }"
[routerLink]="[]" [routerLink]="[]"
> >
<div class="flex-grow-1 mr-2">{{ holding.name }}</div> <div class="flex-grow-1 mr-2">
{{ holding.assetProfile.name }}
</div>
<div class="d-flex justify-content-end"> <div class="d-flex justify-content-end">
<gf-value <gf-value
class="justify-content-end" class="justify-content-end"
@ -359,13 +361,15 @@
<a <a
class="d-flex" class="d-flex"
[queryParams]="{ [queryParams]="{
dataSource: holding.dataSource, dataSource: holding.assetProfile.dataSource,
holdingDetailDialog: true, holdingDetailDialog: true,
symbol: holding.symbol symbol: holding.assetProfile.symbol
}" }"
[routerLink]="[]" [routerLink]="[]"
> >
<div class="flex-grow-1 mr-2">{{ holding.name }}</div> <div class="flex-grow-1 mr-2">
{{ holding.assetProfile.name }}
</div>
<div class="d-flex justify-content-end"> <div class="d-flex justify-content-end">
<gf-value <gf-value
class="justify-content-end" class="justify-content-end"
@ -438,7 +442,7 @@
</div> </div>
<gf-toggle <gf-toggle
class="d-none d-lg-block" class="d-none d-lg-block"
[defaultValue]="mode" [defaultValue]="mode()"
[isLoading]="false" [isLoading]="false"
[options]="modeOptions" [options]="modeOptions"
(valueChange)="onChangeGroupBy($event.value)" (valueChange)="onChangeGroupBy($event.value)"
@ -472,7 +476,7 @@
[benchmarkDataItems]="investmentsByGroup" [benchmarkDataItems]="investmentsByGroup"
[benchmarkDataLabel]="investmentTimelineDataLabel" [benchmarkDataLabel]="investmentTimelineDataLabel"
[currency]="user?.settings?.baseCurrency" [currency]="user?.settings?.baseCurrency"
[groupBy]="mode" [groupBy]="mode()"
[isInPercentage]=" [isInPercentage]="
hasImpersonationId || user.settings.isRestrictedView hasImpersonationId || user.settings.isRestrictedView
" "
@ -497,7 +501,7 @@
</div> </div>
<gf-toggle <gf-toggle
class="d-none d-lg-block" class="d-none d-lg-block"
[defaultValue]="mode" [defaultValue]="mode()"
[isLoading]="false" [isLoading]="false"
[options]="modeOptions" [options]="modeOptions"
(valueChange)="onChangeGroupBy($event.value)" (valueChange)="onChangeGroupBy($event.value)"
@ -509,7 +513,7 @@
[benchmarkDataItems]="dividendsByGroup" [benchmarkDataItems]="dividendsByGroup"
[benchmarkDataLabel]="dividendTimelineDataLabel" [benchmarkDataLabel]="dividendTimelineDataLabel"
[currency]="user?.settings?.baseCurrency" [currency]="user?.settings?.baseCurrency"
[groupBy]="mode" [groupBy]="mode()"
[isInPercentage]=" [isInPercentage]="
hasImpersonationId || user.settings.isRestrictedView hasImpersonationId || user.settings.isRestrictedView
" "

16
apps/client/src/app/pages/public/public-page.component.ts

@ -1,5 +1,5 @@
import { UNKNOWN_KEY } from '@ghostfolio/common/config'; import { UNKNOWN_KEY } from '@ghostfolio/common/config';
import { prettifySymbol } from '@ghostfolio/common/helper'; import { getCountryName, prettifySymbol } from '@ghostfolio/common/helper';
import { import {
InfoItem, InfoItem,
PortfolioPosition, PortfolioPosition,
@ -9,6 +9,7 @@ import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { Market } from '@ghostfolio/common/types'; import { Market } from '@ghostfolio/common/types';
import { GfActivitiesTableComponent } from '@ghostfolio/ui/activities-table/activities-table.component'; import { GfActivitiesTableComponent } from '@ghostfolio/ui/activities-table/activities-table.component';
import { GfHoldingsTableComponent } from '@ghostfolio/ui/holdings-table/holdings-table.component'; import { GfHoldingsTableComponent } from '@ghostfolio/ui/holdings-table/holdings-table.component';
import { translate } from '@ghostfolio/ui/i18n';
import { GfPortfolioProportionChartComponent } from '@ghostfolio/ui/portfolio-proportion-chart/portfolio-proportion-chart.component'; import { GfPortfolioProportionChartComponent } from '@ghostfolio/ui/portfolio-proportion-chart/portfolio-proportion-chart.component';
import { DataService } from '@ghostfolio/ui/services'; import { DataService } from '@ghostfolio/ui/services';
import { GfValueComponent } from '@ghostfolio/ui/value'; import { GfValueComponent } from '@ghostfolio/ui/value';
@ -74,7 +75,10 @@ export class GfPublicPageComponent implements OnInit {
}; };
protected readonly pageSize = Number.MAX_SAFE_INTEGER; protected readonly pageSize = Number.MAX_SAFE_INTEGER;
protected positions: { protected positions: {
[symbol: string]: Pick<PortfolioPosition, 'currency' | 'name'> & { [symbol: string]: Pick<
PortfolioPosition['assetProfile'],
'currency' | 'name'
> & {
value: number; value: number;
}; };
}; };
@ -182,14 +186,14 @@ export class GfPublicPageComponent implements OnInit {
if (position.assetProfile.countries.length > 0) { if (position.assetProfile.countries.length > 0) {
for (const country of position.assetProfile.countries) { for (const country of position.assetProfile.countries) {
const { code, continent, name, weight } = country; const { code, continent, weight } = country;
if (this.continents[continent]?.value) { if (this.continents[continent]?.value) {
this.continents[continent].value += this.continents[continent].value +=
weight * (position.valueInBaseCurrency ?? 0); weight * (position.valueInBaseCurrency ?? 0);
} else { } else {
this.continents[continent] = { this.continents[continent] = {
name: continent, name: translate(continent),
value: value:
weight * weight *
(this.publicPortfolioDetails.holdings[symbol] (this.publicPortfolioDetails.holdings[symbol]
@ -202,7 +206,7 @@ export class GfPublicPageComponent implements OnInit {
weight * (position.valueInBaseCurrency ?? 0); weight * (position.valueInBaseCurrency ?? 0);
} else { } else {
this.countries[code] = { this.countries[code] = {
name, name: getCountryName({ code }),
value: value:
weight * weight *
(this.publicPortfolioDetails.holdings[symbol] (this.publicPortfolioDetails.holdings[symbol]
@ -229,7 +233,7 @@ export class GfPublicPageComponent implements OnInit {
weight * (position.valueInBaseCurrency ?? 0); weight * (position.valueInBaseCurrency ?? 0);
} else { } else {
this.sectors[name] = { this.sectors[name] = {
name, name: translate(name),
value: value:
weight * weight *
(this.publicPortfolioDetails.holdings[symbol] (this.publicPortfolioDetails.holdings[symbol]

17
apps/client/src/app/pages/resources/personal-finance-tools/product-page.component.ts

@ -1,3 +1,4 @@
import { getCountryName } from '@ghostfolio/common/helper';
import { Product } from '@ghostfolio/common/interfaces'; import { Product } from '@ghostfolio/common/interfaces';
import { personalFinanceTools } from '@ghostfolio/common/personal-finance-tools'; import { personalFinanceTools } from '@ghostfolio/common/personal-finance-tools';
import { publicRoutes } from '@ghostfolio/common/routes/routes'; import { publicRoutes } from '@ghostfolio/common/routes/routes';
@ -32,6 +33,7 @@ export class GfProductPageComponent implements OnInit {
) {} ) {}
public ngOnInit() { public ngOnInit() {
const locale = document.documentElement.lang;
const { subscriptionOffer } = this.dataService.fetchInfo(); const { subscriptionOffer } = this.dataService.fetchInfo();
this.price = subscriptionOffer?.price; this.price = subscriptionOffer?.price;
@ -55,18 +57,23 @@ export class GfProductPageComponent implements OnInit {
'Türkçe' 'Türkçe'
], ],
name: 'Ghostfolio', name: 'Ghostfolio',
origin: $localize`Switzerland`, origin: getCountryName({ locale, code: 'CH' }),
regions: [$localize`Global`], regions: [$localize`Global`],
slogan: 'Open Source Wealth Management', slogan: 'Open Source Wealth Management',
useAnonymously: true useAnonymously: true
}; };
this.product2 = personalFinanceTools.find(({ key }) => { this.product2 = {
return key === this.route.snapshot.data['key']; ...personalFinanceTools.find(({ key }) => {
}); return key === this.route.snapshot.data['key'];
})
};
if (this.product2.origin) { if (this.product2.origin) {
this.product2.origin = translate(this.product2.origin); this.product2.origin = getCountryName({
locale,
code: this.product2.origin
});
} }
if (this.product2.regions) { if (this.product2.regions) {

770
apps/client/src/locales/messages.ca.xlf

File diff suppressed because it is too large

770
apps/client/src/locales/messages.de.xlf

File diff suppressed because it is too large

770
apps/client/src/locales/messages.es.xlf

File diff suppressed because it is too large

770
apps/client/src/locales/messages.fr.xlf

File diff suppressed because it is too large

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

Loading…
Cancel
Save