Browse Source

Merge branch 'main' into feature/migrate-from-angular-material-design-2-to-3

pull/5431/head
Thomas Kaul 2 months ago
parent
commit
fafe93187f
  1. 94
      CHANGELOG.md
  2. 2
      README.md
  3. 2
      apps/api/src/app/account/account.controller.ts
  4. 5
      apps/api/src/app/account/account.service.ts
  5. 4
      apps/api/src/app/admin/admin.controller.ts
  6. 62
      apps/api/src/app/admin/admin.service.ts
  7. 4
      apps/api/src/app/endpoints/tags/create-tag.dto.ts
  8. 15
      apps/api/src/app/endpoints/watchlist/watchlist.service.ts
  9. 7
      apps/api/src/app/import/import-data.dto.ts
  10. 1
      apps/api/src/app/import/import.controller.ts
  11. 2
      apps/api/src/app/import/import.module.ts
  12. 98
      apps/api/src/app/import/import.service.ts
  13. 10
      apps/api/src/app/order/create-order.dto.ts
  14. 1
      apps/api/src/app/order/interfaces/activities.interface.ts
  15. 6
      apps/api/src/app/order/order.controller.ts
  16. 12
      apps/api/src/app/order/order.service.ts
  17. 10
      apps/api/src/app/order/update-order.dto.ts
  18. 4
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btceur.spec.ts
  19. 4
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd.spec.ts
  20. 4
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell-partially.spec.ts
  21. 4
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell.spec.ts
  22. 2
      apps/api/src/app/portfolio/portfolio.controller.ts
  23. 148
      apps/api/src/app/portfolio/portfolio.service.ts
  24. 7
      apps/api/src/app/user/user.service.ts
  25. 156
      apps/api/src/assets/cryptocurrencies/cryptocurrencies.json
  26. 3
      apps/api/src/interceptors/redact-values-in-response/redact-values-in-response.interceptor.ts
  27. 2
      apps/api/src/models/rules/account-cluster-risk/current-investment.ts
  28. 2
      apps/api/src/models/rules/account-cluster-risk/single-account.ts
  29. 2
      apps/api/src/models/rules/currency-cluster-risk/base-currency-current-investment.ts
  30. 2
      apps/api/src/models/rules/currency-cluster-risk/current-investment.ts
  31. 2
      apps/api/src/models/rules/emergency-fund/emergency-fund-setup.ts
  32. 2
      apps/api/src/models/rules/fees/fee-ratio-initial-investment.ts
  33. 90
      apps/api/src/models/rules/liquidity/buying-power.ts
  34. 3
      apps/api/src/services/configuration/configuration.service.ts
  35. 5
      apps/api/src/services/cron/cron.service.ts
  36. 19
      apps/api/src/services/data-provider/coingecko/coingecko.service.ts
  37. 5
      apps/api/src/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service.ts
  38. 31
      apps/api/src/services/data-provider/data-provider.service.ts
  39. 51
      apps/api/src/services/data-provider/eod-historical-data/eod-historical-data.service.ts
  40. 31
      apps/api/src/services/data-provider/financial-modeling-prep/financial-modeling-prep.service.ts
  41. 192
      apps/api/src/services/data-provider/ghostfolio/ghostfolio.service.ts
  42. 11
      apps/api/src/services/data-provider/google-sheets/google-sheets.service.ts
  43. 17
      apps/api/src/services/data-provider/manual/manual.service.ts
  44. 13
      apps/api/src/services/data-provider/rapid-api/rapid-api.service.ts
  45. 1
      apps/api/src/services/interfaces/environment.interface.ts
  46. 14
      apps/client/src/app/app-routing.module.ts
  47. 10
      apps/client/src/app/components/access-table/access-table.component.html
  48. 8
      apps/client/src/app/components/access-table/access-table.component.ts
  49. 43
      apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts
  50. 52
      apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html
  51. 8
      apps/client/src/app/components/account-detail-dialog/account-detail-dialog.module.ts
  52. 69
      apps/client/src/app/components/admin-market-data/admin-market-data.component.ts
  53. 6
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts
  54. 8
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html
  55. 7
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/interfaces/interfaces.ts
  56. 10
      apps/client/src/app/components/admin-platform/create-or-update-platform-dialog/create-or-update-platform-dialog.html
  57. 10
      apps/client/src/app/components/admin-tag/create-or-update-tag-dialog/create-or-update-tag-dialog.html
  58. 10
      apps/client/src/app/components/dialog-footer/dialog-footer.component.ts
  59. 14
      apps/client/src/app/components/dialog-footer/dialog-footer.module.ts
  60. 11
      apps/client/src/app/components/dialog-header/dialog-header.component.ts
  61. 14
      apps/client/src/app/components/dialog-header/dialog-header.module.ts
  62. 67
      apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts
  63. 6
      apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html
  64. 1
      apps/client/src/app/components/home-holdings/home-holdings.html
  65. 8
      apps/client/src/app/components/home-overview/home-overview.component.ts
  66. 13
      apps/client/src/app/components/home-watchlist/home-watchlist.component.ts
  67. 11
      apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.component.ts
  68. 9
      apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.html
  69. 7
      apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.module.ts
  70. 12
      apps/client/src/app/components/portfolio-performance/portfolio-performance.component.ts
  71. 16
      apps/client/src/app/components/portfolio-performance/portfolio-performance.module.ts
  72. 16
      apps/client/src/app/components/rule/rule.component.html
  73. 4
      apps/client/src/app/components/rule/rule.component.ts
  74. 10
      apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.html
  75. 10
      apps/client/src/app/core/notification/prompt-dialog/prompt-dialog.component.ts
  76. 9
      apps/client/src/app/core/notification/prompt-dialog/prompt-dialog.html
  77. 10
      apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.html
  78. 10
      apps/client/src/app/pages/i18n/i18n-page.html
  79. 16
      apps/client/src/app/pages/landing/landing-page-routing.module.ts
  80. 37
      apps/client/src/app/pages/landing/landing-page.component.ts
  81. 30
      apps/client/src/app/pages/landing/landing-page.module.ts
  82. 15
      apps/client/src/app/pages/landing/landing-page.routes.ts
  83. 48
      apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts
  84. 28
      apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.html
  85. 17
      apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.component.ts
  86. 54
      apps/client/src/app/pages/portfolio/x-ray/x-ray-page.component.html
  87. 6
      apps/client/src/app/pages/portfolio/x-ray/x-ray-page.component.ts
  88. 21
      apps/client/src/app/pages/pricing/pricing-page-routing.module.ts
  89. 42
      apps/client/src/app/pages/pricing/pricing-page.component.ts
  90. 28
      apps/client/src/app/pages/pricing/pricing-page.module.ts
  91. 14
      apps/client/src/app/pages/pricing/pricing-page.routes.ts
  92. 1
      apps/client/src/app/pages/public/public-page.html
  93. 22
      apps/client/src/app/pages/register/register-page-routing.module.ts
  94. 31
      apps/client/src/app/pages/register/register-page.component.ts
  95. 26
      apps/client/src/app/pages/register/register-page.module.ts
  96. 15
      apps/client/src/app/pages/register/register-page.routes.ts
  97. 10
      apps/client/src/app/pages/zen/zen-page.component.ts
  98. 26
      apps/client/src/app/pages/zen/zen-page.module.ts
  99. 15
      apps/client/src/app/pages/zen/zen-page.routes.ts
  100. 26
      apps/client/src/app/services/import-activities.service.ts

94
CHANGELOG.md

@ -11,6 +11,100 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Migrated from _Material Design_ 2 to _Material Design_ 3 - Migrated from _Material Design_ 2 to _Material Design_ 3
## 2.195.0 - 2025-08-29
### Changed
- Reused the request timeout in various functions of the data providers
- Refactored the _ZEN_ page to standalone
- Upgraded `chart.js` from version `4.4.9` to `4.5.0`
### Fixed
- Handled an exception in the get quotes functionality of the _Financial Modeling Prep_ service
## 2.194.0 - 2025-08-27
### Added
- Extended the watchlist endpoint by 50-Day and 200-Day trends (experimental)
### Changed
- Moved the support to customize rules in the _X-ray_ section from experimental to general availability
- Improved the create or update activity dialog’s asset sub class selector for valuables to update the options dynamically based on the selected asset class
- Improved the error handling in data providers
- Randomized the minutes of the hourly data gathering cron job
- Refactored the dialog footer component to standalone
- Refactored the dialog header component to standalone
- Refactored the landing page to standalone
- Refactored the pricing page to standalone
- Refactored the register page to standalone
- Migrated the login with access token dialog from `ngModel` to form control
- Upgraded `@ionic/angular` from version `8.6.3` to `8.7.3`
- Upgraded `ionicons` from version `8.0.10` to `8.0.13`
- Upgraded `prisma` from version `6.12.0` to `6.14.0`
## 2.193.0 - 2025-08-22
### Added
- Added a filter by data source for the asset profiles in the admin control panel
- Extended the data providers management of the admin control panel by every data provider in use
### Changed
- Improved the error handling in data providers
- Upgraded `yahoo-finance2` from version `3.4.1` to `3.6.4`
## 2.192.0 - 2025-08-21
### Added
- Included accounts in the search results of the assistant
- Included the data source in the asset profile search results of the assistant
- Added the quantity column to the holdings table of the account detail dialog
### Changed
- Migrated the prompt dialog component from `ngModel` to form control
- Refreshed the cryptocurrencies list
- Improved the language localization for German (`de`)
## 2.191.1 - 2025-08-14
### Added
- Added a new static portfolio analysis rule: _Liquidity_ (Buying Power)
- Added the interest and dividend values to the account detail dialog
### Changed
- Moved the chart of the account detail dialog from experimental to general availability
- Improved the dynamic numerical precision for various values in the account detail dialog
- Improved the usability of the _Cancel_ / _Close_ and _Save_ buttons in various dialogs
- Extended the accounts endpoint by allocations
- Extended the accounts endpoint by dividend and interest
- Refactored the portfolio performance component to standalone
- Improved the language localization for German (`de`)
- Improved the language localization for Portuguese (`pt`)
- Improved the language localization for Spanish (`es`)
## 2.190.0 - 2025-08-09
### Changed
- Extended the import functionality by tags
- Improved the dynamic numerical precision for various values in the holding detail dialog
- Shortened the date in the activities table on mobile
- Introduced the fuzzy search for the accounts endpoint
- Refactored the fuzzy search for the holdings of the assistant
- Eliminated the warnings of the database seeding process
- Improved the language localization for German (`de`)
- Improved the language localization for Polish (`pl`)
- Improved the language localization for Spanish (`es`)
- Removed the unused `codelyzer` dependency
## 2.189.0 - 2025-08-05 ## 2.189.0 - 2025-08-05
### Changed ### Changed

2
README.md

@ -295,7 +295,7 @@ Are you building your own project? Add the `ghostfolio` topic to your _GitHub_ r
Ghostfolio is **100% free** and **open source**. We encourage and support an active and healthy community that accepts contributions from the public - including you. Ghostfolio is **100% free** and **open source**. We encourage and support an active and healthy community that accepts contributions from the public - including you.
Not sure what to work on? We have [some ideas](https://github.com/ghostfolio/ghostfolio/issues?q=is%3Aissue+is%3Aopen+label%3A%22help+wanted%22), even for [newcomers](https://github.com/ghostfolio/ghostfolio/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22). Please join the Ghostfolio [Slack](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) channel or post to [@ghostfolio\_](https://x.com/ghostfolio_) on _X_. We would love to hear from you. Not sure what to work on? We have [some ideas](https://github.com/ghostfolio/ghostfolio/issues?q=is%3Aissue+is%3Aopen+label%3A%22help+wanted%22%20no%3Aassignee), even for [newcomers](https://github.com/ghostfolio/ghostfolio/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22%20no%3Aassignee). Please join the Ghostfolio [Slack](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) channel or post to [@ghostfolio\_](https://x.com/ghostfolio_) on _X_. We would love to hear from you.
If you like to support this project, get [**Ghostfolio Premium**](https://ghostfol.io/en/pricing) or [**Buy me a coffee**](https://www.buymeacoffee.com/ghostfolio). If you like to support this project, get [**Ghostfolio Premium**](https://ghostfol.io/en/pricing) or [**Buy me a coffee**](https://www.buymeacoffee.com/ghostfolio).

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

@ -89,6 +89,7 @@ export class AccountController {
public async getAllAccounts( public async getAllAccounts(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Query('dataSource') filterByDataSource?: string, @Query('dataSource') filterByDataSource?: string,
@Query('query') filterBySearchQuery?: string,
@Query('symbol') filterBySymbol?: string @Query('symbol') filterBySymbol?: string
): Promise<AccountsResponse> { ): Promise<AccountsResponse> {
const impersonationUserId = const impersonationUserId =
@ -96,6 +97,7 @@ export class AccountController {
const filters = this.apiService.buildFiltersFromQueryParams({ const filters = this.apiService.buildFiltersFromQueryParams({
filterByDataSource, filterByDataSource,
filterBySearchQuery,
filterBySymbol filterBySymbol
}); });

5
apps/api/src/app/account/account.service.ts

@ -12,7 +12,8 @@ import {
AccountBalance, AccountBalance,
Order, Order,
Platform, Platform,
Prisma Prisma,
SymbolProfile
} from '@prisma/client'; } from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { format } from 'date-fns'; import { format } from 'date-fns';
@ -62,7 +63,7 @@ export class AccountService {
orderBy?: Prisma.AccountOrderByWithRelationInput; orderBy?: Prisma.AccountOrderByWithRelationInput;
}): Promise< }): Promise<
(Account & { (Account & {
activities?: Order[]; activities?: (Order & { SymbolProfile?: SymbolProfile })[];
balances?: AccountBalance[]; balances?: AccountBalance[];
platform?: Platform; platform?: Platform;
})[] })[]

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

@ -66,7 +66,7 @@ export class AdminController {
@HasPermission(permissions.accessAdminControl) @HasPermission(permissions.accessAdminControl)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getAdminData(): Promise<AdminData> { public async getAdminData(): Promise<AdminData> {
return this.adminService.get({ user: this.request.user }); return this.adminService.get();
} }
@Get('demo-user/sync') @Get('demo-user/sync')
@ -197,6 +197,7 @@ export class AdminController {
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getMarketData( public async getMarketData(
@Query('assetSubClasses') filterByAssetSubClasses?: string, @Query('assetSubClasses') filterByAssetSubClasses?: string,
@Query('dataSource') filterByDataSource?: string,
@Query('presetId') presetId?: MarketDataPreset, @Query('presetId') presetId?: MarketDataPreset,
@Query('query') filterBySearchQuery?: string, @Query('query') filterBySearchQuery?: string,
@Query('skip') skip?: number, @Query('skip') skip?: number,
@ -206,6 +207,7 @@ export class AdminController {
): Promise<AdminMarketData> { ): Promise<AdminMarketData> {
const filters = this.apiService.buildFiltersFromQueryParams({ const filters = this.apiService.buildFiltersFromQueryParams({
filterByAssetSubClasses, filterByAssetSubClasses,
filterByDataSource,
filterBySearchQuery filterBySearchQuery
}); });

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

@ -29,7 +29,7 @@ import {
Filter Filter
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { Sector } from '@ghostfolio/common/interfaces/sector.interface'; import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
import { MarketDataPreset, UserWithSettings } from '@ghostfolio/common/types'; import { MarketDataPreset } from '@ghostfolio/common/types';
import { import {
BadRequestException, BadRequestException,
@ -133,11 +133,8 @@ export class AdminService {
} }
} }
public async get({ user }: { user: UserWithSettings }): Promise<AdminData> { public async get(): Promise<AdminData> {
const dataSources = await this.dataProviderService.getDataSources({ const dataSources = Object.values(DataSource);
user,
includeGhostfolio: true
});
const [settings, transactionCount, userCount] = await Promise.all([ const [settings, transactionCount, userCount] = await Promise.all([
this.propertyService.get(), this.propertyService.get(),
@ -145,24 +142,31 @@ export class AdminService {
this.countUsersWithAnalytics() this.countUsersWithAnalytics()
]); ]);
const dataProviders = await Promise.all( const dataProviders = (
dataSources.map(async (dataSource) => { await Promise.all(
const dataProviderInfo = this.dataProviderService dataSources.map(async (dataSource) => {
.getDataProvider(dataSource) const assetProfileCount =
.getDataProviderInfo(); await this.prismaService.symbolProfile.count({
where: {
dataSource
}
});
const assetProfileCount = await this.prismaService.symbolProfile.count({ if (assetProfileCount > 0 || dataSource === 'GHOSTFOLIO') {
where: { const dataProviderInfo = this.dataProviderService
dataSource .getDataProvider(dataSource)
.getDataProviderInfo();
return {
...dataProviderInfo,
assetProfileCount
};
} }
});
return { return null;
...dataProviderInfo, })
assetProfileCount )
}; ).filter(Boolean);
})
);
return { return {
dataProviders, dataProviders,
@ -214,12 +218,12 @@ export class AdminService {
return type === 'SEARCH_QUERY'; return type === 'SEARCH_QUERY';
})?.id; })?.id;
const { ASSET_SUB_CLASS: filtersByAssetSubClass } = groupBy( const {
filters, ASSET_SUB_CLASS: filtersByAssetSubClass,
({ type }) => { DATA_SOURCE: filtersByDataSource
return type; } = groupBy(filters, ({ type }) => {
} return type;
); });
const marketDataItems = await this.prismaService.marketData.groupBy({ const marketDataItems = await this.prismaService.marketData.groupBy({
_count: true, _count: true,
@ -230,6 +234,10 @@ export class AdminService {
where.assetSubClass = AssetSubClass[filtersByAssetSubClass[0].id]; where.assetSubClass = AssetSubClass[filtersByAssetSubClass[0].id];
} }
if (filtersByDataSource) {
where.dataSource = DataSource[filtersByDataSource[0].id];
}
if (searchQuery) { if (searchQuery) {
where.OR = [ where.OR = [
{ id: { mode: 'insensitive', startsWith: searchQuery } }, { id: { mode: 'insensitive', startsWith: searchQuery } },

4
apps/api/src/app/endpoints/tags/create-tag.dto.ts

@ -1,6 +1,10 @@
import { IsOptional, IsString } from 'class-validator'; import { IsOptional, IsString } from 'class-validator';
export class CreateTagDto { export class CreateTagDto {
@IsOptional()
@IsString()
id?: string;
@IsString() @IsString()
name: string; name: string;

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

@ -116,10 +116,13 @@ export class WatchlistService {
return profile.dataSource === dataSource && profile.symbol === symbol; return profile.dataSource === dataSource && profile.symbol === symbol;
}); });
const allTimeHigh = await this.marketDataService.getMax({ const [allTimeHigh, trends] = await Promise.all([
dataSource, this.marketDataService.getMax({
symbol dataSource,
}); symbol
}),
this.benchmarkService.getBenchmarkTrends({ dataSource, symbol })
]);
const performancePercent = const performancePercent =
this.benchmarkService.calculateChangeInPercentage( this.benchmarkService.calculateChangeInPercentage(
@ -138,7 +141,9 @@ export class WatchlistService {
performancePercent, performancePercent,
date: allTimeHigh?.date date: allTimeHigh?.date
} }
} },
trend50d: trends.trend50d,
trend200d: trends.trend200d
}; };
}) })
); );

7
apps/api/src/app/import/import-data.dto.ts

@ -3,6 +3,7 @@ import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { Type } from 'class-transformer'; import { Type } from 'class-transformer';
import { IsArray, IsOptional, ValidateNested } from 'class-validator'; import { IsArray, IsOptional, ValidateNested } from 'class-validator';
import { CreateTagDto } from '../endpoints/tags/create-tag.dto';
import { CreateAccountWithBalancesDto } from './create-account-with-balances.dto'; import { CreateAccountWithBalancesDto } from './create-account-with-balances.dto';
import { CreateAssetProfileWithMarketDataDto } from './create-asset-profile-with-market-data.dto'; import { CreateAssetProfileWithMarketDataDto } from './create-asset-profile-with-market-data.dto';
@ -23,4 +24,10 @@ export class ImportDataDto {
@Type(() => CreateAssetProfileWithMarketDataDto) @Type(() => CreateAssetProfileWithMarketDataDto)
@ValidateNested({ each: true }) @ValidateNested({ each: true })
assetProfiles?: CreateAssetProfileWithMarketDataDto[]; assetProfiles?: CreateAssetProfileWithMarketDataDto[];
@IsArray()
@IsOptional()
@Type(() => CreateTagDto)
@ValidateNested({ each: true })
tags?: CreateTagDto[];
} }

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

@ -74,6 +74,7 @@ export class ImportController {
accountsWithBalancesDto: importData.accounts ?? [], accountsWithBalancesDto: importData.accounts ?? [],
activitiesDto: importData.activities, activitiesDto: importData.activities,
assetProfilesWithMarketDataDto: importData.assetProfiles ?? [], assetProfilesWithMarketDataDto: importData.assetProfiles ?? [],
tagsDto: importData.tags ?? [],
user: this.request.user user: this.request.user
}); });

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

@ -13,6 +13,7 @@ import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-da
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { DataGatheringModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module'; import { DataGatheringModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
import { TagModule } from '@ghostfolio/api/services/tag/tag.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
@ -35,6 +36,7 @@ import { ImportService } from './import.service';
PrismaModule, PrismaModule,
RedisCacheModule, RedisCacheModule,
SymbolProfileModule, SymbolProfileModule,
TagModule,
TransformDataSourceInRequestModule, TransformDataSourceInRequestModule,
TransformDataSourceInResponseModule TransformDataSourceInResponseModule
], ],

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

@ -13,12 +13,14 @@ import { DataProviderService } from '@ghostfolio/api/services/data-provider/data
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service'; import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { TagService } from '@ghostfolio/api/services/tag/tag.service';
import { DATA_GATHERING_QUEUE_PRIORITY_HIGH } from '@ghostfolio/common/config'; import { DATA_GATHERING_QUEUE_PRIORITY_HIGH } from '@ghostfolio/common/config';
import { import {
getAssetProfileIdentifier, getAssetProfileIdentifier,
parseDate parseDate
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces'; import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { import {
AccountWithPlatform, AccountWithPlatform,
OrderWithAccount, OrderWithAccount,
@ -46,7 +48,8 @@ export class ImportService {
private readonly orderService: OrderService, private readonly orderService: OrderService,
private readonly platformService: PlatformService, private readonly platformService: PlatformService,
private readonly portfolioService: PortfolioService, private readonly portfolioService: PortfolioService,
private readonly symbolProfileService: SymbolProfileService private readonly symbolProfileService: SymbolProfileService,
private readonly tagService: TagService
) {} ) {}
public async getDividends({ public async getDividends({
@ -154,6 +157,7 @@ export class ImportService {
assetProfilesWithMarketDataDto, assetProfilesWithMarketDataDto,
isDryRun = false, isDryRun = false,
maxActivitiesToImport, maxActivitiesToImport,
tagsDto,
user user
}: { }: {
accountsWithBalancesDto: ImportDataDto['accounts']; accountsWithBalancesDto: ImportDataDto['accounts'];
@ -161,10 +165,12 @@ export class ImportService {
assetProfilesWithMarketDataDto: ImportDataDto['assetProfiles']; assetProfilesWithMarketDataDto: ImportDataDto['assetProfiles'];
isDryRun?: boolean; isDryRun?: boolean;
maxActivitiesToImport: number; maxActivitiesToImport: number;
tagsDto: ImportDataDto['tags'];
user: UserWithSettings; user: UserWithSettings;
}): Promise<Activity[]> { }): Promise<Activity[]> {
const accountIdMapping: { [oldAccountId: string]: string } = {}; const accountIdMapping: { [oldAccountId: string]: string } = {};
const assetProfileSymbolMapping: { [oldSymbol: string]: string } = {}; const assetProfileSymbolMapping: { [oldSymbol: string]: string } = {};
const tagIdMapping: { [oldTagId: string]: string } = {};
const userCurrency = user.settings.settings.baseCurrency; const userCurrency = user.settings.settings.baseCurrency;
if (!isDryRun && accountsWithBalancesDto?.length) { if (!isDryRun && accountsWithBalancesDto?.length) {
@ -293,6 +299,50 @@ export class ImportService {
} }
} }
if (tagsDto?.length) {
const existingTagsOfUser = await this.tagService.getTagsForUser(user.id);
const canCreateOwnTag = hasPermission(
user.permissions,
permissions.createOwnTag
);
for (const tag of tagsDto) {
const existingTagOfUser = existingTagsOfUser.find(({ id }) => {
return id === tag.id;
});
if (!existingTagOfUser || existingTagOfUser.userId !== null) {
if (!canCreateOwnTag) {
throw new Error(
`Insufficient permissions to create custom tag ("${tag.name}")`
);
}
if (!isDryRun) {
const existingTag = await this.tagService.getTag({ id: tag.id });
let oldTagId: string;
if (existingTag) {
oldTagId = tag.id;
delete tag.id;
}
const tagObject: Prisma.TagCreateInput = {
...tag,
user: { connect: { id: user.id } }
};
const newTag = await this.tagService.createTag(tagObject);
if (existingTag && oldTagId) {
tagIdMapping[oldTagId] = newTag.id;
}
}
}
}
}
for (const activity of activitiesDto) { for (const activity of activitiesDto) {
if (!activity.dataSource) { if (!activity.dataSource) {
if (['FEE', 'INTEREST', 'LIABILITY'].includes(activity.type)) { if (['FEE', 'INTEREST', 'LIABILITY'].includes(activity.type)) {
@ -313,6 +363,11 @@ export class ImportService {
if (assetProfileSymbolMapping[activity.symbol]) { if (assetProfileSymbolMapping[activity.symbol]) {
activity.symbol = assetProfileSymbolMapping[activity.symbol]; activity.symbol = assetProfileSymbolMapping[activity.symbol];
} }
// If a new tag is created, then update the tag ID in all activities
activity.tags = (activity.tags ?? []).map((tagId) => {
return tagIdMapping[tagId] ?? tagId;
});
} }
} }
@ -340,6 +395,24 @@ export class ImportService {
}); });
} }
const tags = (await this.tagService.getTagsForUser(user.id)).map(
({ id, name }) => {
return { id, name };
}
);
if (isDryRun) {
tagsDto
.filter(({ id }) => {
return !tags.some(({ id: tagId }) => {
return tagId === id;
});
})
.forEach(({ id, name }) => {
tags.push({ id, name });
});
}
const activities: Activity[] = []; const activities: Activity[] = [];
for (const activity of activitiesExtendedWithErrors) { for (const activity of activitiesExtendedWithErrors) {
@ -351,6 +424,7 @@ export class ImportService {
const fee = activity.fee; const fee = activity.fee;
const quantity = activity.quantity; const quantity = activity.quantity;
const SymbolProfile = activity.SymbolProfile; const SymbolProfile = activity.SymbolProfile;
const tagIds = activity.tagIds ?? [];
const type = activity.type; const type = activity.type;
const unitPrice = activity.unitPrice; const unitPrice = activity.unitPrice;
@ -388,11 +462,17 @@ export class ImportService {
const validatedAccount = accounts.find(({ id }) => { const validatedAccount = accounts.find(({ id }) => {
return id === accountId; return id === accountId;
}); });
const validatedTags = tags.filter(({ id: tagId }) => {
return tagIds.some((activityTagId) => {
return activityTagId === tagId;
});
});
let order: let order:
| OrderWithAccount | OrderWithAccount
| (Omit<OrderWithAccount, 'Account'> & { | (Omit<OrderWithAccount, 'account' | 'tags'> & {
Account?: { id: string; name: string }; account?: { id: string; name: string };
tags?: { id: string; name: string }[];
}); });
if (isDryRun) { if (isDryRun) {
@ -404,7 +484,7 @@ export class ImportService {
quantity, quantity,
type, type,
unitPrice, unitPrice,
Account: validatedAccount, account: validatedAccount,
accountId: validatedAccount?.id, accountId: validatedAccount?.id,
accountUserId: undefined, accountUserId: undefined,
createdAt: new Date(), createdAt: new Date(),
@ -436,6 +516,7 @@ export class ImportService {
userId: dataSource === 'MANUAL' ? user.id : undefined userId: dataSource === 'MANUAL' ? user.id : undefined
}, },
symbolProfileId: undefined, symbolProfileId: undefined,
tags: validatedTags,
updatedAt: new Date(), updatedAt: new Date(),
userId: user.id userId: user.id
}; };
@ -469,6 +550,9 @@ export class ImportService {
} }
} }
}, },
tags: validatedTags.map(({ id }) => {
return { id };
}),
updateAccountBalance: false, updateAccountBalance: false,
user: { connect: { id: user.id } }, user: { connect: { id: user.id } },
userId: user.id userId: user.id
@ -546,6 +630,7 @@ export class ImportService {
fee, fee,
quantity, quantity,
symbol, symbol,
tags,
type, type,
unitPrice unitPrice
}) => { }) => {
@ -594,7 +679,8 @@ export class ImportService {
isActive: true, isActive: true,
sectors: undefined, sectors: undefined,
updatedAt: undefined updatedAt: undefined
} },
tagIds: tags
}; };
} }
); );
@ -626,7 +712,7 @@ export class ImportService {
const assetProfiles: { const assetProfiles: {
[assetProfileIdentifier: string]: Partial<SymbolProfile>; [assetProfileIdentifier: string]: Partial<SymbolProfile>;
} = {}; } = {};
const dataSources = await this.dataProviderService.getDataSources({ user }); const dataSources = await this.dataProviderService.getDataSources();
for (const [ for (const [
index, index,

10
apps/api/src/app/order/create-order.dto.ts

@ -1,13 +1,7 @@
import { IsCurrencyCode } from '@ghostfolio/api/validators/is-currency-code'; import { IsCurrencyCode } from '@ghostfolio/api/validators/is-currency-code';
import { IsAfter1970Constraint } from '@ghostfolio/common/validator-constraints/is-after-1970'; import { IsAfter1970Constraint } from '@ghostfolio/common/validator-constraints/is-after-1970';
import { import { AssetClass, AssetSubClass, DataSource, Type } from '@prisma/client';
AssetClass,
AssetSubClass,
DataSource,
Tag,
Type
} from '@prisma/client';
import { Transform, TransformFnParams } from 'class-transformer'; import { Transform, TransformFnParams } from 'class-transformer';
import { import {
IsArray, IsArray,
@ -70,7 +64,7 @@ export class CreateOrderDto {
@IsArray() @IsArray()
@IsOptional() @IsOptional()
tags?: Tag[]; tags?: string[];
@IsEnum(Type, { each: true }) @IsEnum(Type, { each: true })
type: Type; type: Type;

1
apps/api/src/app/order/interfaces/activities.interface.ts

@ -14,6 +14,7 @@ export interface Activity extends Order {
feeInAssetProfileCurrency: number; feeInAssetProfileCurrency: number;
feeInBaseCurrency: number; feeInBaseCurrency: number;
SymbolProfile?: EnhancedSymbolProfile; SymbolProfile?: EnhancedSymbolProfile;
tagIds?: string[];
tags?: Tag[]; tags?: Tag[];
unitPriceInAssetProfileCurrency: number; unitPriceInAssetProfileCurrency: number;
updateAccountBalance?: boolean; updateAccountBalance?: boolean;

6
apps/api/src/app/order/order.controller.ts

@ -217,6 +217,9 @@ export class OrderController {
} }
} }
}, },
tags: data.tags?.map((id) => {
return { id };
}),
user: { connect: { id: this.request.user.id } }, user: { connect: { id: this.request.user.id } },
userId: this.request.user.id userId: this.request.user.id
}); });
@ -293,6 +296,9 @@ export class OrderController {
name: data.symbol name: data.symbol
} }
}, },
tags: data.tags?.map((id) => {
return { id };
}),
user: { connect: { id: this.request.user.id } } user: { connect: { id: this.request.user.id } }
}, },
where: { where: {

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

@ -97,7 +97,7 @@ export class OrderService {
assetSubClass?: AssetSubClass; assetSubClass?: AssetSubClass;
currency?: string; currency?: string;
symbol?: string; symbol?: string;
tags?: Tag[]; tags?: { id: string }[];
updateAccountBalance?: boolean; updateAccountBalance?: boolean;
userId: string; userId: string;
} }
@ -201,9 +201,7 @@ export class OrderService {
account, account,
isDraft, isDraft,
tags: { tags: {
connect: tags.map(({ id }) => { connect: tags
return { id };
})
} }
}, },
include: { SymbolProfile: true } include: { SymbolProfile: true }
@ -658,7 +656,7 @@ export class OrderService {
assetSubClass?: AssetSubClass; assetSubClass?: AssetSubClass;
currency?: string; currency?: string;
symbol?: string; symbol?: string;
tags?: Tag[]; tags?: { id: string }[];
type?: ActivityType; type?: ActivityType;
}; };
where: Prisma.OrderWhereUniqueInput; where: Prisma.OrderWhereUniqueInput;
@ -720,9 +718,7 @@ export class OrderService {
...data, ...data,
isDraft, isDraft,
tags: { tags: {
connect: tags.map(({ id }) => { connect: tags
return { id };
})
} }
} }
}); });

10
apps/api/src/app/order/update-order.dto.ts

@ -1,13 +1,7 @@
import { IsCurrencyCode } from '@ghostfolio/api/validators/is-currency-code'; import { IsCurrencyCode } from '@ghostfolio/api/validators/is-currency-code';
import { IsAfter1970Constraint } from '@ghostfolio/common/validator-constraints/is-after-1970'; import { IsAfter1970Constraint } from '@ghostfolio/common/validator-constraints/is-after-1970';
import { import { AssetClass, AssetSubClass, DataSource, Type } from '@prisma/client';
AssetClass,
AssetSubClass,
DataSource,
Tag,
Type
} from '@prisma/client';
import { Transform, TransformFnParams } from 'class-transformer'; import { Transform, TransformFnParams } from 'class-transformer';
import { import {
IsArray, IsArray,
@ -71,7 +65,7 @@ export class UpdateOrderDto {
@IsArray() @IsArray()
@IsOptional() @IsOptional()
tags?: Tag[]; tags?: string[];
@IsString() @IsString()
type: Type; type: Type;

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

@ -18,6 +18,7 @@ import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/po
import { parseDate } from '@ghostfolio/common/helper'; import { parseDate } from '@ghostfolio/common/helper';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Tag } from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { join } from 'path'; import { join } from 'path';
@ -108,6 +109,9 @@ describe('PortfolioCalculator', () => {
name: 'Bitcoin', name: 'Bitcoin',
symbol: activity.symbol symbol: activity.symbol
}, },
tags: activity.tags?.map((id) => {
return { id } as Tag;
}),
unitPriceInAssetProfileCurrency: 44558.42 unitPriceInAssetProfileCurrency: 44558.42
})); }));

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

@ -18,6 +18,7 @@ import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/po
import { parseDate } from '@ghostfolio/common/helper'; import { parseDate } from '@ghostfolio/common/helper';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Tag } from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { join } from 'path'; import { join } from 'path';
@ -108,6 +109,9 @@ describe('PortfolioCalculator', () => {
name: 'Bitcoin', name: 'Bitcoin',
symbol: activity.symbol symbol: activity.symbol
}, },
tags: activity.tags?.map((id) => {
return { id } as Tag;
}),
unitPriceInAssetProfileCurrency: 44558.42 unitPriceInAssetProfileCurrency: 44558.42
})); }));

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

@ -18,6 +18,7 @@ import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/po
import { parseDate } from '@ghostfolio/common/helper'; import { parseDate } from '@ghostfolio/common/helper';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Tag } from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { join } from 'path'; import { join } from 'path';
@ -111,6 +112,9 @@ describe('PortfolioCalculator', () => {
name: 'Novartis AG', name: 'Novartis AG',
symbol: activity.symbol symbol: activity.symbol
}, },
tags: activity.tags?.map((id) => {
return { id } as Tag;
}),
unitPriceInAssetProfileCurrency: activity.unitPrice unitPriceInAssetProfileCurrency: activity.unitPrice
})); }));

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

@ -18,6 +18,7 @@ import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/po
import { parseDate } from '@ghostfolio/common/helper'; import { parseDate } from '@ghostfolio/common/helper';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Tag } from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { join } from 'path'; import { join } from 'path';
@ -111,6 +112,9 @@ describe('PortfolioCalculator', () => {
name: 'Novartis AG', name: 'Novartis AG',
symbol: activity.symbol symbol: activity.symbol
}, },
tags: activity.tags?.map((id) => {
return { id } as Tag;
}),
unitPriceInAssetProfileCurrency: activity.unitPrice unitPriceInAssetProfileCurrency: activity.unitPrice
})); }));

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

@ -413,6 +413,7 @@ export class PortfolioController {
filterByAssetClasses, filterByAssetClasses,
filterByDataSource, filterByDataSource,
filterByHoldingType, filterByHoldingType,
filterBySearchQuery,
filterBySymbol, filterBySymbol,
filterByTags filterByTags
}); });
@ -421,7 +422,6 @@ export class PortfolioController {
dateRange, dateRange,
filters, filters,
impersonationId, impersonationId,
query: filterBySearchQuery,
userId: this.request.user.id userId: this.request.user.id
}); });

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

@ -15,6 +15,7 @@ import { EconomicMarketClusterRiskDevelopedMarkets } from '@ghostfolio/api/model
import { EconomicMarketClusterRiskEmergingMarkets } from '@ghostfolio/api/models/rules/economic-market-cluster-risk/emerging-markets'; import { EconomicMarketClusterRiskEmergingMarkets } from '@ghostfolio/api/models/rules/economic-market-cluster-risk/emerging-markets';
import { EmergencyFundSetup } from '@ghostfolio/api/models/rules/emergency-fund/emergency-fund-setup'; import { EmergencyFundSetup } from '@ghostfolio/api/models/rules/emergency-fund/emergency-fund-setup';
import { FeeRatioInitialInvestment } from '@ghostfolio/api/models/rules/fees/fee-ratio-initial-investment'; import { FeeRatioInitialInvestment } from '@ghostfolio/api/models/rules/fees/fee-ratio-initial-investment';
import { BuyingPower } from '@ghostfolio/api/models/rules/liquidity/buying-power';
import { RegionalMarketClusterRiskAsiaPacific } from '@ghostfolio/api/models/rules/regional-market-cluster-risk/asia-pacific'; import { RegionalMarketClusterRiskAsiaPacific } from '@ghostfolio/api/models/rules/regional-market-cluster-risk/asia-pacific';
import { RegionalMarketClusterRiskEmergingMarkets } from '@ghostfolio/api/models/rules/regional-market-cluster-risk/emerging-markets'; import { RegionalMarketClusterRiskEmergingMarkets } from '@ghostfolio/api/models/rules/regional-market-cluster-risk/emerging-markets';
import { RegionalMarketClusterRiskEurope } from '@ghostfolio/api/models/rules/regional-market-cluster-risk/europe'; import { RegionalMarketClusterRiskEurope } from '@ghostfolio/api/models/rules/regional-market-cluster-risk/europe';
@ -161,7 +162,7 @@ export class PortfolioService {
this.accountService.accounts({ this.accountService.accounts({
where, where,
include: { include: {
activities: true, activities: { include: { SymbolProfile: true } },
platform: true platform: true
}, },
orderBy: { name: 'asc' } orderBy: { name: 'asc' }
@ -176,39 +177,74 @@ export class PortfolioService {
const userCurrency = this.request.user.settings.settings.baseCurrency; const userCurrency = this.request.user.settings.settings.baseCurrency;
return accounts.map((account) => { return Promise.all(
let transactionCount = 0; accounts.map(async (account) => {
let dividendInBaseCurrency = 0;
let interestInBaseCurrency = 0;
let transactionCount = 0;
for (const { isDraft } of account.activities) { for (const {
if (!isDraft) { currency,
transactionCount += 1; date,
isDraft,
quantity,
SymbolProfile,
type,
unitPrice
} of account.activities) {
switch (type) {
case ActivityType.DIVIDEND:
dividendInBaseCurrency +=
await this.exchangeRateDataService.toCurrencyAtDate(
new Big(quantity).mul(unitPrice).toNumber(),
currency ?? SymbolProfile.currency,
userCurrency,
date
);
break;
case ActivityType.INTEREST:
interestInBaseCurrency +=
await this.exchangeRateDataService.toCurrencyAtDate(
unitPrice,
currency ?? SymbolProfile.currency,
userCurrency,
date
);
break;
}
if (!isDraft) {
transactionCount += 1;
}
} }
}
const valueInBaseCurrency = const valueInBaseCurrency =
details.accounts[account.id]?.valueInBaseCurrency ?? 0; details.accounts[account.id]?.valueInBaseCurrency ?? 0;
const result = { const result = {
...account, ...account,
transactionCount, dividendInBaseCurrency,
valueInBaseCurrency, interestInBaseCurrency,
allocationInPercentage: null, // TODO transactionCount,
balanceInBaseCurrency: this.exchangeRateDataService.toCurrency(
account.balance,
account.currency,
userCurrency
),
value: this.exchangeRateDataService.toCurrency(
valueInBaseCurrency, valueInBaseCurrency,
userCurrency, allocationInPercentage: 0,
account.currency balanceInBaseCurrency: this.exchangeRateDataService.toCurrency(
) account.balance,
}; account.currency,
userCurrency
),
value: this.exchangeRateDataService.toCurrency(
valueInBaseCurrency,
userCurrency,
account.currency
)
};
delete result.activities; delete result.activities;
return result; return result;
}); })
);
} }
public async getAccountsWithAggregations({ public async getAccountsWithAggregations({
@ -220,12 +256,30 @@ export class PortfolioService {
userId: string; userId: string;
withExcludedAccounts?: boolean; withExcludedAccounts?: boolean;
}): Promise<AccountsResponse> { }): Promise<AccountsResponse> {
const accounts = await this.getAccounts({ let accounts = await this.getAccounts({
filters, filters,
userId, userId,
withExcludedAccounts withExcludedAccounts
}); });
const searchQuery = filters.find(({ type }) => {
return type === 'SEARCH_QUERY';
})?.id;
if (searchQuery) {
const fuse = new Fuse(accounts, {
keys: ['name', 'platform.name'],
threshold: 0.3
});
accounts = fuse.search(searchQuery).map(({ item }) => {
return item;
});
}
let totalBalanceInBaseCurrency = new Big(0); let totalBalanceInBaseCurrency = new Big(0);
let totalDividendInBaseCurrency = new Big(0);
let totalInterestInBaseCurrency = new Big(0);
let totalValueInBaseCurrency = new Big(0); let totalValueInBaseCurrency = new Big(0);
let transactionCount = 0; let transactionCount = 0;
@ -233,16 +287,33 @@ export class PortfolioService {
totalBalanceInBaseCurrency = totalBalanceInBaseCurrency.plus( totalBalanceInBaseCurrency = totalBalanceInBaseCurrency.plus(
account.balanceInBaseCurrency account.balanceInBaseCurrency
); );
totalDividendInBaseCurrency = totalDividendInBaseCurrency.plus(
account.dividendInBaseCurrency
);
totalInterestInBaseCurrency = totalInterestInBaseCurrency.plus(
account.interestInBaseCurrency
);
totalValueInBaseCurrency = totalValueInBaseCurrency.plus( totalValueInBaseCurrency = totalValueInBaseCurrency.plus(
account.valueInBaseCurrency account.valueInBaseCurrency
); );
transactionCount += account.transactionCount; transactionCount += account.transactionCount;
} }
for (const account of accounts) {
account.allocationInPercentage =
totalValueInBaseCurrency.toNumber() > Number.EPSILON
? Big(account.valueInBaseCurrency)
.div(totalValueInBaseCurrency)
.toNumber()
: 0;
}
return { return {
accounts, accounts,
transactionCount, transactionCount,
totalBalanceInBaseCurrency: totalBalanceInBaseCurrency.toNumber(), totalBalanceInBaseCurrency: totalBalanceInBaseCurrency.toNumber(),
totalDividendInBaseCurrency: totalDividendInBaseCurrency.toNumber(),
totalInterestInBaseCurrency: totalInterestInBaseCurrency.toNumber(),
totalValueInBaseCurrency: totalValueInBaseCurrency.toNumber() totalValueInBaseCurrency: totalValueInBaseCurrency.toNumber()
}; };
} }
@ -276,13 +347,11 @@ export class PortfolioService {
dateRange, dateRange,
filters, filters,
impersonationId, impersonationId,
query,
userId userId
}: { }: {
dateRange: DateRange; dateRange: DateRange;
filters?: Filter[]; filters?: Filter[];
impersonationId: string; impersonationId: string;
query?: string;
userId: string; userId: string;
}) { }) {
userId = await this.getUserId(impersonationId, userId); userId = await this.getUserId(impersonationId, userId);
@ -295,13 +364,17 @@ export class PortfolioService {
let holdings = Object.values(holdingsMap); let holdings = Object.values(holdingsMap);
if (query) { const searchQuery = filters.find(({ type }) => {
return type === 'SEARCH_QUERY';
})?.id;
if (searchQuery) {
const fuse = new Fuse(holdings, { const fuse = new Fuse(holdings, {
keys: ['isin', 'name', 'symbol'], keys: ['isin', 'name', 'symbol'],
threshold: 0.3 threshold: 0.3
}); });
holdings = fuse.search(query).map(({ item }) => { holdings = fuse.search(searchQuery).map(({ item }) => {
return item; return item;
}); });
} }
@ -1268,6 +1341,17 @@ export class PortfolioService {
], ],
userSettings userSettings
), ),
liquidity: await this.rulesService.evaluate(
[
new BuyingPower(
this.exchangeRateDataService,
this.i18nService,
summary.cash,
userSettings.language
)
],
userSettings
),
regionalMarketClusterRisk: regionalMarketClusterRisk:
summary.activityCount > 0 summary.activityCount > 0
? await this.rulesService.evaluate( ? await this.rulesService.evaluate(

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

@ -13,6 +13,7 @@ import { EconomicMarketClusterRiskDevelopedMarkets } from '@ghostfolio/api/model
import { EconomicMarketClusterRiskEmergingMarkets } from '@ghostfolio/api/models/rules/economic-market-cluster-risk/emerging-markets'; import { EconomicMarketClusterRiskEmergingMarkets } from '@ghostfolio/api/models/rules/economic-market-cluster-risk/emerging-markets';
import { EmergencyFundSetup } from '@ghostfolio/api/models/rules/emergency-fund/emergency-fund-setup'; import { EmergencyFundSetup } from '@ghostfolio/api/models/rules/emergency-fund/emergency-fund-setup';
import { FeeRatioInitialInvestment } from '@ghostfolio/api/models/rules/fees/fee-ratio-initial-investment'; import { FeeRatioInitialInvestment } from '@ghostfolio/api/models/rules/fees/fee-ratio-initial-investment';
import { BuyingPower } from '@ghostfolio/api/models/rules/liquidity/buying-power';
import { RegionalMarketClusterRiskAsiaPacific } from '@ghostfolio/api/models/rules/regional-market-cluster-risk/asia-pacific'; import { RegionalMarketClusterRiskAsiaPacific } from '@ghostfolio/api/models/rules/regional-market-cluster-risk/asia-pacific';
import { RegionalMarketClusterRiskEmergingMarkets } from '@ghostfolio/api/models/rules/regional-market-cluster-risk/emerging-markets'; import { RegionalMarketClusterRiskEmergingMarkets } from '@ghostfolio/api/models/rules/regional-market-cluster-risk/emerging-markets';
import { RegionalMarketClusterRiskEurope } from '@ghostfolio/api/models/rules/regional-market-cluster-risk/europe'; import { RegionalMarketClusterRiskEurope } from '@ghostfolio/api/models/rules/regional-market-cluster-risk/europe';
@ -287,6 +288,12 @@ export class UserService {
undefined, undefined,
undefined undefined
).getSettings(user.settings.settings), ).getSettings(user.settings.settings),
BuyingPower: new BuyingPower(
undefined,
undefined,
undefined,
undefined
).getSettings(user.settings.settings),
CurrencyClusterRiskBaseCurrencyCurrentInvestment: CurrencyClusterRiskBaseCurrencyCurrentInvestment:
new CurrencyClusterRiskBaseCurrencyCurrentInvestment( new CurrencyClusterRiskBaseCurrencyCurrentInvestment(
undefined, undefined,

156
apps/api/src/assets/cryptocurrencies/cryptocurrencies.json

@ -60,6 +60,7 @@
"1GUY": "1GUY", "1GUY": "1GUY",
"1HUB": "1HubAI", "1HUB": "1HubAI",
"1INCH": "1inch", "1INCH": "1inch",
"1IQ": "People with 1 IQ",
"1IRST": "1irstcoin", "1IRST": "1irstcoin",
"1MCT": "MicroCreditToken", "1MCT": "MicroCreditToken",
"1MDC": "1MDC", "1MDC": "1MDC",
@ -122,7 +123,6 @@
"4EVER": "4EVERLAND", "4EVER": "4EVERLAND",
"4JNET": "4JNET", "4JNET": "4JNET",
"4MW": "For Meta World", "4MW": "For Meta World",
"4P": "4P FOUR",
"4RZ": "4REALZA COIN", "4RZ": "4REALZA COIN",
"4THPILLAR": "4th Pillar Four Token", "4THPILLAR": "4th Pillar Four Token",
"4TOKEN": "Ignore Fud", "4TOKEN": "Ignore Fud",
@ -148,6 +148,7 @@
"A1INCH": "1inch (Arbitrum Bridge)", "A1INCH": "1inch (Arbitrum Bridge)",
"A2A": "A2A", "A2A": "A2A",
"A2I": "Arcana AI", "A2I": "Arcana AI",
"A2Z": "Arena-Z",
"A4": "A4 Finance", "A4": "A4 Finance",
"A47": "AGENDA 47", "A47": "AGENDA 47",
"A4M": "AlienForm", "A4M": "AlienForm",
@ -466,6 +467,7 @@
"AINN": "AINN", "AINN": "AINN",
"AINTI": "AIntivirus", "AINTI": "AIntivirus",
"AINU": "Ainu Token", "AINU": "Ainu Token",
"AIO": "AIO",
"AION": "Aion", "AION": "Aion",
"AIONE": "AiONE", "AIONE": "AiONE",
"AIOS": "INT OS", "AIOS": "INT OS",
@ -668,6 +670,7 @@
"AMERICA": "America", "AMERICA": "America",
"AMERICAI": "AMERICA AI Agent", "AMERICAI": "AMERICA AI Agent",
"AMERICANCOIN": "AmericanCoin", "AMERICANCOIN": "AmericanCoin",
"AMETA": "Alpha City",
"AMF": "AddMeFast", "AMF": "AddMeFast",
"AMG": "DeHeroGame Amazing Token", "AMG": "DeHeroGame Amazing Token",
"AMI": "AMMYI Coin", "AMI": "AMMYI Coin",
@ -764,6 +767,7 @@
"ANTMONS": "Antmons", "ANTMONS": "Antmons",
"ANTS": "ANTS Reloaded", "ANTS": "ANTS Reloaded",
"ANTT": "Antara Token", "ANTT": "Antara Token",
"ANUBHAV": "Anubhav Trainings",
"ANUS": "URANUS", "ANUS": "URANUS",
"ANV": "Aniverse", "ANV": "Aniverse",
"ANVL": "Anvil", "ANVL": "Anvil",
@ -998,8 +1002,9 @@
"ASMO": "AS Monaco Fan Token", "ASMO": "AS Monaco Fan Token",
"ASN": "Ascension Coin", "ASN": "Ascension Coin",
"ASNT": "Assent Protocol", "ASNT": "Assent Protocol",
"ASP": "Aspire", "ASP": "Aspecta",
"ASPC": "Astropup Coin", "ASPC": "Astropup Coin",
"ASPIRE": "Aspire",
"ASPIRIN": "Aspirin", "ASPIRIN": "Aspirin",
"ASPO": "ASPO Shards", "ASPO": "ASPO Shards",
"ASQT": "ASQ Protocol", "ASQT": "ASQ Protocol",
@ -1008,6 +1013,7 @@
"ASS": "Australian Safe Shepherd", "ASS": "Australian Safe Shepherd",
"ASSA": "AssaPlay", "ASSA": "AssaPlay",
"ASSARA": "ASSARA", "ASSARA": "ASSARA",
"ASSDAQ": "ASSDAQ",
"ASSET": "iAssets", "ASSET": "iAssets",
"ASST": "AssetStream", "ASST": "AssetStream",
"AST": "AirSwap", "AST": "AirSwap",
@ -1093,8 +1099,10 @@
"ATS": "Alltoscan", "ATS": "Alltoscan",
"ATT": "Attila", "ATT": "Attila",
"ATTR": "Attrace", "ATTR": "Attrace",
"ATU": "Quantum",
"ATX": "ArtexCoin", "ATX": "ArtexCoin",
"AU": "AutoCrypto", "AU": "AutoCrypto",
"AU79": "AU79",
"AUA": "ArubaCoin", "AUA": "ArubaCoin",
"AUC": "Auctus", "AUC": "Auctus",
"AUCO": "Advanced United Continent", "AUCO": "Advanced United Continent",
@ -1271,6 +1279,7 @@
"BABYBOME": "Book of Baby Memes", "BABYBOME": "Book of Baby Memes",
"BABYBOMEOW": "Baby of BOMEOW", "BABYBOMEOW": "Baby of BOMEOW",
"BABYBONK": "Baby Bonk", "BABYBONK": "Baby Bonk",
"BABYBOSS": "Baby Boss",
"BABYBROC": "Baby Broccoli", "BABYBROC": "Baby Broccoli",
"BABYBROCCOL": "Baby Broccoli", "BABYBROCCOL": "Baby Broccoli",
"BABYBROCCOLI": "BabyBroccoli", "BABYBROCCOLI": "BabyBroccoli",
@ -1361,6 +1370,7 @@
"BABYWLFI": "Baby WLFI", "BABYWLFI": "Baby WLFI",
"BABYX": "Baby X", "BABYX": "Baby X",
"BAC": "Basis Cash", "BAC": "Basis Cash",
"BACHI": "Bachi on Base",
"BACK": "DollarBack", "BACK": "DollarBack",
"BACOIN": "BACoin", "BACOIN": "BACoin",
"BACON": "BaconDAO (BACON)", "BACON": "BaconDAO (BACON)",
@ -1397,6 +1407,7 @@
"BALIN": "Balin Bank", "BALIN": "Balin Bank",
"BALKANCOIN": "Balkancoin", "BALKANCOIN": "Balkancoin",
"BALL": "BitBall", "BALL": "BitBall",
"BALLTZE": "BALLTZE",
"BALLZ": "Wolf Wif", "BALLZ": "Wolf Wif",
"BALN": "Balanced", "BALN": "Balanced",
"BALPHA": "bAlpha", "BALPHA": "bAlpha",
@ -1517,6 +1528,7 @@
"BBG": "BigBang", "BBG": "BigBang",
"BBGC": "BigBang Game", "BBGC": "BigBang Game",
"BBI": "BelugaPay", "BBI": "BelugaPay",
"BBITBTC": "BounceBit BTC",
"BBK": "BitBlocks", "BBK": "BitBlocks",
"BBL": "beoble", "BBL": "beoble",
"BBN": "BBNCOIN", "BBN": "BBNCOIN",
@ -2145,6 +2157,7 @@
"BNC": "Bifrost Native Coin", "BNC": "Bifrost Native Coin",
"BND": "Bened", "BND": "Bened",
"BNF": "BonFi", "BNF": "BonFi",
"BNFT": "APENFT (BitTorrent Bridge)",
"BNIU": "Backed Niu Technologies", "BNIU": "Backed Niu Technologies",
"BNIX": "BNIX Token", "BNIX": "BNIX Token",
"BNK": "Bankera", "BNK": "Bankera",
@ -2191,6 +2204,7 @@
"BOBMARLEY": "Bob Marley Meme", "BOBMARLEY": "Bob Marley Meme",
"BOBO": "BOBO", "BOBO": "BOBO",
"BOBOT": "Bobo The Bear", "BOBOT": "Bobo The Bear",
"BOBR": "Based BOBR",
"BOBS": "Bob's Repair", "BOBS": "Bob's Repair",
"BOBT": "BOB Token", "BOBT": "BOB Token",
"BOBTHE": "Bob The Builder", "BOBTHE": "Bob The Builder",
@ -2391,6 +2405,7 @@
"BPUNI": "Binance-Peg Uniswap Protocol Token (Binance Bridge)", "BPUNI": "Binance-Peg Uniswap Protocol Token (Binance Bridge)",
"BPUSDC": "Binance-Peg USD Coin (Binance Bridge)", "BPUSDC": "Binance-Peg USD Coin (Binance Bridge)",
"BPX": "Black Phoenix", "BPX": "Black Phoenix",
"BPXL": "BombPixel",
"BQ": "Bitqy", "BQ": "Bitqy",
"BQC": "BQCoin", "BQC": "BQCoin",
"BQQQ": "Bitsdaq Token", "BQQQ": "Bitsdaq Token",
@ -2462,6 +2477,7 @@
"BRK": "BreakoutCoin", "BRK": "BreakoutCoin",
"BRKBX": "Berkshire Hathaway xStock", "BRKBX": "Berkshire Hathaway xStock",
"BRKL": "Brokoli Token", "BRKL": "Brokoli Token",
"BRM": "BullRun Meme",
"BRMV": "BRMV Token", "BRMV": "BRMV Token",
"BRN": "BRN Metaverse", "BRN": "BRN Metaverse",
"BRNK": "Brank", "BRNK": "Brank",
@ -2549,9 +2565,11 @@
"BSTC": "BST Chain", "BSTC": "BST Chain",
"BSTK": "BattleStake", "BSTK": "BattleStake",
"BSTN": "BitStation", "BSTN": "BitStation",
"BSTR": "BSTR",
"BSTS": "Magic Beasties", "BSTS": "Magic Beasties",
"BSTY": "GlobalBoost", "BSTY": "GlobalBoost",
"BSV": "Bitcoin SV", "BSV": "Bitcoin SV",
"BSVBRC": "BSVBRC",
"BSW": "Biswap", "BSW": "Biswap",
"BSWAP": "BaseSwap", "BSWAP": "BaseSwap",
"BSWT": "BaySwap", "BSWT": "BaySwap",
@ -2693,7 +2711,8 @@
"BUCKY": "Bucky", "BUCKY": "Bucky",
"BUD": "Buddy", "BUD": "Buddy",
"BUDDHA": "Buddha", "BUDDHA": "Buddha",
"BUDDY": "BUDDY", "BUDDY": "alright buddy",
"BUDDYONSOL": "BUDDY",
"BUDG": "Bulldogswap", "BUDG": "Bulldogswap",
"BUENO": "Bueno", "BUENO": "Bueno",
"BUF": "Buftoad", "BUF": "Buftoad",
@ -2728,7 +2747,9 @@
"BULLIONFX": "BullionFX", "BULLIONFX": "BullionFX",
"BULLISH": "bullish", "BULLISH": "bullish",
"BULLMOON": "Bull Moon", "BULLMOON": "Bull Moon",
"BULLPEPE": "Bullpepe", "BULLPEPE": "Bull Pepe",
"BULLPEPEIO": "Bullpepe",
"BULLPEPENET": "Bull Pepe",
"BULLS": "Bull Coin", "BULLS": "Bull Coin",
"BULLSEYE": "bulls-eye", "BULLSEYE": "bulls-eye",
"BULLSH": "Bullshit Inu", "BULLSH": "Bullshit Inu",
@ -2832,6 +2853,7 @@
"BZX": "Bitcoin Zero", "BZX": "Bitcoin Zero",
"BZZ": "Swarmv", "BZZ": "Swarmv",
"BZZONE": "Bzzone", "BZZONE": "Bzzone",
"C": "Chainbase Token",
"C2": "Coin.2", "C2": "Coin.2",
"C20": "Crypto20", "C20": "Crypto20",
"C25": "C25 Coin", "C25": "C25 Coin",
@ -3390,6 +3412,7 @@
"CLH": "ClearDAO", "CLH": "ClearDAO",
"CLICK": "Clickcoin", "CLICK": "Clickcoin",
"CLIFF": "Clifford Inu", "CLIFF": "Clifford Inu",
"CLIFFORD": "Clifford",
"CLIMB": "CLIMB TOKEN FINANCE", "CLIMB": "CLIMB TOKEN FINANCE",
"CLIN": "Clinicoin", "CLIN": "Clinicoin",
"CLINK": "cLINK", "CLINK": "cLINK",
@ -3408,6 +3431,7 @@
"CLOA": "Cloak", "CLOA": "Cloak",
"CLOAK": "CloakCoin", "CLOAK": "CloakCoin",
"CLOKI": "CATLOKI", "CLOKI": "CATLOKI",
"CLOOTS": "CryptoLoots",
"CLORE": "Clore.ai", "CLORE": "Clore.ai",
"CLOUD": "Cloud", "CLOUD": "Cloud",
"CLOUDGPU": "CloudGPU", "CLOUDGPU": "CloudGPU",
@ -3588,6 +3612,7 @@
"CONJ": "Conjee", "CONJ": "Conjee",
"CONK": "ShibaPoconk", "CONK": "ShibaPoconk",
"CONS": "ConSpiracy Coin", "CONS": "ConSpiracy Coin",
"CONSCIOUS": "Conscious Token",
"CONSENTIUM": "Consentium", "CONSENTIUM": "Consentium",
"CONTENTBOX": "ContentBox", "CONTENTBOX": "ContentBox",
"CONTROL": "Control Token", "CONTROL": "Control Token",
@ -3924,6 +3949,7 @@
"CTN": "Continuum Finance", "CTN": "Continuum Finance",
"CTO": "BaseCTO", "CTO": "BaseCTO",
"CTOAI": "ClustroAI", "CTOAI": "ClustroAI",
"CTOC": "CTOC",
"CTOK": "Codyfight", "CTOK": "Codyfight",
"CTP": "Ctomorrow Platform", "CTP": "Ctomorrow Platform",
"CTPL": "Cultiplan", "CTPL": "Cultiplan",
@ -4169,6 +4195,7 @@
"DARKEN": "Dark Energy Crystals", "DARKEN": "Dark Energy Crystals",
"DARKF": "Dark Frontiers", "DARKF": "Dark Frontiers",
"DARKMAGACOIN": "DARK MAGA", "DARKMAGACOIN": "DARK MAGA",
"DARKSTAR": "DarkStar",
"DARKT": "Dark Trump", "DARKT": "Dark Trump",
"DARKTOKEN": "DarkToken", "DARKTOKEN": "DarkToken",
"DART": "dART Insurance", "DART": "dART Insurance",
@ -4373,6 +4400,7 @@
"DEOD": "Decentrawood", "DEOD": "Decentrawood",
"DEOR": "Decentralized Oracle", "DEOR": "Decentralized Oracle",
"DEP": "DEAPCOIN", "DEP": "DEAPCOIN",
"DEPIN": "DEPIN",
"DEPINU": "Depression Inu", "DEPINU": "Depression Inu",
"DEPO": "Depo", "DEPO": "Depo",
"DEPTH": "Depth Token", "DEPTH": "Depth Token",
@ -5368,6 +5396,7 @@
"EMILY": "Emily", "EMILY": "Emily",
"EMIT": "Time Machine NFTs", "EMIT": "Time Machine NFTs",
"EML": "EML Protocol", "EML": "EML Protocol",
"EMMM": "emmm",
"EMN.CUR": "Eastman Chemical", "EMN.CUR": "Eastman Chemical",
"EMOJI": "MOMOJI", "EMOJI": "MOMOJI",
"EMON": "Ethermon", "EMON": "Ethermon",
@ -5808,6 +5837,7 @@
"FARTDEV": "Fart Dev", "FARTDEV": "Fart Dev",
"FARTIMUS": "Fartimus Prime", "FARTIMUS": "Fartimus Prime",
"FARTING": "Farting Unicorn", "FARTING": "Farting Unicorn",
"FARTLESS": "FARTLESS COIN",
"FAS": "fast construction coin", "FAS": "fast construction coin",
"FAST": "Fastswap", "FAST": "Fastswap",
"FASTAI": "Fast And Ai", "FASTAI": "Fast And Ai",
@ -5875,6 +5905,7 @@
"FEGV1": "FEG Token v1", "FEGV1": "FEG Token v1",
"FEGV2": "FEG Token", "FEGV2": "FEG Token",
"FEI": "Fei Protocol", "FEI": "Fei Protocol",
"FELIS": "Felis",
"FELIX": "FelixCoin", "FELIX": "FelixCoin",
"FELIX2": "Felix 2.0 ETH", "FELIX2": "Felix 2.0 ETH",
"FEN": "First Ever NFT", "FEN": "First Ever NFT",
@ -5964,6 +5995,7 @@
"FIO": "FIO Protocol", "FIO": "FIO Protocol",
"FIONA": "Fiona", "FIONA": "Fiona",
"FIONABSC": "Fiona", "FIONABSC": "Fiona",
"FIR": "Fireverse",
"FIRA": "Defira", "FIRA": "Defira",
"FIRE": "Matr1x Fire", "FIRE": "Matr1x Fire",
"FIRECOIN": "FireCoin", "FIRECOIN": "FireCoin",
@ -6072,6 +6104,7 @@
"FLOVM": "FLOV MARKET", "FLOVM": "FLOV MARKET",
"FLOW": "Flow", "FLOW": "Flow",
"FLOWER": "FlowerAI", "FLOWER": "FlowerAI",
"FLOWM": "Flowmatic",
"FLOWP": "Flow Protocol", "FLOWP": "Flow Protocol",
"FLOYX": "Floyx", "FLOYX": "Floyx",
"FLP": "Gameflip", "FLP": "Gameflip",
@ -6099,7 +6132,7 @@
"FLY": "Franklin", "FLY": "Franklin",
"FLYCOIN": "FlyCoin", "FLYCOIN": "FlyCoin",
"FLZ": "Fellaz", "FLZ": "Fellaz",
"FM": "Flowmatic", "FM": "Full Moon",
"FMA": "FLAMA", "FMA": "FLAMA",
"FMB": "FREEMOON BINANCE", "FMB": "FREEMOON BINANCE",
"FMC": "Fimarkcoin", "FMC": "Fimarkcoin",
@ -6272,6 +6305,7 @@
"FROGE": "Froge Finance", "FROGE": "Froge Finance",
"FROGEX": "FrogeX", "FROGEX": "FrogeX",
"FROGGER": "FROGGER", "FROGGER": "FROGGER",
"FROGGIE": "Froggie",
"FROGGY": "Froggy", "FROGGY": "Froggy",
"FROGLIC": "Pink Hood Froglicker", "FROGLIC": "Pink Hood Froglicker",
"FROGO": "Frogo", "FROGO": "Frogo",
@ -6762,6 +6796,7 @@
"GLFT": "Global Fan Token", "GLFT": "Global Fan Token",
"GLI": "GLI TOKEN", "GLI": "GLI TOKEN",
"GLIDE": "Glide Finance", "GLIDE": "Glide Finance",
"GLIDR": "Glidr",
"GLIESE": "GlieseCoin", "GLIESE": "GlieseCoin",
"GLINK": "Gemlink", "GLINK": "Gemlink",
"GLINT": "BeamSwap", "GLINT": "BeamSwap",
@ -6893,6 +6928,7 @@
"GOLDN": "GoLondon", "GOLDN": "GoLondon",
"GOLDPIECES": "GoldPieces", "GOLDPIECES": "GoldPieces",
"GOLDS": "Gold Standard", "GOLDS": "Gold Standard",
"GOLDSECURED": "Gold Secured Currency",
"GOLDX": "eToro Gold", "GOLDX": "eToro Gold",
"GOLDY": "DeFi Land Gold", "GOLDY": "DeFi Land Gold",
"GOLF": "GolfCoin", "GOLF": "GolfCoin",
@ -7046,6 +7082,7 @@
"GROKGIRL": "Grok Girl", "GROKGIRL": "Grok Girl",
"GROKGROW": "GrokGrow", "GROKGROW": "GrokGrow",
"GROKHEROES": "GROK heroes", "GROKHEROES": "GROK heroes",
"GROKIM": "Grok Imagine Penguin",
"GROKINU": "Grok Inu", "GROKINU": "Grok Inu",
"GROKKING": "GrokKing", "GROKKING": "GrokKing",
"GROKKY": "GroKKy", "GROKKY": "GroKKy",
@ -7096,7 +7133,7 @@
"GSTT": "GSTT", "GSTT": "GSTT",
"GSWAP": "Gameswap", "GSWAP": "Gameswap",
"GSWIFT": "GameSwift", "GSWIFT": "GameSwift",
"GSX": "Gold Secured Currency", "GSX": "Goldman Sachs xStock",
"GSY": "GenesysCoin", "GSY": "GenesysCoin",
"GSYS": "Genesys", "GSYS": "Genesys",
"GT": "Gatechain Token", "GT": "Gatechain Token",
@ -7342,6 +7379,7 @@
"HELI": "Helion", "HELI": "Helion",
"HELINK": "Chainlink (Huobi Exchange)", "HELINK": "Chainlink (Huobi Exchange)",
"HELIOS": "Mission Helios", "HELIOS": "Mission Helios",
"HELIOSAI": "HeliosAI",
"HELL": "HELL COIN", "HELL": "HELL COIN",
"HELLO": "HELLO", "HELLO": "HELLO",
"HELMET": "Helmet Insure", "HELMET": "Helmet Insure",
@ -7672,6 +7710,7 @@
"HUSKY": "Husky", "HUSKY": "Husky",
"HUSL": "Hustle Token", "HUSL": "Hustle Token",
"HUSTLE": "Agent Hustle", "HUSTLE": "Agent Hustle",
"HUSTLEV1": "Tensorium",
"HUT": "Hibiki Run", "HUT": "Hibiki Run",
"HVC": "HeavyCoin", "HVC": "HeavyCoin",
"HVCO": "High Voltage Coin", "HVCO": "High Voltage Coin",
@ -7713,7 +7752,7 @@
"HYPERC": "HyperChainX", "HYPERC": "HyperChainX",
"HYPERCOIN": "HyperCoin", "HYPERCOIN": "HyperCoin",
"HYPERD": "HyperDAO", "HYPERD": "HyperDAO",
"HYPERF": "HyperFly", "HYPERFLY": "HyperFly",
"HYPERIONX": "HyperionX", "HYPERIONX": "HyperionX",
"HYPERS": "HyperSpace", "HYPERS": "HyperSpace",
"HYPERSKIDS": "HYPERSKIDS", "HYPERSKIDS": "HYPERSKIDS",
@ -7831,6 +7870,7 @@
"IFIT": "CALO INDOOR", "IFIT": "CALO INDOOR",
"IFLT": "InflationCoin", "IFLT": "InflationCoin",
"IFOR": "iFortune", "IFOR": "iFortune",
"IFR": "Inferium",
"IFT": "InvestFeed", "IFT": "InvestFeed",
"IFUM": "Infleum", "IFUM": "Infleum",
"IFUND": "Unifund", "IFUND": "Unifund",
@ -7851,6 +7891,7 @@
"IJC": "IjasCoin", "IJC": "IjasCoin",
"IJZ": "iinjaz", "IJZ": "iinjaz",
"IJZV1": "iinjaz v1", "IJZV1": "iinjaz v1",
"IKA": "IKA Token",
"IKI": "ikipay", "IKI": "ikipay",
"IKIGAI": "Ikigai", "IKIGAI": "Ikigai",
"ILA": "Infinite Launch", "ILA": "Infinite Launch",
@ -7861,6 +7902,7 @@
"ILT": "iOlite", "ILT": "iOlite",
"ILV": "Illuvium", "ILV": "Illuvium",
"IMAGE": "Imagen AI", "IMAGE": "Imagen AI",
"IMAGINE": "IMAGINE",
"IMARO": "IMARO", "IMARO": "IMARO",
"IMAYC": "IMAYC", "IMAYC": "IMAYC",
"IMBREX": "Imbrex", "IMBREX": "Imbrex",
@ -7895,7 +7937,7 @@
"IMU": "imusify", "IMU": "imusify",
"IMVR": "ImmVRse", "IMVR": "ImmVRse",
"IMX": "Immutable X", "IMX": "Immutable X",
"IN": "InCoin", "IN": "INFINIT",
"INA": "pepeinatux", "INA": "pepeinatux",
"INARI": "Inari", "INARI": "Inari",
"INB": "Insight Chain", "INB": "Insight Chain",
@ -8012,6 +8054,7 @@
"IOSHIB": "IoTexShiba", "IOSHIB": "IoTexShiba",
"IOST": "IOS token", "IOST": "IOS token",
"IOT": "Helium IOT", "IOT": "Helium IOT",
"IOTAI": "IoTAI",
"IOTW": "IOTW", "IOTW": "IOTW",
"IOTX": "IoTeX Network", "IOTX": "IoTeX Network",
"IOU": "IOU1", "IOU": "IOU1",
@ -8120,6 +8163,7 @@
"IX": "X-Block", "IX": "X-Block",
"IXC": "IXcoin", "IXC": "IXcoin",
"IXIR": "IXIR", "IXIR": "IXIR",
"IXORA": "IXORAPAD",
"IXP": "IMPACTXPRIME", "IXP": "IMPACTXPRIME",
"IXS": "IX Swap", "IXS": "IX Swap",
"IXT": "iXledger", "IXT": "iXledger",
@ -8163,6 +8207,7 @@
"JASMY": "JasmyCoin", "JASMY": "JasmyCoin",
"JASON": "Jason Derulo", "JASON": "Jason Derulo",
"JAV": "Javsphere", "JAV": "Javsphere",
"JAWN": "Long Jawn Silvers",
"JAWS": "AutoShark", "JAWS": "AutoShark",
"JAY": "Jaypeggers", "JAY": "Jaypeggers",
"JBO": "JBOX", "JBO": "JBOX",
@ -8186,7 +8231,8 @@
"JEETOLAX": "Jeetolax", "JEETOLAX": "Jeetolax",
"JEETS": "I'm a Jeet", "JEETS": "I'm a Jeet",
"JEFE": "JEFE TOKEN", "JEFE": "JEFE TOKEN",
"JEFF": "Jeff in Space", "JEFF": "JEFF",
"JEFFINSPACE": "Jeff in Space",
"JEFFRY": "jeffry", "JEFFRY": "jeffry",
"JEJUDOGE": "Jejudoge", "JEJUDOGE": "Jejudoge",
"JELLI": "JELLI", "JELLI": "JELLI",
@ -8265,6 +8311,7 @@
"JOEY": "Joey Inu", "JOEY": "Joey Inu",
"JOGECO": "Jogecodog", "JOGECO": "Jogecodog",
"JOHM": "Johm lemmon", "JOHM": "Johm lemmon",
"JOHN": "John Tsubasa Rivals",
"JOHNNY": "Johnny The Bull", "JOHNNY": "Johnny The Bull",
"JOINCOIN": "JoinCoin", "JOINCOIN": "JoinCoin",
"JOINT": "Joint Ventures", "JOINT": "Joint Ventures",
@ -8428,6 +8475,7 @@
"KAT": "Karat", "KAT": "Karat",
"KATA": "Katana Inu", "KATA": "Katana Inu",
"KATANA": "Katana Finance", "KATANA": "Katana Finance",
"KATANANET": "Katana Network",
"KATCHU": "Katchu Coin", "KATCHU": "Katchu Coin",
"KATT": "Katt Daddy", "KATT": "Katt Daddy",
"KATYCAT": "Katy Perry Fans", "KATYCAT": "Katy Perry Fans",
@ -8527,6 +8575,7 @@
"KHAI": "khai", "KHAI": "khai",
"KHEOWZOO": "khaokheowzoo", "KHEOWZOO": "khaokheowzoo",
"KHM": "Kohima", "KHM": "Kohima",
"KHYPE": "Kinetiq Staked HYPE",
"KI": "Genopets KI", "KI": "Genopets KI",
"KIAN": "Porta", "KIAN": "Porta",
"KIBA": "Kiba Inu", "KIBA": "Kiba Inu",
@ -8662,6 +8711,7 @@
"KNUT": "Knut From Zoo", "KNUT": "Knut From Zoo",
"KNW": "Knowledge", "KNW": "Knowledge",
"KOAI": "KOI", "KOAI": "KOI",
"KOALA": "KOALA",
"KOBAN": "KOBAN", "KOBAN": "KOBAN",
"KOBE": "Shabu Shabu", "KOBE": "Shabu Shabu",
"KOBO": "KoboCoin", "KOBO": "KoboCoin",
@ -8776,7 +8826,7 @@
"KTT": "K-Tune", "KTT": "K-Tune",
"KTX": "KwikTrust", "KTX": "KwikTrust",
"KUAI": "Kuai Token", "KUAI": "Kuai Token",
"KUB": "Bitkub Coin", "KUB": "KUB Coin",
"KUBE": "KubeCoin", "KUBE": "KubeCoin",
"KUBO": "KUBO", "KUBO": "KUBO",
"KUBOS": "KubosCoin", "KUBOS": "KubosCoin",
@ -8891,6 +8941,7 @@
"LAS": "LNAsolution Coin", "LAS": "LNAsolution Coin",
"LASOL": "LamaSol", "LASOL": "LamaSol",
"LAT": "PlatON Network", "LAT": "PlatON Network",
"LATINA": "Latina",
"LATOKEN": "LATOKEN", "LATOKEN": "LATOKEN",
"LATOM": "Liquid ATOM", "LATOM": "Liquid ATOM",
"LATTE": "LatteSwap", "LATTE": "LatteSwap",
@ -8910,6 +8961,7 @@
"LAY3R": "AutoLayer", "LAY3R": "AutoLayer",
"LAYER": "Solayer", "LAYER": "Solayer",
"LAZ": "Lazarus", "LAZ": "Lazarus",
"LAZHUZHU": "LAZHUZHU",
"LAZIO": "Lazio Fan Token", "LAZIO": "Lazio Fan Token",
"LAZYCAT": "LAZYCAT", "LAZYCAT": "LAZYCAT",
"LB": "LoveBit", "LB": "LoveBit",
@ -9110,12 +9162,14 @@
"LIO": "Lio", "LIO": "Lio",
"LION": "Loaded Lions", "LION": "Loaded Lions",
"LIONT": "Lion Token", "LIONT": "Lion Token",
"LIORA": "Liora",
"LIPC": "LIpcoin", "LIPC": "LIpcoin",
"LIPS": "LipChain", "LIPS": "LipChain",
"LIQ": "LIQ Protocol", "LIQ": "LIQ Protocol",
"LIQD": "Liquid Finance", "LIQD": "LiquidLaunch",
"LIQR": "Topshelf Finance", "LIQR": "Topshelf Finance",
"LIQUI": "Liquidus", "LIQUI": "Liquidus",
"LIQUID": "Liquid Finance",
"LIQUIDIUM": "LIQUIDIUM•TOKEN", "LIQUIDIUM": "LIQUIDIUM•TOKEN",
"LIR": "Let it Ride", "LIR": "Let it Ride",
"LIS": "Realis Network", "LIS": "Realis Network",
@ -9134,6 +9188,7 @@
"LITHO": "Lithosphere", "LITHO": "Lithosphere",
"LITION": "Lition", "LITION": "Lition",
"LITT": "LitLab Games", "LITT": "LitLab Games",
"LITTLEGUY": "just a little guy",
"LIV": "LiviaCoin", "LIV": "LiviaCoin",
"LIVE": "TRONbetLive", "LIVE": "TRONbetLive",
"LIVENCOIN": "LivenPay", "LIVENCOIN": "LivenPay",
@ -9235,6 +9290,7 @@
"LONGFU": "LONGFU", "LONGFU": "LONGFU",
"LONGM": "Long Mao", "LONGM": "Long Mao",
"LONGSHINE": "LongShine", "LONGSHINE": "LongShine",
"LOOBY": "Looby by Stephen Bliss",
"LOOK": "LookCoin", "LOOK": "LookCoin",
"LOOKS": "LooksRare", "LOOKS": "LooksRare",
"LOOM": "Loom Network", "LOOM": "Loom Network",
@ -9308,6 +9364,7 @@
"LSHARE": "LSHARE", "LSHARE": "LSHARE",
"LSILVER": "Lyfe Silver", "LSILVER": "Lyfe Silver",
"LSK": "Lisk", "LSK": "Lisk",
"LSKV1": "Lisk v1",
"LSP": "Lumenswap", "LSP": "Lumenswap",
"LSPHERE": "Lunasphere", "LSPHERE": "Lunasphere",
"LSR": "LaserEyes", "LSR": "LaserEyes",
@ -9456,7 +9513,7 @@
"LZ": "LaunchZone", "LZ": "LaunchZone",
"LZM": "LoungeM", "LZM": "LoungeM",
"LZUSDC": "LayerZero Bridged USDC (Fantom)", "LZUSDC": "LayerZero Bridged USDC (Fantom)",
"M": "MetaVerse-M", "M": "MemeCore",
"M1": "SupplyShock", "M1": "SupplyShock",
"M2O": "M2O Token", "M2O": "M2O Token",
"M3M3": "M3M3", "M3M3": "M3M3",
@ -9466,6 +9523,7 @@
"MABA": "Make America Based Again", "MABA": "Make America Based Again",
"MAC": "MachineCoin", "MAC": "MachineCoin",
"MACHO": "macho", "MACHO": "macho",
"MACRO": "Macro Millions",
"MADA": "MilkADA", "MADA": "MilkADA",
"MADAGASCARTOKEN": "Madagascar Token", "MADAGASCARTOKEN": "Madagascar Token",
"MADANA": "MADANA", "MADANA": "MADANA",
@ -9492,6 +9550,7 @@
"MAGAF": "MAGA FRENS", "MAGAF": "MAGA FRENS",
"MAGAHAT": "MAGA Hat", "MAGAHAT": "MAGA Hat",
"MAGAIBA": "Magaiba", "MAGAIBA": "Magaiba",
"MAGAL": "Magallaneer",
"MAGAN": "Maganomics On Solana", "MAGAN": "Maganomics On Solana",
"MAGANOMICS": "Maganomics", "MAGANOMICS": "Maganomics",
"MAGAP": "MAGA PEPE", "MAGAP": "MAGA PEPE",
@ -9579,6 +9638,7 @@
"MARI": "MarijuanaCoin", "MARI": "MarijuanaCoin",
"MARIC": "Maricoin", "MARIC": "Maricoin",
"MARIE": "Marie Rose", "MARIE": "Marie Rose",
"MARIEROSE": "Marie",
"MARIO": "MARIO CEO", "MARIO": "MARIO CEO",
"MARK": "Benchmark Protocol", "MARK": "Benchmark Protocol",
"MARKE": "Market Ledger", "MARKE": "Market Ledger",
@ -9592,6 +9652,7 @@
"MARSC": "MarsCoin", "MARSC": "MarsCoin",
"MARSCOIN": "MarsCoin", "MARSCOIN": "MarsCoin",
"MARSH": "Unmarshal", "MARSH": "Unmarshal",
"MARSMI": "MarsMi",
"MARSO": "Marso.Tech", "MARSO": "Marso.Tech",
"MARSRISE": "MarsRise", "MARSRISE": "MarsRise",
"MARSUPILAMI": "MARSUPILAMI INU", "MARSUPILAMI": "MARSUPILAMI INU",
@ -9678,6 +9739,7 @@
"MBET": "MoonBet", "MBET": "MoonBet",
"MBF": "MoonBear.Finance", "MBF": "MoonBear.Finance",
"MBG": "MBG Token", "MBG": "MBG Token",
"MBGA": "MBGA",
"MBI": "Monster Byte Inc", "MBI": "Monster Byte Inc",
"MBID": "myBID", "MBID": "myBID",
"MBILLY": "MAMA BILLY", "MBILLY": "MAMA BILLY",
@ -9770,6 +9832,7 @@
"MDX": "Mdex (BSC)", "MDX": "Mdex (BSC)",
"MDXH": "Mdex (HECO)", "MDXH": "Mdex (HECO)",
"ME": "Magic Eden", "ME": "Magic Eden",
"MEA": "MECCA",
"MEAN": "Meanfi", "MEAN": "Meanfi",
"MEB": "Meblox Protocol", "MEB": "Meblox Protocol",
"MEC": "MegaCoin", "MEC": "MegaCoin",
@ -9914,6 +9977,7 @@
"METAUFO": "MetaUFO", "METAUFO": "MetaUFO",
"METAV": "METAVERSE", "METAV": "METAVERSE",
"METAVE": "Metaverse Convergence", "METAVE": "Metaverse Convergence",
"METAVERSEM": "MetaVerse-M",
"METAVERSEX": "MetaverseX", "METAVERSEX": "MetaverseX",
"METAVIE": "Metavie", "METAVIE": "Metavie",
"METAVPAD": "MetaVPad", "METAVPAD": "MetaVPad",
@ -10018,6 +10082,7 @@
"MIKS": "MIKS COIN", "MIKS": "MIKS COIN",
"MIL": "Milllionaire Coin", "MIL": "Milllionaire Coin",
"MILA": "MILADY MEME TOKEN", "MILA": "MILADY MEME TOKEN",
"MILC": "Micro Licensing Coin",
"MILE": "milestoneBased", "MILE": "milestoneBased",
"MILEI": "MILEI", "MILEI": "MILEI",
"MILK": "MilkyWay", "MILK": "MilkyWay",
@ -10026,6 +10091,7 @@
"MILKSHAKE": "Milkshake Swap", "MILKSHAKE": "Milkshake Swap",
"MILKYWAY": "MilkyWayZone", "MILKYWAY": "MilkyWayZone",
"MILLI": "Million", "MILLI": "Million",
"MILLIMV1": "Millimeter v1",
"MILLY": "milly", "MILLY": "milly",
"MILO": "Milo Inu", "MILO": "Milo Inu",
"MILOCEO": "Milo CEO", "MILOCEO": "Milo CEO",
@ -10045,6 +10111,7 @@
"MINDC": "MindCoin", "MINDC": "MindCoin",
"MINDCOIN": "MindCoin", "MINDCOIN": "MindCoin",
"MINDEX": "Mindexcoin", "MINDEX": "Mindexcoin",
"MINDFAK": "Mindfak By Matt Furie",
"MINDGENE": "Mind Gene", "MINDGENE": "Mind Gene",
"MINDS": "Minds", "MINDS": "Minds",
"MINDSYNC": "Mindsync", "MINDSYNC": "Mindsync",
@ -10166,6 +10233,7 @@
"MMIT": "MangoMan Intelligent", "MMIT": "MangoMan Intelligent",
"MMNXT": "MMNXT", "MMNXT": "MMNXT",
"MMO": "MMOCoin", "MMO": "MMOCoin",
"MMON": "Multiverse Monkey",
"MMPRO": "Market Making Pro", "MMPRO": "Market Making Pro",
"MMS": "Marsverse", "MMS": "Marsverse",
"MMSC": "MMSC PLATFORM", "MMSC": "MMSC PLATFORM",
@ -10370,6 +10438,7 @@
"MOOO": "Hashtagger", "MOOO": "Hashtagger",
"MOOV": "dotmoovs", "MOOV": "dotmoovs",
"MOOX": "Moox Protocol", "MOOX": "Moox Protocol",
"MOOXV1": "Moox Protocol v1",
"MOPS": "Mops", "MOPS": "Mops",
"MOR": "Morpheus", "MOR": "Morpheus",
"MORA": "Meliora", "MORA": "Meliora",
@ -10394,6 +10463,7 @@
"MOTH": "MOTH", "MOTH": "MOTH",
"MOTHER": "Mother Iggy", "MOTHER": "Mother Iggy",
"MOTI": "Motion", "MOTI": "Motion",
"MOTION": "motion",
"MOTO": "Motocoin", "MOTO": "Motocoin",
"MOUND": "Mound Token", "MOUND": "Mound Token",
"MOUTAI": "Moutai", "MOUTAI": "Moutai",
@ -10481,6 +10551,7 @@
"MSHEESHA": "Sheesha Finance Polygon", "MSHEESHA": "Sheesha Finance Polygon",
"MSHIB": "Magic Shiba Starter", "MSHIB": "Magic Shiba Starter",
"MSHIP": "MetaShipping", "MSHIP": "MetaShipping",
"MSIA": "Messiah",
"MSN": "Meson.Network", "MSN": "Meson.Network",
"MSOL": "Marinade Staked SOL", "MSOL": "Marinade Staked SOL",
"MSOT": "BTour Chain", "MSOT": "BTour Chain",
@ -10701,6 +10772,7 @@
"NANJ": "NANJCOIN", "NANJ": "NANJCOIN",
"NANO": "Nano", "NANO": "Nano",
"NAO": "Nettensor", "NAO": "Nettensor",
"NAORIS": "Naoris Protocol",
"NAOS": "NAOS Finance", "NAOS": "NAOS Finance",
"NAP": "Napoli Fan Token", "NAP": "Napoli Fan Token",
"NARCO": "Mr. Narco", "NARCO": "Mr. Narco",
@ -10830,7 +10902,8 @@
"NERD": "Nerd Bot", "NERD": "Nerd Bot",
"NERDS": "NERDS", "NERDS": "NERDS",
"NERF": "Neural Radiance Field", "NERF": "Neural Radiance Field",
"NERO": "Nero Token", "NERO": "NERO Chain",
"NEROTOKEN": "Nero Token",
"NERVE": "NERVE", "NERVE": "NERVE",
"NES": "Nest AI", "NES": "Nest AI",
"NESS": "Ness LAB", "NESS": "Ness LAB",
@ -10893,6 +10966,7 @@
"NEXMI": "NexMillionaires", "NEXMI": "NexMillionaires",
"NEXMS": "NexMillionaires", "NEXMS": "NexMillionaires",
"NEXO": "NEXO", "NEXO": "NEXO",
"NEXOR": "Nexora",
"NEXT": "Connext Network", "NEXT": "Connext Network",
"NEXTEX": "Next.exchange Token", "NEXTEX": "Next.exchange Token",
"NEXTEXV1": "Next.exchange Token v1", "NEXTEXV1": "Next.exchange Token v1",
@ -11034,6 +11108,7 @@
"NNT": "Nunu Spirits", "NNT": "Nunu Spirits",
"NOA": "NOA PLAY", "NOA": "NOA PLAY",
"NOAH": "NOAHCOIN", "NOAH": "NOAHCOIN",
"NOBIKO": "Longcat",
"NOBL": "NobleCoin", "NOBL": "NobleCoin",
"NOBODY": "Nobody Sausage", "NOBODY": "Nobody Sausage",
"NOBS": "No BS Crypto", "NOBS": "No BS Crypto",
@ -11421,6 +11496,7 @@
"OMNILAYER": "Omni", "OMNILAYER": "Omni",
"OMNIR": "Omni Real Estate Token", "OMNIR": "Omni Real Estate Token",
"OMNIX": "OmniBotX", "OMNIX": "OmniBotX",
"OMNIXIO": "OMNIX",
"OMNOM": "Doge Eat Doge", "OMNOM": "Doge Eat Doge",
"OMNOMN": "Omega Network", "OMNOMN": "Omega Network",
"OMT": "Mars Token", "OMT": "Mars Token",
@ -11439,6 +11515,7 @@
"ONET": "ONE Token", "ONET": "ONE Token",
"ONEX": "ONE TECH", "ONEX": "ONE TECH",
"ONF": "ONF Token", "ONF": "ONF Token",
"ONFA": "ONFA Hope",
"ONGAS": "Ontology Gas", "ONGAS": "Ontology Gas",
"ONI": "ONINO", "ONI": "ONINO",
"ONIG": "Onigiri", "ONIG": "Onigiri",
@ -11485,6 +11562,7 @@
"OPENP": "Open Platform", "OPENP": "Open Platform",
"OPENRI": "Open Rights Exchange", "OPENRI": "Open Rights Exchange",
"OPENSOURCE": "Open Source Network", "OPENSOURCE": "Open Source Network",
"OPENVC": "OpenVoiceCoin",
"OPENW": "OpenWorld", "OPENW": "OpenWorld",
"OPENX": "OpenSwap Optimism Token", "OPENX": "OpenSwap Optimism Token",
"OPEPE": "Optimism PEPE", "OPEPE": "Optimism PEPE",
@ -11640,6 +11718,7 @@
"OWNDATA": "OWNDATA", "OWNDATA": "OWNDATA",
"OWNLY": "Ownly", "OWNLY": "Ownly",
"OWO": "SoMon", "OWO": "SoMon",
"OWOCOIN": "Owo",
"OX": "Open Exchange Token", "OX": "Open Exchange Token",
"OXAI": "OxAI.com", "OXAI": "OxAI.com",
"OXB": "Oxbull Tech", "OXB": "Oxbull Tech",
@ -11737,6 +11816,7 @@
"PAR": "Parachute", "PAR": "Parachute",
"PARA": "Paralink Network", "PARA": "Paralink Network",
"PARAB": "Parabolic", "PARAB": "Parabolic",
"PARABO": "PARABOLIC AI",
"PARAD": "Paradox", "PARAD": "Paradox",
"PARADOX": "The Paradox Metaverse", "PARADOX": "The Paradox Metaverse",
"PARAG": "Paragon Network", "PARAG": "Paragon Network",
@ -11864,6 +11944,7 @@
"PEAS": "Peapods Finance", "PEAS": "Peapods Finance",
"PEBIRD": "PEPE BIRD", "PEBIRD": "PEPE BIRD",
"PEC": "PeaceCoin", "PEC": "PeaceCoin",
"PECH": "PEPE CASH",
"PECL": "PECland", "PECL": "PECland",
"PED": "PEDRO", "PED": "PEDRO",
"PEDRO": "Pedro The Raccoon", "PEDRO": "Pedro The Raccoon",
@ -12170,6 +12251,7 @@
"PIZZACOIN": "PizzaCoin", "PIZZACOIN": "PizzaCoin",
"PIZZASWAP": "PizzaSwap", "PIZZASWAP": "PizzaSwap",
"PJM": "Pajama.Finance", "PJM": "Pajama.Finance",
"PJN": "PJN",
"PKB": "ParkByte", "PKB": "ParkByte",
"PKC": "Pikciochain", "PKC": "Pikciochain",
"PKD": "PetKingdom", "PKD": "PetKingdom",
@ -12255,6 +12337,7 @@
"PLY": "Aurigami", "PLY": "Aurigami",
"PLYR": "PLYR L1", "PLYR": "PLYR L1",
"PLZ": "PLUNZ", "PLZ": "PLUNZ",
"PM": "PumpMeme",
"PMA": "PumaPay", "PMA": "PumaPay",
"PMD": "Pandemic Multiverse", "PMD": "Pandemic Multiverse",
"PME": "DogePome", "PME": "DogePome",
@ -12310,6 +12393,7 @@
"POGAI": "POGAI", "POGAI": "POGAI",
"POGS": "POG", "POGS": "POG",
"POINT": "SportPoint", "POINT": "SportPoint",
"POINTS": "POINTS",
"POK": "Pokmonsters", "POK": "Pokmonsters",
"POKEGROK": "PokeGROK", "POKEGROK": "PokeGROK",
"POKEM": "Pokemonio", "POKEM": "Pokemonio",
@ -12521,6 +12605,7 @@
"PROTOCOLZ": "Protocol Zero", "PROTOCOLZ": "Protocol Zero",
"PROTON": "Proton", "PROTON": "Proton",
"PROUD": "PROUD Money", "PROUD": "PROUD Money",
"PROVE": "Succinct",
"PROXI": "PROXI", "PROXI": "PROXI",
"PRP": "Pepe Prime", "PRP": "Pepe Prime",
"PRPS": "Purpose", "PRPS": "Purpose",
@ -12618,6 +12703,7 @@
"PUMPY": "WOW MOON LAMBO PUMPPPPPPY", "PUMPY": "WOW MOON LAMBO PUMPPPPPPY",
"PUN": "Punkko", "PUN": "Punkko",
"PUNCH": "PUNCHWORD", "PUNCH": "PUNCHWORD",
"PUNCHI": "Punchimals",
"PUNDIAI": "Pundi AI", "PUNDIAI": "Pundi AI",
"PUNDIX": "Pundi X", "PUNDIX": "Pundi X",
"PUNDU": "Pundu", "PUNDU": "Pundu",
@ -12711,6 +12797,7 @@
"QAC": "Quasarcoin", "QAC": "Quasarcoin",
"QAI": "QuantixAI", "QAI": "QuantixAI",
"QANX": "QANplatform", "QANX": "QANplatform",
"QANXV2": "QANplatform v2",
"QARK": "QANplatform", "QARK": "QANplatform",
"QASH": "Quoine Liquid", "QASH": "Quoine Liquid",
"QAU": "Quantum", "QAU": "Quantum",
@ -13137,6 +13224,7 @@
"RHINOMARS": "RhinoMars", "RHINOMARS": "RhinoMars",
"RHOC": "RChain", "RHOC": "RChain",
"RHP": "Rhypton Club", "RHP": "Rhypton Club",
"RHUB": "ROLLHUB",
"RIA": "aRIA Currency", "RIA": "aRIA Currency",
"RIB": "Ribus", "RIB": "Ribus",
"RIBB": "Ribbit", "RIBB": "Ribbit",
@ -13177,6 +13265,7 @@
"RINTARO": "Rintaro", "RINTARO": "Rintaro",
"RINU": "Raichu Inu", "RINU": "Raichu Inu",
"RIO": "Realio Network", "RIO": "Realio Network",
"RION": "Hyperion",
"RIOT": "Riot Racers", "RIOT": "Riot Racers",
"RIOV1": "Realio Network v1", "RIOV1": "Realio Network v1",
"RIP": "Fantom Doge", "RIP": "Fantom Doge",
@ -13383,13 +13472,16 @@
"RUBIT": "Rublebit", "RUBIT": "Rublebit",
"RUBIUS": "Rubius", "RUBIUS": "Rubius",
"RUBIX": "Rubix", "RUBIX": "Rubix",
"RUBMEME": "Reverse Unit Bias",
"RUBX": "eToro Russian Ruble", "RUBX": "eToro Russian Ruble",
"RUBY": "RubyToken", "RUBY": "RubyToken",
"RUBYEX": "Ruby.Exchange", "RUBYEX": "Ruby.Exchange",
"RUC": "Rush", "RUC": "Rush",
"RUFF": "Ruff", "RUFF": "Ruff",
"RUG": "Rug", "RUG": "RUGMAN",
"RUGA": "RUGAME", "RUGA": "RUGAME",
"RUGMONEY": "Rug",
"RUGPROOF": "Launchpad",
"RUGPULL": "Captain Rug Pull", "RUGPULL": "Captain Rug Pull",
"RUGZ": "pulltherug.finance", "RUGZ": "pulltherug.finance",
"RULER": "Ruler Protocol", "RULER": "Ruler Protocol",
@ -13491,6 +13583,7 @@
"SAFEREUM": "Safereum", "SAFEREUM": "Safereum",
"SAFES": "SafeSwap", "SAFES": "SafeSwap",
"SAFESTAR": "Safe Star", "SAFESTAR": "Safe Star",
"SAFESV1": "SafeSwap v1",
"SAFET": "SafemoonTon", "SAFET": "SafemoonTon",
"SAFEX": "SafeExchangeCoin", "SAFEX": "SafeExchangeCoin",
"SAFLE": "Safle", "SAFLE": "Safle",
@ -13550,6 +13643,7 @@
"SAPP": "Sapphire", "SAPP": "Sapphire",
"SAPPC": "SappChat", "SAPPC": "SappChat",
"SAR": "Saren", "SAR": "Saren",
"SARA": "Pulsara",
"SARCO": "Sarcophagus", "SARCO": "Sarcophagus",
"SARM": "Stella Armada", "SARM": "Stella Armada",
"SAROS": "Saros", "SAROS": "Saros",
@ -13676,6 +13770,7 @@
"SCSX": "Secure Cash", "SCSX": "Secure Cash",
"SCT": "SuperCells", "SCT": "SuperCells",
"SCTK": "SharesChain", "SCTK": "SharesChain",
"SCTRL": "SOLCONTROL",
"SCUBA": "Scuba Dog", "SCUBA": "Scuba Dog",
"SCY": "Synchrony", "SCY": "Synchrony",
"SD": "Stader", "SD": "Stader",
@ -14026,6 +14121,7 @@
"SILVERWAY": "Silverway", "SILVERWAY": "Silverway",
"SIM": "Simpson", "SIM": "Simpson",
"SIMBA": "SIMBA The Sloth", "SIMBA": "SIMBA The Sloth",
"SIMON": "Simon the Gator",
"SIMP": "SO-COL", "SIMP": "SO-COL",
"SIMPLE": "SimpleChain", "SIMPLE": "SimpleChain",
"SIMPS": "Simpson MAGA", "SIMPS": "Simpson MAGA",
@ -14120,6 +14216,7 @@
"SLA": "SUPERLAUNCH", "SLA": "SUPERLAUNCH",
"SLAM": "Slam Token", "SLAM": "Slam Token",
"SLAP": "CatSlap", "SLAP": "CatSlap",
"SLAPS": "Slap",
"SLAVI": "Slavi Coin", "SLAVI": "Slavi Coin",
"SLAYER": "ThreatSlayerAI by Virtuals", "SLAYER": "ThreatSlayerAI by Virtuals",
"SLB": "Solberg", "SLB": "Solberg",
@ -14336,6 +14433,7 @@
"SOLAMA": "Solama", "SOLAMA": "Solama",
"SOLAMB": "SOLAMB", "SOLAMB": "SOLAMB",
"SOLAN": "Solana Beach", "SOLAN": "Solana Beach",
"SOLANACORN": "Solanacorn",
"SOLANAP": "Solana Poker", "SOLANAP": "Solana Poker",
"SOLANAS": "Solana Swap", "SOLANAS": "Solana Swap",
"SOLANATREASURY": "Solana Treasury Machine", "SOLANATREASURY": "Solana Treasury Machine",
@ -14571,6 +14669,7 @@
"SPURS": "Tottenham Hotspur Fan Token", "SPURS": "Tottenham Hotspur Fan Token",
"SPWN": "Bitspawn", "SPWN": "Bitspawn",
"SPX": "SPX6900", "SPX": "SPX6900",
"SPX6969": "SPX 6969",
"SPXC": "SpaceXCoin", "SPXC": "SpaceXCoin",
"SPY": "Smarty Pay", "SPY": "Smarty Pay",
"SPYRO": "SPYRO", "SPYRO": "SPYRO",
@ -14880,6 +14979,7 @@
"SUMI": "SUMI", "SUMI": "SUMI",
"SUMMER": "Summer", "SUMMER": "Summer",
"SUMMIT": "Summit", "SUMMIT": "Summit",
"SUMMITTHE": "SUMMIT",
"SUMO": "Sumokoin", "SUMO": "Sumokoin",
"SUN": "Sun Token", "SUN": "Sun Token",
"SUNC": "Sunrise", "SUNC": "Sunrise",
@ -15087,6 +15187,7 @@
"TALE": "PrompTale AI", "TALE": "PrompTale AI",
"TALENT": "Talent Protocol", "TALENT": "Talent Protocol",
"TALES": "Tales of Pepe", "TALES": "Tales of Pepe",
"TALEX": "TaleX",
"TALIS": "Talis Protocol", "TALIS": "Talis Protocol",
"TALK": "Talken", "TALK": "Talken",
"TAMA": "Tamadoge", "TAMA": "Tamadoge",
@ -15197,6 +15298,7 @@
"TDX": "Tidex Token", "TDX": "Tidex Token",
"TEA": "TeaDAO", "TEA": "TeaDAO",
"TEAM": "TeamUP", "TEAM": "TeamUP",
"TEARS": "Liberals Tears",
"TEC": "TeCoin", "TEC": "TeCoin",
"TECAR": "Tesla Cars", "TECAR": "Tesla Cars",
"TECH": "TechCoin", "TECH": "TechCoin",
@ -15461,8 +15563,10 @@
"TOC": "TouchCon", "TOC": "TouchCon",
"TODAY": "TodayCoin", "TODAY": "TodayCoin",
"TODD": "TURBO TODD", "TODD": "TURBO TODD",
"TOG": "Token of Games",
"TOK": "Tokai", "TOK": "Tokai",
"TOKA": "Tonka Finance", "TOKA": "Tonka Finance",
"TOKABU": "Tokabu",
"TOKAMAK": "Tokamak Network", "TOKAMAK": "Tokamak Network",
"TOKAU": "Tokyo AU", "TOKAU": "Tokyo AU",
"TOKC": "Tokyo Coin", "TOKC": "Tokyo Coin",
@ -15550,6 +15654,7 @@
"TOWELI": "Towelie", "TOWELI": "Towelie",
"TOWER": "Tower", "TOWER": "Tower",
"TOWN": "Town Star", "TOWN": "Town Star",
"TOWNS": "Towns",
"TOX": "INTOverse", "TOX": "INTOverse",
"TOXI": "ToxicGarden.finance SEED", "TOXI": "ToxicGarden.finance SEED",
"TOYBOX": "Memefi Toybox 404", "TOYBOX": "Memefi Toybox 404",
@ -15562,6 +15667,7 @@
"TPG": "Troll Payment", "TPG": "Troll Payment",
"TPRO": "TPRO Network", "TPRO": "TPRO Network",
"TPT": "Token Pocket", "TPT": "Token Pocket",
"TPTU": "Trading and Payment Token",
"TPU": "TensorSpace", "TPU": "TensorSpace",
"TPV": "TravGoPV", "TPV": "TravGoPV",
"TPY": "Thrupenny", "TPY": "Thrupenny",
@ -15608,8 +15714,9 @@
"TREAT": "Shiba Inu Treat", "TREAT": "Shiba Inu Treat",
"TREB": "Treble", "TREB": "Treble",
"TRECENTO": "Trecento Blockchain Capital", "TRECENTO": "Trecento Blockchain Capital",
"TREE": "Tree", "TREE": "Treehouse",
"TREEB": "Retreeb", "TREEB": "Retreeb",
"TREEOFALPHA": "Tree",
"TREMP": "Doland Tremp", "TREMP": "Doland Tremp",
"TRENCHER": "Trencher", "TRENCHER": "Trencher",
"TRESTLE": "TRESTLE", "TRESTLE": "TRESTLE",
@ -15646,6 +15753,7 @@
"TROGE": "Troge", "TROGE": "Troge",
"TROLL": "TROLL", "TROLL": "TROLL",
"TROLLC": "Trollcoin", "TROLLC": "Trollcoin",
"TROLLGE": "TROLLGE",
"TROLLHEIM": "Trollheim", "TROLLHEIM": "Trollheim",
"TROLLICTO": "TROLLI CTO", "TROLLICTO": "TROLLI CTO",
"TROLLMODE": "TROLL MODE", "TROLLMODE": "TROLL MODE",
@ -15807,6 +15915,7 @@
"TUSD": "True USD", "TUSD": "True USD",
"TUSDV1": "True USD v1", "TUSDV1": "True USD v1",
"TUT": "Tutorial", "TUT": "Tutorial",
"TUTC": "TUTUT COIN",
"TUTELLUS": "Tutellus", "TUTELLUS": "Tutellus",
"TUTTER": "Tutter", "TUTTER": "Tutter",
"TUX": "Tux The Penguin", "TUX": "Tux The Penguin",
@ -15932,6 +16041,7 @@
"UGOLD": "UGOLD Inc.", "UGOLD": "UGOLD Inc.",
"UGT": "Universal Games Token", "UGT": "Universal Games Token",
"UHP": "Ulgen Hash Power", "UHP": "Ulgen Hash Power",
"UI": "uiui",
"UIBT": "Unibit", "UIBT": "Unibit",
"UIM": "UNIVERSE ISLAND", "UIM": "UNIVERSE ISLAND",
"UIN": "Alliance Chain", "UIN": "Alliance Chain",
@ -16016,6 +16126,7 @@
"UNIT": "Universal Currency", "UNIT": "Universal Currency",
"UNIT0": "UNIT0", "UNIT0": "UNIT0",
"UNITARYSTATUS": "UnitaryStatus Dollar", "UNITARYSTATUS": "UnitaryStatus Dollar",
"UNITE": "Unite",
"UNITED": "UnitedCoins", "UNITED": "UnitedCoins",
"UNITRADE": "UniTrade", "UNITRADE": "UniTrade",
"UNITREEAI": "Unitree G1 AI", "UNITREEAI": "Unitree G1 AI",
@ -16065,6 +16176,7 @@
"UR": "UR", "UR": "UR",
"URAC": "Uranus", "URAC": "Uranus",
"URALS": "Urals Coin", "URALS": "Urals Coin",
"URANUS": "Uranus",
"URFA": "Urfaspor Token", "URFA": "Urfaspor Token",
"URMOM": "urmom", "URMOM": "urmom",
"URO": "Urolithin A", "URO": "Urolithin A",
@ -16237,7 +16349,7 @@
"VANT": "Vanta Network", "VANT": "Vanta Network",
"VANY": "Vanywhere", "VANY": "Vanywhere",
"VAPE": "VAPE", "VAPE": "VAPE",
"VAPOR": "Vaporcoin", "VAPOR": "Hypervapor",
"VARA": "Vara Network", "VARA": "Vara Network",
"VARIUS": "Varius", "VARIUS": "Varius",
"VARK": "Aardvark", "VARK": "Aardvark",
@ -16289,8 +16401,9 @@
"VEEN": "LIVEEN", "VEEN": "LIVEEN",
"VEG": "BitVegan", "VEG": "BitVegan",
"VEGA": "Vega Protocol", "VEGA": "Vega Protocol",
"VEGAS": "Vegasino", "VEGAS": "Vegas",
"VEGASI": "Vegas Inu Token", "VEGASI": "Vegas Inu Token",
"VEGASINO": "Vegasino",
"VEGE": "Vege Token", "VEGE": "Vege Token",
"VEIL": "VEIL", "VEIL": "VEIL",
"VEKTOR": "VEKTOR", "VEKTOR": "VEKTOR",
@ -16388,6 +16501,7 @@
"VIRES": "Vires Finance", "VIRES": "Vires Finance",
"VIRTU": "VIRTUCLOUD", "VIRTU": "VIRTUCLOUD",
"VIRTUAL": "Virtual Protocol", "VIRTUAL": "Virtual Protocol",
"VIRTUALMINING": "VirtualMining Coin",
"VIRTUM": "VIRTUMATE", "VIRTUM": "VIRTUMATE",
"VIS": "Vigorus", "VIS": "Vigorus",
"VISIO": "Visio", "VISIO": "Visio",
@ -16435,7 +16549,7 @@
"VLXPAD": "VelasPad", "VLXPAD": "VelasPad",
"VMANTA": "Bifrost Voucher MANTA", "VMANTA": "Bifrost Voucher MANTA",
"VMATIC": "Venus MATIC", "VMATIC": "Venus MATIC",
"VMC": "VirtualMining Coin", "VMC": "VMS Classic",
"VME": "TrueVett", "VME": "TrueVett",
"VMINT": "Volumint", "VMINT": "Volumint",
"VMPX": "VMPX (Ordinals)", "VMPX": "VMPX (Ordinals)",
@ -16521,6 +16635,7 @@
"VSG": "Vitalik Smart Gas", "VSG": "Vitalik Smart Gas",
"VSHARE": "V3S Share", "VSHARE": "V3S Share",
"VSL": "vSlice", "VSL": "vSlice",
"VSN": "Vision",
"VSO": "Verso", "VSO": "Verso",
"VSOL": "VSolidus", "VSOL": "VSolidus",
"VSP": "Vesper Finance", "VSP": "Vesper Finance",
@ -16757,6 +16872,7 @@
"WEN": "Wen", "WEN": "Wen",
"WEND": "Wellnode", "WEND": "Wellnode",
"WENIS": "WenisCoin", "WENIS": "WenisCoin",
"WENL": "Wen Lambo Financial",
"WENLAMBO": "Wenlambo", "WENLAMBO": "Wenlambo",
"WEOS": "Wrapped EOS", "WEOS": "Wrapped EOS",
"WEPC": "World Earn & Play Community", "WEPC": "World Earn & Play Community",
@ -17535,6 +17651,7 @@
"YES": "YES Money", "YES": "YES Money",
"YESCOIN": "YesCoin", "YESCOIN": "YesCoin",
"YESP": "Yesports", "YESP": "Yesports",
"YESTOKEN": "Yes Token",
"YESW": "Yes World", "YESW": "Yes World",
"YETI": "Yeti Finance", "YETI": "Yeti Finance",
"YETIUSD": "YUSD Stablecoin", "YETIUSD": "YUSD Stablecoin",
@ -17665,7 +17782,8 @@
"ZARP": "ZARP Stablecoin", "ZARP": "ZARP Stablecoin",
"ZARX": "eToro South African Rand", "ZARX": "eToro South African Rand",
"ZASH": "ZIMBOCASH", "ZASH": "ZIMBOCASH",
"ZAT": "ZatGo", "ZAT": "zkApes",
"ZATGO": "ZatGo",
"ZAZA": "ZAZA", "ZAZA": "ZAZA",
"ZAZU": "Zazu", "ZAZU": "Zazu",
"ZAZZLES": "Zazzles", "ZAZZLES": "Zazzles",

3
apps/api/src/interceptors/redact-values-in-response/redact-values-in-response.interceptor.ts

@ -50,12 +50,15 @@ export class RedactValuesInResponseInterceptor<T>
'feeInBaseCurrency', 'feeInBaseCurrency',
'grossPerformance', 'grossPerformance',
'grossPerformanceWithCurrencyEffect', 'grossPerformanceWithCurrencyEffect',
'interestInBaseCurrency',
'investment', 'investment',
'netPerformance', 'netPerformance',
'netPerformanceWithCurrencyEffect', 'netPerformanceWithCurrencyEffect',
'quantity', 'quantity',
'symbolMapping', 'symbolMapping',
'totalBalanceInBaseCurrency', 'totalBalanceInBaseCurrency',
'totalDividendInBaseCurrency',
'totalInterestInBaseCurrency',
'totalValueInBaseCurrency', 'totalValueInBaseCurrency',
'unitPrice', 'unitPrice',
'value', 'value',

2
apps/api/src/models/rules/account-cluster-risk/current-investment.ts

@ -124,7 +124,7 @@ export class AccountClusterRiskCurrentInvestment extends Rule<Settings> {
public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings { public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings {
return { return {
baseCurrency, baseCurrency,
isActive: xRayRules?.[this.getKey()].isActive ?? true, isActive: xRayRules?.[this.getKey()]?.isActive ?? true,
thresholdMax: xRayRules?.[this.getKey()]?.thresholdMax ?? 0.5 thresholdMax: xRayRules?.[this.getKey()]?.thresholdMax ?? 0.5
}; };
} }

2
apps/api/src/models/rules/account-cluster-risk/single-account.ts

@ -74,7 +74,7 @@ export class AccountClusterRiskSingleAccount extends Rule<RuleSettings> {
public getSettings({ xRayRules }: UserSettings): RuleSettings { public getSettings({ xRayRules }: UserSettings): RuleSettings {
return { return {
isActive: xRayRules?.[this.getKey()].isActive ?? true isActive: xRayRules?.[this.getKey()]?.isActive ?? true
}; };
} }
} }

2
apps/api/src/models/rules/currency-cluster-risk/base-currency-current-investment.ts

@ -100,7 +100,7 @@ export class CurrencyClusterRiskBaseCurrencyCurrentInvestment extends Rule<Setti
public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings { public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings {
return { return {
baseCurrency, baseCurrency,
isActive: xRayRules?.[this.getKey()].isActive ?? true isActive: xRayRules?.[this.getKey()]?.isActive ?? true
}; };
} }
} }

2
apps/api/src/models/rules/currency-cluster-risk/current-investment.ts

@ -101,7 +101,7 @@ export class CurrencyClusterRiskCurrentInvestment extends Rule<Settings> {
public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings { public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings {
return { return {
baseCurrency, baseCurrency,
isActive: xRayRules?.[this.getKey()].isActive ?? true, isActive: xRayRules?.[this.getKey()]?.isActive ?? true,
thresholdMax: xRayRules?.[this.getKey()]?.thresholdMax ?? 0.5 thresholdMax: xRayRules?.[this.getKey()]?.thresholdMax ?? 0.5
}; };
} }

2
apps/api/src/models/rules/emergency-fund/emergency-fund-setup.ts

@ -62,7 +62,7 @@ export class EmergencyFundSetup extends Rule<Settings> {
public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings { public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings {
return { return {
baseCurrency, baseCurrency,
isActive: xRayRules?.[this.getKey()].isActive ?? true isActive: xRayRules?.[this.getKey()]?.isActive ?? true
}; };
} }
} }

2
apps/api/src/models/rules/fees/fee-ratio-initial-investment.ts

@ -85,7 +85,7 @@ export class FeeRatioInitialInvestment extends Rule<Settings> {
public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings { public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings {
return { return {
baseCurrency, baseCurrency,
isActive: xRayRules?.[this.getKey()].isActive ?? true, isActive: xRayRules?.[this.getKey()]?.isActive ?? true,
thresholdMax: xRayRules?.[this.getKey()]?.thresholdMax ?? 0.01 thresholdMax: xRayRules?.[this.getKey()]?.thresholdMax ?? 0.01
}; };
} }

90
apps/api/src/models/rules/liquidity/buying-power.ts

@ -0,0 +1,90 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { Rule } from '@ghostfolio/api/models/rule';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service';
import { UserSettings } from '@ghostfolio/common/interfaces';
export class BuyingPower extends Rule<Settings> {
private buyingPower: number;
public constructor(
protected exchangeRateDataService: ExchangeRateDataService,
private i18nService: I18nService,
buyingPower: number,
languageCode: string
) {
super(exchangeRateDataService, {
languageCode,
key: BuyingPower.name
});
this.buyingPower = buyingPower;
}
public evaluate(ruleSettings: Settings) {
if (this.buyingPower < ruleSettings.thresholdMin) {
return {
evaluation: this.i18nService.getTranslation({
id: 'rule.liquidityBuyingPower.false',
languageCode: this.getLanguageCode(),
placeholders: {
baseCurrency: ruleSettings.baseCurrency,
thresholdMin: ruleSettings.thresholdMin
}
}),
value: false
};
}
return {
evaluation: this.i18nService.getTranslation({
id: 'rule.liquidityBuyingPower.true',
languageCode: this.getLanguageCode(),
placeholders: {
baseCurrency: ruleSettings.baseCurrency,
thresholdMin: ruleSettings.thresholdMin
}
}),
value: true
};
}
public getCategoryName() {
return this.i18nService.getTranslation({
id: 'rule.liquidity.category',
languageCode: this.getLanguageCode()
});
}
public getConfiguration() {
return {
threshold: {
max: 200000,
min: 0,
step: 1000,
unit: ''
},
thresholdMin: true
};
}
public getName() {
return this.i18nService.getTranslation({
id: 'rule.liquidityBuyingPower',
languageCode: this.getLanguageCode()
});
}
public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings {
return {
baseCurrency,
isActive: xRayRules?.[this.getKey()]?.isActive ?? true,
thresholdMin: xRayRules?.[this.getKey()]?.thresholdMin ?? 0
};
}
}
interface Settings extends RuleSettings {
baseCurrency: string;
thresholdMin: number;
}

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

@ -40,9 +40,6 @@ export class ConfigurationService {
DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER: json({ DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER: json({
default: [] default: []
}), }),
DATA_SOURCES_LEGACY: json({
default: []
}),
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: bool({ default: false }), ENABLE_FEATURE_FEAR_AND_GREED_INDEX: bool({ default: false }),
ENABLE_FEATURE_READ_ONLY_MODE: bool({ default: false }), ENABLE_FEATURE_READ_ONLY_MODE: bool({ default: false }),
ENABLE_FEATURE_SOCIAL_LOGIN: bool({ default: false }), ENABLE_FEATURE_SOCIAL_LOGIN: bool({ default: false }),

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

@ -17,6 +17,7 @@ import { Cron, CronExpression } from '@nestjs/schedule';
@Injectable() @Injectable()
export class CronService { export class CronService {
private static readonly EVERY_HOUR_AT_RANDOM_MINUTE = `${new Date().getMinutes()} * * * *`;
private static readonly EVERY_SUNDAY_AT_LUNCH_TIME = '0 12 * * 0'; private static readonly EVERY_SUNDAY_AT_LUNCH_TIME = '0 12 * * 0';
public constructor( public constructor(
@ -28,8 +29,8 @@ export class CronService {
private readonly userService: UserService private readonly userService: UserService
) {} ) {}
@Cron(CronExpression.EVERY_HOUR) @Cron(CronService.EVERY_HOUR_AT_RANDOM_MINUTE)
public async runEveryHour() { public async runEveryHourAtRandomMinute() {
if (await this.isDataGatheringEnabled()) { if (await this.isDataGatheringEnabled()) {
await this.dataGatheringService.gather7Days(); await this.dataGatheringService.gather7Days();
} }

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

@ -78,7 +78,7 @@ export class CoinGeckoService implements DataProviderInterface {
} catch (error) { } catch (error) {
let message = error; let message = error;
if (error?.name === 'AbortError') { if (['AbortError', 'TimeoutError'].includes(error?.name)) {
message = `RequestError: The operation to get the asset profile for ${symbol} was aborted because the request to the data provider took more than ${( message = `RequestError: The operation to get the asset profile for ${symbol} was aborted because the request to the data provider took more than ${(
this.configurationService.get('REQUEST_TIMEOUT') / 1000 this.configurationService.get('REQUEST_TIMEOUT') / 1000
).toFixed(3)} seconds`; ).toFixed(3)} seconds`;
@ -196,8 +196,10 @@ export class CoinGeckoService implements DataProviderInterface {
} catch (error) { } catch (error) {
let message = error; let message = error;
if (error?.name === 'AbortError') { if (['AbortError', 'TimeoutError'].includes(error?.name)) {
message = `RequestError: The operation to get the quotes was aborted because the request to the data provider took more than ${( message = `RequestError: The operation to get the quotes for ${symbols.join(
', '
)} was aborted because the request to the data provider took more than ${(
this.configurationService.get('REQUEST_TIMEOUT') / 1000 this.configurationService.get('REQUEST_TIMEOUT') / 1000
).toFixed(3)} seconds`; ).toFixed(3)} seconds`;
} }
@ -212,15 +214,16 @@ export class CoinGeckoService implements DataProviderInterface {
return 'bitcoin'; return 'bitcoin';
} }
public async search({ query }: GetSearchParams): Promise<LookupResponse> { public async search({
query,
requestTimeout = this.configurationService.get('REQUEST_TIMEOUT')
}: GetSearchParams): Promise<LookupResponse> {
let items: LookupItem[] = []; let items: LookupItem[] = [];
try { try {
const { coins } = await fetch(`${this.apiUrl}/search?query=${query}`, { const { coins } = await fetch(`${this.apiUrl}/search?query=${query}`, {
headers: this.headers, headers: this.headers,
signal: AbortSignal.timeout( signal: AbortSignal.timeout(requestTimeout)
this.configurationService.get('REQUEST_TIMEOUT')
)
}).then((res) => res.json()); }).then((res) => res.json());
items = coins.map(({ id: symbol, name }) => { items = coins.map(({ id: symbol, name }) => {
@ -237,7 +240,7 @@ export class CoinGeckoService implements DataProviderInterface {
} catch (error) { } catch (error) {
let message = error; let message = error;
if (error?.name === 'AbortError') { if (['AbortError', 'TimeoutError'].includes(error?.name)) {
message = `RequestError: The operation to search for ${query} was aborted because the request to the data provider took more than ${( message = `RequestError: The operation to search for ${query} was aborted because the request to the data provider took more than ${(
this.configurationService.get('REQUEST_TIMEOUT') / 1000 this.configurationService.get('REQUEST_TIMEOUT') / 1000
).toFixed(3)} seconds`; ).toFixed(3)} seconds`;

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

@ -163,7 +163,7 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
public async getAssetProfile( public async getAssetProfile(
aSymbol: string aSymbol: string
): Promise<Partial<SymbolProfile>> { ): Promise<Partial<SymbolProfile>> {
const response: Partial<SymbolProfile> = {}; let response: Partial<SymbolProfile> = {};
try { try {
let symbol = aSymbol; let symbol = aSymbol;
@ -241,10 +241,13 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
} }
const url = assetProfile.summaryProfile?.website; const url = assetProfile.summaryProfile?.website;
if (url) { if (url) {
response.url = url; response.url = url;
} }
} catch (error) { } catch (error) {
response = undefined;
if (error.message === `Quote not found for symbol: ${aSymbol}`) { if (error.message === `Quote not found for symbol: ${aSymbol}`) {
throw new AssetProfileDelistedError( throw new AssetProfileDelistedError(
`No data found, ${aSymbol} (${this.getName()}) may be delisted` `No data found, ${aSymbol} (${this.getName()}) may be delisted`

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

@ -26,13 +26,12 @@ import {
LookupItem, LookupItem,
LookupResponse LookupResponse
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { hasRole } from '@ghostfolio/common/permissions';
import type { Granularity, UserWithSettings } from '@ghostfolio/common/types'; import type { Granularity, UserWithSettings } from '@ghostfolio/common/types';
import { Inject, Injectable, Logger, OnModuleInit } from '@nestjs/common'; import { Inject, Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { DataSource, MarketData, SymbolProfile } from '@prisma/client'; import { DataSource, MarketData, SymbolProfile } from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { eachDayOfInterval, format, isBefore, isValid } from 'date-fns'; import { eachDayOfInterval, format, isValid } from 'date-fns';
import { groupBy, isEmpty, isNumber, uniqWith } from 'lodash'; import { groupBy, isEmpty, isNumber, uniqWith } from 'lodash';
import ms from 'ms'; import ms from 'ms';
@ -108,7 +107,9 @@ export class DataProviderService implements OnModuleInit {
promises.push( promises.push(
promise.then((symbolProfile) => { promise.then((symbolProfile) => {
response[symbol] = symbolProfile; if (symbolProfile) {
response[symbol] = symbolProfile;
}
}) })
); );
} }
@ -160,25 +161,9 @@ export class DataProviderService implements OnModuleInit {
return DataSource[this.configurationService.get('DATA_SOURCE_IMPORT')]; return DataSource[this.configurationService.get('DATA_SOURCE_IMPORT')];
} }
public async getDataSources({ public async getDataSources(): Promise<DataSource[]> {
includeGhostfolio = false,
user
}: {
includeGhostfolio?: boolean;
user: UserWithSettings;
}): Promise<DataSource[]> {
let dataSourcesKey: 'DATA_SOURCES' | 'DATA_SOURCES_LEGACY' = 'DATA_SOURCES';
if (
!hasRole(user, 'ADMIN') &&
isBefore(user.createdAt, new Date('2025-03-23')) &&
this.configurationService.get('DATA_SOURCES_LEGACY')?.length > 0
) {
dataSourcesKey = 'DATA_SOURCES_LEGACY';
}
const dataSources: DataSource[] = this.configurationService const dataSources: DataSource[] = this.configurationService
.get(dataSourcesKey) .get('DATA_SOURCES')
.map((dataSource) => { .map((dataSource) => {
return DataSource[dataSource]; return DataSource[dataSource];
}); });
@ -187,7 +172,7 @@ export class DataProviderService implements OnModuleInit {
PROPERTY_API_KEY_GHOSTFOLIO PROPERTY_API_KEY_GHOSTFOLIO
); );
if (includeGhostfolio || ghostfolioApiKey) { if (ghostfolioApiKey) {
dataSources.push('GHOSTFOLIO'); dataSources.push('GHOSTFOLIO');
} }
@ -634,7 +619,7 @@ export class DataProviderService implements OnModuleInit {
return { items: lookupItems }; return { items: lookupItems };
} }
const dataSources = await this.getDataSources({ user }); const dataSources = await this.getDataSources();
const dataProviderServices = dataSources.map((dataSource) => { const dataProviderServices = dataSources.map((dataSource) => {
return this.getDataProvider(DataSource[dataSource]); return this.getDataProvider(DataSource[dataSource]);

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

@ -51,18 +51,26 @@ export class EodHistoricalDataService implements DataProviderInterface {
} }
public async getAssetProfile({ public async getAssetProfile({
requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'),
symbol symbol
}: GetAssetProfileParams): Promise<Partial<SymbolProfile>> { }: GetAssetProfileParams): Promise<Partial<SymbolProfile>> {
const [searchResult] = await this.getSearchResult(symbol); const [searchResult] = await this.getSearchResult({
requestTimeout,
query: symbol
});
if (!searchResult) {
return undefined;
}
return { return {
symbol, symbol,
assetClass: searchResult?.assetClass, assetClass: searchResult.assetClass,
assetSubClass: searchResult?.assetSubClass, assetSubClass: searchResult.assetSubClass,
currency: this.convertCurrency(searchResult?.currency), currency: this.convertCurrency(searchResult.currency),
dataSource: this.getName(), dataSource: this.getName(),
isin: searchResult?.isin, isin: searchResult.isin,
name: searchResult?.name name: searchResult.name
}; };
} }
@ -282,8 +290,10 @@ export class EodHistoricalDataService implements DataProviderInterface {
} catch (error) { } catch (error) {
let message = error; let message = error;
if (error?.name === 'AbortError') { if (['AbortError', 'TimeoutError'].includes(error?.name)) {
message = `RequestError: The operation to get the quotes was aborted because the request to the data provider took more than ${( message = `RequestError: The operation to get the quotes for ${symbols.join(
', '
)} was aborted because the request to the data provider took more than ${(
this.configurationService.get('REQUEST_TIMEOUT') / 1000 this.configurationService.get('REQUEST_TIMEOUT') / 1000
).toFixed(3)} seconds`; ).toFixed(3)} seconds`;
} }
@ -298,8 +308,11 @@ export class EodHistoricalDataService implements DataProviderInterface {
return 'AAPL.US'; return 'AAPL.US';
} }
public async search({ query }: GetSearchParams): Promise<LookupResponse> { public async search({
const searchResult = await this.getSearchResult(query); query,
requestTimeout = this.configurationService.get('REQUEST_TIMEOUT')
}: GetSearchParams): Promise<LookupResponse> {
const searchResult = await this.getSearchResult({ query, requestTimeout });
return { return {
items: searchResult items: searchResult
@ -388,7 +401,13 @@ export class EodHistoricalDataService implements DataProviderInterface {
return name; return name;
} }
private async getSearchResult(aQuery: string) { private async getSearchResult({
query,
requestTimeout = this.configurationService.get('REQUEST_TIMEOUT')
}: {
query: string;
requestTimeout?: number;
}) {
let searchResult: (LookupItem & { let searchResult: (LookupItem & {
assetClass: AssetClass; assetClass: AssetClass;
assetSubClass: AssetSubClass; assetSubClass: AssetSubClass;
@ -397,11 +416,9 @@ export class EodHistoricalDataService implements DataProviderInterface {
try { try {
const response = await fetch( const response = await fetch(
`${this.URL}/search/${aQuery}?api_token=${this.apiKey}`, `${this.URL}/search/${query}?api_token=${this.apiKey}`,
{ {
signal: AbortSignal.timeout( signal: AbortSignal.timeout(requestTimeout)
this.configurationService.get('REQUEST_TIMEOUT')
)
} }
).then((res) => res.json()); ).then((res) => res.json());
@ -426,8 +443,8 @@ export class EodHistoricalDataService implements DataProviderInterface {
} catch (error) { } catch (error) {
let message = error; let message = error;
if (error?.name === 'AbortError') { if (['AbortError', 'TimeoutError'].includes(error?.name)) {
message = `RequestError: The operation to search for ${aQuery} was aborted because the request to the data provider took more than ${( message = `RequestError: The operation to search for ${query} was aborted because the request to the data provider took more than ${(
this.configurationService.get('REQUEST_TIMEOUT') / 1000 this.configurationService.get('REQUEST_TIMEOUT') / 1000
).toFixed(3)} seconds`; ).toFixed(3)} seconds`;
} }

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

@ -64,7 +64,7 @@ export class FinancialModelingPrepService implements DataProviderInterface {
requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'), requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'),
symbol symbol
}: GetAssetProfileParams): Promise<Partial<SymbolProfile>> { }: GetAssetProfileParams): Promise<Partial<SymbolProfile>> {
const response: Partial<SymbolProfile> = { let response: Partial<SymbolProfile> = {
symbol, symbol,
dataSource: this.getName() dataSource: this.getName()
}; };
@ -201,8 +201,9 @@ export class FinancialModelingPrepService implements DataProviderInterface {
} }
} catch (error) { } catch (error) {
let message = error; let message = error;
response = undefined;
if (error?.name === 'AbortError') { if (['AbortError', 'TimeoutError'].includes(error?.name)) {
message = `RequestError: The operation to get the asset profile for ${symbol} was aborted because the request to the data provider took more than ${( message = `RequestError: The operation to get the asset profile for ${symbol} was aborted because the request to the data provider took more than ${(
requestTimeout / 1000 requestTimeout / 1000
).toFixed(3)} seconds`; ).toFixed(3)} seconds`;
@ -364,8 +365,13 @@ export class FinancialModelingPrepService implements DataProviderInterface {
await Promise.all( await Promise.all(
quotes.map(({ symbol }) => { quotes.map(({ symbol }) => {
return this.getAssetProfile({ symbol }).then(({ currency }) => { return this.getAssetProfile({
currencyBySymbolMap[symbol] = { currency }; requestTimeout,
symbol
}).then((assetProfile) => {
if (assetProfile?.currency) {
currencyBySymbolMap[symbol] = { currency: assetProfile.currency };
}
}); });
}) })
); );
@ -392,8 +398,10 @@ export class FinancialModelingPrepService implements DataProviderInterface {
} catch (error) { } catch (error) {
let message = error; let message = error;
if (error?.name === 'AbortError') { if (['AbortError', 'TimeoutError'].includes(error?.name)) {
message = `RequestError: The operation to get the quotes was aborted because the request to the data provider took more than ${( message = `RequestError: The operation to get the quotes for ${symbols.join(
', '
)} was aborted because the request to the data provider took more than ${(
requestTimeout / 1000 requestTimeout / 1000
).toFixed(3)} seconds`; ).toFixed(3)} seconds`;
} }
@ -408,7 +416,10 @@ export class FinancialModelingPrepService implements DataProviderInterface {
return 'AAPL'; return 'AAPL';
} }
public async search({ query }: GetSearchParams): Promise<LookupResponse> { public async search({
query,
requestTimeout = this.configurationService.get('REQUEST_TIMEOUT')
}: GetSearchParams): Promise<LookupResponse> {
const assetProfileBySymbolMap: { const assetProfileBySymbolMap: {
[symbol: string]: Partial<SymbolProfile>; [symbol: string]: Partial<SymbolProfile>;
} = {}; } = {};
@ -419,9 +430,7 @@ export class FinancialModelingPrepService implements DataProviderInterface {
const result = await fetch( const result = await fetch(
`${this.getUrl({ version: 'stable' })}/search-isin?isin=${query.toUpperCase()}&apikey=${this.apiKey}`, `${this.getUrl({ version: 'stable' })}/search-isin?isin=${query.toUpperCase()}&apikey=${this.apiKey}`,
{ {
signal: AbortSignal.timeout( signal: AbortSignal.timeout(requestTimeout)
this.configurationService.get('REQUEST_TIMEOUT')
)
} }
).then((res) => res.json()); ).then((res) => res.json());
@ -469,7 +478,7 @@ export class FinancialModelingPrepService implements DataProviderInterface {
} catch (error) { } catch (error) {
let message = error; let message = error;
if (error?.name === 'AbortError') { if (['AbortError', 'TimeoutError'].includes(error?.name)) {
message = `RequestError: The operation to search for ${query} was aborted because the request to the data provider took more than ${( message = `RequestError: The operation to search for ${query} was aborted because the request to the data provider took more than ${(
this.configurationService.get('REQUEST_TIMEOUT') / 1000 this.configurationService.get('REQUEST_TIMEOUT') / 1000
).toFixed(3)} seconds`; ).toFixed(3)} seconds`;

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

@ -51,43 +51,48 @@ export class GhostfolioService implements DataProviderInterface {
requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'), requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'),
symbol symbol
}: GetAssetProfileParams): Promise<Partial<SymbolProfile>> { }: GetAssetProfileParams): Promise<Partial<SymbolProfile>> {
let response: DataProviderGhostfolioAssetProfileResponse = {}; let assetProfile: DataProviderGhostfolioAssetProfileResponse;
try { try {
const assetProfile = (await fetch( const response = await fetch(
`${this.URL}/v1/data-providers/ghostfolio/asset-profile/${symbol}`, `${this.URL}/v1/data-providers/ghostfolio/asset-profile/${symbol}`,
{ {
headers: await this.getRequestHeaders(), headers: await this.getRequestHeaders(),
signal: AbortSignal.timeout(requestTimeout) signal: AbortSignal.timeout(requestTimeout)
} }
).then((res) => );
res.json()
)) as DataProviderGhostfolioAssetProfileResponse; if (!response.ok) {
throw new Response(await response.text(), {
status: response.status,
statusText: response.statusText
});
}
response = assetProfile; assetProfile =
(await response.json()) as DataProviderGhostfolioAssetProfileResponse;
} catch (error) { } catch (error) {
let message = error; let message = error;
if (error.name === 'AbortError') { if (['AbortError', 'TimeoutError'].includes(error?.name)) {
message = `RequestError: The operation to get the quotes was aborted because the request to the data provider took more than ${( message = `RequestError: The operation to get the asset profile for ${symbol} was aborted because the request to the data provider took more than ${(
requestTimeout / 1000 requestTimeout / 1000
).toFixed(3)} seconds`; ).toFixed(3)} seconds`;
} else if (error.response?.statusCode === StatusCodes.TOO_MANY_REQUESTS) { } else if (error?.status === StatusCodes.TOO_MANY_REQUESTS) {
message = 'RequestError: The daily request limit has been exceeded'; message = 'RequestError: The daily request limit has been exceeded';
} else if (error.response?.statusCode === StatusCodes.UNAUTHORIZED) { } else if (
if (!error.request?.options?.headers?.authorization?.includes('-')) { [StatusCodes.FORBIDDEN, StatusCodes.UNAUTHORIZED].includes(
message = error?.status
'RequestError: The provided API key is invalid. Please update it in the Settings section of the Admin Control panel.'; )
} else { ) {
message = message =
'RequestError: The provided API key has expired. Please request a new one and update it in the Settings section of the Admin Control panel.'; 'RequestError: The API key is invalid. Please update it in the Settings section of the Admin Control panel.';
}
} }
Logger.error(message, 'GhostfolioService'); Logger.error(message, 'GhostfolioService');
} }
return response; return assetProfile;
} }
public getDataProviderInfo(): DataProviderInfo { public getDataProviderInfo(): DataProviderInfo {
@ -108,12 +113,12 @@ export class GhostfolioService implements DataProviderInterface {
}: GetDividendsParams): Promise<{ }: GetDividendsParams): Promise<{
[date: string]: IDataProviderHistoricalResponse; [date: string]: IDataProviderHistoricalResponse;
}> { }> {
let response: { let dividends: {
[date: string]: IDataProviderHistoricalResponse; [date: string]: IDataProviderHistoricalResponse;
} = {}; } = {};
try { try {
const { dividends } = (await fetch( const response = await fetch(
`${this.URL}/v2/data-providers/ghostfolio/dividends/${symbol}?from=${format(from, DATE_FORMAT)}&granularity=${granularity}&to=${format( `${this.URL}/v2/data-providers/ghostfolio/dividends/${symbol}?from=${format(from, DATE_FORMAT)}&granularity=${granularity}&to=${format(
to, to,
DATE_FORMAT DATE_FORMAT
@ -122,28 +127,34 @@ export class GhostfolioService implements DataProviderInterface {
headers: await this.getRequestHeaders(), headers: await this.getRequestHeaders(),
signal: AbortSignal.timeout(requestTimeout) signal: AbortSignal.timeout(requestTimeout)
} }
).then((res) => res.json())) as DividendsResponse; );
if (!response.ok) {
throw new Response(await response.text(), {
status: response.status,
statusText: response.statusText
});
}
response = dividends; dividends = ((await response.json()) as DividendsResponse).dividends;
} catch (error) { } catch (error) {
let message = error; let message = error;
if (error.response?.statusCode === StatusCodes.TOO_MANY_REQUESTS) { if (error?.status === StatusCodes.TOO_MANY_REQUESTS) {
message = 'RequestError: The daily request limit has been exceeded'; message = 'RequestError: The daily request limit has been exceeded';
} else if (error.response?.statusCode === StatusCodes.UNAUTHORIZED) { } else if (
if (!error.request?.options?.headers?.authorization?.includes('-')) { [StatusCodes.FORBIDDEN, StatusCodes.UNAUTHORIZED].includes(
message = error?.status
'RequestError: The provided API key is invalid. Please update it in the Settings section of the Admin Control panel.'; )
} else { ) {
message = message =
'RequestError: The provided API key has expired. Please request a new one and update it in the Settings section of the Admin Control panel.'; 'RequestError: The API key is invalid. Please update it in the Settings section of the Admin Control panel.';
}
} }
Logger.error(message, 'GhostfolioService'); Logger.error(message, 'GhostfolioService');
} }
return response; return dividends;
} }
public async getHistorical({ public async getHistorical({
@ -156,7 +167,7 @@ export class GhostfolioService implements DataProviderInterface {
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse }; [symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
}> { }> {
try { try {
const { historicalData } = (await fetch( const response = await fetch(
`${this.URL}/v2/data-providers/ghostfolio/historical/${symbol}?from=${format(from, DATE_FORMAT)}&granularity=${granularity}&to=${format( `${this.URL}/v2/data-providers/ghostfolio/historical/${symbol}?from=${format(from, DATE_FORMAT)}&granularity=${granularity}&to=${format(
to, to,
DATE_FORMAT DATE_FORMAT
@ -165,27 +176,36 @@ export class GhostfolioService implements DataProviderInterface {
headers: await this.getRequestHeaders(), headers: await this.getRequestHeaders(),
signal: AbortSignal.timeout(requestTimeout) signal: AbortSignal.timeout(requestTimeout)
} }
).then((res) => res.json())) as HistoricalResponse; );
if (!response.ok) {
throw new Response(await response.text(), {
status: response.status,
statusText: response.statusText
});
}
const { historicalData } = (await response.json()) as HistoricalResponse;
return { return {
[symbol]: historicalData [symbol]: historicalData
}; };
} catch (error) { } catch (error) {
let message = error; if (error?.status === StatusCodes.TOO_MANY_REQUESTS) {
error.name = 'RequestError';
if (error.response?.statusCode === StatusCodes.TOO_MANY_REQUESTS) { error.message =
message = 'RequestError: The daily request limit has been exceeded'; 'RequestError: The daily request limit has been exceeded';
} else if (error.response?.statusCode === StatusCodes.UNAUTHORIZED) { } else if (
if (!error.request?.options?.headers?.authorization?.includes('-')) { [StatusCodes.FORBIDDEN, StatusCodes.UNAUTHORIZED].includes(
message = error?.status
'RequestError: The provided API key is invalid. Please update it in the Settings section of the Admin Control panel.'; )
} else { ) {
message = error.name = 'RequestError';
'RequestError: The provided API key has expired. Please request a new one and update it in the Settings section of the Admin Control panel.'; error.message =
} 'RequestError: The API key is invalid. Please update it in the Settings section of the Admin Control panel.';
} }
Logger.error(message, 'GhostfolioService'); Logger.error(error.message, 'GhostfolioService');
throw new Error( throw new Error(
`Could not get historical market data for ${symbol} (${this.getName()}) from ${format( `Could not get historical market data for ${symbol} (${this.getName()}) from ${format(
@ -210,45 +230,53 @@ export class GhostfolioService implements DataProviderInterface {
}: GetQuotesParams): Promise<{ }: GetQuotesParams): Promise<{
[symbol: string]: IDataProviderResponse; [symbol: string]: IDataProviderResponse;
}> { }> {
let response: { [symbol: string]: IDataProviderResponse } = {}; let quotes: { [symbol: string]: IDataProviderResponse } = {};
if (symbols.length <= 0) { if (symbols.length <= 0) {
return response; return quotes;
} }
try { try {
const { quotes } = (await fetch( const response = await fetch(
`${this.URL}/v2/data-providers/ghostfolio/quotes?symbols=${symbols.join(',')}`, `${this.URL}/v2/data-providers/ghostfolio/quotes?symbols=${symbols.join(',')}`,
{ {
headers: await this.getRequestHeaders(), headers: await this.getRequestHeaders(),
signal: AbortSignal.timeout(requestTimeout) signal: AbortSignal.timeout(requestTimeout)
} }
).then((res) => res.json())) as QuotesResponse; );
response = quotes; if (!response.ok) {
throw new Response(await response.text(), {
status: response.status,
statusText: response.statusText
});
}
quotes = ((await response.json()) as QuotesResponse).quotes;
} catch (error) { } catch (error) {
let message = error; let message = error;
if (error.name === 'AbortError') { if (['AbortError', 'TimeoutError'].includes(error?.name)) {
message = `RequestError: The operation to get the quotes was aborted because the request to the data provider took more than ${( message = `RequestError: The operation to get the quotes for ${symbols.join(
', '
)} was aborted because the request to the data provider took more than ${(
requestTimeout / 1000 requestTimeout / 1000
).toFixed(3)} seconds`; ).toFixed(3)} seconds`;
} else if (error.response?.statusCode === StatusCodes.TOO_MANY_REQUESTS) { } else if (error?.status === StatusCodes.TOO_MANY_REQUESTS) {
message = 'RequestError: The daily request limit has been exceeded'; message = 'RequestError: The daily request limit has been exceeded';
} else if (error.response?.statusCode === StatusCodes.UNAUTHORIZED) { } else if (
if (!error.request?.options?.headers?.authorization?.includes('-')) { [StatusCodes.FORBIDDEN, StatusCodes.UNAUTHORIZED].includes(
message = error?.status
'RequestError: The provided API key is invalid. Please update it in the Settings section of the Admin Control panel.'; )
} else { ) {
message = message =
'RequestError: The provided API key has expired. Please request a new one and update it in the Settings section of the Admin Control panel.'; 'RequestError: The API key is invalid. Please update it in the Settings section of the Admin Control panel.';
}
} }
Logger.error(message, 'GhostfolioService'); Logger.error(message, 'GhostfolioService');
} }
return response; return quotes;
} }
public getTestSymbol() { public getTestSymbol() {
@ -256,36 +284,44 @@ export class GhostfolioService implements DataProviderInterface {
} }
public async search({ public async search({
requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'), query,
query requestTimeout = this.configurationService.get('REQUEST_TIMEOUT')
}: GetSearchParams): Promise<LookupResponse> { }: GetSearchParams): Promise<LookupResponse> {
let searchResult: LookupResponse = { items: [] }; let searchResult: LookupResponse = { items: [] };
try { try {
searchResult = (await fetch( const response = await fetch(
`${this.URL}/v2/data-providers/ghostfolio/lookup?query=${query}`, `${this.URL}/v2/data-providers/ghostfolio/lookup?query=${query}`,
{ {
headers: await this.getRequestHeaders(), headers: await this.getRequestHeaders(),
signal: AbortSignal.timeout(requestTimeout) signal: AbortSignal.timeout(requestTimeout)
} }
).then((res) => res.json())) as LookupResponse; );
if (!response.ok) {
throw new Response(await response.text(), {
status: response.status,
statusText: response.statusText
});
}
searchResult = (await response.json()) as LookupResponse;
} catch (error) { } catch (error) {
let message = error; let message = error;
if (error.name === 'AbortError') { if (['AbortError', 'TimeoutError'].includes(error?.name)) {
message = `RequestError: The operation to search for ${query} was aborted because the request to the data provider took more than ${( message = `RequestError: The operation to search for ${query} was aborted because the request to the data provider took more than ${(
requestTimeout / 1000 requestTimeout / 1000
).toFixed(3)} seconds`; ).toFixed(3)} seconds`;
} else if (error.response?.statusCode === StatusCodes.TOO_MANY_REQUESTS) { } else if (error?.status === StatusCodes.TOO_MANY_REQUESTS) {
message = 'RequestError: The daily request limit has been exceeded'; message = 'RequestError: The daily request limit has been exceeded';
} else if (error.response?.statusCode === StatusCodes.UNAUTHORIZED) { } else if (
if (!error.request?.options?.headers?.authorization?.includes('-')) { [StatusCodes.FORBIDDEN, StatusCodes.UNAUTHORIZED].includes(
message = error?.status
'RequestError: The provided API key is invalid. Please update it in the Settings section of the Admin Control panel.'; )
} else { ) {
message = message =
'RequestError: The provided API key has expired. Please request a new one and update it in the Settings section of the Admin Control panel.'; 'RequestError: The API key is invalid. Please update it in the Settings section of the Admin Control panel.';
}
} }
Logger.error(message, 'GhostfolioService'); Logger.error(message, 'GhostfolioService');

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

@ -36,13 +36,10 @@ export class GoogleSheetsService implements DataProviderInterface {
return true; return true;
} }
public async getAssetProfile({ public async getAssetProfile({}: GetAssetProfileParams): Promise<
symbol Partial<SymbolProfile>
}: GetAssetProfileParams): Promise<Partial<SymbolProfile>> { > {
return { return undefined;
symbol,
dataSource: this.getName()
};
} }
public getDataProviderInfo(): DataProviderInfo { public getDataProviderInfo(): DataProviderInfo {

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

@ -45,21 +45,20 @@ export class ManualService implements DataProviderInterface {
public async getAssetProfile({ public async getAssetProfile({
symbol symbol
}: GetAssetProfileParams): Promise<Partial<SymbolProfile>> { }: GetAssetProfileParams): Promise<Partial<SymbolProfile>> {
const assetProfile: Partial<SymbolProfile> = {
symbol,
dataSource: this.getName()
};
const [symbolProfile] = await this.symbolProfileService.getSymbolProfiles([ const [symbolProfile] = await this.symbolProfileService.getSymbolProfiles([
{ symbol, dataSource: this.getName() } { symbol, dataSource: this.getName() }
]); ]);
if (symbolProfile) { if (!symbolProfile) {
assetProfile.currency = symbolProfile.currency; return undefined;
assetProfile.name = symbolProfile.name;
} }
return assetProfile; return {
symbol,
currency: symbolProfile.currency,
dataSource: this.getName(),
name: symbolProfile.name
};
} }
public getDataProviderInfo(): DataProviderInfo { public getDataProviderInfo(): DataProviderInfo {

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

@ -35,13 +35,10 @@ export class RapidApiService implements DataProviderInterface {
return !!this.configurationService.get('API_KEY_RAPID_API'); return !!this.configurationService.get('API_KEY_RAPID_API');
} }
public async getAssetProfile({ public async getAssetProfile({}: GetAssetProfileParams): Promise<
symbol Partial<SymbolProfile>
}: GetAssetProfileParams): Promise<Partial<SymbolProfile>> { > {
return { return undefined;
symbol,
dataSource: this.getName()
};
} }
public getDataProviderInfo(): DataProviderInfo { public getDataProviderInfo(): DataProviderInfo {
@ -165,7 +162,7 @@ export class RapidApiService implements DataProviderInterface {
} catch (error) { } catch (error) {
let message = error; let message = error;
if (error?.name === 'AbortError') { if (['AbortError', 'TimeoutError'].includes(error?.name)) {
message = `RequestError: The operation was aborted because the request to the data provider took more than ${( message = `RequestError: The operation was aborted because the request to the data provider took more than ${(
this.configurationService.get('REQUEST_TIMEOUT') / 1000 this.configurationService.get('REQUEST_TIMEOUT') / 1000
).toFixed(3)} seconds`; ).toFixed(3)} seconds`;

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

@ -16,7 +16,6 @@ export interface Environment extends CleanedEnvAccessors {
DATA_SOURCE_IMPORT: string; DATA_SOURCE_IMPORT: string;
DATA_SOURCES: string[]; DATA_SOURCES: string[];
DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER: string[]; DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER: string[];
DATA_SOURCES_LEGACY: string[];
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: boolean; ENABLE_FEATURE_FEAR_AND_GREED_INDEX: boolean;
ENABLE_FEATURE_READ_ONLY_MODE: boolean; ENABLE_FEATURE_READ_ONLY_MODE: boolean;
ENABLE_FEATURE_SOCIAL_LOGIN: boolean; ENABLE_FEATURE_SOCIAL_LOGIN: boolean;

14
apps/client/src/app/app-routing.module.ts

@ -106,9 +106,7 @@ const routes: Routes = [
{ {
path: publicRoutes.pricing.path, path: publicRoutes.pricing.path,
loadChildren: () => loadChildren: () =>
import('./pages/pricing/pricing-page.module').then( import('./pages/pricing/pricing-page.routes').then((m) => m.routes)
(m) => m.PricingPageModule
)
}, },
{ {
path: publicRoutes.public.path, path: publicRoutes.public.path,
@ -120,9 +118,7 @@ const routes: Routes = [
{ {
path: publicRoutes.register.path, path: publicRoutes.register.path,
loadChildren: () => loadChildren: () =>
import('./pages/register/register-page.module').then( import('./pages/register/register-page.routes').then((m) => m.routes)
(m) => m.RegisterPageModule
)
}, },
{ {
path: publicRoutes.resources.path, path: publicRoutes.resources.path,
@ -132,9 +128,7 @@ const routes: Routes = [
{ {
path: publicRoutes.start.path, path: publicRoutes.start.path,
loadChildren: () => loadChildren: () =>
import('./pages/landing/landing-page.module').then( import('./pages/landing/landing-page.routes').then((m) => m.routes)
(m) => m.LandingPageModule
)
}, },
{ {
loadComponent: () => loadComponent: () =>
@ -147,7 +141,7 @@ const routes: Routes = [
{ {
path: internalRoutes.zen.path, path: internalRoutes.zen.path,
loadChildren: () => loadChildren: () =>
import('./pages/zen/zen-page.module').then((m) => m.ZenPageModule) import('./pages/zen/zen-page.routes').then((m) => m.routes)
}, },
{ {
// wildcard, if requested url doesn't match any paths for routes defined // wildcard, if requested url doesn't match any paths for routes defined

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

@ -67,12 +67,18 @@
<mat-menu #transactionMenu="matMenu" xPosition="before"> <mat-menu #transactionMenu="matMenu" xPosition="before">
@if (element.type === 'PUBLIC') { @if (element.type === 'PUBLIC') {
<button mat-menu-item (click)="onCopyUrlToClipboard(element.id)"> <button mat-menu-item (click)="onCopyUrlToClipboard(element.id)">
<ng-container i18n>Copy link to clipboard</ng-container> <span class="align-items-center d-flex">
<ion-icon class="mr-2" name="copy-outline" />
<span i18n>Copy link to clipboard</span>
</span>
</button> </button>
<hr class="my-0" /> <hr class="my-0" />
} }
<button mat-menu-item (click)="onDeleteAccess(element.id)"> <button mat-menu-item (click)="onDeleteAccess(element.id)">
<ng-container i18n>Revoke</ng-container> <span class="align-items-center d-flex">
<ion-icon class="mr-2" name="remove-circle-outline" />
<span i18n>Revoke</span>
</span>
</button> </button>
</mat-menu> </mat-menu>
</td> </td>

8
apps/client/src/app/components/access-table/access-table.component.ts

@ -22,10 +22,12 @@ import { RouterModule } from '@angular/router';
import { IonIcon } from '@ionic/angular/standalone'; import { IonIcon } from '@ionic/angular/standalone';
import { addIcons } from 'ionicons'; import { addIcons } from 'ionicons';
import { import {
copyOutline,
ellipsisHorizontal, ellipsisHorizontal,
linkOutline, linkOutline,
lockClosedOutline, lockClosedOutline,
lockOpenOutline lockOpenOutline,
removeCircleOutline
} from 'ionicons/icons'; } from 'ionicons/icons';
import ms from 'ms'; import ms from 'ms';
@ -62,10 +64,12 @@ export class GfAccessTableComponent implements OnChanges {
private snackBar: MatSnackBar private snackBar: MatSnackBar
) { ) {
addIcons({ addIcons({
copyOutline,
ellipsisHorizontal, ellipsisHorizontal,
linkOutline, linkOutline,
lockClosedOutline, lockClosedOutline,
lockOpenOutline lockOpenOutline,
removeCircleOutline
}); });
} }

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

@ -2,6 +2,7 @@ import { CreateAccountBalanceDto } from '@ghostfolio/api/app/account-balance/cre
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { NUMERICAL_PRECISION_THRESHOLD_6_FIGURES } from '@ghostfolio/common/config';
import { DATE_FORMAT, downloadAsFile } from '@ghostfolio/common/helper'; import { DATE_FORMAT, downloadAsFile } from '@ghostfolio/common/helper';
import { import {
AccountBalancesResponse, AccountBalancesResponse,
@ -51,12 +52,18 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
public accountBalances: AccountBalancesResponse['balances']; public accountBalances: AccountBalancesResponse['balances'];
public activities: OrderWithAccount[]; public activities: OrderWithAccount[];
public balance: number; public balance: number;
public balancePrecision = 2;
public currency: string; public currency: string;
public dataSource: MatTableDataSource<Activity>; public dataSource: MatTableDataSource<Activity>;
public dividendInBaseCurrency: number;
public dividendInBaseCurrencyPrecision = 2;
public equity: number; public equity: number;
public equityPrecision = 2;
public hasPermissionToDeleteAccountBalance: boolean; public hasPermissionToDeleteAccountBalance: boolean;
public historicalDataItems: HistoricalDataItem[]; public historicalDataItems: HistoricalDataItem[];
public holdings: PortfolioPosition[]; public holdings: PortfolioPosition[];
public interestInBaseCurrency: number;
public interestInBaseCurrencyPrecision = 2;
public isLoadingActivities: boolean; public isLoadingActivities: boolean;
public isLoadingChart: boolean; public isLoadingChart: boolean;
public name: string; public name: string;
@ -181,6 +188,8 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
({ ({
balance, balance,
currency, currency,
dividendInBaseCurrency,
interestInBaseCurrency,
name, name,
platform, platform,
transactionCount, transactionCount,
@ -188,14 +197,48 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
valueInBaseCurrency valueInBaseCurrency
}) => { }) => {
this.balance = balance; this.balance = balance;
if (
this.balance >= NUMERICAL_PRECISION_THRESHOLD_6_FIGURES &&
this.data.deviceType === 'mobile'
) {
this.balancePrecision = 0;
}
this.currency = currency; this.currency = currency;
this.dividendInBaseCurrency = dividendInBaseCurrency;
if (
this.data.deviceType === 'mobile' &&
this.dividendInBaseCurrency >=
NUMERICAL_PRECISION_THRESHOLD_6_FIGURES
) {
this.dividendInBaseCurrencyPrecision = 0;
}
if (isNumber(balance) && isNumber(value)) { if (isNumber(balance) && isNumber(value)) {
this.equity = new Big(value).minus(balance).toNumber(); this.equity = new Big(value).minus(balance).toNumber();
if (
this.data.deviceType === 'mobile' &&
this.equity >= NUMERICAL_PRECISION_THRESHOLD_6_FIGURES
) {
this.equityPrecision = 0;
}
} else { } else {
this.equity = null; this.equity = null;
} }
this.interestInBaseCurrency = interestInBaseCurrency;
if (
this.data.deviceType === 'mobile' &&
this.interestInBaseCurrency >=
NUMERICAL_PRECISION_THRESHOLD_6_FIGURES
) {
this.interestInBaseCurrencyPrecision = 0;
}
this.name = name; this.name = name;
this.platformName = platform?.name ?? '-'; this.platformName = platform?.name ?? '-';
this.transactionCount = transactionCount; this.transactionCount = transactionCount;

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

@ -20,20 +20,18 @@
</div> </div>
</div> </div>
@if (user?.settings?.isExperimentalFeatures) { <div class="chart-container mb-3">
<div class="chart-container mb-3"> <gf-investment-chart
<gf-investment-chart class="h-100"
class="h-100" [currency]="user?.settings?.baseCurrency"
[currency]="user?.settings?.baseCurrency" [historicalDataItems]="historicalDataItems"
[historicalDataItems]="historicalDataItems" [isInPercent]="
[isInPercent]=" data.hasImpersonationId || user.settings.isRestrictedView
data.hasImpersonationId || user.settings.isRestrictedView "
" [isLoading]="isLoadingChart"
[isLoading]="isLoadingChart" [locale]="user?.settings?.locale"
[locale]="user?.settings?.locale" />
/> </div>
</div>
}
<div class="mb-3 row"> <div class="mb-3 row">
<div class="col-6 mb-3"> <div class="col-6 mb-3">
@ -42,6 +40,7 @@
size="medium" size="medium"
[isCurrency]="true" [isCurrency]="true"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[precision]="balancePrecision"
[unit]="currency" [unit]="currency"
[value]="balance" [value]="balance"
>Cash Balance</gf-value >Cash Balance</gf-value
@ -53,11 +52,36 @@
size="medium" size="medium"
[isCurrency]="true" [isCurrency]="true"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[precision]="equityPrecision"
[unit]="currency" [unit]="currency"
[value]="equity" [value]="equity"
>Equity</gf-value >Equity</gf-value
> >
</div> </div>
<div class="col-6 mb-3">
<gf-value
i18n
size="medium"
[isCurrency]="true"
[locale]="user?.settings?.locale"
[precision]="interestInBaseCurrencyPrecision"
[unit]="user?.settings?.baseCurrency"
[value]="interestInBaseCurrency"
>Interest</gf-value
>
</div>
<div class="col-6 mb-3">
<gf-value
i18n
size="medium"
[isCurrency]="true"
[locale]="user?.settings?.locale"
[precision]="dividendInBaseCurrencyPrecision"
[unit]="user?.settings?.baseCurrency"
[value]="dividendInBaseCurrency"
>Dividend</gf-value
>
</div>
<div class="col-6 mb-3"> <div class="col-6 mb-3">
<gf-value i18n size="medium" [value]="transactionCount" <gf-value i18n size="medium" [value]="transactionCount"
>Activities</gf-value >Activities</gf-value

8
apps/client/src/app/components/account-detail-dialog/account-detail-dialog.module.ts

@ -1,5 +1,5 @@
import { GfDialogFooterModule } from '@ghostfolio/client/components/dialog-footer/dialog-footer.module'; import { GfDialogFooterComponent } from '@ghostfolio/client/components/dialog-footer/dialog-footer.component';
import { GfDialogHeaderModule } from '@ghostfolio/client/components/dialog-header/dialog-header.module'; import { GfDialogHeaderComponent } from '@ghostfolio/client/components/dialog-header/dialog-header.component';
import { GfInvestmentChartModule } from '@ghostfolio/client/components/investment-chart/investment-chart.module'; import { GfInvestmentChartModule } from '@ghostfolio/client/components/investment-chart/investment-chart.module';
import { GfAccountBalancesComponent } from '@ghostfolio/ui/account-balances'; import { GfAccountBalancesComponent } from '@ghostfolio/ui/account-balances';
import { GfActivitiesTableComponent } from '@ghostfolio/ui/activities-table'; import { GfActivitiesTableComponent } from '@ghostfolio/ui/activities-table';
@ -22,8 +22,8 @@ import { AccountDetailDialog } from './account-detail-dialog.component';
CommonModule, CommonModule,
GfAccountBalancesComponent, GfAccountBalancesComponent,
GfActivitiesTableComponent, GfActivitiesTableComponent,
GfDialogFooterModule, GfDialogFooterComponent,
GfDialogHeaderModule, GfDialogHeaderComponent,
GfHoldingsTableComponent, GfHoldingsTableComponent,
GfInvestmentChartModule, GfInvestmentChartModule,
GfValueComponent, GfValueComponent,

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

@ -103,39 +103,46 @@ export class GfAdminMarketDataComponent
@ViewChild(MatSort) sort: MatSort; @ViewChild(MatSort) sort: MatSort;
public activeFilters: Filter[] = []; public activeFilters: Filter[] = [];
public allFilters: Filter[] = Object.keys(AssetSubClass) public allFilters: Filter[] = [
.filter((assetSubClass) => { ...Object.keys(AssetSubClass)
return assetSubClass !== 'CASH'; .filter((assetSubClass) => {
}) return assetSubClass !== 'CASH';
.map((assetSubClass) => { })
.map((assetSubClass) => {
return {
id: assetSubClass.toString(),
label: translate(assetSubClass),
type: 'ASSET_SUB_CLASS' as Filter['type']
};
}),
...Object.keys(DataSource).map((dataSource) => {
return { return {
id: assetSubClass.toString(), id: dataSource.toString(),
label: translate(assetSubClass), label: dataSource,
type: 'ASSET_SUB_CLASS' as Filter['type'] type: 'DATA_SOURCE' as Filter['type']
}; };
}) }),
.concat([ {
{ id: 'BENCHMARKS',
id: 'BENCHMARKS', label: $localize`Benchmarks`,
label: $localize`Benchmarks`, type: 'PRESET_ID' as Filter['type']
type: 'PRESET_ID' as Filter['type'] },
}, {
{ id: 'CURRENCIES',
id: 'CURRENCIES', label: $localize`Currencies`,
label: $localize`Currencies`, type: 'PRESET_ID' as Filter['type']
type: 'PRESET_ID' as Filter['type'] },
}, {
{ id: 'ETF_WITHOUT_COUNTRIES',
id: 'ETF_WITHOUT_COUNTRIES', label: $localize`ETFs without Countries`,
label: $localize`ETFs without Countries`, type: 'PRESET_ID' as Filter['type']
type: 'PRESET_ID' as Filter['type'] },
}, {
{ id: 'ETF_WITHOUT_SECTORS',
id: 'ETF_WITHOUT_SECTORS', label: $localize`ETFs without Sectors`,
label: $localize`ETFs without Sectors`, type: 'PRESET_ID' as Filter['type']
type: 'PRESET_ID' as Filter['type'] }
} ];
]);
public benchmarks: Partial<SymbolProfile>[]; public benchmarks: Partial<SymbolProfile>[];
public currentDataSource: DataSource; public currentDataSource: DataSource;
public currentSymbol: string; public currentSymbol: string;

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

@ -13,6 +13,7 @@ import {
import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { import {
AdminMarketDataDetails, AdminMarketDataDetails,
AssetClassSelectorOption,
AssetProfileIdentifier, AssetProfileIdentifier,
LineChartItem, LineChartItem,
ScraperConfiguration, ScraperConfiguration,
@ -82,10 +83,7 @@ import ms from 'ms';
import { EMPTY, Subject } from 'rxjs'; import { EMPTY, Subject } from 'rxjs';
import { catchError, takeUntil } from 'rxjs/operators'; import { catchError, takeUntil } from 'rxjs/operators';
import { import { AssetProfileDialogParams } from './interfaces/interfaces';
AssetClassSelectorOption,
AssetProfileDialogParams
} from './interfaces/interfaces';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,

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

@ -549,7 +549,13 @@
<ng-container i18n>Data Gathering</ng-container> <ng-container i18n>Data Gathering</ng-container>
</mat-checkbox> </mat-checkbox>
</div> </div>
<button i18n mat-button type="button" (click)="onClose()">Cancel</button> <button mat-button type="button" (click)="onClose()">
@if (assetProfileForm.dirty) {
<ng-container i18n>Cancel</ng-container>
} @else {
<ng-container i18n>Close</ng-container>
}
</button>
<button <button
color="primary" color="primary"
mat-flat-button mat-flat-button

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

@ -1,11 +1,6 @@
import { ColorScheme } from '@ghostfolio/common/types'; import { ColorScheme } from '@ghostfolio/common/types';
import { AssetClass, AssetSubClass, DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
export interface AssetClassSelectorOption {
id: AssetClass | AssetSubClass;
label: string;
}
export interface AssetProfileDialogParams { export interface AssetProfileDialogParams {
colorScheme: ColorScheme; colorScheme: ColorScheme;

10
apps/client/src/app/components/admin-platform/create-or-update-platform-dialog/create-or-update-platform-dialog.html

@ -39,12 +39,18 @@
</div> </div>
</div> </div>
<div class="justify-content-end" mat-dialog-actions> <div class="justify-content-end" mat-dialog-actions>
<button i18n mat-button type="button" (click)="onCancel()">Cancel</button> <button mat-button type="button" (click)="onCancel()">
@if (platformForm.dirty) {
<ng-container i18n>Cancel</ng-container>
} @else {
<ng-container i18n>Close</ng-container>
}
</button>
<button <button
color="primary" color="primary"
mat-flat-button mat-flat-button
type="submit" type="submit"
[disabled]="!platformForm.valid" [disabled]="!(platformForm.dirty && platformForm.valid)"
> >
<ng-container i18n>Save</ng-container> <ng-container i18n>Save</ng-container>
</button> </button>

10
apps/client/src/app/components/admin-tag/create-or-update-tag-dialog/create-or-update-tag-dialog.html

@ -22,12 +22,18 @@
</div> </div>
</div> </div>
<div class="justify-content-end" mat-dialog-actions> <div class="justify-content-end" mat-dialog-actions>
<button i18n mat-button type="button" (click)="onCancel()">Cancel</button> <button mat-button type="button" (click)="onCancel()">
@if (tagForm.dirty) {
<ng-container i18n>Cancel</ng-container>
} @else {
<ng-container i18n>Close</ng-container>
}
</button>
<button <button
color="primary" color="primary"
mat-flat-button mat-flat-button
type="submit" type="submit"
[disabled]="!tagForm.valid" [disabled]="!(tagForm.dirty && tagForm.valid)"
> >
<ng-container i18n>Save</ng-container> <ng-container i18n>Save</ng-container>
</button> </button>

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

@ -5,18 +5,20 @@ import {
Input, Input,
Output Output
} from '@angular/core'; } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { IonIcon } from '@ionic/angular/standalone';
import { addIcons } from 'ionicons'; import { addIcons } from 'ionicons';
import { close } from 'ionicons/icons'; import { close } from 'ionicons/icons';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'justify-content-center' }, host: { class: 'justify-content-center' },
imports: [IonIcon, MatButtonModule],
selector: 'gf-dialog-footer', selector: 'gf-dialog-footer',
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './dialog-footer.component.html',
styleUrls: ['./dialog-footer.component.scss'], styleUrls: ['./dialog-footer.component.scss'],
standalone: false templateUrl: './dialog-footer.component.html'
}) })
export class DialogFooterComponent { export class GfDialogFooterComponent {
@Input() deviceType: string; @Input() deviceType: string;
@Output() closeButtonClicked = new EventEmitter<void>(); @Output() closeButtonClicked = new EventEmitter<void>();

14
apps/client/src/app/components/dialog-footer/dialog-footer.module.ts

@ -1,14 +0,0 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { IonIcon } from '@ionic/angular/standalone';
import { DialogFooterComponent } from './dialog-footer.component';
@NgModule({
declarations: [DialogFooterComponent],
exports: [DialogFooterComponent],
imports: [CommonModule, IonIcon, MatButtonModule],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class GfDialogFooterModule {}

11
apps/client/src/app/components/dialog-header/dialog-header.component.ts

@ -1,3 +1,4 @@
import { CommonModule } from '@angular/common';
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
Component, Component,
@ -5,18 +6,20 @@ import {
Input, Input,
Output Output
} from '@angular/core'; } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { IonIcon } from '@ionic/angular/standalone';
import { addIcons } from 'ionicons'; import { addIcons } from 'ionicons';
import { close } from 'ionicons/icons'; import { close } from 'ionicons/icons';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'justify-content-center' }, host: { class: 'justify-content-center' },
imports: [CommonModule, IonIcon, MatButtonModule],
selector: 'gf-dialog-header', selector: 'gf-dialog-header',
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './dialog-header.component.html',
styleUrls: ['./dialog-header.component.scss'], styleUrls: ['./dialog-header.component.scss'],
standalone: false templateUrl: './dialog-header.component.html'
}) })
export class DialogHeaderComponent { export class GfDialogHeaderComponent {
@Input() deviceType: string; @Input() deviceType: string;
@Input() position: 'center' | 'left' = 'left'; @Input() position: 'center' | 'left' = 'left';
@Input() title: string; @Input() title: string;

14
apps/client/src/app/components/dialog-header/dialog-header.module.ts

@ -1,14 +0,0 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { IonIcon } from '@ionic/angular/standalone';
import { DialogHeaderComponent } from './dialog-header.component';
@NgModule({
declarations: [DialogHeaderComponent],
exports: [DialogHeaderComponent],
imports: [CommonModule, IonIcon, MatButtonModule],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class GfDialogHeaderModule {}

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

@ -1,9 +1,13 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { GfDialogFooterModule } from '@ghostfolio/client/components/dialog-footer/dialog-footer.module'; import { GfDialogFooterComponent } from '@ghostfolio/client/components/dialog-footer/dialog-footer.component';
import { GfDialogHeaderModule } from '@ghostfolio/client/components/dialog-header/dialog-header.module'; import { GfDialogHeaderComponent } from '@ghostfolio/client/components/dialog-header/dialog-header.component';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { NUMERICAL_PRECISION_THRESHOLD } from '@ghostfolio/common/config'; import {
NUMERICAL_PRECISION_THRESHOLD_3_FIGURES,
NUMERICAL_PRECISION_THRESHOLD_5_FIGURES,
NUMERICAL_PRECISION_THRESHOLD_6_FIGURES
} from '@ghostfolio/common/config';
import { DATE_FORMAT, downloadAsFile } from '@ghostfolio/common/helper'; import { DATE_FORMAT, downloadAsFile } from '@ghostfolio/common/helper';
import { import {
DataProviderInfo, DataProviderInfo,
@ -74,8 +78,8 @@ import { HoldingDetailDialogParams } from './interfaces/interfaces';
GfAccountsTableComponent, GfAccountsTableComponent,
GfActivitiesTableComponent, GfActivitiesTableComponent,
GfDataProviderCreditsComponent, GfDataProviderCreditsComponent,
GfDialogFooterModule, GfDialogFooterComponent,
GfDialogHeaderModule, GfDialogHeaderComponent,
GfHistoricalMarketDataEditorComponent, GfHistoricalMarketDataEditorComponent,
GfLineChartComponent, GfLineChartComponent,
GfPortfolioProportionChartComponent, GfPortfolioProportionChartComponent,
@ -101,6 +105,7 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
public assetClass: string; public assetClass: string;
public assetSubClass: string; public assetSubClass: string;
public averagePrice: number; public averagePrice: number;
public averagePricePrecision = 2;
public benchmarkDataItems: LineChartItem[]; public benchmarkDataItems: LineChartItem[];
public benchmarkLabel = $localize`Average Unit Price`; public benchmarkLabel = $localize`Average Unit Price`;
public countries: { public countries: {
@ -122,11 +127,15 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
public marketDataItems: MarketData[] = []; public marketDataItems: MarketData[] = [];
public marketPrice: number; public marketPrice: number;
public marketPriceMax: number; public marketPriceMax: number;
public marketPriceMaxPrecision = 2;
public marketPriceMin: number; public marketPriceMin: number;
public marketPriceMinPrecision = 2;
public marketPricePrecision = 2;
public netPerformance: number; public netPerformance: number;
public netPerformancePrecision = 2; public netPerformancePrecision = 2;
public netPerformancePercent: number; public netPerformancePercent: number;
public netPerformancePercentWithCurrencyEffect: number; public netPerformancePercentWithCurrencyEffect: number;
public netPerformancePercentWithCurrencyEffectPrecision = 2;
public netPerformanceWithCurrencyEffect: number; public netPerformanceWithCurrencyEffect: number;
public netPerformanceWithCurrencyEffectPrecision = 2; public netPerformanceWithCurrencyEffectPrecision = 2;
public quantity: number; public quantity: number;
@ -274,6 +283,14 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
value value
}) => { }) => {
this.averagePrice = averagePrice; this.averagePrice = averagePrice;
if (
this.averagePrice >= NUMERICAL_PRECISION_THRESHOLD_6_FIGURES &&
this.data.deviceType === 'mobile'
) {
this.averagePricePrecision = 0;
}
this.benchmarkDataItems = []; this.benchmarkDataItems = [];
this.countries = {}; this.countries = {};
this.dataProviderInfo = dataProviderInfo; this.dataProviderInfo = dataProviderInfo;
@ -281,7 +298,8 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
if ( if (
this.data.deviceType === 'mobile' && this.data.deviceType === 'mobile' &&
this.dividendInBaseCurrency >= NUMERICAL_PRECISION_THRESHOLD this.dividendInBaseCurrency >=
NUMERICAL_PRECISION_THRESHOLD_6_FIGURES
) { ) {
this.dividendInBaseCurrencyPrecision = 0; this.dividendInBaseCurrencyPrecision = 0;
} }
@ -320,19 +338,42 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
if ( if (
this.data.deviceType === 'mobile' && this.data.deviceType === 'mobile' &&
this.investmentInBaseCurrencyWithCurrencyEffect >= this.investmentInBaseCurrencyWithCurrencyEffect >=
NUMERICAL_PRECISION_THRESHOLD NUMERICAL_PRECISION_THRESHOLD_6_FIGURES
) { ) {
this.investmentInBaseCurrencyWithCurrencyEffectPrecision = 0; this.investmentInBaseCurrencyWithCurrencyEffectPrecision = 0;
} }
this.marketPrice = marketPrice; this.marketPrice = marketPrice;
this.marketPriceMax = marketPriceMax; this.marketPriceMax = marketPriceMax;
if (
this.data.deviceType === 'mobile' &&
this.marketPriceMax >= NUMERICAL_PRECISION_THRESHOLD_6_FIGURES
) {
this.marketPriceMaxPrecision = 0;
}
this.marketPriceMin = marketPriceMin; this.marketPriceMin = marketPriceMin;
if (
this.data.deviceType === 'mobile' &&
this.marketPriceMin >= NUMERICAL_PRECISION_THRESHOLD_6_FIGURES
) {
this.marketPriceMinPrecision = 0;
}
if (
this.data.deviceType === 'mobile' &&
this.marketPrice >= NUMERICAL_PRECISION_THRESHOLD_6_FIGURES
) {
this.marketPricePrecision = 0;
}
this.netPerformance = netPerformance; this.netPerformance = netPerformance;
if ( if (
this.data.deviceType === 'mobile' && this.data.deviceType === 'mobile' &&
this.netPerformance >= NUMERICAL_PRECISION_THRESHOLD this.netPerformance >= NUMERICAL_PRECISION_THRESHOLD_6_FIGURES
) { ) {
this.netPerformancePrecision = 0; this.netPerformancePrecision = 0;
} }
@ -342,13 +383,21 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
this.netPerformancePercentWithCurrencyEffect = this.netPerformancePercentWithCurrencyEffect =
netPerformancePercentWithCurrencyEffect; netPerformancePercentWithCurrencyEffect;
if (
this.data.deviceType === 'mobile' &&
this.netPerformancePercentWithCurrencyEffect >=
NUMERICAL_PRECISION_THRESHOLD_3_FIGURES
) {
this.netPerformancePercentWithCurrencyEffectPrecision = 0;
}
this.netPerformanceWithCurrencyEffect = this.netPerformanceWithCurrencyEffect =
netPerformanceWithCurrencyEffect; netPerformanceWithCurrencyEffect;
if ( if (
this.data.deviceType === 'mobile' && this.data.deviceType === 'mobile' &&
this.netPerformanceWithCurrencyEffect >= this.netPerformanceWithCurrencyEffect >=
NUMERICAL_PRECISION_THRESHOLD NUMERICAL_PRECISION_THRESHOLD_5_FIGURES
) { ) {
this.netPerformanceWithCurrencyEffectPrecision = 0; this.netPerformanceWithCurrencyEffectPrecision = 0;
} }

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

@ -77,6 +77,7 @@
[colorizeSign]="true" [colorizeSign]="true"
[isPercent]="true" [isPercent]="true"
[locale]="data.locale" [locale]="data.locale"
[precision]="netPerformancePercentWithCurrencyEffectPrecision"
[value]="netPerformancePercentWithCurrencyEffect" [value]="netPerformancePercentWithCurrencyEffect"
> >
@if ( @if (
@ -95,6 +96,7 @@
size="medium" size="medium"
[isCurrency]="true" [isCurrency]="true"
[locale]="data.locale" [locale]="data.locale"
[precision]="averagePricePrecision"
[unit]="SymbolProfile?.currency" [unit]="SymbolProfile?.currency"
[value]="averagePrice" [value]="averagePrice"
>Average Unit Price</gf-value >Average Unit Price</gf-value
@ -106,6 +108,7 @@
size="medium" size="medium"
[isCurrency]="true" [isCurrency]="true"
[locale]="data.locale" [locale]="data.locale"
[precision]="marketPricePrecision"
[unit]="SymbolProfile?.currency" [unit]="SymbolProfile?.currency"
[value]="marketPrice" [value]="marketPrice"
>Market Price</gf-value >Market Price</gf-value
@ -122,6 +125,7 @@
marketPriceMin?.toFixed(2) === marketPrice?.toFixed(2) && marketPriceMin?.toFixed(2) === marketPrice?.toFixed(2) &&
marketPriceMax?.toFixed(2) !== marketPriceMin?.toFixed(2) marketPriceMax?.toFixed(2) !== marketPriceMin?.toFixed(2)
}" }"
[precision]="marketPriceMinPrecision"
[unit]="SymbolProfile?.currency" [unit]="SymbolProfile?.currency"
[value]="marketPriceMin" [value]="marketPriceMin"
>Minimum Price</gf-value >Minimum Price</gf-value
@ -138,6 +142,7 @@
marketPriceMax?.toFixed(2) === marketPrice?.toFixed(2) && marketPriceMax?.toFixed(2) === marketPrice?.toFixed(2) &&
marketPriceMax?.toFixed(2) !== marketPriceMin?.toFixed(2) marketPriceMax?.toFixed(2) !== marketPriceMin?.toFixed(2)
}" }"
[precision]="marketPriceMaxPrecision"
[unit]="SymbolProfile?.currency" [unit]="SymbolProfile?.currency"
[value]="marketPriceMax" [value]="marketPriceMax"
>Maximum Price</gf-value >Maximum Price</gf-value
@ -208,6 +213,7 @@
<gf-value <gf-value
i18n i18n
size="medium" size="medium"
[deviceType]="data.deviceType"
[isDate]="true" [isDate]="true"
[locale]="data.locale" [locale]="data.locale"
[value]="firstBuyDate" [value]="firstBuyDate"

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

@ -48,6 +48,7 @@
<gf-holdings-table <gf-holdings-table
[baseCurrency]="user?.settings?.baseCurrency" [baseCurrency]="user?.settings?.baseCurrency"
[deviceType]="deviceType" [deviceType]="deviceType"
[hasPermissionToShowQuantities]="false"
[holdings]="holdings" [holdings]="holdings"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
(holdingClicked)="onHoldingClicked($event)" (holdingClicked)="onHoldingClicked($event)"

8
apps/client/src/app/components/home-overview/home-overview.component.ts

@ -1,9 +1,9 @@
import { GfPortfolioPerformanceModule } from '@ghostfolio/client/components/portfolio-performance/portfolio-performance.module'; import { GfPortfolioPerformanceComponent } from '@ghostfolio/client/components/portfolio-performance/portfolio-performance.component';
import { LayoutService } from '@ghostfolio/client/core/layout.service'; import { LayoutService } from '@ghostfolio/client/core/layout.service';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { NUMERICAL_PRECISION_THRESHOLD } from '@ghostfolio/common/config'; import { NUMERICAL_PRECISION_THRESHOLD_6_FIGURES } from '@ghostfolio/common/config';
import { import {
AssetProfileIdentifier, AssetProfileIdentifier,
LineChartItem, LineChartItem,
@ -32,7 +32,7 @@ import { takeUntil } from 'rxjs/operators';
imports: [ imports: [
CommonModule, CommonModule,
GfLineChartComponent, GfLineChartComponent,
GfPortfolioPerformanceModule, GfPortfolioPerformanceComponent,
MatButtonModule, MatButtonModule,
RouterModule RouterModule
], ],
@ -143,7 +143,7 @@ export class GfHomeOverviewComponent implements OnDestroy, OnInit {
if ( if (
this.deviceType === 'mobile' && this.deviceType === 'mobile' &&
this.performance.currentValueInBaseCurrency >= this.performance.currentValueInBaseCurrency >=
NUMERICAL_PRECISION_THRESHOLD NUMERICAL_PRECISION_THRESHOLD_6_FIGURES
) { ) {
this.precision = 0; this.precision = 0;
} }

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

@ -7,7 +7,6 @@ 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 { BenchmarkTrend } from '@ghostfolio/common/types';
import { GfBenchmarkComponent } from '@ghostfolio/ui/benchmark'; import { GfBenchmarkComponent } from '@ghostfolio/ui/benchmark';
import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator'; import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator';
@ -137,17 +136,7 @@ export class HomeWatchlistComponent implements OnDestroy, OnInit {
.fetchWatchlist() .fetchWatchlist()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ watchlist }) => { .subscribe(({ watchlist }) => {
this.watchlist = watchlist.map( this.watchlist = watchlist;
({ dataSource, marketCondition, name, performances, symbol }) => ({
dataSource,
marketCondition,
name,
performances,
symbol,
trend50d: 'UNKNOWN' as BenchmarkTrend,
trend200d: 'UNKNOWN' as BenchmarkTrend
})
);
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
}); });

11
apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.component.ts

@ -6,6 +6,7 @@ import {
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service'; import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
import { ChangeDetectionStrategy, Component, Inject } from '@angular/core'; import { ChangeDetectionStrategy, Component, Inject } from '@angular/core';
import { FormControl, Validators } from '@angular/forms';
import { MatCheckboxChange } from '@angular/material/checkbox'; import { MatCheckboxChange } from '@angular/material/checkbox';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
@ -20,6 +21,10 @@ import { eyeOffOutline, eyeOutline } from 'ionicons/icons';
standalone: false standalone: false
}) })
export class LoginWithAccessTokenDialog { export class LoginWithAccessTokenDialog {
public accessTokenFormControl = new FormControl(
this.data.accessToken,
Validators.required
);
public isAccessTokenHidden = true; public isAccessTokenHidden = true;
public constructor( public constructor(
@ -45,8 +50,10 @@ export class LoginWithAccessTokenDialog {
} }
public onLoginWithAccessToken() { public onLoginWithAccessToken() {
if (this.data.accessToken) { if (this.accessTokenFormControl.valid) {
this.dialogRef.close(this.data); this.dialogRef.close({
accessToken: this.accessTokenFormControl.value
});
} }
} }

9
apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.html

@ -11,9 +11,8 @@
<mat-label i18n>Security Token</mat-label> <mat-label i18n>Security Token</mat-label>
<input <input
matInput matInput
name="password" [formControl]="accessTokenFormControl"
[type]="isAccessTokenHidden ? 'password' : 'text'" [type]="isAccessTokenHidden ? 'password' : 'text'"
[(ngModel)]="data.accessToken"
/> />
<button <button
mat-button mat-button
@ -65,8 +64,10 @@
<button <button
color="primary" color="primary"
mat-flat-button mat-flat-button
[disabled]="!data.accessToken" [disabled]="
[mat-dialog-close]="data" !(accessTokenFormControl.dirty && accessTokenFormControl.valid)
"
(click)="onLoginWithAccessToken()"
> >
<ng-container i18n>Sign in</ng-container> <ng-container i18n>Sign in</ng-container>
</button> </button>

7
apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.module.ts

@ -1,7 +1,7 @@
import { TextFieldModule } from '@angular/cdk/text-field'; import { TextFieldModule } from '@angular/cdk/text-field';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox'; import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatDialogModule } from '@angular/material/dialog'; import { MatDialogModule } from '@angular/material/dialog';
@ -9,15 +9,14 @@ import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input'; import { MatInputModule } from '@angular/material/input';
import { IonIcon } from '@ionic/angular/standalone'; import { IonIcon } from '@ionic/angular/standalone';
import { GfDialogHeaderModule } from '../dialog-header/dialog-header.module'; import { GfDialogHeaderComponent } from '../dialog-header/dialog-header.component';
import { LoginWithAccessTokenDialog } from './login-with-access-token-dialog.component'; import { LoginWithAccessTokenDialog } from './login-with-access-token-dialog.component';
@NgModule({ @NgModule({
declarations: [LoginWithAccessTokenDialog], declarations: [LoginWithAccessTokenDialog],
imports: [ imports: [
CommonModule, CommonModule,
FormsModule, GfDialogHeaderComponent,
GfDialogHeaderModule,
IonIcon, IonIcon,
MatButtonModule, MatButtonModule,
MatCheckboxModule, MatCheckboxModule,

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

@ -8,7 +8,9 @@ import {
PortfolioPerformance, PortfolioPerformance,
ResponseError ResponseError
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { GfValueComponent } from '@ghostfolio/ui/value';
import { CommonModule } from '@angular/common';
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
Component, Component,
@ -17,19 +19,21 @@ import {
OnChanges, OnChanges,
ViewChild ViewChild
} from '@angular/core'; } from '@angular/core';
import { IonIcon } from '@ionic/angular/standalone';
import { CountUp } from 'countup.js'; import { CountUp } from 'countup.js';
import { addIcons } from 'ionicons'; import { addIcons } from 'ionicons';
import { timeOutline } from 'ionicons/icons'; import { timeOutline } from 'ionicons/icons';
import { isNumber } from 'lodash'; import { isNumber } from 'lodash';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
@Component({ @Component({
selector: 'gf-portfolio-performance',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './portfolio-performance.component.html', imports: [CommonModule, GfValueComponent, IonIcon, NgxSkeletonLoaderModule],
selector: 'gf-portfolio-performance',
styleUrls: ['./portfolio-performance.component.scss'], styleUrls: ['./portfolio-performance.component.scss'],
standalone: false templateUrl: './portfolio-performance.component.html'
}) })
export class PortfolioPerformanceComponent implements OnChanges { export class GfPortfolioPerformanceComponent implements OnChanges {
@Input() deviceType: string; @Input() deviceType: string;
@Input() errors: ResponseError['errors']; @Input() errors: ResponseError['errors'];
@Input() isAllTimeHigh: boolean; @Input() isAllTimeHigh: boolean;

16
apps/client/src/app/components/portfolio-performance/portfolio-performance.module.ts

@ -1,16 +0,0 @@
import { GfValueComponent } from '@ghostfolio/ui/value';
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { IonIcon } from '@ionic/angular/standalone';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { PortfolioPerformanceComponent } from './portfolio-performance.component';
@NgModule({
declarations: [PortfolioPerformanceComponent],
exports: [PortfolioPerformanceComponent],
imports: [CommonModule, GfValueComponent, IonIcon, NgxSkeletonLoaderModule],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class GfPortfolioPerformanceModule {}

16
apps/client/src/app/components/rule/rule.component.html

@ -64,14 +64,24 @@
<mat-menu #rulesMenu="matMenu" xPosition="before"> <mat-menu #rulesMenu="matMenu" xPosition="before">
@if (rule?.isActive && rule?.configuration) { @if (rule?.isActive && rule?.configuration) {
<button mat-menu-item (click)="onCustomizeRule(rule)"> <button mat-menu-item (click)="onCustomizeRule(rule)">
<ng-container i18n>Customize</ng-container>... <span class="align-items-center d-flex">
<ion-icon class="mr-2" name="options-outline" />
<span><ng-container i18n>Customize</ng-container>...</span>
</span>
</button> </button>
<hr class="my-0" />
} }
<button mat-menu-item (click)="onUpdateRule(rule)"> <button mat-menu-item (click)="onUpdateRule(rule)">
@if (rule?.isActive) { @if (rule?.isActive) {
<ng-container i18n>Deactivate</ng-container> <span class="align-items-center d-flex">
<ion-icon class="mr-2" name="remove-circle-outline" />
<span i18n>Deactivate</span>
</span>
} @else { } @else {
<ng-container i18n>Activate</ng-container> <span class="align-items-center d-flex">
<ion-icon class="mr-2" name="add-circle-outline" />
<span i18n>Activate</span>
</span>
} }
</button> </button>
</mat-menu> </mat-menu>

4
apps/client/src/app/components/rule/rule.component.ts

@ -16,8 +16,10 @@ import {
import { MatDialog } from '@angular/material/dialog'; import { MatDialog } from '@angular/material/dialog';
import { addIcons } from 'ionicons'; import { addIcons } from 'ionicons';
import { import {
addCircleOutline,
checkmarkCircleOutline, checkmarkCircleOutline,
ellipsisHorizontal, ellipsisHorizontal,
optionsOutline,
removeCircleOutline, removeCircleOutline,
warningOutline warningOutline
} from 'ionicons/icons'; } from 'ionicons/icons';
@ -50,8 +52,10 @@ export class RuleComponent implements OnInit {
private dialog: MatDialog private dialog: MatDialog
) { ) {
addIcons({ addIcons({
addCircleOutline,
checkmarkCircleOutline, checkmarkCircleOutline,
ellipsisHorizontal, ellipsisHorizontal,
optionsOutline,
removeCircleOutline, removeCircleOutline,
warningOutline warningOutline
}); });

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

@ -55,12 +55,18 @@
} }
</div> </div>
<div class="justify-content-end" mat-dialog-actions> <div class="justify-content-end" mat-dialog-actions>
<button i18n mat-button type="button" (click)="onCancel()">Cancel</button> <button mat-button type="button" (click)="onCancel()">
@if (accessForm.dirty) {
<ng-container i18n>Cancel</ng-container>
} @else {
<ng-container i18n>Close</ng-container>
}
</button>
<button <button
color="primary" color="primary"
mat-flat-button mat-flat-button
type="submit" type="submit"
[disabled]="!accessForm.valid" [disabled]="!(accessForm.dirty && accessForm.valid)"
> >
<ng-container i18n>Save</ng-container> <ng-container i18n>Save</ng-container>
</button> </button>

10
apps/client/src/app/core/notification/prompt-dialog/prompt-dialog.component.ts

@ -1,5 +1,5 @@
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatDialogModule, MatDialogRef } from '@angular/material/dialog'; import { MatDialogModule, MatDialogRef } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field'; import { MatFormFieldModule } from '@angular/material/form-field';
@ -7,11 +7,11 @@ import { MatInputModule } from '@angular/material/input';
@Component({ @Component({
imports: [ imports: [
FormsModule,
MatButtonModule, MatButtonModule,
MatDialogModule, MatDialogModule,
MatFormFieldModule, MatFormFieldModule,
MatInputModule MatInputModule,
ReactiveFormsModule
], ],
selector: 'gf-prompt-dialog', selector: 'gf-prompt-dialog',
templateUrl: './prompt-dialog.html' templateUrl: './prompt-dialog.html'
@ -20,8 +20,8 @@ export class GfPromptDialogComponent {
public confirmLabel: string; public confirmLabel: string;
public defaultValue: string; public defaultValue: string;
public discardLabel: string; public discardLabel: string;
public formControl = new FormControl('');
public title: string; public title: string;
public value: string;
public valueLabel: string; public valueLabel: string;
public constructor(public dialogRef: MatDialogRef<GfPromptDialogComponent>) {} public constructor(public dialogRef: MatDialogRef<GfPromptDialogComponent>) {}
@ -36,8 +36,8 @@ export class GfPromptDialogComponent {
this.confirmLabel = aParams.confirmLabel; this.confirmLabel = aParams.confirmLabel;
this.defaultValue = aParams.defaultValue; this.defaultValue = aParams.defaultValue;
this.discardLabel = aParams.discardLabel; this.discardLabel = aParams.discardLabel;
this.formControl.setValue(aParams.defaultValue);
this.title = aParams.title; this.title = aParams.title;
this.value = aParams.defaultValue;
this.valueLabel = aParams.valueLabel; this.valueLabel = aParams.valueLabel;
} }
} }

9
apps/client/src/app/core/notification/prompt-dialog/prompt-dialog.html

@ -7,7 +7,7 @@
@if (valueLabel) { @if (valueLabel) {
<mat-label>{{ valueLabel }}</mat-label> <mat-label>{{ valueLabel }}</mat-label>
} }
<input matInput [(ngModel)]="value" /> <input matInput [formControl]="formControl" />
</mat-form-field> </mat-form-field>
</div> </div>
@ -15,7 +15,12 @@
<button mat-button (click)="dialogRef.close('discard')"> <button mat-button (click)="dialogRef.close('discard')">
{{ discardLabel }} {{ discardLabel }}
</button> </button>
<button color="primary" mat-flat-button (click)="dialogRef.close(value)"> <button
color="primary"
mat-flat-button
[disabled]="!(formControl.dirty && formControl.valid)"
(click)="dialogRef.close(formControl.value)"
>
{{ confirmLabel }} {{ confirmLabel }}
</button> </button>
</div> </div>

10
apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.html

@ -100,12 +100,18 @@
} }
</div> </div>
<div class="justify-content-end" mat-dialog-actions> <div class="justify-content-end" mat-dialog-actions>
<button i18n mat-button type="button" (click)="onCancel()">Cancel</button> <button mat-button type="button" (click)="onCancel()">
@if (accountForm.dirty) {
<ng-container i18n>Cancel</ng-container>
} @else {
<ng-container i18n>Close</ng-container>
}
</button>
<button <button
color="primary" color="primary"
mat-flat-button mat-flat-button
type="submit" type="submit"
[disabled]="!accountForm.valid" [disabled]="!(accountForm.dirty && accountForm.valid)"
> >
<ng-container i18n>Save</ng-container> <ng-container i18n>Save</ng-container>
</button> </button>

10
apps/client/src/app/pages/i18n/i18n-page.html

@ -67,6 +67,16 @@
($&#123;fixedIncomeValueRatio&#125;%) is within the range of ($&#123;fixedIncomeValueRatio&#125;%) is within the range of
$&#123;thresholdMin&#125;% and $&#123;thresholdMax&#125;% $&#123;thresholdMin&#125;% and $&#123;thresholdMax&#125;%
</li> </li>
<li i18n="@@rule.liquidity.category">Liquidity</li>
<li i18n="@@rule.liquidityBuyingPower">Buying Power</li>
<li i18n="@@rule.liquidityBuyingPower.false">
Your buying power is below $&#123;thresholdMin&#125;
$&#123;baseCurrency&#125;
</li>
<li i18n="@@rule.liquidityBuyingPower.true">
Your buying power exceeds $&#123;thresholdMin&#125;
$&#123;baseCurrency&#125;
</li>
<li i18n="@@rule.currencyClusterRisk.category">Currency Cluster Risks</li> <li i18n="@@rule.currencyClusterRisk.category">Currency Cluster Risks</li>
<li i18n="@@rule.currencyClusterRiskBaseCurrencyCurrentInvestment"> <li i18n="@@rule.currencyClusterRiskBaseCurrencyCurrentInvestment">
Investment: Base Currency Investment: Base Currency

16
apps/client/src/app/pages/landing/landing-page-routing.module.ts

@ -1,16 +0,0 @@
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { LandingPageComponent } from './landing-page.component';
const routes: Routes = [
{ path: '', component: LandingPageComponent, canActivate: [AuthGuard] }
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class LandingPageRoutingModule {}

37
apps/client/src/app/pages/landing/landing-page.component.ts

@ -1,21 +1,46 @@
import { GfWorldMapChartModule } from '@ghostfolio/client/components/world-map-chart/world-map-chart.module';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { Statistics } from '@ghostfolio/common/interfaces'; import { Statistics } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { publicRoutes } from '@ghostfolio/common/routes/routes'; import { publicRoutes } from '@ghostfolio/common/routes/routes';
import { GfCarouselComponent } from '@ghostfolio/ui/carousel';
import { GfLogoComponent } from '@ghostfolio/ui/logo';
import { GfValueComponent } from '@ghostfolio/ui/value';
import { CommonModule } from '@angular/common';
import { Component, OnDestroy, OnInit } from '@angular/core'; import { Component, OnDestroy, OnInit } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { RouterModule } from '@angular/router';
import { IonIcon } from '@ionic/angular/standalone';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { addIcons } from 'ionicons';
import {
cloudDownloadOutline,
peopleOutline,
starOutline
} from 'ionicons/icons';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
@Component({ @Component({
host: { class: 'page' }, host: { class: 'page' },
imports: [
CommonModule,
GfCarouselComponent,
GfLogoComponent,
GfValueComponent,
GfWorldMapChartModule,
IonIcon,
MatButtonModule,
MatCardModule,
RouterModule
],
selector: 'gf-landing-page', selector: 'gf-landing-page',
styleUrls: ['./landing-page.scss'], styleUrls: ['./landing-page.scss'],
templateUrl: './landing-page.html', templateUrl: './landing-page.html'
standalone: false
}) })
export class LandingPageComponent implements OnDestroy, OnInit { export class GfLandingPageComponent implements OnDestroy, OnInit {
public countriesOfSubscribersMap: { public countriesOfSubscribersMap: {
[code: string]: { value: number }; [code: string]: { value: number };
} = {}; } = {};
@ -118,6 +143,12 @@ export class LandingPageComponent implements OnDestroy, OnInit {
); );
this.statistics = statistics; this.statistics = statistics;
addIcons({
cloudDownloadOutline,
peopleOutline,
starOutline
});
} }
public ngOnInit() { public ngOnInit() {

30
apps/client/src/app/pages/landing/landing-page.module.ts

@ -1,30 +0,0 @@
import { GfWorldMapChartModule } from '@ghostfolio/client/components/world-map-chart/world-map-chart.module';
import { GfCarouselComponent } from '@ghostfolio/ui/carousel';
import { GfLogoComponent } from '@ghostfolio/ui/logo';
import { GfValueComponent } from '@ghostfolio/ui/value';
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { RouterModule } from '@angular/router';
import { LandingPageRoutingModule } from './landing-page-routing.module';
import { LandingPageComponent } from './landing-page.component';
@NgModule({
declarations: [LandingPageComponent],
imports: [
CommonModule,
GfCarouselComponent,
GfLogoComponent,
GfValueComponent,
GfWorldMapChartModule,
LandingPageRoutingModule,
MatButtonModule,
MatCardModule,
RouterModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class LandingPageModule {}

15
apps/client/src/app/pages/landing/landing-page.routes.ts

@ -0,0 +1,15 @@
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { publicRoutes } from '@ghostfolio/common/routes/routes';
import { Routes } from '@angular/router';
import { GfLandingPageComponent } from './landing-page.component';
export const routes: Routes = [
{
canActivate: [AuthGuard],
component: GfLandingPageComponent,
path: '',
title: publicRoutes.register.title
}
];

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

@ -1,8 +1,12 @@
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto'; import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { UpdateOrderDto } from '@ghostfolio/api/app/order/update-order.dto'; import { UpdateOrderDto } from '@ghostfolio/api/app/order/update-order.dto';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { ASSET_CLASS_MAPPING } from '@ghostfolio/common/config';
import { getDateFormatString } from '@ghostfolio/common/helper'; import { getDateFormatString } from '@ghostfolio/common/helper';
import { LookupItem } from '@ghostfolio/common/interfaces'; import {
AssetClassSelectorOption,
LookupItem
} from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { GfEntityLogoComponent } from '@ghostfolio/ui/entity-logo'; import { GfEntityLogoComponent } from '@ghostfolio/ui/entity-logo';
import { translate } from '@ghostfolio/ui/i18n'; import { translate } from '@ghostfolio/ui/i18n';
@ -37,7 +41,7 @@ import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input'; import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select'; import { MatSelectModule } from '@angular/material/select';
import { IonIcon } from '@ionic/angular/standalone'; import { IonIcon } from '@ionic/angular/standalone';
import { AssetClass, AssetSubClass, Tag, Type } from '@prisma/client'; import { AssetClass, Tag, Type } from '@prisma/client';
import { isAfter, isToday } from 'date-fns'; import { isAfter, isToday } from 'date-fns';
import { addIcons } from 'ionicons'; import { addIcons } from 'ionicons';
import { calendarClearOutline, refreshOutline } from 'ionicons/icons'; import { calendarClearOutline, refreshOutline } from 'ionicons/icons';
@ -73,12 +77,16 @@ import { CreateOrUpdateActivityDialogParams } from './interfaces/interfaces';
}) })
export class GfCreateOrUpdateActivityDialog implements OnDestroy { export class GfCreateOrUpdateActivityDialog implements OnDestroy {
public activityForm: FormGroup; public activityForm: FormGroup;
public assetClasses = Object.keys(AssetClass).map((assetClass) => {
return { id: assetClass, label: translate(assetClass) }; public assetClassOptions: AssetClassSelectorOption[] = Object.keys(AssetClass)
}); .map((id) => {
public assetSubClasses = Object.keys(AssetSubClass).map((assetSubClass) => { return { id, label: translate(id) } as AssetClassSelectorOption;
return { id: assetSubClass, label: translate(assetSubClass) }; })
}); .sort((a, b) => {
return a.label.localeCompare(b.label);
});
public assetSubClassOptions: AssetClassSelectorOption[] = [];
public currencies: string[] = []; public currencies: string[] = [];
public currencyOfAssetProfile: string; public currencyOfAssetProfile: string;
public currentMarketPrice = null; public currentMarketPrice = null;
@ -273,6 +281,26 @@ export class GfCreateOrUpdateActivityDialog implements OnDestroy {
} }
}); });
this.activityForm
.get('assetClass')
.valueChanges.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((assetClass) => {
const assetSubClasses = ASSET_CLASS_MAPPING.get(assetClass) ?? [];
this.assetSubClassOptions = assetSubClasses
.map((assetSubClass) => {
return {
id: assetSubClass,
label: translate(assetSubClass)
};
})
.sort((a, b) => a.label.localeCompare(b.label));
this.activityForm.get('assetSubClass').setValue(null);
this.changeDetectorRef.markForCheck();
});
this.activityForm.get('date').valueChanges.subscribe(() => { this.activityForm.get('date').valueChanges.subscribe(() => {
if (isToday(this.activityForm.get('date').value)) { if (isToday(this.activityForm.get('date').value)) {
this.activityForm.get('updateAccountBalance').enable(); this.activityForm.get('updateAccountBalance').enable();
@ -495,7 +523,9 @@ export class GfCreateOrUpdateActivityDialog implements OnDestroy {
? undefined ? undefined
: this.activityForm.get('searchSymbol')?.value?.symbol) ?? : this.activityForm.get('searchSymbol')?.value?.symbol) ??
this.activityForm.get('name')?.value, this.activityForm.get('name')?.value,
tags: this.activityForm.get('tags').value, tags: this.activityForm.get('tags').value?.map(({ id }) => {
return id;
}),
type: this.activityForm.get('type').value, type: this.activityForm.get('type').value,
unitPrice: this.activityForm.get('unitPrice').value unitPrice: this.activityForm.get('unitPrice').value
}; };

28
apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.html

@ -290,9 +290,12 @@
<mat-label i18n>Asset Class</mat-label> <mat-label i18n>Asset Class</mat-label>
<mat-select formControlName="assetClass"> <mat-select formControlName="assetClass">
<mat-option [value]="null" /> <mat-option [value]="null" />
@for (assetClass of assetClasses; track assetClass) { @for (
<mat-option [value]="assetClass.id">{{ assetClassOption of assetClassOptions;
assetClass.label track assetClassOption.id
) {
<mat-option [value]="assetClassOption.id">{{
assetClassOption.label
}}</mat-option> }}</mat-option>
} }
</mat-select> </mat-select>
@ -306,9 +309,12 @@
<mat-label i18n>Asset Sub Class</mat-label> <mat-label i18n>Asset Sub Class</mat-label>
<mat-select formControlName="assetSubClass"> <mat-select formControlName="assetSubClass">
<mat-option [value]="null" /> <mat-option [value]="null" />
@for (assetSubClass of assetSubClasses; track assetSubClass) { @for (
<mat-option [value]="assetSubClass.id">{{ assetSubClassOption of assetSubClassOptions;
assetSubClass.label track assetSubClassOption.id
) {
<mat-option [value]="assetSubClassOption.id">{{
assetSubClassOption.label
}}</mat-option> }}</mat-option>
} }
</mat-select> </mat-select>
@ -335,12 +341,18 @@
[value]="total" [value]="total"
/> />
<div> <div>
<button i18n mat-button type="button" (click)="onCancel()">Cancel</button> <button mat-button type="button" (click)="onCancel()">
@if (activityForm.dirty) {
<ng-container i18n>Cancel</ng-container>
} @else {
<ng-container i18n>Close</ng-container>
}
</button>
<button <button
color="primary" color="primary"
mat-flat-button mat-flat-button
type="submit" type="submit"
[disabled]="!activityForm.valid" [disabled]="!(activityForm.dirty && activityForm.valid)"
> >
<ng-container i18n>Save</ng-container> <ng-container i18n>Save</ng-container>
</button> </button>

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

@ -1,8 +1,9 @@
import { CreateTagDto } from '@ghostfolio/api/app/endpoints/tags/create-tag.dto';
import { CreateAccountWithBalancesDto } from '@ghostfolio/api/app/import/create-account-with-balances.dto'; import { CreateAccountWithBalancesDto } from '@ghostfolio/api/app/import/create-account-with-balances.dto';
import { CreateAssetProfileWithMarketDataDto } from '@ghostfolio/api/app/import/create-asset-profile-with-market-data.dto'; import { CreateAssetProfileWithMarketDataDto } from '@ghostfolio/api/app/import/create-asset-profile-with-market-data.dto';
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { GfDialogFooterModule } from '@ghostfolio/client/components/dialog-footer/dialog-footer.module'; import { GfDialogFooterComponent } from '@ghostfolio/client/components/dialog-footer/dialog-footer.component';
import { GfDialogHeaderModule } from '@ghostfolio/client/components/dialog-header/dialog-header.module'; import { GfDialogHeaderComponent } from '@ghostfolio/client/components/dialog-header/dialog-header.component';
import { GfFileDropModule } from '@ghostfolio/client/directives/file-drop/file-drop.module'; import { GfFileDropModule } from '@ghostfolio/client/directives/file-drop/file-drop.module';
import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module'; import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
@ -57,8 +58,8 @@ import { ImportActivitiesDialogParams } from './interfaces/interfaces';
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ imports: [
GfActivitiesTableComponent, GfActivitiesTableComponent,
GfDialogFooterModule, GfDialogFooterComponent,
GfDialogHeaderModule, GfDialogHeaderComponent,
GfFileDropModule, GfFileDropModule,
GfSymbolModule, GfSymbolModule,
IonIcon, IonIcon,
@ -94,6 +95,7 @@ export class GfImportActivitiesDialog implements OnDestroy {
public sortColumn = 'date'; public sortColumn = 'date';
public sortDirection: SortDirection = 'desc'; public sortDirection: SortDirection = 'desc';
public stepperOrientation: StepperOrientation; public stepperOrientation: StepperOrientation;
public tags: CreateTagDto[] = [];
public totalItems: number; public totalItems: number;
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
@ -169,7 +171,8 @@ export class GfImportActivitiesDialog implements OnDestroy {
await this.importActivitiesService.importSelectedActivities({ await this.importActivitiesService.importSelectedActivities({
accounts: this.accounts, accounts: this.accounts,
activities: this.selectedActivities, activities: this.selectedActivities,
assetProfiles: this.assetProfiles assetProfiles: this.assetProfiles,
tags: this.tags
}); });
this.snackBar.open( this.snackBar.open(
@ -297,6 +300,7 @@ export class GfImportActivitiesDialog implements OnDestroy {
this.accounts = content.accounts; this.accounts = content.accounts;
this.assetProfiles = content.assetProfiles; this.assetProfiles = content.assetProfiles;
this.tags = content.tags;
if (!isArray(content.activities)) { if (!isArray(content.activities)) {
if (isArray(content.orders)) { if (isArray(content.orders)) {
@ -328,7 +332,8 @@ export class GfImportActivitiesDialog implements OnDestroy {
accounts: content.accounts, accounts: content.accounts,
activities: content.activities, activities: content.activities,
assetProfiles: content.assetProfiles, assetProfiles: content.assetProfiles,
isDryRun: true isDryRun: true,
tags: content.tags
}); });
this.activities = activities; this.activities = activities;
this.dataSource = new MatTableDataSource(activities.reverse()); this.dataSource = new MatTableDataSource(activities.reverse());

54
apps/client/src/app/pages/portfolio/x-ray/x-ray-page.component.html

@ -59,6 +59,28 @@
</div> </div>
} }
</div> </div>
<div
class="mb-4"
[ngClass]="{
'd-none': liquidityRules?.length === 0
}"
>
<h4 class="align-items-center d-flex m-0">
<span i18n>Liquidity</span>
@if (user?.subscription?.type === 'Basic') {
<gf-premium-indicator class="ml-1" />
}
</h4>
<gf-rules
[hasPermissionToUpdateUserSettings]="
!hasImpersonationId && hasPermissionToUpdateUserSettings
"
[isLoading]="isLoading"
[rules]="liquidityRules"
[settings]="user?.settings?.xRayRules"
(rulesUpdated)="onRulesUpdated($event)"
/>
</div>
<div <div
class="mb-4" class="mb-4"
[ngClass]="{ [ngClass]="{
@ -73,9 +95,7 @@
</h4> </h4>
<gf-rules <gf-rules
[hasPermissionToUpdateUserSettings]=" [hasPermissionToUpdateUserSettings]="
!hasImpersonationId && !hasImpersonationId && hasPermissionToUpdateUserSettings
hasPermissionToUpdateUserSettings &&
user?.settings?.isExperimentalFeatures
" "
[isLoading]="isLoading" [isLoading]="isLoading"
[rules]="emergencyFundRules" [rules]="emergencyFundRules"
@ -97,9 +117,7 @@
</h4> </h4>
<gf-rules <gf-rules
[hasPermissionToUpdateUserSettings]=" [hasPermissionToUpdateUserSettings]="
!hasImpersonationId && !hasImpersonationId && hasPermissionToUpdateUserSettings
hasPermissionToUpdateUserSettings &&
user?.settings?.isExperimentalFeatures
" "
[isLoading]="isLoading" [isLoading]="isLoading"
[rules]="currencyClusterRiskRules" [rules]="currencyClusterRiskRules"
@ -121,9 +139,7 @@
</h4> </h4>
<gf-rules <gf-rules
[hasPermissionToUpdateUserSettings]=" [hasPermissionToUpdateUserSettings]="
!hasImpersonationId && !hasImpersonationId && hasPermissionToUpdateUserSettings
hasPermissionToUpdateUserSettings &&
user?.settings?.isExperimentalFeatures
" "
[isLoading]="isLoading" [isLoading]="isLoading"
[rules]="assetClassClusterRiskRules" [rules]="assetClassClusterRiskRules"
@ -145,9 +161,7 @@
</h4> </h4>
<gf-rules <gf-rules
[hasPermissionToUpdateUserSettings]=" [hasPermissionToUpdateUserSettings]="
!hasImpersonationId && !hasImpersonationId && hasPermissionToUpdateUserSettings
hasPermissionToUpdateUserSettings &&
user?.settings?.isExperimentalFeatures
" "
[isLoading]="isLoading" [isLoading]="isLoading"
[rules]="accountClusterRiskRules" [rules]="accountClusterRiskRules"
@ -169,9 +183,7 @@
</h4> </h4>
<gf-rules <gf-rules
[hasPermissionToUpdateUserSettings]=" [hasPermissionToUpdateUserSettings]="
!hasImpersonationId && !hasImpersonationId && hasPermissionToUpdateUserSettings
hasPermissionToUpdateUserSettings &&
user?.settings?.isExperimentalFeatures
" "
[isLoading]="isLoading" [isLoading]="isLoading"
[rules]="economicMarketClusterRiskRules" [rules]="economicMarketClusterRiskRules"
@ -193,9 +205,7 @@
</h4> </h4>
<gf-rules <gf-rules
[hasPermissionToUpdateUserSettings]=" [hasPermissionToUpdateUserSettings]="
!hasImpersonationId && !hasImpersonationId && hasPermissionToUpdateUserSettings
hasPermissionToUpdateUserSettings &&
user?.settings?.isExperimentalFeatures
" "
[isLoading]="isLoading" [isLoading]="isLoading"
[rules]="regionalMarketClusterRiskRules" [rules]="regionalMarketClusterRiskRules"
@ -217,9 +227,7 @@
</h4> </h4>
<gf-rules <gf-rules
[hasPermissionToUpdateUserSettings]=" [hasPermissionToUpdateUserSettings]="
!hasImpersonationId && !hasImpersonationId && hasPermissionToUpdateUserSettings
hasPermissionToUpdateUserSettings &&
user?.settings?.isExperimentalFeatures
" "
[isLoading]="isLoading" [isLoading]="isLoading"
[rules]="feeRules" [rules]="feeRules"
@ -232,9 +240,7 @@
<h4 class="m-0" i18n>Inactive</h4> <h4 class="m-0" i18n>Inactive</h4>
<gf-rules <gf-rules
[hasPermissionToUpdateUserSettings]=" [hasPermissionToUpdateUserSettings]="
!hasImpersonationId && !hasImpersonationId && hasPermissionToUpdateUserSettings
hasPermissionToUpdateUserSettings &&
user?.settings?.isExperimentalFeatures
" "
[isLoading]="isLoading" [isLoading]="isLoading"
[rules]="inactiveRules" [rules]="inactiveRules"

6
apps/client/src/app/pages/portfolio/x-ray/x-ray-page.component.ts

@ -46,6 +46,7 @@ export class GfXRayPageComponent {
public hasPermissionToUpdateUserSettings: boolean; public hasPermissionToUpdateUserSettings: boolean;
public inactiveRules: PortfolioReportRule[]; public inactiveRules: PortfolioReportRule[];
public isLoading = false; public isLoading = false;
public liquidityRules: PortfolioReportRule[];
public regionalMarketClusterRiskRules: PortfolioReportRule[]; public regionalMarketClusterRiskRules: PortfolioReportRule[];
public statistics: PortfolioReportResponse['xRay']['statistics']; public statistics: PortfolioReportResponse['xRay']['statistics'];
public user: User; public user: User;
@ -149,6 +150,11 @@ export class GfXRayPageComponent {
return isActive; return isActive;
}) ?? null; }) ?? null;
this.liquidityRules =
rules['liquidity']?.filter(({ isActive }) => {
return isActive;
}) ?? null;
this.regionalMarketClusterRiskRules = this.regionalMarketClusterRiskRules =
rules['regionalMarketClusterRisk']?.filter(({ isActive }) => { rules['regionalMarketClusterRisk']?.filter(({ isActive }) => {
return isActive; return isActive;

21
apps/client/src/app/pages/pricing/pricing-page-routing.module.ts

@ -1,21 +0,0 @@
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { PricingPageComponent } from './pricing-page.component';
const routes: Routes = [
{
canActivate: [AuthGuard],
component: PricingPageComponent,
path: '',
title: $localize`Pricing`
}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class PricingPageRoutingModule {}

42
apps/client/src/app/pages/pricing/pricing-page.component.ts

@ -5,10 +5,27 @@ import { User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { publicRoutes } from '@ghostfolio/common/routes/routes'; import { publicRoutes } from '@ghostfolio/common/routes/routes';
import { translate } from '@ghostfolio/ui/i18n'; import { translate } from '@ghostfolio/ui/i18n';
import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator';
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; import { CommonModule } from '@angular/common';
import {
ChangeDetectorRef,
Component,
CUSTOM_ELEMENTS_SCHEMA,
OnDestroy,
OnInit
} from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatTooltipModule } from '@angular/material/tooltip';
import { RouterModule } from '@angular/router';
import { IonIcon } from '@ionic/angular/standalone';
import { addIcons } from 'ionicons'; import { addIcons } from 'ionicons';
import { checkmarkCircleOutline, checkmarkOutline } from 'ionicons/icons'; import {
checkmarkCircleOutline,
checkmarkOutline,
informationCircleOutline
} from 'ionicons/icons';
import { StringValue } from 'ms'; import { StringValue } from 'ms';
import { StripeService } from 'ngx-stripe'; import { StripeService } from 'ngx-stripe';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
@ -16,12 +33,21 @@ import { catchError, switchMap, takeUntil } from 'rxjs/operators';
@Component({ @Component({
host: { class: 'page' }, host: { class: 'page' },
imports: [
CommonModule,
GfPremiumIndicatorComponent,
IonIcon,
MatButtonModule,
MatCardModule,
MatTooltipModule,
RouterModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
selector: 'gf-pricing-page', selector: 'gf-pricing-page',
styleUrls: ['./pricing-page.scss'], styleUrls: ['./pricing-page.scss'],
templateUrl: './pricing-page.html', templateUrl: './pricing-page.html'
standalone: false
}) })
export class PricingPageComponent implements OnDestroy, OnInit { export class GfPricingPageComponent implements OnDestroy, OnInit {
public baseCurrency: string; public baseCurrency: string;
public coupon: number; public coupon: number;
public couponId: string; public couponId: string;
@ -56,7 +82,11 @@ export class PricingPageComponent implements OnDestroy, OnInit {
private stripeService: StripeService, private stripeService: StripeService,
private userService: UserService private userService: UserService
) { ) {
addIcons({ checkmarkCircleOutline, checkmarkOutline }); addIcons({
checkmarkCircleOutline,
checkmarkOutline,
informationCircleOutline
});
} }
public ngOnInit() { public ngOnInit() {

28
apps/client/src/app/pages/pricing/pricing-page.module.ts

@ -1,28 +0,0 @@
import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator';
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatTooltipModule } from '@angular/material/tooltip';
import { RouterModule } from '@angular/router';
import { IonIcon } from '@ionic/angular/standalone';
import { PricingPageRoutingModule } from './pricing-page-routing.module';
import { PricingPageComponent } from './pricing-page.component';
@NgModule({
declarations: [PricingPageComponent],
imports: [
CommonModule,
GfPremiumIndicatorComponent,
IonIcon,
MatButtonModule,
MatCardModule,
MatTooltipModule,
PricingPageRoutingModule,
RouterModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class PricingPageModule {}

14
apps/client/src/app/pages/pricing/pricing-page.routes.ts

@ -0,0 +1,14 @@
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { Routes } from '@angular/router';
import { GfPricingPageComponent } from './pricing-page.component';
export const routes: Routes = [
{
canActivate: [AuthGuard],
component: GfPricingPageComponent,
path: '',
title: $localize`Pricing`
}
];

1
apps/client/src/app/pages/public/public-page.html

@ -201,6 +201,7 @@
[data]="holdings" [data]="holdings"
[deviceType]="deviceType" [deviceType]="deviceType"
[hasPermissionToOpenDetails]="false" [hasPermissionToOpenDetails]="false"
[hasPermissionToShowQuantities]="false"
[hasPermissionToShowValues]="false" [hasPermissionToShowValues]="false"
[pageSize]="7" [pageSize]="7"
/> />

22
apps/client/src/app/pages/register/register-page-routing.module.ts

@ -1,22 +0,0 @@
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { publicRoutes } from '@ghostfolio/common/routes/routes';
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { RegisterPageComponent } from './register-page.component';
const routes: Routes = [
{
canActivate: [AuthGuard],
component: RegisterPageComponent,
path: '',
title: publicRoutes.register.title
}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class RegisterPageRoutingModule {}

31
apps/client/src/app/pages/register/register-page.component.ts

@ -3,26 +3,39 @@ import { InternetIdentityService } from '@ghostfolio/client/services/internet-id
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service'; import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
import { InfoItem, LineChartItem } from '@ghostfolio/common/interfaces'; import { InfoItem, LineChartItem } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { GfLogoComponent } from '@ghostfolio/ui/logo';
import { Component, OnDestroy, OnInit } from '@angular/core'; import {
Component,
CUSTOM_ELEMENTS_SCHEMA,
OnDestroy,
OnInit
} from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog'; import { MatDialog } from '@angular/material/dialog';
import { Router } from '@angular/router'; import { Router, RouterModule } from '@angular/router';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators'; import { takeUntil } from 'rxjs/operators';
import { ShowAccessTokenDialogParams } from './show-access-token-dialog/interfaces/interfaces'; import { ShowAccessTokenDialogParams } from './show-access-token-dialog/interfaces/interfaces';
import { ShowAccessTokenDialog } from './show-access-token-dialog/show-access-token-dialog.component'; import { ShowAccessTokenDialog } from './show-access-token-dialog/show-access-token-dialog.component';
import { ShowAccessTokenDialogModule } from './show-access-token-dialog/show-access-token-dialog.module';
@Component({ @Component({
host: { class: 'page' }, host: { class: 'page' },
imports: [
GfLogoComponent,
MatButtonModule,
RouterModule,
ShowAccessTokenDialogModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
selector: 'gf-register-page', selector: 'gf-register-page',
styleUrls: ['./register-page.scss'], styleUrls: ['./register-page.scss'],
templateUrl: './register-page.html', templateUrl: './register-page.html'
standalone: false
}) })
export class RegisterPageComponent implements OnDestroy, OnInit { export class GfRegisterPageComponent implements OnDestroy, OnInit {
public demoAuthToken: string;
public deviceType: string; public deviceType: string;
public hasPermissionForSocialLogin: boolean; public hasPermissionForSocialLogin: boolean;
public hasPermissionForSubscription: boolean; public hasPermissionForSubscription: boolean;
@ -46,18 +59,20 @@ export class RegisterPageComponent implements OnDestroy, OnInit {
} }
public ngOnInit() { public ngOnInit() {
const { demoAuthToken, globalPermissions } = this.dataService.fetchInfo(); const { globalPermissions } = this.dataService.fetchInfo();
this.demoAuthToken = demoAuthToken;
this.deviceType = this.deviceService.getDeviceInfo().deviceType; this.deviceType = this.deviceService.getDeviceInfo().deviceType;
this.hasPermissionForSocialLogin = hasPermission( this.hasPermissionForSocialLogin = hasPermission(
globalPermissions, globalPermissions,
permissions.enableSocialLogin permissions.enableSocialLogin
); );
this.hasPermissionForSubscription = hasPermission( this.hasPermissionForSubscription = hasPermission(
globalPermissions, globalPermissions,
permissions.enableSubscription permissions.enableSubscription
); );
this.hasPermissionToCreateUser = hasPermission( this.hasPermissionToCreateUser = hasPermission(
globalPermissions, globalPermissions,
permissions.createUserAccount permissions.createUserAccount

26
apps/client/src/app/pages/register/register-page.module.ts

@ -1,26 +0,0 @@
import { GfLogoComponent } from '@ghostfolio/ui/logo';
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
import { IonIcon } from '@ionic/angular/standalone';
import { RegisterPageRoutingModule } from './register-page-routing.module';
import { RegisterPageComponent } from './register-page.component';
import { ShowAccessTokenDialogModule } from './show-access-token-dialog/show-access-token-dialog.module';
@NgModule({
declarations: [RegisterPageComponent],
imports: [
CommonModule,
GfLogoComponent,
IonIcon,
MatButtonModule,
RegisterPageRoutingModule,
RouterModule,
ShowAccessTokenDialogModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class RegisterPageModule {}

15
apps/client/src/app/pages/register/register-page.routes.ts

@ -0,0 +1,15 @@
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { publicRoutes } from '@ghostfolio/common/routes/routes';
import { Routes } from '@angular/router';
import { GfRegisterPageComponent } from './register-page.component';
export const routes: Routes = [
{
canActivate: [AuthGuard],
component: GfRegisterPageComponent,
path: '',
title: publicRoutes.register.title
}
];

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

@ -2,7 +2,11 @@ import { UserService } from '@ghostfolio/client/services/user/user.service';
import { TabConfiguration, User } from '@ghostfolio/common/interfaces'; import { TabConfiguration, User } from '@ghostfolio/common/interfaces';
import { internalRoutes } from '@ghostfolio/common/routes/routes'; import { internalRoutes } from '@ghostfolio/common/routes/routes';
import { CommonModule } from '@angular/common';
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { MatTabsModule } from '@angular/material/tabs';
import { RouterModule } from '@angular/router';
import { IonIcon } from '@ionic/angular/standalone';
import { addIcons } from 'ionicons'; import { addIcons } from 'ionicons';
import { analyticsOutline, walletOutline } from 'ionicons/icons'; import { analyticsOutline, walletOutline } from 'ionicons/icons';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
@ -11,12 +15,12 @@ import { takeUntil } from 'rxjs/operators';
@Component({ @Component({
host: { class: 'page has-tabs' }, host: { class: 'page has-tabs' },
imports: [CommonModule, IonIcon, MatTabsModule, RouterModule],
selector: 'gf-zen-page', selector: 'gf-zen-page',
styleUrls: ['./zen-page.scss'], styleUrls: ['./zen-page.scss'],
templateUrl: './zen-page.html', templateUrl: './zen-page.html'
standalone: false
}) })
export class ZenPageComponent implements OnDestroy, OnInit { export class GfZenPageComponent implements OnDestroy, OnInit {
public deviceType: string; public deviceType: string;
public tabs: TabConfiguration[] = []; public tabs: TabConfiguration[] = [];
public user: User; public user: User;

26
apps/client/src/app/pages/zen/zen-page.module.ts

@ -1,26 +0,0 @@
import { GfHomeHoldingsComponent } from '@ghostfolio/client/components/home-holdings/home-holdings.component';
import { GfHomeOverviewComponent } from '@ghostfolio/client/components/home-overview/home-overview.component';
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatTabsModule } from '@angular/material/tabs';
import { RouterModule } from '@angular/router';
import { IonIcon } from '@ionic/angular/standalone';
import { ZenPageRoutingModule } from './zen-page-routing.module';
import { ZenPageComponent } from './zen-page.component';
@NgModule({
declarations: [ZenPageComponent],
imports: [
CommonModule,
GfHomeHoldingsComponent,
GfHomeOverviewComponent,
IonIcon,
MatTabsModule,
RouterModule,
ZenPageRoutingModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class ZenPageModule {}

15
apps/client/src/app/pages/zen/zen-page-routing.module.ts → apps/client/src/app/pages/zen/zen-page.routes.ts

@ -3,12 +3,11 @@ import { GfHomeOverviewComponent } from '@ghostfolio/client/components/home-over
import { AuthGuard } from '@ghostfolio/client/core/auth.guard'; import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { internalRoutes } from '@ghostfolio/common/routes/routes'; import { internalRoutes } from '@ghostfolio/common/routes/routes';
import { NgModule } from '@angular/core'; import { Routes } from '@angular/router';
import { RouterModule, Routes } from '@angular/router';
import { ZenPageComponent } from './zen-page.component'; import { GfZenPageComponent } from './zen-page.component';
const routes: Routes = [ export const routes: Routes = [
{ {
canActivate: [AuthGuard], canActivate: [AuthGuard],
children: [ children: [
@ -22,14 +21,8 @@ const routes: Routes = [
title: internalRoutes.home.subRoutes.holdings.title title: internalRoutes.home.subRoutes.holdings.title
} }
], ],
component: ZenPageComponent, component: GfZenPageComponent,
path: '', path: '',
title: internalRoutes.zen.title title: internalRoutes.zen.title
} }
]; ];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class ZenPageRoutingModule {}

26
apps/client/src/app/services/import-activities.service.ts

@ -1,3 +1,4 @@
import { CreateTagDto } from '@ghostfolio/api/app/endpoints/tags/create-tag.dto';
import { CreateAccountWithBalancesDto } from '@ghostfolio/api/app/import/create-account-with-balances.dto'; import { CreateAccountWithBalancesDto } from '@ghostfolio/api/app/import/create-account-with-balances.dto';
import { CreateAssetProfileWithMarketDataDto } from '@ghostfolio/api/app/import/create-asset-profile-with-market-data.dto'; import { CreateAssetProfileWithMarketDataDto } from '@ghostfolio/api/app/import/create-asset-profile-with-market-data.dto';
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto'; import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
@ -75,12 +76,14 @@ export class ImportActivitiesService {
accounts, accounts,
activities, activities,
assetProfiles, assetProfiles,
isDryRun = false isDryRun = false,
tags
}: { }: {
activities: CreateOrderDto[]; activities: CreateOrderDto[];
accounts?: CreateAccountWithBalancesDto[]; accounts?: CreateAccountWithBalancesDto[];
assetProfiles?: CreateAssetProfileWithMarketDataDto[]; assetProfiles?: CreateAssetProfileWithMarketDataDto[];
isDryRun?: boolean; isDryRun?: boolean;
tags?: CreateTagDto[];
}): Promise<{ }): Promise<{
activities: Activity[]; activities: Activity[];
}> { }> {
@ -89,7 +92,8 @@ export class ImportActivitiesService {
{ {
accounts, accounts,
activities, activities,
assetProfiles assetProfiles,
tags
}, },
isDryRun isDryRun
) )
@ -110,11 +114,13 @@ export class ImportActivitiesService {
public importSelectedActivities({ public importSelectedActivities({
accounts, accounts,
activities, activities,
assetProfiles assetProfiles,
tags
}: { }: {
accounts?: CreateAccountWithBalancesDto[]; accounts?: CreateAccountWithBalancesDto[];
activities: Activity[]; activities: Activity[];
assetProfiles?: CreateAssetProfileWithMarketDataDto[]; assetProfiles?: CreateAssetProfileWithMarketDataDto[];
tags?: CreateTagDto[];
}): Promise<{ }): Promise<{
activities: Activity[]; activities: Activity[];
}> { }> {
@ -124,7 +130,12 @@ export class ImportActivitiesService {
importData.push(this.convertToCreateOrderDto(activity)); importData.push(this.convertToCreateOrderDto(activity));
} }
return this.importJson({ accounts, assetProfiles, activities: importData }); return this.importJson({
accounts,
assetProfiles,
tags,
activities: importData
});
} }
private convertToCreateOrderDto({ private convertToCreateOrderDto({
@ -135,6 +146,7 @@ export class ImportActivitiesService {
fee, fee,
quantity, quantity,
SymbolProfile, SymbolProfile,
tags,
type, type,
unitPrice, unitPrice,
updateAccountBalance updateAccountBalance
@ -150,7 +162,10 @@ export class ImportActivitiesService {
currency: currency ?? SymbolProfile.currency, currency: currency ?? SymbolProfile.currency,
dataSource: SymbolProfile.dataSource, dataSource: SymbolProfile.dataSource,
date: date.toString(), date: date.toString(),
symbol: SymbolProfile.symbol symbol: SymbolProfile.symbol,
tags: tags?.map(({ id }) => {
return id;
})
}; };
} }
@ -391,6 +406,7 @@ export class ImportActivitiesService {
accounts?: CreateAccountWithBalancesDto[]; accounts?: CreateAccountWithBalancesDto[];
activities: CreateOrderDto[]; activities: CreateOrderDto[];
assetProfiles?: CreateAssetProfileWithMarketDataDto[]; assetProfiles?: CreateAssetProfileWithMarketDataDto[];
tags?: CreateTagDto[];
}, },
aIsDryRun = false aIsDryRun = false
) { ) {

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

Loading…
Cancel
Save