Browse Source

Merge branch 'main' into feature/warm-up-portfolio-snapshot-calculation-during-biometric-authentication

pull/7427/head
Thomas Kaul 3 weeks ago
committed by GitHub
parent
commit
bf79c861ec
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 167
      CHANGELOG.md
  2. 12
      apps/api/src/app/account-balance/account-balance.service.ts
  3. 48
      apps/api/src/app/account/account.controller.ts
  4. 96
      apps/api/src/app/account/account.service.ts
  5. 4
      apps/api/src/app/account/interfaces/cash-details.interface.ts
  6. 21
      apps/api/src/app/activities/activities-filter.dto.ts
  7. 97
      apps/api/src/app/activities/activities.controller.ts
  8. 43
      apps/api/src/app/activities/activities.service.ts
  9. 27
      apps/api/src/app/activities/get-activities.dto.ts
  10. 17
      apps/api/src/app/admin/admin.service.ts
  11. 10
      apps/api/src/app/app.module.ts
  12. 3
      apps/api/src/app/asset/asset.controller.ts
  13. 7
      apps/api/src/app/auth/api-key.strategy.ts
  14. 7
      apps/api/src/app/auth/jwt.strategy.ts
  15. 18
      apps/api/src/app/endpoints/ai/ai.controller.ts
  16. 98
      apps/api/src/app/endpoints/asset-profiles/asset-profiles.controller.ts
  17. 4
      apps/api/src/app/endpoints/asset-profiles/asset-profiles.module.ts
  18. 53
      apps/api/src/app/endpoints/asset-profiles/asset-profiles.service.ts
  19. 37
      apps/api/src/app/endpoints/benchmarks/benchmarks.controller.ts
  20. 12
      apps/api/src/app/endpoints/benchmarks/get-benchmark-market-data.dto.ts
  21. 32
      apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.controller.ts
  22. 21
      apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.service.ts
  23. 5
      apps/api/src/app/endpoints/tags/tags.controller.ts
  24. 43
      apps/api/src/app/export/export.controller.ts
  25. 21
      apps/api/src/app/export/export.service.ts
  26. 14
      apps/api/src/app/export/get-export.dto.ts
  27. 4
      apps/api/src/app/health/health.service.ts
  28. 7
      apps/api/src/app/import/import-data.dto.ts
  29. 1
      apps/api/src/app/import/import.controller.ts
  30. 218
      apps/api/src/app/import/import.service.ts
  31. 18
      apps/api/src/app/portfolio/calculator/portfolio-calculator.ts
  32. 3
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-buy.spec.ts
  33. 3
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell-in-two-activities.spec.ts
  34. 3
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell.spec.ts
  35. 3
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy.spec.ts
  36. 3
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btceur-in-base-currency-eur.spec.ts
  37. 3
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btceur.spec.ts
  38. 3
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts
  39. 3
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-short.spec.ts
  40. 3
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd.spec.ts
  41. 14
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts
  42. 3
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-fee.spec.ts
  43. 3
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-googl-buy.spec.ts
  44. 3
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-jnug-buy-and-sell-and-buy-and-sell.spec.ts
  45. 3
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-liability.spec.ts
  46. 3
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-and-sell.spec.ts
  47. 3
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-with-dividend.spec.ts
  48. 3
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-no-activities.spec.ts
  49. 3
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell-partially.spec.ts
  50. 3
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell.spec.ts
  51. 3
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-valuable.spec.ts
  52. 7
      apps/api/src/app/portfolio/errors/portfolio-snapshot-computation.error.ts
  53. 12
      apps/api/src/app/portfolio/get-details.dto.ts
  54. 10
      apps/api/src/app/portfolio/get-dividends.dto.ts
  55. 14
      apps/api/src/app/portfolio/get-holdings.dto.ts
  56. 10
      apps/api/src/app/portfolio/get-investments.dto.ts
  57. 12
      apps/api/src/app/portfolio/get-performance.dto.ts
  58. 177
      apps/api/src/app/portfolio/portfolio.controller.ts
  59. 97
      apps/api/src/app/portfolio/portfolio.service.spec.ts
  60. 55
      apps/api/src/app/portfolio/portfolio.service.ts
  61. 3
      apps/api/src/app/redis-cache/redis-cache.service.mock.ts
  62. 4
      apps/api/src/app/subscription/subscription.controller.ts
  63. 10
      apps/api/src/app/user/user.controller.ts
  64. 4
      apps/api/src/app/user/user.module.ts
  65. 88
      apps/api/src/app/user/user.service.ts
  66. 16
      apps/api/src/dtos/date-range-filter.dto.ts
  67. 23
      apps/api/src/dtos/filter.dto.ts
  68. 26
      apps/api/src/filters/portfolio-snapshot-computation-exception.filter.ts
  69. 18
      apps/api/src/helper/account.helper.ts
  70. 2
      apps/api/src/interceptors/redact-values-in-response/redact-values-in-response.interceptor.ts
  71. 2
      apps/api/src/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor.ts
  72. 12
      apps/api/src/services/asset-profile-split/asset-profile-split.module.ts
  73. 87
      apps/api/src/services/asset-profile-split/asset-profile-split.service.ts
  74. 6
      apps/api/src/services/benchmark/benchmark.service.ts
  75. 10
      apps/api/src/services/data-provider/data-enhancer/trackinsight/trackinsight.service.ts
  76. 12
      apps/api/src/services/data-provider/data-provider.service.ts
  77. 2
      apps/api/src/services/data-provider/financial-modeling-prep/financial-modeling-prep.service.ts
  78. 4
      apps/api/src/services/data-provider/yahoo-finance/yahoo-finance.service.ts
  79. 70
      apps/api/src/services/property/property.service.ts
  80. 2
      apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.processor.ts
  81. 29
      apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock.ts
  82. 21
      apps/api/src/services/symbol-profile/symbol-profile.service.ts
  83. 15
      apps/client/src/app/app.component.ts
  84. 45
      apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts
  85. 58
      apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html
  86. 2
      apps/client/src/app/components/account-detail-dialog/interfaces/interfaces.ts
  87. 56
      apps/client/src/app/components/admin-market-data/admin-market-data.component.ts
  88. 17
      apps/client/src/app/components/admin-market-data/admin-market-data.service.ts
  89. 111
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts
  90. 129
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html
  91. 41
      apps/client/src/app/components/admin-overview/admin-overview.component.ts
  92. 18
      apps/client/src/app/components/admin-overview/admin-overview.html
  93. 3
      apps/client/src/app/components/admin-platform/admin-platform.component.html
  94. 25
      apps/client/src/app/components/admin-settings/admin-settings.component.html
  95. 3
      apps/client/src/app/components/admin-settings/admin-settings.component.ts
  96. 12
      apps/client/src/app/components/admin-tag/admin-tag.component.html
  97. 9
      apps/client/src/app/components/admin-tag/admin-tag.component.ts
  98. 2
      apps/client/src/app/components/admin-users/admin-users.html
  99. 103
      apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.ts
  100. 24
      apps/client/src/app/components/fear-and-greed-index/fear-and-greed-index.component.html

167
CHANGELOG.md

@ -9,16 +9,183 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- Added a live preview of the date and number format to the user settings
- Added the country flag to the currency selector
- Added a _Storybook_ story for the currency selector component
- Extended the entity logo component by a `hasPlaceholder` attribute to reserve the space of a missing logo
- Warmed up the portfolio snapshot calculation in the background during the biometric authentication - Warmed up the portfolio snapshot calculation in the background during the biometric authentication
### Changed ### Changed
- Improved the usability of the create watchlist item dialog by setting the initial focus to the search field
- Migrated the abstract _Material_ form field from a component to a directive
- Removed the redundant `balance` attribute of the account in favor of the account balances
### Fixed
- Fixed the values of the charts and tables in impersonation mode with an unrestricted access to show absolute values instead of percentages
- Fixed the savings rate of the investment timeline chart and the streaks on the analysis page in impersonation mode to be based on the impersonated user
- Fixed the savings rate of the _FIRE_ calculator in impersonation mode to not be based on the impersonating user
## 3.43.0 - 2026-08-06
### Added
- Added the platform logo to the platform selector in the create or update account dialog
- Added the platform logo to the account selector in the create or update activity dialog
- Extended the value component by an `isLoading` attribute to distinguish the loading state from redacted values
### Changed
- Guarded the system tags against deletion and renaming in the tag management of the admin control panel
- Improved the language localization for Spanish (`es`)
### Fixed
- Handled an exception in the country weightings parsing of the _Financial Modeling Prep_ service
## 3.42.0 - 2026-08-04
### Changed
- Improved the usability of the portfolio summary by collapsing the _Holdings_ and _Cash_ breakdowns by default
- Extended the support of the _Exclude from Analysis_ tag from accounts to activities
- Optimized the performance of the search in the assistant by reusing the cached portfolio snapshot
- Improved the validation of the import functionality when referencing an asset profile with the data source `MANUAL`
- Improved the validation of the endpoint to add a custom asset profile in the admin control panel
### Fixed
- Fixed the fuzzy search for the holdings in the assistant
## 3.41.0 - 2026-08-03
### Added
- Added support for the account platforms in the activities import
- Added the database model and endpoints to manage the stock splits of an asset profile (experimental)
### Changed
- Improved the usability of the admin control panel by eliminating the page reload on changing a setting
- Improved the usability of the admin control panel by eliminating the page reload on deleting an asset profile
- Improved the usability of the admin control panel by eliminating the page reload on flushing the cache
- Improved the usability of the admin control panel by eliminating the page reload on gathering historical market data
- Improved the language localization for German (`de`)
### Fixed
- Fixed the loading state in the user detail dialog of the admin control panel’s users section
- Fixed a race condition where the portfolio snapshot computation was completed before its result had been cached, causing a redundant recomputation
- Fixed an endless loop in the portfolio snapshot computation if the computed result could not be read from the cache
## 3.40.0 - 2026-08-02
### Changed
- Improved the style of the read-only tags in the tags selector component
- Improved the language localization for Chinese (`zh`)
- Upgraded `nestjs` from version `11.1.27` to `11.1.28`
### Fixed
- Fixed the handling of the _Exclude from Analysis_ tag in the activities table
- Fixed the persistence of an empty comment in the create or update account dialog
- Resolved a validation error caused by empty strings in the asset profile details dialog of the admin control panel
## 3.39.0 - 2026-08-01
### Changed
- Harmonized the data format of the export functionality
- Removed the deprecated `firstOrderDate` attribute from the `GET api/v2/portfolio/performance` endpoint response
- Removed the deprecated `isExcluded` attribute of the account in favor of the _Exclude from Analysis_ tag including a data migration
- Improved the language localization for German (`de`)
- Upgraded `prisma` from version `7.8.0` to `7.9.1`
### Fixed
- Fixed the scroll behavior of the page content behind an open dialog
- Fixed the export functionality to only include the accounts of the exported activities if a filter is applied
## 3.38.0 - 2026-07-31
### Added
- Added support for the date range filter in the export functionality
- Added support for the date range filter on the portfolio activities page
### Changed
- Improved the style of the tabs in the account detail dialog on mobile
- Improved the style of the tabs in the holding detail dialog on mobile
- Improved the style of the tabs in the asset profile dialog of the admin control panel on mobile
- Improved the style of the empty state in the _Fear & Greed Index_ component
- Added the activity count to the delete menu item of the activities table
- Added the activity count to the deletion confirmation dialog of the activities table
- Improved the style of the type filter in the activities table component (experimental)
- Improved the search functionality by trimming the query
- Improved the log output in the search functionality of the _Yahoo Finance_ service for unsupported queries
- Improved the performance of the property service by caching the properties in memory
- Improved the validation of the query parameters in the activities endpoints
- Improved the language localization for German (`de`)
### Fixed
- Fixed the calendar year date range in time zones with a negative _UTC_ offset
- Fixed the deletion of activities to respect the activity type filter on the activities page (experimental)
- Fixed the deletion of activities to respect the date range filter on the activities page
- Fixed the static portfolio analysis rule for a portfolio with no holdings: _Asset Class Cluster Risks_ (Equity)
- Fixed the static portfolio analysis rule for a portfolio with no holdings: _Asset Class Cluster Risks_ (Fixed Income)
- Fixed the static portfolio analysis rule for a portfolio with no holdings: _Currency Cluster Risks_ (Investment)
- Fixed the static portfolio analysis rule for a portfolio with no holdings: _Currency Cluster Risks_ (Investment: Base Currency)
- Fixed the static portfolio analysis rule for a portfolio with no holdings: _Economic Market Cluster Risks_ (Developed Markets)
- Fixed the static portfolio analysis rule for a portfolio with no holdings: _Economic Market Cluster Risks_ (Emerging Markets)
- Fixed the static portfolio analysis rule for a portfolio with no holdings: _Regional Market Cluster Risks_ (Asia-Pacific)
- Fixed the static portfolio analysis rule for a portfolio with no holdings: _Regional Market Cluster Risks_ (Emerging Markets)
- Fixed the static portfolio analysis rule for a portfolio with no holdings: _Regional Market Cluster Risks_ (Europe)
- Fixed the static portfolio analysis rule for a portfolio with no holdings: _Regional Market Cluster Risks_ (Japan)
- Fixed the static portfolio analysis rule for a portfolio with no holdings: _Regional Market Cluster Risks_ (North America)
## 3.37.0 - 2026-07-30
### Added
- Added an empty state to the _Fear & Greed Index_ component
- Added a _Storybook_ story for the _Fear & Greed Index_ component
### Changed
- Moved the tags to the overview tab of the account detail dialog (experimental)
- Moved the tags to the overview tab of the holding detail dialog
- Consolidated the markets pages into a single route where the _Fear & Greed Index_ is controlled by permission
- Refactored the line chart components to share the common chart configuration
- Improved the language localization for Spanish (`es`)
- Improved the language localization for Ukrainian (`uk`)
### Fixed
- Ignored future-dated account balances in the portfolio calculation
## 3.36.0 - 2026-07-29
### Added
- Added an overview tab to the account detail dialog
- Added the tags (read-only) to the account detail dialog (experimental)
### Changed
- Improved the portfolio summary tab on the home page
- Improved the language localization for German (`de`)
- Upgraded `@openrouter/ai-sdk-provider` from version `2.9.1` to `3.0.0` - Upgraded `@openrouter/ai-sdk-provider` from version `2.9.1` to `3.0.0`
- Upgraded `ai` from version `6.0.174` to `7.0.37` - Upgraded `ai` from version `6.0.174` to `7.0.37`
### Fixed ### Fixed
- Fixed the time in market of the portfolio summary to be empty if there is no activity - Fixed the time in market of the portfolio summary to be empty if there is no activity
- Fixed an issue with the delete button in the activities filter component
- Fixed the tags in the read-only mode of the tags selector component
## 3.35.0 - 2026-07-27 ## 3.35.0 - 2026-07-27

12
apps/api/src/app/account-balance/account-balance.service.ts

@ -1,5 +1,8 @@
import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event'; import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event';
import { WHERE_ACCOUNT_NOT_EXCLUDED } from '@ghostfolio/api/helper/account.helper'; import {
isAccountBalanceInFuture,
WHERE_ACCOUNT_NOT_EXCLUDED
} from '@ghostfolio/api/helper/account.helper';
import { LogPerformance } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor'; import { LogPerformance } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
@ -15,7 +18,7 @@ import { Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter'; import { EventEmitter2 } from '@nestjs/event-emitter';
import { AccountBalance, Prisma } from '@prisma/client'; import { AccountBalance, Prisma } from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { format, parseISO } from 'date-fns'; import { endOfToday, format, parseISO } from 'date-fns';
import { groupBy } from 'lodash'; import { groupBy } from 'lodash';
@Injectable() @Injectable()
@ -114,8 +117,13 @@ export class AccountBalanceService {
const accumulatedBalancesByDate: { [date: string]: HistoricalDataItem } = const accumulatedBalancesByDate: { [date: string]: HistoricalDataItem } =
{}; {};
const lastBalancesByAccount: { [accountId: string]: Big } = {}; const lastBalancesByAccount: { [accountId: string]: Big } = {};
const endOfTodayDate = endOfToday();
for (const { accountId, date, valueInBaseCurrency } of balances) { for (const { accountId, date, valueInBaseCurrency } of balances) {
if (isAccountBalanceInFuture({ date, endOfTodayDate })) {
continue;
}
const formattedDate = format(date, DATE_FORMAT); const formattedDate = format(date, DATE_FORMAT);
lastBalancesByAccount[accountId] = new Big(valueInBaseCurrency); lastBalancesByAccount[accountId] = new Big(valueInBaseCurrency);

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

@ -156,32 +156,34 @@ export class AccountController {
public async createAccount( public async createAccount(
@Body() data: CreateAccountDto @Body() data: CreateAccountDto
): Promise<AccountModel> { ): Promise<AccountModel> {
const { tags: tagIds, ...accountData } = data; const { balance, tags: tagIds, ...accountData } = data;
if (accountData.platformId) { if (accountData.platformId) {
const platformId = accountData.platformId; const platformId = accountData.platformId;
delete accountData.platformId; delete accountData.platformId;
return this.accountService.createAccount( return this.accountService.createAccount({
{ balance,
tagIds,
data: {
...accountData, ...accountData,
platform: { connect: { id: platformId } }, platform: { connect: { id: platformId } },
user: { connect: { id: this.request.user.id } } user: { connect: { id: this.request.user.id } }
}, },
this.request.user.id, userId: this.request.user.id
tagIds });
);
} else { } else {
delete accountData.platformId; delete accountData.platformId;
return this.accountService.createAccount( return this.accountService.createAccount({
{ balance,
tagIds,
data: {
...accountData, ...accountData,
user: { connect: { id: this.request.user.id } } user: { connect: { id: this.request.user.id } }
}, },
this.request.user.id, userId: this.request.user.id
tagIds });
);
} }
} }
@ -257,35 +259,35 @@ export class AccountController {
); );
} }
const { tags: tagIds, ...accountData } = data; const { balance, tags: tagIds, ...accountData } = data;
if (accountData.platformId) { if (accountData.platformId) {
const platformId = accountData.platformId; const platformId = accountData.platformId;
delete accountData.platformId; delete accountData.platformId;
return this.accountService.updateAccount( return this.accountService.updateAccount({
{ balance,
tagIds,
data: { data: {
...accountData, ...accountData,
platform: { connect: { id: platformId } }, platform: { connect: { id: platformId } },
user: { connect: { id: this.request.user.id } } user: { connect: { id: this.request.user.id } }
}, },
userId: this.request.user.id,
where: { where: {
id_userId: { id_userId: {
id, id,
userId: this.request.user.id userId: this.request.user.id
} }
} }
}, });
this.request.user.id,
tagIds
);
} else { } else {
// platformId is null, remove it // platformId is null, remove it
delete accountData.platformId; delete accountData.platformId;
return this.accountService.updateAccount( return this.accountService.updateAccount({
{ balance,
tagIds,
data: { data: {
...accountData, ...accountData,
platform: originalAccount.platformId platform: originalAccount.platformId
@ -293,16 +295,14 @@ export class AccountController {
: undefined, : undefined,
user: { connect: { id: this.request.user.id } } user: { connect: { id: this.request.user.id } }
}, },
userId: this.request.user.id,
where: { where: {
id_userId: { id_userId: {
id, id,
userId: this.request.user.id userId: this.request.user.id
} }
} }
}, });
this.request.user.id,
tagIds
);
} }
} }
} }

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

@ -1,11 +1,16 @@
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service'; import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event'; import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event';
import { WHERE_ACCOUNT_NOT_EXCLUDED } from '@ghostfolio/api/helper/account.helper'; import {
getWhereAccountBalanceNotInFuture,
isAccountBalanceInFuture,
WHERE_ACCOUNT_NOT_EXCLUDED
} from '@ghostfolio/api/helper/account.helper';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { TagService } from '@ghostfolio/api/services/tag/tag.service'; import { TagService } from '@ghostfolio/api/services/tag/tag.service';
import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { Filter } from '@ghostfolio/common/interfaces'; import { Filter } from '@ghostfolio/common/interfaces';
import { AccountWithBalance } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter'; import { EventEmitter2 } from '@nestjs/event-emitter';
@ -19,8 +24,8 @@ import {
Tag Tag
} from '@prisma/client'; } from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { format } from 'date-fns'; import { endOfToday, format } from 'date-fns';
import { groupBy } from 'lodash'; import { groupBy, isNil } from 'lodash';
import { CashDetails } from './interfaces/cash-details.interface'; import { CashDetails } from './interfaces/cash-details.interface';
@ -36,12 +41,14 @@ export class AccountService {
public async account({ public async account({
id_userId id_userId
}: Prisma.AccountWhereUniqueInput): Promise<Account | null> { }: Prisma.AccountWhereUniqueInput): Promise<AccountWithBalance | null> {
const account = await this.prismaService.account.findUnique({ const account = await this.prismaService.account.findUnique({
include: { include: {
balances: { balances: {
orderBy: { date: 'desc' }, orderBy: { date: 'desc' },
take: 1 take: 1,
// Ignore account balances in the future
where: getWhereAccountBalanceNotInFuture()
} }
}, },
where: { id_userId } where: { id_userId }
@ -81,7 +88,7 @@ export class AccountService {
where?: Prisma.AccountWhereInput; where?: Prisma.AccountWhereInput;
orderBy?: Prisma.AccountOrderByWithRelationInput; orderBy?: Prisma.AccountOrderByWithRelationInput;
}): Promise< }): Promise<
(Account & { (AccountWithBalance & {
activities?: (Order & { SymbolProfile?: SymbolProfile })[]; activities?: (Order & { SymbolProfile?: SymbolProfile })[];
balances?: AccountBalance[]; balances?: AccountBalance[];
platform?: Platform; platform?: Platform;
@ -95,7 +102,16 @@ export class AccountService {
include.balances = { include.balances = {
orderBy: { date: 'desc' }, orderBy: { date: 'desc' },
...(isBalancesIncluded ? {} : { take: 1 }) // If the balances are included, they are returned as-is (including the
// ones in the future) because the client renders the full history. The
// balance is derived below and skips the account balances in the future.
...(isBalancesIncluded
? {}
: {
take: 1,
// Ignore account balances in the future
where: getWhereAccountBalanceNotInFuture()
})
}; };
if (isTagsIncluded) { if (isTagsIncluded) {
@ -115,10 +131,17 @@ export class AccountService {
where where
}); });
const endOfTodayDate = endOfToday();
return accounts.map((account) => { return accounts.map((account) => {
const result = { const result = {
...account, ...account,
balance: account.balances[0]?.value ?? 0, balance:
// The balances are ordered by date descending, hence the first account
// balance which is not in the future reflects the current balance
account.balances.find(({ date }) => {
return !isAccountBalanceInFuture({ date, endOfTodayDate });
})?.value ?? 0,
tags: isTagsIncluded tags: isTagsIncluded
? (account.tags as unknown as { tag: Tag }[]).map(({ tag }) => { ? (account.tags as unknown as { tag: Tag }[]).map(({ tag }) => {
return tag; return tag;
@ -138,12 +161,18 @@ export class AccountService {
}); });
} }
public async createAccount( public async createAccount({
data: Prisma.AccountCreateInput, balance,
aUserId: string, data,
tagIds?: string[] tagIds,
): Promise<Account> { userId
await this.tagService.validateTagIds({ tagIds, userId: aUserId }); }: {
balance?: number;
data: Prisma.AccountCreateInput;
tagIds?: string[];
userId: string;
}): Promise<Account> {
await this.tagService.validateTagIds({ tagIds, userId });
const account = await this.prismaService.account.create({ const account = await this.prismaService.account.create({
data: { data: {
@ -160,12 +189,14 @@ export class AccountService {
} }
}); });
if (!isNil(balance)) {
await this.accountBalanceService.createOrUpdateAccountBalance({ await this.accountBalanceService.createOrUpdateAccountBalance({
balance,
userId,
accountId: account.id, accountId: account.id,
balance: data.balance, date: format(new Date(), DATE_FORMAT)
date: format(new Date(), DATE_FORMAT),
userId: aUserId
}); });
}
this.eventEmitter.emit( this.eventEmitter.emit(
PortfolioChangedEvent.getName(), PortfolioChangedEvent.getName(),
@ -194,7 +225,7 @@ export class AccountService {
return account; return account;
} }
public async getAccounts(aUserId: string): Promise<Account[]> { public async getAccounts(aUserId: string): Promise<AccountWithBalance[]> {
const accounts = await this.accounts({ const accounts = await this.accounts({
include: { include: {
activities: true, activities: true,
@ -273,17 +304,20 @@ export class AccountService {
}; };
} }
public async updateAccount( public async updateAccount({
params: { balance,
data,
tagIds,
userId,
where
}: {
balance?: number;
data: Prisma.AccountUpdateInput; data: Prisma.AccountUpdateInput;
tagIds?: string[];
userId: string;
where: Prisma.AccountWhereUniqueInput; where: Prisma.AccountWhereUniqueInput;
}, }): Promise<Account> {
aUserId: string, await this.tagService.validateTagIds({ tagIds, userId });
tagIds?: string[]
): Promise<Account> {
const { data, where } = params;
await this.tagService.validateTagIds({ tagIds, userId: aUserId });
const account = await this.prismaService.account.update({ const account = await this.prismaService.account.update({
data: { data: {
@ -302,12 +336,14 @@ export class AccountService {
where where
}); });
if (!isNil(balance)) {
await this.accountBalanceService.createOrUpdateAccountBalance({ await this.accountBalanceService.createOrUpdateAccountBalance({
balance,
userId,
accountId: account.id, accountId: account.id,
balance: data.balance as number, date: format(new Date(), DATE_FORMAT)
date: format(new Date(), DATE_FORMAT),
userId: aUserId
}); });
}
this.eventEmitter.emit( this.eventEmitter.emit(
PortfolioChangedEvent.getName(), PortfolioChangedEvent.getName(),

4
apps/api/src/app/account/interfaces/cash-details.interface.ts

@ -1,6 +1,6 @@
import { Account } from '@prisma/client'; import { AccountWithBalance } from '@ghostfolio/common/types';
export interface CashDetails { export interface CashDetails {
accounts: Account[]; accounts: AccountWithBalance[];
balanceInBaseCurrency: number; balanceInBaseCurrency: number;
} }

21
apps/api/src/app/activities/activities-filter.dto.ts

@ -0,0 +1,21 @@
import { DATE_RANGE_PATTERN } from '@ghostfolio/api/dtos/date-range-filter.dto';
import { FilterDto } from '@ghostfolio/api/dtos/filter.dto';
import { DateRange } from '@ghostfolio/common/types';
import { Type as ActivityType } from '@prisma/client';
import { Transform, TransformFnParams } from 'class-transformer';
import { IsEnum, IsOptional, Matches } from 'class-validator';
import { isString } from 'lodash';
export class ActivitiesFilterDto extends FilterDto {
@IsEnum(ActivityType, { each: true })
@IsOptional()
@Transform(({ value }: TransformFnParams) => {
return isString(value) ? value.split(',') : value;
})
activityTypes?: ActivityType[];
@IsOptional()
@Matches(DATE_RANGE_PATTERN)
range?: DateRange;
}

97
apps/api/src/app/activities/activities.controller.ts

@ -18,7 +18,7 @@ import {
ActivityResponse ActivityResponse
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
import type { DateRange, RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Body, Body,
@ -29,7 +29,6 @@ import {
HttpException, HttpException,
Inject, Inject,
Param, Param,
ParseIntPipe,
Post, Post,
Put, Put,
Query, Query,
@ -38,11 +37,13 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { Order, Prisma, Type as ActivityType } from '@prisma/client'; import { Order } from '@prisma/client';
import { parseISO } from 'date-fns'; import { parseISO } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { ActivitiesFilterDto } from './activities-filter.dto';
import { ActivitiesService } from './activities.service'; import { ActivitiesService } from './activities.service';
import { GetActivitiesDto } from './get-activities.dto';
@Controller('activities') @Controller('activities')
export class ActivitiesController { export class ActivitiesController {
@ -60,22 +61,47 @@ export class ActivitiesController {
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
public async deleteActivities( public async deleteActivities(
@Query('accounts') filterByAccounts?: string, @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Query('assetClasses') filterByAssetClasses?: string, @Query()
@Query('dataSource') filterByDataSource?: string, {
@Query('symbol') filterBySymbol?: string, accounts,
@Query('tags') filterByTags?: string activityTypes,
assetClasses,
dataSource,
range,
symbol,
tags
}: ActivitiesFilterDto
): Promise<number> { ): Promise<number> {
if (impersonationId) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
let endDate: Date;
let startDate: Date;
if (range) {
({ endDate, startDate } = getIntervalFromDateRange({
dateRange: range
}));
}
const filters = this.apiService.buildFiltersFromQueryParams({ const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts, filterByAccounts: accounts,
filterByAssetClasses, filterByAssetClasses: assetClasses,
filterByDataSource, filterByDataSource: dataSource,
filterBySymbol, filterBySymbol: symbol,
filterByTags filterByTags: tags
}); });
return this.activitiesService.deleteActivities({ return this.activitiesService.deleteActivities({
endDate,
filters, filters,
startDate,
types: activityTypes,
userId: this.request.user.id userId: this.request.user.id
}); });
} }
@ -108,38 +134,41 @@ export class ActivitiesController {
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getAllActivities( public async getAllActivities(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Query('accounts') filterByAccounts?: string, @Query()
@Query('activityTypes') filterByTypes?: string, {
@Query('assetClasses') filterByAssetClasses?: string, accounts,
@Query('dataSource') filterByDataSource?: string, activityTypes,
@Query('range') dateRange?: DateRange, assetClasses,
@Query('skip', new ParseIntPipe({ optional: true })) skip?: number, dataSource,
@Query('sortColumn') sortColumn?: string, range,
@Query('sortDirection') sortDirection?: Prisma.SortOrder, skip,
@Query('symbol') filterBySymbol?: string, sortColumn,
@Query('tags') filterByTags?: string, sortDirection,
@Query('take', new ParseIntPipe({ optional: true })) take?: number symbol,
tags,
take
}: GetActivitiesDto
): Promise<ActivitiesResponse> { ): Promise<ActivitiesResponse> {
let endDate: Date; let endDate: Date;
let startDate: Date; let startDate: Date;
if (dateRange) { if (range) {
({ endDate, startDate } = getIntervalFromDateRange({ dateRange })); ({ endDate, startDate } = getIntervalFromDateRange({
dateRange: range
}));
} }
const filters = this.apiService.buildFiltersFromQueryParams({ const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts, filterByAccounts: accounts,
filterByAssetClasses, filterByAssetClasses: assetClasses,
filterByDataSource, filterByDataSource: dataSource,
filterBySymbol, filterBySymbol: symbol,
filterByTags filterByTags: tags
}); });
const impersonationUserId = const impersonationUserId =
await this.impersonationService.validateImpersonationId(impersonationId); await this.impersonationService.validateImpersonationId(impersonationId);
const types = (filterByTypes?.split(',') as ActivityType[]) ?? [];
const userCurrency = this.request.user.settings.settings.baseCurrency; const userCurrency = this.request.user.settings.settings.baseCurrency;
const { activities, count } = await this.activitiesService.getActivities({ const { activities, count } = await this.activitiesService.getActivities({
@ -150,9 +179,9 @@ export class ActivitiesController {
sortDirection, sortDirection,
startDate, startDate,
take, take,
types,
userCurrency, userCurrency,
includeDrafts: true, includeDrafts: true,
types: activityTypes,
userId: impersonationUserId || this.request.user.id, userId: impersonationUserId || this.request.user.id,
withExcludedAccountsAndActivities: true withExcludedAccountsAndActivities: true
}); });

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

@ -3,7 +3,10 @@ import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { CashDetails } from '@ghostfolio/api/app/account/interfaces/cash-details.interface'; import { CashDetails } from '@ghostfolio/api/app/account/interfaces/cash-details.interface';
import { AssetProfileChangedEvent } from '@ghostfolio/api/events/asset-profile-changed.event'; import { AssetProfileChangedEvent } from '@ghostfolio/api/events/asset-profile-changed.event';
import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event'; import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event';
import { WHERE_ACCOUNT_NOT_EXCLUDED } from '@ghostfolio/api/helper/account.helper'; import {
isAccountBalanceInFuture,
WHERE_ACCOUNT_NOT_EXCLUDED
} from '@ghostfolio/api/helper/account.helper';
import { LogPerformance } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor'; import { LogPerformance } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor';
import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service'; import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
@ -17,12 +20,13 @@ import {
DATA_GATHERING_QUEUE_PRIORITY_HIGH, DATA_GATHERING_QUEUE_PRIORITY_HIGH,
GATHER_ASSET_PROFILE_PROCESS_JOB_NAME, GATHER_ASSET_PROFILE_PROCESS_JOB_NAME,
GATHER_ASSET_PROFILE_PROCESS_JOB_OPTIONS, GATHER_ASSET_PROFILE_PROCESS_JOB_OPTIONS,
ghostfolioPrefix, NON_INVESTMENT_ACTIVITY_TYPES,
TAG_ID_EXCLUDE_FROM_ANALYSIS TAG_ID_EXCLUDE_FROM_ANALYSIS
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { import {
canDeleteAssetProfile, canDeleteAssetProfile,
getAssetProfileIdentifier getAssetProfileIdentifier,
isValidCustomAssetProfileSymbol
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
import { import {
ActivitiesResponse, ActivitiesResponse,
@ -45,7 +49,6 @@ import {
Type as ActivityType Type as ActivityType
} from '@prisma/client'; } from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { isUUID } from 'class-validator';
import { endOfToday, isAfter } from 'date-fns'; import { endOfToday, isAfter } from 'date-fns';
import { groupBy, uniqBy } from 'lodash'; import { groupBy, uniqBy } from 'lodash';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
@ -189,7 +192,7 @@ export class ActivitiesService {
const userId = data.userId; const userId = data.userId;
if ( if (
['FEE', 'INTEREST', 'LIABILITY'].includes(data.type) || NON_INVESTMENT_ACTIVITY_TYPES.includes(data.type) ||
(data.SymbolProfile.connectOrCreate.create.dataSource === 'MANUAL' && (data.SymbolProfile.connectOrCreate.create.dataSource === 'MANUAL' &&
data.type === 'BUY') data.type === 'BUY')
) { ) {
@ -201,10 +204,9 @@ export class ActivitiesService {
let symbol: string; let symbol: string;
if ( if (
data.SymbolProfile.connectOrCreate.create.symbol.startsWith( isValidCustomAssetProfileSymbol(
`${ghostfolioPrefix}_` data.SymbolProfile.connectOrCreate.create.symbol
) || )
isUUID(data.SymbolProfile.connectOrCreate.create.symbol)
) { ) {
// Connect custom asset profile (clone) // Connect custom asset profile (clone)
symbol = data.SymbolProfile.connectOrCreate.create.symbol; symbol = data.SymbolProfile.connectOrCreate.create.symbol;
@ -259,7 +261,7 @@ export class ActivitiesService {
const orderData: Prisma.OrderCreateInput = data; const orderData: Prisma.OrderCreateInput = data;
const isDraft = ['FEE', 'INTEREST', 'LIABILITY'].includes(data.type) const isDraft = NON_INVESTMENT_ACTIVITY_TYPES.includes(data.type)
? false ? false
: isAfter(data.date as Date, endOfToday()); : isAfter(data.date as Date, endOfToday());
@ -358,14 +360,23 @@ export class ActivitiesService {
} }
public async deleteActivities({ public async deleteActivities({
endDate,
filters, filters,
startDate,
types,
userId userId
}: { }: {
endDate?: Date;
filters?: Filter[]; filters?: Filter[];
startDate?: Date;
types?: ActivityType[];
userId: string; userId: string;
}): Promise<number> { }): Promise<number> {
const { activities } = await this.getActivities({ const { activities } = await this.getActivities({
endDate,
filters, filters,
startDate,
types,
userId, userId,
includeDrafts: true, includeDrafts: true,
userCurrency: undefined, userCurrency: undefined,
@ -456,6 +467,7 @@ export class ActivitiesService {
} }
const activities: Activity[] = []; const activities: Activity[] = [];
const endOfTodayDate = endOfToday();
for (const account of cashDetails.accounts) { for (const account of cashDetails.accounts) {
const { balances } = await this.accountBalanceService.getAccountBalances({ const { balances } = await this.accountBalanceService.getAccountBalances({
@ -468,6 +480,15 @@ export class ActivitiesService {
let currentBalanceInBaseCurrency = 0; let currentBalanceInBaseCurrency = 0;
for (const balanceItem of balances) { for (const balanceItem of balances) {
if (
isAccountBalanceInFuture({
endOfTodayDate,
date: balanceItem.date
})
) {
continue;
}
const syntheticActivityTemplate: Activity = { const syntheticActivityTemplate: Activity = {
userId, userId,
accountId: account.id, accountId: account.id,
@ -961,7 +982,7 @@ export class ActivitiesService {
let isDraft = false; let isDraft = false;
if ( if (
['FEE', 'INTEREST', 'LIABILITY'].includes(data.type) || NON_INVESTMENT_ACTIVITY_TYPES.includes(data.type) ||
(data.SymbolProfile.connect.dataSource_symbol.dataSource === 'MANUAL' && (data.SymbolProfile.connect.dataSource_symbol.dataSource === 'MANUAL' &&
data.type === 'BUY') data.type === 'BUY')
) { ) {

27
apps/api/src/app/activities/get-activities.dto.ts

@ -0,0 +1,27 @@
import { Prisma } from '@prisma/client';
import { Type } from 'class-transformer';
import { IsIn, IsInt, IsOptional, Min } from 'class-validator';
import { ActivitiesFilterDto } from './activities-filter.dto';
export class GetActivitiesDto extends ActivitiesFilterDto {
@IsInt()
@IsOptional()
@Min(0)
@Type(() => Number)
skip?: number;
@IsIn(Object.values(Prisma.OrderScalarFieldEnum))
@IsOptional()
sortColumn?: keyof typeof Prisma.OrderScalarFieldEnum;
@IsIn(['asc', 'desc'] as Prisma.SortOrder[])
@IsOptional()
sortDirection?: Prisma.SortOrder;
@IsInt()
@IsOptional()
@Min(0)
@Type(() => Number)
take?: number;
}

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

@ -7,6 +7,7 @@ import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { import {
ghostfolioPrefix,
PROPERTY_CURRENCIES, PROPERTY_CURRENCIES,
PROPERTY_IS_READ_ONLY_MODE, PROPERTY_IS_READ_ONLY_MODE,
PROPERTY_IS_USER_SIGNUP_ENABLED PROPERTY_IS_USER_SIGNUP_ENABLED
@ -14,7 +15,8 @@ import {
import { import {
applyAssetProfileOverrides, applyAssetProfileOverrides,
getAssetProfileIdentifier, getAssetProfileIdentifier,
getCurrencyFromSymbol getCurrencyFromSymbol,
hasGhostfolioPrefix
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
import { import {
AdminData, AdminData,
@ -63,6 +65,12 @@ export class AdminService {
> { > {
try { try {
if (dataSource === 'MANUAL') { if (dataSource === 'MANUAL') {
if (!hasGhostfolioPrefix(symbol)) {
throw new BadRequestException(
`symbol ("${symbol}") must start with the prefix "${ghostfolioPrefix}_" for the data source ("${dataSource}")`
);
}
return this.symbolProfileService.add({ return this.symbolProfileService.add({
currency, currency,
dataSource, dataSource,
@ -107,8 +115,11 @@ export class AdminService {
await this.marketDataService.deleteMany({ dataSource, symbol }); await this.marketDataService.deleteMany({ dataSource, symbol });
const currency = getCurrencyFromSymbol(symbol); const currency = getCurrencyFromSymbol(symbol);
const customCurrencies =
await this.propertyService.getByKey<string[]>(PROPERTY_CURRENCIES); const customCurrencies = await this.propertyService.getByKey<string[]>(
PROPERTY_CURRENCIES,
{ skipCache: true }
);
if (customCurrencies.includes(currency)) { if (customCurrencies.includes(currency)) {
const updatedCustomCurrencies = customCurrencies.filter( const updatedCustomCurrencies = customCurrencies.filter(

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

@ -1,4 +1,5 @@
import { EventsModule } from '@ghostfolio/api/events/events.module'; import { EventsModule } from '@ghostfolio/api/events/events.module';
import { PortfolioSnapshotComputationExceptionFilter } from '@ghostfolio/api/filters/portfolio-snapshot-computation-exception.filter';
import { getRedisConnectionOptions } from '@ghostfolio/api/helper/redis.helper'; import { getRedisConnectionOptions } from '@ghostfolio/api/helper/redis.helper';
import { BullBoardAuthMiddleware } from '@ghostfolio/api/middlewares/bull-board-auth.middleware'; import { BullBoardAuthMiddleware } from '@ghostfolio/api/middlewares/bull-board-auth.middleware';
import { HtmlTemplateMiddleware } from '@ghostfolio/api/middlewares/html-template.middleware'; import { HtmlTemplateMiddleware } from '@ghostfolio/api/middlewares/html-template.middleware';
@ -24,6 +25,7 @@ import { ThrottlerStorageRedisService } from '@nest-lab/throttler-storage-redis'
import { BullModule } from '@nestjs/bull'; import { BullModule } from '@nestjs/bull';
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common'; import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config'; import { ConfigModule } from '@nestjs/config';
import { APP_FILTER } from '@nestjs/core';
import { EventEmitterModule } from '@nestjs/event-emitter'; import { EventEmitterModule } from '@nestjs/event-emitter';
import { ScheduleModule } from '@nestjs/schedule'; import { ScheduleModule } from '@nestjs/schedule';
import { ServeStaticModule } from '@nestjs/serve-static'; import { ServeStaticModule } from '@nestjs/serve-static';
@ -184,7 +186,13 @@ import { UserModule } from './user/user.module';
UserModule, UserModule,
WatchlistModule WatchlistModule
], ],
providers: [I18nService] providers: [
I18nService,
{
provide: APP_FILTER,
useClass: PortfolioSnapshotComputationExceptionFilter
}
]
}) })
export class AppModule implements NestModule { export class AppModule implements NestModule {
public configure(consumer: MiddlewareConsumer) { public configure(consumer: MiddlewareConsumer) {

3
apps/api/src/app/asset/asset.controller.ts

@ -20,7 +20,7 @@ export class AssetController {
@Param('dataSource') dataSource: DataSource, @Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string @Param('symbol') symbol: string
): Promise<AssetResponse> { ): Promise<AssetResponse> {
const { assetProfile, marketData } = const { assetProfile, marketData, splits } =
await this.assetProfilesService.getAssetProfile({ await this.assetProfilesService.getAssetProfile({
dataSource, dataSource,
symbol symbol
@ -28,6 +28,7 @@ export class AssetController {
return { return {
marketData, marketData,
splits,
assetProfile: pick(assetProfile, ['dataSource', 'name', 'symbol']) assetProfile: pick(assetProfile, ['dataSource', 'name', 'symbol'])
}; };
} }

7
apps/api/src/app/auth/api-key.strategy.ts

@ -35,6 +35,13 @@ export class ApiKeyStrategy extends PassportStrategy(
); );
} }
if (await this.userService.isDailyRequestLimitExceeded({ user })) {
throw new HttpException(
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
StatusCodes.TOO_MANY_REQUESTS
);
}
await this.prismaService.analytics.upsert({ await this.prismaService.analytics.upsert({
create: { user: { connect: { id: user.id } } }, create: { user: { connect: { id: user.id } } },
update: { update: {

7
apps/api/src/app/auth/jwt.strategy.ts

@ -42,6 +42,13 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
); );
} }
if (await this.userService.isDailyRequestLimitExceeded({ user })) {
throw new HttpException(
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
StatusCodes.TOO_MANY_REQUESTS
);
}
const country = const country =
countriesAndTimezones.getCountryForTimezone(timezone)?.id; countriesAndTimezones.getCountryForTimezone(timezone)?.id;

18
apps/api/src/app/endpoints/ai/ai.controller.ts

@ -1,4 +1,5 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { FilterDto } from '@ghostfolio/api/dtos/filter.dto';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { ApiService } from '@ghostfolio/api/services/api/api.service'; import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { AiPromptResponse } from '@ghostfolio/common/interfaces'; import { AiPromptResponse } from '@ghostfolio/common/interfaces';
@ -31,18 +32,15 @@ export class AiController {
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getPrompt( public async getPrompt(
@Param('mode') mode: AiPromptMode, @Param('mode') mode: AiPromptMode,
@Query('accounts') filterByAccounts?: string, @Query()
@Query('assetClasses') filterByAssetClasses?: string, { accounts, assetClasses, dataSource, symbol, tags }: FilterDto
@Query('dataSource') filterByDataSource?: string,
@Query('symbol') filterBySymbol?: string,
@Query('tags') filterByTags?: string
): Promise<AiPromptResponse> { ): Promise<AiPromptResponse> {
const filters = this.apiService.buildFiltersFromQueryParams({ const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts, filterByAccounts: accounts,
filterByAssetClasses, filterByAssetClasses: assetClasses,
filterByDataSource, filterByDataSource: dataSource,
filterBySymbol, filterBySymbol: symbol,
filterByTags filterByTags: tags
}); });
const prompt = await this.aiService.getPrompt({ const prompt = await this.aiService.getPrompt({

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

@ -4,7 +4,10 @@ import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interce
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor'; import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
import { ApiService } from '@ghostfolio/api/services/api/api.service'; import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { UpdateAssetProfileDataDto } from '@ghostfolio/common/dtos'; import {
CreateAssetProfileSplitDto,
UpdateAssetProfileDataDto
} from '@ghostfolio/common/dtos';
import { getCurrencyFromSymbol, isCurrency } from '@ghostfolio/common/helper'; import { getCurrencyFromSymbol, isCurrency } from '@ghostfolio/common/helper';
import { AssetProfileResponse } from '@ghostfolio/common/interfaces'; import { AssetProfileResponse } from '@ghostfolio/common/interfaces';
import { import {
@ -18,19 +21,22 @@ import { MarketDataPreset, RequestWithUser } from '@ghostfolio/common/types';
import { import {
Body, Body,
Controller, Controller,
Delete,
Get, Get,
HttpException, HttpException,
Inject, Inject,
Param, Param,
ParseIntPipe, ParseIntPipe,
Patch, Patch,
Post,
Query, Query,
UseGuards, UseGuards,
UseInterceptors UseInterceptors
} from '@nestjs/common'; } from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { DataSource, Prisma } from '@prisma/client'; import { AssetProfileSplit, DataSource, Prisma } from '@prisma/client';
import { parseISO } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { AssetProfilesService } from './asset-profiles.service'; import { AssetProfilesService } from './asset-profiles.service';
@ -119,6 +125,51 @@ export class AssetProfilesController {
}); });
} }
@Post(':dataSource/:symbol/splits')
@UseGuards(AuthGuard('jwt'))
@UseInterceptors(TransformDataSourceInRequestInterceptor)
public async createSplit(
@Body() data: CreateAssetProfileSplitDto,
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
): Promise<AssetProfileSplit> {
const { id: symbolProfileId } = await this.validateAccessToSplits({
dataSource,
symbol,
permission: permissions.createAssetProfileSplit,
permissionOfOwnAssetProfile:
permissions.createAssetProfileSplitOfOwnAssetProfile
});
return this.assetProfilesService.createSplit({
dataSource,
symbol,
symbolProfileId,
date: parseISO(data.date),
denominator: data.denominator,
numerator: data.numerator
});
}
@Delete(':dataSource/:symbol/splits/:id')
@UseGuards(AuthGuard('jwt'))
@UseInterceptors(TransformDataSourceInRequestInterceptor)
public async deleteSplit(
@Param('dataSource') dataSource: DataSource,
@Param('id') id: string,
@Param('symbol') symbol: string
): Promise<void> {
const { id: symbolProfileId } = await this.validateAccessToSplits({
dataSource,
symbol,
permission: permissions.deleteAssetProfileSplit,
permissionOfOwnAssetProfile:
permissions.deleteAssetProfileSplitOfOwnAssetProfile
});
return this.assetProfilesService.deleteSplit({ id, symbolProfileId });
}
@HasPermission(permissions.accessAdminControl) @HasPermission(permissions.accessAdminControl)
@Patch(':dataSource/:symbol') @Patch(':dataSource/:symbol')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@ -139,4 +190,47 @@ export class AssetProfilesController {
assetProfileData assetProfileData
); );
} }
private async validateAccessToSplits({
dataSource,
permission,
permissionOfOwnAssetProfile,
symbol
}: {
dataSource: DataSource;
permission: string;
permissionOfOwnAssetProfile: string;
symbol: string;
}) {
const [assetProfile] = await this.symbolProfileService.getSymbolProfiles([
{ dataSource, symbol }
]);
if (!assetProfile) {
throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
);
}
const canAccessAllAssetProfiles = hasPermission(
this.request.user.permissions,
permission
);
const canAccessOwnAssetProfile =
assetProfile.userId === this.request.user.id &&
hasPermission(this.request.user.permissions, permissionOfOwnAssetProfile);
if (!canAccessAllAssetProfiles && !canAccessOwnAssetProfile) {
throw new HttpException(
assetProfile.userId
? getReasonPhrase(StatusCodes.NOT_FOUND)
: getReasonPhrase(StatusCodes.FORBIDDEN),
assetProfile.userId ? StatusCodes.NOT_FOUND : StatusCodes.FORBIDDEN
);
}
return assetProfile;
}
} }

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

@ -2,11 +2,13 @@ import { ActivitiesModule } from '@ghostfolio/api/app/activities/activities.modu
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module'; import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module'; import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module';
import { ApiModule } from '@ghostfolio/api/services/api/api.module'; import { ApiModule } from '@ghostfolio/api/services/api/api.module';
import { AssetProfileSplitModule } from '@ghostfolio/api/services/asset-profile-split/asset-profile-split.module';
import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module'; import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module'; import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { DataGatheringQueueModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
@ -20,7 +22,9 @@ import { AssetProfilesService } from './asset-profiles.service';
imports: [ imports: [
ActivitiesModule, ActivitiesModule,
ApiModule, ApiModule,
AssetProfileSplitModule,
BenchmarkModule, BenchmarkModule,
DataGatheringQueueModule,
DataProviderModule, DataProviderModule,
ExchangeRateDataModule, ExchangeRateDataModule,
MarketDataModule, MarketDataModule,

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

@ -1,9 +1,11 @@
import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service'; import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service';
import { AssetProfileSplitService } from '@ghostfolio/api/services/asset-profile-split/asset-profile-split.service';
import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service'; import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { UpdateAssetProfileDataDto } from '@ghostfolio/common/dtos'; import { UpdateAssetProfileDataDto } from '@ghostfolio/common/dtos';
import { import {
@ -30,7 +32,9 @@ import { groupBy } from 'lodash';
export class AssetProfilesService { export class AssetProfilesService {
public constructor( public constructor(
private readonly activitiesService: ActivitiesService, private readonly activitiesService: ActivitiesService,
private readonly assetProfileSplitService: AssetProfileSplitService,
private readonly benchmarkService: BenchmarkService, private readonly benchmarkService: BenchmarkService,
private readonly dataGatheringService: DataGatheringService,
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly marketDataService: MarketDataService, private readonly marketDataService: MarketDataService,
@ -38,6 +42,48 @@ export class AssetProfilesService {
private readonly symbolProfileService: SymbolProfileService private readonly symbolProfileService: SymbolProfileService
) {} ) {}
public async createSplit({
dataSource,
date,
denominator,
numerator,
symbol,
symbolProfileId
}: {
date: Date;
denominator: number;
numerator: number;
symbolProfileId: string;
} & AssetProfileIdentifier) {
const assetProfileSplit = await this.assetProfileSplitService.upsert({
date,
denominator,
numerator,
symbolProfileId
});
await this.dataGatheringService.gatherSymbol({ dataSource, symbol });
return assetProfileSplit;
}
public async deleteSplit({
id,
symbolProfileId
}: {
id: string;
symbolProfileId: string;
}) {
const isDeleted = await this.assetProfileSplitService.deleteById({
id,
symbolProfileId
});
if (!isDeleted) {
throw new NotFoundException();
}
}
public async getAssetProfile({ public async getAssetProfile({
dataSource, dataSource,
symbol symbol
@ -54,7 +100,7 @@ export class AssetProfilesService {
await this.activitiesService.getStatisticsByCurrency(currency)); await this.activitiesService.getStatisticsByCurrency(currency));
} }
const [[assetProfile], marketData] = await Promise.all([ const [[assetProfile], marketData, splits] = await Promise.all([
this.symbolProfileService.getSymbolProfiles([ this.symbolProfileService.getSymbolProfiles([
{ {
dataSource, dataSource,
@ -69,7 +115,8 @@ export class AssetProfilesService {
dataSource, dataSource,
symbol symbol
} }
}) }),
this.assetProfileSplitService.getSplits({ dataSource, symbol })
]); ]);
if (assetProfile) { if (assetProfile) {
@ -80,6 +127,7 @@ export class AssetProfilesService {
return { return {
marketData, marketData,
splits,
assetProfile: assetProfile ?? { assetProfile: assetProfile ?? {
activitiesCount, activitiesCount,
currency, currency,
@ -92,6 +140,7 @@ export class AssetProfilesService {
} }
}; };
} }
public async getAssetProfiles({ public async getAssetProfiles({
filters = [], filters = [],
presetId, presetId,

37
apps/api/src/app/endpoints/benchmarks/benchmarks.controller.ts

@ -5,17 +5,14 @@ import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interc
import { ApiService } from '@ghostfolio/api/services/api/api.service'; import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service'; import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service';
import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper'; import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper';
import { import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
DEFAULT_DATE_RANGE,
HEADER_KEY_IMPERSONATION
} from '@ghostfolio/common/config';
import type { import type {
AssetProfileIdentifier, AssetProfileIdentifier,
BenchmarkMarketDataDetailsResponse, BenchmarkMarketDataDetailsResponse,
BenchmarkResponse BenchmarkResponse
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
import type { DateRange, RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Body, Body,
@ -37,6 +34,7 @@ import { DataSource } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { BenchmarksService } from './benchmarks.service'; import { BenchmarksService } from './benchmarks.service';
import { GetBenchmarkMarketDataDto } from './get-benchmark-market-data.dto';
@Controller('benchmarks') @Controller('benchmarks')
export class BenchmarksController { export class BenchmarksController {
@ -121,38 +119,39 @@ export class BenchmarksController {
@Param('dataSource') dataSource: DataSource, @Param('dataSource') dataSource: DataSource,
@Param('startDateString') startDateString: string, @Param('startDateString') startDateString: string,
@Param('symbol') symbol: string, @Param('symbol') symbol: string,
@Query('range') dateRange: DateRange = DEFAULT_DATE_RANGE, @Query()
@Query('accounts') filterByAccounts?: string, {
@Query('assetClasses') filterByAssetClasses?: string, accounts,
@Query('dataSource') filterByDataSource?: string, assetClasses,
@Query('symbol') filterBySymbol?: string, dataSource: filterByDataSource,
@Query('tags') filterByTags?: string, range,
@Query('withExcludedAccounts') withExcludedAccountsParam = 'false' symbol: filterBySymbol,
tags,
withExcludedAccounts
}: GetBenchmarkMarketDataDto
): Promise<BenchmarkMarketDataDetailsResponse> { ): Promise<BenchmarkMarketDataDetailsResponse> {
const { endDate, startDate } = getIntervalFromDateRange({ const { endDate, startDate } = getIntervalFromDateRange({
dateRange, dateRange: range,
startDate: new Date(startDateString) startDate: new Date(startDateString)
}); });
const filters = this.apiService.buildFiltersFromQueryParams({ const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts,
filterByAssetClasses,
filterByDataSource, filterByDataSource,
filterBySymbol, filterBySymbol,
filterByTags filterByAccounts: accounts,
filterByAssetClasses: assetClasses,
filterByTags: tags
}); });
const withExcludedAccounts = withExcludedAccountsParam === 'true';
return this.benchmarksService.getMarketDataForUser({ return this.benchmarksService.getMarketDataForUser({
dataSource, dataSource,
dateRange,
endDate, endDate,
filters, filters,
impersonationId, impersonationId,
startDate, startDate,
symbol, symbol,
withExcludedAccounts, withExcludedAccounts,
dateRange: range,
user: this.request.user user: this.request.user
}); });
} }

12
apps/api/src/app/endpoints/benchmarks/get-benchmark-market-data.dto.ts

@ -0,0 +1,12 @@
import { DateRangeFilterDto } from '@ghostfolio/api/dtos/date-range-filter.dto';
import { Transform, TransformFnParams } from 'class-transformer';
import { IsBoolean } from 'class-validator';
export class GetBenchmarkMarketDataDto extends DateRangeFilterDto {
@IsBoolean()
@Transform(({ value }: TransformFnParams) => {
return value === 'true';
})
withExcludedAccounts? = false;
}

32
apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.controller.ts

@ -51,7 +51,7 @@ export class GhostfolioController {
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests(); const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests();
if ( if (
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests this.request.user.dataProviderGhostfolioDailyRequests >= maxDailyRequests
) { ) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS), getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
@ -90,12 +90,12 @@ export class GhostfolioController {
@Version('2') @Version('2')
public async getDividends( public async getDividends(
@Param('symbol') symbol: string, @Param('symbol') symbol: string,
@Query() query: GetDividendsDto @Query() { from, granularity, to }: GetDividendsDto
): Promise<DividendsResponse> { ): Promise<DividendsResponse> {
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests(); const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests();
if ( if (
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests this.request.user.dataProviderGhostfolioDailyRequests >= maxDailyRequests
) { ) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS), getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
@ -105,10 +105,10 @@ export class GhostfolioController {
try { try {
const dividends = await this.ghostfolioService.getDividends({ const dividends = await this.ghostfolioService.getDividends({
granularity,
symbol, symbol,
from: parseDate(query.from), from: parseDate(from),
granularity: query.granularity, to: parseDate(to)
to: parseDate(query.to)
}); });
await this.ghostfolioService.incrementDailyRequests({ await this.ghostfolioService.incrementDailyRequests({
@ -130,12 +130,12 @@ export class GhostfolioController {
@Version('2') @Version('2')
public async getHistorical( public async getHistorical(
@Param('symbol') symbol: string, @Param('symbol') symbol: string,
@Query() query: GetHistoricalDto @Query() { from, granularity, to }: GetHistoricalDto
): Promise<HistoricalResponse> { ): Promise<HistoricalResponse> {
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests(); const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests();
if ( if (
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests this.request.user.dataProviderGhostfolioDailyRequests >= maxDailyRequests
) { ) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS), getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
@ -145,10 +145,10 @@ export class GhostfolioController {
try { try {
const historicalData = await this.ghostfolioService.getHistorical({ const historicalData = await this.ghostfolioService.getHistorical({
granularity,
symbol, symbol,
from: parseDate(query.from), from: parseDate(from),
granularity: query.granularity, to: parseDate(to)
to: parseDate(query.to)
}); });
await this.ghostfolioService.incrementDailyRequests({ await this.ghostfolioService.incrementDailyRequests({
@ -176,7 +176,7 @@ export class GhostfolioController {
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests(); const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests();
if ( if (
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests this.request.user.dataProviderGhostfolioDailyRequests >= maxDailyRequests
) { ) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS), getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
@ -215,7 +215,7 @@ export class GhostfolioController {
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests(); const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests();
if ( if (
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests this.request.user.dataProviderGhostfolioDailyRequests >= maxDailyRequests
) { ) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS), getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
@ -247,12 +247,12 @@ export class GhostfolioController {
@UseGuards(AuthGuard('api-key'), HasPermissionGuard) @UseGuards(AuthGuard('api-key'), HasPermissionGuard)
@Version('2') @Version('2')
public async getQuotes( public async getQuotes(
@Query() query: GetQuotesDto @Query() { symbols }: GetQuotesDto
): Promise<QuotesResponse> { ): Promise<QuotesResponse> {
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests(); const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests();
if ( if (
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests this.request.user.dataProviderGhostfolioDailyRequests >= maxDailyRequests
) { ) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS), getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
@ -262,7 +262,7 @@ export class GhostfolioController {
try { try {
const quotes = await this.ghostfolioService.getQuotes({ const quotes = await this.ghostfolioService.getQuotes({
symbols: query.symbols symbols
}); });
await this.ghostfolioService.incrementDailyRequests({ await this.ghostfolioService.incrementDailyRequests({

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

@ -17,7 +17,10 @@ import {
DERIVED_CURRENCIES DERIVED_CURRENCIES
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { PROPERTY_DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER_MAX_REQUESTS } from '@ghostfolio/common/config'; import { PROPERTY_DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER_MAX_REQUESTS } from '@ghostfolio/common/config';
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper'; import {
getAssetProfileIdentifier,
isValidSearchQuery
} from '@ghostfolio/common/helper';
import { import {
DataProviderGhostfolioAssetProfileResponse, DataProviderGhostfolioAssetProfileResponse,
DataProviderHistoricalResponse, DataProviderHistoricalResponse,
@ -326,8 +329,12 @@ export class GhostfolioService {
} }
public async incrementDailyRequests({ userId }: { userId: string }) { public async incrementDailyRequests({ userId }: { userId: string }) {
await this.prismaService.analytics.update({ await this.prismaService.analytics.upsert({
data: { create: {
dataProviderGhostfolioDailyRequests: 1,
user: { connect: { id: userId } }
},
update: {
dataProviderGhostfolioDailyRequests: { increment: 1 } dataProviderGhostfolioDailyRequests: { increment: 1 }
}, },
where: { userId } where: { userId }
@ -340,7 +347,9 @@ export class GhostfolioService {
}: GetSearchParams): Promise<LookupResponse> { }: GetSearchParams): Promise<LookupResponse> {
const results: LookupResponse = { items: [] }; const results: LookupResponse = { items: [] };
if (!query) { query = query?.trim();
if (!isValidSearchQuery(query)) {
return results; return results;
} }
@ -348,10 +357,6 @@ export class GhostfolioService {
let lookupItems: LookupItem[] = []; let lookupItems: LookupItem[] = [];
const promises: Promise<{ items: LookupItem[] }>[] = []; const promises: Promise<{ items: LookupItem[] }>[] = [];
if (query?.length < 2) {
return { items: lookupItems };
}
for (const dataProviderService of this.getDataProviderServices()) { for (const dataProviderService of this.getDataProviderServices()) {
promises.push( promises.push(
dataProviderService.search({ dataProviderService.search({

5
apps/api/src/app/endpoints/tags/tags.controller.ts

@ -2,6 +2,7 @@ import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorat
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { TagService } from '@ghostfolio/api/services/tag/tag.service'; import { TagService } from '@ghostfolio/api/services/tag/tag.service';
import { CreateTagDto, UpdateTagDto } from '@ghostfolio/common/dtos'; import { CreateTagDto, UpdateTagDto } from '@ghostfolio/common/dtos';
import { isSystemTag } from '@ghostfolio/common/helper';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types'; import { RequestWithUser } from '@ghostfolio/common/types';
@ -69,7 +70,7 @@ export class TagsController {
id id
}); });
if (!originalTag) { if (!originalTag || isSystemTag(originalTag)) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN), getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN StatusCodes.FORBIDDEN
@ -94,7 +95,7 @@ export class TagsController {
id id
}); });
if (!originalTag) { if (!originalTag || isSystemTag(originalTag)) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN), getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN StatusCodes.FORBIDDEN

43
apps/api/src/app/export/export.controller.ts

@ -2,6 +2,7 @@ import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor'; import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor'; import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
import { ApiService } from '@ghostfolio/api/services/api/api.service'; import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper';
import { ExportResponse } from '@ghostfolio/common/interfaces'; import { ExportResponse } from '@ghostfolio/common/interfaces';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
@ -15,9 +16,9 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { Type as ActivityType } from '@prisma/client';
import { ExportService } from './export.service'; import { ExportService } from './export.service';
import { GetExportDto } from './get-export.dto';
@Controller('export') @Controller('export')
export class ExportController { export class ExportController {
@ -32,29 +33,41 @@ export class ExportController {
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
public async export( public async export(
@Query('accounts') filterByAccounts?: string, @Query()
@Query('activityIds') filterByActivityIds?: string, {
@Query('activityTypes') filterByTypes?: string, accounts,
@Query('assetClasses') filterByAssetClasses?: string, activityIds,
@Query('dataSource') filterByDataSource?: string, activityTypes,
@Query('symbol') filterBySymbol?: string, assetClasses,
@Query('tags') filterByTags?: string dataSource,
range,
symbol,
tags
}: GetExportDto
): Promise<ExportResponse> { ): Promise<ExportResponse> {
const activityIds = filterByActivityIds?.split(',') ?? []; let endDate: Date;
const activityTypes = (filterByTypes?.split(',') as ActivityType[]) ?? []; let startDate: Date;
if (range) {
({ endDate, startDate } = getIntervalFromDateRange({
dateRange: range
}));
}
const filters = this.apiService.buildFiltersFromQueryParams({ const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts, filterByAccounts: accounts,
filterByAssetClasses, filterByAssetClasses: assetClasses,
filterByDataSource, filterByDataSource: dataSource,
filterBySymbol, filterBySymbol: symbol,
filterByTags filterByTags: tags
}); });
return this.exportService.export({ return this.exportService.export({
activityIds, activityIds,
activityTypes, activityTypes,
endDate,
filters, filters,
startDate,
userId: this.request.user.id, userId: this.request.user.id,
userSettings: this.request.user.settings.settings userSettings: this.request.user.settings.settings
}); });

21
apps/api/src/app/export/export.service.ts

@ -25,13 +25,17 @@ export class ExportService {
public async export({ public async export({
activityIds, activityIds,
activityTypes, activityTypes,
endDate,
filters, filters,
startDate,
userId, userId,
userSettings userSettings
}: { }: {
activityIds?: string[]; activityIds?: string[];
activityTypes?: ActivityType[]; activityTypes?: ActivityType[];
endDate?: Date;
filters?: Filter[]; filters?: Filter[];
startDate?: Date;
userId: string; userId: string;
userSettings: UserSettings; userSettings: UserSettings;
}): Promise<ExportResponse> { }): Promise<ExportResponse> {
@ -41,7 +45,9 @@ export class ExportService {
const platformsMap: { [platformId: string]: Platform } = {}; const platformsMap: { [platformId: string]: Platform } = {};
let { activities } = await this.activitiesService.getActivities({ let { activities } = await this.activitiesService.getActivities({
endDate,
filters, filters,
startDate,
userId, userId,
includeDrafts: true, includeDrafts: true,
sortColumn: 'date', sortColumn: 'date',
@ -67,6 +73,13 @@ export class ExportService {
}; };
} }
const isFilteredExport =
activityIds?.length > 0 ||
activityTypes?.length > 0 ||
filters?.length > 0 ||
!!endDate ||
!!startDate;
const accounts = ( const accounts = (
await this.accountService.accounts({ await this.accountService.accounts({
where, where,
@ -81,7 +94,7 @@ export class ExportService {
}) })
) )
.filter(({ id }) => { .filter(({ id }) => {
return activityIds?.length > 0 return isFilteredExport
? activities.some(({ accountId }) => { ? activities.some(({ accountId }) => {
return accountId === id; return accountId === id;
}) })
@ -89,30 +102,26 @@ export class ExportService {
}) })
.map( .map(
({ ({
balance,
balances, balances,
comment, comment,
currency, currency,
id, id,
isExcluded,
name, name,
platform, platform,
platformId, platformId,
tags tags
}) => { }): ExportResponse['accounts'][number] => {
if (platformId) { if (platformId) {
platformsMap[platformId] = platform; platformsMap[platformId] = platform;
} }
return { return {
balance,
balances: balances.map(({ date, value }) => { balances: balances.map(({ date, value }) => {
return { date: date.toISOString(), value }; return { date: date.toISOString(), value };
}), }),
comment, comment,
currency, currency,
id, id,
isExcluded,
name, name,
platformId, platformId,
tags: tags.map(({ id: tagId }) => { tags: tags.map(({ id: tagId }) => {

14
apps/api/src/app/export/get-export.dto.ts

@ -0,0 +1,14 @@
import { ActivitiesFilterDto } from '@ghostfolio/api/app/activities/activities-filter.dto';
import { Transform, TransformFnParams } from 'class-transformer';
import { IsOptional, IsUUID } from 'class-validator';
import { isString } from 'lodash';
export class GetExportDto extends ActivitiesFilterDto {
@IsOptional()
@IsUUID(undefined, { each: true })
@Transform(({ value }: TransformFnParams) => {
return isString(value) ? value.split(',') : value;
})
activityIds?: string[];
}

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

@ -26,7 +26,9 @@ export class HealthService {
public async isDatabaseHealthy() { public async isDatabaseHealthy() {
try { try {
await this.propertyService.getByKey(PROPERTY_CURRENCIES); await this.propertyService.getByKey(PROPERTY_CURRENCIES, {
skipCache: true
});
return true; return true;
} catch { } catch {

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

@ -2,6 +2,7 @@ import {
CreateAccountWithBalancesDto, CreateAccountWithBalancesDto,
CreateAssetProfileWithMarketDataDto, CreateAssetProfileWithMarketDataDto,
CreateOrderDto, CreateOrderDto,
CreatePlatformDto,
CreateTagDto CreateTagDto
} from '@ghostfolio/common/dtos'; } from '@ghostfolio/common/dtos';
@ -26,6 +27,12 @@ export class ImportDataDto {
@ValidateNested({ each: true }) @ValidateNested({ each: true })
assetProfiles?: CreateAssetProfileWithMarketDataDto[]; assetProfiles?: CreateAssetProfileWithMarketDataDto[];
@IsArray()
@IsOptional()
@Type(() => CreatePlatformDto)
@ValidateNested({ each: true })
platforms?: CreatePlatformDto[];
@IsArray() @IsArray()
@IsOptional() @IsOptional()
@Type(() => CreateTagDto) @Type(() => CreateTagDto)

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

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

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

@ -9,10 +9,16 @@ import { MarketDataService } from '@ghostfolio/api/services/market-data/market-d
import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service'; import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { TagService } from '@ghostfolio/api/services/tag/tag.service'; import { TagService } from '@ghostfolio/api/services/tag/tag.service';
import { DATA_GATHERING_QUEUE_PRIORITY_HIGH } from '@ghostfolio/common/config'; import {
DATA_GATHERING_QUEUE_PRIORITY_HIGH,
ghostfolioPrefix,
NON_INVESTMENT_ACTIVITY_TYPES,
TAG_ID_EXCLUDE_FROM_ANALYSIS
} from '@ghostfolio/common/config';
import { CreateAssetProfileDto, CreateOrderDto } from '@ghostfolio/common/dtos'; import { CreateAssetProfileDto, CreateOrderDto } from '@ghostfolio/common/dtos';
import { import {
getAssetProfileIdentifier, getAssetProfileIdentifier,
isValidCustomAssetProfileSymbol,
parseDate parseDate
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
import { import {
@ -174,6 +180,7 @@ export class ImportService {
assetProfilesWithMarketDataDto, assetProfilesWithMarketDataDto,
isDryRun = false, isDryRun = false,
maxActivitiesToImport, maxActivitiesToImport,
platformsDto,
tagsDto, tagsDto,
user user
}: { }: {
@ -182,14 +189,89 @@ export class ImportService {
assetProfilesWithMarketDataDto: ImportDataDto['assetProfiles']; assetProfilesWithMarketDataDto: ImportDataDto['assetProfiles'];
isDryRun?: boolean; isDryRun?: boolean;
maxActivitiesToImport: number; maxActivitiesToImport: number;
platformsDto: ImportDataDto['platforms'];
tagsDto: ImportDataDto['tags']; tagsDto: ImportDataDto['tags'];
user: UserWithSettings; user: UserWithSettings;
}): Promise<Activity[]> { }): Promise<Activity[]> {
const accountIdMapping: { [oldAccountId: string]: string } = {}; const accountIdMapping: { [oldAccountId: string]: string } = {};
const assetProfileSymbolMapping: { [oldSymbol: string]: string } = {}; const assetProfileSymbolMapping: { [oldSymbol: string]: string } = {};
const platformIdMapping: { [oldPlatformId: string]: string } = {};
const tagIdMapping: { [oldTagId: string]: string } = {}; const tagIdMapping: { [oldTagId: string]: string } = {};
const userCurrency = user.settings.settings.baseCurrency; const userCurrency = user.settings.settings.baseCurrency;
// Validate the symbols before any data is persisted
for (const [index, assetProfileWithMarketData] of (
assetProfilesWithMarketDataDto ?? []
).entries()) {
if (
assetProfileWithMarketData.dataSource === DataSource.MANUAL &&
!isValidCustomAssetProfileSymbol(assetProfileWithMarketData.symbol)
) {
throw new Error(
`assetProfiles.${index}.symbol ("${assetProfileWithMarketData.symbol}") must be a UUID or start with the prefix "${ghostfolioPrefix}_" for the data source ("${DataSource.MANUAL}")`
);
}
}
// Validate the symbols before any data is persisted. Activities without a
// data source are excluded, since a symbol is generated in
// createActivity() if needed.
for (const [index, activity] of activitiesDto.entries()) {
if (!activity.dataSource) {
if (NON_INVESTMENT_ACTIVITY_TYPES.includes(activity.type)) {
activity.dataSource = DataSource.MANUAL;
} else {
activity.dataSource =
this.dataProviderService.getDataSourceForImport();
}
} else if (
activity.dataSource === DataSource.MANUAL &&
!isValidCustomAssetProfileSymbol(activity.symbol)
) {
throw new Error(
`activities.${index}.symbol ("${activity.symbol}") must be a UUID or start with the prefix "${ghostfolioPrefix}_" for the data source ("${DataSource.MANUAL}")`
);
}
}
if (platformsDto?.length) {
const canCreatePlatform = hasPermission(
user.permissions,
permissions.createPlatform
);
const existingPlatforms = await this.platformService.getPlatforms();
for (const platform of platformsDto) {
// Check if there is any existing platform with the same ID, otherwise
// fall back to a platform with the same URL
const existingPlatform =
existingPlatforms.find(({ id }) => {
return id === platform.id;
}) ??
existingPlatforms.find(({ url }) => {
return url === platform.url;
});
if (existingPlatform) {
// Store the new to old platform ID mappings for creating accounts
if (platform.id && existingPlatform.id !== platform.id) {
platformIdMapping[platform.id] = existingPlatform.id;
}
} else {
if (!canCreatePlatform) {
throw new Error(
`Insufficient permissions to create platform ("${platform.name}")`
);
}
if (!isDryRun) {
await this.platformService.createPlatform(platform);
}
}
}
}
const existingTagsOfUser = const existingTagsOfUser =
tagsDto?.length || (!isDryRun && accountsWithBalancesDto?.length) tagsDto?.length || (!isDryRun && accountsWithBalancesDto?.length)
? await this.tagService.getTagsForUser(user.id) ? await this.tagService.getTagsForUser(user.id)
@ -272,10 +354,16 @@ export class ImportService {
// If there is no account or if the account belongs to a different user then create a new account // If there is no account or if the account belongs to a different user then create a new account
if (!accountWithSameId || accountWithSameId.userId !== user.id) { if (!accountWithSameId || accountWithSameId.userId !== user.id) {
const account = omit(accountWithBalances, ['balances', 'tags']); const account = omit(accountWithBalances, [
'balance',
'balances',
'isExcluded',
'tags'
]);
let oldAccountId: string; let oldAccountId: string;
const platformId = account.platformId; const platformId =
platformIdMapping[account.platformId] ?? account.platformId;
delete account.platformId; delete account.platformId;
@ -292,6 +380,16 @@ export class ImportService {
return existingTagIds.has(tagId); return existingTagIds.has(tagId);
}); });
// Map the legacy isExcluded attribute of old export files to
// the "Exclude from Analysis" tag
if (
accountWithBalances.isExcluded &&
existingTagIds.has(TAG_ID_EXCLUDE_FROM_ANALYSIS) &&
!tagIds.includes(TAG_ID_EXCLUDE_FROM_ANALYSIS)
) {
tagIds.push(TAG_ID_EXCLUDE_FROM_ANALYSIS);
}
let accountObject: Prisma.AccountCreateInput = { let accountObject: Prisma.AccountCreateInput = {
...account, ...account,
balances: { balances: {
@ -311,11 +409,12 @@ export class ImportService {
}; };
} }
const newAccount = await this.accountService.createAccount( const newAccount = await this.accountService.createAccount({
accountObject, tagIds,
user.id, balance: accountWithBalances.balance,
tagIds data: accountObject,
); userId: user.id
});
// Store the new to old account ID mappings for updating activities // Store the new to old account ID mappings for updating activities
if (accountWithSameId && oldAccountId) { if (accountWithSameId && oldAccountId) {
@ -325,39 +424,77 @@ export class ImportService {
} }
} }
if (!isDryRun && assetProfilesWithMarketDataDto?.length) { if (assetProfilesWithMarketDataDto?.length) {
const existingAssetProfiles = const customAssetProfileNames = assetProfilesWithMarketDataDto
await this.symbolProfileService.getSymbolProfiles( .filter(({ dataSource, name }) => {
return dataSource === DataSource.MANUAL && Boolean(name);
})
.map(({ name }) => {
return name;
});
const [existingAssetProfiles, existingCustomAssetProfilesOfUser] =
await Promise.all([
this.symbolProfileService.getSymbolProfiles(
assetProfilesWithMarketDataDto.map(({ dataSource, symbol }) => { assetProfilesWithMarketDataDto.map(({ dataSource, symbol }) => {
return { dataSource, symbol }; return { dataSource, symbol };
}) })
); ),
this.symbolProfileService.getCustomSymbolProfilesByNames({
names: customAssetProfileNames,
userId: user.id
})
]);
for (const assetProfileWithMarketData of assetProfilesWithMarketDataDto) { for (const assetProfileWithMarketData of assetProfilesWithMarketDataDto) {
let symbol = assetProfileWithMarketData.symbol;
// Check if there is any existing asset profile // Check if there is any existing asset profile
const existingAssetProfile = existingAssetProfiles.find( const existingAssetProfile = existingAssetProfiles.find(
({ dataSource, symbol }) => { (assetProfile) => {
return ( return (
dataSource === assetProfileWithMarketData.dataSource && assetProfile.dataSource ===
symbol === assetProfileWithMarketData.symbol assetProfileWithMarketData.dataSource &&
assetProfile.symbol === assetProfileWithMarketData.symbol
); );
} }
); );
// If there is no asset profile or if the asset profile belongs to a different user, then create a new asset profile // If there is no asset profile or if the asset profile belongs to a
// different user, then reuse the custom asset profile of the user or
// create a new asset profile
if (!existingAssetProfile || existingAssetProfile.userId !== user.id) { if (!existingAssetProfile || existingAssetProfile.userId !== user.id) {
// Check if the user has a custom asset profile with the same name.
// Skip asset profiles with a legacy free-text symbol as they would
// fail the symbol validation on a future import.
const existingCustomAssetProfileOfUser =
assetProfileWithMarketData.dataSource === DataSource.MANUAL
? existingCustomAssetProfilesOfUser.find((customAssetProfile) => {
return (
customAssetProfile.name ===
assetProfileWithMarketData.name &&
isValidCustomAssetProfileSymbol(customAssetProfile.symbol)
);
})
: undefined;
if (existingCustomAssetProfileOfUser) {
// Reuse the custom asset profile of the user instead of creating a duplicate
symbol = existingCustomAssetProfileOfUser.symbol;
} else {
const assetProfile: CreateAssetProfileDto = omit( const assetProfile: CreateAssetProfileDto = omit(
assetProfileWithMarketData, assetProfileWithMarketData,
'marketData' 'marketData'
); );
// Asset profile belongs to a different user // Asset profile belongs to a different user, generate a new symbol
if (existingAssetProfile) { if (existingAssetProfile && !isDryRun) {
const symbol = randomUUID(); symbol = randomUUID();
assetProfileSymbolMapping[assetProfile.symbol] = symbol;
assetProfile.symbol = symbol;
} }
assetProfile.symbol = symbol;
if (!isDryRun) {
// Create a new asset profile // Create a new asset profile
const assetProfileObject: Prisma.SymbolProfileCreateInput = { const assetProfileObject: Prisma.SymbolProfileCreateInput = {
...assetProfile, ...assetProfile,
@ -366,30 +503,38 @@ export class ImportService {
await this.symbolProfileService.add(assetProfileObject); await this.symbolProfileService.add(assetProfileObject);
} }
}
if (symbol !== assetProfileWithMarketData.symbol) {
assetProfileSymbolMapping[assetProfileWithMarketData.symbol] =
symbol;
// Keep the asset profile in sync with the activities to validate
assetProfileWithMarketData.symbol = symbol;
}
}
if (!isDryRun) {
// Insert or update market data // Insert or update market data
const marketDataObjects = assetProfileWithMarketData.marketData.map( const marketDataObjects = (
(marketData) => { assetProfileWithMarketData.marketData ?? []
).map((marketData) => {
return { return {
...marketData, ...marketData,
dataSource: assetProfileWithMarketData.dataSource, symbol,
symbol: assetProfileWithMarketData.symbol dataSource: assetProfileWithMarketData.dataSource
} as Prisma.MarketDataUpdateInput; } as Prisma.MarketDataUpdateInput;
} });
);
await this.marketDataService.updateMany({ data: marketDataObjects }); await this.marketDataService.updateMany({ data: marketDataObjects });
} }
} }
}
for (const activity of activitiesDto) { for (const activity of activitiesDto) {
if (!activity.dataSource) { // If an asset profile is created or reused, then update the symbol in all activities
if (['FEE', 'INTEREST', 'LIABILITY'].includes(activity.type)) { if (assetProfileSymbolMapping[activity.symbol]) {
activity.dataSource = DataSource.MANUAL; activity.symbol = assetProfileSymbolMapping[activity.symbol];
} else {
activity.dataSource =
this.dataProviderService.getDataSourceForImport();
}
} }
if (!isDryRun) { if (!isDryRun) {
@ -398,11 +543,6 @@ export class ImportService {
activity.accountId = accountIdMapping[activity.accountId]; activity.accountId = accountIdMapping[activity.accountId];
} }
// If a new asset profile is created, then update the symbol in all activities
if (assetProfileSymbolMapping[activity.symbol]) {
activity.symbol = assetProfileSymbolMapping[activity.symbol];
}
// If a new tag is created, then update the tag ID in all activities // If a new tag is created, then update the tag ID in all activities
activity.tags = (activity.tags ?? []).map((tagId) => { activity.tags = (activity.tags ?? []).map((tagId) => {
return tagIdMapping[tagId] ?? tagId; return tagIdMapping[tagId] ?? tagId;

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

@ -1,4 +1,5 @@
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { PortfolioSnapshotComputationError } from '@ghostfolio/api/app/portfolio/errors/portfolio-snapshot-computation.error';
import { PortfolioCalculatorPosition } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-calculator-position.interface'; import { PortfolioCalculatorPosition } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-calculator-position.interface';
import { PortfolioOrder } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-order.interface'; import { PortfolioOrder } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-order.interface';
import { PortfolioSnapshotValue } from '@ghostfolio/api/app/portfolio/interfaces/snapshot-value.interface'; import { PortfolioSnapshotValue } from '@ghostfolio/api/app/portfolio/interfaces/snapshot-value.interface';
@ -65,6 +66,8 @@ import { isNumber, sortBy, sum, uniqBy } from 'lodash';
export abstract class PortfolioCalculator { export abstract class PortfolioCalculator {
protected static readonly ENABLE_LOGGING = false; protected static readonly ENABLE_LOGGING = false;
private static readonly MAX_INITIALIZATION_ATTEMPTS = 3;
protected readonly logger = new Logger(PortfolioCalculator.name); protected readonly logger = new Logger(PortfolioCalculator.name);
protected accountBalanceItems: HistoricalDataItem[]; protected accountBalanceItems: HistoricalDataItem[];
@ -174,6 +177,11 @@ export abstract class PortfolioCalculator {
this.computeTransactionPoints(); this.computeTransactionPoints();
this.snapshotPromise = this.initialize(); this.snapshotPromise = this.initialize();
// Mark the rejection as handled to prevent an unhandled promise rejection
// in case the snapshot promise is never awaited. Consumers awaiting it
// still receive the error.
this.snapshotPromise.catch(() => undefined);
} }
protected abstract calculateOverallPerformance( protected abstract calculateOverallPerformance(
@ -1124,7 +1132,7 @@ export abstract class PortfolioCalculator {
} }
@LogPerformance @LogPerformance
private async initialize() { private async initialize(attempt = 1) {
const startTimeTotal = performance.now(); const startTimeTotal = performance.now();
let cachedPortfolioSnapshot: PortfolioSnapshot; let cachedPortfolioSnapshot: PortfolioSnapshot;
@ -1183,6 +1191,12 @@ export abstract class PortfolioCalculator {
}); });
} }
} else { } else {
if (attempt > PortfolioCalculator.MAX_INITIALIZATION_ATTEMPTS) {
throw new PortfolioSnapshotComputationError(
`Portfolio snapshot of user '${this.userId}' could not be computed after ${PortfolioCalculator.MAX_INITIALIZATION_ATTEMPTS} attempts`
);
}
// Wait for computation // Wait for computation
await this.portfolioSnapshotService.addJobToQueue({ await this.portfolioSnapshotService.addJobToQueue({
data: { data: {
@ -1205,7 +1219,7 @@ export abstract class PortfolioCalculator {
await job.finished(); await job.finished();
} }
await this.initialize(); await this.initialize(attempt + 1);
} }
} }
} }

3
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-buy.spec.ts

@ -54,6 +54,9 @@ describe('PortfolioCalculator', () => {
let redisCacheService: RedisCacheService; let redisCacheService: RedisCacheService;
beforeEach(() => { beforeEach(() => {
PortfolioSnapshotServiceMock.reset();
RedisCacheServiceMock.reset();
configurationService = new ConfigurationService(); configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null); currentRateService = new CurrentRateService(null, null, null, null);

3
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell-in-two-activities.spec.ts

@ -54,6 +54,9 @@ describe('PortfolioCalculator', () => {
let redisCacheService: RedisCacheService; let redisCacheService: RedisCacheService;
beforeEach(() => { beforeEach(() => {
PortfolioSnapshotServiceMock.reset();
RedisCacheServiceMock.reset();
configurationService = new ConfigurationService(); configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null); currentRateService = new CurrentRateService(null, null, null, null);

3
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell.spec.ts

@ -54,6 +54,9 @@ describe('PortfolioCalculator', () => {
let redisCacheService: RedisCacheService; let redisCacheService: RedisCacheService;
beforeEach(() => { beforeEach(() => {
PortfolioSnapshotServiceMock.reset();
RedisCacheServiceMock.reset();
configurationService = new ConfigurationService(); configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null); currentRateService = new CurrentRateService(null, null, null, null);

3
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy.spec.ts

@ -54,6 +54,9 @@ describe('PortfolioCalculator', () => {
let redisCacheService: RedisCacheService; let redisCacheService: RedisCacheService;
beforeEach(() => { beforeEach(() => {
PortfolioSnapshotServiceMock.reset();
RedisCacheServiceMock.reset();
configurationService = new ConfigurationService(); configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null); currentRateService = new CurrentRateService(null, null, null, null);

3
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btceur-in-base-currency-eur.spec.ts

@ -76,6 +76,9 @@ describe('PortfolioCalculator', () => {
}); });
beforeEach(() => { beforeEach(() => {
PortfolioSnapshotServiceMock.reset();
RedisCacheServiceMock.reset();
configurationService = new ConfigurationService(); configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null); currentRateService = new CurrentRateService(null, null, null, null);

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

@ -64,6 +64,9 @@ describe('PortfolioCalculator', () => {
}); });
beforeEach(() => { beforeEach(() => {
PortfolioSnapshotServiceMock.reset();
RedisCacheServiceMock.reset();
configurationService = new ConfigurationService(); configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null); currentRateService = new CurrentRateService(null, null, null, null);

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

@ -66,6 +66,9 @@ describe('PortfolioCalculator', () => {
let redisCacheService: RedisCacheService; let redisCacheService: RedisCacheService;
beforeEach(() => { beforeEach(() => {
PortfolioSnapshotServiceMock.reset();
RedisCacheServiceMock.reset();
configurationService = new ConfigurationService(); configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null); currentRateService = new CurrentRateService(null, null, null, null);

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

@ -64,6 +64,9 @@ describe('PortfolioCalculator', () => {
}); });
beforeEach(() => { beforeEach(() => {
PortfolioSnapshotServiceMock.reset();
RedisCacheServiceMock.reset();
configurationService = new ConfigurationService(); configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null); currentRateService = new CurrentRateService(null, null, null, null);

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

@ -64,6 +64,9 @@ describe('PortfolioCalculator', () => {
}); });
beforeEach(() => { beforeEach(() => {
PortfolioSnapshotServiceMock.reset();
RedisCacheServiceMock.reset();
configurationService = new ConfigurationService(); configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null); currentRateService = new CurrentRateService(null, null, null, null);

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

@ -77,6 +77,9 @@ describe('PortfolioCalculator', () => {
let redisCacheService: RedisCacheService; let redisCacheService: RedisCacheService;
beforeEach(() => { beforeEach(() => {
PortfolioSnapshotServiceMock.reset();
RedisCacheServiceMock.reset();
configurationService = new ConfigurationService(); configurationService = new ConfigurationService();
exchangeRateDataService = new ExchangeRateDataService( exchangeRateDataService = new ExchangeRateDataService(
@ -166,6 +169,14 @@ describe('PortfolioCalculator', () => {
id: randomUUID(), id: randomUUID(),
value: 2000, value: 2000,
valueInBaseCurrency: 1800 valueInBaseCurrency: 1800
},
{
// Ignored future account balance
accountId,
date: parseDate('2050-12-31'),
id: randomUUID(),
value: 0,
valueInBaseCurrency: 0
} }
] ]
}); });
@ -178,7 +189,6 @@ describe('PortfolioCalculator', () => {
createdAt: parseDate('2023-12-31'), createdAt: parseDate('2023-12-31'),
currency: 'USD', currency: 'USD',
id: accountId, id: accountId,
isExcluded: false,
name: 'USD', name: 'USD',
platformId: null, platformId: null,
updatedAt: parseDate('2023-12-31'), updatedAt: parseDate('2023-12-31'),
@ -354,7 +364,6 @@ describe('PortfolioCalculator', () => {
createdAt: parseDate('2023-12-31'), createdAt: parseDate('2023-12-31'),
currency: 'CHF', currency: 'CHF',
id: accountId, id: accountId,
isExcluded: false,
name: 'CHF', name: 'CHF',
platformId: null, platformId: null,
updatedAt: parseDate('2023-12-31'), updatedAt: parseDate('2023-12-31'),
@ -486,7 +495,6 @@ describe('PortfolioCalculator', () => {
createdAt: parseDate('2023-12-31'), createdAt: parseDate('2023-12-31'),
currency: 'CHF', currency: 'CHF',
id: accountId, id: accountId,
isExcluded: false,
name: 'CHF', name: 'CHF',
platformId: null, platformId: null,
updatedAt: parseDate('2023-12-31'), updatedAt: parseDate('2023-12-31'),

3
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-fee.spec.ts

@ -54,6 +54,9 @@ describe('PortfolioCalculator', () => {
let redisCacheService: RedisCacheService; let redisCacheService: RedisCacheService;
beforeEach(() => { beforeEach(() => {
PortfolioSnapshotServiceMock.reset();
RedisCacheServiceMock.reset();
configurationService = new ConfigurationService(); configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null); currentRateService = new CurrentRateService(null, null, null, null);

3
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-googl-buy.spec.ts

@ -66,6 +66,9 @@ describe('PortfolioCalculator', () => {
let redisCacheService: RedisCacheService; let redisCacheService: RedisCacheService;
beforeEach(() => { beforeEach(() => {
PortfolioSnapshotServiceMock.reset();
RedisCacheServiceMock.reset();
configurationService = new ConfigurationService(); configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null); currentRateService = new CurrentRateService(null, null, null, null);

3
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-jnug-buy-and-sell-and-buy-and-sell.spec.ts

@ -67,6 +67,9 @@ describe('PortfolioCalculator', () => {
}); });
beforeEach(() => { beforeEach(() => {
PortfolioSnapshotServiceMock.reset();
RedisCacheServiceMock.reset();
configurationService = new ConfigurationService(); configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null); currentRateService = new CurrentRateService(null, null, null, null);

3
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-liability.spec.ts

@ -54,6 +54,9 @@ describe('PortfolioCalculator', () => {
let redisCacheService: RedisCacheService; let redisCacheService: RedisCacheService;
beforeEach(() => { beforeEach(() => {
PortfolioSnapshotServiceMock.reset();
RedisCacheServiceMock.reset();
configurationService = new ConfigurationService(); configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null); currentRateService = new CurrentRateService(null, null, null, null);

3
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-and-sell.spec.ts

@ -52,6 +52,9 @@ describe('PortfolioCalculator', () => {
let redisCacheService: RedisCacheService; let redisCacheService: RedisCacheService;
beforeEach(() => { beforeEach(() => {
PortfolioSnapshotServiceMock.reset();
RedisCacheServiceMock.reset();
configurationService = new ConfigurationService(); configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null); currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService( exchangeRateDataService = new ExchangeRateDataService(

3
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-with-dividend.spec.ts

@ -54,6 +54,9 @@ describe('PortfolioCalculator', () => {
let redisCacheService: RedisCacheService; let redisCacheService: RedisCacheService;
beforeEach(() => { beforeEach(() => {
PortfolioSnapshotServiceMock.reset();
RedisCacheServiceMock.reset();
configurationService = new ConfigurationService(); configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null); currentRateService = new CurrentRateService(null, null, null, null);

3
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-no-activities.spec.ts

@ -49,6 +49,9 @@ describe('PortfolioCalculator', () => {
let redisCacheService: RedisCacheService; let redisCacheService: RedisCacheService;
beforeEach(() => { beforeEach(() => {
PortfolioSnapshotServiceMock.reset();
RedisCacheServiceMock.reset();
configurationService = new ConfigurationService(); configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null); currentRateService = new CurrentRateService(null, null, null, null);

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

@ -67,6 +67,9 @@ describe('PortfolioCalculator', () => {
}); });
beforeEach(() => { beforeEach(() => {
PortfolioSnapshotServiceMock.reset();
RedisCacheServiceMock.reset();
configurationService = new ConfigurationService(); configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null); currentRateService = new CurrentRateService(null, null, null, null);

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

@ -67,6 +67,9 @@ describe('PortfolioCalculator', () => {
}); });
beforeEach(() => { beforeEach(() => {
PortfolioSnapshotServiceMock.reset();
RedisCacheServiceMock.reset();
configurationService = new ConfigurationService(); configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null); currentRateService = new CurrentRateService(null, null, null, null);

3
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-valuable.spec.ts

@ -54,6 +54,9 @@ describe('PortfolioCalculator', () => {
let redisCacheService: RedisCacheService; let redisCacheService: RedisCacheService;
beforeEach(() => { beforeEach(() => {
PortfolioSnapshotServiceMock.reset();
RedisCacheServiceMock.reset();
configurationService = new ConfigurationService(); configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null); currentRateService = new CurrentRateService(null, null, null, null);

7
apps/api/src/app/portfolio/errors/portfolio-snapshot-computation.error.ts

@ -0,0 +1,7 @@
export class PortfolioSnapshotComputationError extends Error {
public constructor(message: string) {
super(message);
this.name = 'PortfolioSnapshotComputationError';
}
}

12
apps/api/src/app/portfolio/get-details.dto.ts

@ -0,0 +1,12 @@
import { DateRangeFilterDto } from '@ghostfolio/api/dtos/date-range-filter.dto';
import { Transform, TransformFnParams } from 'class-transformer';
import { IsBoolean } from 'class-validator';
export class GetDetailsDto extends DateRangeFilterDto {
@IsBoolean()
@Transform(({ value }: TransformFnParams) => {
return value === 'true';
})
withMarkets? = false;
}

10
apps/api/src/app/portfolio/get-dividends.dto.ts

@ -0,0 +1,10 @@
import { DateRangeFilterDto } from '@ghostfolio/api/dtos/date-range-filter.dto';
import { GroupBy } from '@ghostfolio/common/types';
import { IsIn, IsOptional } from 'class-validator';
export class GetDividendsDto extends DateRangeFilterDto {
@IsIn(['month', 'year'] as GroupBy[])
@IsOptional()
groupBy?: GroupBy;
}

14
apps/api/src/app/portfolio/get-holdings.dto.ts

@ -0,0 +1,14 @@
import { DateRangeFilterDto } from '@ghostfolio/api/dtos/date-range-filter.dto';
import { HoldingType } from '@ghostfolio/common/types';
import { IsIn, IsOptional, IsString } from 'class-validator';
export class GetHoldingsDto extends DateRangeFilterDto {
@IsIn(['ACTIVE', 'CLOSED'] as HoldingType[])
@IsOptional()
holdingType?: HoldingType;
@IsOptional()
@IsString()
query?: string;
}

10
apps/api/src/app/portfolio/get-investments.dto.ts

@ -0,0 +1,10 @@
import { DateRangeFilterDto } from '@ghostfolio/api/dtos/date-range-filter.dto';
import { GroupBy } from '@ghostfolio/common/types';
import { IsIn, IsOptional } from 'class-validator';
export class GetInvestmentsDto extends DateRangeFilterDto {
@IsIn(['month', 'year'] as GroupBy[])
@IsOptional()
groupBy?: GroupBy;
}

12
apps/api/src/app/portfolio/get-performance.dto.ts

@ -0,0 +1,12 @@
import { DateRangeFilterDto } from '@ghostfolio/api/dtos/date-range-filter.dto';
import { Transform, TransformFnParams } from 'class-transformer';
import { IsBoolean } from 'class-validator';
export class GetPerformanceDto extends DateRangeFilterDto {
@IsBoolean()
@Transform(({ value }: TransformFnParams) => {
return value === 'true';
})
withExcludedAccounts? = false;
}

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

@ -15,7 +15,6 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration/con
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service'; import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper'; import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper';
import { import {
DEFAULT_DATE_RANGE,
HEADER_KEY_IMPERSONATION, HEADER_KEY_IMPERSONATION,
UNKNOWN_KEY UNKNOWN_KEY
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
@ -34,11 +33,7 @@ import {
isRestrictedView, isRestrictedView,
permissions permissions
} from '@ghostfolio/common/permissions'; } from '@ghostfolio/common/permissions';
import type { import type { RequestWithUser } from '@ghostfolio/common/types';
DateRange,
GroupBy,
RequestWithUser
} from '@ghostfolio/common/types';
import { import {
Body, Body,
@ -60,6 +55,11 @@ import { AssetClass, AssetSubClass, DataSource } from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { GetDetailsDto } from './get-details.dto';
import { GetDividendsDto } from './get-dividends.dto';
import { GetHoldingsDto } from './get-holdings.dto';
import { GetInvestmentsDto } from './get-investments.dto';
import { GetPerformanceDto } from './get-performance.dto';
import { PortfolioService } from './portfolio.service'; import { PortfolioService } from './portfolio.service';
import { UpdateHoldingTagsDto } from './update-holding-tags.dto'; import { UpdateHoldingTagsDto } from './update-holding-tags.dto';
@ -82,16 +82,17 @@ export class PortfolioController {
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getDetails( public async getDetails(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Query('accounts') filterByAccounts?: string, @Query()
@Query('assetClasses') filterByAssetClasses?: string, {
@Query('dataSource') filterByDataSource?: string, accounts: filterByAccounts,
@Query('range') dateRange: DateRange = DEFAULT_DATE_RANGE, assetClasses: filterByAssetClasses,
@Query('symbol') filterBySymbol?: string, dataSource: filterByDataSource,
@Query('tags') filterByTags?: string, range,
@Query('withMarkets') withMarketsParam = 'false' symbol: filterBySymbol,
tags: filterByTags,
withMarkets
}: GetDetailsDto
): Promise<PortfolioDetails & { hasError: boolean }> { ): Promise<PortfolioDetails & { hasError: boolean }> {
const withMarkets = withMarketsParam === 'true';
let hasDetails = true; let hasDetails = true;
let hasError = false; let hasError = false;
@ -118,10 +119,10 @@ export class PortfolioController {
platforms, platforms,
summary summary
} = await this.portfolioService.getDetails({ } = await this.portfolioService.getDetails({
dateRange,
filters, filters,
impersonationId, impersonationId,
withMarkets, withMarkets,
dateRange: range,
userId: this.request.user.id, userId: this.request.user.id,
withSummary: true withSummary: true
}); });
@ -135,7 +136,7 @@ export class PortfolioController {
if ( if (
hasReadRestrictedAccessPermission({ hasReadRestrictedAccessPermission({
impersonationId, impersonationId,
user: this.request.user accesses: this.request.user?.accessesGet
}) || }) ||
isRestrictedView(this.request.user) isRestrictedView(this.request.user)
) { ) {
@ -179,7 +180,7 @@ export class PortfolioController {
hasDetails === false || hasDetails === false ||
hasReadRestrictedAccessPermission({ hasReadRestrictedAccessPermission({
impersonationId, impersonationId,
user: this.request.user accesses: this.request.user?.accessesGet
}) || }) ||
isRestrictedView(this.request.user) isRestrictedView(this.request.user)
) { ) {
@ -326,20 +327,23 @@ export class PortfolioController {
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
public async getDividends( public async getDividends(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Query('accounts') filterByAccounts?: string, @Query()
@Query('assetClasses') filterByAssetClasses?: string, {
@Query('dataSource') filterByDataSource?: string, accounts,
@Query('groupBy') groupBy?: GroupBy, assetClasses,
@Query('range') dateRange: DateRange = DEFAULT_DATE_RANGE, dataSource,
@Query('symbol') filterBySymbol?: string, groupBy,
@Query('tags') filterByTags?: string range,
symbol,
tags
}: GetDividendsDto
): Promise<PortfolioDividendsResponse> { ): Promise<PortfolioDividendsResponse> {
const filters = this.apiService.buildFiltersFromQueryParams({ const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts, filterByAccounts: accounts,
filterByAssetClasses, filterByAssetClasses: assetClasses,
filterByDataSource, filterByDataSource: dataSource,
filterBySymbol, filterBySymbol: symbol,
filterByTags filterByTags: tags
}); });
const impersonationUserId = const impersonationUserId =
@ -349,7 +353,9 @@ export class PortfolioController {
const { settings } = await this.userService.user({ id: userId }); const { settings } = await this.userService.user({ id: userId });
const userCurrency = settings.settings.baseCurrency; const userCurrency = settings.settings.baseCurrency;
const { endDate, startDate } = getIntervalFromDateRange({ dateRange }); const { endDate, startDate } = getIntervalFromDateRange({
dateRange: range
});
const { activities } = await this.activitiesService.getActivities({ const { activities } = await this.activitiesService.getActivities({
endDate, endDate,
@ -368,7 +374,7 @@ export class PortfolioController {
if ( if (
hasReadRestrictedAccessPermission({ hasReadRestrictedAccessPermission({
impersonationId, impersonationId,
user: this.request.user accesses: this.request.user?.accessesGet
}) || }) ||
isRestrictedView(this.request.user) isRestrictedView(this.request.user)
) { ) {
@ -429,29 +435,32 @@ export class PortfolioController {
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getHoldings( public async getHoldings(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Query('accounts') filterByAccounts?: string, @Query()
@Query('assetClasses') filterByAssetClasses?: string, {
@Query('dataSource') filterByDataSource?: string, accounts,
@Query('holdingType') filterByHoldingType?: string, assetClasses,
@Query('query') filterBySearchQuery?: string, dataSource,
@Query('range') dateRange: DateRange = DEFAULT_DATE_RANGE, holdingType,
@Query('symbol') filterBySymbol?: string, query,
@Query('tags') filterByTags?: string range,
symbol,
tags
}: GetHoldingsDto
): Promise<PortfolioHoldingsResponse> { ): Promise<PortfolioHoldingsResponse> {
const filters = this.apiService.buildFiltersFromQueryParams({ const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts, filterByAccounts: accounts,
filterByAssetClasses, filterByAssetClasses: assetClasses,
filterByDataSource, filterByDataSource: dataSource,
filterByHoldingType, filterByHoldingType: holdingType,
filterBySearchQuery, filterBySearchQuery: query,
filterBySymbol, filterBySymbol: symbol,
filterByTags filterByTags: tags
}); });
const holdings = await this.portfolioService.getHoldings({ const holdings = await this.portfolioService.getHoldings({
dateRange,
filters, filters,
impersonationId, impersonationId,
dateRange: range,
userId: this.request.user.id userId: this.request.user.id
}); });
@ -463,35 +472,38 @@ export class PortfolioController {
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
public async getInvestments( public async getInvestments(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Query('accounts') filterByAccounts?: string, @Query()
@Query('assetClasses') filterByAssetClasses?: string, {
@Query('dataSource') filterByDataSource?: string, accounts,
@Query('groupBy') groupBy?: GroupBy, assetClasses,
@Query('range') dateRange: DateRange = DEFAULT_DATE_RANGE, dataSource,
@Query('symbol') filterBySymbol?: string, groupBy,
@Query('tags') filterByTags?: string range,
symbol,
tags
}: GetInvestmentsDto
): Promise<PortfolioInvestmentsResponse> { ): Promise<PortfolioInvestmentsResponse> {
const filters = this.apiService.buildFiltersFromQueryParams({ const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts, filterByAccounts: accounts,
filterByAssetClasses, filterByAssetClasses: assetClasses,
filterByDataSource, filterByDataSource: dataSource,
filterBySymbol, filterBySymbol: symbol,
filterByTags filterByTags: tags
}); });
let { investments, streaks } = await this.portfolioService.getInvestments({ let { investments, savingsRate, streaks } =
dateRange, await this.portfolioService.getInvestments({
filters, filters,
groupBy, groupBy,
impersonationId, impersonationId,
savingsRate: this.request.user?.settings?.settings.savingsRate, dateRange: range,
userId: this.request.user.id userId: this.request.user.id
}); });
if ( if (
hasReadRestrictedAccessPermission({ hasReadRestrictedAccessPermission({
impersonationId, impersonationId,
user: this.request.user accesses: this.request.user?.accessesGet
}) || }) ||
isRestrictedView(this.request.user) isRestrictedView(this.request.user)
) { ) {
@ -509,6 +521,8 @@ export class PortfolioController {
'currentStreak', 'currentStreak',
'longestStreak' 'longestStreak'
]); ]);
savingsRate = null;
} }
if ( if (
@ -525,7 +539,7 @@ export class PortfolioController {
]); ]);
} }
return { investments, streaks }; return { investments, savingsRate, streaks };
} }
@Get('performance') @Get('performance')
@ -536,36 +550,37 @@ export class PortfolioController {
@Version('2') @Version('2')
public async getPerformanceV2( public async getPerformanceV2(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Query('accounts') filterByAccounts?: string, @Query()
@Query('assetClasses') filterByAssetClasses?: string, {
@Query('dataSource') filterByDataSource?: string, accounts,
@Query('range') dateRange: DateRange = DEFAULT_DATE_RANGE, assetClasses,
@Query('symbol') filterBySymbol?: string, dataSource,
@Query('tags') filterByTags?: string, range,
@Query('withExcludedAccounts') withExcludedAccountsParam = 'false' symbol,
tags,
withExcludedAccounts
}: GetPerformanceDto
): Promise<PortfolioPerformanceResponse> { ): Promise<PortfolioPerformanceResponse> {
const withExcludedAccounts = withExcludedAccountsParam === 'true';
const filters = this.apiService.buildFiltersFromQueryParams({ const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts, filterByAccounts: accounts,
filterByAssetClasses, filterByAssetClasses: assetClasses,
filterByDataSource, filterByDataSource: dataSource,
filterBySymbol, filterBySymbol: symbol,
filterByTags filterByTags: tags
}); });
const performanceInformation = await this.portfolioService.getPerformance({ const performanceInformation = await this.portfolioService.getPerformance({
dateRange,
filters, filters,
impersonationId, impersonationId,
withExcludedAccounts, withExcludedAccounts,
dateRange: range,
userId: this.request.user.id userId: this.request.user.id
}); });
if ( if (
hasReadRestrictedAccessPermission({ hasReadRestrictedAccessPermission({
impersonationId, impersonationId,
user: this.request.user accesses: this.request.user?.accessesGet
}) || }) ||
isRestrictedView(this.request.user) || isRestrictedView(this.request.user) ||
this.request.user.settings.settings.viewMode === 'ZEN' this.request.user.settings.settings.viewMode === 'ZEN'

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

@ -1,6 +1,7 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service'; import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { CashDetails } from '@ghostfolio/api/app/account/interfaces/cash-details.interface'; import { CashDetails } from '@ghostfolio/api/app/account/interfaces/cash-details.interface';
import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service'; import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service';
import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator';
import { userDummyData } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; import { userDummyData } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { UserService } from '@ghostfolio/api/app/user/user.service'; import { UserService } from '@ghostfolio/api/app/user/user.service';
@ -11,9 +12,13 @@ import { ImpersonationService } from '@ghostfolio/api/services/impersonation/imp
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { UNKNOWN_KEY } from '@ghostfolio/common/config'; import { UNKNOWN_KEY } from '@ghostfolio/common/config';
import { parseDate } from '@ghostfolio/common/helper'; import { parseDate } from '@ghostfolio/common/helper';
import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces'; import {
AssetProfileIdentifier,
PortfolioSummary
} from '@ghostfolio/common/interfaces';
import { AccountWithBalance } from '@ghostfolio/common/types';
import { Account, DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
@ -92,6 +97,7 @@ describe('PortfolioService', () => {
null, null,
null, null,
null, null,
null,
null null
); );
@ -187,7 +193,6 @@ describe('PortfolioService', () => {
createdAt: parseDate('2024-01-01'), createdAt: parseDate('2024-01-01'),
currency: 'USD', currency: 'USD',
id: randomUUID(), id: randomUUID(),
isExcluded: false,
name: 'USD', name: 'USD',
platformId: null, platformId: null,
updatedAt: parseDate('2024-01-01'), updatedAt: parseDate('2024-01-01'),
@ -215,13 +220,12 @@ describe('PortfolioService', () => {
it('should return cash holdings when the calculator emits cash positions with the exchange-rate data source', async () => { it('should return cash holdings when the calculator emits cash positions with the exchange-rate data source', async () => {
const accountId = randomUUID(); const accountId = randomUUID();
const cashAccount: Account = { const cashAccount: AccountWithBalance = {
balance: 2000, balance: 2000,
comment: null, comment: null,
createdAt: parseDate('2024-01-01'), createdAt: parseDate('2024-01-01'),
currency: 'USD', currency: 'USD',
id: accountId, id: accountId,
isExcluded: false,
name: 'USD', name: 'USD',
platformId: null, platformId: null,
updatedAt: parseDate('2024-01-01'), updatedAt: parseDate('2024-01-01'),
@ -337,6 +341,86 @@ describe('PortfolioService', () => {
}); });
}); });
describe('getSummary', () => {
const getSummary = (args: object) => {
return (
portfolioService as unknown as {
getSummary: (aArgs: object) => Promise<PortfolioSummary>;
}
).getSummary(args);
};
function createPortfolioCalculator() {
return {
getDividendInBaseCurrency: jest.fn().mockResolvedValue(new Big(0)),
getFeesInBaseCurrency: jest.fn().mockResolvedValue(new Big(0)),
getInterestInBaseCurrency: jest.fn().mockResolvedValue(new Big(0)),
getLiabilitiesInBaseCurrency: jest.fn().mockResolvedValue(new Big(0)),
getSnapshot: jest.fn().mockResolvedValue({
currentValueInBaseCurrency: new Big(3000),
totalCashInBaseCurrency: new Big(1000),
totalInvestment: new Big(2000),
totalInvestmentWithCurrencyEffect: new Big(2000)
}),
getStartDate: jest.fn().mockReturnValue(parseDate('2024-01-01'))
} as unknown as PortfolioCalculator;
}
beforeEach(() => {
jest
.spyOn(activitiesService, 'getActivities')
.mockResolvedValue({ activities: [], count: 0 });
jest
.spyOn(impersonationService, 'validateImpersonationId')
.mockResolvedValue(null);
jest.spyOn(portfolioService, 'getPerformance').mockResolvedValue({
performance: {
currentValueInBaseCurrency: 3000,
netPerformance: 500,
netPerformancePercentage: 0.2,
netPerformancePercentageWithCurrencyEffect: 0.2,
netPerformanceWithCurrencyEffect: 500
}
} as Awaited<ReturnType<typeof portfolioService.getPerformance>>);
jest.spyOn(userService, 'user').mockResolvedValue({
id: userDummyData.id,
settings: {
settings: {
baseCurrency: 'CHF'
}
}
} as unknown as Awaited<ReturnType<typeof userService.user>>);
});
it('should derive the cash and net worth from the account balance when there are no excluded accounts, no emergency fund and no liabilities', async () => {
jest.spyOn(accountService, 'getCashDetails').mockResolvedValue({
accounts: [],
balanceInBaseCurrency: 1000
});
const portfolioCalculator = createPortfolioCalculator();
const summary = await getSummary({
portfolioCalculator,
balanceInBaseCurrency: 1000,
emergencyFundHoldingsValueInBaseCurrency: 0,
filteredValueInBaseCurrency: new Big(3000),
impersonationId: undefined,
userCurrency: 'CHF',
userId: userDummyData.id
});
expect(summary.cash).toBe(1000);
expect(summary.emergencyFund.total).toBe(0);
expect(summary.excludedAccountsAndActivities).toBe(0);
expect(summary.totalAssetsInBaseCurrency).toBe(3000);
expect(summary.totalValueInBaseCurrency).toBe(3000);
});
});
describe('getValueOfAccountsAndPlatforms', () => { describe('getValueOfAccountsAndPlatforms', () => {
const getValueOfAccountsAndPlatforms = (args: object) => { const getValueOfAccountsAndPlatforms = (args: object) => {
return ( return (
@ -353,7 +437,6 @@ describe('PortfolioService', () => {
balance: 100, balance: 100,
currency: 'USD', currency: 'USD',
id: randomUUID(), id: randomUUID(),
isExcluded: false,
name: 'Account 1', name: 'Account 1',
platform: { name: 'Platform 1' }, platform: { name: 'Platform 1' },
platformId: randomUUID() platformId: randomUUID()
@ -362,7 +445,7 @@ describe('PortfolioService', () => {
beforeEach(() => { beforeEach(() => {
jest jest
.spyOn(accountService, 'getAccounts') .spyOn(accountService, 'getAccounts')
.mockResolvedValue([account] as unknown as Account[]); .mockResolvedValue([account] as unknown as AccountWithBalance[]);
jest jest
.spyOn(exchangeRateDataService, 'toCurrency') .spyOn(exchangeRateDataService, 'toCurrency')

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

@ -64,6 +64,7 @@ import {
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { TimelinePosition } from '@ghostfolio/common/models'; import { TimelinePosition } from '@ghostfolio/common/models';
import { import {
AccountWithBalance,
AccountWithValue, AccountWithValue,
DateRange, DateRange,
GroupBy, GroupBy,
@ -75,7 +76,6 @@ import { PerformanceCalculationType } from '@ghostfolio/common/types/performance
import { Inject, Injectable, Logger } from '@nestjs/common'; import { Inject, Injectable, Logger } from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { import {
Account,
Type as ActivityType, Type as ActivityType,
AssetClass, AssetClass,
AssetSubClass, AssetSubClass,
@ -165,6 +165,10 @@ export class PortfolioService {
}; };
} }
const filtersWithoutSearchQueryFilter = filters?.filter(({ type }) => {
return type !== 'SEARCH_QUERY';
});
const [accounts, details, user] = await Promise.all([ const [accounts, details, user] = await Promise.all([
this.accountService.accounts({ this.accountService.accounts({
where, where,
@ -176,8 +180,8 @@ export class PortfolioService {
orderBy: { name: 'asc' } orderBy: { name: 'asc' }
}), }),
this.getDetails({ this.getDetails({
filters,
withExcludedAccounts, withExcludedAccounts,
filters: filtersWithoutSearchQueryFilter,
impersonationId: userId, impersonationId: userId,
userId: this.request.user.id userId: this.request.user.id
}), }),
@ -369,14 +373,6 @@ export class PortfolioService {
userId: string; userId: string;
}) { }) {
userId = await this.getUserId(impersonationId, userId); userId = await this.getUserId(impersonationId, userId);
const { holdings: holdingsMap } = await this.getDetails({
dateRange,
filters,
impersonationId,
userId
});
let holdings = Object.values(holdingsMap);
const { SEARCH_QUERY: [filterBySearchQuery] = [] } = groupBy( const { SEARCH_QUERY: [filterBySearchQuery] = [] } = groupBy(
filters, filters,
@ -385,9 +381,22 @@ export class PortfolioService {
} }
); );
const filtersWithoutSearchQueryFilter = filters?.filter(({ type }) => {
return type !== 'SEARCH_QUERY';
});
const { holdings: holdingsMap } = await this.getDetails({
dateRange,
impersonationId,
userId,
filters: filtersWithoutSearchQueryFilter
});
let holdings = Object.values(holdingsMap);
if (filterBySearchQuery) { if (filterBySearchQuery) {
const fuse = new Fuse(holdings, { const fuse = new Fuse(holdings, {
keys: ['isin', 'name', 'symbol'], keys: ['assetProfile.isin', 'assetProfile.name', 'assetProfile.symbol'],
threshold: 0.3 threshold: 0.3
}); });
@ -404,19 +413,18 @@ export class PortfolioService {
filters, filters,
groupBy, groupBy,
impersonationId, impersonationId,
savingsRate,
userId userId
}: { }: {
dateRange: DateRange; dateRange: DateRange;
filters?: Filter[]; filters?: Filter[];
groupBy?: GroupBy; groupBy?: GroupBy;
impersonationId: string; impersonationId: string;
savingsRate: number;
userId: string; userId: string;
}): Promise<PortfolioInvestmentsResponse> { }): Promise<PortfolioInvestmentsResponse> {
userId = await this.getUserId(impersonationId, userId); userId = await this.getUserId(impersonationId, userId);
const user = await this.userService.user({ id: userId }); const user = await this.userService.user({ id: userId });
const userCurrency = this.getUserCurrency(user); const userCurrency = this.getUserCurrency(user);
const savingsRate = (user.settings?.settings as UserSettings)?.savingsRate;
const { endDate, startDate } = getIntervalFromDateRange({ dateRange }); const { endDate, startDate } = getIntervalFromDateRange({ dateRange });
@ -429,6 +437,7 @@ export class PortfolioService {
if (activities.length === 0) { if (activities.length === 0) {
return { return {
savingsRate,
investments: [], investments: [],
streaks: { currentStreak: 0, longestStreak: 0 } streaks: { currentStreak: 0, longestStreak: 0 }
}; };
@ -475,6 +484,7 @@ export class PortfolioService {
return { return {
investments, investments,
savingsRate,
streaks streaks
}; };
} }
@ -651,6 +661,7 @@ export class PortfolioService {
}; };
} }
), ),
isin: assetProfile.isin,
name: assetProfile.name, name: assetProfile.name,
sectors: assetProfile.sectors, sectors: assetProfile.sectors,
symbol: assetProfile.symbol, symbol: assetProfile.symbol,
@ -1035,7 +1046,6 @@ export class PortfolioService {
return { return {
chart: [], chart: [],
dateOfFirstActivity: undefined, dateOfFirstActivity: undefined,
firstOrderDate: undefined,
hasErrors: false, hasErrors: false,
performance: { performance: {
currentNetWorth: 0, currentNetWorth: 0,
@ -1093,7 +1103,6 @@ export class PortfolioService {
errors, errors,
hasErrors, hasErrors,
dateOfFirstActivity: parseDate(historicalData[0]?.date), dateOfFirstActivity: parseDate(historicalData[0]?.date),
firstOrderDate: parseDate(historicalData[0]?.date),
performance: { performance: {
netPerformance, netPerformance,
netPerformanceWithCurrencyEffect, netPerformanceWithCurrencyEffect,
@ -1126,6 +1135,8 @@ export class PortfolioService {
withSummary: true withSummary: true
}); });
const hasOpenHoldings = Object.keys(holdings).length > 0;
const marketsAdvancedTotalInBaseCurrency = getSum( const marketsAdvancedTotalInBaseCurrency = getSum(
Object.values(marketsAdvanced).map(({ valueInBaseCurrency }) => { Object.values(marketsAdvanced).map(({ valueInBaseCurrency }) => {
return new Big(valueInBaseCurrency); return new Big(valueInBaseCurrency);
@ -1185,8 +1196,7 @@ export class PortfolioService {
id: 'rule.currencyClusterRisk.category', id: 'rule.currencyClusterRisk.category',
languageCode: userSettings.language languageCode: userSettings.language
}), }),
rules: rules: hasOpenHoldings
summary.activityCount > 0
? await this.rulesService.evaluate( ? await this.rulesService.evaluate(
[ [
new CurrencyClusterRiskBaseCurrencyCurrentInvestment( new CurrencyClusterRiskBaseCurrencyCurrentInvestment(
@ -1212,8 +1222,7 @@ export class PortfolioService {
id: 'rule.assetClassClusterRisk.category', id: 'rule.assetClassClusterRisk.category',
languageCode: userSettings.language languageCode: userSettings.language
}), }),
rules: rules: hasOpenHoldings
summary.activityCount > 0
? await this.rulesService.evaluate( ? await this.rulesService.evaluate(
[ [
new AssetClassClusterRiskEquity( new AssetClassClusterRiskEquity(
@ -1266,8 +1275,7 @@ export class PortfolioService {
id: 'rule.economicMarketClusterRisk.category', id: 'rule.economicMarketClusterRisk.category',
languageCode: userSettings.language languageCode: userSettings.language
}), }),
rules: rules: hasOpenHoldings
summary.activityCount > 0
? await this.rulesService.evaluate( ? await this.rulesService.evaluate(
[ [
new EconomicMarketClusterRiskDevelopedMarkets( new EconomicMarketClusterRiskDevelopedMarkets(
@ -1295,8 +1303,7 @@ export class PortfolioService {
id: 'rule.regionalMarketClusterRisk.category', id: 'rule.regionalMarketClusterRisk.category',
languageCode: userSettings.language languageCode: userSettings.language
}), }),
rules: rules: hasOpenHoldings
summary.activityCount > 0
? await this.rulesService.evaluate( ? await this.rulesService.evaluate(
[ [
new RegionalMarketClusterRiskAsiaPacific( new RegionalMarketClusterRiskAsiaPacific(
@ -2136,7 +2143,7 @@ export class PortfolioService {
const accounts: PortfolioDetails['accounts'] = {}; const accounts: PortfolioDetails['accounts'] = {};
const platforms: PortfolioDetails['platforms'] = {}; const platforms: PortfolioDetails['platforms'] = {};
let currentAccounts: (Account & { let currentAccounts: (AccountWithBalance & {
Order?: Order[]; Order?: Order[];
platform?: Platform; platform?: Platform;
tags?: Tag[]; tags?: Tag[];

3
apps/api/src/app/redis-cache/redis-cache.service.mock.ts

@ -18,6 +18,9 @@ export const RedisCacheServiceMock = {
return `portfolio-snapshot-${userId}${filtersHash > 0 ? `-${filtersHash}` : ''}`; return `portfolio-snapshot-${userId}${filtersHash > 0 ? `-${filtersHash}` : ''}`;
}, },
reset: () => {
RedisCacheServiceMock.cache.clear();
},
set: (key: string, value: string): Promise<string> => { set: (key: string, value: string): Promise<string> => {
RedisCacheServiceMock.cache.set(key, value); RedisCacheServiceMock.cache.set(key, value);

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

@ -54,7 +54,9 @@ export class SubscriptionController {
} }
let coupons = let coupons =
(await this.propertyService.getByKey<Coupon[]>(PROPERTY_COUPONS)) ?? []; (await this.propertyService.getByKey<Coupon[]>(PROPERTY_COUPONS, {
skipCache: true
})) ?? [];
const coupon = coupons.find((currentCoupon) => { const coupon = coupons.find((currentCoupon) => {
return currentCoupon.code === couponCode; return currentCoupon.code === couponCode;

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

@ -1,7 +1,9 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { CustomThrottlerGuard } from '@ghostfolio/api/guards/custom-throttler.guard'; import { CustomThrottlerGuard } from '@ghostfolio/api/guards/custom-throttler.guard';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { decodeDataSource } from '@ghostfolio/api/helper/data-source.helper';
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.interceptor'; import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service'; import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
@ -119,6 +121,7 @@ export class UserController {
@Get() @Get()
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getUser( public async getUser(
@Headers('accept-language') acceptLanguage: string, @Headers('accept-language') acceptLanguage: string,
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string
@ -165,6 +168,7 @@ export class UserController {
@Put('setting') @Put('setting')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async updateUserSetting(@Body() data: UpdateUserSettingDto) { public async updateUserSetting(@Body() data: UpdateUserSettingDto) {
if ( if (
size(data) === 1 && size(data) === 1 &&
@ -192,6 +196,12 @@ export class UserController {
data data
); );
if (userSettings['filters.dataSource']) {
userSettings['filters.dataSource'] = decodeDataSource(
userSettings['filters.dataSource']
);
}
for (const key in userSettings) { for (const key in userSettings) {
if (userSettings[key] === false || userSettings[key] === null) { if (userSettings[key] === false || userSettings[key] === null) {
delete userSettings[key]; delete userSettings[key];

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

@ -1,6 +1,7 @@
import { ActivitiesModule } from '@ghostfolio/api/app/activities/activities.module'; import { ActivitiesModule } from '@ghostfolio/api/app/activities/activities.module';
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module'; import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
import { RedactValuesInResponseModule } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.module'; import { RedactValuesInResponseModule } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.module';
import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { I18nModule } from '@ghostfolio/api/services/i18n/i18n.module'; import { I18nModule } from '@ghostfolio/api/services/i18n/i18n.module';
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module'; import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module';
@ -30,7 +31,8 @@ import { UserService } from './user.service';
PropertyModule, PropertyModule,
RedactValuesInResponseModule, RedactValuesInResponseModule,
SubscriptionModule, SubscriptionModule,
TagModule TagModule,
TransformDataSourceInResponseModule
], ],
providers: [UserService] providers: [UserService]
}) })

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

@ -31,9 +31,12 @@ import {
DEFAULT_LOCALE, DEFAULT_LOCALE,
PROPERTY_API_KEY_GHOSTFOLIO, PROPERTY_API_KEY_GHOSTFOLIO,
PROPERTY_IS_READ_ONLY_MODE, PROPERTY_IS_READ_ONLY_MODE,
PROPERTY_MAX_DAILY_REQUESTS,
PROPERTY_REFERRAL_PARTNERS, PROPERTY_REFERRAL_PARTNERS,
PROPERTY_SYSTEM_MESSAGE, PROPERTY_SYSTEM_MESSAGE,
TAG_ID_EXCLUDE_FROM_ANALYSIS TAG_ID_EXCLUDE_FROM_ANALYSIS,
THROTTLE_DAILY_KEY,
THROTTLE_DAILY_TTL
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { SubscriptionType } from '@ghostfolio/common/enums'; import { SubscriptionType } from '@ghostfolio/common/enums';
import { import {
@ -50,15 +53,18 @@ import {
import { UserWithSettings } from '@ghostfolio/common/types'; import { UserWithSettings } from '@ghostfolio/common/types';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Injectable } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter'; import { EventEmitter2 } from '@nestjs/event-emitter';
import { InjectThrottlerStorage, ThrottlerStorage } from '@nestjs/throttler';
import { Prisma, Role, Settings, User } from '@prisma/client'; import { Prisma, Role, Settings, User } from '@prisma/client';
import { differenceInDays, subDays } from 'date-fns'; import { differenceInDays, subDays } from 'date-fns';
import { without } from 'lodash'; import { isNil, without } from 'lodash';
import { createHmac } from 'node:crypto'; import { createHmac } from 'node:crypto';
@Injectable() @Injectable()
export class UserService { export class UserService {
private readonly logger = new Logger(UserService.name);
public constructor( public constructor(
private readonly activitiesService: ActivitiesService, private readonly activitiesService: ActivitiesService,
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
@ -67,7 +73,9 @@ export class UserService {
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService, private readonly propertyService: PropertyService,
private readonly subscriptionService: SubscriptionService, private readonly subscriptionService: SubscriptionService,
private readonly tagService: TagService private readonly tagService: TagService,
@InjectThrottlerStorage()
private readonly throttlerStorage: ThrottlerStorage
) {} ) {}
public async count(args?: Prisma.UserCountArgs) { public async count(args?: Prisma.UserCountArgs) {
@ -128,6 +136,7 @@ export class UserService {
where: { granteeUserId: id } where: { granteeUserId: id }
}), }),
this.prismaService.account.findMany({ this.prismaService.account.findMany({
include: { platform: true },
orderBy: { orderBy: {
name: 'asc' name: 'asc'
}, },
@ -178,15 +187,15 @@ export class UserService {
systemMessage = systemMessageProperty; systemMessage = systemMessageProperty;
} }
let tags = tagsForUser.filter((tag) => { let tags = tagsForUser;
return tag.id !== TAG_ID_EXCLUDE_FROM_ANALYSIS;
});
if ( if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
subscription.type === SubscriptionType.Basic subscription.type === SubscriptionType.Basic
) { ) {
tags = []; tags = tags.filter(({ id }) => {
return id === TAG_ID_EXCLUDE_FROM_ANALYSIS;
});
} }
return { return {
@ -228,6 +237,38 @@ export class UserService {
return usersWithAdminRole.length > 0; return usersWithAdminRole.length > 0;
} }
public async isDailyRequestLimitExceeded({
user
}: {
user: UserWithSettings;
}) {
if (user.subscription?.type === SubscriptionType.Premium) {
return false;
}
const maxDailyRequests = await this.getMaxDailyRequests();
if (maxDailyRequests === undefined) {
return false;
}
try {
const { isBlocked } = await this.throttlerStorage.increment(
`${THROTTLE_DAILY_KEY}-${user.id}`,
THROTTLE_DAILY_TTL,
maxDailyRequests,
THROTTLE_DAILY_TTL,
THROTTLE_DAILY_KEY
);
return isBlocked;
} catch (error) {
this.logger.error(error);
return false;
}
}
public async user( public async user(
userWhereUniqueInput: Prisma.UserWhereUniqueInput userWhereUniqueInput: Prisma.UserWhereUniqueInput
): Promise<UserWithSettings | null> { ): Promise<UserWithSettings | null> {
@ -286,7 +327,7 @@ export class UserService {
updatedAt, updatedAt,
activityCount: analytics?.activityCount, activityCount: analytics?.activityCount,
dataProviderGhostfolioDailyRequests: dataProviderGhostfolioDailyRequests:
analytics?.dataProviderGhostfolioDailyRequests analytics?.dataProviderGhostfolioDailyRequests ?? 0
}; };
if (user.settings) { if (user.settings) {
@ -509,9 +550,11 @@ export class UserService {
currentPermissions, currentPermissions,
permissions.accessHoldingsChart, permissions.accessHoldingsChart,
permissions.createAccess, permissions.createAccess,
permissions.createAssetProfileSplitOfOwnAssetProfile,
permissions.createMarketDataOfOwnAssetProfile, permissions.createMarketDataOfOwnAssetProfile,
permissions.createOwnTag, permissions.createOwnTag,
permissions.createWatchlistItem, permissions.createWatchlistItem,
permissions.deleteAssetProfileSplitOfOwnAssetProfile,
permissions.readAiPrompt, permissions.readAiPrompt,
permissions.readMarketDataOfOwnAssetProfile, permissions.readMarketDataOfOwnAssetProfile,
permissions.updateMarketDataOfOwnAssetProfile permissions.updateMarketDataOfOwnAssetProfile
@ -555,7 +598,10 @@ export class UserService {
} }
} else { } else {
if ( if (
await this.propertyService.getByKey<string>(PROPERTY_API_KEY_GHOSTFOLIO) this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX') ||
(await this.propertyService.getByKey<string>(
PROPERTY_API_KEY_GHOSTFOLIO
))
) { ) {
currentPermissions.push(permissions.readMarketDataOfMarkets); currentPermissions.push(permissions.readMarketDataOfMarkets);
} }
@ -779,4 +825,26 @@ export class UserService {
return settings; return settings;
} }
private async getMaxDailyRequests() {
const value = await this.propertyService.getByKey<string>(
PROPERTY_MAX_DAILY_REQUESTS
);
if (isNil(value) || value === '') {
return undefined;
}
const maxDailyRequests = Number(value);
if (!Number.isInteger(maxDailyRequests) || maxDailyRequests < 0) {
this.logger.warn(
`The property ${PROPERTY_MAX_DAILY_REQUESTS} is not a non-negative integer ("${value}"), the daily request limit is not applied`
);
return undefined;
}
return maxDailyRequests;
}
} }

16
apps/api/src/dtos/date-range-filter.dto.ts

@ -0,0 +1,16 @@
import { DATE_RANGES, DEFAULT_DATE_RANGE } from '@ghostfolio/common/config';
import { DateRange } from '@ghostfolio/common/types';
import { Matches } from 'class-validator';
import { FilterDto } from './filter.dto';
// A named date range or a calendar year like '2024', '2023', '2022', etc.
export const DATE_RANGE_PATTERN = new RegExp(
`^(${DATE_RANGES.join('|')}|\\d{4})$`
);
export class DateRangeFilterDto extends FilterDto {
@Matches(DATE_RANGE_PATTERN)
range?: DateRange = DEFAULT_DATE_RANGE;
}

23
apps/api/src/dtos/filter.dto.ts

@ -0,0 +1,23 @@
import { IsOptional, IsString } from 'class-validator';
export class FilterDto {
@IsOptional()
@IsString()
accounts?: string;
@IsOptional()
@IsString()
assetClasses?: string;
@IsOptional()
@IsString()
dataSource?: string;
@IsOptional()
@IsString()
symbol?: string;
@IsOptional()
@IsString()
tags?: string;
}

26
apps/api/src/filters/portfolio-snapshot-computation-exception.filter.ts

@ -0,0 +1,26 @@
import { PortfolioSnapshotComputationError } from '@ghostfolio/api/app/portfolio/errors/portfolio-snapshot-computation.error';
import { ArgumentsHost, Catch, ExceptionFilter, Logger } from '@nestjs/common';
import { Response } from 'express';
import { getReasonPhrase, StatusCodes } from 'http-status-codes';
@Catch(PortfolioSnapshotComputationError)
export class PortfolioSnapshotComputationExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger(
PortfolioSnapshotComputationExceptionFilter.name
);
public catch(
exception: PortfolioSnapshotComputationError,
host: ArgumentsHost
) {
this.logger.error(exception.message);
const response = host.switchToHttp().getResponse<Response>();
response.status(StatusCodes.SERVICE_UNAVAILABLE).json({
message: getReasonPhrase(StatusCodes.SERVICE_UNAVAILABLE),
statusCode: StatusCodes.SERVICE_UNAVAILABLE
});
}
}

18
apps/api/src/helper/account.helper.ts

@ -1,12 +1,28 @@
import { TAG_ID_EXCLUDE_FROM_ANALYSIS } from '@ghostfolio/common/config'; import { TAG_ID_EXCLUDE_FROM_ANALYSIS } from '@ghostfolio/common/config';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { endOfToday, isAfter } from 'date-fns';
export const WHERE_ACCOUNT_NOT_EXCLUDED: Prisma.AccountWhereInput = { export const WHERE_ACCOUNT_NOT_EXCLUDED: Prisma.AccountWhereInput = {
isExcluded: false,
tags: { tags: {
none: { none: {
tagId: TAG_ID_EXCLUDE_FROM_ANALYSIS tagId: TAG_ID_EXCLUDE_FROM_ANALYSIS
} }
} }
}; };
export function getWhereAccountBalanceNotInFuture(): Prisma.AccountBalanceWhereInput {
return {
date: { lte: endOfToday() }
};
}
export function isAccountBalanceInFuture({
date,
endOfTodayDate = endOfToday()
}: {
date: Date;
endOfTodayDate?: Date;
}) {
return isAfter(date, endOfTodayDate);
}

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

@ -38,7 +38,7 @@ export class RedactValuesInResponseInterceptor<T> implements NestInterceptor<
if ( if (
hasReadRestrictedAccessPermission({ hasReadRestrictedAccessPermission({
impersonationId, impersonationId,
user accesses: user?.accessesGet
}) || }) ||
isRestrictedView(user) isRestrictedView(user)
) { ) {

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

@ -78,6 +78,7 @@ export class TransformDataSourceInResponseInterceptor<
valueMap, valueMap,
object: data, object: data,
paths: [ paths: [
'["filters.dataSource"]',
'activities[*].assetProfile.dataSource', 'activities[*].assetProfile.dataSource',
'activities[*].dataSource', 'activities[*].dataSource',
'assetProfile.dataSource', 'assetProfile.dataSource',
@ -88,6 +89,7 @@ export class TransformDataSourceInResponseInterceptor<
'holdings[*].assetProfile.dataSource', 'holdings[*].assetProfile.dataSource',
'holdings[*].dataSource', 'holdings[*].dataSource',
'items[*].dataSource', 'items[*].dataSource',
'settings["filters.dataSource"]',
'watchlist[*].dataSource' 'watchlist[*].dataSource'
] ]
}); });

12
apps/api/src/services/asset-profile-split/asset-profile-split.module.ts

@ -0,0 +1,12 @@
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { Module } from '@nestjs/common';
import { AssetProfileSplitService } from './asset-profile-split.service';
@Module({
exports: [AssetProfileSplitService],
imports: [PrismaModule],
providers: [AssetProfileSplitService]
})
export class AssetProfileSplitModule {}

87
apps/api/src/services/asset-profile-split/asset-profile-split.service.ts

@ -0,0 +1,87 @@
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { resetHours } from '@ghostfolio/common/helper';
import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common';
import { AssetProfileSplit } from '@prisma/client';
@Injectable()
export class AssetProfileSplitService {
public constructor(private readonly prismaService: PrismaService) {}
/**
* Deletes the split with the given id of an asset profile and returns
* whether it existed
*/
public async deleteById({
id,
symbolProfileId
}: {
id: string;
symbolProfileId: string;
}) {
const { count } = await this.prismaService.assetProfileSplit.deleteMany({
where: {
id,
symbolProfileId
}
});
return count > 0;
}
/**
* Returns the splits of the given asset profile in ascending order by date
*/
public async getSplits({
dataSource,
symbol
}: AssetProfileIdentifier): Promise<AssetProfileSplit[]> {
return this.prismaService.assetProfileSplit.findMany({
orderBy: [
{
date: 'asc'
}
],
where: {
symbolProfile: {
dataSource,
symbol
}
}
});
}
public async upsert({
date,
denominator,
numerator,
symbolProfileId
}: {
date: Date;
denominator: number;
numerator: number;
symbolProfileId: string;
}): Promise<AssetProfileSplit> {
const dateOfSplit = resetHours(date);
return this.prismaService.assetProfileSplit.upsert({
create: {
denominator,
numerator,
symbolProfileId,
date: dateOfSplit
},
update: {
denominator,
numerator
},
where: {
symbolProfileId_date: {
symbolProfileId,
date: dateOfSplit
}
}
});
}
}

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

@ -159,7 +159,8 @@ export class BenchmarkService {
let benchmarks = let benchmarks =
(await this.propertyService.getByKey<BenchmarkProperty[]>( (await this.propertyService.getByKey<BenchmarkProperty[]>(
PROPERTY_BENCHMARKS PROPERTY_BENCHMARKS,
{ skipCache: true }
)) ?? []; )) ?? [];
benchmarks.push({ symbolProfileId: assetProfile.id }); benchmarks.push({ symbolProfileId: assetProfile.id });
@ -196,7 +197,8 @@ export class BenchmarkService {
let benchmarks = let benchmarks =
(await this.propertyService.getByKey<BenchmarkProperty[]>( (await this.propertyService.getByKey<BenchmarkProperty[]>(
PROPERTY_BENCHMARKS PROPERTY_BENCHMARKS,
{ skipCache: true }
)) ?? []; )) ?? [];
benchmarks = benchmarks.filter(({ symbolProfileId }) => { benchmarks = benchmarks.filter(({ symbolProfileId }) => {

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

@ -50,12 +50,10 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
response: Partial<SymbolProfile>; response: Partial<SymbolProfile>;
symbol: string; symbol: string;
}): Promise<Partial<SymbolProfile>> { }): Promise<Partial<SymbolProfile>> {
if ( if (!(
!(
response.assetClass === 'EQUITY' && response.assetClass === 'EQUITY' &&
['ETF', 'MUTUALFUND'].includes(response.assetSubClass) ['ETF', 'MUTUALFUND'].includes(response.assetSubClass)
) )) {
) {
return response; return response;
} }
@ -214,8 +212,8 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
return undefined; return undefined;
}) })
.catch(({ message }) => { .catch(({ message }) => {
this.logger.error( this.logger.warn(
`Failed to search Trackinsight symbol for ${symbol} (${message})` `Could not search Trackinsight symbol for "${symbol}": ${message}`
); );
return undefined; return undefined;

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

@ -9,6 +9,7 @@ import { PropertyService } from '@ghostfolio/api/services/property/property.serv
import { import {
DEFAULT_CURRENCY, DEFAULT_CURRENCY,
DERIVED_CURRENCIES, DERIVED_CURRENCIES,
NON_INVESTMENT_ACTIVITY_TYPES,
PROPERTY_API_KEY_GHOSTFOLIO, PROPERTY_API_KEY_GHOSTFOLIO,
PROPERTY_DATA_SOURCE_MAPPING PROPERTY_DATA_SOURCE_MAPPING
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
@ -20,7 +21,8 @@ import {
getCurrencyFromSymbol, getCurrencyFromSymbol,
getStartOfUtcDate, getStartOfUtcDate,
isCurrency, isCurrency,
isDerivedCurrency isDerivedCurrency,
isValidSearchQuery
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
import { import {
AssetProfileIdentifier, AssetProfileIdentifier,
@ -267,7 +269,7 @@ export class DataProviderService implements OnModuleInit {
if (!assetProfiles[assetProfileIdentifier]) { if (!assetProfiles[assetProfileIdentifier]) {
if ( if (
(dataSource === DataSource.MANUAL && type === 'BUY') || (dataSource === DataSource.MANUAL && type === 'BUY') ||
['FEE', 'INTEREST', 'LIABILITY'].includes(type) NON_INVESTMENT_ACTIVITY_TYPES.includes(type)
) { ) {
const assetProfileInImport = assetProfilesWithMarketDataDto?.find( const assetProfileInImport = assetProfilesWithMarketDataDto?.find(
(assetProfile) => { (assetProfile) => {
@ -317,7 +319,7 @@ export class DataProviderService implements OnModuleInit {
if (!assetProfile?.name) { if (!assetProfile?.name) {
throw new Error( throw new Error(
`activities.${index}.symbol ("${symbol}") is not valid for the specified data source ("${maskedDataSource}")` `${activityPath}.symbol ("${symbol}") is not valid for the specified data source ("${maskedDataSource}")`
); );
} }
@ -838,7 +840,9 @@ export class DataProviderService implements OnModuleInit {
let lookupItems: LookupItem[] = []; let lookupItems: LookupItem[] = [];
const promises: Promise<LookupResponse>[] = []; const promises: Promise<LookupResponse>[] = [];
if (query?.length < 2) { query = query?.trim();
if (!isValidSearchQuery(query)) {
return { items: lookupItems }; return { items: lookupItems };
} }

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

@ -178,7 +178,7 @@ export class FinancialModelingPrepService
aliases: FinancialModelingPrepService.countriesMapping, aliases: FinancialModelingPrepService.countriesMapping,
name: countryName name: countryName
}), }),
weight: parseFloat(weightPercentage.slice(0, -1)) / 100 weight: parseFloat(`${weightPercentage}`) / 100
}; };
}); });

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

@ -330,8 +330,12 @@ export class YahooFinanceService implements DataProviderInterface {
}); });
} }
} catch (error) { } catch (error) {
if (error?.name === 'BadRequestError') {
this.logger.warn(`Could not search for "${query}": ${error.message}`);
} else {
this.logger.error(error); this.logger.error(error);
} }
}
return { items }; return { items };
} }

70
apps/api/src/services/property/property.service.ts

@ -6,27 +6,39 @@ import {
import { PropertyKey } from '@ghostfolio/common/types'; import { PropertyKey } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Property } from '@prisma/client';
import { addMilliseconds, isBefore } from 'date-fns';
import ms from 'ms';
import { PropertyValue } from './interfaces/interfaces'; import { PropertyValue } from './interfaces/interfaces';
@Injectable() @Injectable()
export class PropertyService { export class PropertyService {
private static readonly CACHE_TTL = ms('1 minute');
private cachedProperties: Promise<Property[]>;
private cachedPropertiesExpiresAt: Date;
public constructor(private readonly prismaService: PrismaService) {} public constructor(private readonly prismaService: PrismaService) {}
public async delete({ key }: { key: PropertyKey }) { public async delete({ key }: { key: PropertyKey }) {
return this.prismaService.property.delete({ const property = await this.prismaService.property.delete({
where: { key } where: { key }
}); });
this.invalidateCache();
return property;
} }
public async get() { public async get({ skipCache = false } = {}) {
const response: { const response: {
[key: string]: PropertyValue; [key: string]: PropertyValue;
} = { } = {
[PROPERTY_CURRENCIES]: [] [PROPERTY_CURRENCIES]: []
}; };
const properties = await this.prismaService.property.findMany(); const properties = await this.getProperties({ skipCache });
for (const property of properties) { for (const property of properties) {
let value = property.value; let value = property.value;
@ -41,8 +53,11 @@ export class PropertyService {
return response; return response;
} }
public async getByKey<TValue extends PropertyValue>(aKey: PropertyKey) { public async getByKey<TValue extends PropertyValue>(
const properties = await this.get(); aKey: PropertyKey,
{ skipCache = false } = {}
) {
const properties = await this.get({ skipCache });
return properties[aKey] as TValue; return properties[aKey] as TValue;
} }
@ -53,10 +68,53 @@ export class PropertyService {
} }
public async put({ key, value }: { key: PropertyKey; value: string }) { public async put({ key, value }: { key: PropertyKey; value: string }) {
return this.prismaService.property.upsert({ const property = await this.prismaService.property.upsert({
create: { key, value }, create: { key, value },
update: { value }, update: { value },
where: { key } where: { key }
}); });
this.invalidateCache();
return property;
}
/**
* Returns the properties from the in-memory cache, falling back to the
* database. Callers which write back a modified property must set
* skipCache to avoid basing the write on a stale read.
*/
private async getProperties({ skipCache = false } = {}) {
if (skipCache) {
return this.prismaService.property.findMany();
}
if (
this.cachedProperties &&
isBefore(new Date(), this.cachedPropertiesExpiresAt)
) {
return this.cachedProperties;
}
const properties = this.prismaService.property.findMany().catch((error) => {
if (this.cachedProperties === properties) {
this.invalidateCache();
}
throw error;
});
this.cachedProperties = properties;
this.cachedPropertiesExpiresAt = addMilliseconds(
new Date(),
PropertyService.CACHE_TTL
);
return this.cachedProperties;
}
private invalidateCache() {
this.cachedProperties = undefined;
this.cachedPropertiesExpiresAt = undefined;
} }
} }

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

@ -87,7 +87,7 @@ export class PortfolioSnapshotProcessor {
: 0 : 0
); );
this.redisCacheService.set( await this.redisCacheService.set(
this.redisCacheService.getPortfolioSnapshotKey({ this.redisCacheService.getPortfolioSnapshotKey({
filters: job.data.filters, filters: job.data.filters,
userId: job.data.userId userId: job.data.userId

29
apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock.ts

@ -1,32 +1,47 @@
import { PortfolioSnapshotValue } from '@ghostfolio/api/app/portfolio/interfaces/snapshot-value.interface';
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
import type { Job, JobId, JobOptions } from 'bull'; import type { Job, JobId, JobOptions } from 'bull';
import ms from 'ms';
import { setTimeout } from 'timers/promises'; import { setTimeout } from 'timers/promises';
import { PortfolioSnapshotQueueJob } from './interfaces/portfolio-snapshot-queue-job.interface'; import { PortfolioSnapshotQueueJob } from './interfaces/portfolio-snapshot-queue-job.interface';
export const PortfolioSnapshotServiceMock = { export const PortfolioSnapshotServiceMock = {
addJobToQueue({ addJobToQueue: ({
opts opts
}: { }: {
data: PortfolioSnapshotQueueJob; data: PortfolioSnapshotQueueJob;
name: string; name: string;
opts?: JobOptions; opts?: JobOptions;
}): Promise<Job> { }): Promise<Job> => {
const mockJob: Partial<Job> = { const mockJob: Partial<Job> = {
finished: async () => { finished: async () => {
await setTimeout(100); await setTimeout(100);
return Promise.resolve(); // Mimic the processor which caches the computed portfolio snapshot
// under the job id
await RedisCacheServiceMock.set(
opts?.jobId as string,
JSON.stringify({
expiration: Date.now() + ms('1 minute'),
portfolioSnapshot: {}
} as unknown as PortfolioSnapshotValue)
);
} }
}; };
this.jobsStore.set(opts?.jobId, mockJob); PortfolioSnapshotServiceMock.jobsStore.set(opts?.jobId, mockJob);
return Promise.resolve(mockJob as Job); return Promise.resolve(mockJob as Job);
}, },
getJob(jobId: JobId): Promise<Job> { getJob: (jobId: JobId): Promise<Job> => {
const job = this.jobsStore.get(jobId); const job = PortfolioSnapshotServiceMock.jobsStore.get(jobId);
return Promise.resolve(job as Job); return Promise.resolve(job as Job);
}, },
jobsStore: new Map<JobId, Partial<Job>>() jobsStore: new Map<JobId, Partial<Job>>(),
reset: () => {
PortfolioSnapshotServiceMock.jobsStore.clear();
}
}; };

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

@ -105,6 +105,27 @@ export class SymbolProfileService {
}; };
} }
public async getCustomSymbolProfilesByNames({
names,
userId
}: {
names: string[];
userId: string;
}): Promise<Pick<SymbolProfile, 'name' | 'symbol'>[]> {
if (names.length === 0) {
return [];
}
return this.prismaService.symbolProfile.findMany({
select: { name: true, symbol: true },
where: {
userId,
dataSource: DataSource.MANUAL,
name: { in: names }
}
});
}
public async getSymbolProfiles( public async getSymbolProfiles(
aAssetProfileIdentifiers: AssetProfileIdentifier[] aAssetProfileIdentifiers: AssetProfileIdentifier[]
): Promise<EnhancedAssetProfile[]> { ): Promise<EnhancedAssetProfile[]> {

15
apps/client/src/app/app.component.ts

@ -57,12 +57,12 @@ export class GfAppComponent implements OnInit {
public currentRoute: string; public currentRoute: string;
public currentSubRoute: string; public currentSubRoute: string;
public deviceType: string; public deviceType: string;
public hasImpersonationId: boolean;
public hasInfoMessage: boolean; public hasInfoMessage: boolean;
public hasPermissionToChangeDateRange: boolean; public hasPermissionToChangeDateRange: boolean;
public hasPermissionToChangeFilters: boolean; public hasPermissionToChangeFilters: boolean;
public hasPromotion = false; public hasPromotion = false;
public hasTabs = false; public hasTabs = false;
public impersonationId: string | null;
public info: InfoItem; public info: InfoItem;
public pageTitle: string; public pageTitle: string;
public routerLinkRegister = publicRoutes.register.routerLink; public routerLinkRegister = publicRoutes.register.routerLink;
@ -116,7 +116,7 @@ export class GfAppComponent implements OnInit {
.onChangeHasImpersonation() .onChangeHasImpersonation()
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((impersonationId) => { .subscribe((impersonationId) => {
this.hasImpersonationId = !!impersonationId; this.impersonationId = impersonationId;
}); });
this.router.events this.router.events
@ -135,7 +135,10 @@ export class GfAppComponent implements OnInit {
this.currentSubRoute === this.currentSubRoute ===
internalRoutes.home.subRoutes?.holdings.path) || internalRoutes.home.subRoutes?.holdings.path) ||
(this.currentRoute === internalRoutes.portfolio.path && (this.currentRoute === internalRoutes.portfolio.path &&
!this.currentSubRoute)) && !this.currentSubRoute) ||
(this.currentRoute === internalRoutes.portfolio.path &&
this.currentSubRoute ===
internalRoutes.portfolio.subRoutes?.activities.path)) &&
this.user?.settings?.viewMode !== 'ZEN' this.user?.settings?.viewMode !== 'ZEN'
) { ) {
this.hasPermissionToChangeDateRange = true; this.hasPermissionToChangeDateRange = true;
@ -288,13 +291,12 @@ export class GfAppComponent implements OnInit {
baseCurrency: this.user?.settings?.baseCurrency, baseCurrency: this.user?.settings?.baseCurrency,
colorScheme: this.user?.settings?.colorScheme, colorScheme: this.user?.settings?.colorScheme,
deviceType: this.deviceType, deviceType: this.deviceType,
hasImpersonationId: this.hasImpersonationId,
hasPermissionToAccessAdminControl: hasPermission( hasPermissionToAccessAdminControl: hasPermission(
this.user?.permissions, this.user?.permissions,
permissions.accessAdminControl permissions.accessAdminControl
), ),
hasPermissionToCreateActivity: hasPermissionToCreateActivity:
!this.hasImpersonationId && !this.impersonationId &&
hasPermission( hasPermission(
this.user?.permissions, this.user?.permissions,
permissions.createActivity permissions.createActivity
@ -305,12 +307,13 @@ export class GfAppComponent implements OnInit {
permissions.reportDataGlitch permissions.reportDataGlitch
), ),
hasPermissionToUpdateActivity: hasPermissionToUpdateActivity:
!this.hasImpersonationId && !this.impersonationId &&
hasPermission( hasPermission(
this.user?.permissions, this.user?.permissions,
permissions.updateActivity permissions.updateActivity
) && ) &&
!this.user?.settings?.isRestrictedView, !this.user?.settings?.isRestrictedView,
impersonationId: this.impersonationId,
locale: this.user?.settings?.locale locale: this.user?.settings?.locale
}, },
height: this.deviceType === 'mobile' ? '98vh' : '80vh', height: this.deviceType === 'mobile' ? '98vh' : '80vh',

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

@ -14,13 +14,19 @@ import {
PortfolioPosition, PortfolioPosition,
User User
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import {
hasPermission,
hasReadRestrictedAccessPermission,
permissions
} from '@ghostfolio/common/permissions';
import { GfAccountBalancesComponent } from '@ghostfolio/ui/account-balances'; import { GfAccountBalancesComponent } from '@ghostfolio/ui/account-balances';
import { GfActivitiesTableComponent } from '@ghostfolio/ui/activities-table'; import { GfActivitiesTableComponent } from '@ghostfolio/ui/activities-table';
import { GfDialogFooterComponent } from '@ghostfolio/ui/dialog-footer'; import { GfDialogFooterComponent } from '@ghostfolio/ui/dialog-footer';
import { GfDialogHeaderComponent } from '@ghostfolio/ui/dialog-header'; import { GfDialogHeaderComponent } from '@ghostfolio/ui/dialog-header';
import { GfHoldingsTableComponent } from '@ghostfolio/ui/holdings-table'; import { GfHoldingsTableComponent } from '@ghostfolio/ui/holdings-table';
import { translate } from '@ghostfolio/ui/i18n';
import { DataService } from '@ghostfolio/ui/services'; import { DataService } from '@ghostfolio/ui/services';
import { GfTagsSelectorComponent } from '@ghostfolio/ui/tags-selector';
import { GfValueComponent } from '@ghostfolio/ui/value'; import { GfValueComponent } from '@ghostfolio/ui/value';
import { import {
@ -42,12 +48,14 @@ import { MatTableDataSource } from '@angular/material/table';
import { MatTabsModule } from '@angular/material/tabs'; import { MatTabsModule } from '@angular/material/tabs';
import { NavigationStart, Router } from '@angular/router'; import { NavigationStart, Router } from '@angular/router';
import { IonIcon } from '@ionic/angular/standalone'; import { IonIcon } from '@ionic/angular/standalone';
import { Tag } from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { format, parseISO } from 'date-fns'; import { format, parseISO } from 'date-fns';
import { addIcons } from 'ionicons'; import { addIcons } from 'ionicons';
import { import {
albumsOutline, albumsOutline,
cashOutline, cashOutline,
readerOutline,
swapVerticalOutline swapVerticalOutline
} from 'ionicons/icons'; } from 'ionicons/icons';
import { isNumber } from 'lodash'; import { isNumber } from 'lodash';
@ -69,6 +77,7 @@ import {
GfDialogHeaderComponent, GfDialogHeaderComponent,
GfHoldingsTableComponent, GfHoldingsTableComponent,
GfInvestmentChartComponent, GfInvestmentChartComponent,
GfTagsSelectorComponent,
GfValueComponent, GfValueComponent,
IonIcon, IonIcon,
MatButtonModule, MatButtonModule,
@ -97,7 +106,7 @@ export class GfAccountDetailDialogComponent implements OnInit {
protected holdings: PortfolioPosition[]; protected holdings: PortfolioPosition[];
protected interestInBaseCurrency: number; protected interestInBaseCurrency: number;
protected interestInBaseCurrencyPrecision = 2; protected interestInBaseCurrencyPrecision = 2;
protected isLoadingActivities: boolean; protected isLoading = true;
protected isLoadingChart: boolean; protected isLoadingChart: boolean;
protected name: string | null; protected name: string | null;
protected pageIndex = 0; protected pageIndex = 0;
@ -105,6 +114,7 @@ export class GfAccountDetailDialogComponent implements OnInit {
protected platformName: string; protected platformName: string;
protected sortColumn = 'date'; protected sortColumn = 'date';
protected sortDirection: SortDirection = 'desc'; protected sortDirection: SortDirection = 'desc';
protected tags: Tag[];
protected totalItems: number; protected totalItems: number;
protected user: User; protected user: User;
protected valueInBaseCurrency: number; protected valueInBaseCurrency: number;
@ -148,7 +158,12 @@ export class GfAccountDetailDialogComponent implements OnInit {
} }
}); });
addIcons({ albumsOutline, cashOutline, swapVerticalOutline }); addIcons({
albumsOutline,
cashOutline,
readerOutline,
swapVerticalOutline
});
} }
public ngOnInit() { public ngOnInit() {
@ -214,7 +229,10 @@ export class GfAccountDetailDialogComponent implements OnInit {
protected showValuesInPercentage() { protected showValuesInPercentage() {
return ( return (
this.data.hasImpersonationId || this.user?.settings?.isRestrictedView hasReadRestrictedAccessPermission({
accesses: this.user?.access,
impersonationId: this.data.impersonationId
}) || this.user?.settings?.isRestrictedView
); );
} }
@ -231,6 +249,7 @@ export class GfAccountDetailDialogComponent implements OnInit {
interestInBaseCurrency, interestInBaseCurrency,
name, name,
platform, platform,
tags,
value, value,
valueInBaseCurrency valueInBaseCurrency
}) => { }) => {
@ -280,16 +299,25 @@ export class GfAccountDetailDialogComponent implements OnInit {
this.name = name; this.name = name;
this.platformName = platform?.name ?? '-'; this.platformName = platform?.name ?? '-';
this.tags =
tags?.map((tag) => {
return {
...tag,
name: translate(tag.name)
};
}) ?? [];
this.valueInBaseCurrency = valueInBaseCurrency; this.valueInBaseCurrency = valueInBaseCurrency;
this.isLoading = false;
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
} }
); );
} }
private fetchActivities() { private fetchActivities() {
this.isLoadingActivities = true;
this.dataService this.dataService
.fetchActivities({ .fetchActivities({
filters: [{ id: this.data.accountId, type: 'ACCOUNT' }], filters: [{ id: this.data.accountId, type: 'ACCOUNT' }],
@ -303,8 +331,6 @@ export class GfAccountDetailDialogComponent implements OnInit {
this.dataSource = new MatTableDataSource(activities); this.dataSource = new MatTableDataSource(activities);
this.totalItems = count; this.totalItems = count;
this.isLoadingActivities = false;
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
}); });
} }
@ -325,8 +351,7 @@ export class GfAccountDetailDialogComponent implements OnInit {
} }
], ],
range: DEFAULT_DATE_RANGE, range: DEFAULT_DATE_RANGE,
withExcludedAccounts: true, withExcludedAccounts: true
withItems: true
}) })
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
}).subscribe({ }).subscribe({

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

@ -12,6 +12,7 @@
<gf-value <gf-value
size="large" size="large"
[isCurrency]="true" [isCurrency]="true"
[isLoading]="isLoading"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[unit]="user?.settings?.baseCurrency" [unit]="user?.settings?.baseCurrency"
[value]="valueInBaseCurrency" [value]="valueInBaseCurrency"
@ -30,12 +31,25 @@
/> />
</div> </div>
<div class="mb-3 row"> <mat-tab-group
animationDuration="0ms"
class="mb-4"
[disablePagination]="true"
[mat-stretch-tabs]="false"
>
<mat-tab>
<ng-template mat-tab-label>
<ion-icon name="reader-outline" />
<div class="d-none d-sm-block ml-2" i18n>Overview</div>
</ng-template>
<div class="container mt-3 p-0">
<div class="row w-100">
<div class="col-6 mb-3"> <div class="col-6 mb-3">
<gf-value <gf-value
i18n i18n
size="medium" size="medium"
[isCurrency]="true" [isCurrency]="true"
[isLoading]="isLoading"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[precision]="balancePrecision" [precision]="balancePrecision"
[unit]="currency" [unit]="currency"
@ -48,6 +62,7 @@
i18n i18n
size="medium" size="medium"
[isCurrency]="true" [isCurrency]="true"
[isLoading]="isLoading"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[precision]="equityPrecision" [precision]="equityPrecision"
[unit]="currency" [unit]="currency"
@ -60,6 +75,7 @@
i18n i18n
size="medium" size="medium"
[isCurrency]="true" [isCurrency]="true"
[isLoading]="isLoading"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[precision]="interestInBaseCurrencyPrecision" [precision]="interestInBaseCurrencyPrecision"
[unit]="user?.settings?.baseCurrency" [unit]="user?.settings?.baseCurrency"
@ -72,6 +88,7 @@
i18n i18n
size="medium" size="medium"
[isCurrency]="true" [isCurrency]="true"
[isLoading]="isLoading"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[precision]="dividendInBaseCurrencyPrecision" [precision]="dividendInBaseCurrencyPrecision"
[unit]="user?.settings?.baseCurrency" [unit]="user?.settings?.baseCurrency"
@ -80,20 +97,37 @@
> >
</div> </div>
<div class="col-6 mb-3"> <div class="col-6 mb-3">
<gf-value i18n size="medium" [value]="activitiesCount" <gf-value
>Activities</gf-value size="medium"
[isLoading]="isLoading"
[locale]="user?.settings?.locale"
[value]="activitiesCount"
> >
@if (activitiesCount === 1) {
<ng-container i18n>Activity</ng-container>
} @else {
<ng-container i18n>Activities</ng-container>
}
</gf-value>
</div> </div>
<div class="col-6 mb-3"> <div class="col-6 mb-3">
<gf-value i18n size="medium" [value]="platformName">Platform</gf-value> <gf-value
i18n
size="medium"
[isLoading]="isLoading"
[locale]="user?.settings?.locale"
[value]="platformName"
>Platform</gf-value
>
</div> </div>
@if (user?.settings?.isExperimentalFeatures) {
<div class="col-12 mb-3">
<gf-tags-selector [readonly]="true" [tags]="tags" />
</div> </div>
}
<mat-tab-group </div>
animationDuration="0ms" </div>
[class.d-none]="isLoadingActivities" </mat-tab>
[mat-stretch-tabs]="false"
>
<mat-tab> <mat-tab>
<ng-template mat-tab-label> <ng-template mat-tab-label>
<ion-icon name="albums-outline" /> <ion-icon name="albums-outline" />
@ -124,8 +158,8 @@
[pageSize]="pageSize" [pageSize]="pageSize"
[showAccountColumn]="false" [showAccountColumn]="false"
[showActions]=" [showActions]="
!data.hasImpersonationId &&
data.hasPermissionToCreateActivity && data.hasPermissionToCreateActivity &&
!data.impersonationId &&
user?.settings?.isExperimentalFeatures && user?.settings?.isExperimentalFeatures &&
!user?.settings?.isRestrictedView !user?.settings?.isRestrictedView
" "
@ -149,8 +183,8 @@
[currentBalance]="balance" [currentBalance]="balance"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[showActions]=" [showActions]="
!data.hasImpersonationId &&
hasPermissionToDeleteAccountBalance && hasPermissionToDeleteAccountBalance &&
!data.impersonationId &&
!user.settings.isRestrictedView !user.settings.isRestrictedView
" "
(accountBalanceCreated)="onAddAccountBalance($event)" (accountBalanceCreated)="onAddAccountBalance($event)"

2
apps/client/src/app/components/account-detail-dialog/interfaces/interfaces.ts

@ -1,8 +1,8 @@
export interface AccountDetailDialogParams { export interface AccountDetailDialogParams {
accountId: string; accountId: string;
deviceType: string; deviceType: string;
hasImpersonationId: boolean;
hasPermissionToCreateActivity: boolean; hasPermissionToCreateActivity: boolean;
impersonationId: string | null;
} }
export interface AccountDetailDialogResult { export interface AccountDetailDialogResult {

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

@ -43,6 +43,7 @@ import {
MatPaginatorModule, MatPaginatorModule,
PageEvent PageEvent
} from '@angular/material/paginator'; } from '@angular/material/paginator';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { import {
MatSort, MatSort,
MatSortModule, MatSortModule,
@ -64,6 +65,7 @@ import {
ellipsisVertical, ellipsisVertical,
trashOutline trashOutline
} from 'ionicons/icons'; } from 'ionicons/icons';
import ms from 'ms';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
@ -88,6 +90,7 @@ import { CreateAssetProfileDialogParams } from './create-asset-profile-dialog/in
MatCheckboxModule, MatCheckboxModule,
MatMenuModule, MatMenuModule,
MatPaginatorModule, MatPaginatorModule,
MatSnackBarModule,
MatSortModule, MatSortModule,
MatTableModule, MatTableModule,
NgxSkeletonLoaderModule, NgxSkeletonLoaderModule,
@ -177,6 +180,7 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit {
private readonly dialog = inject(MatDialog); private readonly dialog = inject(MatDialog);
private readonly route = inject(ActivatedRoute); private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router); private readonly router = inject(Router);
private readonly snackBar = inject(MatSnackBar);
private readonly userService = inject(UserService); private readonly userService = inject(UserService);
public constructor() { public constructor() {
@ -239,7 +243,7 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit {
.subscribe((filters) => { .subscribe((filters) => {
this.activeFilters = filters; this.activeFilters = filters;
this.loadData(); this.reloadData({ pageIndex: 0 });
}); });
addIcons({ addIcons({
@ -285,15 +289,25 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit {
dataSource, dataSource,
symbol symbol
}: AssetProfileIdentifier) { }: AssetProfileIdentifier) {
this.adminMarketDataService.deleteAssetProfile({ dataSource, symbol }); this.adminMarketDataService
.deleteAssetProfile({ dataSource, symbol })
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
this.reloadData();
});
} }
protected onDeleteAssetProfiles() { protected onDeleteAssetProfiles() {
this.adminMarketDataService.deleteAssetProfiles( this.adminMarketDataService
.deleteAssetProfiles(
this.selection.selected.map(({ dataSource, symbol }) => { this.selection.selected.map(({ dataSource, symbol }) => {
return { dataSource, symbol }; return { dataSource, symbol };
}) })
); )
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
this.reloadData();
});
} }
protected onGatherMax() { protected onGatherMax() {
@ -301,9 +315,7 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit {
.gatherMax() .gatherMax()
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => { .subscribe(() => {
setTimeout(() => { this.notifyDataGatheringHasBeenStarted();
window.location.reload();
}, 300);
}); });
} }
@ -311,7 +323,9 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit {
this.adminService this.adminService
.gatherProfileData() .gatherProfileData()
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(); .subscribe(() => {
this.notifyDataGatheringHasBeenStarted();
});
} }
protected onGatherRecentMarketData() { protected onGatherRecentMarketData() {
@ -319,9 +333,7 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit {
.gatherRecentMarketData() .gatherRecentMarketData()
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => { .subscribe(() => {
setTimeout(() => { this.notifyDataGatheringHasBeenStarted();
window.location.reload();
}, 300);
}); });
} }
@ -396,6 +408,16 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit {
}); });
} }
private notifyDataGatheringHasBeenStarted() {
this.snackBar.open(
'✅ ' + $localize`Data gathering has been started.`,
undefined,
{
duration: ms('3 seconds')
}
);
}
private openAssetProfileDialog({ private openAssetProfileDialog({
dataSource, dataSource,
symbol symbol
@ -431,6 +453,8 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit {
if (newAssetProfileIdentifier) { if (newAssetProfileIdentifier) {
this.onOpenAssetProfileDialog(newAssetProfileIdentifier); this.onOpenAssetProfileDialog(newAssetProfileIdentifier);
} else { } else {
this.reloadData();
this.router.navigate(['.'], { relativeTo: this.route }); this.router.navigate(['.'], { relativeTo: this.route });
} }
}); });
@ -483,4 +507,14 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit {
}); });
}); });
} }
private reloadData({
pageIndex = this.paginator().pageIndex
}: { pageIndex?: number } = {}) {
this.loadData({
pageIndex,
sortColumn: this.sort().active,
sortDirection: this.sort().direction
});
}
} }

17
apps/client/src/app/components/admin-market-data/admin-market-data.service.ts

@ -4,7 +4,7 @@ import { NotificationService } from '@ghostfolio/ui/notifications';
import { AdminService } from '@ghostfolio/ui/services'; import { AdminService } from '@ghostfolio/ui/services';
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { EMPTY, catchError, finalize, forkJoin } from 'rxjs'; import { EMPTY, Subject, catchError, finalize, forkJoin } from 'rxjs';
@Injectable() @Injectable()
export class AdminMarketDataService { export class AdminMarketDataService {
@ -14,25 +14,29 @@ export class AdminMarketDataService {
) {} ) {}
public deleteAssetProfile({ dataSource, symbol }: AssetProfileIdentifier) { public deleteAssetProfile({ dataSource, symbol }: AssetProfileIdentifier) {
const assetProfileDeleted = new Subject<void>();
this.notificationService.confirm({ this.notificationService.confirm({
confirmFn: () => { confirmFn: () => {
this.adminService this.adminService
.deleteProfileData({ dataSource, symbol }) .deleteProfileData({ dataSource, symbol })
.subscribe(() => { .subscribe(() => {
setTimeout(() => { assetProfileDeleted.next();
window.location.reload(); assetProfileDeleted.complete();
}, 300);
}); });
}, },
confirmType: ConfirmationDialogType.Warn, confirmType: ConfirmationDialogType.Warn,
title: $localize`Do you really want to delete this asset profile?` title: $localize`Do you really want to delete this asset profile?`
}); });
return assetProfileDeleted.asObservable();
} }
public deleteAssetProfiles( public deleteAssetProfiles(
aAssetProfileIdentifiers: AssetProfileIdentifier[] aAssetProfileIdentifiers: AssetProfileIdentifier[]
) { ) {
const assetProfileCount = aAssetProfileIdentifiers.length; const assetProfileCount = aAssetProfileIdentifiers.length;
const assetProfilesDeleted = new Subject<void>();
this.notificationService.confirm({ this.notificationService.confirm({
confirmFn: () => { confirmFn: () => {
@ -55,7 +59,8 @@ export class AdminMarketDataService {
return EMPTY; return EMPTY;
}), }),
finalize(() => { finalize(() => {
window.location.reload(); assetProfilesDeleted.next();
assetProfilesDeleted.complete();
}) })
) )
.subscribe(); .subscribe();
@ -66,5 +71,7 @@ export class AdminMarketDataService {
? $localize`Do you really want to delete this asset profile?` ? $localize`Do you really want to delete this asset profile?`
: $localize`Do you really want to delete these ${assetProfileCount}:count: asset profiles?` : $localize`Do you really want to delete these ${assetProfileCount}:count: asset profiles?`
}); });
return assetProfilesDeleted.asObservable();
} }
} }

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

@ -11,7 +11,11 @@ import {
DATE_FORMAT, DATE_FORMAT,
getCountryName, getCountryName,
getCurrencyFromSymbol, getCurrencyFromSymbol,
isCurrency getDateFormatString,
getStringOrNull,
getStringOrUndefined,
isCurrency,
isSplitRatio
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
import { import {
AdminMarketDataDetails, AdminMarketDataDetails,
@ -35,6 +39,7 @@ import { GfSymbolAutocompleteComponent } from '@ghostfolio/ui/symbol-autocomplet
import { GfValueComponent } from '@ghostfolio/ui/value'; import { GfValueComponent } from '@ghostfolio/ui/value';
import { TextFieldModule } from '@angular/cdk/text-field'; import { TextFieldModule } from '@angular/cdk/text-field';
import { CommonModule } from '@angular/common';
import { HttpErrorResponse } from '@angular/common/http'; import { HttpErrorResponse } from '@angular/common/http';
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
@ -61,6 +66,7 @@ import {
MatCheckboxChange, MatCheckboxChange,
MatCheckboxModule MatCheckboxModule
} from '@angular/material/checkbox'; } from '@angular/material/checkbox';
import { MatDatepickerModule } from '@angular/material/datepicker';
import { import {
MAT_DIALOG_DATA, MAT_DIALOG_DATA,
MatDialogModule, MatDialogModule,
@ -74,6 +80,7 @@ import { MatTabsModule } from '@angular/material/tabs';
import { IonIcon } from '@ionic/angular/standalone'; import { IonIcon } from '@ionic/angular/standalone';
import { import {
AssetClass, AssetClass,
AssetProfileSplit,
AssetSubClass, AssetSubClass,
DataGatheringFrequency, DataGatheringFrequency,
DataSource, DataSource,
@ -86,11 +93,14 @@ import { format } from 'date-fns';
import { StatusCodes } from 'http-status-codes'; import { StatusCodes } from 'http-status-codes';
import { addIcons } from 'ionicons'; import { addIcons } from 'ionicons';
import { import {
calendarClearOutline,
codeSlashOutline, codeSlashOutline,
createOutline, createOutline,
ellipsisVertical, ellipsisVertical,
gitCompareOutline,
readerOutline, readerOutline,
serverOutline serverOutline,
trashOutline
} from 'ionicons/icons'; } from 'ionicons/icons';
import { isBoolean } from 'lodash'; import { isBoolean } from 'lodash';
import ms from 'ms'; import ms from 'ms';
@ -103,6 +113,7 @@ import { AssetProfileDialogParams } from './interfaces/interfaces';
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'd-flex flex-column h-100' }, host: { class: 'd-flex flex-column h-100' },
imports: [ imports: [
CommonModule,
FormsModule, FormsModule,
GfCurrencySelectorComponent, GfCurrencySelectorComponent,
GfEntityLogoComponent, GfEntityLogoComponent,
@ -114,6 +125,7 @@ import { AssetProfileDialogParams } from './interfaces/interfaces';
IonIcon, IonIcon,
MatButtonModule, MatButtonModule,
MatCheckboxModule, MatCheckboxModule,
MatDatepickerModule,
MatDialogModule, MatDialogModule,
MatInputModule, MatInputModule,
MatMenuModule, MatMenuModule,
@ -194,6 +206,26 @@ export class GfAssetProfileDialogComponent implements OnInit {
} }
); );
protected readonly assetProfileSplitForm = this.formBuilder.group(
{
date: new FormControl<Date | null>(null, Validators.required),
denominator: new FormControl<number | null>(null, Validators.required),
numerator: new FormControl<number | null>(null, Validators.required)
},
{
validators: (control: AbstractControl): ValidationErrors | null => {
const { denominator, numerator } = control.value as {
denominator: number;
numerator: number;
};
return isSplitRatio({ denominator, numerator })
? null
: { invalidSplitRatio: true };
}
}
);
protected readonly canDeleteAssetProfile = canDeleteAssetProfile; protected readonly canDeleteAssetProfile = canDeleteAssetProfile;
protected canEditAssetProfile = true; protected canEditAssetProfile = true;
@ -245,11 +277,13 @@ export class GfAssetProfileDialogComponent implements OnInit {
value: 'max' value: 'max'
} }
]; ];
protected defaultDateFormat: string;
protected readonly getCountryName = getCountryName; protected readonly getCountryName = getCountryName;
protected historicalDataItems: LineChartItem[]; protected historicalDataItems: LineChartItem[];
protected isBenchmark = false; protected isBenchmark = false;
protected isDataGatheringEnabled: boolean; protected isDataGatheringEnabled: boolean;
protected isEditAssetProfileIdentifierMode = false; protected isEditAssetProfileIdentifierMode = false;
protected isLoading = true;
protected readonly isUUID = isUUID; protected readonly isUUID = isUUID;
protected marketDataItems: MarketData[] = []; protected marketDataItems: MarketData[] = [];
@ -268,6 +302,8 @@ export class GfAssetProfileDialogComponent implements OnInit {
[name: string]: { name: string; value: number }; [name: string]: { name: string; value: number };
}; };
protected splits: AssetProfileSplit[] = [];
protected readonly translate = translate; protected readonly translate = translate;
protected user: User; protected user: User;
@ -291,11 +327,14 @@ export class GfAssetProfileDialogComponent implements OnInit {
private userService: UserService private userService: UserService
) { ) {
addIcons({ addIcons({
calendarClearOutline,
codeSlashOutline, codeSlashOutline,
createOutline, createOutline,
ellipsisVertical, ellipsisVertical,
gitCompareOutline,
readerOutline, readerOutline,
serverOutline serverOutline,
trashOutline
}); });
} }
@ -308,6 +347,7 @@ export class GfAssetProfileDialogComponent implements OnInit {
this.benchmarks = benchmarks; this.benchmarks = benchmarks;
this.currencies = currencies; this.currencies = currencies;
this.defaultDateFormat = getDateFormatString(this.data.locale);
this.initialize(); this.initialize();
} }
@ -362,8 +402,9 @@ export class GfAssetProfileDialogComponent implements OnInit {
symbol: this.data.symbol symbol: this.data.symbol
}) })
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ assetProfile, marketData }) => { .subscribe(({ assetProfile, marketData, splits }) => {
this.assetProfile = assetProfile; this.assetProfile = assetProfile;
this.splits = splits ?? [];
this.assetClassLabel = translate(this.assetProfile?.assetClass ?? ''); this.assetClassLabel = translate(this.assetProfile?.assetClass ?? '');
this.assetSubClassLabel = translate( this.assetSubClassLabel = translate(
@ -456,6 +497,8 @@ export class GfAssetProfileDialogComponent implements OnInit {
this.assetProfileForm.markAsPristine(); this.assetProfileForm.markAsPristine();
this.isLoading = false;
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
}); });
} }
@ -491,9 +534,12 @@ export class GfAssetProfileDialogComponent implements OnInit {
dataSource, dataSource,
symbol symbol
}: AssetProfileIdentifier) { }: AssetProfileIdentifier) {
this.adminMarketDataService.deleteAssetProfile({ dataSource, symbol }); this.adminMarketDataService
.deleteAssetProfile({ dataSource, symbol })
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
this.dialogRef.close(); this.dialogRef.close();
});
} }
protected onGatherProfileDataBySymbol({ protected onGatherProfileDataBySymbol({
@ -519,6 +565,45 @@ export class GfAssetProfileDialogComponent implements OnInit {
.subscribe(); .subscribe();
} }
protected onAddSplit() {
const { date, denominator, numerator } =
this.assetProfileSplitForm.getRawValue();
if (!date || !denominator || !numerator) {
return;
}
this.adminService
.postAssetProfileSplit({
dataSource: this.data.dataSource,
split: {
denominator,
numerator,
date: format(date, DATE_FORMAT)
},
symbol: this.data.symbol
})
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
this.assetProfileSplitForm.reset();
this.initialize();
});
}
protected onDeleteSplit(aId: string) {
this.adminService
.deleteAssetProfileSplit({
dataSource: this.data.dataSource,
id: aId,
symbol: this.data.symbol
})
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
this.initialize();
});
}
protected onMarketDataChanged(withRefresh: boolean = false) { protected onMarketDataChanged(withRefresh: boolean = false) {
if (withRefresh) { if (withRefresh) {
this.initialize(); this.initialize();
@ -568,9 +653,10 @@ export class GfAssetProfileDialogComponent implements OnInit {
this.assetProfileForm.controls.scraperConfiguration.controls.headers this.assetProfileForm.controls.scraperConfiguration.controls.headers
.value ?? '{}' .value ?? '{}'
) as Record<string, string>, ) as Record<string, string>,
locale: locale: getStringOrUndefined(
this.assetProfileForm.controls.scraperConfiguration.controls.locale this.assetProfileForm.controls.scraperConfiguration.controls.locale
?.value ?? undefined, ?.value
),
mode: mode:
this.assetProfileForm.controls.scraperConfiguration.controls.mode this.assetProfileForm.controls.scraperConfiguration.controls.mode
?.value ?? undefined, ?.value ?? undefined,
@ -619,7 +705,7 @@ export class GfAssetProfileDialogComponent implements OnInit {
assetClass: this.assetProfileForm.controls.assetClass.value ?? undefined, assetClass: this.assetProfileForm.controls.assetClass.value ?? undefined,
assetSubClass: assetSubClass:
this.assetProfileForm.controls.assetSubClass.value ?? undefined, this.assetProfileForm.controls.assetSubClass.value ?? undefined,
comment: this.assetProfileForm.controls.comment.value ?? undefined, comment: getStringOrNull(this.assetProfileForm.controls.comment.value),
currency: this.assetProfileForm.controls.currency.value ?? undefined, currency: this.assetProfileForm.controls.currency.value ?? undefined,
dataGatheringFrequency: dataGatheringFrequency:
this.assetProfileForm.controls.dataGatheringFrequency.value ?? this.assetProfileForm.controls.dataGatheringFrequency.value ??
@ -628,7 +714,7 @@ export class GfAssetProfileDialogComponent implements OnInit {
? this.assetProfileForm.controls.isActive.value ? this.assetProfileForm.controls.isActive.value
: undefined, : undefined,
name: this.assetProfileForm.controls.name.value ?? undefined, name: this.assetProfileForm.controls.name.value ?? undefined,
url: this.assetProfileForm.controls.url.value ?? undefined url: getStringOrNull(this.assetProfileForm.controls.url.value)
}; };
try { try {
@ -737,9 +823,10 @@ export class GfAssetProfileDialogComponent implements OnInit {
this.assetProfileForm.controls.scraperConfiguration.controls.headers this.assetProfileForm.controls.scraperConfiguration.controls.headers
.value ?? '{}' .value ?? '{}'
) as Record<string, string>, ) as Record<string, string>,
locale: locale: getStringOrUndefined(
this.assetProfileForm.controls.scraperConfiguration.controls.locale this.assetProfileForm.controls.scraperConfiguration.controls.locale
?.value ?? undefined, ?.value
),
mode: this.assetProfileForm.controls.scraperConfiguration.controls mode: this.assetProfileForm.controls.scraperConfiguration.controls
.mode?.value, .mode?.value,
selector: selector:

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

@ -105,6 +105,7 @@
<mat-tab-group <mat-tab-group
animationDuration="0ms" animationDuration="0ms"
[disablePagination]="true"
[dynamicHeight]="true" [dynamicHeight]="true"
[mat-stretch-tabs]="false" [mat-stretch-tabs]="false"
> >
@ -182,6 +183,7 @@
i18n i18n
size="medium" size="medium"
[enableCopyToClipboardButton]="true" [enableCopyToClipboardButton]="true"
[isLoading]="isLoading"
[value]="assetProfile?.symbol" [value]="assetProfile?.symbol"
>Symbol</gf-value >Symbol</gf-value
> >
@ -190,6 +192,7 @@
<gf-value <gf-value
i18n i18n
size="medium" size="medium"
[isLoading]="isLoading"
[value]=" [value]="
assetProfile?.dataProviderInfo?.name ?? assetProfile?.dataProviderInfo?.name ??
assetProfile?.dataSource assetProfile?.dataSource
@ -212,7 +215,11 @@
</div> </div>
} }
<div class="col-6 mb-3"> <div class="col-6 mb-3">
<gf-value i18n size="medium" [value]="assetProfile?.currency" <gf-value
i18n
size="medium"
[isLoading]="isLoading"
[value]="assetProfile?.currency"
>Currency</gf-value >Currency</gf-value
> >
</div> </div>
@ -230,6 +237,7 @@
i18n i18n
size="medium" size="medium"
[isDate]="assetProfile?.dateOfFirstActivity ? true : false" [isDate]="assetProfile?.dateOfFirstActivity ? true : false"
[isLoading]="isLoading"
[locale]="data.locale" [locale]="data.locale"
[value]="assetProfile?.dateOfFirstActivity ?? '-'" [value]="assetProfile?.dateOfFirstActivity ?? '-'"
>First Activity</gf-value >First Activity</gf-value
@ -239,6 +247,7 @@
<gf-value <gf-value
i18n i18n
size="medium" size="medium"
[isLoading]="isLoading"
[locale]="data.locale" [locale]="data.locale"
[value]="assetProfile?.activitiesCount" [value]="assetProfile?.activitiesCount"
>Activities</gf-value >Activities</gf-value
@ -502,6 +511,124 @@
</div> </div>
</div> </div>
</mat-tab> </mat-tab>
<!-- Splits are not applied to the portfolio calculation yet, hence the
tab to manage them is disabled -->
@if (false && user?.settings?.isExperimentalFeatures) {
<mat-tab>
<ng-template mat-tab-label>
<ion-icon name="git-compare-outline" />
<div class="d-none d-sm-block ml-2" i18n>Splits</div>
</ng-template>
<div class="container mt-3 p-0">
<div class="no-gutters row w-100">
<div class="col-12">
@if (splits.length > 0) {
<table class="gf-table mb-3 w-100">
<thead>
<tr class="mat-mdc-header-row">
<th class="mat-mdc-header-cell px-1 py-2" i18n>Date</th>
<th
class="mat-mdc-header-cell px-1 py-2 text-right"
i18n
>
Split Ratio
</th>
<th class="mat-mdc-header-cell px-1 py-2"></th>
</tr>
</thead>
<tbody>
@for (split of splits; track split.id) {
<tr class="mat-mdc-row">
<td class="mat-mdc-cell px-1 py-2">
{{ split.date | date: defaultDateFormat }}
</td>
<td class="mat-mdc-cell px-1 py-2 text-right">
{{ split.numerator }}:{{ split.denominator }}
</td>
<td class="mat-mdc-cell px-1 py-2 text-right">
<button
mat-icon-button
type="button"
[disabled]="!canEditAssetProfile"
(click)="onDeleteSplit(split.id)"
>
<ion-icon name="trash-outline" />
</button>
</td>
</tr>
}
</tbody>
</table>
}
<form
[formGroup]="assetProfileSplitForm"
(ngSubmit)="onAddSplit()"
>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Date</mat-label>
<input
formControlName="date"
matInput
[matDatepicker]="dateOfSplit"
/>
<mat-datepicker-toggle
class="mr-2"
matSuffix
[for]="dateOfSplit"
>
<ion-icon
class="text-muted"
matDatepickerToggleIcon
name="calendar-clear-outline"
/>
</mat-datepicker-toggle>
<mat-datepicker #dateOfSplit />
</mat-form-field>
<div class="align-items-center d-flex mb-3">
<mat-form-field
appearance="outline"
class="w-100 without-hint"
>
<mat-label i18n>Shares After</mat-label>
<input
formControlName="numerator"
matInput
step="1"
type="number"
/>
</mat-form-field>
<div class="mx-2">:</div>
<mat-form-field
appearance="outline"
class="w-100 without-hint"
>
<mat-label i18n>Shares Before</mat-label>
<input
formControlName="denominator"
matInput
step="1"
type="number"
/>
</mat-form-field>
</div>
<div class="text-right">
<button
color="primary"
mat-flat-button
type="submit"
[disabled]="
!assetProfileSplitForm.valid || !canEditAssetProfile
"
>
<ng-container i18n>Add</ng-container>
</button>
</div>
</form>
</div>
</div>
</div>
</mat-tab>
}
@if (assetProfile?.dataSource === 'MANUAL') { @if (assetProfile?.dataSource === 'MANUAL') {
<mat-tab> <mat-tab>
<ng-template mat-tab-label> <ng-template mat-tab-label>

41
apps/client/src/app/components/admin-overview/admin-overview.component.ts

@ -64,6 +64,7 @@ import {
} from 'ionicons/icons'; } from 'ionicons/icons';
import ms, { StringValue } from 'ms'; import ms, { StringValue } from 'ms';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { catchError, of, switchMap } from 'rxjs';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
@ -105,6 +106,8 @@ export class GfAdminOverviewComponent implements OnInit {
protected readonly info: InfoItem; protected readonly info: InfoItem;
protected isDataGatheringEnabled: boolean; protected isDataGatheringEnabled: boolean;
protected isLoading = false; protected isLoading = false;
protected isReadOnlyMode: boolean;
protected isUserSignupEnabled: boolean;
protected readonly permissions = permissions; protected readonly permissions = permissions;
protected systemMessage: SystemMessage; protected systemMessage: SystemMessage;
protected userCount: number; protected userCount: number;
@ -179,6 +182,8 @@ export class GfAdminOverviewComponent implements OnInit {
} }
public ngOnInit() { public ngOnInit() {
this.isLoading = true;
this.fetchAdminData(); this.fetchAdminData();
} }
@ -270,9 +275,15 @@ export class GfAdminOverviewComponent implements OnInit {
.flush() .flush()
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => { .subscribe(() => {
setTimeout(() => { this.dataService.updateInfo();
window.location.reload();
}, 300); this.snackBar.open(
'✅ ' + $localize`Cache has been flushed.`,
undefined,
{
duration: ms('3 seconds')
}
);
}); });
}, },
confirmType: ConfirmationDialogType.Warn, confirmType: ConfirmationDialogType.Warn,
@ -330,8 +341,6 @@ export class GfAdminOverviewComponent implements OnInit {
} }
private fetchAdminData() { private fetchAdminData() {
this.isLoading = true;
this.adminService this.adminService
.fetchAdminData() .fetchAdminData()
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
@ -344,6 +353,11 @@ export class GfAdminOverviewComponent implements OnInit {
this.isDataGatheringEnabled = this.isDataGatheringEnabled =
settings[PROPERTY_IS_DATA_GATHERING_ENABLED] === false ? false : true; settings[PROPERTY_IS_DATA_GATHERING_ENABLED] === false ? false : true;
this.isReadOnlyMode = settings[PROPERTY_IS_READ_ONLY_MODE] === true;
this.isUserSignupEnabled =
settings[PROPERTY_IS_USER_SIGNUP_ENABLED] === false ? false : true;
this.systemMessage = settings[PROPERTY_SYSTEM_MESSAGE] as SystemMessage; this.systemMessage = settings[PROPERTY_SYSTEM_MESSAGE] as SystemMessage;
this.userCount = userCount; this.userCount = userCount;
this.version = version; this.version = version;
@ -372,11 +386,20 @@ export class GfAdminOverviewComponent implements OnInit {
.putAdminSetting(key, { .putAdminSetting(key, {
value: value || value === false ? JSON.stringify(value) : undefined value: value || value === false ? JSON.stringify(value) : undefined
}) })
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(
switchMap(() => {
return this.userService.get(true);
}),
catchError(() => {
// Refresh anyway to reflect the actual state of the settings
return of(undefined);
}),
takeUntilDestroyed(this.destroyRef)
)
.subscribe(() => { .subscribe(() => {
setTimeout(() => { this.dataService.updateInfo();
window.location.reload();
}, 300); this.fetchAdminData();
}); });
} }

18
apps/client/src/app/components/admin-overview/admin-overview.html

@ -7,6 +7,7 @@
i18n i18n
size="large" size="large"
[enableCopyToClipboardButton]="true" [enableCopyToClipboardButton]="true"
[isLoading]="isLoading"
[value]="version" [value]="version"
>Version</gf-value >Version</gf-value
> >
@ -19,6 +20,7 @@
<gf-value <gf-value
i18n i18n
size="large" size="large"
[isLoading]="isLoading"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[value]="userCount" [value]="userCount"
>Users</gf-value >Users</gf-value
@ -32,6 +34,7 @@
<gf-value <gf-value
i18n i18n
size="large" size="large"
[isLoading]="isLoading"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[subLabel]="activitiesCountPerUser" [subLabel]="activitiesCountPerUser"
[value]="activitiesCount" [value]="activitiesCount"
@ -52,9 +55,8 @@
<mat-slide-toggle <mat-slide-toggle
color="primary" color="primary"
hideIcon="true" hideIcon="true"
[checked]=" [checked]="isUserSignupEnabled"
info.globalPermissions.includes(permissions.createUserAccount) [disabled]="isLoading"
"
(change)="onEnableUserSignupModeChange($event)" (change)="onEnableUserSignupModeChange($event)"
/> />
</div> </div>
@ -66,7 +68,8 @@
<mat-slide-toggle <mat-slide-toggle
color="primary" color="primary"
hideIcon="true" hideIcon="true"
[checked]="info?.isReadOnlyMode" [checked]="isReadOnlyMode"
[disabled]="isLoading"
(change)="onReadOnlyModeChange($event)" (change)="onReadOnlyModeChange($event)"
/> />
</div> </div>
@ -79,6 +82,7 @@
color="primary" color="primary"
hideIcon="true" hideIcon="true"
[checked]="isDataGatheringEnabled" [checked]="isDataGatheringEnabled"
[disabled]="isLoading"
(change)="onEnableDataGatheringChange($event)" (change)="onEnableDataGatheringChange($event)"
/> />
</div> </div>
@ -89,7 +93,9 @@
<div class="w-50"> <div class="w-50">
@if (systemMessage) { @if (systemMessage) {
<div class="align-items-center d-flex"> <div class="align-items-center d-flex">
<div class="text-truncate">{{ systemMessage | json }}</div> <div class="text-truncate">
{{ systemMessage | json }}
</div>
<button <button
class="h-100 mx-1 no-min-width px-2" class="h-100 mx-1 no-min-width px-2"
mat-button mat-button
@ -99,7 +105,7 @@
</button> </button>
</div> </div>
} }
@if (!info?.systemMessage) { @if (!systemMessage) {
<button <button
class="mt-2" class="mt-2"
color="accent" color="accent"

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

@ -22,13 +22,12 @@
<ng-container i18n>Name</ng-container> <ng-container i18n>Name</ng-container>
</th> </th>
<td *matCellDef="let element" class="px-1" mat-cell> <td *matCellDef="let element" class="px-1" mat-cell>
@if (element.url) {
<gf-entity-logo <gf-entity-logo
class="d-inline mr-1" class="d-inline mr-1"
[hasPlaceholder]="true"
[tooltip]="element.name" [tooltip]="element.name"
[url]="element.url" [url]="element.url"
/> />
}
<span>{{ element.name }}</span> <span>{{ element.name }}</span>
</td></ng-container </td></ng-container
> >

25
apps/client/src/app/components/admin-settings/admin-settings.component.html

@ -59,7 +59,11 @@
</th> </th>
<td *matCellDef="let element" class="px-1" mat-cell> <td *matCellDef="let element" class="px-1" mat-cell>
<div class="d-flex align-items-center"> <div class="d-flex align-items-center">
<gf-entity-logo class="mr-1" [url]="element.url" /> <gf-entity-logo
class="mr-1"
[hasPlaceholder]="true"
[url]="element.url"
/>
<div> <div>
@if (isGhostfolioDataProvider(element)) { @if (isGhostfolioDataProvider(element)) {
<a <a
@ -105,8 +109,13 @@
</th> </th>
<td *matCellDef="let element" class="px-1" mat-cell> <td *matCellDef="let element" class="px-1" mat-cell>
@if ( @if (
!isGhostfolioDataProvider(element) || hasGhostfolioApiKey &&
isGhostfolioApiKeyValid === true isGhostfolioApiKeyValid === false &&
isGhostfolioDataProvider(element)
) {
<span class="text-danger" i18n>Invalid API key</span>
} @else if (
hasGhostfolioApiKey || !isGhostfolioDataProvider(element)
) { ) {
<gf-data-provider-status [dataSource]="element.dataSource" /> <gf-data-provider-status [dataSource]="element.dataSource" />
} }
@ -162,7 +171,7 @@
<td *matCellDef="let element" class="px-1 text-right" mat-cell> <td *matCellDef="let element" class="px-1 text-right" mat-cell>
@if (isGhostfolioDataProvider(element)) { @if (isGhostfolioDataProvider(element)) {
@if (isGhostfolioApiKeyValid === true) { @if (hasGhostfolioApiKey) {
<button <button
class="mx-1 no-min-width px-2" class="mx-1 no-min-width px-2"
mat-button mat-button
@ -171,7 +180,11 @@
> >
<ion-icon name="ellipsis-horizontal" /> <ion-icon name="ellipsis-horizontal" />
</button> </button>
<mat-menu #ghostfolioApiMenu="matMenu" xPosition="before"> <mat-menu
#ghostfolioApiMenu="matMenu"
class="no-max-width"
xPosition="before"
>
<button mat-menu-item (click)="onRemoveGhostfolioApiKey()"> <button mat-menu-item (click)="onRemoveGhostfolioApiKey()">
<span class="align-items-center d-flex"> <span class="align-items-center d-flex">
<ion-icon class="mr-2" name="trash-outline" /> <ion-icon class="mr-2" name="trash-outline" />
@ -179,7 +192,7 @@
</span> </span>
</button> </button>
</mat-menu> </mat-menu>
} @else if (isGhostfolioApiKeyValid === false) { } @else if (hasGhostfolioApiKey === false) {
<button <button
class="special" class="special"
mat-flat-button mat-flat-button

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

@ -81,6 +81,7 @@ export class GfAdminSettingsComponent implements OnInit {
'actions' 'actions'
]; ];
public ghostfolioApiStatus: DataProviderGhostfolioStatusResponse; public ghostfolioApiStatus: DataProviderGhostfolioStatusResponse;
public hasGhostfolioApiKey: boolean;
public isGhostfolioApiKeyValid: boolean; public isGhostfolioApiKeyValid: boolean;
public isLoading = false; public isLoading = false;
public readonly premiumDataProviderMailHref = `mailto:hi@ghostfol.io?subject=Ghostfolio Premium Data Provider&body=${[ public readonly premiumDataProviderMailHref = `mailto:hi@ghostfol.io?subject=Ghostfolio Premium Data Provider&body=${[
@ -184,6 +185,8 @@ export class GfAdminSettingsComponent implements OnInit {
PROPERTY_API_KEY_GHOSTFOLIO PROPERTY_API_KEY_GHOSTFOLIO
] as string; ] as string;
this.hasGhostfolioApiKey = !!ghostfolioApiKey;
if (ghostfolioApiKey) { if (ghostfolioApiKey) {
this.adminService this.adminService
.fetchGhostfolioDataProviderStatus(ghostfolioApiKey) .fetchGhostfolioDataProviderStatus(ghostfolioApiKey)

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

@ -83,7 +83,11 @@
<ion-icon name="ellipsis-horizontal" /> <ion-icon name="ellipsis-horizontal" />
</button> </button>
<mat-menu #tagMenu="matMenu" xPosition="before"> <mat-menu #tagMenu="matMenu" xPosition="before">
<button mat-menu-item (click)="onUpdateTag(element)"> <button
mat-menu-item
[disabled]="isSystemTag(element)"
(click)="onUpdateTag(element)"
>
<span class="align-items-center d-flex"> <span class="align-items-center d-flex">
<ion-icon class="mr-2" name="create-outline" /> <ion-icon class="mr-2" name="create-outline" />
<span><ng-container i18n>Edit</ng-container>...</span> <span><ng-container i18n>Edit</ng-container>...</span>
@ -92,7 +96,11 @@
<hr class="m-0" /> <hr class="m-0" />
<button <button
mat-menu-item mat-menu-item
[disabled]="element.accountCount > 0 || element.activityCount > 0" [disabled]="
element.accountCount > 0 ||
element.activityCount > 0 ||
isSystemTag(element)
"
(click)="onDeleteTag(element.id)" (click)="onDeleteTag(element.id)"
> >
<span class="align-items-center d-flex"> <span class="align-items-center d-flex">

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

@ -2,7 +2,11 @@ import { UserService } from '@ghostfolio/client/services/user/user.service';
import { DEFAULT_PAGE_SIZE } from '@ghostfolio/common/config'; import { DEFAULT_PAGE_SIZE } from '@ghostfolio/common/config';
import { CreateTagDto, UpdateTagDto } from '@ghostfolio/common/dtos'; import { CreateTagDto, UpdateTagDto } from '@ghostfolio/common/dtos';
import { ConfirmationDialogType } from '@ghostfolio/common/enums'; import { ConfirmationDialogType } from '@ghostfolio/common/enums';
import { getLocale, getLowercase } from '@ghostfolio/common/helper'; import {
getLocale,
getLowercase,
isSystemTag
} from '@ghostfolio/common/helper';
import { translate } from '@ghostfolio/ui/i18n'; import { translate } from '@ghostfolio/ui/i18n';
import { NotificationService } from '@ghostfolio/ui/notifications'; import { NotificationService } from '@ghostfolio/ui/notifications';
import { DataService } from '@ghostfolio/ui/services'; import { DataService } from '@ghostfolio/ui/services';
@ -67,6 +71,7 @@ export class GfAdminTagComponent implements OnInit {
'activities', 'activities',
'actions' 'actions'
]; ];
protected readonly isSystemTag = isSystemTag;
protected readonly pageSize = DEFAULT_PAGE_SIZE; protected readonly pageSize = DEFAULT_PAGE_SIZE;
protected tags: Tag[]; protected tags: Tag[];
protected readonly translate = translate; protected readonly translate = translate;
@ -99,7 +104,7 @@ export class GfAdminTagComponent implements OnInit {
return id === params['tagId']; return id === params['tagId'];
}); });
if (tag) { if (tag && !isSystemTag(tag)) {
this.openUpdateTagDialog(tag); this.openUpdateTagDialog(tag);
} }
} else { } else {

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

@ -251,7 +251,7 @@
> >
<span class="align-items-center d-flex"> <span class="align-items-center d-flex">
<ion-icon class="mr-2" name="trash-outline" /> <ion-icon class="mr-2" name="trash-outline" />
<span i18n>Delete User</span> <span i18n>Delete</span>
</span> </span>
</button> </button>
</mat-menu> </mat-menu>

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

@ -1,20 +1,21 @@
import { import {
getTooltipOptions, getChartBorderColor,
getVerticalHoverLinePlugin getChartElementsOptions,
getTimeAxisOptions,
getValueAxisOptions,
getVerticalHoverLinePlugin,
getZeroLineAnnotation
} from '@ghostfolio/common/chart-helper'; } from '@ghostfolio/common/chart-helper';
import { primaryColorRgb, secondaryColorRgb } from '@ghostfolio/common/config'; import { primaryColorRgb, secondaryColorRgb } from '@ghostfolio/common/config';
import { import { getLocale, parseDate } from '@ghostfolio/common/helper';
getBackgroundColor,
getDateFormatString,
getLocale,
getTextColor,
parseDate
} from '@ghostfolio/common/helper';
import { LineChartItem, User } from '@ghostfolio/common/interfaces'; import { LineChartItem, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { internalRoutes } from '@ghostfolio/common/routes/routes'; import { internalRoutes } from '@ghostfolio/common/routes/routes';
import { ColorScheme } from '@ghostfolio/common/types'; import { ColorScheme } from '@ghostfolio/common/types';
import { registerChartConfiguration } from '@ghostfolio/ui/chart'; import {
getTimeSeriesTooltipOptions,
registerChartConfiguration
} from '@ghostfolio/ui/chart';
import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator'; import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator';
import { import {
@ -166,28 +167,13 @@ export class GfBenchmarkComparatorComponent implements OnChanges, OnDestroy {
data, data,
options: { options: {
animation: false, animation: false,
elements: { elements: getChartElementsOptions(this.colorScheme()),
line: {
tension: 0
},
point: {
hoverBackgroundColor: getBackgroundColor(this.colorScheme()),
hoverRadius: 2,
radius: 0
}
},
interaction: { intersect: false, mode: 'index' }, interaction: { intersect: false, mode: 'index' },
maintainAspectRatio: true, maintainAspectRatio: true,
plugins: { plugins: {
annotation: { annotation: {
annotations: { annotations: {
yAxis: { yAxis: getZeroLineAnnotation(this.colorScheme())
borderColor: `rgba(${getTextColor(this.colorScheme())}, 0.1)`,
borderWidth: 1,
scaleID: 'y',
type: 'line',
value: 0
}
} }
}, },
legend: { legend: {
@ -195,54 +181,21 @@ export class GfBenchmarkComparatorComponent implements OnChanges, OnDestroy {
}, },
tooltip: this.getTooltipPluginConfiguration(), tooltip: this.getTooltipPluginConfiguration(),
verticalHoverLine: { verticalHoverLine: {
color: `rgba(${getTextColor(this.colorScheme())}, 0.1)` color: getChartBorderColor(this.colorScheme())
} }
}, },
responsive: true, responsive: true,
scales: { scales: {
x: { x: getTimeAxisOptions({
border: { colorScheme: this.colorScheme(),
color: `rgba(${getTextColor(this.colorScheme())}, 0.1)`, locale: this.locale()
width: 1 }),
}, y: getValueAxisOptions({
display: true, colorScheme: this.colorScheme(),
grid: { tickCallback: (tickValue) => {
display: false return `${Number(tickValue).toFixed(2)} %`;
},
type: 'time',
time: {
tooltipFormat: getDateFormatString(this.locale()),
unit: 'year'
}
},
y: {
border: {
width: 0
},
display: true,
grid: {
color: ({ scale, tick }) => {
if (
tick.value === 0 ||
tick.value === scale.max ||
tick.value === scale.min
) {
return `rgba(${getTextColor(this.colorScheme())}, 0.1)`;
}
return 'transparent';
}
},
position: 'right',
ticks: {
callback: (value: number) => {
return `${value.toFixed(2)} %`;
},
display: true,
mirror: true,
z: 1
}
} }
})
} }
}, },
plugins: [ plugins: [
@ -255,16 +208,10 @@ export class GfBenchmarkComparatorComponent implements OnChanges, OnDestroy {
} }
private getTooltipPluginConfiguration(): Partial<TooltipOptions<'line'>> { private getTooltipPluginConfiguration(): Partial<TooltipOptions<'line'>> {
return { return getTimeSeriesTooltipOptions<'line'>({
...getTooltipOptions({
colorScheme: this.colorScheme(), colorScheme: this.colorScheme(),
locale: this.locale(), locale: this.locale(),
unit: '%' unit: '%'
}), });
mode: 'index',
position: 'top',
xAlign: 'center',
yAlign: 'bottom'
};
} }
} }

24
apps/client/src/app/components/fear-and-greed-index/fear-and-greed-index.component.html

@ -1,24 +0,0 @@
<div class="position-relative">
<div class="align-items-center d-flex flex-row" [hidden]="!fearAndGreedIndex">
<div class="h2 mb-0 mr-2">{{ fearAndGreedIndexEmoji }}</div>
<div>
<div class="h4 mb-0">
<span class="mr-2">{{ fearAndGreedIndexText }}</span>
<small class="text-muted"
><strong>{{ fearAndGreedIndex | number: '1.0-0' }}</strong
>/100</small
>
</div>
<small class="d-block" i18n>Current Market Mood</small>
</div>
</div>
@if (!fearAndGreedIndex) {
<ngx-skeleton-loader
animation="pulse"
class="position-absolute w-100"
[theme]="{
height: '100%'
}"
/>
}
</div>

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

Loading…
Cancel
Save