Browse Source

fix: remove invalid { each: true } from @IsEnum decorators on scalar DTO properties

pull/7164/head
AkashNegi1 3 weeks ago
parent
commit
c424570643
  1. 1
      .gitignore
  2. 102
      CHANGELOG.md
  3. 2
      README.md
  4. 4
      apps/api/jest.config.ts
  5. 10
      apps/api/src/app/access/access.controller.ts
  6. 7
      apps/api/src/app/access/access.service.ts
  7. 8
      apps/api/src/app/activities/activities.service.ts
  8. 4
      apps/api/src/app/admin/admin.controller.ts
  9. 12
      apps/api/src/app/admin/admin.service.ts
  10. 2
      apps/api/src/app/admin/queue/queue.service.ts
  11. 8
      apps/api/src/app/endpoints/benchmarks/benchmarks.service.ts
  12. 9
      apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.service.ts
  13. 22
      apps/api/src/app/endpoints/public/public.controller.ts
  14. 7
      apps/api/src/app/endpoints/watchlist/watchlist.service.ts
  15. 15
      apps/api/src/app/import/import.service.ts
  16. 2
      apps/api/src/app/info/info.service.ts
  17. 15
      apps/api/src/app/portfolio/portfolio.service.ts
  18. 12
      apps/api/src/app/symbol/symbol.service.ts
  19. 6
      apps/api/src/helper/country.helper.ts
  20. 67
      apps/api/src/helper/data-source.helper.ts
  21. 2
      apps/api/src/interceptors/performance-logging/performance-logging.service.ts
  22. 2
      apps/api/src/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor.ts
  23. 26
      apps/api/src/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor.ts
  24. 5
      apps/api/src/services/configuration/configuration.service.ts
  25. 47
      apps/api/src/services/cron/cron.module.ts
  26. 11
      apps/api/src/services/cron/cron.service.ts
  27. 18
      apps/api/src/services/data-provider/data-enhancer/trackinsight/trackinsight.service.ts
  28. 89
      apps/api/src/services/data-provider/data-provider.service.ts
  29. 6
      apps/api/src/services/data-provider/financial-modeling-prep/financial-modeling-prep.service.ts
  30. 1
      apps/api/src/services/data-provider/interfaces/data-provider.interface.ts
  31. 45
      apps/api/src/services/data-provider/manual/manual.service.ts
  32. 19
      apps/api/src/services/data-provider/rapid-api/rapid-api.service.ts
  33. 18
      apps/api/src/services/exchange-rate-data/exchange-rate-data.service.ts
  34. 69
      apps/api/src/services/fetch/fetch.service.ts
  35. 19
      apps/api/src/services/fetch/interfaces/proxy-route.interface.ts
  36. 2
      apps/api/src/services/interfaces/environment.interface.ts
  37. 74
      apps/api/src/services/market-data/market-data.service.ts
  38. 3
      apps/api/src/services/queues/data-gathering/data-gathering.module.ts
  39. 21
      apps/api/src/services/queues/data-gathering/data-gathering.processor.ts
  40. 180
      apps/api/src/services/queues/data-gathering/data-gathering.service.ts
  41. 37
      apps/api/src/services/queues/statistics-gathering/statistics-gathering.processor.ts
  42. 4
      apps/api/src/services/twitter-bot/twitter-bot.service.ts
  43. 12
      apps/client/project.json
  44. 4
      apps/client/src/app/components/access-table/access-table.component.html
  45. 10
      apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts
  46. 2
      apps/client/src/app/components/admin-jobs/admin-jobs.html
  47. 22
      apps/client/src/app/components/admin-market-data/admin-market-data.component.ts
  48. 5
      apps/client/src/app/components/admin-market-data/admin-market-data.html
  49. 2
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts
  50. 7
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html
  51. 6
      apps/client/src/app/components/admin-platform/admin-platform.component.html
  52. 6
      apps/client/src/app/components/admin-platform/admin-platform.component.ts
  53. 30
      apps/client/src/app/components/admin-settings/admin-settings.component.scss
  54. 6
      apps/client/src/app/components/admin-tag/admin-tag.component.html
  55. 6
      apps/client/src/app/components/admin-tag/admin-tag.component.ts
  56. 7
      apps/client/src/app/components/footer/footer.component.html
  57. 41
      apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts
  58. 4
      apps/client/src/app/components/home-market/home-market.component.ts
  59. 79
      apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts
  60. 12
      apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.html
  61. 1
      apps/client/src/app/components/user-account-access/user-account-access.component.ts
  62. 3
      apps/client/src/app/components/user-account-settings/user-account-settings.component.ts
  63. 17
      apps/client/src/app/components/user-account-settings/user-account-settings.html
  64. 3
      apps/client/src/app/components/user-detail-dialog/user-detail-dialog.html
  65. 7
      apps/client/src/app/pages/accounts/transfer-balance/interfaces/interfaces.ts
  66. 78
      apps/client/src/app/pages/accounts/transfer-balance/transfer-balance-dialog.component.ts
  67. 6
      apps/client/src/app/pages/api/api-page.component.ts
  68. 8
      apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.html
  69. 11
      apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.scss
  70. 333
      apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts
  71. 2
      apps/client/src/app/pages/portfolio/allocations/allocations-page.html
  72. 6
      apps/client/src/app/pages/portfolio/allocations/interfaces/interfaces.ts
  73. 132
      apps/client/src/app/pages/resources/personal-finance-tools/product-page.component.ts
  74. 116
      apps/client/src/app/pages/resources/personal-finance-tools/product-page.html
  75. 296
      apps/client/src/locales/messages.ca.xlf
  76. 310
      apps/client/src/locales/messages.de.xlf
  77. 296
      apps/client/src/locales/messages.es.xlf
  78. 296
      apps/client/src/locales/messages.fr.xlf
  79. 296
      apps/client/src/locales/messages.it.xlf
  80. 8789
      apps/client/src/locales/messages.ja.xlf
  81. 296
      apps/client/src/locales/messages.ko.xlf
  82. 296
      apps/client/src/locales/messages.nl.xlf
  83. 296
      apps/client/src/locales/messages.pl.xlf
  84. 296
      apps/client/src/locales/messages.pt.xlf
  85. 296
      apps/client/src/locales/messages.tr.xlf
  86. 296
      apps/client/src/locales/messages.uk.xlf
  87. 265
      apps/client/src/locales/messages.xlf
  88. 296
      apps/client/src/locales/messages.zh.xlf
  89. 13
      libs/common/src/lib/config.ts
  90. 8
      libs/common/src/lib/dtos/create-access.dto.ts
  91. 4
      libs/common/src/lib/dtos/create-asset-profile.dto.ts
  92. 6
      libs/common/src/lib/dtos/create-order.dto.ts
  93. 8
      libs/common/src/lib/dtos/update-access.dto.ts
  94. 4
      libs/common/src/lib/dtos/update-asset-profile.dto.ts
  95. 4
      libs/common/src/lib/dtos/update-order.dto.ts
  96. 22
      libs/common/src/lib/helper.ts
  97. 5
      libs/common/src/lib/interfaces/access-settings.interface.ts
  98. 3
      libs/common/src/lib/interfaces/access.interface.ts
  99. 2
      libs/common/src/lib/interfaces/holding.interface.ts
  100. 2
      libs/common/src/lib/interfaces/index.ts

1
.gitignore

@ -32,7 +32,6 @@ npm-debug.log
.env.prod .env.prod
.github/instructions/nx.instructions.md .github/instructions/nx.instructions.md
.nx/cache .nx/cache
.nx/migrate-runs
.nx/polygraph .nx/polygraph
.nx/self-healing .nx/self-healing
.nx/workspace-data .nx/workspace-data

102
CHANGELOG.md

@ -5,107 +5,6 @@ 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).
## Unreleased
### Added
- Added support for routing outgoing requests through a per-domain proxy via the `PROXY_ROUTES` setting in the `FetchService`
## 3.18.0 - 2026-06-28
### Added
- Added support for filtering in the public access for portfolio sharing (experimental)
- Set up the language localization for Japanese (`ja`)
### Changed
- Improved the alias display in the access table to share the portfolio
- Improved the language localization for German (`de`)
### Fixed
- Fixed a phantom `UNKNOWN` slice in the portfolio proportion chart component caused by floating-point rounding
- Fixed the base currency for the total value calculation in the public access for portfolio sharing
- Fixed an issue in the public access for portfolio sharing that exposed absolute values of the top holdings of ETFs
- Fixed the time zone handling in the `api` test suite for deterministic execution in `UTC`
## 3.17.0 - 2026-06-26
### Added
- Added `zod` as a root dependency to resolve peer dependency warnings
### Changed
- Improved the error message styling in the import activities dialog
- Improved the grantee display in the access table to share the portfolio
- Improved the country mapping for data providers
- Upgraded `bull-board` from version `7.2.1` to `8.0.1`
- Upgraded `Nx` from version `22.7.5` to `23.0.1`
- Upgraded `prettier` from version `3.8.3` to `3.8.4`
### Fixed
- Improved the table headers’ alignment in the queue jobs table of the admin control panel
## 3.16.0 - 2026-06-24
### Added
- Extended the user account settings with a copy-to-clipboard button for the user id
- Added pagination to the platform management of the admin control panel
- Added pagination to the tag management of the admin control panel
- Extended the asset profile details dialog of the admin control panel with a copy-to-clipboard button for the ISIN number
- Extended the asset profile details dialog of the admin control panel with a copy-to-clipboard button for the symbol
### Changed
- Improved the throughput of the market data gathering queue by applying the rate limit per data source
- Decreased the rate limiter duration of the market data gathering queue jobs from 4 to 3 seconds
- Removed the deprecated `SymbolProfile` field from the endpoint `GET api/v1/portfolio/holding/:dataSource/:symbol`
- Upgraded `@simplewebauthn/browser` and `@simplewebauthn/server` from version `13.2.2` to `13.3`
### Fixed
- Fixed an issue with hourly market data updates not refreshing prices for asset profiles with `MANUAL` data source
- Fixed an issue with the log context formatting in the performance logging service
## 3.15.1 - 2026-06-23
### 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
- Upgraded `@internationalized/number` from version `3.6.6` to `3.6.7`
### Fixed
- Fixed an issue where symbols with special characters caused API request failures by URL encoding the symbol
- Fixed the disabled state of the delete action in the asset profiles actions menu of the historical market data table in the admin control panel
- Fixed the persistence of an empty `locale` string in the scraper configuration
- Fixed a transaction timeout that prevented gathering historical market data for symbols with a long history
- Fixed an exception in various portfolio endpoints when historical exchange rate data is missing
## 3.14.0 - 2026-06-22
### Added
- Exposed the `ENABLE_FEATURE_CRON` environment variable to control scheduled cron job execution
- Exposed the `PROCESSOR_GATHER_STATISTICS_CONCURRENCY` environment variable to control the concurrency of the statistics gathering queue processor
### Changed
- Consolidated the exchange rates to be gathered with hourly market data
- Improved the language localization for German (`de`)
- Upgraded `@openrouter/ai-sdk-provider` from version `2.9.0` to `2.9.1`
- Upgraded `undici` from version `7.24.4` to `8.5.0`
### Fixed
- Fixed an issue in the data provider service where asset profiles and historical data could be missing for symbols that exist in multiple data sources by keying the responses by the asset profile identifier
- Resolved an exception in the benchmarks service when the current market price is unavailable
## 3.13.0 - 2026-06-20 ## 3.13.0 - 2026-06-20
### Added ### Added
@ -116,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
- Removed the deprecated `SymbolProfile` field from the endpoint `GET api/v1/portfolio/holding/:dataSource/:symbol`
- Changed the _Fear & Greed Index_ (market mood) in the markets overview to use the stored market data instead of a live quote - Changed the _Fear & Greed Index_ (market mood) in the markets overview to use the stored market data instead of a live quote
- Moved the endpoint to get the asset profiles from `GET api/v1/admin/market-data` to `GET api/v1/asset-profiles` - Moved the endpoint to get the asset profiles from `GET api/v1/admin/market-data` to `GET api/v1/asset-profiles`
- Moved the endpoint to get the asset profile details from `GET api/v1/market-data/:dataSource/:symbol` to `GET api/v1/asset-profiles/:dataSource/:symbol` - Moved the endpoint to get the asset profile details from `GET api/v1/market-data/:dataSource/:symbol` to `GET api/v1/asset-profiles/:dataSource/:symbol`

2
README.md

@ -107,7 +107,7 @@ We provide official container images hosted on [Docker Hub](https://hub.docker.c
| `REQUEST_TIMEOUT` | `number` (optional) | `2000` | The timeout of network requests to data providers in milliseconds | | `REQUEST_TIMEOUT` | `number` (optional) | `2000` | The timeout of network requests to data providers in milliseconds |
| `ROOT_URL` | `string` (optional) | `http://0.0.0.0:3333` | The root URL of the Ghostfolio application, used for generating callback URLs and external links. | | `ROOT_URL` | `string` (optional) | `http://0.0.0.0:3333` | The root URL of the Ghostfolio application, used for generating callback URLs and external links. |
#### OpenID Connect OIDC (experimental) #### OpenID Connect OIDC (Experimental)
| Name | Type | Default Value | Description | | Name | Type | Default Value | Description |
| -------------------------- | --------------------- | ------------------------------------ | ---------------------------------------------------------------------------------------------------- | | -------------------------- | --------------------- | ------------------------------------ | ---------------------------------------------------------------------------------------------------- |

4
apps/api/jest.config.ts

@ -1,8 +1,4 @@
/* eslint-disable */ /* eslint-disable */
// Run tests in UTC for deterministic date-based calculations
process.env.TZ = 'UTC';
export default { export default {
displayName: 'api', displayName: 'api',

10
apps/api/src/app/access/access.controller.ts

@ -3,7 +3,7 @@ import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { CreateAccessDto, UpdateAccessDto } from '@ghostfolio/common/dtos'; import { CreateAccessDto, UpdateAccessDto } from '@ghostfolio/common/dtos';
import { SubscriptionType } from '@ghostfolio/common/enums'; import { SubscriptionType } from '@ghostfolio/common/enums';
import { Access, AccessSettings } from '@ghostfolio/common/interfaces'; import { Access } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
@ -46,14 +46,13 @@ export class AccessController {
}); });
return accessesWithGranteeUser.map( return accessesWithGranteeUser.map(
({ alias, granteeUser, id, permissions, settings }) => { ({ alias, granteeUser, id, permissions }) => {
if (granteeUser) { if (granteeUser) {
return { return {
alias, alias,
id, id,
permissions, permissions,
grantee: granteeUser?.id, grantee: granteeUser?.id,
settings: settings as AccessSettings,
type: 'PRIVATE' type: 'PRIVATE'
}; };
} }
@ -63,7 +62,6 @@ export class AccessController {
id, id,
permissions, permissions,
grantee: 'Public', grantee: 'Public',
settings: settings as AccessSettings,
type: 'PUBLIC' type: 'PUBLIC'
}; };
} }
@ -93,7 +91,6 @@ export class AccessController {
? { connect: { id: data.granteeUserId } } ? { connect: { id: data.granteeUserId } }
: undefined, : undefined,
permissions: data.permissions, permissions: data.permissions,
settings: this.accessService.buildSettings(data.filters),
user: { connect: { id: this.request.user.id } } user: { connect: { id: this.request.user.id } }
}); });
} catch { } catch {
@ -161,8 +158,7 @@ export class AccessController {
granteeUser: data.granteeUserId granteeUser: data.granteeUserId
? { connect: { id: data.granteeUserId } } ? { connect: { id: data.granteeUserId } }
: { disconnect: true }, : { disconnect: true },
permissions: data.permissions, permissions: data.permissions
settings: this.accessService.buildSettings(data.filters)
}, },
where: { id } where: { id }
}); });

7
apps/api/src/app/access/access.service.ts

@ -1,5 +1,4 @@
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { AccessSettings, Filter } from '@ghostfolio/common/interfaces';
import { AccessWithGranteeUser } from '@ghostfolio/common/types'; import { AccessWithGranteeUser } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
@ -40,12 +39,6 @@ export class AccessService {
}); });
} }
public buildSettings(filters?: Filter[]) {
const settings: AccessSettings = filters?.length ? { filters } : {};
return settings as Prisma.InputJsonValue;
}
public async createAccess(data: Prisma.AccessCreateInput): Promise<Access> { public async createAccess(data: Prisma.AccessCreateInput): Promise<Access> {
return this.prismaService.access.create({ return this.prismaService.access.create({
data data

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

@ -746,10 +746,10 @@ export class ActivitiesService {
const value = new Big(order.quantity).mul(order.unitPrice).toNumber(); const value = new Big(order.quantity).mul(order.unitPrice).toNumber();
const [ const [
feeInAssetProfileCurrency = 0, feeInAssetProfileCurrency,
feeInBaseCurrency = 0, feeInBaseCurrency,
unitPriceInAssetProfileCurrency = 0, unitPriceInAssetProfileCurrency,
valueInBaseCurrency = 0 valueInBaseCurrency
] = await Promise.all([ ] = await Promise.all([
this.exchangeRateDataService.toCurrencyAtDate( this.exchangeRateDataService.toCurrencyAtDate(
order.fee, order.fee,

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

@ -86,8 +86,8 @@ export class AdminController {
@HasPermission(permissions.accessAdminControl) @HasPermission(permissions.accessAdminControl)
@Post('gather') @Post('gather')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async gatherRecentMarketData(): Promise<void> { public async gather7Days(): Promise<void> {
this.dataGatheringService.gatherRecentMarketData(); this.dataGatheringService.gather7Days();
} }
@HasPermission(permissions.accessAdminControl) @HasPermission(permissions.accessAdminControl)

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

@ -11,10 +11,7 @@ import {
PROPERTY_IS_READ_ONLY_MODE, PROPERTY_IS_READ_ONLY_MODE,
PROPERTY_IS_USER_SIGNUP_ENABLED PROPERTY_IS_USER_SIGNUP_ENABLED
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { import { getCurrencyFromSymbol } from '@ghostfolio/common/helper';
getAssetProfileIdentifier,
getCurrencyFromSymbol
} from '@ghostfolio/common/helper';
import { import {
AdminData, AdminData,
AdminUserResponse, AdminUserResponse,
@ -71,17 +68,14 @@ export class AdminService {
{ dataSource, symbol } { dataSource, symbol }
]); ]);
const assetProfile = if (!assetProfiles[symbol]?.currency) {
assetProfiles[getAssetProfileIdentifier({ dataSource, symbol })];
if (!assetProfile?.currency) {
throw new BadRequestException( throw new BadRequestException(
`Asset profile not found for ${symbol} (${dataSource})` `Asset profile not found for ${symbol} (${dataSource})`
); );
} }
return this.symbolProfileService.add( return this.symbolProfileService.add(
assetProfile as Prisma.SymbolProfileCreateInput assetProfiles[symbol] as Prisma.SymbolProfileCreateInput
); );
} catch (error) { } catch (error) {
if ( if (

2
apps/api/src/app/admin/queue/queue.service.ts

@ -56,7 +56,7 @@ export class QueueService {
} }
public async getJobs({ public async getJobs({
limit = 5000, limit = 1000,
status = QUEUE_JOB_STATUS_LIST status = QUEUE_JOB_STATUS_LIST
}: { }: {
limit?: number; limit?: number;

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

@ -81,14 +81,6 @@ export class BenchmarksService {
}) })
]); ]);
if (!currentSymbolItem) {
this.logger.error(
`No current market price is available for ${symbol} (${dataSource})`
);
return { marketData };
}
const exchangeRates = const exchangeRates =
await this.exchangeRateDataService.getExchangeRatesByCurrency({ await this.exchangeRateDataService.getExchangeRatesByCurrency({
startDate, startDate,

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

@ -16,7 +16,6 @@ import {
DERIVED_CURRENCIES DERIVED_CURRENCIES
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { PROPERTY_DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER_MAX_REQUESTS } from '@ghostfolio/common/config'; import { PROPERTY_DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER_MAX_REQUESTS } from '@ghostfolio/common/config';
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
import { import {
DataProviderGhostfolioAssetProfileResponse, DataProviderGhostfolioAssetProfileResponse,
DataProviderHistoricalResponse, DataProviderHistoricalResponse,
@ -61,13 +60,7 @@ export class GhostfolioService {
} }
]) ])
.then(async (assetProfiles) => { .then(async (assetProfiles) => {
const assetProfile = const assetProfile = assetProfiles[symbol];
assetProfiles[
getAssetProfileIdentifier({
symbol,
dataSource: dataProviderService.getName()
})
];
const dataSourceOrigin = DataSource.GHOSTFOLIO; const dataSourceOrigin = DataSource.GHOSTFOLIO;
if (assetProfile) { if (assetProfile) {

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

@ -9,18 +9,18 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
import { DEFAULT_CURRENCY } from '@ghostfolio/common/config'; import { DEFAULT_CURRENCY } from '@ghostfolio/common/config';
import { SubscriptionType } from '@ghostfolio/common/enums'; import { SubscriptionType } from '@ghostfolio/common/enums';
import { getSum } from '@ghostfolio/common/helper'; import { getSum } from '@ghostfolio/common/helper';
import { import { PublicPortfolioResponse } from '@ghostfolio/common/interfaces';
AccessSettings, import type { RequestWithUser } from '@ghostfolio/common/types';
PublicPortfolioResponse
} from '@ghostfolio/common/interfaces';
import { import {
Controller, Controller,
Get, Get,
HttpException, HttpException,
Inject,
Param, Param,
UseInterceptors UseInterceptors
} from '@nestjs/common'; } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { import {
AssetClass, AssetClass,
AssetSubClass, AssetSubClass,
@ -37,6 +37,7 @@ export class PublicController {
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly portfolioService: PortfolioService, private readonly portfolioService: PortfolioService,
@Inject(REQUEST) private readonly request: RequestWithUser,
private readonly userService: UserService private readonly userService: UserService
) {} ) {}
@ -65,8 +66,6 @@ export class PublicController {
hasDetails = user.subscription.type === SubscriptionType.Premium; hasDetails = user.subscription.type === SubscriptionType.Premium;
} }
const { filters } = (access.settings ?? {}) as AccessSettings;
const [ const [
{ createdAt, holdings, markets }, { createdAt, holdings, markets },
{ performance: performance1d }, { performance: performance1d },
@ -74,7 +73,6 @@ export class PublicController {
{ performance: performanceYtd } { performance: performanceYtd }
] = await Promise.all([ ] = await Promise.all([
this.portfolioService.getDetails({ this.portfolioService.getDetails({
filters,
impersonationId: access.userId, impersonationId: access.userId,
userId: user.id, userId: user.id,
withMarkets: true withMarkets: true
@ -82,7 +80,6 @@ export class PublicController {
...['1d', 'max', 'ytd'].map((dateRange) => { ...['1d', 'max', 'ytd'].map((dateRange) => {
return this.portfolioService.getPerformance({ return this.portfolioService.getPerformance({
dateRange, dateRange,
filters,
impersonationId: undefined, impersonationId: undefined,
userId: user.id userId: user.id
}); });
@ -90,7 +87,6 @@ export class PublicController {
]); ]);
const { activities } = await this.activitiesService.getActivities({ const { activities } = await this.activitiesService.getActivities({
filters,
sortColumn: 'date', sortColumn: 'date',
sortDirection: 'desc', sortDirection: 'desc',
take: 10, take: 10,
@ -164,7 +160,8 @@ export class PublicController {
this.exchangeRateDataService.toCurrency( this.exchangeRateDataService.toCurrency(
quantity * marketPrice, quantity * marketPrice,
assetProfile.currency, assetProfile.currency,
user.settings?.settings.baseCurrency ?? DEFAULT_CURRENCY this.request.user?.settings?.settings.baseCurrency ??
DEFAULT_CURRENCY
) )
); );
}) })
@ -196,11 +193,6 @@ export class PublicController {
portfolioPosition.assetProfile.assetSubClass === AssetSubClass.CASH portfolioPosition.assetProfile.assetSubClass === AssetSubClass.CASH
? portfolioPosition.assetProfile.assetSubClassLabel ? portfolioPosition.assetProfile.assetSubClassLabel
: undefined, : undefined,
holdings: portfolioPosition.assetProfile.holdings?.map(
({ allocationInPercentage, name }) => {
return { allocationInPercentage, name };
}
),
...(hasDetails ...(hasDetails
? {} ? {}
: { : {

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

@ -40,17 +40,14 @@ export class WatchlistService {
{ dataSource, symbol } { dataSource, symbol }
]); ]);
const assetProfile = if (!assetProfiles[symbol]?.currency) {
assetProfiles[getAssetProfileIdentifier({ dataSource, symbol })];
if (!assetProfile?.currency) {
throw new BadRequestException( throw new BadRequestException(
`Asset profile not found for ${symbol} (${dataSource})` `Asset profile not found for ${symbol} (${dataSource})`
); );
} }
await this.symbolProfileService.add( await this.symbolProfileService.add(
assetProfile as Prisma.SymbolProfileCreateInput assetProfiles[symbol] as Prisma.SymbolProfileCreateInput
); );
} }

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

@ -592,19 +592,18 @@ export class ImportService {
const value = new Big(quantity).mul(unitPrice).toNumber(); const value = new Big(quantity).mul(unitPrice).toNumber();
const valueInBaseCurrency = const valueInBaseCurrency = this.exchangeRateDataService.toCurrencyAtDate(
(await this.exchangeRateDataService.toCurrencyAtDate( value,
value, currency ?? assetProfile.currency,
currency ?? assetProfile.currency, userCurrency,
userCurrency, date
date );
)) ?? 0;
activities.push({ activities.push({
...order, ...order,
error, error,
value, value,
valueInBaseCurrency, valueInBaseCurrency: await valueInBaseCurrency,
// @ts-ignore // @ts-ignore
SymbolProfile: assetProfile SymbolProfile: assetProfile
}); });

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

@ -1,7 +1,6 @@
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service'; import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
import { UserService } from '@ghostfolio/api/app/user/user.service'; import { UserService } from '@ghostfolio/api/app/user/user.service';
import { encodeDataSource } from '@ghostfolio/api/helper/data-source.helper';
import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service'; import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { 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';
@ -18,6 +17,7 @@ import {
PROPERTY_UPTIME, PROPERTY_UPTIME,
ghostfolioFearAndGreedIndexDataSourceStocks ghostfolioFearAndGreedIndexDataSourceStocks
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { encodeDataSource } from '@ghostfolio/common/helper';
import { InfoItem, Statistics } from '@ghostfolio/common/interfaces'; import { InfoItem, Statistics } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';

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

@ -205,21 +205,21 @@ export class PortfolioService {
switch (type) { switch (type) {
case ActivityType.DIVIDEND: case ActivityType.DIVIDEND:
dividendInBaseCurrency += dividendInBaseCurrency +=
(await this.exchangeRateDataService.toCurrencyAtDate( await this.exchangeRateDataService.toCurrencyAtDate(
new Big(quantity).mul(unitPrice).toNumber(), new Big(quantity).mul(unitPrice).toNumber(),
currency ?? SymbolProfile.currency, currency ?? SymbolProfile.currency,
userCurrency, userCurrency,
date date
)) ?? 0; );
break; break;
case ActivityType.INTEREST: case ActivityType.INTEREST:
interestInBaseCurrency += interestInBaseCurrency +=
(await this.exchangeRateDataService.toCurrencyAtDate( await this.exchangeRateDataService.toCurrencyAtDate(
unitPrice, unitPrice,
currency ?? SymbolProfile.currency, currency ?? SymbolProfile.currency,
userCurrency, userCurrency,
date date
)) ?? 0; );
break; break;
} }
@ -889,13 +889,10 @@ export class PortfolioService {
marketPrice marketPrice
); );
const historicalDataItems = if (historicalData[symbol]) {
historicalData[getAssetProfileIdentifier({ dataSource, symbol })];
if (historicalDataItems) {
let j = -1; let j = -1;
for (const [date, { marketPrice }] of Object.entries( for (const [date, { marketPrice }] of Object.entries(
historicalDataItems historicalData[symbol]
)) { )) {
while ( while (
j + 1 < transactionPoints.length && j + 1 < transactionPoints.length &&

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

@ -94,17 +94,12 @@ export class SymbolService {
date = new Date(), date = new Date(),
symbol symbol
}: DataGatheringItem): Promise<DataProviderHistoricalResponse> { }: DataGatheringItem): Promise<DataProviderHistoricalResponse> {
const assetProfileIdentifier = getAssetProfileIdentifier({
dataSource,
symbol
});
let historicalData: { let historicalData: {
[assetProfileIdentifier: string]: { [symbol: string]: {
[date: string]: DataProviderHistoricalResponse; [date: string]: DataProviderHistoricalResponse;
}; };
} = { } = {
[assetProfileIdentifier]: {} [symbol]: {}
}; };
try { try {
@ -117,8 +112,7 @@ export class SymbolService {
return { return {
marketPrice: marketPrice:
historicalData?.[assetProfileIdentifier]?.[format(date, DATE_FORMAT)] historicalData?.[symbol]?.[format(date, DATE_FORMAT)]?.marketPrice
?.marketPrice
}; };
} }

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

@ -7,12 +7,8 @@ export function getCountryCodeByName({
aliases?: Record<string, string>; aliases?: Record<string, string>;
name: string; name: string;
}): string { }): string {
if (aliases[name]) {
return aliases[name];
}
for (const [code, country] of Object.entries(countries)) { for (const [code, country] of Object.entries(countries)) {
if (country.name === name) { if (country.name === name || country.name === aliases[name]) {
return code; return code;
} }
} }

67
apps/api/src/helper/data-source.helper.ts

@ -1,67 +0,0 @@
import { DataSource } from '@prisma/client';
import { createHash } from 'node:crypto';
const encodedDataSourceByDataSource = new Map<DataSource, string>(
Object.values(DataSource).map((dataSource) => {
return [dataSource, hashDataSource(dataSource)];
})
);
const dataSourceByEncodedDataSource = new Map<string, DataSource>(
[...encodedDataSourceByDataSource].map(([dataSource, encodedDataSource]) => {
return [encodedDataSource, dataSource];
})
);
/**
* @deprecated Backward compatibility to support importing data that was
* exported using the previous data source encoding
*/
const dataSourceByDeprecatedEncodedDataSource = new Map<string, DataSource>(
Object.values(DataSource).map((dataSource) => {
return [deprecatedHashDataSource(dataSource), dataSource];
})
);
/**
* @deprecated Backward compatibility (see above)
*/
function deprecatedHashDataSource(dataSource: DataSource) {
return Buffer.from(dataSource, 'utf-8').toString('hex');
}
function hashDataSource(dataSource: DataSource) {
return createHash('sha256').update(dataSource).digest('hex').slice(0, 8);
}
export function decodeDataSource(encodedDataSource: string) {
if (!encodedDataSource) {
return undefined;
}
return (
dataSourceByEncodedDataSource.get(encodedDataSource) ??
dataSourceByDeprecatedEncodedDataSource.get(encodedDataSource) ??
encodedDataSource
);
}
export function encodeDataSource(dataSource: DataSource) {
if (!dataSource) {
return undefined;
}
return encodedDataSourceByDataSource.get(dataSource);
}
export function getMaskedGhostfolioDataSource({
dataSource,
ghostfolioDataSources
}: {
dataSource: DataSource;
ghostfolioDataSources: string[];
}) {
return ghostfolioDataSources.includes(dataSource)
? DataSource.GHOSTFOLIO
: dataSource;
}

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

@ -2,7 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
@Injectable() @Injectable()
export class PerformanceLoggingService { export class PerformanceLoggingService {
private readonly logger = new Logger(); private readonly logger = new Logger(PerformanceLoggingService.name);
public logPerformance({ public logPerformance({
className, className,

2
apps/api/src/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor.ts

@ -1,5 +1,5 @@
import { decodeDataSource } from '@ghostfolio/api/helper/data-source.helper';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { decodeDataSource } from '@ghostfolio/common/helper';
import { import {
CallHandler, CallHandler,

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

@ -1,10 +1,6 @@
import {
encodeDataSource,
getMaskedGhostfolioDataSource
} from '@ghostfolio/api/helper/data-source.helper';
import { redactPaths } from '@ghostfolio/api/helper/object.helper'; import { redactPaths } from '@ghostfolio/api/helper/object.helper';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { hasRole } from '@ghostfolio/common/permissions'; import { encodeDataSource } from '@ghostfolio/common/helper';
import { import {
CallHandler, CallHandler,
@ -48,32 +44,20 @@ export class TransformDataSourceInResponseInterceptor<
next: CallHandler<T> next: CallHandler<T>
): Observable<any> { ): Observable<any> {
const isExportMode = context.getClass().name === 'ExportController'; const isExportMode = context.getClass().name === 'ExportController';
const { user } = context.switchToHttp().getRequest();
return next.handle().pipe( return next.handle().pipe(
map((data: any) => { map((data: any) => {
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
const valueMap = hasRole(user, 'ADMIN') const valueMap = this.encodedDataSourceMap;
? {}
: { ...this.encodedDataSourceMap };
if (isExportMode) { if (isExportMode) {
const ghostfolioDataSources = this.configurationService.get( for (const dataSource of this.configurationService.get(
'DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER' 'DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER'
) as DataSource[]; )) {
valueMap[dataSource] = 'GHOSTFOLIO';
for (const dataSource of ghostfolioDataSources) {
valueMap[dataSource] = getMaskedGhostfolioDataSource({
dataSource,
ghostfolioDataSources
});
} }
} }
if (Object.keys(valueMap).length === 0) {
return data;
}
data = redactPaths({ data = redactPaths({
valueMap, valueMap,
object: data, object: data,

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

@ -6,7 +6,6 @@ import {
DEFAULT_PORT, DEFAULT_PORT,
DEFAULT_PROCESSOR_GATHER_ASSET_PROFILE_CONCURRENCY, DEFAULT_PROCESSOR_GATHER_ASSET_PROFILE_CONCURRENCY,
DEFAULT_PROCESSOR_GATHER_HISTORICAL_MARKET_DATA_CONCURRENCY, DEFAULT_PROCESSOR_GATHER_HISTORICAL_MARKET_DATA_CONCURRENCY,
DEFAULT_PROCESSOR_GATHER_STATISTICS_CONCURRENCY,
DEFAULT_PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_CONCURRENCY, DEFAULT_PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_CONCURRENCY,
DEFAULT_PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_TIMEOUT DEFAULT_PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_TIMEOUT
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
@ -44,7 +43,6 @@ export class ConfigurationService {
ENABLE_FEATURE_AUTH_GOOGLE: bool({ default: false }), ENABLE_FEATURE_AUTH_GOOGLE: bool({ default: false }),
ENABLE_FEATURE_AUTH_OIDC: bool({ default: false }), ENABLE_FEATURE_AUTH_OIDC: bool({ default: false }),
ENABLE_FEATURE_AUTH_TOKEN: bool({ default: true }), ENABLE_FEATURE_AUTH_TOKEN: bool({ default: true }),
ENABLE_FEATURE_CRON: bool({ default: true }),
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: bool({ default: false }), ENABLE_FEATURE_FEAR_AND_GREED_INDEX: bool({ default: false }),
ENABLE_FEATURE_GATHER_NEW_EXCHANGE_RATES: bool({ default: true }), ENABLE_FEATURE_GATHER_NEW_EXCHANGE_RATES: bool({ default: true }),
ENABLE_FEATURE_READ_ONLY_MODE: bool({ default: false }), ENABLE_FEATURE_READ_ONLY_MODE: bool({ default: false }),
@ -90,9 +88,6 @@ export class ConfigurationService {
PROCESSOR_GATHER_HISTORICAL_MARKET_DATA_CONCURRENCY: num({ PROCESSOR_GATHER_HISTORICAL_MARKET_DATA_CONCURRENCY: num({
default: DEFAULT_PROCESSOR_GATHER_HISTORICAL_MARKET_DATA_CONCURRENCY default: DEFAULT_PROCESSOR_GATHER_HISTORICAL_MARKET_DATA_CONCURRENCY
}), }),
PROCESSOR_GATHER_STATISTICS_CONCURRENCY: num({
default: DEFAULT_PROCESSOR_GATHER_STATISTICS_CONCURRENCY
}),
PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_CONCURRENCY: num({ PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_CONCURRENCY: num({
default: DEFAULT_PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_CONCURRENCY default: DEFAULT_PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_CONCURRENCY
}), }),

47
apps/api/src/services/cron/cron.module.ts

@ -1,17 +1,12 @@
import { UserModule } from '@ghostfolio/api/app/user/user.module'; import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { UserService } from '@ghostfolio/api/app/user/user.service';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
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 { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service';
import { StatisticsGatheringQueueModule } from '@ghostfolio/api/services/queues/statistics-gathering/statistics-gathering.module'; import { StatisticsGatheringQueueModule } from '@ghostfolio/api/services/queues/statistics-gathering/statistics-gathering.module';
import { StatisticsGatheringService } from '@ghostfolio/api/services/queues/statistics-gathering/statistics-gathering.service';
import { TwitterBotModule } from '@ghostfolio/api/services/twitter-bot/twitter-bot.module'; import { TwitterBotModule } from '@ghostfolio/api/services/twitter-bot/twitter-bot.module';
import { TwitterBotService } from '@ghostfolio/api/services/twitter-bot/twitter-bot.service';
import { Logger, Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { CronService } from './cron.service'; import { CronService } from './cron.service';
@ -19,46 +14,12 @@ import { CronService } from './cron.service';
imports: [ imports: [
ConfigurationModule, ConfigurationModule,
DataGatheringQueueModule, DataGatheringQueueModule,
ExchangeRateDataModule,
PropertyModule, PropertyModule,
StatisticsGatheringQueueModule, StatisticsGatheringQueueModule,
TwitterBotModule, TwitterBotModule,
UserModule UserModule
], ],
providers: [ providers: [CronService]
{
inject: [
ConfigurationService,
DataGatheringService,
PropertyService,
StatisticsGatheringService,
TwitterBotService,
UserService
],
provide: CronService,
useFactory: (
configurationService: ConfigurationService,
dataGatheringService: DataGatheringService,
propertyService: PropertyService,
statisticsGatheringService: StatisticsGatheringService,
twitterBotService: TwitterBotService,
userService: UserService
) => {
if (!configurationService.get('ENABLE_FEATURE_CRON')) {
Logger.log('Scheduled cron jobs are disabled', 'CronService');
return null;
}
return new CronService(
configurationService,
dataGatheringService,
propertyService,
statisticsGatheringService,
twitterBotService,
userService
);
}
}
]
}) })
export class CronModule {} export class CronModule {}

11
apps/api/src/services/cron/cron.service.ts

@ -1,5 +1,6 @@
import { UserService } from '@ghostfolio/api/app/user/user.service'; import { UserService } from '@ghostfolio/api/app/user/user.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.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 { StatisticsGatheringService } from '@ghostfolio/api/services/queues/statistics-gathering/statistics-gathering.service'; import { StatisticsGatheringService } from '@ghostfolio/api/services/queues/statistics-gathering/statistics-gathering.service';
@ -23,6 +24,7 @@ export class CronService {
public constructor( public constructor(
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly dataGatheringService: DataGatheringService, private readonly dataGatheringService: DataGatheringService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly propertyService: PropertyService, private readonly propertyService: PropertyService,
private readonly statisticsGatheringService: StatisticsGatheringService, private readonly statisticsGatheringService: StatisticsGatheringService,
private readonly twitterBotService: TwitterBotService, private readonly twitterBotService: TwitterBotService,
@ -39,11 +41,16 @@ export class CronService {
@Cron(CronService.EVERY_HOUR_AT_RANDOM_MINUTE) @Cron(CronService.EVERY_HOUR_AT_RANDOM_MINUTE)
public async runEveryHourAtRandomMinute() { public async runEveryHourAtRandomMinute() {
if (await this.isDataGatheringEnabled()) { if (await this.isDataGatheringEnabled()) {
await this.dataGatheringService.gatherHourlyMarketData(); await this.dataGatheringService.gather7Days();
await this.dataGatheringService.gatherRecentMarketData(); await this.dataGatheringService.gatherHourlySymbols();
} }
} }
@Cron(CronExpression.EVERY_12_HOURS)
public async runEveryTwelveHours() {
await this.exchangeRateDataService.loadCurrencies();
}
@Cron(CronExpression.EVERY_DAY_AT_5PM) @Cron(CronExpression.EVERY_DAY_AT_5PM)
public async runEveryDayAtFivePm() { public async runEveryDayAtFivePm() {
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {

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

@ -13,18 +13,12 @@ import { SymbolProfile } from '@prisma/client';
@Injectable() @Injectable()
export class TrackinsightDataEnhancerService implements DataEnhancerInterface { export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
private static baseUrl = 'https://www.trackinsight.com'; private static baseUrl = 'https://www.trackinsight.com/data-api';
private static countriesMapping = { private static countriesMapping = {
'Republic of Korea': 'KR', 'Russian Federation': 'Russia',
'Russian Federation': 'RU', USA: 'United States'
Turkey: 'TR',
USA: 'US',
'Virgin Islands, British': 'VG'
}; };
private static holdingsWeightTreshold = 0.85; private static holdingsWeightTreshold = 0.85;
private static sectorsMapping: Record<string, SectorName> = { private static sectorsMapping: Record<string, SectorName> = {
'Consumer Discretionary': 'Consumer Cyclical', 'Consumer Discretionary': 'Consumer Cyclical',
'Consumer Staples': 'Consumer Defensive', 'Consumer Staples': 'Consumer Defensive',
@ -77,7 +71,7 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
const profile = await this.fetchService const profile = await this.fetchService
.fetch( .fetch(
`${TrackinsightDataEnhancerService.baseUrl}/data-api/funds/${trackinsightSymbol}.json`, `${TrackinsightDataEnhancerService.baseUrl}/funds/${trackinsightSymbol}.json`,
{ {
signal: AbortSignal.timeout(requestTimeout) signal: AbortSignal.timeout(requestTimeout)
} }
@ -101,7 +95,7 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
const holdings = await this.fetchService const holdings = await this.fetchService
.fetch( .fetch(
`${TrackinsightDataEnhancerService.baseUrl}/data-api/holdings/${trackinsightSymbol}.json`, `${TrackinsightDataEnhancerService.baseUrl}/holdings/${trackinsightSymbol}.json`,
{ {
signal: AbortSignal.timeout(requestTimeout) signal: AbortSignal.timeout(requestTimeout)
} }
@ -194,7 +188,7 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
}) { }) {
return this.fetchService return this.fetchService
.fetch( .fetch(
`${TrackinsightDataEnhancerService.baseUrl}/search-api/search_v2/${symbol}/_/ticker/default/0/3`, `https://www.trackinsight.com/search-api/search_v2/${symbol}/_/ticker/default/0/3`,
{ {
signal: AbortSignal.timeout(requestTimeout) signal: AbortSignal.timeout(requestTimeout)
} }

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

@ -1,6 +1,5 @@
import { ImportDataDto } from '@ghostfolio/api/app/import/import-data.dto'; import { ImportDataDto } from '@ghostfolio/api/app/import/import-data.dto';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { getMaskedGhostfolioDataSource } from '@ghostfolio/api/helper/data-source.helper';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface'; import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
@ -87,11 +86,12 @@ export class DataProviderService implements OnModuleInit {
return false; return false;
} }
// TODO: Change symbol in response to assetProfileIdentifier
public async getAssetProfiles(items: AssetProfileIdentifier[]): Promise<{ public async getAssetProfiles(items: AssetProfileIdentifier[]): Promise<{
[assetProfileIdentifier: string]: Partial<SymbolProfile>; [symbol: string]: Partial<SymbolProfile>;
}> { }> {
const response: { const response: {
[assetProfileIdentifier: string]: Partial<SymbolProfile>; [symbol: string]: Partial<SymbolProfile>;
} = {}; } = {};
const itemsGroupedByDataSource = groupBy(items, ({ dataSource }) => { const itemsGroupedByDataSource = groupBy(items, ({ dataSource }) => {
@ -117,12 +117,7 @@ export class DataProviderService implements OnModuleInit {
promises.push( promises.push(
promise.then((assetProfile) => { promise.then((assetProfile) => {
if (isCurrency(assetProfile?.currency)) { if (isCurrency(assetProfile?.currency)) {
response[ response[symbol] = assetProfile;
getAssetProfileIdentifier({
symbol,
dataSource: DataSource[dataSource]
})
] = { ...assetProfile, symbol };
} }
}) })
); );
@ -222,9 +217,6 @@ export class DataProviderService implements OnModuleInit {
} = {}; } = {};
const dataSources = await this.getDataSources(); const dataSources = await this.getDataSources();
const ghostfolioDataSources = this.configurationService.get(
'DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER'
);
for (const [ for (const [
index, index,
@ -232,10 +224,6 @@ export class DataProviderService implements OnModuleInit {
] of activitiesDto.entries()) { ] of activitiesDto.entries()) {
const activityPath = const activityPath =
maxActivitiesToImport === 1 ? 'activity' : `activities.${index}`; maxActivitiesToImport === 1 ? 'activity' : `activities.${index}`;
const maskedDataSource = getMaskedGhostfolioDataSource({
dataSource,
ghostfolioDataSources
});
if (!dataSources.includes(dataSource)) { if (!dataSources.includes(dataSource)) {
throw new Error( throw new Error(
@ -251,7 +239,7 @@ export class DataProviderService implements OnModuleInit {
if (dataProvider.getDataProviderInfo().isPremium) { if (dataProvider.getDataProviderInfo().isPremium) {
throw new Error( throw new Error(
`${activityPath}.dataSource ("${maskedDataSource}") requires Ghostfolio Premium` `${activityPath}.dataSource ("${dataSource}") is not valid`
); );
} }
} }
@ -295,7 +283,7 @@ export class DataProviderService implements OnModuleInit {
symbol symbol
} }
]) ])
)?.[assetProfileIdentifier]; )?.[symbol];
} catch {} } catch {}
if (!assetProfile?.name) { if (!assetProfile?.name) {
@ -314,7 +302,7 @@ export class DataProviderService implements OnModuleInit {
if (!assetProfile?.name) { if (!assetProfile?.name) {
throw new Error( throw new Error(
`activities.${index}.symbol ("${symbol}") is not valid for the specified data source ("${maskedDataSource}")` `activities.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")`
); );
} }
@ -345,20 +333,17 @@ export class DataProviderService implements OnModuleInit {
}); });
} }
// TODO: Change symbol in response to assetProfileIdentifier
public async getHistorical( public async getHistorical(
aItems: AssetProfileIdentifier[], aItems: AssetProfileIdentifier[],
aGranularity: Granularity = 'month', aGranularity: Granularity = 'month',
from: Date, from: Date,
to: Date to: Date
): Promise<{ ): Promise<{
[assetProfileIdentifier: string]: { [symbol: string]: { [date: string]: DataProviderHistoricalResponse };
[date: string]: DataProviderHistoricalResponse;
};
}> { }> {
let response: { let response: {
[assetProfileIdentifier: string]: { [symbol: string]: { [date: string]: DataProviderHistoricalResponse };
[date: string]: DataProviderHistoricalResponse;
};
} = {}; } = {};
if (isEmpty(aItems) || !isValid(from) || !isValid(to)) { if (isEmpty(aItems) || !isValid(from) || !isValid(to)) {
@ -398,20 +383,13 @@ export class DataProviderService implements OnModuleInit {
ORDER BY date;`; ORDER BY date;`;
response = marketDataByGranularity.reduce((r, marketData) => { response = marketDataByGranularity.reduce((r, marketData) => {
const { dataSource, date, marketPrice, symbol } = marketData; const { date, marketPrice, symbol } = marketData;
const assetProfileIdentifier = getAssetProfileIdentifier({
dataSource,
symbol
});
if (!r[assetProfileIdentifier]) { if (!r[symbol]) {
r[assetProfileIdentifier] = {}; r[symbol] = {};
} }
r[assetProfileIdentifier][format(new Date(date), DATE_FORMAT)] = { r[symbol][format(new Date(date), DATE_FORMAT)] = { marketPrice };
marketPrice
};
return r; return r;
}, {}); }, {});
@ -422,6 +400,7 @@ export class DataProviderService implements OnModuleInit {
} }
} }
// TODO: Change symbol in response to assetProfileIdentifier
public async getHistoricalRaw({ public async getHistoricalRaw({
assetProfileIdentifiers, assetProfileIdentifiers,
from, from,
@ -431,9 +410,7 @@ export class DataProviderService implements OnModuleInit {
from: Date; from: Date;
to: Date; to: Date;
}): Promise<{ }): Promise<{
[assetProfileIdentifier: string]: { [symbol: string]: { [date: string]: DataProviderHistoricalResponse };
[date: string]: DataProviderHistoricalResponse;
};
}> { }> {
for (const { currency, rootCurrency } of DERIVED_CURRENCIES) { for (const { currency, rootCurrency } of DERIVED_CURRENCIES) {
if ( if (
@ -466,14 +443,11 @@ export class DataProviderService implements OnModuleInit {
); );
const result: { const result: {
[assetProfileIdentifier: string]: { [symbol: string]: { [date: string]: DataProviderHistoricalResponse };
[date: string]: DataProviderHistoricalResponse;
};
} = {}; } = {};
const promises: Promise<{ const promises: Promise<{
data: { [date: string]: DataProviderHistoricalResponse }; data: { [date: string]: DataProviderHistoricalResponse };
dataSource: DataSource;
symbol: string; symbol: string;
}>[] = []; }>[] = [];
for (const { dataSource, symbol } of assetProfileIdentifiers) { for (const { dataSource, symbol } of assetProfileIdentifiers) {
@ -491,7 +465,6 @@ export class DataProviderService implements OnModuleInit {
promises.push( promises.push(
Promise.resolve({ Promise.resolve({
data, data,
dataSource,
symbol symbol
}) })
); );
@ -505,7 +478,7 @@ export class DataProviderService implements OnModuleInit {
requestTimeout: ms('30 seconds') requestTimeout: ms('30 seconds')
}) })
.then((data) => { .then((data) => {
return { dataSource, symbol, data: data?.[symbol] }; return { symbol, data: data?.[symbol] };
}) })
); );
} }
@ -515,26 +488,22 @@ export class DataProviderService implements OnModuleInit {
try { try {
const allData = await Promise.all(promises); const allData = await Promise.all(promises);
for (const { data, dataSource, symbol } of allData) { for (const { data, symbol } of allData) {
const currency = DERIVED_CURRENCIES.find(({ rootCurrency }) => { const currency = DERIVED_CURRENCIES.find(({ rootCurrency }) => {
return `${DEFAULT_CURRENCY}${rootCurrency}` === symbol; return `${DEFAULT_CURRENCY}${rootCurrency}` === symbol;
}); });
if (currency) { if (currency) {
// Add derived currency // Add derived currency
result[ result[`${DEFAULT_CURRENCY}${currency.currency}`] =
getAssetProfileIdentifier({ this.transformHistoricalData({
dataSource, allData,
symbol: `${DEFAULT_CURRENCY}${currency.currency}` currency: `${DEFAULT_CURRENCY}${currency.rootCurrency}`,
}) factor: currency.factor
] = this.transformHistoricalData({ });
allData,
currency: `${DEFAULT_CURRENCY}${currency.rootCurrency}`,
factor: currency.factor
});
} }
result[getAssetProfileIdentifier({ dataSource, symbol })] = data; result[symbol] = data;
} }
} catch (error) { } catch (error) {
this.logger.error(error); this.logger.error(error);
@ -670,11 +639,7 @@ export class DataProviderService implements OnModuleInit {
); );
const promise = Promise.resolve( const promise = Promise.resolve(
dataProvider.getQuotes({ dataProvider.getQuotes({ requestTimeout, symbols: symbolsChunk })
requestTimeout,
useCache,
symbols: symbolsChunk
})
); );
promises.push( promises.push(

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

@ -54,9 +54,9 @@ export class FinancialModelingPrepService
implements DataProviderInterface, OnModuleInit implements DataProviderInterface, OnModuleInit
{ {
private static countriesMapping = { private static countriesMapping = {
'Korea (the Republic of)': 'KR', 'Korea (the Republic of)': 'South Korea',
'Russian Federation': 'RU', 'Russian Federation': 'Russia',
'Taiwan (Province of China)': 'TW' 'Taiwan (Province of China)': 'Taiwan'
}; };
private readonly logger = new Logger(FinancialModelingPrepService.name); private readonly logger = new Logger(FinancialModelingPrepService.name);

1
apps/api/src/services/data-provider/interfaces/data-provider.interface.ts

@ -75,7 +75,6 @@ export interface GetHistoricalParams {
export interface GetQuotesParams { export interface GetQuotesParams {
requestTimeout?: number; requestTimeout?: number;
symbols: string[]; symbols: string[];
useCache?: boolean;
} }
export interface GetSearchParams { export interface GetSearchParams {

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

@ -136,8 +136,7 @@ export class ManualService implements DataProviderInterface {
} }
public async getQuotes({ public async getQuotes({
symbols, symbols
useCache = true
}: GetQuotesParams): Promise<{ [symbol: string]: DataProviderResponse }> { }: GetQuotesParams): Promise<{ [symbol: string]: DataProviderResponse }> {
const response: { [symbol: string]: DataProviderResponse } = {}; const response: { [symbol: string]: DataProviderResponse } = {};
@ -165,32 +164,32 @@ export class ManualService implements DataProviderInterface {
} }
}); });
const symbolProfilesToScrape = symbolProfiles.filter( const symbolProfilesWithScraperConfigurationAndInstantMode =
({ scraperConfiguration }) => { symbolProfiles.filter(({ scraperConfiguration }) => {
return ( return (
(scraperConfiguration?.mode === 'instant' || !useCache) && scraperConfiguration?.mode === 'instant' &&
scraperConfiguration?.selector && scraperConfiguration?.selector &&
scraperConfiguration?.url scraperConfiguration?.url
); );
} });
);
const scraperResultPromises =
const scraperResultPromises = symbolProfilesToScrape.map( symbolProfilesWithScraperConfigurationAndInstantMode.map(
async ({ scraperConfiguration, symbol }) => { async ({ scraperConfiguration, symbol }) => {
try { try {
const marketPrice = await this.scrape({ const marketPrice = await this.scrape({
scraperConfiguration, scraperConfiguration,
symbol symbol
}); });
return { marketPrice, symbol }; return { marketPrice, symbol };
} catch (error) { } catch (error) {
this.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}`
); );
return { symbol, marketPrice: undefined }; return { symbol, marketPrice: undefined };
}
} }
} );
);
// Wait for all scraping requests to complete concurrently // Wait for all scraping requests to complete concurrently
const scraperResults = await Promise.all(scraperResultPromises); const scraperResults = await Promise.all(scraperResultPromises);

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

@ -8,7 +8,10 @@ import {
GetSearchParams GetSearchParams
} from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface'; } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
import { FetchService } from '@ghostfolio/api/services/fetch/fetch.service'; import { FetchService } from '@ghostfolio/api/services/fetch/fetch.service';
import { ghostfolioFearAndGreedIndexSymbolStocks } from '@ghostfolio/common/config'; import {
ghostfolioFearAndGreedIndexSymbol,
ghostfolioFearAndGreedIndexSymbolStocks
} from '@ghostfolio/common/config';
import { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper'; import { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper';
import { import {
DataProviderHistoricalResponse, DataProviderHistoricalResponse,
@ -61,7 +64,12 @@ export class RapidApiService implements DataProviderInterface {
[symbol: string]: { [date: string]: DataProviderHistoricalResponse }; [symbol: string]: { [date: string]: DataProviderHistoricalResponse };
}> { }> {
try { try {
if (symbol === ghostfolioFearAndGreedIndexSymbolStocks) { if (
[
ghostfolioFearAndGreedIndexSymbol,
ghostfolioFearAndGreedIndexSymbolStocks
].includes(symbol)
) {
const fgi = await this.getFearAndGreedIndex(); const fgi = await this.getFearAndGreedIndex();
return { return {
@ -98,7 +106,12 @@ export class RapidApiService implements DataProviderInterface {
try { try {
const symbol = symbols[0]; const symbol = symbols[0];
if (symbol === ghostfolioFearAndGreedIndexSymbolStocks) { if (
[
ghostfolioFearAndGreedIndexSymbol,
ghostfolioFearAndGreedIndexSymbolStocks
].includes(symbol)
) {
const fgi = await this.getFearAndGreedIndex(); const fgi = await this.getFearAndGreedIndex();
return { return {

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

@ -15,7 +15,6 @@ import {
getYesterday, getYesterday,
resetHours resetHours
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
import { DataProviderHistoricalResponse } from '@ghostfolio/common/interfaces';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { import {
@ -164,7 +163,7 @@ export class ExchangeRateDataService {
} }
public async loadCurrencies() { public async loadCurrencies() {
const historicalData = await this.dataProviderService.getHistorical( const result = await this.dataProviderService.getHistorical(
this.currencyPairs, this.currencyPairs,
'day', 'day',
getYesterday(), getYesterday(),
@ -178,21 +177,8 @@ export class ExchangeRateDataService {
requestTimeout: ms('30 seconds') requestTimeout: ms('30 seconds')
}); });
const result: {
[symbol: string]: { [date: string]: DataProviderHistoricalResponse };
} = {};
for (const { dataSource, symbol } of this.currencyPairs) { for (const { dataSource, symbol } of this.currencyPairs) {
const assetProfileIdentifier = getAssetProfileIdentifier({ const quote = quotes[getAssetProfileIdentifier({ dataSource, symbol })];
dataSource,
symbol
});
if (historicalData[assetProfileIdentifier]) {
result[symbol] = historicalData[assetProfileIdentifier];
}
const quote = quotes[assetProfileIdentifier];
if (isNumber(quote?.marketPrice)) { if (isNumber(quote?.marketPrice)) {
result[symbol] = { result[symbol] = {

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

@ -4,7 +4,6 @@ import {
PROPERTY_API_KEY_OPENROUTER, PROPERTY_API_KEY_OPENROUTER,
PROPERTY_OPENROUTER_MODEL, PROPERTY_OPENROUTER_MODEL,
PROPERTY_OPENROUTER_MODEL_WEB_FETCH, PROPERTY_OPENROUTER_MODEL_WEB_FETCH,
PROPERTY_PROXY_ROUTES,
PROPERTY_WEB_FETCH_ROUTES PROPERTY_WEB_FETCH_ROUTES
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
@ -13,7 +12,6 @@ import { createOpenRouter } from '@openrouter/ai-sdk-provider';
import { generateText, jsonSchema, tool } from 'ai'; import { generateText, jsonSchema, tool } from 'ai';
import ms from 'ms'; import ms from 'ms';
import { ProxyRoute } from './interfaces/proxy-route.interface';
import { WebFetchRoute } from './interfaces/web-fetch-route.interface'; import { WebFetchRoute } from './interfaces/web-fetch-route.interface';
@Injectable() @Injectable()
@ -23,17 +21,11 @@ export class FetchService implements OnModuleInit {
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');
private proxyRoutes: ProxyRoute[] = [];
private webFetchRoutes: WebFetchRoute[] = []; private webFetchRoutes: WebFetchRoute[] = [];
public constructor(private readonly propertyService: PropertyService) {} public constructor(private readonly propertyService: PropertyService) {}
public async onModuleInit() { public async onModuleInit() {
this.proxyRoutes =
(await this.propertyService.getByKey<ProxyRoute[]>(
PROPERTY_PROXY_ROUTES
)) ?? [];
this.webFetchRoutes = this.webFetchRoutes =
(await this.propertyService.getByKey<WebFetchRoute[]>( (await this.propertyService.getByKey<WebFetchRoute[]>(
PROPERTY_WEB_FETCH_ROUTES PROPERTY_WEB_FETCH_ROUTES
@ -67,10 +59,8 @@ export class FetchService implements OnModuleInit {
} }
} }
const proxiedInput = this.applyProxyRoute(input);
try { try {
return await globalThis.fetch(proxiedInput, init); return await globalThis.fetch(input, init);
} catch (error) { } catch (error) {
if (error instanceof Error) { if (error instanceof Error) {
this.logger.error( this.logger.error(
@ -181,73 +171,18 @@ export class FetchService implements OnModuleInit {
} }
} }
/**
* Rewrites the origin (protocol, host and port) of a request when its domain
* matches a configured {@link ProxyRoute}, preserving path and query. Returns
* the input unchanged when no route matches or parsing fails.
*/
private applyProxyRoute(input: RequestInfo | URL): RequestInfo | URL {
let requestUrl: URL;
try {
requestUrl = new URL(
input instanceof Request ? input.url : input.toString()
);
} catch {
return input;
}
const route = this.proxyRoutes.find(({ domain }) => {
return this.hostnameMatchesDomain({
domain,
hostname: requestUrl.hostname
});
});
if (!route) {
return input;
}
try {
const proxyUrl = new URL(route.url);
requestUrl.host = proxyUrl.host;
requestUrl.protocol = proxyUrl.protocol;
} catch {
this.logger.warn(
`Skipping proxy route for "${route.domain}": invalid url "${route.url}"`
);
return input;
}
return input instanceof Request
? new Request(requestUrl.toString(), input)
: requestUrl.toString();
}
private getMatchingWebFetchRoute(url: string) { private getMatchingWebFetchRoute(url: string) {
try { try {
const { hostname } = new URL(url); const { hostname } = new URL(url);
return this.webFetchRoutes.find(({ domain }) => { return this.webFetchRoutes.find(({ domain }) => {
return this.hostnameMatchesDomain({ domain, hostname }); return hostname === domain || hostname.endsWith(`.${domain}`);
}); });
} catch { } catch {
return undefined; return undefined;
} }
} }
private hostnameMatchesDomain({
domain,
hostname
}: {
domain: string;
hostname: string;
}): boolean {
return hostname === domain || hostname.endsWith(`.${domain}`);
}
private redactUrl(rawUrl: string): string { private redactUrl(rawUrl: string): string {
try { try {
const url = new URL(rawUrl); const url = new URL(rawUrl);

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

@ -1,19 +0,0 @@
/**
* Overrides the origin (protocol, host and port) of outgoing requests for a
* given domain.
*
* Configured via the `PROXY_ROUTES` property as a JSON array, e.g.
*
* [
* {
* "domain": "example.com",
* "url": "http://example-proxy:8191"
* }
* ]
*
* Matches the domain itself and its subdomains (e.g. `api.example.com`).
*/
export interface ProxyRoute {
domain: string;
url: string;
}

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

@ -19,7 +19,6 @@ export interface Environment extends CleanedEnvAccessors {
ENABLE_FEATURE_AUTH_GOOGLE: boolean; ENABLE_FEATURE_AUTH_GOOGLE: boolean;
ENABLE_FEATURE_AUTH_OIDC: boolean; ENABLE_FEATURE_AUTH_OIDC: boolean;
ENABLE_FEATURE_AUTH_TOKEN: boolean; ENABLE_FEATURE_AUTH_TOKEN: boolean;
ENABLE_FEATURE_CRON: boolean;
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: boolean; ENABLE_FEATURE_FEAR_AND_GREED_INDEX: boolean;
ENABLE_FEATURE_GATHER_NEW_EXCHANGE_RATES: boolean; ENABLE_FEATURE_GATHER_NEW_EXCHANGE_RATES: boolean;
ENABLE_FEATURE_READ_ONLY_MODE: boolean; ENABLE_FEATURE_READ_ONLY_MODE: boolean;
@ -45,7 +44,6 @@ export interface Environment extends CleanedEnvAccessors {
PORT: number; PORT: number;
PROCESSOR_GATHER_ASSET_PROFILE_CONCURRENCY: number; PROCESSOR_GATHER_ASSET_PROFILE_CONCURRENCY: number;
PROCESSOR_GATHER_HISTORICAL_MARKET_DATA_CONCURRENCY: number; PROCESSOR_GATHER_HISTORICAL_MARKET_DATA_CONCURRENCY: number;
PROCESSOR_GATHER_STATISTICS_CONCURRENCY: number;
PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_CONCURRENCY: number; PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_CONCURRENCY: number;
PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_TIMEOUT: number; PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_TIMEOUT: number;
REDIS_DB: number; REDIS_DB: number;

74
apps/api/src/services/market-data/market-data.service.ts

@ -1,7 +1,6 @@
import { DateQuery } from '@ghostfolio/api/app/portfolio/interfaces/date-query.interface'; import { DateQuery } from '@ghostfolio/api/app/portfolio/interfaces/date-query.interface';
import { DataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces'; import { DataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { DEFAULT_PROCESSOR_GATHER_HISTORICAL_MARKET_DATA_TIMEOUT } from '@ghostfolio/common/config';
import { UpdateMarketDataDto } from '@ghostfolio/common/dtos'; import { UpdateMarketDataDto } from '@ghostfolio/common/dtos';
import { resetHours } from '@ghostfolio/common/helper'; import { resetHours } from '@ghostfolio/common/helper';
import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces'; import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
@ -156,52 +155,49 @@ export class MarketDataService {
dataSource, dataSource,
symbol symbol
}: AssetProfileIdentifier & { data: Prisma.MarketDataUpdateInput[] }) { }: AssetProfileIdentifier & { data: Prisma.MarketDataUpdateInput[] }) {
await this.prismaService.$transaction( await this.prismaService.$transaction(async (prisma) => {
async (prisma) => { if (data.length > 0) {
if (data.length > 0) { let minTime = Infinity;
let minTime = Infinity; let maxTime = -Infinity;
let maxTime = -Infinity;
for (const { date } of data) { for (const { date } of data) {
const time = (date as Date).getTime(); const time = (date as Date).getTime();
if (time < minTime) { if (time < minTime) {
minTime = time; minTime = time;
} }
if (time > maxTime) { if (time > maxTime) {
maxTime = time; maxTime = time;
}
} }
}
const minDate = new Date(minTime); const minDate = new Date(minTime);
const maxDate = new Date(maxTime); const maxDate = new Date(maxTime);
await prisma.marketData.deleteMany({ await prisma.marketData.deleteMany({
where: { where: {
dataSource, dataSource,
symbol, symbol,
date: { date: {
gte: minDate, gte: minDate,
lte: maxDate lte: maxDate
}
} }
}); }
});
await prisma.marketData.createMany({ await prisma.marketData.createMany({
data: data.map(({ date, marketPrice, state }) => ({ data: data.map(({ date, marketPrice, state }) => ({
dataSource, dataSource,
symbol, symbol,
date: date as Date, date: date as Date,
marketPrice: marketPrice as number, marketPrice: marketPrice as number,
state: state as MarketDataState state: state as MarketDataState
})), })),
skipDuplicates: true skipDuplicates: true
}); });
} }
}, });
{ timeout: DEFAULT_PROCESSOR_GATHER_HISTORICAL_MARKET_DATA_TIMEOUT }
);
} }
public async updateAssetProfileIdentifier( public async updateAssetProfileIdentifier(

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

@ -28,8 +28,7 @@ import { DataGatheringProcessor } from './data-gathering.processor';
}), }),
BullModule.registerQueue({ BullModule.registerQueue({
limiter: { limiter: {
duration: ms('3 seconds'), duration: ms('4 seconds'),
groupKey: 'dataSource',
max: 1 max: 1
}, },
name: DATA_GATHERING_QUEUE name: DATA_GATHERING_QUEUE

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

@ -10,11 +10,7 @@ import {
GATHER_ASSET_PROFILE_PROCESS_JOB_NAME, GATHER_ASSET_PROFILE_PROCESS_JOB_NAME,
GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { import { DATE_FORMAT, getStartOfUtcDate } from '@ghostfolio/common/helper';
DATE_FORMAT,
getAssetProfileIdentifier,
getStartOfUtcDate
} from '@ghostfolio/common/helper';
import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces'; import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
import { Process, Processor } from '@nestjs/bull'; import { Process, Processor } from '@nestjs/bull';
@ -118,11 +114,6 @@ export class DataGatheringProcessor {
to: new Date() to: new Date()
}); });
const assetProfileIdentifier = getAssetProfileIdentifier({
dataSource,
symbol
});
const data: Prisma.MarketDataUpdateInput[] = []; const data: Prisma.MarketDataUpdateInput[] = [];
let lastMarketPrice: number; let lastMarketPrice: number;
@ -140,14 +131,12 @@ export class DataGatheringProcessor {
) )
) { ) {
if ( if (
historicalData[assetProfileIdentifier]?.[ historicalData[symbol]?.[format(currentDate, DATE_FORMAT)]
format(currentDate, DATE_FORMAT) ?.marketPrice
]?.marketPrice
) { ) {
lastMarketPrice = lastMarketPrice =
historicalData[assetProfileIdentifier]?.[ historicalData[symbol]?.[format(currentDate, DATE_FORMAT)]
format(currentDate, DATE_FORMAT) ?.marketPrice;
]?.marketPrice;
} }
if (lastMarketPrice) { if (lastMarketPrice) {

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

@ -69,6 +69,89 @@ export class DataGatheringService {
return this.dataGatheringQueue.addBulk(jobs); return this.dataGatheringQueue.addBulk(jobs);
} }
public async gather7Days() {
await this.gatherSymbols({
dataGatheringItems: await this.getCurrencies7D(),
priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH
});
await this.gatherSymbols({
dataGatheringItems: await this.getSymbols7D({
withUserSubscription: true
}),
priority: DATA_GATHERING_QUEUE_PRIORITY_MEDIUM
});
await this.gatherSymbols({
dataGatheringItems: await this.getSymbols7D({
withUserSubscription: false
}),
priority: DATA_GATHERING_QUEUE_PRIORITY_LOW
});
}
public async gatherMax() {
const dataGatheringItems = await this.getSymbolsMax();
await this.gatherSymbols({
dataGatheringItems,
priority: DATA_GATHERING_QUEUE_PRIORITY_LOW
});
}
public async gatherSymbol({ dataSource, date, symbol }: DataGatheringItem) {
const dataGatheringItems = (await this.getSymbolsMax())
.filter((dataGatheringItem) => {
return (
dataGatheringItem.dataSource === dataSource &&
dataGatheringItem.symbol === symbol
);
})
.map((item) => ({
...item,
date: date ?? item.date
}));
await this.gatherSymbols({
dataGatheringItems,
force: true,
priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH
});
}
public async gatherSymbolForDate({
dataSource,
date,
symbol
}: { date: Date } & AssetProfileIdentifier) {
try {
const historicalData = await this.dataProviderService.getHistoricalRaw({
assetProfileIdentifiers: [{ dataSource, symbol }],
from: date,
to: date
});
const marketPrice =
historicalData[symbol][format(date, DATE_FORMAT)].marketPrice;
if (marketPrice) {
return await this.prismaService.marketData.upsert({
create: {
dataSource,
date,
marketPrice,
symbol
},
update: { marketPrice },
where: { dataSource_date_symbol: { dataSource, date, symbol } }
});
}
} catch (error) {
this.logger.error(error);
} finally {
return undefined;
}
}
public async gatherAssetProfiles( public async gatherAssetProfiles(
aAssetProfileIdentifiers?: AssetProfileIdentifier[] aAssetProfileIdentifiers?: AssetProfileIdentifier[]
) { ) {
@ -93,9 +176,7 @@ export class DataGatheringService {
assetProfileIdentifiers assetProfileIdentifiers
); );
for (const assetProfile of Object.values(assetProfiles)) { for (const [symbol, assetProfile] of Object.entries(assetProfiles)) {
const { symbol } = assetProfile;
const symbolProfile = symbolProfiles.find( const symbolProfile = symbolProfiles.find(
({ symbol: symbolProfileSymbol }) => { ({ symbol: symbolProfileSymbol }) => {
return symbolProfileSymbol === symbol; return symbolProfileSymbol === symbol;
@ -197,13 +278,7 @@ export class DataGatheringService {
} }
} }
public async gatherHourlyMarketData() { public async gatherHourlySymbols() {
try {
await this.exchangeRateDataService.loadCurrencies();
} catch (error) {
this.logger.error('Could not gather exchange rates', error);
}
const assetProfileIdentifiers = const assetProfileIdentifiers =
await this.getHourlyAssetProfileIdentifiers(); await this.getHourlyAssetProfileIdentifiers();
@ -243,91 +318,6 @@ export class DataGatheringService {
} }
} }
public async gatherMax() {
const dataGatheringItems = await this.getSymbolsMax();
await this.gatherSymbols({
dataGatheringItems,
priority: DATA_GATHERING_QUEUE_PRIORITY_LOW
});
}
public async gatherRecentMarketData() {
await this.gatherSymbols({
dataGatheringItems: await this.getCurrencies7D(),
priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH
});
await this.gatherSymbols({
dataGatheringItems: await this.getSymbols7D({
withUserSubscription: true
}),
priority: DATA_GATHERING_QUEUE_PRIORITY_MEDIUM
});
await this.gatherSymbols({
dataGatheringItems: await this.getSymbols7D({
withUserSubscription: false
}),
priority: DATA_GATHERING_QUEUE_PRIORITY_LOW
});
}
public async gatherSymbol({ dataSource, date, symbol }: DataGatheringItem) {
const dataGatheringItems = (await this.getSymbolsMax())
.filter((dataGatheringItem) => {
return (
dataGatheringItem.dataSource === dataSource &&
dataGatheringItem.symbol === symbol
);
})
.map((item) => ({
...item,
date: date ?? item.date
}));
await this.gatherSymbols({
dataGatheringItems,
force: true,
priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH
});
}
public async gatherSymbolForDate({
dataSource,
date,
symbol
}: { date: Date } & AssetProfileIdentifier) {
try {
const historicalData = await this.dataProviderService.getHistoricalRaw({
assetProfileIdentifiers: [{ dataSource, symbol }],
from: date,
to: date
});
const marketPrice =
historicalData[getAssetProfileIdentifier({ dataSource, symbol })][
format(date, DATE_FORMAT)
].marketPrice;
if (marketPrice) {
return await this.prismaService.marketData.upsert({
create: {
dataSource,
date,
marketPrice,
symbol
},
update: { marketPrice },
where: { dataSource_date_symbol: { dataSource, date, symbol } }
});
}
} catch (error) {
this.logger.error(error);
} finally {
return undefined;
}
}
public async gatherSymbols({ public async gatherSymbols({
dataGatheringItems, dataGatheringItems,
force = false, force = false,

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

@ -2,7 +2,6 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration/con
import { FetchService } from '@ghostfolio/api/services/fetch/fetch.service'; import { FetchService } from '@ghostfolio/api/services/fetch/fetch.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { import {
DEFAULT_PROCESSOR_GATHER_STATISTICS_CONCURRENCY,
GATHER_STATISTICS_DOCKER_HUB_PULLS_PROCESS_JOB_NAME, GATHER_STATISTICS_DOCKER_HUB_PULLS_PROCESS_JOB_NAME,
GATHER_STATISTICS_GITHUB_CONTRIBUTORS_PROCESS_JOB_NAME, GATHER_STATISTICS_GITHUB_CONTRIBUTORS_PROCESS_JOB_NAME,
GATHER_STATISTICS_GITHUB_STARGAZERS_PROCESS_JOB_NAME, GATHER_STATISTICS_GITHUB_STARGAZERS_PROCESS_JOB_NAME,
@ -36,14 +35,7 @@ export class StatisticsGatheringProcessor {
private readonly propertyService: PropertyService private readonly propertyService: PropertyService
) {} ) {}
@Process({ @Process(GATHER_STATISTICS_DOCKER_HUB_PULLS_PROCESS_JOB_NAME)
concurrency: parseInt(
process.env.PROCESSOR_GATHER_STATISTICS_CONCURRENCY ??
DEFAULT_PROCESSOR_GATHER_STATISTICS_CONCURRENCY.toString(),
10
),
name: GATHER_STATISTICS_DOCKER_HUB_PULLS_PROCESS_JOB_NAME
})
public async gatherDockerHubPullsStatistics() { public async gatherDockerHubPullsStatistics() {
this.logger.log('Docker Hub pulls statistics gathering has been started'); this.logger.log('Docker Hub pulls statistics gathering has been started');
@ -57,14 +49,7 @@ export class StatisticsGatheringProcessor {
this.logger.log('Docker Hub pulls statistics gathering has been completed'); this.logger.log('Docker Hub pulls statistics gathering has been completed');
} }
@Process({ @Process(GATHER_STATISTICS_GITHUB_CONTRIBUTORS_PROCESS_JOB_NAME)
concurrency: parseInt(
process.env.PROCESSOR_GATHER_STATISTICS_CONCURRENCY ??
DEFAULT_PROCESSOR_GATHER_STATISTICS_CONCURRENCY.toString(),
10
),
name: GATHER_STATISTICS_GITHUB_CONTRIBUTORS_PROCESS_JOB_NAME
})
public async gatherGitHubContributorsStatistics() { public async gatherGitHubContributorsStatistics() {
this.logger.log( this.logger.log(
'GitHub contributors statistics gathering has been started' 'GitHub contributors statistics gathering has been started'
@ -82,14 +67,7 @@ export class StatisticsGatheringProcessor {
); );
} }
@Process({ @Process(GATHER_STATISTICS_GITHUB_STARGAZERS_PROCESS_JOB_NAME)
concurrency: parseInt(
process.env.PROCESSOR_GATHER_STATISTICS_CONCURRENCY ??
DEFAULT_PROCESSOR_GATHER_STATISTICS_CONCURRENCY.toString(),
10
),
name: GATHER_STATISTICS_GITHUB_STARGAZERS_PROCESS_JOB_NAME
})
public async gatherGitHubStargazersStatistics() { public async gatherGitHubStargazersStatistics() {
this.logger.log('GitHub stargazers statistics gathering has been started'); this.logger.log('GitHub stargazers statistics gathering has been started');
@ -105,14 +83,7 @@ export class StatisticsGatheringProcessor {
); );
} }
@Process({ @Process(GATHER_STATISTICS_UPTIME_PROCESS_JOB_NAME)
concurrency: parseInt(
process.env.PROCESSOR_GATHER_STATISTICS_CONCURRENCY ??
DEFAULT_PROCESSOR_GATHER_STATISTICS_CONCURRENCY.toString(),
10
),
name: GATHER_STATISTICS_UPTIME_PROCESS_JOB_NAME
})
public async gatherUptimeStatistics() { public async gatherUptimeStatistics() {
const monitorId = await this.propertyService.getByKey<string>( const monitorId = await this.propertyService.getByKey<string>(
PROPERTY_BETTER_UPTIME_MONITOR_ID PROPERTY_BETTER_UPTIME_MONITOR_ID

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

@ -3,7 +3,7 @@ import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.s
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { import {
ghostfolioFearAndGreedIndexDataSourceStocks, ghostfolioFearAndGreedIndexDataSourceStocks,
ghostfolioFearAndGreedIndexSymbolStocks ghostfolioFearAndGreedIndexSymbol
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { import {
resolveFearAndGreedIndex, resolveFearAndGreedIndex,
@ -49,7 +49,7 @@ export class TwitterBotService implements OnModuleInit {
const symbolItem = await this.symbolService.get({ const symbolItem = await this.symbolService.get({
dataGatheringItem: { dataGatheringItem: {
dataSource: ghostfolioFearAndGreedIndexDataSourceStocks, dataSource: ghostfolioFearAndGreedIndexDataSourceStocks,
symbol: ghostfolioFearAndGreedIndexSymbolStocks symbol: ghostfolioFearAndGreedIndexSymbol
} }
}); });

12
apps/client/project.json

@ -26,10 +26,6 @@
"baseHref": "/it/", "baseHref": "/it/",
"translation": "apps/client/src/locales/messages.it.xlf" "translation": "apps/client/src/locales/messages.it.xlf"
}, },
"ja": {
"baseHref": "/ja/",
"translation": "apps/client/src/locales/messages.ja.xlf"
},
"ko": { "ko": {
"baseHref": "/ko/", "baseHref": "/ko/",
"translation": "apps/client/src/locales/messages.ko.xlf" "translation": "apps/client/src/locales/messages.ko.xlf"
@ -118,10 +114,6 @@
"baseHref": "/it/", "baseHref": "/it/",
"localize": ["it"] "localize": ["it"]
}, },
"development-ja": {
"baseHref": "/ja/",
"localize": ["ja"]
},
"development-ko": { "development-ko": {
"baseHref": "/ko/", "baseHref": "/ko/",
"localize": ["ko"] "localize": ["ko"]
@ -247,9 +239,6 @@
"development-it": { "development-it": {
"buildTarget": "client:build:development-it" "buildTarget": "client:build:development-it"
}, },
"development-ja": {
"buildTarget": "client:build:development-ja"
},
"development-ko": { "development-ko": {
"buildTarget": "client:build:development-ko" "buildTarget": "client:build:development-ko"
}, },
@ -289,7 +278,6 @@
"messages.es.xlf", "messages.es.xlf",
"messages.fr.xlf", "messages.fr.xlf",
"messages.it.xlf", "messages.it.xlf",
"messages.ja.xlf",
"messages.ko.xlf", "messages.ko.xlf",
"messages.nl.xlf", "messages.nl.xlf",
"messages.pl.xlf", "messages.pl.xlf",

4
apps/client/src/app/components/access-table/access-table.component.html

@ -2,14 +2,14 @@
<table class="gf-table w-100" mat-table [dataSource]="dataSource"> <table class="gf-table w-100" mat-table [dataSource]="dataSource">
<ng-container matColumnDef="alias"> <ng-container matColumnDef="alias">
<th *matHeaderCellDef class="px-1" i18n mat-header-cell>Alias</th> <th *matHeaderCellDef class="px-1" i18n mat-header-cell>Alias</th>
<td *matCellDef="let element" class="px-1 text-nowrap" mat-cell> <td *matCellDef="let element" class="px-1" mat-cell>
{{ element.alias }} {{ element.alias }}
</td> </td>
</ng-container> </ng-container>
<ng-container matColumnDef="grantee"> <ng-container matColumnDef="grantee">
<th *matHeaderCellDef class="px-1" i18n mat-header-cell>Grantee</th> <th *matHeaderCellDef class="px-1" i18n mat-header-cell>Grantee</th>
<td *matCellDef="let element" class="px-1 text-nowrap" mat-cell> <td *matCellDef="let element" class="px-1" mat-cell>
{{ element.grantee }} {{ element.grantee }}
</td> </td>
</ng-container> </ng-container>

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_4_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_4_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_4_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_4_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_4_FIGURES NUMERICAL_PRECISION_THRESHOLD_5_FIGURES
) { ) {
this.interestInBaseCurrencyPrecision = 0; this.interestInBaseCurrencyPrecision = 0;
} }

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

@ -27,7 +27,7 @@
<ng-container matColumnDef="index"> <ng-container matColumnDef="index">
<th <th
*matHeaderCellDef *matHeaderCellDef
class="justify-content-end px-1 py-2" class="px-1 py-2 text-right"
mat-header-cell mat-header-cell
mat-sort-header="id" mat-sort-header="id"
> >

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

@ -305,9 +305,9 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit {
); );
} }
protected onGatherMax() { protected onGather7Days() {
this.adminService this.adminService
.gatherMax() .gather7Days()
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => { .subscribe(() => {
setTimeout(() => { setTimeout(() => {
@ -316,16 +316,9 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit {
}); });
} }
protected onGatherProfileData() { protected onGatherMax() {
this.adminService
.gatherProfileData()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe();
}
protected onGatherRecentMarketData() {
this.adminService this.adminService
.gatherRecentMarketData() .gatherMax()
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => { .subscribe(() => {
setTimeout(() => { setTimeout(() => {
@ -334,6 +327,13 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit {
}); });
} }
protected onGatherProfileData() {
this.adminService
.gatherProfileData()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe();
}
protected onOpenAssetProfileDialog({ protected onOpenAssetProfileDialog({
dataSource, dataSource,
symbol symbol

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

@ -220,7 +220,7 @@
class="no-max-width" class="no-max-width"
xPosition="before" xPosition="before"
> >
<button mat-menu-item (click)="onGatherRecentMarketData()"> <button mat-menu-item (click)="onGather7Days()">
<ng-container i18n <ng-container i18n
>Gather Recent Historical Market Data</ng-container >Gather Recent Historical Market Data</ng-container
> >
@ -285,8 +285,7 @@
!canDeleteAssetProfile({ !canDeleteAssetProfile({
activitiesCount: element.activitiesCount, activitiesCount: element.activitiesCount,
isBenchmark: element.isBenchmark, isBenchmark: element.isBenchmark,
symbol: element.symbol, symbol: element.symbol
watchedByCount: element.watchedByCount
}) })
" "
(click)=" (click)="

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

@ -550,7 +550,7 @@ export class GfAssetProfileDialogComponent implements OnInit {
) as Record<string, string>, ) as Record<string, string>,
locale: locale:
this.assetProfileForm.controls.scraperConfiguration.controls.locale this.assetProfileForm.controls.scraperConfiguration.controls.locale
?.value || undefined, ?.value ?? undefined,
mode: mode:
this.assetProfileForm.controls.scraperConfiguration.controls.mode this.assetProfileForm.controls.scraperConfiguration.controls.mode
?.value ?? undefined, ?.value ?? undefined,

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

@ -165,11 +165,7 @@
</div> </div>
} @else { } @else {
<div class="col-6 mb-3"> <div class="col-6 mb-3">
<gf-value <gf-value i18n size="medium" [value]="assetProfile?.symbol"
i18n
size="medium"
[enableCopyToClipboardButton]="true"
[value]="assetProfile?.symbol"
>Symbol</gf-value >Symbol</gf-value
> >
</div> </div>
@ -206,7 +202,6 @@
<div class="col-6 mb-3"> <div class="col-6 mb-3">
<gf-value <gf-value
size="medium" size="medium"
[enableCopyToClipboardButton]="true"
[hidden]="!assetProfile?.isin" [hidden]="!assetProfile?.isin"
[value]="assetProfile?.isin" [value]="assetProfile?.isin"
>ISIN</gf-value >ISIN</gf-value

6
apps/client/src/app/components/admin-platform/admin-platform.component.html

@ -96,9 +96,3 @@
<tr *matHeaderRowDef="displayedColumns" mat-header-row></tr> <tr *matHeaderRowDef="displayedColumns" mat-header-row></tr>
<tr *matRowDef="let row; columns: displayedColumns" mat-row></tr> <tr *matRowDef="let row; columns: displayedColumns" mat-row></tr>
</table> </table>
<mat-paginator
[class.d-none]="dataSource.data.length <= pageSize"
[hidePageSize]="true"
[pageSize]="pageSize"
/>

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

@ -1,5 +1,4 @@
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { DEFAULT_PAGE_SIZE } from '@ghostfolio/common/config';
import { CreatePlatformDto, UpdatePlatformDto } from '@ghostfolio/common/dtos'; import { CreatePlatformDto, UpdatePlatformDto } from '@ghostfolio/common/dtos';
import { ConfirmationDialogType } from '@ghostfolio/common/enums'; import { ConfirmationDialogType } from '@ghostfolio/common/enums';
import { getLocale, getLowercase } from '@ghostfolio/common/helper'; import { getLocale, getLowercase } from '@ghostfolio/common/helper';
@ -23,7 +22,6 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog'; import { MatDialog } from '@angular/material/dialog';
import { MatMenuModule } from '@angular/material/menu'; import { MatMenuModule } from '@angular/material/menu';
import { MatPaginator, MatPaginatorModule } from '@angular/material/paginator';
import { MatSort, MatSortModule } from '@angular/material/sort'; import { MatSort, MatSortModule } from '@angular/material/sort';
import { MatTableDataSource, MatTableModule } from '@angular/material/table'; import { MatTableDataSource, MatTableModule } from '@angular/material/table';
import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { ActivatedRoute, Router, RouterModule } from '@angular/router';
@ -48,7 +46,6 @@ import { CreateOrUpdatePlatformDialogParams } from './create-or-update-platform-
IonIcon, IonIcon,
MatButtonModule, MatButtonModule,
MatMenuModule, MatMenuModule,
MatPaginatorModule,
MatSortModule, MatSortModule,
MatTableModule, MatTableModule,
RouterModule RouterModule
@ -62,13 +59,11 @@ export class GfAdminPlatformComponent implements OnInit {
protected dataSource = new MatTableDataSource<Platform>(); protected dataSource = new MatTableDataSource<Platform>();
protected readonly displayedColumns = ['name', 'url', 'accounts', 'actions']; protected readonly displayedColumns = ['name', 'url', 'accounts', 'actions'];
protected readonly pageSize = DEFAULT_PAGE_SIZE;
protected platforms: Platform[]; protected platforms: Platform[];
private readonly deviceType = computed( private readonly deviceType = computed(
() => this.deviceDetectorService.deviceInfo().deviceType () => this.deviceDetectorService.deviceInfo().deviceType
); );
private readonly paginator = viewChild.required(MatPaginator);
private readonly sort = viewChild.required(MatSort); private readonly sort = viewChild.required(MatSort);
private readonly adminService = inject(AdminService); private readonly adminService = inject(AdminService);
@ -150,7 +145,6 @@ export class GfAdminPlatformComponent implements OnInit {
this.platforms = platforms; this.platforms = platforms;
this.dataSource = new MatTableDataSource(platforms); this.dataSource = new MatTableDataSource(platforms);
this.dataSource.paginator = this.paginator();
this.dataSource.sort = this.sort(); this.dataSource.sort = this.sort();
this.dataSource.sortingDataAccessor = getLowercase; this.dataSource.sortingDataAccessor = getLowercase;

30
apps/client/src/app/components/admin-settings/admin-settings.component.scss

@ -10,33 +10,7 @@
} }
.mat-mdc-card { .mat-mdc-card {
--gradient-opacity: 50%; --mat-card-outlined-container-color: whitesmoke;
--mat-card-outlined-outline-color: rgb(193, 219, 254);
background-color: white;
background-image: linear-gradient(
109.6deg,
color-mix(in srgb, rgba(255, 255, 255, 1) 100%, transparent) 11.2%,
color-mix(
in srgb,
rgba(221, 108, 241, 0.26) var(--gradient-opacity),
transparent
)
42%,
color-mix(
in srgb,
rgba(229, 106, 253, 0.71) var(--gradient-opacity),
transparent
)
71.5%,
color-mix(
in srgb,
rgba(123, 183, 253, 1) var(--gradient-opacity),
transparent
)
100%
);
color: rgb(var(--dark-primary-text));
.mat-mdc-card-actions { .mat-mdc-card-actions {
min-height: 0; min-height: 0;
@ -58,6 +32,6 @@
:host-context(.theme-dark) { :host-context(.theme-dark) {
.mat-mdc-card { .mat-mdc-card {
--mat-card-outlined-outline-color: white; --mat-card-outlined-container-color: #222222;
} }
} }

6
apps/client/src/app/components/admin-tag/admin-tag.component.html

@ -89,9 +89,3 @@
<tr *matHeaderRowDef="displayedColumns" mat-header-row></tr> <tr *matHeaderRowDef="displayedColumns" mat-header-row></tr>
<tr *matRowDef="let row; columns: displayedColumns" mat-row></tr> <tr *matRowDef="let row; columns: displayedColumns" mat-row></tr>
</table> </table>
<mat-paginator
[class.d-none]="dataSource.data.length <= pageSize"
[hidePageSize]="true"
[pageSize]="pageSize"
/>

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

@ -1,5 +1,4 @@
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { DEFAULT_PAGE_SIZE } from '@ghostfolio/common/config';
import { CreateTagDto, UpdateTagDto } from '@ghostfolio/common/dtos'; import { CreateTagDto, UpdateTagDto } from '@ghostfolio/common/dtos';
import { ConfirmationDialogType } from '@ghostfolio/common/enums'; import { ConfirmationDialogType } from '@ghostfolio/common/enums';
import { getLocale, getLowercase } from '@ghostfolio/common/helper'; import { getLocale, getLowercase } from '@ghostfolio/common/helper';
@ -22,7 +21,6 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog'; import { MatDialog } from '@angular/material/dialog';
import { MatMenuModule } from '@angular/material/menu'; import { MatMenuModule } from '@angular/material/menu';
import { MatPaginator, MatPaginatorModule } from '@angular/material/paginator';
import { MatSort, MatSortModule } from '@angular/material/sort'; import { MatSort, MatSortModule } from '@angular/material/sort';
import { MatTableDataSource, MatTableModule } from '@angular/material/table'; import { MatTableDataSource, MatTableModule } from '@angular/material/table';
import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { ActivatedRoute, Router, RouterModule } from '@angular/router';
@ -46,7 +44,6 @@ import { CreateOrUpdateTagDialogParams } from './create-or-update-tag-dialog/int
IonIcon, IonIcon,
MatButtonModule, MatButtonModule,
MatMenuModule, MatMenuModule,
MatPaginatorModule,
MatSortModule, MatSortModule,
MatTableModule, MatTableModule,
RouterModule RouterModule
@ -65,13 +62,11 @@ export class GfAdminTagComponent implements OnInit {
'activities', 'activities',
'actions' 'actions'
]; ];
protected readonly pageSize = DEFAULT_PAGE_SIZE;
protected tags: Tag[]; protected tags: Tag[];
private readonly deviceType = computed( private readonly deviceType = computed(
() => this.deviceDetectorService.deviceInfo().deviceType () => this.deviceDetectorService.deviceInfo().deviceType
); );
private readonly paginator = viewChild.required(MatPaginator);
private readonly sort = viewChild.required(MatSort); private readonly sort = viewChild.required(MatSort);
private readonly changeDetectorRef = inject(ChangeDetectorRef); private readonly changeDetectorRef = inject(ChangeDetectorRef);
@ -152,7 +147,6 @@ export class GfAdminTagComponent implements OnInit {
this.tags = tags; this.tags = tags;
this.dataSource = new MatTableDataSource(this.tags); this.dataSource = new MatTableDataSource(this.tags);
this.dataSource.paginator = this.paginator();
this.dataSource.sort = this.sort(); this.dataSource.sort = this.sort();
this.dataSource.sortingDataAccessor = getLowercase; this.dataSource.sortingDataAccessor = getLowercase;

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

@ -141,13 +141,6 @@
<li> <li>
<a href="../it" title="Ghostfolio in Italiano">Italiano</a> <a href="../it" title="Ghostfolio in Italiano">Italiano</a>
</li> </li>
<!--
<li>
<a href="../ja" title="Ghostfolio in Japanese (日本語)"
>Japanese (日本語)</a
>
</li>
-->
<li> <li>
<a href="../ko" title="Ghostfolio in Korean (한국어)" <a href="../ko" title="Ghostfolio in Korean (한국어)"
>Korean (한국어)</a >Korean (한국어)</a

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

@ -2,7 +2,7 @@ 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_4_FIGURES NUMERICAL_PRECISION_THRESHOLD_5_FIGURES
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { CreateOrderDto } from '@ghostfolio/common/dtos'; import { CreateOrderDto } from '@ghostfolio/common/dtos';
import { import {
@ -297,11 +297,10 @@ export class GfHoldingDetailDialogComponent implements OnInit {
value value
}) => { }) => {
this.activitiesCount = activitiesCount; this.activitiesCount = activitiesCount;
this.assetProfile = assetProfile;
this.averagePrice = averagePrice; this.averagePrice = averagePrice;
if ( if (
this.averagePrice >= NUMERICAL_PRECISION_THRESHOLD_4_FIGURES && this.averagePrice >= NUMERICAL_PRECISION_THRESHOLD_5_FIGURES &&
this.data.deviceType === 'mobile' this.data.deviceType === 'mobile'
) { ) {
this.averagePricePrecision = 0; this.averagePricePrecision = 0;
@ -320,7 +319,7 @@ export class GfHoldingDetailDialogComponent implements OnInit {
if ( if (
this.data.deviceType === 'mobile' && this.data.deviceType === 'mobile' &&
this.dividendInBaseCurrency >= this.dividendInBaseCurrency >=
NUMERICAL_PRECISION_THRESHOLD_4_FIGURES NUMERICAL_PRECISION_THRESHOLD_5_FIGURES
) { ) {
this.dividendInBaseCurrencyPrecision = 0; this.dividendInBaseCurrencyPrecision = 0;
} }
@ -358,7 +357,7 @@ export class GfHoldingDetailDialogComponent implements OnInit {
if ( if (
this.data.deviceType === 'mobile' && this.data.deviceType === 'mobile' &&
this.investmentInBaseCurrencyWithCurrencyEffect >= this.investmentInBaseCurrencyWithCurrencyEffect >=
NUMERICAL_PRECISION_THRESHOLD_4_FIGURES NUMERICAL_PRECISION_THRESHOLD_5_FIGURES
) { ) {
this.investmentInBaseCurrencyWithCurrencyEffectPrecision = 0; this.investmentInBaseCurrencyWithCurrencyEffectPrecision = 0;
} }
@ -368,7 +367,7 @@ export class GfHoldingDetailDialogComponent implements OnInit {
if ( if (
this.data.deviceType === 'mobile' && this.data.deviceType === 'mobile' &&
this.marketPriceMax >= NUMERICAL_PRECISION_THRESHOLD_4_FIGURES this.marketPriceMax >= NUMERICAL_PRECISION_THRESHOLD_5_FIGURES
) { ) {
this.marketPriceMaxPrecision = 0; this.marketPriceMaxPrecision = 0;
} }
@ -377,14 +376,14 @@ export class GfHoldingDetailDialogComponent implements OnInit {
if ( if (
this.data.deviceType === 'mobile' && this.data.deviceType === 'mobile' &&
this.marketPriceMin >= NUMERICAL_PRECISION_THRESHOLD_4_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_4_FIGURES this.marketPrice >= NUMERICAL_PRECISION_THRESHOLD_5_FIGURES
) { ) {
this.marketPricePrecision = 0; this.marketPricePrecision = 0;
} }
@ -406,7 +405,7 @@ export class GfHoldingDetailDialogComponent implements OnInit {
if ( if (
this.data.deviceType === 'mobile' && this.data.deviceType === 'mobile' &&
this.netPerformanceWithCurrencyEffect >= this.netPerformanceWithCurrencyEffect >=
NUMERICAL_PRECISION_THRESHOLD_4_FIGURES NUMERICAL_PRECISION_THRESHOLD_5_FIGURES
) { ) {
this.netPerformanceWithCurrencyEffectPrecision = 0; this.netPerformanceWithCurrencyEffectPrecision = 0;
} }
@ -425,7 +424,9 @@ export class GfHoldingDetailDialogComponent implements OnInit {
} }
} }
this.reportDataGlitchMail = `mailto:hi@ghostfol.io?Subject=Ghostfolio Data Glitch Report&body=Hello%0D%0DI would like to report a data glitch for%0D%0DSymbol: ${assetProfile?.symbol}%0DData Source: ${assetProfile?.dataSource}%0D%0DAdditional notes:%0D%0DCan you please take a look?%0D%0DKind regards`;
this.sectors = {}; this.sectors = {};
this.assetProfile = assetProfile;
this.tags = tags.map((tag) => { this.tags = tags.map((tag) => {
return { return {
@ -438,22 +439,16 @@ export class GfHoldingDetailDialogComponent implements OnInit {
this.value = value; this.value = value;
const reportDataGlitchSubject = `Ghostfolio Data Glitch Report${ if (assetProfile?.assetClass) {
this.assetProfile?.symbol ? ` (${this.assetProfile.symbol})` : '' this.assetClass = translate(assetProfile?.assetClass);
}`;
this.reportDataGlitchMail = `mailto:hi@ghostfol.io?Subject=${reportDataGlitchSubject}&body=Hello%0D%0DI would like to report a data glitch for%0D%0DSymbol: ${this.assetProfile?.symbol}%0DData Source: ${this.assetProfile?.dataSource}%0D%0DAdditional notes:%0D%0DCan you please take a look?%0D%0DKind regards`;
if (this.assetProfile?.assetClass) {
this.assetClass = translate(this.assetProfile?.assetClass);
} }
if (this.assetProfile?.assetSubClass) { if (assetProfile?.assetSubClass) {
this.assetSubClass = translate(this.assetProfile?.assetSubClass); this.assetSubClass = translate(assetProfile?.assetSubClass);
} }
if (this.assetProfile?.countries?.length > 0) { if (assetProfile?.countries?.length > 0) {
for (const country of this.assetProfile.countries) { for (const country of assetProfile.countries) {
this.countries[country.code] = { this.countries[country.code] = {
name: getCountryName({ code: country.code }), name: getCountryName({ code: country.code }),
value: country.weight value: country.weight
@ -461,8 +456,8 @@ export class GfHoldingDetailDialogComponent implements OnInit {
} }
} }
if (this.assetProfile?.sectors?.length > 0) { if (assetProfile?.sectors?.length > 0) {
for (const sector of this.assetProfile.sectors) { for (const sector of assetProfile.sectors) {
this.sectors[sector.name] = { this.sectors[sector.name] = {
name: translate(sector.name), name: translate(sector.name),
value: sector.weight value: sector.weight

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

@ -1,6 +1,6 @@
import { GfFearAndGreedIndexComponent } from '@ghostfolio/client/components/fear-and-greed-index/fear-and-greed-index.component'; import { GfFearAndGreedIndexComponent } from '@ghostfolio/client/components/fear-and-greed-index/fear-and-greed-index.component';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { ghostfolioFearAndGreedIndexSymbolStocks } from '@ghostfolio/common/config'; import { ghostfolioFearAndGreedIndexSymbol } from '@ghostfolio/common/config';
import { resetHours } from '@ghostfolio/common/helper'; import { resetHours } from '@ghostfolio/common/helper';
import { import {
Benchmark, Benchmark,
@ -86,7 +86,7 @@ export class GfHomeMarketComponent implements OnInit {
.fetchSymbolItem({ .fetchSymbolItem({
dataSource: this.info.fearAndGreedDataSource, dataSource: this.info.fearAndGreedDataSource,
includeHistoricalData: this.numberOfDays, includeHistoricalData: this.numberOfDays,
symbol: ghostfolioFearAndGreedIndexSymbolStocks symbol: ghostfolioFearAndGreedIndexSymbol
}) })
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ historicalData, marketPrice }) => { .subscribe(({ historicalData, marketPrice }) => {

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

@ -1,17 +1,6 @@
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { CreateAccessDto, UpdateAccessDto } from '@ghostfolio/common/dtos'; import { CreateAccessDto, UpdateAccessDto } from '@ghostfolio/common/dtos';
import { Filter, PortfolioPosition } from '@ghostfolio/common/interfaces';
import { AccountWithPlatform } from '@ghostfolio/common/types';
import { validateObjectForForm } from '@ghostfolio/common/utils'; import { validateObjectForForm } from '@ghostfolio/common/utils';
import { NotificationService } from '@ghostfolio/ui/notifications'; import { NotificationService } from '@ghostfolio/ui/notifications';
import {
GfPortfolioFilterFormComponent,
getAssetClassFilters,
getFiltersFromPortfolioFilterFormValue,
getHoldingsForFilter,
getPortfolioFilterFormValue,
getTagFilters
} from '@ghostfolio/ui/portfolio-filter-form';
import { DataService } from '@ghostfolio/ui/services'; import { DataService } from '@ghostfolio/ui/services';
import type { HttpErrorResponse } from '@angular/common/http'; import type { HttpErrorResponse } from '@angular/common/http';
@ -51,7 +40,6 @@ import { CreateOrUpdateAccessDialogParams } from './interfaces/interfaces';
host: { class: 'h-100' }, host: { class: 'h-100' },
imports: [ imports: [
FormsModule, FormsModule,
GfPortfolioFilterFormComponent,
MatButtonModule, MatButtonModule,
MatDialogModule, MatDialogModule,
MatFormFieldModule, MatFormFieldModule,
@ -64,16 +52,9 @@ import { CreateOrUpdateAccessDialogParams } from './interfaces/interfaces';
templateUrl: 'create-or-update-access-dialog.html' templateUrl: 'create-or-update-access-dialog.html'
}) })
export class GfCreateOrUpdateAccessDialogComponent implements OnInit { export class GfCreateOrUpdateAccessDialogComponent implements OnInit {
public accounts: AccountWithPlatform[] = [];
public assetClasses: Filter[] = [];
public holdings: PortfolioPosition[] = [];
public tags: Filter[] = [];
protected accessForm: FormGroup; protected accessForm: FormGroup;
protected readonly mode: 'create' | 'update'; protected readonly mode: 'create' | 'update';
private hasExperimentalFeatures = false;
private readonly changeDetectorRef = inject(ChangeDetectorRef); private readonly changeDetectorRef = inject(ChangeDetectorRef);
private readonly data = private readonly data =
@ -87,26 +68,17 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit {
private readonly formBuilder = inject(FormBuilder); private readonly formBuilder = inject(FormBuilder);
private readonly notificationService = inject(NotificationService); private readonly notificationService = inject(NotificationService);
private readonly userService = inject(UserService);
public constructor() { public constructor() {
this.mode = this.data.access ? 'update' : 'create'; this.mode = this.data.access ? 'update' : 'create';
} }
public get canApplyFilters() {
return (
this.accessForm?.get('type')?.value === 'PUBLIC' &&
this.hasExperimentalFeatures
);
}
public ngOnInit() { public ngOnInit() {
const access = this.data?.access; const access = this.data?.access;
const isPublic = access?.type === 'PUBLIC'; const isPublic = access?.type === 'PUBLIC';
this.accessForm = this.formBuilder.group({ this.accessForm = this.formBuilder.group({
alias: [access?.alias ?? ''], alias: [access?.alias ?? ''],
filters: [null],
granteeUserId: [ granteeUserId: [
access?.grantee ?? null, access?.grantee ?? null,
isPublic ? null : Validators.required isPublic ? null : Validators.required
@ -121,19 +93,6 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit {
] ]
}); });
this.assetClasses = getAssetClassFilters();
this.userService
.get()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ accounts, settings, tags }) => {
this.accounts = accounts;
this.hasExperimentalFeatures = settings.isExperimentalFeatures ?? false;
this.tags = getTagFilters(tags);
this.changeDetectorRef.markForCheck();
});
this.accessForm this.accessForm
.get('type') .get('type')
?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)) ?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef))
@ -143,7 +102,6 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit {
if (accessType === 'PRIVATE') { if (accessType === 'PRIVATE') {
granteeUserIdControl?.setValidators(Validators.required); granteeUserIdControl?.setValidators(Validators.required);
this.accessForm.get('filters')?.setValue(null);
} else { } else {
granteeUserIdControl?.clearValidators(); granteeUserIdControl?.clearValidators();
granteeUserIdControl?.setValue(null); granteeUserIdControl?.setValue(null);
@ -156,8 +114,6 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit {
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
}); });
this.loadHoldings();
} }
protected onCancel() { protected onCancel() {
@ -172,18 +128,9 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit {
} }
} }
private buildFilters(): Filter[] {
return getFiltersFromPortfolioFilterFormValue(
this.accessForm.get('filters')?.value
);
}
private async createAccess() { private async createAccess() {
const filters = this.buildFilters();
const access: CreateAccessDto = { const access: CreateAccessDto = {
alias: this.accessForm.get('alias')?.value, alias: this.accessForm.get('alias')?.value,
filters: filters.length > 0 ? filters : undefined,
granteeUserId: this.accessForm.get('granteeUserId')?.value, granteeUserId: this.accessForm.get('granteeUserId')?.value,
permissions: [this.accessForm.get('permissions')?.value] permissions: [this.accessForm.get('permissions')?.value]
}; };
@ -217,19 +164,6 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit {
} }
} }
private loadHoldings() {
this.dataService
.fetchPortfolioHoldings()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ holdings }) => {
this.holdings = getHoldingsForFilter(holdings);
this.updateFiltersFormControl(this.data.access?.settings?.filters);
this.changeDetectorRef.markForCheck();
});
}
private async updateAccess() { private async updateAccess() {
const accessId = this.data.access?.id; const accessId = this.data.access?.id;
@ -237,11 +171,8 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit {
return; return;
} }
const filters = this.buildFilters();
const access: UpdateAccessDto = { const access: UpdateAccessDto = {
alias: this.accessForm.get('alias')?.value, alias: this.accessForm.get('alias')?.value,
filters: filters.length > 0 ? filters : undefined,
granteeUserId: this.accessForm.get('granteeUserId')?.value, granteeUserId: this.accessForm.get('granteeUserId')?.value,
id: accessId, id: accessId,
permissions: [this.accessForm.get('permissions')?.value] permissions: [this.accessForm.get('permissions')?.value]
@ -275,14 +206,4 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit {
console.error(error); console.error(error);
} }
} }
private updateFiltersFormControl(filters: Filter[] | undefined) {
if (!filters?.length) {
return;
}
this.accessForm
.get('filters')
?.setValue(getPortfolioFilterFormValue(filters, this.holdings));
}
} }

12
apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.html

@ -59,18 +59,6 @@
</mat-form-field> </mat-form-field>
</div> </div>
} }
@if (canApplyFilters) {
<h2 class="h6" i18n>Portfolio Filters</h2>
<gf-portfolio-filter-form
appearance="outline"
class="w-100"
formControlName="filters"
[accounts]="accounts"
[assetClasses]="assetClasses"
[holdings]="holdings"
[tags]="tags"
/>
}
</div> </div>
<div class="justify-content-end" mat-dialog-actions> <div class="justify-content-end" mat-dialog-actions>
<button mat-button type="button" (click)="onCancel()"> <button mat-button type="button" (click)="onCancel()">

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

@ -229,7 +229,6 @@ export class GfUserAccountAccessComponent implements OnInit {
grantee: access.grantee === 'Public' ? undefined : access.grantee, grantee: access.grantee === 'Public' ? undefined : access.grantee,
id: access.id, id: access.id,
permissions: access.permissions, permissions: access.permissions,
settings: access.settings,
type: access.type type: access.type
} }
} satisfies CreateOrUpdateAccessDialogParams, } satisfies CreateOrUpdateAccessDialogParams,

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

@ -12,7 +12,6 @@ import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { internalRoutes } from '@ghostfolio/common/routes/routes'; import { internalRoutes } from '@ghostfolio/common/routes/routes';
import { NotificationService } from '@ghostfolio/ui/notifications'; import { NotificationService } from '@ghostfolio/ui/notifications';
import { DataService } from '@ghostfolio/ui/services'; import { DataService } from '@ghostfolio/ui/services';
import { GfValueComponent } from '@ghostfolio/ui/value';
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
@ -52,7 +51,6 @@ import { catchError } from 'rxjs/operators';
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ imports: [
FormsModule, FormsModule,
GfValueComponent,
IonIcon, IonIcon,
MatButtonModule, MatButtonModule,
MatCardModule, MatCardModule,
@ -91,7 +89,6 @@ export class GfUserAccountSettingsComponent implements OnInit {
'es', 'es',
'fr', 'fr',
'it', 'it',
// 'ja',
'ko', 'ko',
'nl', 'nl',
'pl', 'pl',

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

@ -103,15 +103,6 @@
>Italiano (<ng-container i18n>Community</ng-container >Italiano (<ng-container i18n>Community</ng-container
>)</mat-option >)</mat-option
> >
<!--
@if (user?.settings?.isExperimentalFeatures) {
<mat-option value="ja"
>Japanese / 日本語 (<ng-container i18n
>Community</ng-container
>)</mat-option
>
}
-->
@if (user?.settings?.isExperimentalFeatures) { @if (user?.settings?.isExperimentalFeatures) {
<mat-option value="ko" <mat-option value="ko"
>Korean / 한국어 (<ng-container i18n >Korean / 한국어 (<ng-container i18n
@ -269,13 +260,7 @@
<div class="pr-1 w-50"> <div class="pr-1 w-50">
Ghostfolio <ng-container i18n>User ID</ng-container> Ghostfolio <ng-container i18n>User ID</ng-container>
</div> </div>
<div class="pl-1 w-50"> <div class="pl-1 text-monospace w-50">{{ user?.id }}</div>
<gf-value
class="text-monospace"
[enableCopyToClipboardButton]="true"
[value]="user?.id"
/>
</div>
</div> </div>
<div class="align-items-center d-flex py-1"> <div class="align-items-center d-flex py-1">
<div class="pr-1 w-50"></div> <div class="pr-1 w-50"></div>

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

@ -171,8 +171,7 @@
mat-cell mat-cell
> >
@if (element.price === null) { @if (element.price === null) {
<span></span> <span></span>
<span class="ml-1">{{ baseCurrency }}</span>
} @else { } @else {
<gf-value <gf-value
class="d-inline-block justify-content-end" class="d-inline-block justify-content-end"

7
apps/client/src/app/pages/accounts/transfer-balance/interfaces/interfaces.ts

@ -1,12 +1,5 @@
import { FormControl, FormGroup } from '@angular/forms';
import { Account } from '@prisma/client'; import { Account } from '@prisma/client';
export interface TransferBalanceDialogParams { export interface TransferBalanceDialogParams {
accounts: Account[]; accounts: Account[];
} }
export type TransferBalanceForm = FormGroup<{
balance: FormControl<number | string | null>;
fromAccount: FormControl<string | null>;
toAccount: FormControl<string | null>;
}>;

78
apps/client/src/app/pages/accounts/transfer-balance/transfer-balance-dialog.component.ts

@ -1,9 +1,10 @@
import { TransferBalanceDto } from '@ghostfolio/common/dtos'; import { TransferBalanceDto } from '@ghostfolio/common/dtos';
import { GfEntityLogoComponent } from '@ghostfolio/ui/entity-logo'; import { GfEntityLogoComponent } from '@ghostfolio/ui/entity-logo';
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; import { ChangeDetectionStrategy, Component, Inject } from '@angular/core';
import { import {
FormControl, AbstractControl,
FormBuilder,
FormGroup, FormGroup,
ReactiveFormsModule, ReactiveFormsModule,
ValidationErrors, ValidationErrors,
@ -20,10 +21,7 @@ import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select'; import { MatSelectModule } from '@angular/material/select';
import { Account } from '@prisma/client'; import { Account } from '@prisma/client';
import { import { TransferBalanceDialogParams } from './interfaces/interfaces';
TransferBalanceDialogParams,
TransferBalanceForm
} from './interfaces/interfaces';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
@ -42,63 +40,57 @@ import {
templateUrl: 'transfer-balance-dialog.html' templateUrl: 'transfer-balance-dialog.html'
}) })
export class GfTransferBalanceDialogComponent { export class GfTransferBalanceDialogComponent {
protected readonly accounts: Account[] = public accounts: Account[] = [];
inject<TransferBalanceDialogParams>(MAT_DIALOG_DATA).accounts; public currency: string;
public transferBalanceForm: FormGroup;
protected currency: string;
protected readonly transferBalanceForm: TransferBalanceForm = new FormGroup(
{
balance: new FormControl<number | string | null>('', Validators.required),
fromAccount: new FormControl<string | null>('', Validators.required),
toAccount: new FormControl<string | null>('', Validators.required)
},
{
validators: this.compareAccounts
}
);
private readonly dialogRef = public constructor(
inject<MatDialogRef<GfTransferBalanceDialogComponent>>(MatDialogRef); @Inject(MAT_DIALOG_DATA) public data: TransferBalanceDialogParams,
public dialogRef: MatDialogRef<GfTransferBalanceDialogComponent>,
private formBuilder: FormBuilder
) {}
public ngOnInit() { public ngOnInit() {
this.transferBalanceForm.controls.fromAccount.valueChanges.subscribe( this.accounts = this.data.accounts;
(id) => {
const currency = this.accounts.find((account) => {
return account.id === id;
})?.currency;
if (currency) { this.transferBalanceForm = this.formBuilder.group(
this.currency = currency; {
} balance: ['', Validators.required],
fromAccount: ['', Validators.required],
toAccount: ['', Validators.required]
},
{
validators: this.compareAccounts
} }
); );
this.transferBalanceForm.get('fromAccount').valueChanges.subscribe((id) => {
this.currency = this.accounts.find((account) => {
return account.id === id;
}).currency;
});
} }
protected onCancel() { public onCancel() {
this.dialogRef.close(); this.dialogRef.close();
} }
protected onSubmit() { public onSubmit() {
const account: TransferBalanceDto = { const account: TransferBalanceDto = {
accountIdFrom: this.transferBalanceForm.controls.fromAccount.value ?? '', accountIdFrom: this.transferBalanceForm.get('fromAccount').value,
accountIdTo: this.transferBalanceForm.controls.toAccount.value ?? '', accountIdTo: this.transferBalanceForm.get('toAccount').value,
balance: Number(this.transferBalanceForm.controls.balance.value) balance: this.transferBalanceForm.get('balance').value
}; };
this.dialogRef.close({ account }); this.dialogRef.close({ account });
} }
private compareAccounts( private compareAccounts(control: AbstractControl): ValidationErrors {
formGroup: TransferBalanceForm const accountFrom = control.get('fromAccount');
): ValidationErrors | null { const accountTo = control.get('toAccount');
const accountFrom = formGroup.controls.fromAccount;
const accountTo = formGroup.controls.toAccount;
if (accountFrom.value === accountTo.value) { if (accountFrom.value === accountTo.value) {
return { invalid: true }; return { invalid: true };
} }
return null;
} }
} }

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

@ -94,7 +94,7 @@ export class GfApiPageComponent implements OnInit {
private fetchAssetProfile({ symbol }: { symbol: string }) { private fetchAssetProfile({ symbol }: { symbol: string }) {
return this.http return this.http
.get<DataProviderGhostfolioAssetProfileResponse>( .get<DataProviderGhostfolioAssetProfileResponse>(
`/api/v1/data-providers/ghostfolio/asset-profile/${encodeURIComponent(symbol)}`, `/api/v1/data-providers/ghostfolio/asset-profile/${symbol}`,
{ headers: this.getHeaders() } { headers: this.getHeaders() }
) )
.pipe(this.catchFetchFailure(), takeUntilDestroyed(this.destroyRef)); .pipe(this.catchFetchFailure(), takeUntilDestroyed(this.destroyRef));
@ -107,7 +107,7 @@ export class GfApiPageComponent implements OnInit {
return this.http return this.http
.get<DividendsResponse>( .get<DividendsResponse>(
`/api/v2/data-providers/ghostfolio/dividends/${encodeURIComponent(symbol)}`, `/api/v2/data-providers/ghostfolio/dividends/${symbol}`,
{ {
params, params,
headers: this.getHeaders() headers: this.getHeaders()
@ -129,7 +129,7 @@ export class GfApiPageComponent implements OnInit {
return this.http return this.http
.get<HistoricalResponse>( .get<HistoricalResponse>(
`/api/v2/data-providers/ghostfolio/historical/${encodeURIComponent(symbol)}`, `/api/v2/data-providers/ghostfolio/historical/${symbol}`,
{ {
params, params,
headers: this.getHeaders() headers: this.getHeaders()

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

@ -164,14 +164,12 @@
@for (message of errorMessages; track message; let i = $index) { @for (message of errorMessages; track message; let i = $index) {
<mat-expansion-panel [disabled]="!details[i]"> <mat-expansion-panel [disabled]="!details[i]">
<mat-expansion-panel-header class="pl-1"> <mat-expansion-panel-header class="pl-1">
<mat-panel-title class="w-100"> <mat-panel-title>
<div class="d-flex w-100"> <div class="d-flex">
<div class="align-items-center d-flex mr-2"> <div class="align-items-center d-flex mr-2">
<ion-icon name="warning-outline" /> <ion-icon name="warning-outline" />
</div> </div>
<div class="text-truncate" title="{{ message }}"> <div>{{ message }}</div>
{{ message }}
</div>
</div> </div>
</mat-panel-title> </mat-panel-title>
</mat-expansion-panel-header> </mat-expansion-panel-header>

11
apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.scss

@ -1,5 +1,3 @@
@use '@angular/material' as mat;
:host { :host {
display: block; display: block;
@ -43,13 +41,8 @@
} }
.mat-expansion-panel { .mat-expansion-panel {
@include mat.expansion-overrides( background: none;
( box-shadow: none;
container-background-color: transparent,
container-elevation-shadow: none,
container-shape: 0
)
);
.mat-expansion-panel-header { .mat-expansion-panel-header {
color: inherit; color: inherit;

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

@ -12,7 +12,7 @@ import {
User User
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { MarketAdvanced } from '@ghostfolio/common/types'; import { Market, MarketAdvanced } from '@ghostfolio/common/types';
import { translate } from '@ghostfolio/ui/i18n'; import { translate } from '@ghostfolio/ui/i18n';
import { GfPortfolioProportionChartComponent } from '@ghostfolio/ui/portfolio-proportion-chart'; import { GfPortfolioProportionChartComponent } from '@ghostfolio/ui/portfolio-proportion-chart';
import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator'; import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator';
@ -24,9 +24,7 @@ import { GfWorldMapChartComponent } from '@ghostfolio/ui/world-map-chart';
import { import {
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
computed,
DestroyRef, DestroyRef,
inject,
OnInit OnInit
} from '@angular/core'; } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
@ -43,9 +41,6 @@ import {
} from '@prisma/client'; } from '@prisma/client';
import { isNumber } from 'lodash'; import { isNumber } from 'lodash';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
import { filter, switchMap, tap } from 'rxjs';
import { AllocationsPageParams } from './interfaces/interfaces';
@Component({ @Component({
imports: [ imports: [
@ -62,23 +57,21 @@ import { AllocationsPageParams } from './interfaces/interfaces';
templateUrl: './allocations-page.html' templateUrl: './allocations-page.html'
}) })
export class GfAllocationsPageComponent implements OnInit { export class GfAllocationsPageComponent implements OnInit {
protected accounts: { public accounts: {
[id: string]: Pick<Account, 'name'> & { [id: string]: Pick<Account, 'name'> & {
id: string; id: string;
value: number; value: number;
}; };
}; };
protected continents: { public continents: {
[code: string]: { name: string; value: number }; [code: string]: { name: string; value: number };
}; };
protected countries: { public countries: {
[code: string]: { name: string; value: number }; [code: string]: { name: string; value: number };
}; };
protected readonly deviceType = computed( public deviceType: string;
() => this.deviceDetectorService.deviceInfo().deviceType public hasImpersonationId: boolean;
); public holdings: {
protected hasImpersonationId: boolean;
protected holdings: {
[symbol: string]: Pick< [symbol: string]: Pick<
PortfolioPosition['assetProfile'], PortfolioPosition['assetProfile'],
| 'assetClass' | 'assetClass'
@ -89,26 +82,28 @@ export class GfAllocationsPageComponent implements OnInit {
| 'name' | 'name'
> & { etfProvider: string; exchange?: string; value: number }; > & { etfProvider: string; exchange?: string; value: number };
}; };
protected isLoading = false; public isLoading = false;
protected markets: PortfolioDetails['markets']; public markets: {
protected marketsAdvanced: { [key in Market]: { id: Market; valueInPercentage: number };
};
public marketsAdvanced: {
[key in MarketAdvanced]: { [key in MarketAdvanced]: {
id: MarketAdvanced; id: MarketAdvanced;
name: string; name: string;
value: number; value: number;
}; };
}; };
protected platforms: { public platforms: {
[id: string]: Pick<Platform, 'name'> & { [id: string]: Pick<Platform, 'name'> & {
id: string; id: string;
value: number; value: number;
}; };
}; };
protected portfolioDetails: PortfolioDetails; public portfolioDetails: PortfolioDetails;
protected sectors: { public sectors: {
[name: string]: { name: string; value: number }; [name: string]: { name: string; value: number };
}; };
protected symbols: { public symbols: {
[name: string]: { [name: string]: {
dataSource?: DataSource; dataSource?: DataSource;
name: string; name: string;
@ -116,46 +111,38 @@ export class GfAllocationsPageComponent implements OnInit {
value: number; value: number;
}; };
}; };
protected topHoldings: HoldingWithParents[]; public topHoldings: HoldingWithParents[];
protected readonly UNKNOWN_KEY = UNKNOWN_KEY; public topHoldingsMap: {
protected user: User;
private topHoldingsMap: {
[name: string]: { name: string; value: number }; [name: string]: { name: string; value: number };
}; };
private totalValueInEtf = 0; public totalValueInEtf = 0;
public UNKNOWN_KEY = UNKNOWN_KEY;
private readonly changeDetectorRef = inject(ChangeDetectorRef); public user: User;
private readonly dataService = inject(DataService); public worldMapChartFormat: string;
private readonly destroyRef = inject(DestroyRef);
private readonly deviceDetectorService = inject(DeviceDetectorService); public constructor(
private readonly dialog = inject(MatDialog); private changeDetectorRef: ChangeDetectorRef,
private readonly impersonationStorageService = inject( private dataService: DataService,
ImpersonationStorageService private destroyRef: DestroyRef,
); private deviceDetectorService: DeviceDetectorService,
private readonly route = inject(ActivatedRoute); private dialog: MatDialog,
private readonly router = inject(Router); private impersonationStorageService: ImpersonationStorageService,
private readonly userService = inject(UserService); private route: ActivatedRoute,
private router: Router,
public constructor() { private userService: UserService
) {
this.route.queryParams this.route.queryParams
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe( .subscribe((params) => {
({ accountId, accountDetailDialog }: AllocationsPageParams) => { if (params['accountId'] && params['accountDetailDialog']) {
if (accountId && accountDetailDialog) { this.openAccountDetailDialog(params['accountId']);
this.openAccountDetailDialog(accountId);
}
} }
); });
}
protected get worldMapChartFormat(): string {
return this.showValuesInPercentage()
? '{0}%'
: `{0} ${this.user?.settings?.baseCurrency}`;
} }
public ngOnInit() { public ngOnInit() {
this.deviceType = this.deviceDetectorService.getDeviceInfo().deviceType;
this.impersonationStorageService this.impersonationStorageService
.onChangeHasImpersonation() .onChangeHasImpersonation()
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
@ -164,58 +151,56 @@ export class GfAllocationsPageComponent implements OnInit {
}); });
this.userService.stateChanged this.userService.stateChanged
.pipe( .pipe(takeUntilDestroyed(this.destroyRef))
filter((state) => !!state?.user), .subscribe((state) => {
tap((state) => { if (state?.user) {
this.user = state.user; this.user = state.user;
this.worldMapChartFormat = this.showValuesInPercentage()
? `{0}%`
: `{0} ${this.user?.settings?.baseCurrency}`;
this.isLoading = true; this.isLoading = true;
this.initialize(); this.initialize();
this.changeDetectorRef.markForCheck(); this.fetchPortfolioDetails()
}), .pipe(takeUntilDestroyed(this.destroyRef))
switchMap(() => this.fetchPortfolioDetails()), .subscribe((portfolioDetails) => {
takeUntilDestroyed(this.destroyRef) this.initialize();
)
.subscribe((portfolioDetails) => {
this.initialize();
this.portfolioDetails = portfolioDetails; this.portfolioDetails = portfolioDetails;
this.initializeAllocationsData(); this.initializeAllocationsData();
this.isLoading = false; this.isLoading = false;
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
});
this.changeDetectorRef.markForCheck();
}
}); });
this.initialize(); this.initialize();
} }
protected onAccountChartClicked({ accountId }: { accountId: string }) { public onAccountChartClicked({ accountId }: { accountId: string }) {
if (accountId && accountId !== UNKNOWN_KEY) { if (accountId && accountId !== UNKNOWN_KEY) {
void this.router.navigate([], { this.router.navigate([], {
queryParams: { accountId, accountDetailDialog: true } queryParams: { accountId, accountDetailDialog: true }
}); });
} }
} }
protected onSymbolChartClicked({ public onSymbolChartClicked({ dataSource, symbol }: AssetProfileIdentifier) {
dataSource,
symbol
}: AssetProfileIdentifier) {
if (dataSource && symbol) { if (dataSource && symbol) {
void this.router.navigate([], { this.router.navigate([], {
queryParams: { dataSource, symbol, holdingDetailDialog: true } queryParams: { dataSource, symbol, holdingDetailDialog: true }
}); });
} }
} }
protected showValuesInPercentage() {
return this.hasImpersonationId || this.user?.settings?.isRestrictedView;
}
private extractCurrency({ private extractCurrency({
assetClass, assetClass,
assetSubClass, assetSubClass,
@ -241,9 +226,9 @@ export class GfAllocationsPageComponent implements OnInit {
name name
}: { }: {
assetSubClass: PortfolioPosition['assetProfile']['assetSubClass']; assetSubClass: PortfolioPosition['assetProfile']['assetSubClass'];
name?: string; name: string;
}) { }) {
if (assetSubClass === 'ETF' && name) { if (assetSubClass === 'ETF') {
const [firstWord] = name.split(' '); const [firstWord] = name.split(' ');
return firstWord; return firstWord;
} }
@ -313,7 +298,7 @@ export class GfAllocationsPageComponent implements OnInit {
this.platforms = {}; this.platforms = {};
this.portfolioDetails = { this.portfolioDetails = {
accounts: {}, accounts: {},
createdAt: new Date(), createdAt: undefined,
holdings: {}, holdings: {},
platforms: {}, platforms: {},
summary: undefined summary: undefined
@ -342,7 +327,7 @@ export class GfAllocationsPageComponent implements OnInit {
let value = 0; let value = 0;
if (this.showValuesInPercentage()) { if (this.showValuesInPercentage()) {
value = valueInPercentage ?? 0; value = valueInPercentage;
} else { } else {
value = valueInBaseCurrency; value = valueInBaseCurrency;
} }
@ -357,24 +342,30 @@ export class GfAllocationsPageComponent implements OnInit {
for (const [symbol, position] of Object.entries( for (const [symbol, position] of Object.entries(
this.portfolioDetails.holdings this.portfolioDetails.holdings
)) { )) {
let value = 0;
if (this.showValuesInPercentage()) {
value = position.allocationInPercentage;
} else {
value = position.valueInBaseCurrency;
}
this.holdings[symbol] = { this.holdings[symbol] = {
value,
assetClass: assetClass:
position.assetProfile.assetClass || (UNKNOWN_KEY as AssetClass), position.assetProfile.assetClass || (UNKNOWN_KEY as AssetClass),
assetClassLabel: position.assetProfile.assetClassLabel ?? UNKNOWN_KEY, assetClassLabel: position.assetProfile.assetClassLabel || UNKNOWN_KEY,
assetSubClass: assetSubClass:
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: this.extractCurrency(position.assetProfile), 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
}), }),
exchange: position.exchange, exchange: position.exchange,
name: position.assetProfile.name, name: position.assetProfile.name
value: this.showValuesInPercentage()
? position.allocationInPercentage
: (position.valueInBaseCurrency ?? 0)
}; };
// Prepare analysis data by continents, countries, holdings and sectors // Prepare analysis data by continents, countries, holdings and sectors
@ -382,50 +373,53 @@ export class GfAllocationsPageComponent 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, weight } = country; const { code, continent, weight } = country;
const value =
(isNumber(position.valueInBaseCurrency)
? position.valueInBaseCurrency
: position.valueInPercentage) ?? 0;
const continentData = this.continents[continent];
if (continentData) { if (this.continents[continent]?.value) {
continentData.value += weight * value; this.continents[continent].value +=
weight *
(isNumber(position.valueInBaseCurrency)
? position.valueInBaseCurrency
: position.valueInPercentage);
} else { } else {
this.continents[continent] = { this.continents[continent] = {
name: translate(continent), name: translate(continent),
value: weight * value value:
weight *
(isNumber(position.valueInBaseCurrency)
? this.portfolioDetails.holdings[symbol].valueInBaseCurrency
: this.portfolioDetails.holdings[symbol].valueInPercentage)
}; };
} }
const countryData = this.countries[code]; if (this.countries[code]?.value) {
this.countries[code].value +=
if (countryData) { weight *
countryData.value += weight * value; (isNumber(position.valueInBaseCurrency)
? position.valueInBaseCurrency
: position.valueInPercentage);
} else { } else {
this.countries[code] = { this.countries[code] = {
name: getCountryName({ code }), name: getCountryName({ code }),
value: weight * value value:
weight *
(isNumber(position.valueInBaseCurrency)
? this.portfolioDetails.holdings[symbol].valueInBaseCurrency
: this.portfolioDetails.holdings[symbol].valueInPercentage)
}; };
} }
} }
} else { } else {
const value = this.continents[UNKNOWN_KEY].value += isNumber(
(isNumber(position.valueInBaseCurrency) position.valueInBaseCurrency
? position.valueInBaseCurrency )
: position.valueInPercentage) ?? 0; ? this.portfolioDetails.holdings[symbol].valueInBaseCurrency
: this.portfolioDetails.holdings[symbol].valueInPercentage;
const continentData = this.continents[UNKNOWN_KEY];
this.countries[UNKNOWN_KEY].value += isNumber(
if (continentData) { position.valueInBaseCurrency
continentData.value += value; )
} ? this.portfolioDetails.holdings[symbol].valueInBaseCurrency
: this.portfolioDetails.holdings[symbol].valueInPercentage;
const countryData = this.countries[UNKNOWN_KEY];
if (countryData) {
countryData.value += value;
}
} }
if (position.assetProfile.holdings.length > 0) { if (position.assetProfile.holdings.length > 0) {
@ -435,18 +429,21 @@ export class GfAllocationsPageComponent implements OnInit {
valueInBaseCurrency valueInBaseCurrency
} of position.assetProfile.holdings) { } of position.assetProfile.holdings) {
const normalizedAssetName = this.normalizeAssetName(name); const normalizedAssetName = this.normalizeAssetName(name);
const value = isNumber(valueInBaseCurrency)
? valueInBaseCurrency
: allocationInPercentage * (position.valueInPercentage ?? 0);
const holdingData = this.topHoldingsMap[normalizedAssetName];
if (holdingData) { if (this.topHoldingsMap[normalizedAssetName]?.value) {
holdingData.value += value; this.topHoldingsMap[normalizedAssetName].value += isNumber(
valueInBaseCurrency
)
? valueInBaseCurrency
: allocationInPercentage *
this.portfolioDetails.holdings[symbol].valueInPercentage;
} else { } else {
this.topHoldingsMap[normalizedAssetName] = { this.topHoldingsMap[normalizedAssetName] = {
name, name,
value value: isNumber(valueInBaseCurrency)
? valueInBaseCurrency
: allocationInPercentage *
this.portfolioDetails.holdings[symbol].valueInPercentage
}; };
} }
} }
@ -455,33 +452,30 @@ export class GfAllocationsPageComponent implements OnInit {
if (position.assetProfile.sectors.length > 0) { if (position.assetProfile.sectors.length > 0) {
for (const sector of position.assetProfile.sectors) { for (const sector of position.assetProfile.sectors) {
const { name, weight } = sector; const { name, weight } = sector;
const value =
(isNumber(position.valueInBaseCurrency)
? position.valueInBaseCurrency
: position.valueInPercentage) ?? 0;
const sectorData = this.sectors[name]; if (this.sectors[name]?.value) {
this.sectors[name].value +=
if (sectorData) { weight *
sectorData.value += weight * value; (isNumber(position.valueInBaseCurrency)
? position.valueInBaseCurrency
: position.valueInPercentage);
} else { } else {
this.sectors[name] = { this.sectors[name] = {
name: translate(name), name: translate(name),
value: weight * value value:
weight *
(isNumber(position.valueInBaseCurrency)
? this.portfolioDetails.holdings[symbol].valueInBaseCurrency
: this.portfolioDetails.holdings[symbol].valueInPercentage)
}; };
} }
} }
} else { } else {
const value = this.sectors[UNKNOWN_KEY].value += isNumber(
(isNumber(position.valueInBaseCurrency) position.valueInBaseCurrency
? position.valueInBaseCurrency )
: position.valueInPercentage) ?? 0; ? this.portfolioDetails.holdings[symbol].valueInBaseCurrency
: this.portfolioDetails.holdings[symbol].valueInPercentage;
const sectorData = this.sectors[UNKNOWN_KEY];
if (sectorData) {
sectorData.value += value;
}
} }
if (this.holdings[symbol].assetSubClass === 'ETF') { if (this.holdings[symbol].assetSubClass === 'ETF') {
@ -490,26 +484,23 @@ export class GfAllocationsPageComponent implements OnInit {
this.symbols[prettifySymbol(symbol)] = { this.symbols[prettifySymbol(symbol)] = {
dataSource: position.assetProfile.dataSource, dataSource: position.assetProfile.dataSource,
name: position.assetProfile.name ?? '', name: position.assetProfile.name,
symbol: prettifySymbol(symbol), symbol: prettifySymbol(symbol),
value: value: isNumber(position.valueInBaseCurrency)
(isNumber(position.valueInBaseCurrency) ? position.valueInBaseCurrency
? position.valueInBaseCurrency : position.valueInPercentage
: position.valueInPercentage) ?? 0
}; };
} }
this.markets = this.portfolioDetails.markets; this.markets = this.portfolioDetails.markets;
if (this.portfolioDetails.marketsAdvanced) { Object.values(this.portfolioDetails.marketsAdvanced).forEach(
Object.values(this.portfolioDetails.marketsAdvanced).forEach( ({ id, valueInBaseCurrency, valueInPercentage }) => {
({ id, valueInBaseCurrency, valueInPercentage }) => { this.marketsAdvanced[id].value = isNumber(valueInBaseCurrency)
this.marketsAdvanced[id].value = isNumber(valueInBaseCurrency) ? valueInBaseCurrency
? valueInBaseCurrency : valueInPercentage;
: valueInPercentage; }
} );
);
}
for (const [ for (const [
id, id,
@ -518,7 +509,7 @@ export class GfAllocationsPageComponent implements OnInit {
let value = 0; let value = 0;
if (this.showValuesInPercentage()) { if (this.showValuesInPercentage()) {
value = valueInPercentage ?? 0; value = valueInPercentage;
} else { } else {
value = valueInBaseCurrency; value = valueInBaseCurrency;
} }
@ -531,11 +522,12 @@ export class GfAllocationsPageComponent implements OnInit {
} }
this.topHoldings = Object.values(this.topHoldingsMap) this.topHoldings = Object.values(this.topHoldingsMap)
.map(({ name, value }): HoldingWithParents => { .map(({ name, value }) => {
if (this.showValuesInPercentage()) { if (this.showValuesInPercentage()) {
return { return {
name, name,
allocationInPercentage: value allocationInPercentage: value,
valueInBaseCurrency: null
}; };
} }
@ -555,12 +547,11 @@ export class GfAllocationsPageComponent implements OnInit {
} }
); );
return currentParentHolding && return currentParentHolding
isNumber(currentParentHolding.valueInBaseCurrency)
? { ? {
allocationInPercentage: allocationInPercentage:
currentParentHolding.valueInBaseCurrency / value, currentParentHolding.valueInBaseCurrency / value,
name: holding.assetProfile.name ?? '', name: holding.assetProfile.name,
position: holding, position: holding,
symbol: prettifySymbol(symbol), symbol: prettifySymbol(symbol),
valueInBaseCurrency: valueInBaseCurrency:
@ -605,22 +596,26 @@ export class GfAllocationsPageComponent implements OnInit {
autoFocus: false, autoFocus: false,
data: { data: {
accountId: aAccountId, accountId: aAccountId,
deviceType: this.deviceType(), deviceType: this.deviceType,
hasImpersonationId: this.hasImpersonationId, hasImpersonationId: this.hasImpersonationId,
hasPermissionToCreateActivity: hasPermissionToCreateActivity:
!this.hasImpersonationId && !this.hasImpersonationId &&
hasPermission(this.user?.permissions, permissions.createActivity) && hasPermission(this.user?.permissions, permissions.createActivity) &&
!this.user?.settings?.isRestrictedView !this.user?.settings?.isRestrictedView
}, },
height: this.deviceType() === 'mobile' ? '98vh' : '80vh', height: this.deviceType === 'mobile' ? '98vh' : '80vh',
width: this.deviceType() === 'mobile' ? '100vw' : '50rem' width: this.deviceType === 'mobile' ? '100vw' : '50rem'
}); });
dialogRef dialogRef
.afterClosed() .afterClosed()
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => { .subscribe(() => {
void this.router.navigate(['.'], { relativeTo: this.route }); this.router.navigate(['.'], { relativeTo: this.route });
}); });
} }
public showValuesInPercentage() {
return this.hasImpersonationId || this.user?.settings?.isRestrictedView;
}
} }

2
apps/client/src/app/pages/portfolio/allocations/allocations-page.html

@ -115,7 +115,7 @@
[isInPercentage]="showValuesInPercentage()" [isInPercentage]="showValuesInPercentage()"
[keys]="['symbol']" [keys]="['symbol']"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[showLabels]="deviceType() !== 'mobile'" [showLabels]="deviceType !== 'mobile'"
(proportionChartClicked)="onSymbolChartClicked($event)" (proportionChartClicked)="onSymbolChartClicked($event)"
/> />
</mat-card-content> </mat-card-content>

6
apps/client/src/app/pages/portfolio/allocations/interfaces/interfaces.ts

@ -1,6 +0,0 @@
import { Params } from '@angular/router';
export interface AllocationsPageParams extends Params {
accountDetailDialog?: string;
accountId?: string;
}

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

@ -5,95 +5,84 @@ import { publicRoutes } from '@ghostfolio/common/routes/routes';
import { translate } from '@ghostfolio/ui/i18n'; import { translate } from '@ghostfolio/ui/i18n';
import { DataService } from '@ghostfolio/ui/services'; import { DataService } from '@ghostfolio/ui/services';
import { import { Component, OnInit } from '@angular/core';
ChangeDetectionStrategy,
Component,
computed,
inject
} from '@angular/core';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { ActivatedRoute, RouterModule } from '@angular/router'; import { ActivatedRoute, RouterModule } from '@angular/router';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'page' }, host: { class: 'page' },
imports: [MatButtonModule, RouterModule], imports: [MatButtonModule, RouterModule],
selector: 'gf-product-page', selector: 'gf-product-page',
styleUrls: ['./product-page.scss'], styleUrls: ['./product-page.scss'],
templateUrl: './product-page.html' templateUrl: './product-page.html'
}) })
export class GfProductPageComponent { export class GfProductPageComponent implements OnInit {
protected readonly price = computed(() => { public key: string;
public price: number;
public product1: Product;
public product2: Product;
public routerLinkAbout = publicRoutes.about.routerLink;
public routerLinkFeatures = publicRoutes.features.routerLink;
public routerLinkResourcesPersonalFinanceTools =
publicRoutes.resources.subRoutes.personalFinanceTools.routerLink;
public tags: string[];
public constructor(
private dataService: DataService,
private route: ActivatedRoute
) {}
public ngOnInit() {
const { subscriptionOffer } = this.dataService.fetchInfo(); const { subscriptionOffer } = this.dataService.fetchInfo();
return subscriptionOffer?.price;
});
protected readonly product1 = computed<Product>(() => ({ this.price = subscriptionOffer?.price;
founded: 2021,
hasFreePlan: true,
hasSelfHostingAbility: true,
isOpenSource: true,
key: 'ghostfolio',
languages: [
'Chinese (简体中文)',
'Deutsch',
'English',
'Español',
'Français',
'Italiano',
// 'Japanese (日本語)',
'Korean (한국어)',
'Nederlands',
'Português',
'Türkçe'
],
name: 'Ghostfolio',
origin: getCountryName({ code: 'CH' }),
regions: [$localize`Global`],
slogan: 'Open Source Wealth Management',
useAnonymously: true
}));
protected readonly product2 = computed<Product>(() => { this.product1 = {
const product = personalFinanceTools.find(({ key }) => { founded: 2021,
return key === this.route.snapshot.data['key']; hasFreePlan: true,
}); hasSelfHostingAbility: true,
isOpenSource: true,
key: 'ghostfolio',
languages: [
'Chinese (简体中文)',
'Deutsch',
'English',
'Español',
'Français',
'Italiano',
'Korean (한국어)',
'Nederlands',
'Português',
'Türkçe'
],
name: 'Ghostfolio',
origin: getCountryName({ code: 'CH' }),
regions: [$localize`Global`],
slogan: 'Open Source Wealth Management',
useAnonymously: true
};
const mappedProduct = { this.product2 = {
key: product?.key ?? '', ...personalFinanceTools.find(({ key }) => {
name: product?.name ?? '', return key === this.route.snapshot.data['key'];
...product })
}; };
if (mappedProduct.origin) { if (this.product2.origin) {
mappedProduct.origin = getCountryName({ code: mappedProduct.origin }); this.product2.origin = getCountryName({ code: this.product2.origin });
} }
if (mappedProduct.regions) { if (this.product2.regions) {
mappedProduct.regions = mappedProduct.regions.map((region) => { this.product2.regions = this.product2.regions.map((region) => {
return region === 'Global' return translate(region);
? translate(region)
: getCountryName({ code: region });
}); });
} }
return mappedProduct; this.tags = [
}); this.product1.name,
this.product1.origin,
protected readonly routerLinkAbout = publicRoutes.about.routerLink; this.product2.name,
protected readonly routerLinkFeatures = publicRoutes.features.routerLink; this.product2.origin,
protected readonly routerLinkResourcesPersonalFinanceTools =
publicRoutes.resources.subRoutes.personalFinanceTools.routerLink;
protected readonly tags = computed<string[]>(() => {
const product1 = this.product1();
const product2 = this.product2();
return [
product1.name,
product1.origin,
product2.name,
product2.origin,
$localize`Alternative`, $localize`Alternative`,
$localize`App`, $localize`App`,
$localize`Budgeting`, $localize`Budgeting`,
@ -114,14 +103,11 @@ export class GfProductPageComponent {
$localize`Wealth Management`, $localize`Wealth Management`,
`WealthTech` `WealthTech`
] ]
.filter((item): item is string => { .filter((item) => {
return !!item; return !!item;
}) })
.sort((a, b) => { .sort((a, b) => {
return a.localeCompare(b, undefined, { sensitivity: 'base' }); return a.localeCompare(b, undefined, { sensitivity: 'base' });
}); });
}); }
private readonly dataService = inject(DataService);
private readonly route = inject(ActivatedRoute);
} }

116
apps/client/src/app/pages/resources/personal-finance-tools/product-page.html

@ -6,10 +6,10 @@
<h1 class="mb-1"> <h1 class="mb-1">
<strong>Ghostfolio</strong>: <strong>Ghostfolio</strong>:
<ng-container i18n>The Open Source Alternative to</ng-container <ng-container i18n>The Open Source Alternative to</ng-container
>&nbsp;<strong>{{ product2().name }}</strong> >&nbsp;<strong>{{ product2.name }}</strong>
</h1> </h1>
</div> </div>
@if (product2().isArchived) { @if (product2.isArchived) {
<div class="info-container my-4 p-2 rounded text-center text-muted"> <div class="info-container my-4 p-2 rounded text-center text-muted">
<ng-container i18n>This page has been archived.</ng-container> <ng-container i18n>This page has been archived.</ng-container>
</div> </div>
@ -17,7 +17,7 @@
<section class="mb-4"> <section class="mb-4">
<p i18n> <p i18n>
Are you looking for an open source alternative to Are you looking for an open source alternative to
{{ product2().name }}? {{ product2.name }}?
<a [routerLink]="routerLinkAbout">Ghostfolio</a> is a powerful <a [routerLink]="routerLinkAbout">Ghostfolio</a> is a powerful
portfolio management tool that provides individuals with a portfolio management tool that provides individuals with a
comprehensive platform to track, analyze, and optimize their comprehensive platform to track, analyze, and optimize their
@ -31,7 +31,7 @@
</p> </p>
<p i18n> <p i18n>
Ghostfolio is an open source software (OSS), providing a Ghostfolio is an open source software (OSS), providing a
cost-effective alternative to {{ product2().name }} making it cost-effective alternative to {{ product2.name }} making it
particularly suitable for individuals on a tight budget, such as particularly suitable for individuals on a tight budget, such as
those those
<a href="../en/blog/2023/07/exploring-the-path-to-fire" <a href="../en/blog/2023/07/exploring-the-path-to-fire"
@ -42,9 +42,9 @@
</p> </p>
<p i18n> <p i18n>
Let’s dive deeper into the detailed Ghostfolio vs Let’s dive deeper into the detailed Ghostfolio vs
{{ product2().name }} comparison table below to gain a thorough {{ product2.name }} comparison table below to gain a thorough
understanding of how Ghostfolio positions itself relative to understanding of how Ghostfolio positions itself relative to
{{ product2().name }}. We will explore various aspects such as {{ product2.name }}. We will explore various aspects such as
features, data privacy, pricing, and more, allowing you to make a features, data privacy, pricing, and more, allowing you to make a
well-informed choice for your personal requirements. well-informed choice for your personal requirements.
</p> </p>
@ -54,7 +54,7 @@
<caption class="text-center" i18n> <caption class="text-center" i18n>
Ghostfolio vs Ghostfolio vs
{{ {{
product2().name product2.name
}} }}
comparison table comparison table
</caption> </caption>
@ -63,31 +63,31 @@
<th class="mat-mdc-header-cell px-1 py-2"></th> <th class="mat-mdc-header-cell px-1 py-2"></th>
<th class="mat-mdc-header-cell px-1 py-2">Ghostfolio</th> <th class="mat-mdc-header-cell px-1 py-2">Ghostfolio</th>
<th class="mat-mdc-header-cell px-1 py-2"> <th class="mat-mdc-header-cell px-1 py-2">
{{ product2().name }} {{ product2.name }}
</th> </th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr class="mat-mdc-row"> <tr class="mat-mdc-row">
<td class="mat-mdc-cell px-3 py-2 text-right"></td> <td class="mat-mdc-cell px-3 py-2 text-right"></td>
<td class="mat-mdc-cell px-1 py-2">{{ product1().slogan }}</td> <td class="mat-mdc-cell px-1 py-2">{{ product1.slogan }}</td>
<td class="mat-mdc-cell px-1 py-2">{{ product2().slogan }}</td> <td class="mat-mdc-cell px-1 py-2">{{ product2.slogan }}</td>
</tr> </tr>
<tr class="mat-mdc-row"> <tr class="mat-mdc-row">
<td class="mat-mdc-cell px-3 py-2 text-right" i18n>Founded</td> <td class="mat-mdc-cell px-3 py-2 text-right" i18n>Founded</td>
<td class="mat-mdc-cell px-1 py-2">{{ product1().founded }}</td> <td class="mat-mdc-cell px-1 py-2">{{ product1.founded }}</td>
<td class="mat-mdc-cell px-1 py-2">{{ product2().founded }}</td> <td class="mat-mdc-cell px-1 py-2">{{ product2.founded }}</td>
</tr> </tr>
<tr class="mat-mdc-row"> <tr class="mat-mdc-row">
<td class="mat-mdc-cell px-3 py-2 text-right" i18n>Origin</td> <td class="mat-mdc-cell px-3 py-2 text-right" i18n>Origin</td>
<td class="mat-mdc-cell px-1 py-2">{{ product1().origin }}</td> <td class="mat-mdc-cell px-1 py-2">{{ product1.origin }}</td>
<td class="mat-mdc-cell px-1 py-2">{{ product2().origin }}</td> <td class="mat-mdc-cell px-1 py-2">{{ product2.origin }}</td>
</tr> </tr>
<tr class="mat-mdc-row"> <tr class="mat-mdc-row">
<td class="mat-mdc-cell px-3 py-2 text-right" i18n>Region</td> <td class="mat-mdc-cell px-3 py-2 text-right" i18n>Region</td>
<td class="mat-mdc-cell px-1 py-2"> <td class="mat-mdc-cell px-1 py-2">
@for ( @for (
region of product1().regions; region of product1.regions;
track region; track region;
let isLast = $last let isLast = $last
) { ) {
@ -96,7 +96,7 @@
</td> </td>
<td class="mat-mdc-cell px-1 py-2"> <td class="mat-mdc-cell px-1 py-2">
@for ( @for (
region of product2().regions; region of product2.regions;
track region; track region;
let isLast = $last let isLast = $last
) { ) {
@ -110,7 +110,7 @@
</td> </td>
<td class="mat-mdc-cell px-1 py-2"> <td class="mat-mdc-cell px-1 py-2">
@for ( @for (
language of product1().languages; language of product1.languages;
track language; track language;
let isLast = $last let isLast = $last
) { ) {
@ -119,7 +119,7 @@
</td> </td>
<td class="mat-mdc-cell px-1 py-2"> <td class="mat-mdc-cell px-1 py-2">
@for ( @for (
language of product2().languages; language of product2.languages;
track language; track language;
let isLast = $last let isLast = $last
) { ) {
@ -132,35 +132,35 @@
Open Source Software Open Source Software
</td> </td>
<td class="mat-mdc-cell px-1 py-2"> <td class="mat-mdc-cell px-1 py-2">
@if (product1().isOpenSource) { @if (product1.isOpenSource) {
<span <span
i18n i18n
i18n-title i18n-title
title="{{ product1().name }} is Open Source Software" title="{{ product1.name }} is Open Source Software"
>✅ Yes</span >✅ Yes</span
> >
} @else { } @else {
<span <span
i18n i18n
i18n-title i18n-title
title="{{ product1().name }} is not Open Source Software" title="{{ product1.name }} is not Open Source Software"
>❌ No</span >❌ No</span
> >
} }
</td> </td>
<td class="mat-mdc-cell px-1 py-2"> <td class="mat-mdc-cell px-1 py-2">
@if (product2().isOpenSource) { @if (product2.isOpenSource) {
<span <span
i18n i18n
i18n-title i18n-title
title="{{ product2().name }} is Open Source Software" title="{{ product2.name }} is Open Source Software"
>✅ Yes</span >✅ Yes</span
> >
} @else { } @else {
<span <span
i18n i18n
i18n-title i18n-title
title="{{ product2().name }} is not Open Source Software" title="{{ product2.name }} is not Open Source Software"
>❌ No</span >❌ No</span
> >
} }
@ -171,35 +171,35 @@
Self-Hosting Self-Hosting
</td> </td>
<td class="mat-mdc-cell px-1 py-2"> <td class="mat-mdc-cell px-1 py-2">
@if (product1().hasSelfHostingAbility === true) { @if (product1.hasSelfHostingAbility === true) {
<span <span
i18n i18n
i18n-title i18n-title
title="{{ product1().name }} can be self-hosted" title="{{ product1.name }} can be self-hosted"
>✅ Yes</span >✅ Yes</span
> >
} @else if (product1().hasSelfHostingAbility === false) { } @else if (product1.hasSelfHostingAbility === false) {
<span <span
i18n i18n
i18n-title i18n-title
title="{{ product1().name }} cannot be self-hosted" title="{{ product1.name }} cannot be self-hosted"
>❌ No</span >❌ No</span
> >
} }
</td> </td>
<td class="mat-mdc-cell px-1 py-2"> <td class="mat-mdc-cell px-1 py-2">
@if (product2().hasSelfHostingAbility === true) { @if (product2.hasSelfHostingAbility === true) {
<span <span
i18n i18n
i18n-title i18n-title
title="{{ product2().name }} can be self-hosted" title="{{ product2.name }} can be self-hosted"
>✅ Yes</span >✅ Yes</span
> >
} @else if (product2().hasSelfHostingAbility === false) { } @else if (product2.hasSelfHostingAbility === false) {
<span <span
i18n i18n
i18n-title i18n-title
title="{{ product2().name }} cannot be self-hosted" title="{{ product2.name }} cannot be self-hosted"
>❌ No</span >❌ No</span
> >
} }
@ -210,35 +210,35 @@
Use anonymously Use anonymously
</td> </td>
<td class="mat-mdc-cell px-1 py-2"> <td class="mat-mdc-cell px-1 py-2">
@if (product1().useAnonymously === true) { @if (product1.useAnonymously === true) {
<span <span
i18n i18n
i18n-title i18n-title
title="{{ product1().name }} can be used anonymously" title="{{ product1.name }} can be used anonymously"
>✅ Yes</span >✅ Yes</span
> >
} @else if (product1().useAnonymously === false) { } @else if (product1.useAnonymously === false) {
<span <span
i18n i18n
i18n-title i18n-title
title="{{ product1().name }} cannot be used anonymously" title="{{ product1.name }} cannot be used anonymously"
>❌ No</span >❌ No</span
> >
} }
</td> </td>
<td class="mat-mdc-cell px-1 py-2"> <td class="mat-mdc-cell px-1 py-2">
@if (product2().useAnonymously === true) { @if (product2.useAnonymously === true) {
<span <span
i18n i18n
i18n-title i18n-title
title="{{ product2().name }} can be used anonymously" title="{{ product2.name }} can be used anonymously"
>✅ Yes</span >✅ Yes</span
> >
} @else if (product2().useAnonymously === false) { } @else if (product2.useAnonymously === false) {
<span <span
i18n i18n
i18n-title i18n-title
title="{{ product2().name }} cannot be used anonymously" title="{{ product2.name }} cannot be used anonymously"
>❌ No</span >❌ No</span
> >
} }
@ -249,35 +249,35 @@
Free Plan Free Plan
</td> </td>
<td class="mat-mdc-cell px-1 py-2"> <td class="mat-mdc-cell px-1 py-2">
@if (product1().hasFreePlan === true) { @if (product1.hasFreePlan === true) {
<span <span
i18n i18n
i18n-title i18n-title
title="{{ product1().name }} offers a free plan" title="{{ product1.name }} offers a free plan"
>✅ Yes</span >✅ Yes</span
> >
} @else if (product1().hasFreePlan === false) { } @else if (product1.hasFreePlan === false) {
<span <span
i18n i18n
i18n-title i18n-title
title="{{ product1().name }} does not offer a free plan" title="{{ product1.name }} does not offer a free plan"
>❌ No</span >❌ No</span
> >
} }
</td> </td>
<td class="mat-mdc-cell px-1 py-2"> <td class="mat-mdc-cell px-1 py-2">
@if (product2().hasFreePlan === true) { @if (product2.hasFreePlan === true) {
<span <span
i18n i18n
i18n-title i18n-title
title="{{ product2().name }} offers a free plan" title="{{ product2.name }} offers a free plan"
>✅ Yes</span >✅ Yes</span
> >
} @else if (product2().hasFreePlan === false) { } @else if (product2.hasFreePlan === false) {
<span <span
i18n i18n
i18n-title i18n-title
title="{{ product2().name }} does not offer a free plan" title="{{ product2.name }} does not offer a free plan"
>❌ No</span >❌ No</span
> >
} }
@ -286,22 +286,22 @@
<tr class="mat-mdc-row"> <tr class="mat-mdc-row">
<td class="mat-mdc-cell px-3 py-2 text-right" i18n>Pricing</td> <td class="mat-mdc-cell px-3 py-2 text-right" i18n>Pricing</td>
<td class="mat-mdc-cell px-1 py-2"> <td class="mat-mdc-cell px-1 py-2">
<span i18n>Starting from</span> ${{ price() }} / <span i18n>Starting from</span> ${{ price }} /
<span i18n>year</span> <span i18n>year</span>
</td> </td>
<td class="mat-mdc-cell px-1 py-2"> <td class="mat-mdc-cell px-1 py-2">
@if (product2().pricingPerYear) { @if (product2.pricingPerYear) {
<span i18n>Starting from</span> <span i18n>Starting from</span>
{{ product2().pricingPerYear }} / {{ product2.pricingPerYear }} /
<span i18n>year</span> <span i18n>year</span>
} }
</td> </td>
</tr> </tr>
@if (product1().note || product2().note) { @if (product1.note || product2.note) {
<tr class="mat-mdc-row"> <tr class="mat-mdc-row">
<td class="mat-mdc-cell px-3 py-2 text-right" i18n>Notes</td> <td class="mat-mdc-cell px-3 py-2 text-right" i18n>Notes</td>
<td class="mat-mdc-cell px-1 py-2">{{ product1().note }}</td> <td class="mat-mdc-cell px-1 py-2">{{ product1.note }}</td>
<td class="mat-mdc-cell px-1 py-2">{{ product2().note }}</td> <td class="mat-mdc-cell px-1 py-2">{{ product2.note }}</td>
</tr> </tr>
} }
</tbody> </tbody>
@ -310,9 +310,9 @@
<section class="mb-4"> <section class="mb-4">
<p i18n> <p i18n>
Please note that the information provided in the Ghostfolio vs Please note that the information provided in the Ghostfolio vs
{{ product2().name }} comparison table is based on our independent {{ product2.name }} comparison table is based on our independent
research and analysis. This website is not affiliated with research and analysis. This website is not affiliated with
{{ product2().name }} or any other product mentioned in the {{ product2.name }} or any other product mentioned in the
comparison. As the landscape of personal finance tools evolves, it comparison. As the landscape of personal finance tools evolves, it
is essential to verify any specific details or changes directly from is essential to verify any specific details or changes directly from
the respective product page. Data needs a refresh? Help us maintain the respective product page. Data needs a refresh? Help us maintain
@ -337,7 +337,7 @@
</section> </section>
<section class="mb-4"> <section class="mb-4">
<ul class="list-inline"> <ul class="list-inline">
@for (tag of tags(); track tag) { @for (tag of tags; track tag) {
<li class="list-inline-item"> <li class="list-inline-item">
<span class="badge badge-light">{{ tag }}</span> <span class="badge badge-light">{{ tag }}</span>
</li> </li>
@ -355,7 +355,7 @@
aria-current="page" aria-current="page"
class="active breadcrumb-item text-truncate" class="active breadcrumb-item text-truncate"
> >
Ghostfolio vs {{ product2().name }} Ghostfolio vs {{ product2.name }}
</li> </li>
</ol> </ol>
</nav> </nav>

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

8789
apps/client/src/locales/messages.ja.xlf

File diff suppressed because it is too large

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

265
apps/client/src/locales/messages.xlf

File diff suppressed because it is too large

296
apps/client/src/locales/messages.zh.xlf

File diff suppressed because it is too large

13
libs/common/src/lib/config.ts

@ -5,13 +5,11 @@ import ms from 'ms';
import { ColorScheme, DateRange } from './types'; import { ColorScheme, DateRange } from './types';
export const ghostfolioPrefix = 'GF'; export const ghostfolioPrefix = 'GF';
/* @deprecated */
export const ghostfolioScraperApiSymbolPrefix = `_${ghostfolioPrefix}_`; export const ghostfolioScraperApiSymbolPrefix = `_${ghostfolioPrefix}_`;
export const ghostfolioFearAndGreedIndexDataSourceCryptocurrencies = export const ghostfolioFearAndGreedIndexDataSourceCryptocurrencies =
DataSource.MANUAL; DataSource.MANUAL;
export const ghostfolioFearAndGreedIndexDataSourceStocks = DataSource.RAPID_API; export const ghostfolioFearAndGreedIndexDataSourceStocks = DataSource.RAPID_API;
export const ghostfolioFearAndGreedIndexSymbol = `${ghostfolioScraperApiSymbolPrefix}FEAR_AND_GREED_INDEX`;
export const ghostfolioFearAndGreedIndexSymbolCryptocurrencies = `${ghostfolioPrefix}_FEAR_AND_GREED_INDEX_CRYPTOCURRENCIES`; export const ghostfolioFearAndGreedIndexSymbolCryptocurrencies = `${ghostfolioPrefix}_FEAR_AND_GREED_INDEX_CRYPTOCURRENCIES`;
export const ghostfolioFearAndGreedIndexSymbolStocks = `${ghostfolioPrefix}_FEAR_AND_GREED_INDEX_STOCKS`; export const ghostfolioFearAndGreedIndexSymbolStocks = `${ghostfolioPrefix}_FEAR_AND_GREED_INDEX_STOCKS`;
@ -90,12 +88,8 @@ export const DEFAULT_PAGE_SIZE = 50;
export const DEFAULT_PORT = 3333; export const DEFAULT_PORT = 3333;
export const DEFAULT_PROCESSOR_GATHER_ASSET_PROFILE_CONCURRENCY = 1; export const DEFAULT_PROCESSOR_GATHER_ASSET_PROFILE_CONCURRENCY = 1;
export const DEFAULT_PROCESSOR_GATHER_HISTORICAL_MARKET_DATA_CONCURRENCY = 1; export const DEFAULT_PROCESSOR_GATHER_HISTORICAL_MARKET_DATA_CONCURRENCY = 1;
export const DEFAULT_PROCESSOR_GATHER_HISTORICAL_MARKET_DATA_TIMEOUT =
ms('1 minute');
export const DEFAULT_PROCESSOR_GATHER_STATISTICS_CONCURRENCY = 1;
export const DEFAULT_PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_CONCURRENCY = 1; export const DEFAULT_PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_CONCURRENCY = 1;
export const DEFAULT_PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_TIMEOUT = export const DEFAULT_PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_TIMEOUT = 30000;
ms('30 seconds');
export const DEFAULT_REDACTED_PATHS = [ export const DEFAULT_REDACTED_PATHS = [
'accounts[*].balance', 'accounts[*].balance',
@ -234,7 +228,6 @@ export const HEADER_KEY_SKIP_INTERCEPTOR = 'X-Skip-Interceptor';
export const MAX_TOP_HOLDINGS = 50; export const MAX_TOP_HOLDINGS = 50;
export const NUMERICAL_PRECISION_THRESHOLD_3_FIGURES = 100; export const NUMERICAL_PRECISION_THRESHOLD_3_FIGURES = 100;
export const NUMERICAL_PRECISION_THRESHOLD_4_FIGURES = 1000;
export const NUMERICAL_PRECISION_THRESHOLD_5_FIGURES = 10000; export const NUMERICAL_PRECISION_THRESHOLD_5_FIGURES = 10000;
export const NUMERICAL_PRECISION_THRESHOLD_6_FIGURES = 100000; export const NUMERICAL_PRECISION_THRESHOLD_6_FIGURES = 100000;
@ -259,7 +252,6 @@ export const PROPERTY_IS_READ_ONLY_MODE = 'IS_READ_ONLY_MODE';
export const PROPERTY_IS_USER_SIGNUP_ENABLED = 'IS_USER_SIGNUP_ENABLED'; export const PROPERTY_IS_USER_SIGNUP_ENABLED = 'IS_USER_SIGNUP_ENABLED';
export const PROPERTY_OPENROUTER_MODEL = 'OPENROUTER_MODEL'; export const PROPERTY_OPENROUTER_MODEL = 'OPENROUTER_MODEL';
export const PROPERTY_OPENROUTER_MODEL_WEB_FETCH = 'OPENROUTER_MODEL_WEB_FETCH'; export const PROPERTY_OPENROUTER_MODEL_WEB_FETCH = 'OPENROUTER_MODEL_WEB_FETCH';
export const PROPERTY_PROXY_ROUTES = 'PROXY_ROUTES';
export const PROPERTY_REFERRAL_PARTNERS = 'REFERRAL_PARTNERS'; export const PROPERTY_REFERRAL_PARTNERS = 'REFERRAL_PARTNERS';
export const PROPERTY_SLACK_COMMUNITY_USERS = 'SLACK_COMMUNITY_USERS'; export const PROPERTY_SLACK_COMMUNITY_USERS = 'SLACK_COMMUNITY_USERS';
export const PROPERTY_STRIPE_CONFIG = 'STRIPE_CONFIG'; export const PROPERTY_STRIPE_CONFIG = 'STRIPE_CONFIG';
@ -315,7 +307,6 @@ export const SUPPORTED_LANGUAGE_CODES = [
'es', 'es',
'fr', 'fr',
'it', 'it',
// 'ja',
'ko', 'ko',
'nl', 'nl',
'pl', 'pl',

8
libs/common/src/lib/dtos/create-access.dto.ts

@ -1,17 +1,11 @@
import { Filter } from '@ghostfolio/common/interfaces';
import { AccessPermission } from '@prisma/client'; import { AccessPermission } from '@prisma/client';
import { IsArray, IsEnum, IsOptional, IsString, IsUUID } from 'class-validator'; import { IsEnum, IsOptional, IsString, IsUUID } from 'class-validator';
export class CreateAccessDto { export class CreateAccessDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
alias?: string; alias?: string;
@IsArray()
@IsOptional()
filters?: Filter[];
@IsOptional() @IsOptional()
@IsUUID() @IsUUID()
granteeUserId?: string; granteeUserId?: string;

4
libs/common/src/lib/dtos/create-asset-profile.dto.ts

@ -11,11 +11,11 @@ import {
} from 'class-validator'; } from 'class-validator';
export class CreateAssetProfileDto { export class CreateAssetProfileDto {
@IsEnum(AssetClass, { each: true }) @IsEnum(AssetClass)
@IsOptional() @IsOptional()
assetClass?: AssetClass; assetClass?: AssetClass;
@IsEnum(AssetSubClass, { each: true }) @IsEnum(AssetSubClass)
@IsOptional() @IsOptional()
assetSubClass?: AssetSubClass; assetSubClass?: AssetSubClass;

6
libs/common/src/lib/dtos/create-order.dto.ts

@ -21,11 +21,11 @@ export class CreateOrderDto {
@IsString() @IsString()
accountId?: string; accountId?: string;
@IsEnum(AssetClass, { each: true }) @IsEnum(AssetClass)
@IsOptional() @IsOptional()
assetClass?: AssetClass; assetClass?: AssetClass;
@IsEnum(AssetSubClass, { each: true }) @IsEnum(AssetSubClass)
@IsOptional() @IsOptional()
assetSubClass?: AssetSubClass; assetSubClass?: AssetSubClass;
@ -66,7 +66,7 @@ export class CreateOrderDto {
@IsOptional() @IsOptional()
tags?: string[]; tags?: string[];
@IsEnum(Type, { each: true }) @IsEnum(Type)
type: Type; type: Type;
@IsNumber() @IsNumber()

8
libs/common/src/lib/dtos/update-access.dto.ts

@ -1,17 +1,11 @@
import { Filter } from '@ghostfolio/common/interfaces';
import { AccessPermission } from '@prisma/client'; import { AccessPermission } from '@prisma/client';
import { IsArray, IsEnum, IsOptional, IsString, IsUUID } from 'class-validator'; import { IsEnum, IsOptional, IsString, IsUUID } from 'class-validator';
export class UpdateAccessDto { export class UpdateAccessDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
alias?: string; alias?: string;
@IsArray()
@IsOptional()
filters?: Filter[];
@IsOptional() @IsOptional()
@IsUUID() @IsUUID()
granteeUserId?: string; granteeUserId?: string;

4
libs/common/src/lib/dtos/update-asset-profile.dto.ts

@ -18,11 +18,11 @@ import {
} from 'class-validator'; } from 'class-validator';
export class UpdateAssetProfileDto { export class UpdateAssetProfileDto {
@IsEnum(AssetClass, { each: true }) @IsEnum(AssetClass)
@IsOptional() @IsOptional()
assetClass?: AssetClass; assetClass?: AssetClass;
@IsEnum(AssetSubClass, { each: true }) @IsEnum(AssetSubClass)
@IsOptional() @IsOptional()
assetSubClass?: AssetSubClass; assetSubClass?: AssetSubClass;

4
libs/common/src/lib/dtos/update-order.dto.ts

@ -20,11 +20,11 @@ export class UpdateOrderDto {
@IsString() @IsString()
accountId?: string; accountId?: string;
@IsEnum(AssetClass, { each: true }) @IsEnum(AssetClass)
@IsOptional() @IsOptional()
assetClass?: AssetClass; assetClass?: AssetClass;
@IsEnum(AssetSubClass, { each: true }) @IsEnum(AssetSubClass)
@IsOptional() @IsOptional()
assetSubClass?: AssetSubClass; assetSubClass?: AssetSubClass;

22
libs/common/src/lib/helper.ts

@ -1,6 +1,7 @@
import { NumberParser } from '@internationalized/number'; import { NumberParser } from '@internationalized/number';
import { import {
Type as ActivityType, Type as ActivityType,
DataSource,
MarketData, MarketData,
Prisma, Prisma,
SymbolProfile, SymbolProfile,
@ -23,7 +24,6 @@ import {
es, es,
fr, fr,
it, it,
ja,
ko, ko,
nl, nl,
pl, pl,
@ -38,6 +38,7 @@ import {
DEFAULT_CURRENCY, DEFAULT_CURRENCY,
DEFAULT_LOCALE, DEFAULT_LOCALE,
DERIVED_CURRENCIES, DERIVED_CURRENCIES,
ghostfolioFearAndGreedIndexSymbol,
ghostfolioFearAndGreedIndexSymbolCryptocurrencies, ghostfolioFearAndGreedIndexSymbolCryptocurrencies,
ghostfolioFearAndGreedIndexSymbolStocks, ghostfolioFearAndGreedIndexSymbolStocks,
ghostfolioScraperApiSymbolPrefix ghostfolioScraperApiSymbolPrefix
@ -156,6 +157,7 @@ export function canDeleteAssetProfile({
!isBenchmark && !isBenchmark &&
!isDerivedCurrency(getCurrencyFromSymbol(symbol)) && !isDerivedCurrency(getCurrencyFromSymbol(symbol)) &&
!isRootCurrency(getCurrencyFromSymbol(symbol)) && !isRootCurrency(getCurrencyFromSymbol(symbol)) &&
symbol !== ghostfolioFearAndGreedIndexSymbol &&
symbol !== ghostfolioFearAndGreedIndexSymbolCryptocurrencies && symbol !== ghostfolioFearAndGreedIndexSymbolCryptocurrencies &&
symbol !== ghostfolioFearAndGreedIndexSymbolStocks && symbol !== ghostfolioFearAndGreedIndexSymbolStocks &&
watchedByCount === 0 watchedByCount === 0
@ -166,6 +168,14 @@ export function capitalize(aString: string) {
return aString.charAt(0).toUpperCase() + aString.slice(1).toLowerCase(); return aString.charAt(0).toUpperCase() + aString.slice(1).toLowerCase();
} }
export function decodeDataSource(encodedDataSource: string) {
if (encodedDataSource) {
return Buffer.from(encodedDataSource, 'hex').toString();
}
return undefined;
}
export function downloadAsFile({ export function downloadAsFile({
content, content,
contentType = 'text/plain', contentType = 'text/plain',
@ -191,6 +201,14 @@ export function downloadAsFile({
a.click(); a.click();
} }
export function encodeDataSource(aDataSource: DataSource) {
if (aDataSource) {
return Buffer.from(aDataSource, 'utf-8').toString('hex');
}
return undefined;
}
export function extractNumberFromString({ export function extractNumberFromString({
locale = 'en-US', locale = 'en-US',
value value
@ -263,8 +281,6 @@ export function getDateFnsLocale(aLanguageCode?: string) {
return fr; return fr;
} else if (aLanguageCode === 'it') { } else if (aLanguageCode === 'it') {
return it; return it;
} else if (aLanguageCode === 'ja') {
return ja;
} else if (aLanguageCode === 'ko') { } else if (aLanguageCode === 'ko') {
return ko; return ko;
} else if (aLanguageCode === 'nl') { } else if (aLanguageCode === 'nl') {

5
libs/common/src/lib/interfaces/access-settings.interface.ts

@ -1,5 +0,0 @@
import { Filter } from './filter.interface';
export interface AccessSettings {
filters?: Filter[];
}

3
libs/common/src/lib/interfaces/access.interface.ts

@ -2,13 +2,10 @@ import { AccessType } from '@ghostfolio/common/types';
import { AccessPermission } from '@prisma/client'; import { AccessPermission } from '@prisma/client';
import { AccessSettings } from './access-settings.interface';
export interface Access { export interface Access {
alias?: string; alias?: string;
grantee?: string; grantee?: string;
id: string; id: string;
permissions: AccessPermission[]; permissions: AccessPermission[];
settings?: AccessSettings;
type: AccessType; type: AccessType;
} }

2
libs/common/src/lib/interfaces/holding.interface.ts

@ -1,5 +1,5 @@
export interface Holding { export interface Holding {
allocationInPercentage: number; allocationInPercentage: number;
name: string; name: string;
valueInBaseCurrency?: number; valueInBaseCurrency: number;
} }

2
libs/common/src/lib/interfaces/index.ts

@ -1,4 +1,3 @@
import type { AccessSettings } from './access-settings.interface';
import type { Access } from './access.interface'; import type { Access } from './access.interface';
import type { AccountBalance } from './account-balance.interface'; import type { AccountBalance } from './account-balance.interface';
import type { Activity, ActivityError } from './activities.interface'; import type { Activity, ActivityError } from './activities.interface';
@ -102,7 +101,6 @@ import type { XRayRulesSettings } from './x-ray-rules-settings.interface';
export { export {
Access, Access,
AccessSettings,
AccessTokenResponse, AccessTokenResponse,
AccountBalance, AccountBalance,
AccountBalancesResponse, AccountBalancesResponse,

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

Loading…
Cancel
Save