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
## 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
### 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.
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).

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

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

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

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

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

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

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

@ -29,7 +29,7 @@ import {
Filter
} from '@ghostfolio/common/interfaces';
import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
import { MarketDataPreset, UserWithSettings } from '@ghostfolio/common/types';
import { MarketDataPreset } from '@ghostfolio/common/types';
import {
BadRequestException,
@ -133,11 +133,8 @@ export class AdminService {
}
}
public async get({ user }: { user: UserWithSettings }): Promise<AdminData> {
const dataSources = await this.dataProviderService.getDataSources({
user,
includeGhostfolio: true
});
public async get(): Promise<AdminData> {
const dataSources = Object.values(DataSource);
const [settings, transactionCount, userCount] = await Promise.all([
this.propertyService.get(),
@ -145,24 +142,31 @@ export class AdminService {
this.countUsersWithAnalytics()
]);
const dataProviders = await Promise.all(
dataSources.map(async (dataSource) => {
const dataProviderInfo = this.dataProviderService
.getDataProvider(dataSource)
.getDataProviderInfo();
const dataProviders = (
await Promise.all(
dataSources.map(async (dataSource) => {
const assetProfileCount =
await this.prismaService.symbolProfile.count({
where: {
dataSource
}
});
const assetProfileCount = await this.prismaService.symbolProfile.count({
where: {
dataSource
if (assetProfileCount > 0 || dataSource === 'GHOSTFOLIO') {
const dataProviderInfo = this.dataProviderService
.getDataProvider(dataSource)
.getDataProviderInfo();
return {
...dataProviderInfo,
assetProfileCount
};
}
});
return {
...dataProviderInfo,
assetProfileCount
};
})
);
return null;
})
)
).filter(Boolean);
return {
dataProviders,
@ -214,12 +218,12 @@ export class AdminService {
return type === 'SEARCH_QUERY';
})?.id;
const { ASSET_SUB_CLASS: filtersByAssetSubClass } = groupBy(
filters,
({ type }) => {
return type;
}
);
const {
ASSET_SUB_CLASS: filtersByAssetSubClass,
DATA_SOURCE: filtersByDataSource
} = groupBy(filters, ({ type }) => {
return type;
});
const marketDataItems = await this.prismaService.marketData.groupBy({
_count: true,
@ -230,6 +234,10 @@ export class AdminService {
where.assetSubClass = AssetSubClass[filtersByAssetSubClass[0].id];
}
if (filtersByDataSource) {
where.dataSource = DataSource[filtersByDataSource[0].id];
}
if (searchQuery) {
where.OR = [
{ 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';
export class CreateTagDto {
@IsOptional()
@IsString()
id?: string;
@IsString()
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;
});
const allTimeHigh = await this.marketDataService.getMax({
dataSource,
symbol
});
const [allTimeHigh, trends] = await Promise.all([
this.marketDataService.getMax({
dataSource,
symbol
}),
this.benchmarkService.getBenchmarkTrends({ dataSource, symbol })
]);
const performancePercent =
this.benchmarkService.calculateChangeInPercentage(
@ -138,7 +141,9 @@ export class WatchlistService {
performancePercent,
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 { IsArray, IsOptional, ValidateNested } from 'class-validator';
import { CreateTagDto } from '../endpoints/tags/create-tag.dto';
import { CreateAccountWithBalancesDto } from './create-account-with-balances.dto';
import { CreateAssetProfileWithMarketDataDto } from './create-asset-profile-with-market-data.dto';
@ -23,4 +24,10 @@ export class ImportDataDto {
@Type(() => CreateAssetProfileWithMarketDataDto)
@ValidateNested({ each: true })
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 ?? [],
activitiesDto: importData.activities,
assetProfilesWithMarketDataDto: importData.assetProfiles ?? [],
tagsDto: importData.tags ?? [],
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 { DataGatheringModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.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';
@ -35,6 +36,7 @@ import { ImportService } from './import.service';
PrismaModule,
RedisCacheModule,
SymbolProfileModule,
TagModule,
TransformDataSourceInRequestModule,
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 { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.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 {
getAssetProfileIdentifier,
parseDate
} from '@ghostfolio/common/helper';
import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import {
AccountWithPlatform,
OrderWithAccount,
@ -46,7 +48,8 @@ export class ImportService {
private readonly orderService: OrderService,
private readonly platformService: PlatformService,
private readonly portfolioService: PortfolioService,
private readonly symbolProfileService: SymbolProfileService
private readonly symbolProfileService: SymbolProfileService,
private readonly tagService: TagService
) {}
public async getDividends({
@ -154,6 +157,7 @@ export class ImportService {
assetProfilesWithMarketDataDto,
isDryRun = false,
maxActivitiesToImport,
tagsDto,
user
}: {
accountsWithBalancesDto: ImportDataDto['accounts'];
@ -161,10 +165,12 @@ export class ImportService {
assetProfilesWithMarketDataDto: ImportDataDto['assetProfiles'];
isDryRun?: boolean;
maxActivitiesToImport: number;
tagsDto: ImportDataDto['tags'];
user: UserWithSettings;
}): Promise<Activity[]> {
const accountIdMapping: { [oldAccountId: string]: string } = {};
const assetProfileSymbolMapping: { [oldSymbol: string]: string } = {};
const tagIdMapping: { [oldTagId: string]: string } = {};
const userCurrency = user.settings.settings.baseCurrency;
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) {
if (!activity.dataSource) {
if (['FEE', 'INTEREST', 'LIABILITY'].includes(activity.type)) {
@ -313,6 +363,11 @@ export class ImportService {
if (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[] = [];
for (const activity of activitiesExtendedWithErrors) {
@ -351,6 +424,7 @@ export class ImportService {
const fee = activity.fee;
const quantity = activity.quantity;
const SymbolProfile = activity.SymbolProfile;
const tagIds = activity.tagIds ?? [];
const type = activity.type;
const unitPrice = activity.unitPrice;
@ -388,11 +462,17 @@ export class ImportService {
const validatedAccount = accounts.find(({ id }) => {
return id === accountId;
});
const validatedTags = tags.filter(({ id: tagId }) => {
return tagIds.some((activityTagId) => {
return activityTagId === tagId;
});
});
let order:
| OrderWithAccount
| (Omit<OrderWithAccount, 'Account'> & {
Account?: { id: string; name: string };
| (Omit<OrderWithAccount, 'account' | 'tags'> & {
account?: { id: string; name: string };
tags?: { id: string; name: string }[];
});
if (isDryRun) {
@ -404,7 +484,7 @@ export class ImportService {
quantity,
type,
unitPrice,
Account: validatedAccount,
account: validatedAccount,
accountId: validatedAccount?.id,
accountUserId: undefined,
createdAt: new Date(),
@ -436,6 +516,7 @@ export class ImportService {
userId: dataSource === 'MANUAL' ? user.id : undefined
},
symbolProfileId: undefined,
tags: validatedTags,
updatedAt: new Date(),
userId: user.id
};
@ -469,6 +550,9 @@ export class ImportService {
}
}
},
tags: validatedTags.map(({ id }) => {
return { id };
}),
updateAccountBalance: false,
user: { connect: { id: user.id } },
userId: user.id
@ -546,6 +630,7 @@ export class ImportService {
fee,
quantity,
symbol,
tags,
type,
unitPrice
}) => {
@ -594,7 +679,8 @@ export class ImportService {
isActive: true,
sectors: undefined,
updatedAt: undefined
}
},
tagIds: tags
};
}
);
@ -626,7 +712,7 @@ export class ImportService {
const assetProfiles: {
[assetProfileIdentifier: string]: Partial<SymbolProfile>;
} = {};
const dataSources = await this.dataProviderService.getDataSources({ user });
const dataSources = await this.dataProviderService.getDataSources();
for (const [
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 { IsAfter1970Constraint } from '@ghostfolio/common/validator-constraints/is-after-1970';
import {
AssetClass,
AssetSubClass,
DataSource,
Tag,
Type
} from '@prisma/client';
import { AssetClass, AssetSubClass, DataSource, Type } from '@prisma/client';
import { Transform, TransformFnParams } from 'class-transformer';
import {
IsArray,
@ -70,7 +64,7 @@ export class CreateOrderDto {
@IsArray()
@IsOptional()
tags?: Tag[];
tags?: string[];
@IsEnum(Type, { each: true })
type: Type;

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

@ -14,6 +14,7 @@ export interface Activity extends Order {
feeInAssetProfileCurrency: number;
feeInBaseCurrency: number;
SymbolProfile?: EnhancedSymbolProfile;
tagIds?: string[];
tags?: Tag[];
unitPriceInAssetProfileCurrency: number;
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 } },
userId: this.request.user.id
});
@ -293,6 +296,9 @@ export class OrderController {
name: data.symbol
}
},
tags: data.tags?.map((id) => {
return { id };
}),
user: { connect: { id: this.request.user.id } }
},
where: {

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

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

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

@ -1,13 +1,7 @@
import { IsCurrencyCode } from '@ghostfolio/api/validators/is-currency-code';
import { IsAfter1970Constraint } from '@ghostfolio/common/validator-constraints/is-after-1970';
import {
AssetClass,
AssetSubClass,
DataSource,
Tag,
Type
} from '@prisma/client';
import { AssetClass, AssetSubClass, DataSource, Type } from '@prisma/client';
import { Transform, TransformFnParams } from 'class-transformer';
import {
IsArray,
@ -71,7 +65,7 @@ export class UpdateOrderDto {
@IsArray()
@IsOptional()
tags?: Tag[];
tags?: string[];
@IsString()
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 { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Tag } from '@prisma/client';
import { Big } from 'big.js';
import { join } from 'path';
@ -108,6 +109,9 @@ describe('PortfolioCalculator', () => {
name: 'Bitcoin',
symbol: activity.symbol
},
tags: activity.tags?.map((id) => {
return { id } as Tag;
}),
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 { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Tag } from '@prisma/client';
import { Big } from 'big.js';
import { join } from 'path';
@ -108,6 +109,9 @@ describe('PortfolioCalculator', () => {
name: 'Bitcoin',
symbol: activity.symbol
},
tags: activity.tags?.map((id) => {
return { id } as Tag;
}),
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 { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Tag } from '@prisma/client';
import { Big } from 'big.js';
import { join } from 'path';
@ -111,6 +112,9 @@ describe('PortfolioCalculator', () => {
name: 'Novartis AG',
symbol: activity.symbol
},
tags: activity.tags?.map((id) => {
return { id } as Tag;
}),
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 { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Tag } from '@prisma/client';
import { Big } from 'big.js';
import { join } from 'path';
@ -111,6 +112,9 @@ describe('PortfolioCalculator', () => {
name: 'Novartis AG',
symbol: activity.symbol
},
tags: activity.tags?.map((id) => {
return { id } as Tag;
}),
unitPriceInAssetProfileCurrency: activity.unitPrice
}));

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

@ -413,6 +413,7 @@ export class PortfolioController {
filterByAssetClasses,
filterByDataSource,
filterByHoldingType,
filterBySearchQuery,
filterBySymbol,
filterByTags
});
@ -421,7 +422,6 @@ export class PortfolioController {
dateRange,
filters,
impersonationId,
query: filterBySearchQuery,
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 { EmergencyFundSetup } from '@ghostfolio/api/models/rules/emergency-fund/emergency-fund-setup';
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 { RegionalMarketClusterRiskEmergingMarkets } from '@ghostfolio/api/models/rules/regional-market-cluster-risk/emerging-markets';
import { RegionalMarketClusterRiskEurope } from '@ghostfolio/api/models/rules/regional-market-cluster-risk/europe';
@ -161,7 +162,7 @@ export class PortfolioService {
this.accountService.accounts({
where,
include: {
activities: true,
activities: { include: { SymbolProfile: true } },
platform: true
},
orderBy: { name: 'asc' }
@ -176,39 +177,74 @@ export class PortfolioService {
const userCurrency = this.request.user.settings.settings.baseCurrency;
return accounts.map((account) => {
let transactionCount = 0;
return Promise.all(
accounts.map(async (account) => {
let dividendInBaseCurrency = 0;
let interestInBaseCurrency = 0;
let transactionCount = 0;
for (const { isDraft } of account.activities) {
if (!isDraft) {
transactionCount += 1;
for (const {
currency,
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 =
details.accounts[account.id]?.valueInBaseCurrency ?? 0;
const valueInBaseCurrency =
details.accounts[account.id]?.valueInBaseCurrency ?? 0;
const result = {
...account,
transactionCount,
valueInBaseCurrency,
allocationInPercentage: null, // TODO
balanceInBaseCurrency: this.exchangeRateDataService.toCurrency(
account.balance,
account.currency,
userCurrency
),
value: this.exchangeRateDataService.toCurrency(
const result = {
...account,
dividendInBaseCurrency,
interestInBaseCurrency,
transactionCount,
valueInBaseCurrency,
userCurrency,
account.currency
)
};
allocationInPercentage: 0,
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({
@ -220,12 +256,30 @@ export class PortfolioService {
userId: string;
withExcludedAccounts?: boolean;
}): Promise<AccountsResponse> {
const accounts = await this.getAccounts({
let accounts = await this.getAccounts({
filters,
userId,
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 totalDividendInBaseCurrency = new Big(0);
let totalInterestInBaseCurrency = new Big(0);
let totalValueInBaseCurrency = new Big(0);
let transactionCount = 0;
@ -233,16 +287,33 @@ export class PortfolioService {
totalBalanceInBaseCurrency = totalBalanceInBaseCurrency.plus(
account.balanceInBaseCurrency
);
totalDividendInBaseCurrency = totalDividendInBaseCurrency.plus(
account.dividendInBaseCurrency
);
totalInterestInBaseCurrency = totalInterestInBaseCurrency.plus(
account.interestInBaseCurrency
);
totalValueInBaseCurrency = totalValueInBaseCurrency.plus(
account.valueInBaseCurrency
);
transactionCount += account.transactionCount;
}
for (const account of accounts) {
account.allocationInPercentage =
totalValueInBaseCurrency.toNumber() > Number.EPSILON
? Big(account.valueInBaseCurrency)
.div(totalValueInBaseCurrency)
.toNumber()
: 0;
}
return {
accounts,
transactionCount,
totalBalanceInBaseCurrency: totalBalanceInBaseCurrency.toNumber(),
totalDividendInBaseCurrency: totalDividendInBaseCurrency.toNumber(),
totalInterestInBaseCurrency: totalInterestInBaseCurrency.toNumber(),
totalValueInBaseCurrency: totalValueInBaseCurrency.toNumber()
};
}
@ -276,13 +347,11 @@ export class PortfolioService {
dateRange,
filters,
impersonationId,
query,
userId
}: {
dateRange: DateRange;
filters?: Filter[];
impersonationId: string;
query?: string;
userId: string;
}) {
userId = await this.getUserId(impersonationId, userId);
@ -295,13 +364,17 @@ export class PortfolioService {
let holdings = Object.values(holdingsMap);
if (query) {
const searchQuery = filters.find(({ type }) => {
return type === 'SEARCH_QUERY';
})?.id;
if (searchQuery) {
const fuse = new Fuse(holdings, {
keys: ['isin', 'name', 'symbol'],
threshold: 0.3
});
holdings = fuse.search(query).map(({ item }) => {
holdings = fuse.search(searchQuery).map(({ item }) => {
return item;
});
}
@ -1268,6 +1341,17 @@ export class PortfolioService {
],
userSettings
),
liquidity: await this.rulesService.evaluate(
[
new BuyingPower(
this.exchangeRateDataService,
this.i18nService,
summary.cash,
userSettings.language
)
],
userSettings
),
regionalMarketClusterRisk:
summary.activityCount > 0
? 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 { EmergencyFundSetup } from '@ghostfolio/api/models/rules/emergency-fund/emergency-fund-setup';
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 { RegionalMarketClusterRiskEmergingMarkets } from '@ghostfolio/api/models/rules/regional-market-cluster-risk/emerging-markets';
import { RegionalMarketClusterRiskEurope } from '@ghostfolio/api/models/rules/regional-market-cluster-risk/europe';
@ -287,6 +288,12 @@ export class UserService {
undefined,
undefined
).getSettings(user.settings.settings),
BuyingPower: new BuyingPower(
undefined,
undefined,
undefined,
undefined
).getSettings(user.settings.settings),
CurrencyClusterRiskBaseCurrencyCurrentInvestment:
new CurrencyClusterRiskBaseCurrencyCurrentInvestment(
undefined,

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

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

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

@ -67,12 +67,18 @@
<mat-menu #transactionMenu="matMenu" xPosition="before">
@if (element.type === 'PUBLIC') {
<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>
<hr class="my-0" />
}
<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>
</mat-menu>
</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 { addIcons } from 'ionicons';
import {
copyOutline,
ellipsisHorizontal,
linkOutline,
lockClosedOutline,
lockOpenOutline
lockOpenOutline,
removeCircleOutline
} from 'ionicons/icons';
import ms from 'ms';
@ -62,10 +64,12 @@ export class GfAccessTableComponent implements OnChanges {
private snackBar: MatSnackBar
) {
addIcons({
copyOutline,
ellipsisHorizontal,
linkOutline,
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 { DataService } from '@ghostfolio/client/services/data.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 {
AccountBalancesResponse,
@ -51,12 +52,18 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
public accountBalances: AccountBalancesResponse['balances'];
public activities: OrderWithAccount[];
public balance: number;
public balancePrecision = 2;
public currency: string;
public dataSource: MatTableDataSource<Activity>;
public dividendInBaseCurrency: number;
public dividendInBaseCurrencyPrecision = 2;
public equity: number;
public equityPrecision = 2;
public hasPermissionToDeleteAccountBalance: boolean;
public historicalDataItems: HistoricalDataItem[];
public holdings: PortfolioPosition[];
public interestInBaseCurrency: number;
public interestInBaseCurrencyPrecision = 2;
public isLoadingActivities: boolean;
public isLoadingChart: boolean;
public name: string;
@ -181,6 +188,8 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
({
balance,
currency,
dividendInBaseCurrency,
interestInBaseCurrency,
name,
platform,
transactionCount,
@ -188,14 +197,48 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
valueInBaseCurrency
}) => {
this.balance = balance;
if (
this.balance >= NUMERICAL_PRECISION_THRESHOLD_6_FIGURES &&
this.data.deviceType === 'mobile'
) {
this.balancePrecision = 0;
}
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)) {
this.equity = new Big(value).minus(balance).toNumber();
if (
this.data.deviceType === 'mobile' &&
this.equity >= NUMERICAL_PRECISION_THRESHOLD_6_FIGURES
) {
this.equityPrecision = 0;
}
} else {
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.platformName = platform?.name ?? '-';
this.transactionCount = transactionCount;

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

@ -20,20 +20,18 @@
</div>
</div>
@if (user?.settings?.isExperimentalFeatures) {
<div class="chart-container mb-3">
<gf-investment-chart
class="h-100"
[currency]="user?.settings?.baseCurrency"
[historicalDataItems]="historicalDataItems"
[isInPercent]="
data.hasImpersonationId || user.settings.isRestrictedView
"
[isLoading]="isLoadingChart"
[locale]="user?.settings?.locale"
/>
</div>
}
<div class="chart-container mb-3">
<gf-investment-chart
class="h-100"
[currency]="user?.settings?.baseCurrency"
[historicalDataItems]="historicalDataItems"
[isInPercent]="
data.hasImpersonationId || user.settings.isRestrictedView
"
[isLoading]="isLoadingChart"
[locale]="user?.settings?.locale"
/>
</div>
<div class="mb-3 row">
<div class="col-6 mb-3">
@ -42,6 +40,7 @@
size="medium"
[isCurrency]="true"
[locale]="user?.settings?.locale"
[precision]="balancePrecision"
[unit]="currency"
[value]="balance"
>Cash Balance</gf-value
@ -53,11 +52,36 @@
size="medium"
[isCurrency]="true"
[locale]="user?.settings?.locale"
[precision]="equityPrecision"
[unit]="currency"
[value]="equity"
>Equity</gf-value
>
</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">
<gf-value i18n size="medium" [value]="transactionCount"
>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 { GfDialogHeaderModule } from '@ghostfolio/client/components/dialog-header/dialog-header.module';
import { GfDialogFooterComponent } from '@ghostfolio/client/components/dialog-footer/dialog-footer.component';
import { GfDialogHeaderComponent } from '@ghostfolio/client/components/dialog-header/dialog-header.component';
import { GfInvestmentChartModule } from '@ghostfolio/client/components/investment-chart/investment-chart.module';
import { GfAccountBalancesComponent } from '@ghostfolio/ui/account-balances';
import { GfActivitiesTableComponent } from '@ghostfolio/ui/activities-table';
@ -22,8 +22,8 @@ import { AccountDetailDialog } from './account-detail-dialog.component';
CommonModule,
GfAccountBalancesComponent,
GfActivitiesTableComponent,
GfDialogFooterModule,
GfDialogHeaderModule,
GfDialogFooterComponent,
GfDialogHeaderComponent,
GfHoldingsTableComponent,
GfInvestmentChartModule,
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;
public activeFilters: Filter[] = [];
public allFilters: Filter[] = Object.keys(AssetSubClass)
.filter((assetSubClass) => {
return assetSubClass !== 'CASH';
})
.map((assetSubClass) => {
public allFilters: Filter[] = [
...Object.keys(AssetSubClass)
.filter((assetSubClass) => {
return assetSubClass !== 'CASH';
})
.map((assetSubClass) => {
return {
id: assetSubClass.toString(),
label: translate(assetSubClass),
type: 'ASSET_SUB_CLASS' as Filter['type']
};
}),
...Object.keys(DataSource).map((dataSource) => {
return {
id: assetSubClass.toString(),
label: translate(assetSubClass),
type: 'ASSET_SUB_CLASS' as Filter['type']
id: dataSource.toString(),
label: dataSource,
type: 'DATA_SOURCE' as Filter['type']
};
})
.concat([
{
id: 'BENCHMARKS',
label: $localize`Benchmarks`,
type: 'PRESET_ID' as Filter['type']
},
{
id: 'CURRENCIES',
label: $localize`Currencies`,
type: 'PRESET_ID' as Filter['type']
},
{
id: 'ETF_WITHOUT_COUNTRIES',
label: $localize`ETFs without Countries`,
type: 'PRESET_ID' as Filter['type']
},
{
id: 'ETF_WITHOUT_SECTORS',
label: $localize`ETFs without Sectors`,
type: 'PRESET_ID' as Filter['type']
}
]);
}),
{
id: 'BENCHMARKS',
label: $localize`Benchmarks`,
type: 'PRESET_ID' as Filter['type']
},
{
id: 'CURRENCIES',
label: $localize`Currencies`,
type: 'PRESET_ID' as Filter['type']
},
{
id: 'ETF_WITHOUT_COUNTRIES',
label: $localize`ETFs without Countries`,
type: 'PRESET_ID' as Filter['type']
},
{
id: 'ETF_WITHOUT_SECTORS',
label: $localize`ETFs without Sectors`,
type: 'PRESET_ID' as Filter['type']
}
];
public benchmarks: Partial<SymbolProfile>[];
public currentDataSource: DataSource;
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 {
AdminMarketDataDetails,
AssetClassSelectorOption,
AssetProfileIdentifier,
LineChartItem,
ScraperConfiguration,
@ -82,10 +83,7 @@ import ms from 'ms';
import { EMPTY, Subject } from 'rxjs';
import { catchError, takeUntil } from 'rxjs/operators';
import {
AssetClassSelectorOption,
AssetProfileDialogParams
} from './interfaces/interfaces';
import { AssetProfileDialogParams } from './interfaces/interfaces';
@Component({
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>
</mat-checkbox>
</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
color="primary"
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 { AssetClass, AssetSubClass, DataSource } from '@prisma/client';
export interface AssetClassSelectorOption {
id: AssetClass | AssetSubClass;
label: string;
}
import { DataSource } from '@prisma/client';
export interface AssetProfileDialogParams {
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 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
color="primary"
mat-flat-button
type="submit"
[disabled]="!platformForm.valid"
[disabled]="!(platformForm.dirty && platformForm.valid)"
>
<ng-container i18n>Save</ng-container>
</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 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
color="primary"
mat-flat-button
type="submit"
[disabled]="!tagForm.valid"
[disabled]="!(tagForm.dirty && tagForm.valid)"
>
<ng-container i18n>Save</ng-container>
</button>

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

@ -5,18 +5,20 @@ import {
Input,
Output
} from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { IonIcon } from '@ionic/angular/standalone';
import { addIcons } from 'ionicons';
import { close } from 'ionicons/icons';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'justify-content-center' },
imports: [IonIcon, MatButtonModule],
selector: 'gf-dialog-footer',
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './dialog-footer.component.html',
styleUrls: ['./dialog-footer.component.scss'],
standalone: false
templateUrl: './dialog-footer.component.html'
})
export class DialogFooterComponent {
export class GfDialogFooterComponent {
@Input() deviceType: string;
@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 {
ChangeDetectionStrategy,
Component,
@ -5,18 +6,20 @@ import {
Input,
Output
} from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { IonIcon } from '@ionic/angular/standalone';
import { addIcons } from 'ionicons';
import { close } from 'ionicons/icons';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'justify-content-center' },
imports: [CommonModule, IonIcon, MatButtonModule],
selector: 'gf-dialog-header',
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './dialog-header.component.html',
styleUrls: ['./dialog-header.component.scss'],
standalone: false
templateUrl: './dialog-header.component.html'
})
export class DialogHeaderComponent {
export class GfDialogHeaderComponent {
@Input() deviceType: string;
@Input() position: 'center' | 'left' = 'left';
@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 { GfDialogFooterModule } from '@ghostfolio/client/components/dialog-footer/dialog-footer.module';
import { GfDialogHeaderModule } from '@ghostfolio/client/components/dialog-header/dialog-header.module';
import { GfDialogFooterComponent } from '@ghostfolio/client/components/dialog-footer/dialog-footer.component';
import { GfDialogHeaderComponent } from '@ghostfolio/client/components/dialog-header/dialog-header.component';
import { DataService } from '@ghostfolio/client/services/data.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 {
DataProviderInfo,
@ -74,8 +78,8 @@ import { HoldingDetailDialogParams } from './interfaces/interfaces';
GfAccountsTableComponent,
GfActivitiesTableComponent,
GfDataProviderCreditsComponent,
GfDialogFooterModule,
GfDialogHeaderModule,
GfDialogFooterComponent,
GfDialogHeaderComponent,
GfHistoricalMarketDataEditorComponent,
GfLineChartComponent,
GfPortfolioProportionChartComponent,
@ -101,6 +105,7 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
public assetClass: string;
public assetSubClass: string;
public averagePrice: number;
public averagePricePrecision = 2;
public benchmarkDataItems: LineChartItem[];
public benchmarkLabel = $localize`Average Unit Price`;
public countries: {
@ -122,11 +127,15 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
public marketDataItems: MarketData[] = [];
public marketPrice: number;
public marketPriceMax: number;
public marketPriceMaxPrecision = 2;
public marketPriceMin: number;
public marketPriceMinPrecision = 2;
public marketPricePrecision = 2;
public netPerformance: number;
public netPerformancePrecision = 2;
public netPerformancePercent: number;
public netPerformancePercentWithCurrencyEffect: number;
public netPerformancePercentWithCurrencyEffectPrecision = 2;
public netPerformanceWithCurrencyEffect: number;
public netPerformanceWithCurrencyEffectPrecision = 2;
public quantity: number;
@ -274,6 +283,14 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
value
}) => {
this.averagePrice = averagePrice;
if (
this.averagePrice >= NUMERICAL_PRECISION_THRESHOLD_6_FIGURES &&
this.data.deviceType === 'mobile'
) {
this.averagePricePrecision = 0;
}
this.benchmarkDataItems = [];
this.countries = {};
this.dataProviderInfo = dataProviderInfo;
@ -281,7 +298,8 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
if (
this.data.deviceType === 'mobile' &&
this.dividendInBaseCurrency >= NUMERICAL_PRECISION_THRESHOLD
this.dividendInBaseCurrency >=
NUMERICAL_PRECISION_THRESHOLD_6_FIGURES
) {
this.dividendInBaseCurrencyPrecision = 0;
}
@ -320,19 +338,42 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
if (
this.data.deviceType === 'mobile' &&
this.investmentInBaseCurrencyWithCurrencyEffect >=
NUMERICAL_PRECISION_THRESHOLD
NUMERICAL_PRECISION_THRESHOLD_6_FIGURES
) {
this.investmentInBaseCurrencyWithCurrencyEffectPrecision = 0;
}
this.marketPrice = marketPrice;
this.marketPriceMax = marketPriceMax;
if (
this.data.deviceType === 'mobile' &&
this.marketPriceMax >= NUMERICAL_PRECISION_THRESHOLD_6_FIGURES
) {
this.marketPriceMaxPrecision = 0;
}
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;
if (
this.data.deviceType === 'mobile' &&
this.netPerformance >= NUMERICAL_PRECISION_THRESHOLD
this.netPerformance >= NUMERICAL_PRECISION_THRESHOLD_6_FIGURES
) {
this.netPerformancePrecision = 0;
}
@ -342,13 +383,21 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
this.netPerformancePercentWithCurrencyEffect =
netPerformancePercentWithCurrencyEffect;
if (
this.data.deviceType === 'mobile' &&
this.netPerformancePercentWithCurrencyEffect >=
NUMERICAL_PRECISION_THRESHOLD_3_FIGURES
) {
this.netPerformancePercentWithCurrencyEffectPrecision = 0;
}
this.netPerformanceWithCurrencyEffect =
netPerformanceWithCurrencyEffect;
if (
this.data.deviceType === 'mobile' &&
this.netPerformanceWithCurrencyEffect >=
NUMERICAL_PRECISION_THRESHOLD
NUMERICAL_PRECISION_THRESHOLD_5_FIGURES
) {
this.netPerformanceWithCurrencyEffectPrecision = 0;
}

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

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

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

@ -48,6 +48,7 @@
<gf-holdings-table
[baseCurrency]="user?.settings?.baseCurrency"
[deviceType]="deviceType"
[hasPermissionToShowQuantities]="false"
[holdings]="holdings"
[locale]="user?.settings?.locale"
(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 { DataService } from '@ghostfolio/client/services/data.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.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 {
AssetProfileIdentifier,
LineChartItem,
@ -32,7 +32,7 @@ import { takeUntil } from 'rxjs/operators';
imports: [
CommonModule,
GfLineChartComponent,
GfPortfolioPerformanceModule,
GfPortfolioPerformanceComponent,
MatButtonModule,
RouterModule
],
@ -143,7 +143,7 @@ export class GfHomeOverviewComponent implements OnDestroy, OnInit {
if (
this.deviceType === 'mobile' &&
this.performance.currentValueInBaseCurrency >=
NUMERICAL_PRECISION_THRESHOLD
NUMERICAL_PRECISION_THRESHOLD_6_FIGURES
) {
this.precision = 0;
}

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

@ -7,7 +7,6 @@ import {
User
} from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { BenchmarkTrend } from '@ghostfolio/common/types';
import { GfBenchmarkComponent } from '@ghostfolio/ui/benchmark';
import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator';
@ -137,17 +136,7 @@ export class HomeWatchlistComponent implements OnDestroy, OnInit {
.fetchWatchlist()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ watchlist }) => {
this.watchlist = watchlist.map(
({ dataSource, marketCondition, name, performances, symbol }) => ({
dataSource,
marketCondition,
name,
performances,
symbol,
trend50d: 'UNKNOWN' as BenchmarkTrend,
trend200d: 'UNKNOWN' as BenchmarkTrend
})
);
this.watchlist = watchlist;
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 { ChangeDetectionStrategy, Component, Inject } from '@angular/core';
import { FormControl, Validators } from '@angular/forms';
import { MatCheckboxChange } from '@angular/material/checkbox';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { Router } from '@angular/router';
@ -20,6 +21,10 @@ import { eyeOffOutline, eyeOutline } from 'ionicons/icons';
standalone: false
})
export class LoginWithAccessTokenDialog {
public accessTokenFormControl = new FormControl(
this.data.accessToken,
Validators.required
);
public isAccessTokenHidden = true;
public constructor(
@ -45,8 +50,10 @@ export class LoginWithAccessTokenDialog {
}
public onLoginWithAccessToken() {
if (this.data.accessToken) {
this.dialogRef.close(this.data);
if (this.accessTokenFormControl.valid) {
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>
<input
matInput
name="password"
[formControl]="accessTokenFormControl"
[type]="isAccessTokenHidden ? 'password' : 'text'"
[(ngModel)]="data.accessToken"
/>
<button
mat-button
@ -65,8 +64,10 @@
<button
color="primary"
mat-flat-button
[disabled]="!data.accessToken"
[mat-dialog-close]="data"
[disabled]="
!(accessTokenFormControl.dirty && accessTokenFormControl.valid)
"
(click)="onLoginWithAccessToken()"
>
<ng-container i18n>Sign in</ng-container>
</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 { CommonModule } from '@angular/common';
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 { MatCheckboxModule } from '@angular/material/checkbox';
import { MatDialogModule } from '@angular/material/dialog';
@ -9,15 +9,14 @@ import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
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';
@NgModule({
declarations: [LoginWithAccessTokenDialog],
imports: [
CommonModule,
FormsModule,
GfDialogHeaderModule,
GfDialogHeaderComponent,
IonIcon,
MatButtonModule,
MatCheckboxModule,

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

@ -8,7 +8,9 @@ import {
PortfolioPerformance,
ResponseError
} from '@ghostfolio/common/interfaces';
import { GfValueComponent } from '@ghostfolio/ui/value';
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
@ -17,19 +19,21 @@ import {
OnChanges,
ViewChild
} from '@angular/core';
import { IonIcon } from '@ionic/angular/standalone';
import { CountUp } from 'countup.js';
import { addIcons } from 'ionicons';
import { timeOutline } from 'ionicons/icons';
import { isNumber } from 'lodash';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
@Component({
selector: 'gf-portfolio-performance',
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './portfolio-performance.component.html',
imports: [CommonModule, GfValueComponent, IonIcon, NgxSkeletonLoaderModule],
selector: 'gf-portfolio-performance',
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() errors: ResponseError['errors'];
@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">
@if (rule?.isActive && rule?.configuration) {
<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>
<hr class="my-0" />
}
<button mat-menu-item (click)="onUpdateRule(rule)">
@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 {
<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>
</mat-menu>

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

@ -16,8 +16,10 @@ import {
import { MatDialog } from '@angular/material/dialog';
import { addIcons } from 'ionicons';
import {
addCircleOutline,
checkmarkCircleOutline,
ellipsisHorizontal,
optionsOutline,
removeCircleOutline,
warningOutline
} from 'ionicons/icons';
@ -50,8 +52,10 @@ export class RuleComponent implements OnInit {
private dialog: MatDialog
) {
addIcons({
addCircleOutline,
checkmarkCircleOutline,
ellipsisHorizontal,
optionsOutline,
removeCircleOutline,
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 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
color="primary"
mat-flat-button
type="submit"
[disabled]="!accessForm.valid"
[disabled]="!(accessForm.dirty && accessForm.valid)"
>
<ng-container i18n>Save</ng-container>
</button>

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

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

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

@ -7,7 +7,7 @@
@if (valueLabel) {
<mat-label>{{ valueLabel }}</mat-label>
}
<input matInput [(ngModel)]="value" />
<input matInput [formControl]="formControl" />
</mat-form-field>
</div>
@ -15,7 +15,12 @@
<button mat-button (click)="dialogRef.close('discard')">
{{ discardLabel }}
</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 }}
</button>
</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 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
color="primary"
mat-flat-button
type="submit"
[disabled]="!accountForm.valid"
[disabled]="!(accountForm.dirty && accountForm.valid)"
>
<ng-container i18n>Save</ng-container>
</button>

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

@ -67,6 +67,16 @@
($&#123;fixedIncomeValueRatio&#125;%) is within the range of
$&#123;thresholdMin&#125;% and $&#123;thresholdMax&#125;%
</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.currencyClusterRiskBaseCurrencyCurrentInvestment">
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 { Statistics } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
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 { 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 { addIcons } from 'ionicons';
import {
cloudDownloadOutline,
peopleOutline,
starOutline
} from 'ionicons/icons';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject } from 'rxjs';
@Component({
host: { class: 'page' },
imports: [
CommonModule,
GfCarouselComponent,
GfLogoComponent,
GfValueComponent,
GfWorldMapChartModule,
IonIcon,
MatButtonModule,
MatCardModule,
RouterModule
],
selector: 'gf-landing-page',
styleUrls: ['./landing-page.scss'],
templateUrl: './landing-page.html',
standalone: false
templateUrl: './landing-page.html'
})
export class LandingPageComponent implements OnDestroy, OnInit {
export class GfLandingPageComponent implements OnDestroy, OnInit {
public countriesOfSubscribersMap: {
[code: string]: { value: number };
} = {};
@ -118,6 +143,12 @@ export class LandingPageComponent implements OnDestroy, OnInit {
);
this.statistics = statistics;
addIcons({
cloudDownloadOutline,
peopleOutline,
starOutline
});
}
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 { UpdateOrderDto } from '@ghostfolio/api/app/order/update-order.dto';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { ASSET_CLASS_MAPPING } from '@ghostfolio/common/config';
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 { GfEntityLogoComponent } from '@ghostfolio/ui/entity-logo';
import { translate } from '@ghostfolio/ui/i18n';
@ -37,7 +41,7 @@ import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
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 { addIcons } from 'ionicons';
import { calendarClearOutline, refreshOutline } from 'ionicons/icons';
@ -73,12 +77,16 @@ import { CreateOrUpdateActivityDialogParams } from './interfaces/interfaces';
})
export class GfCreateOrUpdateActivityDialog implements OnDestroy {
public activityForm: FormGroup;
public assetClasses = Object.keys(AssetClass).map((assetClass) => {
return { id: assetClass, label: translate(assetClass) };
});
public assetSubClasses = Object.keys(AssetSubClass).map((assetSubClass) => {
return { id: assetSubClass, label: translate(assetSubClass) };
});
public assetClassOptions: AssetClassSelectorOption[] = Object.keys(AssetClass)
.map((id) => {
return { id, label: translate(id) } as AssetClassSelectorOption;
})
.sort((a, b) => {
return a.label.localeCompare(b.label);
});
public assetSubClassOptions: AssetClassSelectorOption[] = [];
public currencies: string[] = [];
public currencyOfAssetProfile: string;
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(() => {
if (isToday(this.activityForm.get('date').value)) {
this.activityForm.get('updateAccountBalance').enable();
@ -495,7 +523,9 @@ export class GfCreateOrUpdateActivityDialog implements OnDestroy {
? undefined
: this.activityForm.get('searchSymbol')?.value?.symbol) ??
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,
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-select formControlName="assetClass">
<mat-option [value]="null" />
@for (assetClass of assetClasses; track assetClass) {
<mat-option [value]="assetClass.id">{{
assetClass.label
@for (
assetClassOption of assetClassOptions;
track assetClassOption.id
) {
<mat-option [value]="assetClassOption.id">{{
assetClassOption.label
}}</mat-option>
}
</mat-select>
@ -306,9 +309,12 @@
<mat-label i18n>Asset Sub Class</mat-label>
<mat-select formControlName="assetSubClass">
<mat-option [value]="null" />
@for (assetSubClass of assetSubClasses; track assetSubClass) {
<mat-option [value]="assetSubClass.id">{{
assetSubClass.label
@for (
assetSubClassOption of assetSubClassOptions;
track assetSubClassOption.id
) {
<mat-option [value]="assetSubClassOption.id">{{
assetSubClassOption.label
}}</mat-option>
}
</mat-select>
@ -335,12 +341,18 @@
[value]="total"
/>
<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
color="primary"
mat-flat-button
type="submit"
[disabled]="!activityForm.valid"
[disabled]="!(activityForm.dirty && activityForm.valid)"
>
<ng-container i18n>Save</ng-container>
</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 { CreateAssetProfileWithMarketDataDto } from '@ghostfolio/api/app/import/create-asset-profile-with-market-data.dto';
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { GfDialogFooterModule } from '@ghostfolio/client/components/dialog-footer/dialog-footer.module';
import { GfDialogHeaderModule } from '@ghostfolio/client/components/dialog-header/dialog-header.module';
import { GfDialogFooterComponent } from '@ghostfolio/client/components/dialog-footer/dialog-footer.component';
import { GfDialogHeaderComponent } from '@ghostfolio/client/components/dialog-header/dialog-header.component';
import { GfFileDropModule } from '@ghostfolio/client/directives/file-drop/file-drop.module';
import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module';
import { DataService } from '@ghostfolio/client/services/data.service';
@ -57,8 +58,8 @@ import { ImportActivitiesDialogParams } from './interfaces/interfaces';
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
GfActivitiesTableComponent,
GfDialogFooterModule,
GfDialogHeaderModule,
GfDialogFooterComponent,
GfDialogHeaderComponent,
GfFileDropModule,
GfSymbolModule,
IonIcon,
@ -94,6 +95,7 @@ export class GfImportActivitiesDialog implements OnDestroy {
public sortColumn = 'date';
public sortDirection: SortDirection = 'desc';
public stepperOrientation: StepperOrientation;
public tags: CreateTagDto[] = [];
public totalItems: number;
private unsubscribeSubject = new Subject<void>();
@ -169,7 +171,8 @@ export class GfImportActivitiesDialog implements OnDestroy {
await this.importActivitiesService.importSelectedActivities({
accounts: this.accounts,
activities: this.selectedActivities,
assetProfiles: this.assetProfiles
assetProfiles: this.assetProfiles,
tags: this.tags
});
this.snackBar.open(
@ -297,6 +300,7 @@ export class GfImportActivitiesDialog implements OnDestroy {
this.accounts = content.accounts;
this.assetProfiles = content.assetProfiles;
this.tags = content.tags;
if (!isArray(content.activities)) {
if (isArray(content.orders)) {
@ -328,7 +332,8 @@ export class GfImportActivitiesDialog implements OnDestroy {
accounts: content.accounts,
activities: content.activities,
assetProfiles: content.assetProfiles,
isDryRun: true
isDryRun: true,
tags: content.tags
});
this.activities = activities;
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
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
class="mb-4"
[ngClass]="{
@ -73,9 +95,7 @@
</h4>
<gf-rules
[hasPermissionToUpdateUserSettings]="
!hasImpersonationId &&
hasPermissionToUpdateUserSettings &&
user?.settings?.isExperimentalFeatures
!hasImpersonationId && hasPermissionToUpdateUserSettings
"
[isLoading]="isLoading"
[rules]="emergencyFundRules"
@ -97,9 +117,7 @@
</h4>
<gf-rules
[hasPermissionToUpdateUserSettings]="
!hasImpersonationId &&
hasPermissionToUpdateUserSettings &&
user?.settings?.isExperimentalFeatures
!hasImpersonationId && hasPermissionToUpdateUserSettings
"
[isLoading]="isLoading"
[rules]="currencyClusterRiskRules"
@ -121,9 +139,7 @@
</h4>
<gf-rules
[hasPermissionToUpdateUserSettings]="
!hasImpersonationId &&
hasPermissionToUpdateUserSettings &&
user?.settings?.isExperimentalFeatures
!hasImpersonationId && hasPermissionToUpdateUserSettings
"
[isLoading]="isLoading"
[rules]="assetClassClusterRiskRules"
@ -145,9 +161,7 @@
</h4>
<gf-rules
[hasPermissionToUpdateUserSettings]="
!hasImpersonationId &&
hasPermissionToUpdateUserSettings &&
user?.settings?.isExperimentalFeatures
!hasImpersonationId && hasPermissionToUpdateUserSettings
"
[isLoading]="isLoading"
[rules]="accountClusterRiskRules"
@ -169,9 +183,7 @@
</h4>
<gf-rules
[hasPermissionToUpdateUserSettings]="
!hasImpersonationId &&
hasPermissionToUpdateUserSettings &&
user?.settings?.isExperimentalFeatures
!hasImpersonationId && hasPermissionToUpdateUserSettings
"
[isLoading]="isLoading"
[rules]="economicMarketClusterRiskRules"
@ -193,9 +205,7 @@
</h4>
<gf-rules
[hasPermissionToUpdateUserSettings]="
!hasImpersonationId &&
hasPermissionToUpdateUserSettings &&
user?.settings?.isExperimentalFeatures
!hasImpersonationId && hasPermissionToUpdateUserSettings
"
[isLoading]="isLoading"
[rules]="regionalMarketClusterRiskRules"
@ -217,9 +227,7 @@
</h4>
<gf-rules
[hasPermissionToUpdateUserSettings]="
!hasImpersonationId &&
hasPermissionToUpdateUserSettings &&
user?.settings?.isExperimentalFeatures
!hasImpersonationId && hasPermissionToUpdateUserSettings
"
[isLoading]="isLoading"
[rules]="feeRules"
@ -232,9 +240,7 @@
<h4 class="m-0" i18n>Inactive</h4>
<gf-rules
[hasPermissionToUpdateUserSettings]="
!hasImpersonationId &&
hasPermissionToUpdateUserSettings &&
user?.settings?.isExperimentalFeatures
!hasImpersonationId && hasPermissionToUpdateUserSettings
"
[isLoading]="isLoading"
[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 inactiveRules: PortfolioReportRule[];
public isLoading = false;
public liquidityRules: PortfolioReportRule[];
public regionalMarketClusterRiskRules: PortfolioReportRule[];
public statistics: PortfolioReportResponse['xRay']['statistics'];
public user: User;
@ -149,6 +150,11 @@ export class GfXRayPageComponent {
return isActive;
}) ?? null;
this.liquidityRules =
rules['liquidity']?.filter(({ isActive }) => {
return isActive;
}) ?? null;
this.regionalMarketClusterRiskRules =
rules['regionalMarketClusterRisk']?.filter(({ 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 { publicRoutes } from '@ghostfolio/common/routes/routes';
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 { checkmarkCircleOutline, checkmarkOutline } from 'ionicons/icons';
import {
checkmarkCircleOutline,
checkmarkOutline,
informationCircleOutline
} from 'ionicons/icons';
import { StringValue } from 'ms';
import { StripeService } from 'ngx-stripe';
import { Subject } from 'rxjs';
@ -16,12 +33,21 @@ import { catchError, switchMap, takeUntil } from 'rxjs/operators';
@Component({
host: { class: 'page' },
imports: [
CommonModule,
GfPremiumIndicatorComponent,
IonIcon,
MatButtonModule,
MatCardModule,
MatTooltipModule,
RouterModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
selector: 'gf-pricing-page',
styleUrls: ['./pricing-page.scss'],
templateUrl: './pricing-page.html',
standalone: false
templateUrl: './pricing-page.html'
})
export class PricingPageComponent implements OnDestroy, OnInit {
export class GfPricingPageComponent implements OnDestroy, OnInit {
public baseCurrency: string;
public coupon: number;
public couponId: string;
@ -56,7 +82,11 @@ export class PricingPageComponent implements OnDestroy, OnInit {
private stripeService: StripeService,
private userService: UserService
) {
addIcons({ checkmarkCircleOutline, checkmarkOutline });
addIcons({
checkmarkCircleOutline,
checkmarkOutline,
informationCircleOutline
});
}
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"
[deviceType]="deviceType"
[hasPermissionToOpenDetails]="false"
[hasPermissionToShowQuantities]="false"
[hasPermissionToShowValues]="false"
[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 { InfoItem, LineChartItem } from '@ghostfolio/common/interfaces';
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 { Router } from '@angular/router';
import { Router, RouterModule } from '@angular/router';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { ShowAccessTokenDialogParams } from './show-access-token-dialog/interfaces/interfaces';
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({
host: { class: 'page' },
imports: [
GfLogoComponent,
MatButtonModule,
RouterModule,
ShowAccessTokenDialogModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
selector: 'gf-register-page',
styleUrls: ['./register-page.scss'],
templateUrl: './register-page.html',
standalone: false
templateUrl: './register-page.html'
})
export class RegisterPageComponent implements OnDestroy, OnInit {
public demoAuthToken: string;
export class GfRegisterPageComponent implements OnDestroy, OnInit {
public deviceType: string;
public hasPermissionForSocialLogin: boolean;
public hasPermissionForSubscription: boolean;
@ -46,18 +59,20 @@ export class RegisterPageComponent implements OnDestroy, OnInit {
}
public ngOnInit() {
const { demoAuthToken, globalPermissions } = this.dataService.fetchInfo();
const { globalPermissions } = this.dataService.fetchInfo();
this.demoAuthToken = demoAuthToken;
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
this.hasPermissionForSocialLogin = hasPermission(
globalPermissions,
permissions.enableSocialLogin
);
this.hasPermissionForSubscription = hasPermission(
globalPermissions,
permissions.enableSubscription
);
this.hasPermissionToCreateUser = hasPermission(
globalPermissions,
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 { internalRoutes } from '@ghostfolio/common/routes/routes';
import { CommonModule } from '@angular/common';
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 { analyticsOutline, walletOutline } from 'ionicons/icons';
import { DeviceDetectorService } from 'ngx-device-detector';
@ -11,12 +15,12 @@ import { takeUntil } from 'rxjs/operators';
@Component({
host: { class: 'page has-tabs' },
imports: [CommonModule, IonIcon, MatTabsModule, RouterModule],
selector: 'gf-zen-page',
styleUrls: ['./zen-page.scss'],
templateUrl: './zen-page.html',
standalone: false
templateUrl: './zen-page.html'
})
export class ZenPageComponent implements OnDestroy, OnInit {
export class GfZenPageComponent implements OnDestroy, OnInit {
public deviceType: string;
public tabs: TabConfiguration[] = [];
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 { internalRoutes } from '@ghostfolio/common/routes/routes';
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { 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],
children: [
@ -22,14 +21,8 @@ const routes: Routes = [
title: internalRoutes.home.subRoutes.holdings.title
}
],
component: ZenPageComponent,
component: GfZenPageComponent,
path: '',
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 { CreateAssetProfileWithMarketDataDto } from '@ghostfolio/api/app/import/create-asset-profile-with-market-data.dto';
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
@ -75,12 +76,14 @@ export class ImportActivitiesService {
accounts,
activities,
assetProfiles,
isDryRun = false
isDryRun = false,
tags
}: {
activities: CreateOrderDto[];
accounts?: CreateAccountWithBalancesDto[];
assetProfiles?: CreateAssetProfileWithMarketDataDto[];
isDryRun?: boolean;
tags?: CreateTagDto[];
}): Promise<{
activities: Activity[];
}> {
@ -89,7 +92,8 @@ export class ImportActivitiesService {
{
accounts,
activities,
assetProfiles
assetProfiles,
tags
},
isDryRun
)
@ -110,11 +114,13 @@ export class ImportActivitiesService {
public importSelectedActivities({
accounts,
activities,
assetProfiles
assetProfiles,
tags
}: {
accounts?: CreateAccountWithBalancesDto[];
activities: Activity[];
assetProfiles?: CreateAssetProfileWithMarketDataDto[];
tags?: CreateTagDto[];
}): Promise<{
activities: Activity[];
}> {
@ -124,7 +130,12 @@ export class ImportActivitiesService {
importData.push(this.convertToCreateOrderDto(activity));
}
return this.importJson({ accounts, assetProfiles, activities: importData });
return this.importJson({
accounts,
assetProfiles,
tags,
activities: importData
});
}
private convertToCreateOrderDto({
@ -135,6 +146,7 @@ export class ImportActivitiesService {
fee,
quantity,
SymbolProfile,
tags,
type,
unitPrice,
updateAccountBalance
@ -150,7 +162,10 @@ export class ImportActivitiesService {
currency: currency ?? SymbolProfile.currency,
dataSource: SymbolProfile.dataSource,
date: date.toString(),
symbol: SymbolProfile.symbol
symbol: SymbolProfile.symbol,
tags: tags?.map(({ id }) => {
return id;
})
};
}
@ -391,6 +406,7 @@ export class ImportActivitiesService {
accounts?: CreateAccountWithBalancesDto[];
activities: CreateOrderDto[];
assetProfiles?: CreateAssetProfileWithMarketDataDto[];
tags?: CreateTagDto[];
},
aIsDryRun = false
) {

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

Loading…
Cancel
Save