Browse Source

Merge upstream main into account chart click fix

pull/6988/head
Parman Mohammadalizadeh 3 months ago
parent
commit
7e2a5d231e
  1. 80
      CHANGELOG.md
  2. 52
      README.md
  3. 4
      apps/api/src/app/activities/activities.module.ts
  4. 57
      apps/api/src/app/activities/activities.service.ts
  5. 42
      apps/api/src/app/admin/admin.controller.ts
  6. 76
      apps/api/src/app/admin/admin.service.ts
  7. 2
      apps/api/src/app/app.module.ts
  8. 4
      apps/api/src/app/auth/auth.module.ts
  9. 4
      apps/api/src/app/auth/google.strategy.ts
  10. 9
      apps/api/src/app/auth/oidc.strategy.ts
  11. 6
      apps/api/src/app/auth/web-auth.service.ts
  12. 51
      apps/api/src/app/endpoints/asset-profiles/asset-profiles.controller.ts
  13. 13
      apps/api/src/app/endpoints/asset-profiles/asset-profiles.module.ts
  14. 90
      apps/api/src/app/endpoints/asset-profiles/asset-profiles.service.ts
  15. 7
      apps/api/src/app/endpoints/benchmarks/benchmarks.service.ts
  16. 12
      apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.service.ts
  17. 4
      apps/api/src/app/health/health.controller.ts
  18. 4
      apps/api/src/app/import/import.controller.ts
  19. 7
      apps/api/src/app/portfolio/calculator/portfolio-calculator.ts
  20. 2
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts
  21. 6
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts
  22. 156
      apps/api/src/app/portfolio/portfolio.service.spec.ts
  23. 17
      apps/api/src/app/portfolio/portfolio.service.ts
  24. 6
      apps/api/src/app/redis-cache/redis-cache.service.ts
  25. 14
      apps/api/src/app/subscription/subscription.controller.ts
  26. 9
      apps/api/src/app/subscription/subscription.service.ts
  27. 4
      apps/api/src/app/symbol/symbol.service.ts
  28. 12
      apps/api/src/events/asset-profile-changed.listener.ts
  29. 7
      apps/api/src/events/portfolio-changed.listener.ts
  30. 17
      apps/api/src/helper/country.helper.ts
  31. 28
      apps/api/src/helper/sector.helper.ts
  32. 4
      apps/api/src/interceptors/performance-logging/performance-logging.service.ts
  33. 18
      apps/api/src/main.ts
  34. 8
      apps/api/src/middlewares/html-template.middleware.ts
  35. 6
      apps/api/src/services/benchmark/benchmark.service.ts
  36. 1
      apps/api/src/services/configuration/configuration.service.ts
  37. 8
      apps/api/src/services/data-provider/coingecko/coingecko.service.ts
  38. 42
      apps/api/src/services/data-provider/data-enhancer/trackinsight/trackinsight.service.ts
  39. 86
      apps/api/src/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service.ts
  40. 40
      apps/api/src/services/data-provider/data-provider.service.ts
  41. 34
      apps/api/src/services/data-provider/eod-historical-data/eod-historical-data.service.ts
  42. 49
      apps/api/src/services/data-provider/financial-modeling-prep/financial-modeling-prep.service.ts
  43. 12
      apps/api/src/services/data-provider/ghostfolio/ghostfolio.service.ts
  44. 4
      apps/api/src/services/data-provider/google-sheets/google-sheets.service.ts
  45. 9
      apps/api/src/services/data-provider/manual/manual.service.ts
  46. 6
      apps/api/src/services/data-provider/rapid-api/rapid-api.service.ts
  47. 23
      apps/api/src/services/data-provider/yahoo-finance/yahoo-finance.service.ts
  48. 19
      apps/api/src/services/exchange-rate-data/exchange-rate-data.service.ts
  49. 39
      apps/api/src/services/fetch/fetch.service.ts
  50. 8
      apps/api/src/services/i18n/i18n.service.ts
  51. 1
      apps/api/src/services/interfaces/environment.interface.ts
  52. 4
      apps/api/src/services/prisma/prisma.service.ts
  53. 3
      apps/api/src/services/queues/data-gathering/data-gathering.module.ts
  54. 42
      apps/api/src/services/queues/data-gathering/data-gathering.processor.ts
  55. 44
      apps/api/src/services/queues/data-gathering/data-gathering.service.ts
  56. 3
      apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.module.ts
  57. 17
      apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.processor.ts
  58. 3
      apps/api/src/services/queues/statistics-gathering/statistics-gathering.module.ts
  59. 55
      apps/api/src/services/queues/statistics-gathering/statistics-gathering.processor.ts
  60. 85
      apps/api/src/services/symbol-profile/symbol-profile.service.ts
  61. 9
      apps/api/src/services/twitter-bot/twitter-bot.service.ts
  62. 10
      apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts
  63. 1
      apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html
  64. 23
      apps/client/src/app/components/admin-jobs/admin-jobs.component.ts
  65. 9
      apps/client/src/app/components/admin-jobs/admin-jobs.html
  66. 4
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.scss
  67. 10
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts
  68. 19
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html
  69. 20
      apps/client/src/app/components/admin-users/admin-users.component.ts
  70. 2
      apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.html
  71. 2
      apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.ts
  72. 32
      apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts
  73. 9
      apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html
  74. 2
      apps/client/src/app/components/investment-chart/investment-chart.component.html
  75. 3
      apps/client/src/app/components/investment-chart/investment-chart.component.ts
  76. 30
      apps/client/src/app/components/rule/rule-settings-dialog/rule-settings-dialog.component.ts
  77. 32
      apps/client/src/app/components/rule/rule-settings-dialog/rule-settings-dialog.html
  78. 4
      apps/client/src/app/components/rules/rules.component.html
  79. 4
      apps/client/src/app/components/user-detail-dialog/user-detail-dialog.component.scss
  80. 4
      apps/client/src/app/components/user-detail-dialog/user-detail-dialog.html
  81. 55
      apps/client/src/app/pages/admin/admin-page.component.ts
  82. 2
      apps/client/src/app/pages/portfolio/activities/activities-page.html
  83. 5
      apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.html
  84. 39
      apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts
  85. 3
      apps/client/src/app/pages/portfolio/fire/fire-page.component.ts
  86. 11
      apps/client/src/app/pages/portfolio/fire/fire-page.html
  87. 11
      apps/client/src/app/pages/public/public-page.component.ts
  88. 15
      apps/client/src/app/pages/resources/personal-finance-tools/product-page.component.ts
  89. 770
      apps/client/src/locales/messages.ca.xlf
  90. 770
      apps/client/src/locales/messages.de.xlf
  91. 772
      apps/client/src/locales/messages.es.xlf
  92. 770
      apps/client/src/locales/messages.fr.xlf
  93. 770
      apps/client/src/locales/messages.it.xlf
  94. 1000
      apps/client/src/locales/messages.ko.xlf
  95. 770
      apps/client/src/locales/messages.nl.xlf
  96. 770
      apps/client/src/locales/messages.pl.xlf
  97. 770
      apps/client/src/locales/messages.pt.xlf
  98. 770
      apps/client/src/locales/messages.tr.xlf
  99. 856
      apps/client/src/locales/messages.uk.xlf
  100. 734
      apps/client/src/locales/messages.xlf

80
CHANGELOG.md

@ -5,6 +5,86 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## 3.11.0 - 2026-06-14
### Added
- Added support for a click handler in the page tabs component
### Changed
- Improved the styling of the tabs across various dialogs
- Improved the styling of the page tabs component on desktop
- Enabled the _Bull Dashboard_ tab in the admin control panel (experimental)
- Migrated the settings dialog to customize the rule thresholds of the _X-ray_ page from `ngModel` to form control
- Improved the language localization for Spanish (`es`)
- Upgraded `bull-board` from version `7.1.5` to `7.2.1`
- Upgraded `date-fns` from version `4.1.0` to `4.4.0`
### Fixed
- Improved the loading state when customizing the rule thresholds on the _X-ray_ page
## 3.10.0 - 2026-06-13
### Changed
- Improved the dynamic numerical precision for various values in the account detail dialog on mobile
- Improved the dynamic numerical precision for various values in the holding detail dialog on mobile
- Improved the account name display in the activities table
- Optimized the endpoint `GET api/v1/portfolio/holding/:dataSource/:symbol` by improving the processing of the historical market data
### Fixed
- Fixed an issue in the import dividends dialog
- Fixed an issue where certain symbols were incorrectly identified as currencies in various data providers
- Fixed the last request date in the users table of the admin control panel
## 3.9.0 - 2026-06-12
### Added
- Extended the _Public API_ with the endpoint to update the asset profile data (`PATCH api/v1/asset-profiles/:dataSource/:symbol`) (experimental)
- Added support for a dedicated _OpenRouter_ model for the `web_fetch` tool in the `FetchService`
### Changed
- Prefilled the form in the account balance management with the current cash balance
- Disabled the selection of future dates in the account balance management
- Grouped commodities and cryptocurrencies into the unknown bucket of the allocations by continent, country, currency, market and sector charts on the allocations page
- Moved the support for specific calendar year date ranges (`2025`, `2024`, `2023`, etc.) in the assistant from experimental to general availability
- Migrated various components from `NgStyle` to style bindings
- Improved the language localization for Korean (`ko`)
### Fixed
- Grouped activities without an account into the unknown bucket of the allocations by account and platform charts on the allocations page
## 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 ## 3.7.0 - 2026-06-02
### Added ### Added

52
README.md

@ -302,6 +302,58 @@ Grant access of type _Public_ in the _Access_ tab of _My Ghostfolio_.
} }
``` ```
### Update Asset Profile Data (experimental)
#### Prerequisites
[Bearer Token](#authorization-bearer-token) for authorization with admin role
#### Request
`PATCH http://localhost:3333/api/v1/asset-profiles/<INSERT_DATA_SOURCE>/<INSERT_SYMBOL>`
#### Body
```
{
"countries": [
{
"code": "US",
"weight": 1
}
],
"sectors": [
{
"name": "Technology",
"weight": 1
}
]
}
```
| Field | Type | Description |
| ----------- | ------------------ | ---------------------------------------------------------------------- |
| `countries` | `array` (optional) | Countries with `code` (`ISO 3166-1 alpha-2`) and `weight` (`0` to `1`) |
| `holdings` | `array` (optional) | Holdings with `name` and `weight` (`0` to `1`) |
| `sectors` | `array` (optional) | Sectors with `name` and `weight` (`0` to `1`) |
#### Response
##### Success
`200 OK`
##### Error
`404 Not Found`
```
{
"error": "Not Found",
"message": "Could not find the asset profile for MSFT (YAHOO)"
}
```
## Community Projects ## Community Projects
Discover a variety of community projects for Ghostfolio: https://github.com/topics/ghostfolio Discover a variety of community projects for Ghostfolio: https://github.com/topics/ghostfolio

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 });
} }

76
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,25 +349,26 @@ 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 const { assetClass, assetSubClass, countries, name, sectors } =
}) => { applyAssetProfileOverrides(
let countriesCount = countries ? Object.keys(countries).length : 0; assetProfile,
assetProfile.SymbolProfileOverrides
);
const countriesCount = countries ? Object.keys(countries).length : 0;
const lastMarketPrice = lastMarketPriceMap.get( const lastMarketPrice = lastMarketPriceMap.get(
getAssetProfileIdentifier({ dataSource, symbol }) getAssetProfileIdentifier({ dataSource, symbol })
@ -381,33 +382,7 @@ export class AdminService {
); );
})?._count ?? 0; })?._count ?? 0;
let sectorsCount = sectors ? Object.keys(sectors).length : 0; const 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;
if (
(SymbolProfileOverrides.sectors as unknown as Sector[])?.length >
0
) {
sectorsCount = (
SymbolProfileOverrides.sectors as unknown as Prisma.JsonArray
).length;
}
}
return { return {
assetClass, assetClass,
@ -428,8 +403,7 @@ export class AdminService {
isUsedByUsersWithSubscription: await isUsedByUsersWithSubscription, isUsedByUsersWithSubscription: await isUsedByUsersWithSubscription,
watchedByCount: _count.watchedBy watchedByCount: _count.watchedBy
}; };
} })
)
); );
if (presetId) { if (presetId) {
@ -619,6 +593,7 @@ export class AdminService {
assetClass: assetClass as AssetClass, assetClass: assetClass as AssetClass,
assetSubClass: assetSubClass as AssetSubClass, assetSubClass: assetSubClass as AssetSubClass,
countries: countries as Prisma.JsonArray, countries: countries as Prisma.JsonArray,
holdings: holdings as Prisma.JsonArray,
name: name as string, name: name as string,
sectors: sectors as Prisma.JsonArray, sectors: sectors as Prisma.JsonArray,
url: url as string url: url as string
@ -628,21 +603,14 @@ export class AdminService {
comment, comment,
currency, currency,
dataSource, dataSource,
holdings,
isActive, isActive,
scraperConfiguration, scraperConfiguration,
symbol, symbol,
symbolMapping, symbolMapping,
...(dataSource === 'MANUAL' ...this.symbolProfileService.getAssetProfileUpdateInput(
? { assetClass, assetSubClass, countries, name, sectors, url } { dataSource, symbol },
: { symbolProfileOverrides
SymbolProfileOverrides: { )
upsert: {
create: symbolProfileOverrides,
update: symbolProfileOverrides
}
}
})
}; };
await this.symbolProfileService.updateSymbolProfile( await this.symbolProfileService.updateSymbolProfile(
@ -882,7 +850,7 @@ export class AdminService {
activityCount: true, activityCount: true,
country: true, country: true,
dataProviderGhostfolioDailyRequests: true, dataProviderGhostfolioDailyRequests: true,
updatedAt: true lastRequestAt: true
} }
}, },
createdAt: true, createdAt: true,
@ -928,7 +896,7 @@ export class AdminService {
activityCount: _count.activities || 0, activityCount: _count.activities || 0,
country: analytics?.country, country: analytics?.country,
dailyApiRequests: analytics?.dataProviderGhostfolioDailyRequests || 0, dailyApiRequests: analytics?.dataProviderGhostfolioDailyRequests || 0,
lastActivity: analytics?.updatedAt lastActivity: analytics?.lastRequestAt
}; };
} }
); );

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

@ -38,6 +38,7 @@ import { AuthModule } from './auth/auth.module';
import { CacheModule } from './cache/cache.module'; import { CacheModule } from './cache/cache.module';
import { AiModule } from './endpoints/ai/ai.module'; import { AiModule } from './endpoints/ai/ai.module';
import { ApiKeysModule } from './endpoints/api-keys/api-keys.module'; import { ApiKeysModule } from './endpoints/api-keys/api-keys.module';
import { AssetProfilesModule } from './endpoints/asset-profiles/asset-profiles.module';
import { AssetsModule } from './endpoints/assets/assets.module'; import { AssetsModule } from './endpoints/assets/assets.module';
import { BenchmarksModule } from './endpoints/benchmarks/benchmarks.module'; import { BenchmarksModule } from './endpoints/benchmarks/benchmarks.module';
import { GhostfolioModule } from './endpoints/data-providers/ghostfolio/ghostfolio.module'; import { GhostfolioModule } from './endpoints/data-providers/ghostfolio/ghostfolio.module';
@ -69,6 +70,7 @@ import { UserModule } from './user/user.module';
ActivitiesModule, ActivitiesModule,
AiModule, AiModule,
ApiKeysModule, ApiKeysModule,
AssetProfilesModule,
AssetModule, AssetModule,
AssetsModule, AssetsModule,
AuthDeviceModule, AuthDeviceModule,

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

@ -50,6 +50,8 @@ import { OidcStrategy } from './oidc.strategy';
configurationService: ConfigurationService, configurationService: ConfigurationService,
fetchService: FetchService fetchService: FetchService
) => { ) => {
const logger = new Logger('OidcStrategy');
const isOidcEnabled = configurationService.get( const isOidcEnabled = configurationService.get(
'ENABLE_FEATURE_AUTH_OIDC' 'ENABLE_FEATURE_AUTH_OIDC'
); );
@ -101,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 });
} }

51
apps/api/src/app/endpoints/asset-profiles/asset-profiles.controller.ts

@ -0,0 +1,51 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { UpdateAssetProfileDataDto } from '@ghostfolio/common/dtos';
import { EnhancedSymbolProfile } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types';
import {
Body,
Controller,
HttpException,
Inject,
Param,
Patch,
UseGuards
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { DataSource } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { AssetProfilesService } from './asset-profiles.service';
@Controller('asset-profiles')
export class AssetProfilesController {
public constructor(
private readonly assetProfilesService: AssetProfilesService,
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
@HasPermission(permissions.accessAdminControl)
@Patch(':dataSource/:symbol')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async updateAssetProfileData(
@Body() assetProfileData: UpdateAssetProfileDataDto,
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
): Promise<EnhancedSymbolProfile> {
if (!this.request.user.settings.settings.isExperimentalFeatures) {
throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
);
}
return this.assetProfilesService.updateAssetProfileData(
{ dataSource, symbol },
assetProfileData
);
}
}

13
apps/api/src/app/endpoints/asset-profiles/asset-profiles.module.ts

@ -0,0 +1,13 @@
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
import { Module } from '@nestjs/common';
import { AssetProfilesController } from './asset-profiles.controller';
import { AssetProfilesService } from './asset-profiles.service';
@Module({
controllers: [AssetProfilesController],
imports: [SymbolProfileModule],
providers: [AssetProfilesService]
})
export class AssetProfilesModule {}

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

@ -0,0 +1,90 @@
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { UpdateAssetProfileDataDto } from '@ghostfolio/common/dtos';
import {
AssetProfileIdentifier,
EnhancedSymbolProfile
} from '@ghostfolio/common/interfaces';
import { Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
@Injectable()
export class AssetProfilesService {
public constructor(
private readonly symbolProfileService: SymbolProfileService
) {}
public async updateAssetProfileData(
{ dataSource, symbol }: AssetProfileIdentifier,
assetProfileData: UpdateAssetProfileDataDto
): Promise<EnhancedSymbolProfile> {
const notFoundMessage = `Could not find the asset profile for ${symbol} (${dataSource})`;
const data = this.getAssetProfileDataUpdate(assetProfileData);
if (Object.keys(data).length > 0) {
try {
await this.symbolProfileService.updateSymbolProfile(
{
dataSource,
symbol
},
this.symbolProfileService.getAssetProfileUpdateInput(
{ dataSource, symbol },
data
)
);
} catch (error) {
if (
error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === 'P2025'
) {
throw new NotFoundException(notFoundMessage);
}
throw error;
}
}
const [assetProfile] = await this.symbolProfileService.getSymbolProfiles([
{
dataSource,
symbol
}
]);
if (!assetProfile) {
throw new NotFoundException(notFoundMessage);
}
return assetProfile;
}
private getAssetProfileDataUpdate({
countries,
holdings,
sectors
}: UpdateAssetProfileDataDto): Pick<
Prisma.SymbolProfileUpdateInput,
'countries' | 'holdings' | 'sectors'
> {
const data: Pick<
Prisma.SymbolProfileUpdateInput,
'countries' | 'holdings' | 'sectors'
> = {};
if (countries !== undefined) {
data.countries = countries as Prisma.JsonArray;
}
if (holdings !== undefined) {
data.holdings = holdings as Prisma.JsonArray;
}
if (sectors !== undefined) {
data.sectors = sectors as Prisma.JsonArray;
}
return data;
}
}

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 };

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

@ -34,6 +34,8 @@ 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,
@ -99,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;
} }
@ -141,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;
} }
@ -183,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;
} }
@ -271,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;
} }
@ -348,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;
} }

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(
{ {

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;

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

@ -9,6 +9,7 @@ import { DataProviderService } from '@ghostfolio/api/services/data-provider/data
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 { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service'; import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
import { parseDate } from '@ghostfolio/common/helper'; import { parseDate } from '@ghostfolio/common/helper';
import { Account, DataSource } from '@prisma/client'; import { Account, DataSource } from '@prisma/client';
@ -59,10 +60,12 @@ describe('PortfolioService', () => {
null, null,
accountService, accountService,
null, null,
null,
dataProviderService, dataProviderService,
null, null,
exchangeRateDataService, exchangeRateDataService,
null, null,
null,
null null
); );
@ -106,6 +109,67 @@ describe('PortfolioService', () => {
); );
}); });
describe('getAggregatedMarkets', () => {
const getAggregatedMarkets = (holdings: object) => {
return (
portfolioService as unknown as {
getAggregatedMarkets: (aHoldings: object) => {
markets: Record<
string,
{ valueInBaseCurrency: number; valueInPercentage: number }
>;
marketsAdvanced: Record<string, { valueInBaseCurrency: number }>;
};
}
).getAggregatedMarkets(holdings);
};
it('should distribute holdings with countries to their market and route holdings without countries (e.g. commodities, cryptocurrencies) to the unknown bucket', () => {
const holdings = {
'GC=F': {
// Gold
assetProfile: { countries: [] },
markets: { developedMarkets: 0, emergingMarkets: 0, otherMarkets: 0 },
marketsAdvanced: {
asiaPacific: 0,
emergingMarkets: 0,
europe: 0,
japan: 0,
northAmerica: 0,
otherMarkets: 0
},
valueInBaseCurrency: 500
},
MSFT: {
assetProfile: { countries: [{ code: 'US', weight: 1 }] },
markets: { developedMarkets: 1, emergingMarkets: 0, otherMarkets: 0 },
marketsAdvanced: {
asiaPacific: 0,
emergingMarkets: 0,
europe: 0,
japan: 0,
northAmerica: 1,
otherMarkets: 0
},
valueInBaseCurrency: 1000
}
};
const { markets, marketsAdvanced } = getAggregatedMarkets(holdings);
expect(markets.developedMarkets.valueInBaseCurrency).toBe(1000);
expect(markets[UNKNOWN_KEY].valueInBaseCurrency).toBe(500);
expect(markets.developedMarkets.valueInPercentage).toBeCloseTo(
1000 / 1500
);
expect(markets[UNKNOWN_KEY].valueInPercentage).toBeCloseTo(500 / 1500);
expect(marketsAdvanced.northAmerica.valueInBaseCurrency).toBe(1000);
expect(marketsAdvanced[UNKNOWN_KEY].valueInBaseCurrency).toBe(500);
});
});
describe('getCashSymbolProfiles', () => { describe('getCashSymbolProfiles', () => {
it('should use the exchange-rate data source so the symbol-profile join in getDetails matches the calculator positions', () => { it('should use the exchange-rate data source so the symbol-profile join in getDetails matches the calculator positions', () => {
jest jest
@ -269,4 +333,96 @@ describe('PortfolioService', () => {
expect(holdings['USD'].assetProfile.symbol).toBe('USD'); expect(holdings['USD'].assetProfile.symbol).toBe('USD');
}); });
}); });
describe('getValueOfAccountsAndPlatforms', () => {
const getValueOfAccountsAndPlatforms = (args: object) => {
return (
portfolioService as unknown as {
getValueOfAccountsAndPlatforms: (aArgs: object) => Promise<{
accounts: Record<string, { valueInBaseCurrency: number }>;
platforms: Record<string, { valueInBaseCurrency: number }>;
}>;
}
).getValueOfAccountsAndPlatforms(args);
};
const account = {
balance: 100,
currency: 'USD',
id: randomUUID(),
isExcluded: false,
name: 'Account 1',
platform: { name: 'Platform 1' },
platformId: randomUUID()
};
beforeEach(() => {
jest
.spyOn(accountService, 'getAccounts')
.mockResolvedValue([account] as unknown as Account[]);
jest
.spyOn(exchangeRateDataService, 'toCurrency')
.mockImplementation((aValue) => aValue);
});
it('should group activities without an account into the unknown bucket of accounts and platforms', async () => {
const { accounts, platforms } = await getValueOfAccountsAndPlatforms({
activities: [
{
account,
accountId: account.id,
quantity: 1,
SymbolProfile: { symbol: 'AAPL' },
type: 'BUY'
},
{
account: null,
accountId: null,
quantity: 2,
SymbolProfile: { symbol: 'BABA' },
type: 'BUY'
}
],
filters: [],
portfolioItemsNow: {
AAPL: { marketPriceInBaseCurrency: 10 },
BABA: { marketPriceInBaseCurrency: 20 }
},
userCurrency: 'USD',
userId: userDummyData.id
});
// 100 (balance) + 1 * 10 (activity)
expect(accounts[account.id].valueInBaseCurrency).toBe(110);
expect(platforms[account.platformId].valueInBaseCurrency).toBe(110);
// 2 * 20 (activity without an account)
expect(accounts[UNKNOWN_KEY].valueInBaseCurrency).toBe(40);
expect(platforms[UNKNOWN_KEY].valueInBaseCurrency).toBe(40);
});
it('should not create an unknown bucket when every activity has an account', async () => {
const { accounts, platforms } = await getValueOfAccountsAndPlatforms({
activities: [
{
account,
accountId: account.id,
quantity: 1,
SymbolProfile: { symbol: 'AAPL' },
type: 'BUY'
}
],
filters: [],
portfolioItemsNow: {
AAPL: { marketPriceInBaseCurrency: 10 }
},
userCurrency: 'USD',
userId: userDummyData.id
});
expect(accounts[UNKNOWN_KEY]).toBeUndefined();
expect(platforms[UNKNOWN_KEY]).toBeUndefined();
});
});
}); });

17
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,
@ -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;
@ -1452,7 +1453,6 @@ 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.assetProfile.assetClass !== AssetClass.LIQUIDITY) {
if (position.assetProfile.countries.length > 0) { if (position.assetProfile.countries.length > 0) {
markets.developedMarkets.valueInBaseCurrency += markets.developedMarkets.valueInBaseCurrency +=
position.markets.developedMarkets * value; position.markets.developedMarkets * value;
@ -1478,7 +1478,6 @@ export class PortfolioService {
marketsAdvanced[UNKNOWN_KEY].valueInBaseCurrency += value; marketsAdvanced[UNKNOWN_KEY].valueInBaseCurrency += value;
} }
} }
}
const marketsTotalInBaseCurrency = getSum( const marketsTotalInBaseCurrency = getSum(
Object.values(markets).map(({ valueInBaseCurrency }) => { Object.values(markets).map(({ valueInBaseCurrency }) => {
@ -2162,11 +2161,14 @@ export class PortfolioService {
return withExcludedAccounts || account.isExcluded === false; return withExcludedAccounts || account.isExcluded === false;
}); });
for (const account of currentAccounts) { // Iterate over the accounts plus a null entry to group activities without
// an account into the unknown bucket
for (const account of [...currentAccounts, null]) {
const ordersByAccount = activities.filter(({ accountId }) => { const ordersByAccount = activities.filter(({ accountId }) => {
return accountId === account.id; return account ? accountId === account.id : !accountId;
}); });
if (account) {
accounts[account.id] = { accounts[account.id] = {
balance: account.balance, balance: account.balance,
currency: account.currency, currency: account.currency,
@ -2197,6 +2199,7 @@ export class PortfolioService {
) )
}; };
} }
}
for (const { for (const {
account, account,

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;
} }

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
); );

18
apps/api/src/main.ts

@ -23,6 +23,8 @@ 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 // Respect HTTP_PROXY / HTTPS_PROXY / NO_PROXY for outbound HTTP requests
setGlobalDispatcher(new EnvHttpProxyAgent()); setGlobalDispatcher(new EnvHttpProxyAgent());
@ -114,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

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

@ -30,7 +30,6 @@ export class ConfigurationService {
API_KEY_FINANCIAL_MODELING_PREP: str({ default: '' }), API_KEY_FINANCIAL_MODELING_PREP: str({ default: '' }),
API_KEY_OPEN_FIGI: str({ default: '' }), API_KEY_OPEN_FIGI: str({ default: '' }),
API_KEY_RAPID_API: str({ default: '' }), API_KEY_RAPID_API: str({ default: '' }),
BULL_BOARD_IS_READ_ONLY: bool({ default: true }),
CACHE_QUOTES_TTL: num({ default: ms('1 minute') }), CACHE_QUOTES_TTL: num({ default: ms('1 minute') }),
CACHE_TTL: num({ default: CACHE_TTL_NO_CACHE }), CACHE_TTL: num({ default: CACHE_TTL_NO_CACHE }),
DATA_SOURCE_EXCHANGE_RATES: str({ default: DataSource.YAHOO }), DATA_SOURCE_EXCHANGE_RATES: str({ default: DataSource.YAHOO }),

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

@ -29,6 +29,8 @@ 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 = {};
@ -88,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;
@ -214,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;
@ -262,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 };

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

@ -1,13 +1,15 @@
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 { 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 {
@ -17,13 +19,17 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
USA: 'United States' 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 private readonly fetchService: FetchService
@ -115,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
}); });
} }
@ -163,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
}); });
} }
@ -209,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;

86
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 { isCurrencySymbol } 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']
}); });
@ -56,16 +73,7 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
* DOGEUSD -> DOGE-USD * DOGEUSD -> DOGE-USD
*/ */
public convertToYahooFinanceSymbol(aSymbol: string) { public convertToYahooFinanceSymbol(aSymbol: string) {
if ( if (isCurrencySymbol(aSymbol)) {
aSymbol.includes(DEFAULT_CURRENCY) &&
aSymbol.length > DEFAULT_CURRENCY.length
) {
if (
isCurrency(
aSymbol.substring(0, aSymbol.length - DEFAULT_CURRENCY.length)
) &&
isCurrency(aSymbol.substring(aSymbol.length - DEFAULT_CURRENCY.length))
) {
return `${aSymbol}=X`; return `${aSymbol}=X`;
} else if ( } else if (
this.cryptocurrencyService.isCryptocurrency( this.cryptocurrencyService.isCryptocurrency(
@ -81,7 +89,6 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
`-${DEFAULT_CURRENCY}` `-${DEFAULT_CURRENCY}`
); );
} }
}
return aSymbol; return aSymbol;
} }
@ -123,7 +130,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 +229,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 +276,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 +339,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;
}
} }

40
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;
} }
@ -383,15 +385,16 @@ export class DataProviderService implements OnModuleInit {
response = marketDataByGranularity.reduce((r, marketData) => { response = marketDataByGranularity.reduce((r, marketData) => {
const { date, marketPrice, symbol } = marketData; const { date, marketPrice, symbol } = marketData;
r[symbol] = { if (!r[symbol]) {
...(r[symbol] || {}), r[symbol] = {};
[format(new Date(date), DATE_FORMAT)]: { marketPrice } }
};
r[symbol][format(new Date(date), DATE_FORMAT)] = { marketPrice };
return r; return r;
}, {}); }, {});
} catch (error) { } catch (error) {
Logger.error(error, 'DataProviderService'); this.logger.error(error);
} finally { } finally {
return response; return response;
} }
@ -503,7 +506,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 +570,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 +686,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 +723,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;
} }

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

@ -13,7 +13,7 @@ import {
DEFAULT_CURRENCY, DEFAULT_CURRENCY,
REPLACE_NAME_PARTS REPLACE_NAME_PARTS
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { DATE_FORMAT, isCurrency } from '@ghostfolio/common/helper'; import { DATE_FORMAT, isCurrencySymbol } from '@ghostfolio/common/helper';
import { import {
DataProviderHistoricalResponse, DataProviderHistoricalResponse,
DataProviderInfo, DataProviderInfo,
@ -37,6 +37,8 @@ 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';
@ -127,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 {};
@ -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'
); );
} }
@ -292,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'
); );
} }
} }
@ -311,7 +310,7 @@ export class EodHistoricalDataService
).toFixed(3)} seconds`; ).toFixed(3)} seconds`;
} }
Logger.error(message, 'EodHistoricalDataService'); this.logger.error(message);
} }
return {}; return {};
@ -383,21 +382,12 @@ export class EodHistoricalDataService
* Currency: USDCHF -> USDCHF.FOREX * Currency: USDCHF -> USDCHF.FOREX
*/ */
private convertToEodSymbol(aSymbol: string) { private convertToEodSymbol(aSymbol: string) {
if ( if (isCurrencySymbol(aSymbol)) {
aSymbol.startsWith(DEFAULT_CURRENCY) &&
aSymbol.length > DEFAULT_CURRENCY.length
) {
if (
isCurrency(
aSymbol.substring(0, aSymbol.length - DEFAULT_CURRENCY.length)
)
) {
let symbol = aSymbol; let symbol = aSymbol;
symbol = symbol.replace('GBp', 'GBX'); symbol = symbol.replace('GBp', 'GBX');
return `${symbol}.FOREX`; return `${symbol}.FOREX`;
} }
}
return aSymbol; return aSymbol;
} }
@ -465,7 +455,7 @@ export class EodHistoricalDataService
).toFixed(3)} seconds`; ).toFixed(3)} seconds`;
} }
Logger.error(message, 'EodHistoricalDataService'); this.logger.error(message);
} }
return searchResult; return searchResult;

49
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';
@ -15,7 +16,11 @@ import {
DEFAULT_CURRENCY, DEFAULT_CURRENCY,
REPLACE_NAME_PARTS REPLACE_NAME_PARTS
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { DATE_FORMAT, isCurrency, parseDate } from '@ghostfolio/common/helper'; import {
DATE_FORMAT,
isCurrencySymbol,
parseDate
} from '@ghostfolio/common/helper';
import { import {
DataProviderHistoricalResponse, DataProviderHistoricalResponse,
DataProviderInfo, DataProviderInfo,
@ -33,7 +38,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,
@ -55,6 +59,8 @@ 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(
@ -84,9 +90,7 @@ export class FinancialModelingPrepService
}; };
try { try {
if ( if (isCurrencySymbol(symbol)) {
isCurrency(symbol.substring(0, symbol.length - DEFAULT_CURRENCY.length))
) {
response.assetClass = AssetClass.LIQUIDITY; response.assetClass = AssetClass.LIQUIDITY;
response.assetSubClass = AssetSubClass.CASH; response.assetSubClass = AssetSubClass.CASH;
response.currency = symbol.substring( response.currency = symbol.substring(
@ -163,21 +167,11 @@ export class FinancialModelingPrepService
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
}; };
}); });
@ -265,7 +259,7 @@ export class FinancialModelingPrepService
).toFixed(3)} seconds`; ).toFixed(3)} seconds`;
} }
Logger.error(message, 'FinancialModelingPrepService'); this.logger.error(message);
} }
return response; return response;
@ -325,12 +319,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 {};
@ -491,11 +484,7 @@ export class FinancialModelingPrepService
for (const { price, symbol } of quotes) { for (const { price, symbol } of quotes) {
let marketState: MarketState = 'delayed'; let marketState: MarketState = 'delayed';
if ( if (isCurrencySymbol(symbol)) {
isCurrency(
symbol.substring(0, symbol.length - DEFAULT_CURRENCY.length)
)
) {
marketState = 'open'; marketState = 'open';
} }
@ -518,7 +507,7 @@ export class FinancialModelingPrepService
).toFixed(3)} seconds`; ).toFixed(3)} seconds`;
} }
Logger.error(message, 'FinancialModelingPrepService'); this.logger.error(message);
} }
return response; return response;
@ -638,7 +627,7 @@ export class FinancialModelingPrepService
).toFixed(3)} seconds`; ).toFixed(3)} seconds`;
} }
Logger.error(message, 'FinancialModelingPrepService'); this.logger.error(message);
} }
return { items }; return { items };

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

@ -33,6 +33,8 @@ 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`;
@ -89,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;
@ -154,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;
@ -211,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(
@ -283,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;
@ -338,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 {};

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

@ -31,6 +31,8 @@ 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 fetchService: FetchService,
@ -181,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 };
} }
@ -216,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 {};

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

@ -26,6 +26,8 @@ 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 private readonly fetchService: FetchService
@ -122,7 +124,7 @@ export class RapidApiService implements DataProviderInterface {
}; };
} }
} catch (error) { } catch (error) {
Logger.error(error, 'RapidApiService'); this.logger.error(error);
} }
return {}; return {};
@ -167,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}.`);
} }
} }
} }

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

@ -3,6 +3,7 @@ import { PropertyService } from '@ghostfolio/api/services/property/property.serv
import { import {
PROPERTY_API_KEY_OPENROUTER, PROPERTY_API_KEY_OPENROUTER,
PROPERTY_OPENROUTER_MODEL, PROPERTY_OPENROUTER_MODEL,
PROPERTY_OPENROUTER_MODEL_WEB_FETCH,
PROPERTY_WEB_FETCH_ROUTES PROPERTY_WEB_FETCH_ROUTES
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
@ -15,6 +16,8 @@ import { WebFetchRoute } from './interfaces/web-fetch-route.interface';
@Injectable() @Injectable()
export class FetchService implements OnModuleInit { 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 REDACTED_QUERY_PARAM_NAMES = ['apikey', 'api_token'];
private static readonly WEB_FETCH_TIMEOUT = ms('30 seconds'); private static readonly WEB_FETCH_TIMEOUT = ms('30 seconds');
@ -39,7 +42,7 @@ export class FetchService implements OnModuleInit {
const url = input instanceof Request ? input.url : input.toString(); const url = input instanceof Request ? input.url : input.toString();
const urlRedacted = this.redactUrl(url); const urlRedacted = this.redactUrl(url);
Logger.debug(`${method} ${urlRedacted}`, 'FetchService'); this.logger.debug(`${method} ${urlRedacted}`);
if (method === 'GET') { if (method === 'GET') {
const webFetchRoute = this.getMatchingWebFetchRoute(url); const webFetchRoute = this.getMatchingWebFetchRoute(url);
@ -60,15 +63,11 @@ export class FetchService implements OnModuleInit {
return await globalThis.fetch(input, init); return await globalThis.fetch(input, init);
} catch (error) { } catch (error) {
if (error instanceof Error) { if (error instanceof Error) {
Logger.error( this.logger.error(
`${method} ${urlRedacted} failed: [${error.name}] ${error.message}`, `${method} ${urlRedacted} failed: [${error.name}] ${error.message}`
'FetchService'
); );
} else { } else {
Logger.error( this.logger.error(`${method} ${urlRedacted} failed: ${String(error)}`);
`${method} ${urlRedacted} failed: ${String(error)}`,
'FetchService'
);
} }
throw error; throw error;
@ -82,12 +81,18 @@ export class FetchService implements OnModuleInit {
url: string; url: string;
webFetchRoute: WebFetchRoute; webFetchRoute: WebFetchRoute;
}) { }) {
const [openRouterApiKey, openRouterModel] = await Promise.all([ const [openRouterApiKey, openRouterModel, openRouterModelWebFetch] =
await Promise.all([
this.propertyService.getByKey<string>(PROPERTY_API_KEY_OPENROUTER), this.propertyService.getByKey<string>(PROPERTY_API_KEY_OPENROUTER),
this.propertyService.getByKey<string>(PROPERTY_OPENROUTER_MODEL) this.propertyService.getByKey<string>(PROPERTY_OPENROUTER_MODEL),
this.propertyService.getByKey<string>(
PROPERTY_OPENROUTER_MODEL_WEB_FETCH
)
]); ]);
if (!openRouterApiKey || !openRouterModel) { const model = openRouterModelWebFetch || openRouterModel;
if (!model || !openRouterApiKey) {
return undefined; return undefined;
} }
@ -95,7 +100,7 @@ export class FetchService implements OnModuleInit {
const openRouterService = createOpenRouter({ apiKey: openRouterApiKey }); const openRouterService = createOpenRouter({ apiKey: openRouterApiKey });
const { sources, text } = await generateText({ const { sources, text } = await generateText({
model: openRouterService.chat(openRouterModel), model: openRouterService.chat(model),
prompt: [ prompt: [
'You have access to a web_fetch tool. You MUST call it to retrieve the URL below, do not answer from prior knowledge.', '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.', 'Return the fetched response body exactly as received: raw body only, no commentary, no Markdown, and no code fences.',
@ -145,10 +150,7 @@ export class FetchService implements OnModuleInit {
} }
} }
Logger.debug( this.logger.debug(`Routed ${this.redactUrl(url)} via web fetch tool`);
`Routed ${this.redactUrl(url)} via web fetch tool`,
'FetchService'
);
return new Response(body, { return new Response(body, {
headers: webFetchRoute.responseContentType headers: webFetchRoute.responseContentType
@ -159,11 +161,10 @@ export class FetchService implements OnModuleInit {
return undefined; return undefined;
} catch (error) { } catch (error) {
Logger.error( this.logger.error(
`Web fetch tool failed for ${this.redactUrl(url)}: ${ `Web fetch tool failed for ${this.redactUrl(url)}: ${
error instanceof Error ? error.message : String(error) error instanceof Error ? error.message : String(error)
}`, }`
'FetchService'
); );
return undefined; return undefined;

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);
} }
} }

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

@ -10,7 +10,6 @@ export interface Environment extends CleanedEnvAccessors {
API_KEY_FINANCIAL_MODELING_PREP: string; API_KEY_FINANCIAL_MODELING_PREP: string;
API_KEY_OPEN_FIGI: string; API_KEY_OPEN_FIGI: string;
API_KEY_RAPID_API: string; API_KEY_RAPID_API: string;
BULL_BOARD_IS_READ_ONLY: boolean;
CACHE_QUOTES_TTL: number; CACHE_QUOTES_TTL: number;
CACHE_TTL: number; CACHE_TTL: number;
DATA_SOURCE_EXCHANGE_RATES: string; DATA_SOURCE_EXCHANGE_RATES: string;

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);
} }
} }

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

@ -23,8 +23,7 @@ import { DataGatheringProcessor } from './data-gathering.processor';
adapter: BullAdapter, adapter: BullAdapter,
name: DATA_GATHERING_QUEUE, name: DATA_GATHERING_QUEUE,
options: { options: {
displayName: 'Data Gathering', displayName: 'Data Gathering'
readOnlyMode: process.env.BULL_BOARD_IS_READ_ONLY !== 'false'
} }
}), }),
BullModule.registerQueue({ BullModule.registerQueue({

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;

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

@ -29,8 +29,7 @@ import { PortfolioSnapshotProcessor } from './portfolio-snapshot.processor';
adapter: BullAdapter, adapter: BullAdapter,
name: PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE, name: PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE,
options: { options: {
displayName: 'Portfolio Snapshot Computation', displayName: 'Portfolio Snapshot Computation'
readOnlyMode: process.env.BULL_BOARD_IS_READ_ONLY !== 'false'
} }
}), }),
BullModule.registerQueue({ BullModule.registerQueue({

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);
} }

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

@ -20,8 +20,7 @@ import { StatisticsGatheringService } from './statistics-gathering.service';
adapter: BullAdapter, adapter: BullAdapter,
name: STATISTICS_GATHERING_QUEUE, name: STATISTICS_GATHERING_QUEUE,
options: { options: {
displayName: 'Statistics Gathering', displayName: 'Statistics Gathering'
readOnlyMode: process.env.BULL_BOARD_IS_READ_ONLY !== 'false'
} }
}) })
] ]

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

@ -27,6 +27,8 @@ 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 fetchService: FetchService,
@ -35,10 +37,7 @@ export class StatisticsGatheringProcessor {
@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();
@ -47,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();
@ -67,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();
@ -87,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'
); );
} }
@ -100,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);
@ -120,10 +106,7 @@ 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> {
@ -139,7 +122,7 @@ export class StatisticsGatheringProcessor {
return pull_count; return pull_count;
} catch (error) { } catch (error) {
Logger.error(error, 'StatisticsGatheringProcessor - DockerHub'); this.logger.error(error);
throw error; throw error;
} }
@ -169,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;
} }
@ -188,7 +171,7 @@ export class StatisticsGatheringProcessor {
return stargazers_count; return stargazers_count;
} catch (error) { } catch (error) {
Logger.error(error, 'StatisticsGatheringProcessor - GitHub'); this.logger.error(error);
throw error; throw error;
} }
@ -217,7 +200,7 @@ export class StatisticsGatheringProcessor {
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;
} }

85
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,
@ -10,7 +11,12 @@ 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 { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Prisma, SymbolProfile, SymbolProfileOverrides } from '@prisma/client'; import {
DataSource,
Prisma,
SymbolProfile,
SymbolProfileOverrides
} from '@prisma/client';
import { continents, countries } from 'countries-list'; import { continents, countries } from 'countries-list';
@Injectable() @Injectable()
@ -70,6 +76,26 @@ export class SymbolProfileService {
}); });
} }
public getAssetProfileUpdateInput(
{ dataSource }: AssetProfileIdentifier,
data: Prisma.SymbolProfileUpdateInput
): Prisma.SymbolProfileUpdateInput {
if (dataSource === DataSource.MANUAL) {
return data;
}
return {
SymbolProfileOverrides: {
upsert: {
create:
data as Prisma.SymbolProfileOverridesCreateWithoutSymbolProfileInput,
update:
data as Prisma.SymbolProfileOverridesUpdateWithoutSymbolProfileInput
}
}
};
}
public async getSymbolProfiles( public async getSymbolProfiles(
aAssetProfileIdentifiers: AssetProfileIdentifier[] aAssetProfileIdentifiers: AssetProfileIdentifier[]
): Promise<EnhancedSymbolProfile[]> { ): Promise<EnhancedSymbolProfile[]> {
@ -192,21 +218,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 +250,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) {
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; 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);
} }
} }

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

@ -3,7 +3,7 @@ import { UserService } from '@ghostfolio/client/services/user/user.service';
import { import {
DEFAULT_DATE_RANGE, DEFAULT_DATE_RANGE,
DEFAULT_PAGE_SIZE, DEFAULT_PAGE_SIZE,
NUMERICAL_PRECISION_THRESHOLD_6_FIGURES NUMERICAL_PRECISION_THRESHOLD_5_FIGURES
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { CreateAccountBalanceDto } from '@ghostfolio/common/dtos'; import { CreateAccountBalanceDto } from '@ghostfolio/common/dtos';
import { DATE_FORMAT, downloadAsFile } from '@ghostfolio/common/helper'; import { DATE_FORMAT, downloadAsFile } from '@ghostfolio/common/helper';
@ -245,7 +245,7 @@ export class GfAccountDetailDialogComponent implements OnInit {
this.balance = balance; this.balance = balance;
if ( if (
this.balance >= NUMERICAL_PRECISION_THRESHOLD_6_FIGURES && this.balance >= NUMERICAL_PRECISION_THRESHOLD_5_FIGURES &&
this.data.deviceType === 'mobile' this.data.deviceType === 'mobile'
) { ) {
this.balancePrecision = 0; this.balancePrecision = 0;
@ -257,7 +257,7 @@ export class GfAccountDetailDialogComponent implements OnInit {
if ( if (
this.data.deviceType === 'mobile' && this.data.deviceType === 'mobile' &&
this.dividendInBaseCurrency >= this.dividendInBaseCurrency >=
NUMERICAL_PRECISION_THRESHOLD_6_FIGURES NUMERICAL_PRECISION_THRESHOLD_5_FIGURES
) { ) {
this.dividendInBaseCurrencyPrecision = 0; this.dividendInBaseCurrencyPrecision = 0;
} }
@ -267,7 +267,7 @@ export class GfAccountDetailDialogComponent implements OnInit {
if ( if (
this.data.deviceType === 'mobile' && this.data.deviceType === 'mobile' &&
this.equity >= NUMERICAL_PRECISION_THRESHOLD_6_FIGURES this.equity >= NUMERICAL_PRECISION_THRESHOLD_5_FIGURES
) { ) {
this.equityPrecision = 0; this.equityPrecision = 0;
} }
@ -280,7 +280,7 @@ export class GfAccountDetailDialogComponent implements OnInit {
if ( if (
this.data.deviceType === 'mobile' && this.data.deviceType === 'mobile' &&
this.interestInBaseCurrency >= this.interestInBaseCurrency >=
NUMERICAL_PRECISION_THRESHOLD_6_FIGURES NUMERICAL_PRECISION_THRESHOLD_5_FIGURES
) { ) {
this.interestInBaseCurrencyPrecision = 0; this.interestInBaseCurrencyPrecision = 0;
} }

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 &&

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

@ -1,8 +1,5 @@
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { import {
BULL_BOARD_COOKIE_NAME,
BULL_BOARD_ROUTE,
DATA_GATHERING_QUEUE_PRIORITY_HIGH, DATA_GATHERING_QUEUE_PRIORITY_HIGH,
DATA_GATHERING_QUEUE_PRIORITY_LOW, DATA_GATHERING_QUEUE_PRIORITY_LOW,
DATA_GATHERING_QUEUE_PRIORITY_MEDIUM, DATA_GATHERING_QUEUE_PRIORITY_MEDIUM,
@ -10,7 +7,6 @@ import {
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { getDateWithTimeFormatString } from '@ghostfolio/common/helper'; import { getDateWithTimeFormatString } from '@ghostfolio/common/helper';
import { AdminJobs, User } from '@ghostfolio/common/interfaces'; import { AdminJobs, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { NotificationService } from '@ghostfolio/ui/notifications'; import { NotificationService } from '@ghostfolio/ui/notifications';
import { AdminService } from '@ghostfolio/ui/services'; import { AdminService } from '@ghostfolio/ui/services';
@ -106,7 +102,6 @@ export class GfAdminJobsComponent implements OnInit {
'actions' 'actions'
]; ];
protected hasPermissionToAccessBullBoard = false;
protected isLoading = false; protected isLoading = false;
protected readonly statusFilterOptions = QUEUE_JOB_STATUS_LIST; protected readonly statusFilterOptions = QUEUE_JOB_STATUS_LIST;
@ -116,7 +111,6 @@ export class GfAdminJobsComponent implements OnInit {
private readonly changeDetectorRef = inject(ChangeDetectorRef); private readonly changeDetectorRef = inject(ChangeDetectorRef);
private readonly destroyRef = inject(DestroyRef); private readonly destroyRef = inject(DestroyRef);
private readonly notificationService = inject(NotificationService); private readonly notificationService = inject(NotificationService);
private readonly tokenStorageService = inject(TokenStorageService);
private readonly userService = inject(UserService); private readonly userService = inject(UserService);
public constructor() { public constructor() {
@ -129,11 +123,6 @@ export class GfAdminJobsComponent implements OnInit {
this.defaultDateTimeFormat = getDateWithTimeFormatString( this.defaultDateTimeFormat = getDateWithTimeFormatString(
this.user.settings.locale this.user.settings.locale
); );
this.hasPermissionToAccessBullBoard = hasPermission(
this.user.permissions,
permissions.accessAdminControlBullBoard
);
} }
}); });
@ -193,18 +182,6 @@ export class GfAdminJobsComponent implements OnInit {
}); });
} }
protected onOpenBullBoard() {
const token = this.tokenStorageService.getToken();
document.cookie = [
`${BULL_BOARD_COOKIE_NAME}=${encodeURIComponent(token)}`,
'path=/',
'SameSite=Strict'
].join('; ');
window.open(BULL_BOARD_ROUTE, '_blank');
}
protected onViewData(aData: AdminJobs['jobs'][0]['data']) { protected onViewData(aData: AdminJobs['jobs'][0]['data']) {
this.notificationService.alert({ this.notificationService.alert({
title: JSON.stringify(aData, null, ' ') title: JSON.stringify(aData, null, ' ')

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

@ -1,15 +1,6 @@
<div class="container"> <div class="container">
<div class="row"> <div class="row">
<div class="col"> <div class="col">
@if (hasPermissionToAccessBullBoard) {
<div class="d-flex justify-content-end mb-3">
<button mat-stroked-button (click)="onOpenBullBoard()">
<span><ng-container i18n>Overview</ng-container></span>
<ion-icon class="ml-2" name="open-outline" />
</button>
</div>
}
<form class="align-items-center d-flex" [formGroup]="filterForm"> <form class="align-items-center d-flex" [formGroup]="filterForm">
<mat-form-field appearance="outline" class="w-100 without-hint"> <mat-form-field appearance="outline" class="w-100 without-hint">
<mat-select formControlName="status"> <mat-select formControlName="status">

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>

20
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({
pageIndex = 0,
showLoading = true
}: { pageIndex?: number; showLoading?: boolean } = {}) {
if (showLoading) {
this.isLoading = true; 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;

2
apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.html

@ -53,6 +53,6 @@
<canvas <canvas
#chartCanvas #chartCanvas
class="h-100" class="h-100"
[ngStyle]="{ display: isLoading() ? 'none' : 'block' }" [style.display]="isLoading() ? 'none' : 'block'"
></canvas> ></canvas>
</div> </div>

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

@ -17,7 +17,6 @@ import { ColorScheme } from '@ghostfolio/common/types';
import { registerChartConfiguration } from '@ghostfolio/ui/chart'; import { registerChartConfiguration } from '@ghostfolio/ui/chart';
import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator'; import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator';
import { CommonModule } from '@angular/common';
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
Component, Component,
@ -53,7 +52,6 @@ import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ imports: [
CommonModule,
FormsModule, FormsModule,
GfPremiumIndicatorComponent, GfPremiumIndicatorComponent,
IonIcon, IonIcon,

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

@ -2,11 +2,14 @@ import { UserService } from '@ghostfolio/client/services/user/user.service';
import { import {
DEFAULT_PAGE_SIZE, DEFAULT_PAGE_SIZE,
NUMERICAL_PRECISION_THRESHOLD_3_FIGURES, NUMERICAL_PRECISION_THRESHOLD_3_FIGURES,
NUMERICAL_PRECISION_THRESHOLD_5_FIGURES, NUMERICAL_PRECISION_THRESHOLD_5_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 +124,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 +161,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;
@ -276,7 +281,7 @@ export class GfHoldingDetailDialogComponent implements OnInit {
this.averagePrice = averagePrice; this.averagePrice = averagePrice;
if ( if (
this.averagePrice >= NUMERICAL_PRECISION_THRESHOLD_6_FIGURES && this.averagePrice >= NUMERICAL_PRECISION_THRESHOLD_5_FIGURES &&
this.data.deviceType === 'mobile' this.data.deviceType === 'mobile'
) { ) {
this.averagePricePrecision = 0; this.averagePricePrecision = 0;
@ -291,7 +296,7 @@ export class GfHoldingDetailDialogComponent implements OnInit {
if ( if (
this.data.deviceType === 'mobile' && this.data.deviceType === 'mobile' &&
this.dividendInBaseCurrency >= this.dividendInBaseCurrency >=
NUMERICAL_PRECISION_THRESHOLD_6_FIGURES NUMERICAL_PRECISION_THRESHOLD_5_FIGURES
) { ) {
this.dividendInBaseCurrencyPrecision = 0; this.dividendInBaseCurrencyPrecision = 0;
} }
@ -329,7 +334,7 @@ export class GfHoldingDetailDialogComponent implements OnInit {
if ( if (
this.data.deviceType === 'mobile' && this.data.deviceType === 'mobile' &&
this.investmentInBaseCurrencyWithCurrencyEffect >= this.investmentInBaseCurrencyWithCurrencyEffect >=
NUMERICAL_PRECISION_THRESHOLD_6_FIGURES NUMERICAL_PRECISION_THRESHOLD_5_FIGURES
) { ) {
this.investmentInBaseCurrencyWithCurrencyEffectPrecision = 0; this.investmentInBaseCurrencyWithCurrencyEffectPrecision = 0;
} }
@ -339,7 +344,7 @@ export class GfHoldingDetailDialogComponent implements OnInit {
if ( if (
this.data.deviceType === 'mobile' && this.data.deviceType === 'mobile' &&
this.marketPriceMax >= NUMERICAL_PRECISION_THRESHOLD_6_FIGURES this.marketPriceMax >= NUMERICAL_PRECISION_THRESHOLD_5_FIGURES
) { ) {
this.marketPriceMaxPrecision = 0; this.marketPriceMaxPrecision = 0;
} }
@ -348,14 +353,14 @@ export class GfHoldingDetailDialogComponent implements OnInit {
if ( if (
this.data.deviceType === 'mobile' && this.data.deviceType === 'mobile' &&
this.marketPriceMin >= NUMERICAL_PRECISION_THRESHOLD_6_FIGURES this.marketPriceMin >= NUMERICAL_PRECISION_THRESHOLD_5_FIGURES
) { ) {
this.marketPriceMinPrecision = 0; this.marketPriceMinPrecision = 0;
} }
if ( if (
this.data.deviceType === 'mobile' && this.data.deviceType === 'mobile' &&
this.marketPrice >= NUMERICAL_PRECISION_THRESHOLD_6_FIGURES this.marketPrice >= NUMERICAL_PRECISION_THRESHOLD_5_FIGURES
) { ) {
this.marketPricePrecision = 0; this.marketPricePrecision = 0;
} }
@ -364,7 +369,7 @@ export class GfHoldingDetailDialogComponent implements OnInit {
if ( if (
this.data.deviceType === 'mobile' && this.data.deviceType === 'mobile' &&
this.netPerformance >= NUMERICAL_PRECISION_THRESHOLD_6_FIGURES this.netPerformance >= NUMERICAL_PRECISION_THRESHOLD_5_FIGURES
) { ) {
this.netPerformancePrecision = 0; this.netPerformancePrecision = 0;
} }
@ -433,7 +438,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 +450,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>

2
apps/client/src/app/components/investment-chart/investment-chart.component.html

@ -10,5 +10,5 @@
<canvas <canvas
#chartCanvas #chartCanvas
class="h-100" class="h-100"
[ngStyle]="{ display: isLoading ? 'none' : 'block' }" [style.display]="isLoading ? 'none' : 'block'"
></canvas> ></canvas>

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

@ -16,7 +16,6 @@ import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.in
import { ColorScheme, GroupBy } from '@ghostfolio/common/types'; import { ColorScheme, GroupBy } from '@ghostfolio/common/types';
import { registerChartConfiguration } from '@ghostfolio/ui/chart'; import { registerChartConfiguration } from '@ghostfolio/ui/chart';
import { CommonModule } from '@angular/common';
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
Component, Component,
@ -49,7 +48,7 @@ import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, NgxSkeletonLoaderModule], imports: [NgxSkeletonLoaderModule],
selector: 'gf-investment-chart', selector: 'gf-investment-chart',
styleUrls: ['./investment-chart.component.scss'], styleUrls: ['./investment-chart.component.scss'],
templateUrl: './investment-chart.component.html' templateUrl: './investment-chart.component.html'

30
apps/client/src/app/components/rule/rule-settings-dialog/rule-settings-dialog.component.ts

@ -1,8 +1,7 @@
import { XRayRulesSettings } from '@ghostfolio/common/interfaces';
import { GfValueComponent } from '@ghostfolio/ui/value'; import { GfValueComponent } from '@ghostfolio/ui/value';
import { Component, Inject } from '@angular/core'; import { ChangeDetectionStrategy, Component, Inject } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormBuilder, FormGroup, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { import {
MAT_DIALOG_DATA, MAT_DIALOG_DATA,
@ -14,22 +13,37 @@ import { MatSliderModule } from '@angular/material/slider';
import { RuleSettingsDialogParams } from './interfaces/interfaces'; import { RuleSettingsDialogParams } from './interfaces/interfaces';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ imports: [
FormsModule,
GfValueComponent, GfValueComponent,
MatButtonModule, MatButtonModule,
MatDialogModule, MatDialogModule,
MatSliderModule MatSliderModule,
ReactiveFormsModule
], ],
selector: 'gf-rule-settings-dialog', selector: 'gf-rule-settings-dialog',
styleUrls: ['./rule-settings-dialog.scss'], styleUrls: ['./rule-settings-dialog.scss'],
templateUrl: './rule-settings-dialog.html' templateUrl: './rule-settings-dialog.html'
}) })
export class GfRuleSettingsDialogComponent { export class GfRuleSettingsDialogComponent {
public settings: XRayRulesSettings['AccountClusterRiskCurrentInvestment']; public settingsForm: FormGroup;
public constructor( public constructor(
@Inject(MAT_DIALOG_DATA) public data: RuleSettingsDialogParams, @Inject(MAT_DIALOG_DATA) public data: RuleSettingsDialogParams,
public dialogRef: MatDialogRef<GfRuleSettingsDialogComponent> public dialogRef: MatDialogRef<GfRuleSettingsDialogComponent>,
) {} private formBuilder: FormBuilder
) {
this.settingsForm = this.formBuilder.group({
thresholdMax: [this.data.settings.thresholdMax],
thresholdMin: [this.data.settings.thresholdMin]
});
}
public onSubmit() {
this.dialogRef.close({
...this.data.settings,
thresholdMax: this.settingsForm.get('thresholdMax')?.value,
thresholdMin: this.settingsForm.get('thresholdMin')?.value
});
}
} }

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

@ -1,8 +1,14 @@
<div mat-dialog-title>{{ data.categoryName }} › {{ data.rule.name }}</div> <div mat-dialog-title>{{ data.categoryName }} › {{ data.rule.name }}</div>
<form
class="d-flex flex-column h-100"
[formGroup]="settingsForm"
(ngSubmit)="onSubmit()"
>
<div class="py-3" mat-dialog-content> <div class="py-3" mat-dialog-content>
@if ( @if (
data.rule.configuration.thresholdMin && data.rule.configuration.thresholdMax data.rule.configuration.thresholdMin &&
data.rule.configuration.thresholdMax
) { ) {
<div class="w-100"> <div class="w-100">
<h6 class="d-flex mb-0"> <h6 class="d-flex mb-0">
@ -12,14 +18,14 @@
[isPercent]="data.rule.configuration.threshold.unit === '%'" [isPercent]="data.rule.configuration.threshold.unit === '%'"
[locale]="data.locale" [locale]="data.locale"
[precision]="2" [precision]="2"
[value]="data.settings.thresholdMin" [value]="settingsForm.get('thresholdMin').value"
/> />
<span class="mx-1">-</span> <span class="mx-1">-</span>
<gf-value <gf-value
[isPercent]="data.rule.configuration.threshold.unit === '%'" [isPercent]="data.rule.configuration.threshold.unit === '%'"
[locale]="data.locale" [locale]="data.locale"
[precision]="2" [precision]="2"
[value]="data.settings.thresholdMax" [value]="settingsForm.get('thresholdMax').value"
/> />
</h6> </h6>
<div class="align-items-center d-flex w-100"> <div class="align-items-center d-flex w-100">
@ -35,8 +41,8 @@
[min]="data.rule.configuration.threshold.min" [min]="data.rule.configuration.threshold.min"
[step]="data.rule.configuration.threshold.step" [step]="data.rule.configuration.threshold.step"
> >
<input matSliderStartThumb [(ngModel)]="data.settings.thresholdMin" /> <input formControlName="thresholdMin" matSliderStartThumb />
<input matSliderEndThumb [(ngModel)]="data.settings.thresholdMax" /> <input formControlName="thresholdMax" matSliderEndThumb />
</mat-slider> </mat-slider>
<gf-value <gf-value
[isPercent]="data.rule.configuration.threshold.unit === '%'" [isPercent]="data.rule.configuration.threshold.unit === '%'"
@ -55,7 +61,7 @@
[isPercent]="data.rule.configuration.threshold.unit === '%'" [isPercent]="data.rule.configuration.threshold.unit === '%'"
[locale]="data.locale" [locale]="data.locale"
[precision]="2" [precision]="2"
[value]="data.settings.thresholdMin" [value]="settingsForm.get('thresholdMin').value"
/> />
</h6> </h6>
<div class="align-items-center d-flex w-100"> <div class="align-items-center d-flex w-100">
@ -72,7 +78,7 @@
[min]="data.rule.configuration.threshold.min" [min]="data.rule.configuration.threshold.min"
[step]="data.rule.configuration.threshold.step" [step]="data.rule.configuration.threshold.step"
> >
<input matSliderThumb [(ngModel)]="data.settings.thresholdMin" /> <input formControlName="thresholdMin" matSliderThumb />
</mat-slider> </mat-slider>
<gf-value <gf-value
[isPercent]="data.rule.configuration.threshold.unit === '%'" [isPercent]="data.rule.configuration.threshold.unit === '%'"
@ -90,7 +96,7 @@
[isPercent]="data.rule.configuration.threshold.unit === '%'" [isPercent]="data.rule.configuration.threshold.unit === '%'"
[locale]="data.locale" [locale]="data.locale"
[precision]="2" [precision]="2"
[value]="data.settings.thresholdMax" [value]="settingsForm.get('thresholdMax').value"
/> />
</h6> </h6>
<div class="align-items-center d-flex w-100"> <div class="align-items-center d-flex w-100">
@ -107,7 +113,7 @@
[min]="data.rule.configuration.threshold.min" [min]="data.rule.configuration.threshold.min"
[step]="data.rule.configuration.threshold.step" [step]="data.rule.configuration.threshold.step"
> >
<input matSliderThumb [(ngModel)]="data.settings.thresholdMax" /> <input formControlName="thresholdMax" matSliderThumb />
</mat-slider> </mat-slider>
<gf-value <gf-value
[isPercent]="data.rule.configuration.threshold.unit === '%'" [isPercent]="data.rule.configuration.threshold.unit === '%'"
@ -121,12 +127,16 @@
</div> </div>
<div align="end" mat-dialog-actions> <div align="end" mat-dialog-actions>
<button i18n mat-button (click)="dialogRef.close()">Close</button> <button mat-button type="button" (click)="dialogRef.close()">
<ng-container i18n>Close</ng-container>
</button>
<button <button
color="primary" color="primary"
mat-flat-button mat-flat-button
(click)="dialogRef.close(data.settings)" type="submit"
[disabled]="!settingsForm.dirty"
> >
<ng-container i18n>Save</ng-container> <ng-container i18n>Save</ng-container>
</button> </button>
</div> </div>
</form>

4
apps/client/src/app/components/rules/rules.component.html

@ -3,9 +3,7 @@
<div class="col"> <div class="col">
@if (isLoading) { @if (isLoading) {
<gf-rule [isLoading]="true" /> <gf-rule [isLoading]="true" />
} } @else if (rules) {
@if (rules !== null && rules !== undefined) {
@for (rule of rules; track rule.key) { @for (rule of rules; track rule.key) {
<gf-rule <gf-rule
[categoryName]="categoryName" [categoryName]="categoryName"

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"

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

@ -1,10 +1,19 @@
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import {
BULL_BOARD_COOKIE_NAME,
BULL_BOARD_ROUTE
} from '@ghostfolio/common/config';
import { User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { internalRoutes } from '@ghostfolio/common/routes/routes'; import { internalRoutes } from '@ghostfolio/common/routes/routes';
import { import {
GfPageTabsComponent, GfPageTabsComponent,
TabConfiguration TabConfiguration
} from '@ghostfolio/ui/page-tabs'; } from '@ghostfolio/ui/page-tabs';
import { Component, OnInit } from '@angular/core'; import { Component, inject } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { addIcons } from 'ionicons'; import { addIcons } from 'ionicons';
import { import {
flashOutline, flashOutline,
@ -21,10 +30,23 @@ import {
styleUrls: ['./admin-page.scss'], styleUrls: ['./admin-page.scss'],
templateUrl: './admin-page.html' templateUrl: './admin-page.html'
}) })
export class AdminPageComponent implements OnInit { export class AdminPageComponent {
public tabs: TabConfiguration[] = []; public tabs: TabConfiguration[] = [];
private user: User;
private readonly tokenStorageService = inject(TokenStorageService);
private readonly userService = inject(UserService);
public constructor() { public constructor() {
this.userService.stateChanged
.pipe(takeUntilDestroyed())
.subscribe((state) => {
this.user = state?.user;
this.initializeTabs();
});
addIcons({ addIcons({
flashOutline, flashOutline,
peopleOutline, peopleOutline,
@ -34,7 +56,12 @@ export class AdminPageComponent implements OnInit {
}); });
} }
public ngOnInit() { private initializeTabs() {
const hasPermissionToAccessBullBoard = hasPermission(
this.user?.permissions,
permissions.accessAdminControlBullBoard
);
this.tabs = [ this.tabs = [
{ {
iconName: 'reader-outline', iconName: 'reader-outline',
@ -51,7 +78,15 @@ export class AdminPageComponent implements OnInit {
label: internalRoutes.adminControl.subRoutes.marketData.title, label: internalRoutes.adminControl.subRoutes.marketData.title,
routerLink: internalRoutes.adminControl.subRoutes.marketData.routerLink routerLink: internalRoutes.adminControl.subRoutes.marketData.routerLink
}, },
{ hasPermissionToAccessBullBoard
? {
iconName: 'flash-outline',
label: $localize`Job Queue`,
onClick: () => {
this.onOpenBullBoard();
}
}
: {
iconName: 'flash-outline', iconName: 'flash-outline',
label: internalRoutes.adminControl.subRoutes.jobs.title, label: internalRoutes.adminControl.subRoutes.jobs.title,
routerLink: internalRoutes.adminControl.subRoutes.jobs.routerLink routerLink: internalRoutes.adminControl.subRoutes.jobs.routerLink
@ -63,4 +98,16 @@ export class AdminPageComponent implements OnInit {
} }
]; ];
} }
private onOpenBullBoard() {
const token = this.tokenStorageService.getToken();
document.cookie = [
`${BULL_BOARD_COOKIE_NAME}=${encodeURIComponent(token)}`,
'path=/',
'SameSite=Strict'
].join('; ');
window.open(BULL_BOARD_ROUTE, '_blank');
}
} }

2
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

5
apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.html

@ -32,15 +32,18 @@
<mat-label i18n>Holding</mat-label> <mat-label i18n>Holding</mat-label>
<mat-select formControlName="assetProfileIdentifier"> <mat-select formControlName="assetProfileIdentifier">
<mat-select-trigger>{{ <mat-select-trigger>{{
assetProfileForm.get('assetProfileIdentifier')?.value?.name assetProfileForm.get('assetProfileIdentifier')?.value
?.assetProfile?.name
}}</mat-select-trigger> }}</mat-select-trigger>
@for (holding of holdings; track holding) { @for (holding of holdings; track holding) {
<mat-option <mat-option
class="line-height-1" class="line-height-1"
[value]="{ [value]="{
assetProfile: {
dataSource: holding.assetProfile.dataSource, dataSource: holding.assetProfile.dataSource,
name: holding.assetProfile.name, name: holding.assetProfile.name,
symbol: holding.assetProfile.symbol symbol: holding.assetProfile.symbol
}
}" }"
> >
<span <span

39
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,
@ -201,6 +201,26 @@ export class GfAllocationsPageComponent implements OnInit {
} }
} }
private extractCurrency({
assetClass,
assetSubClass,
currency
}: {
assetClass: PortfolioPosition['assetProfile']['assetClass'];
assetSubClass: PortfolioPosition['assetProfile']['assetSubClass'];
currency?: PortfolioPosition['assetProfile']['currency'];
}) {
if (
assetClass === AssetClass.COMMODITY ||
assetSubClass === AssetSubClass.CRYPTOCURRENCY
) {
// Commodities and cryptocurrencies have no meaningful currency exposure
return UNKNOWN_KEY;
}
return currency;
}
private extractEtfProvider({ private extractEtfProvider({
assetSubClass, assetSubClass,
name name
@ -339,7 +359,7 @@ export class GfAllocationsPageComponent implements OnInit {
position.assetProfile.assetSubClass || (UNKNOWN_KEY as AssetSubClass), position.assetProfile.assetSubClass || (UNKNOWN_KEY as AssetSubClass),
assetSubClassLabel: assetSubClassLabel:
position.assetProfile.assetSubClassLabel || UNKNOWN_KEY, position.assetProfile.assetSubClassLabel || UNKNOWN_KEY,
currency: position.assetProfile.currency, currency: this.extractCurrency(position.assetProfile),
etfProvider: this.extractEtfProvider({ etfProvider: this.extractEtfProvider({
assetSubClass: position.assetProfile.assetSubClass, assetSubClass: position.assetProfile.assetSubClass,
name: position.assetProfile.name name: position.assetProfile.name
@ -348,12 +368,11 @@ export class GfAllocationsPageComponent implements OnInit {
name: position.assetProfile.name name: position.assetProfile.name
}; };
if (position.assetProfile.assetClass !== AssetClass.LIQUIDITY) { // Prepare analysis data by continents, countries, holdings and sectors
// Prepare analysis data by continents, countries, holdings and sectors except for liquidity
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 +=
@ -363,7 +382,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)
@ -380,7 +399,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)
@ -442,7 +464,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)
@ -458,7 +480,6 @@ export class GfAllocationsPageComponent implements OnInit {
? this.portfolioDetails.holdings[symbol].valueInBaseCurrency ? this.portfolioDetails.holdings[symbol].valueInBaseCurrency
: this.portfolioDetails.holdings[symbol].valueInPercentage; : this.portfolioDetails.holdings[symbol].valueInPercentage;
} }
}
if (this.holdings[symbol].assetSubClass === 'ETF') { if (this.holdings[symbol].assetSubClass === 'ETF') {
this.totalValueInEtf += this.holdings[symbol].value; this.totalValueInEtf += this.holdings[symbol].value;

3
apps/client/src/app/pages/portfolio/fire/fire-page.component.ts

@ -12,7 +12,7 @@ import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator';
import { DataService } from '@ghostfolio/ui/services'; import { DataService } from '@ghostfolio/ui/services';
import { GfValueComponent } from '@ghostfolio/ui/value'; import { GfValueComponent } from '@ghostfolio/ui/value';
import { CommonModule, NgStyle } from '@angular/common'; import { CommonModule } from '@angular/common';
import { import {
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
@ -35,7 +35,6 @@ import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
GfFireCalculatorComponent, GfFireCalculatorComponent,
GfPremiumIndicatorComponent, GfPremiumIndicatorComponent,
GfValueComponent, GfValueComponent,
NgStyle,
NgxSkeletonLoaderModule, NgxSkeletonLoaderModule,
ReactiveFormsModule ReactiveFormsModule
], ],

11
apps/client/src/app/pages/portfolio/fire/fire-page.html

@ -19,14 +19,15 @@
!hasImpersonationId && hasPermissionToUpdateUserSettings !hasImpersonationId && hasPermissionToUpdateUserSettings
" "
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[ngStyle]="{
opacity: user?.subscription?.type === 'Basic' ? '0.67' : 'initial',
'pointer-events':
user?.subscription?.type === 'Basic' ? 'none' : 'initial'
}"
[projectedTotalAmount]="user?.settings?.projectedTotalAmount" [projectedTotalAmount]="user?.settings?.projectedTotalAmount"
[retirementDate]="user?.settings?.retirementDate" [retirementDate]="user?.settings?.retirementDate"
[savingsRate]="user?.settings?.savingsRate" [savingsRate]="user?.settings?.savingsRate"
[style.opacity]="
user?.subscription?.type === 'Basic' ? '0.67' : 'initial'
"
[style.pointer-events]="
user?.subscription?.type === 'Basic' ? 'none' : 'initial'
"
(annualInterestRateChanged)="onAnnualInterestRateChange($event)" (annualInterestRateChanged)="onAnnualInterestRateChange($event)"
(calculationCompleted)="onCalculationComplete($event)" (calculationCompleted)="onCalculationComplete($event)"
(projectedTotalAmountChanged)="onProjectedTotalAmountChange($event)" (projectedTotalAmountChanged)="onProjectedTotalAmountChange($event)"

11
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';
@ -185,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]
@ -205,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]
@ -232,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]

15
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 = {
...personalFinanceTools.find(({ key }) => {
return key === this.route.snapshot.data['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

772
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

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

File diff suppressed because it is too large

1000
apps/client/src/locales/messages.ko.xlf

File diff suppressed because it is too large

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

856
apps/client/src/locales/messages.uk.xlf

File diff suppressed because it is too large

734
apps/client/src/locales/messages.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