Browse Source

Merge remote-tracking branch 'origin/main' into mr/Upstream-changes-2025-03-12

pull/5027/head
Dan 4 months ago
parent
commit
bd105a51d3
  1. 4
      .github/ISSUE_TEMPLATE/bug_report.md
  2. 110
      CHANGELOG.md
  3. 9
      DEVELOPMENT.md
  4. 13
      SECURITY.md
  5. 18
      apps/api/src/app/account/account.service.ts
  6. 2
      apps/api/src/app/admin/admin.module.ts
  7. 57
      apps/api/src/app/admin/admin.service.ts
  8. 8
      apps/api/src/app/app.module.ts
  9. 33
      apps/api/src/app/auth/web-auth.service.ts
  10. 36
      apps/api/src/app/benchmark/benchmark.module.ts
  11. 11
      apps/api/src/app/endpoints/ai/ai.controller.ts
  12. 7
      apps/api/src/app/endpoints/ai/ai.service.ts
  13. 40
      apps/api/src/app/endpoints/benchmarks/benchmarks.controller.ts
  14. 63
      apps/api/src/app/endpoints/benchmarks/benchmarks.module.ts
  15. 163
      apps/api/src/app/endpoints/benchmarks/benchmarks.service.ts
  16. 36
      apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.controller.ts
  17. 43
      apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.service.ts
  18. 5
      apps/api/src/app/endpoints/market-data/market-data.controller.ts
  19. 3
      apps/api/src/app/endpoints/public/public.controller.ts
  20. 10
      apps/api/src/app/endpoints/tags/create-tag.dto.ts
  21. 87
      apps/api/src/app/endpoints/tags/tags.controller.ts
  22. 12
      apps/api/src/app/endpoints/tags/tags.module.ts
  23. 13
      apps/api/src/app/endpoints/tags/update-tag.dto.ts
  24. 3
      apps/api/src/app/export/export.module.ts
  25. 55
      apps/api/src/app/export/export.service.ts
  26. 2
      apps/api/src/app/import/import.service.ts
  27. 2
      apps/api/src/app/info/info.module.ts
  28. 2
      apps/api/src/app/info/info.service.ts
  29. 2
      apps/api/src/app/portfolio/calculator/portfolio-calculator.ts
  30. 4
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator.ts
  31. 2
      apps/api/src/app/portfolio/portfolio.controller.ts
  32. 28
      apps/api/src/app/portfolio/portfolio.service.ts
  33. 6
      apps/api/src/app/tag/create-tag.dto.ts
  34. 14
      apps/api/src/app/tag/tag.module.ts
  35. 9
      apps/api/src/app/tag/update-tag.dto.ts
  36. 28
      apps/api/src/app/user/user.service.ts
  37. 3
      apps/api/src/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor.ts
  38. 1
      apps/api/src/middlewares/html-template.middleware.ts
  39. 77
      apps/api/src/models/rules/regional-market-cluster-risk/asia-pacific.ts
  40. 77
      apps/api/src/models/rules/regional-market-cluster-risk/japan.ts
  41. 24
      apps/api/src/services/benchmark/benchmark.module.ts
  42. 12
      apps/api/src/services/benchmark/benchmark.service.spec.ts
  43. 162
      apps/api/src/services/benchmark/benchmark.service.ts
  44. 0
      apps/api/src/services/benchmark/interfaces/benchmark-value.interface.ts
  45. 5
      apps/api/src/services/data-provider/alpha-vantage/alpha-vantage.service.ts
  46. 5
      apps/api/src/services/data-provider/coingecko/coingecko.service.ts
  47. 90
      apps/api/src/services/data-provider/data-enhancer/trackinsight/trackinsight.service.ts
  48. 5
      apps/api/src/services/data-provider/eod-historical-data/eod-historical-data.service.ts
  49. 42
      apps/api/src/services/data-provider/financial-modeling-prep/financial-modeling-prep.service.ts
  50. 68
      apps/api/src/services/data-provider/ghostfolio/ghostfolio.service.ts
  51. 5
      apps/api/src/services/data-provider/google-sheets/google-sheets.service.ts
  52. 10
      apps/api/src/services/data-provider/interfaces/data-provider.interface.ts
  53. 5
      apps/api/src/services/data-provider/manual/manual.service.ts
  54. 5
      apps/api/src/services/data-provider/rapid-api/rapid-api.service.ts
  55. 5
      apps/api/src/services/data-provider/yahoo-finance/yahoo-finance.service.ts
  56. 3
      apps/api/src/services/queues/data-gathering/data-gathering.service.ts
  57. 11
      apps/api/src/services/symbol-profile/symbol-profile.service.ts
  58. 73
      apps/api/src/services/tag/tag.service.ts
  59. 2
      apps/api/src/services/twitter-bot/twitter-bot.module.ts
  60. 2
      apps/api/src/services/twitter-bot/twitter-bot.service.ts
  61. 34
      apps/client/localhost.cert
  62. 52
      apps/client/localhost.pem
  63. 9
      apps/client/src/app/app.component.html
  64. 4
      apps/client/src/app/components/admin-market-data/admin-market-data.service.ts
  65. 29
      apps/client/src/app/components/admin-market-data/create-asset-profile-dialog/create-asset-profile-dialog.component.ts
  66. 19
      apps/client/src/app/components/admin-overview/admin-overview.component.ts
  67. 10
      apps/client/src/app/components/admin-overview/admin-overview.html
  68. 2
      apps/client/src/app/components/admin-settings/ghostfolio-premium-api-dialog/ghostfolio-premium-api-dialog.component.ts
  69. 14
      apps/client/src/app/components/admin-tag/admin-tag.component.ts
  70. 4
      apps/client/src/app/components/admin-tag/create-or-update-tag-dialog/create-or-update-tag-dialog.component.ts
  71. 2
      apps/client/src/app/components/asset-profile-icon/asset-profile-icon.component.ts
  72. 86
      apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts
  73. 24
      apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html
  74. 1
      apps/client/src/app/components/home-holdings/home-holdings.html
  75. 2
      apps/client/src/app/components/home-overview/home-overview.html
  76. 9
      apps/client/src/app/components/rule/rule-settings-dialog/rule-settings-dialog.component.ts
  77. 2
      apps/client/src/app/components/rule/rule.component.scss
  78. 4
      apps/client/src/app/core/http-response.interceptor.ts
  79. 3
      apps/client/src/app/core/notification/alert-dialog/alert-dialog.component.ts
  80. 3
      apps/client/src/app/core/notification/confirmation-dialog/confirmation-dialog.component.ts
  81. 2
      apps/client/src/app/core/notification/prompt-dialog/prompt-dialog.component.ts
  82. 20
      apps/client/src/app/pages/about/overview/about-overview-page.html
  83. 4
      apps/client/src/app/pages/api/api-page.component.ts
  84. 5
      apps/client/src/app/pages/faq/self-hosting/self-hosting-page.html
  85. 30
      apps/client/src/app/pages/portfolio/activities/activities-page.component.ts
  86. 1
      apps/client/src/app/pages/portfolio/activities/activities-page.html
  87. 1
      apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts
  88. 11
      apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts
  89. 22
      apps/client/src/app/pages/portfolio/analysis/analysis-page.html
  90. 45
      apps/client/src/app/pages/portfolio/x-ray/x-ray-page.component.html
  91. 14
      apps/client/src/app/pages/portfolio/x-ray/x-ray-page.component.scss
  92. 25
      apps/client/src/app/pages/register/register-page.component.ts
  93. 2
      apps/client/src/app/pages/register/register-page.html
  94. 57
      apps/client/src/app/pages/register/show-access-token-dialog/show-access-token-dialog.component.ts
  95. 89
      apps/client/src/app/pages/register/show-access-token-dialog/show-access-token-dialog.html
  96. 4
      apps/client/src/app/pages/register/show-access-token-dialog/show-access-token-dialog.module.ts
  97. 4
      apps/client/src/app/pages/register/show-access-token-dialog/show-access-token-dialog.scss
  98. 3
      apps/client/src/app/pages/resources/personal-finance-tools/product-page.component.ts
  99. 20
      apps/client/src/app/services/admin.service.ts
  100. 49
      apps/client/src/app/services/data.service.ts

4
.github/ISSUE_TEMPLATE/bug_report.md

@ -26,7 +26,7 @@ Thank you for your understanding and cooperation!
2. 2.
3. 3.
**Expected behavior** **Expected Behavior**
<!-- A clear and concise description of what you expected to happen. --> <!-- A clear and concise description of what you expected to happen. -->
@ -48,6 +48,6 @@ Thank you for your understanding and cooperation!
- Browser - Browser
- OS - OS
**Additional context** **Additional Context**
<!-- Add any other context about the problem here. --> <!-- Add any other context about the problem here. -->

110
CHANGELOG.md

@ -96,13 +96,123 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- Improved the usability of the user account registration
## 2.145.1 - 2025-03-10
### Added
- Extended the export functionality by the account balances
- Added a _Copy portfolio data to clipboard for AI prompt_ action to the analysis page (experimental)
### Changed
- Improved the style of the summary on the _X-ray_ page
- Improved the language localization for German (`de`)
- Upgraded `@simplewebauthn/browser` and `@simplewebauthn/server` from version `9.0` to `13.1`
### Fixed
- Fixed an issue to get dividends in the _Financial Modeling Prep_ service
- Fixed an issue to get historical market data in the _Financial Modeling Prep_ service
- Fixed an issue with serving _Storybook_
## 2.144.0 - 2025-03-06
### Fixed
- Fixed the missing import functionality on the non-empty activities page
- Fixed the functionality to delete an asset profile of a custom currency in the admin control panel
## 2.143.0 - 2025-03-02
### Added
- Added the Ghostfolio _LinkedIn_ page to the about page
- Added the Ghostfolio _LinkedIn_ page to the footer
### Changed
- Optimized the asynchronous operations using `Promise.all()` in the portfolio service (`getPerformance`)
- Improved the symbol lookup in the _Trackinsight_ data enhancer for asset profile data
- Removed the no transactions info component from the holdings table on the home page
- Refactored the show condition of the step by step introduction for new users using the activities count
- Upgraded `color` from version `4.2.3` to `5.0.0`
- Upgraded `prisma` from version `6.3.0` to `6.4.1`
### Fixed
- Handled an exception in the export functionality related to platforms
- Handled an exception in the benchmark service related to unnamed asset profiles
## 2.142.0 - 2025-02-28
### Added
- Extended the export functionality by the platforms
- Extended the portfolio snapshot in the portfolio calculator by the `createdAt` timestamp
- Extended the _Trackinsight_ data enhancer for asset profile data by `cusip`
- Added _Storybook_ to the build process
### Changed
- Upgraded `eslint` dependencies
## 2.141.0 - 2025-02-25
### Added
- Extended the export functionality by the tags
- Extended the portfolio snapshot in the portfolio calculator by the activities count
- Extended the user endpoint `GET api/v1/user` by the activities count
- Added `cusip` to the asset profile model
### Changed
- Upgraded `prettier` from version `3.4.2` to `3.5.1`
### Fixed
- Improved the numeric comparison of strings in the value component
## 2.140.0 - 2025-02-20
### Changed
- Reloaded the available tags after creating a custom tag in the holding detail dialog (experimental)
- Improved the validation of the currency management in the admin control panel
- Migrated the `@ghostfolio/client` components to control flow
- Migrated the `@ghostfolio/ui` components to control flow
- Improved the language localization for German (`de`)
### Fixed
- Improved the error handling in the `HttpResponseInterceptor`
- Fixed an issue while using symbol profile overrides in the historical market data table of the admin control panel
- Added missing assets in _Storybook_ setup
## 2.139.1 - 2025-02-15
### Added
- Extended the tooltip in the chart of the holdings tab on the home page by the allocation, change and performance
- Added a new static portfolio analysis rule: _Regional Market Cluster Risk_ (Asia-Pacific Markets)
- Added a new static portfolio analysis rule: _Regional Market Cluster Risk_ (Japan)
- Added support to create custom tags in the holding detail dialog (experimental)
- Extended the tags selector component by a `readonly` attribute - Extended the tags selector component by a `readonly` attribute
- Extended the tags selector component to support creating custom tags - Extended the tags selector component to support creating custom tags
- Extended the holding detail dialog by the historical market data editor (experimental)
- Added global styles to the _Storybook_ setup - Added global styles to the _Storybook_ setup
### Changed ### Changed
- Improved the symbol lookup in the _Trackinsight_ data enhancer for asset profile data
- Improved the language localization for German (`de`) - Improved the language localization for German (`de`)
- Upgraded `@trivago/prettier-plugin-sort-imports` from version `5.2.1` to `5.2.2`
### Fixed
- Fixed the gaps in the chart of the benchmark comparator
## 2.138.0 - 2025-02-08 ## 2.138.0 - 2025-02-08

9
DEVELOPMENT.md

@ -101,3 +101,12 @@ https://www.prisma.io/docs/concepts/components/prisma-migrate/db-push
Run `npm run prisma migrate dev --name added_job_title` Run `npm run prisma migrate dev --name added_job_title`
https://www.prisma.io/docs/concepts/components/prisma-migrate#getting-started-with-prisma-migrate https://www.prisma.io/docs/concepts/components/prisma-migrate#getting-started-with-prisma-migrate
## SSL
Generate `localhost.cert` and `localhost.pem` files.
```
openssl req -x509 -newkey rsa:2048 -nodes -keyout apps/client/localhost.pem -out apps/client/localhost.cert -days 365 \
-subj "/C=CH/ST=State/L=City/O=Organization/OU=Unit/CN=localhost"
```

13
SECURITY.md

@ -0,0 +1,13 @@
# Security Policy
## Reporting Security Issues
If you discover a security vulnerability in this repository, please report it to security[at]ghostfol.io. We will acknowledge your report and provide guidance on the next steps.
To help us resolve the issue, please include the following details:
- A description of the vulnerability
- Steps to reproduce the vulnerability
- Affected versions of the software
We appreciate your responsible disclosure and will work to address the issue promptly.

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

@ -8,7 +8,13 @@ import { Filter } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter'; import { EventEmitter2 } from '@nestjs/event-emitter';
import { Account, Order, Platform, Prisma } from '@prisma/client'; import {
Account,
AccountBalance,
Order,
Platform,
Prisma
} from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { groupBy } from 'lodash'; import { groupBy } from 'lodash';
@ -57,13 +63,19 @@ export class AccountService {
orderBy?: Prisma.AccountOrderByWithRelationInput; orderBy?: Prisma.AccountOrderByWithRelationInput;
}): Promise< }): Promise<
(Account & { (Account & {
balances?: AccountBalance[];
Order?: Order[]; Order?: Order[];
Platform?: Platform; Platform?: Platform;
})[] })[]
> { > {
const { include = {}, skip, take, cursor, where, orderBy } = params; const { include = {}, skip, take, cursor, where, orderBy } = params;
include.balances = { orderBy: { date: 'desc' }, take: 1 }; const isBalancesIncluded = !!include.balances;
include.balances = {
orderBy: { date: 'desc' },
...(isBalancesIncluded ? {} : { take: 1 })
};
const accounts = await this.prismaService.account.findMany({ const accounts = await this.prismaService.account.findMany({
cursor, cursor,
@ -77,7 +89,9 @@ export class AccountService {
return accounts.map((account) => { return accounts.map((account) => {
account = { ...account, balance: account.balances[0]?.value ?? 0 }; account = { ...account, balance: account.balances[0]?.value ?? 0 };
if (!isBalancesIncluded) {
delete account.balances; delete account.balances;
}
return account; return account;
}); });

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

@ -1,8 +1,8 @@
import { BenchmarkModule } from '@ghostfolio/api/app/benchmark/benchmark.module';
import { OrderModule } from '@ghostfolio/api/app/order/order.module'; import { OrderModule } from '@ghostfolio/api/app/order/order.module';
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module'; import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
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 { ApiModule } from '@ghostfolio/api/services/api/api.module'; import { ApiModule } from '@ghostfolio/api/services/api/api.module';
import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.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';

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

@ -1,7 +1,7 @@
import { BenchmarkService } from '@ghostfolio/api/app/benchmark/benchmark.service';
import { OrderService } from '@ghostfolio/api/app/order/order.service'; import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service'; import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
import { environment } from '@ghostfolio/api/environments/environment'; import { environment } from '@ghostfolio/api/environments/environment';
import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { 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';
@ -30,6 +30,7 @@ import {
EnhancedSymbolProfile, EnhancedSymbolProfile,
Filter Filter
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
import { MarketDataPreset } from '@ghostfolio/common/types'; import { MarketDataPreset } from '@ghostfolio/common/types';
import { BadRequestException, Injectable, Logger } from '@nestjs/common'; import { BadRequestException, Injectable, Logger } from '@nestjs/common';
@ -108,8 +109,27 @@ export class AdminService {
symbol symbol
}: AssetProfileIdentifier) { }: AssetProfileIdentifier) {
await this.marketDataService.deleteMany({ dataSource, symbol }); await this.marketDataService.deleteMany({ dataSource, symbol });
const currency = getCurrencyFromSymbol(symbol);
const customCurrencies = (await this.propertyService.getByKey(
PROPERTY_CURRENCIES
)) as string[];
if (customCurrencies.includes(currency)) {
const updatedCustomCurrencies = customCurrencies.filter(
(customCurrency) => {
return customCurrency !== currency;
}
);
await this.putSetting(
PROPERTY_CURRENCIES,
JSON.stringify(updatedCustomCurrencies)
);
} else {
await this.symbolProfileService.delete({ dataSource, symbol }); await this.symbolProfileService.delete({ dataSource, symbol });
} }
}
public async get(): Promise<AdminData> { public async get(): Promise<AdminData> {
const exchangeRates = this.exchangeRateDataService const exchangeRates = this.exchangeRateDataService
@ -260,6 +280,7 @@ export class AdminService {
scraperConfiguration: true, scraperConfiguration: true,
sectors: true, sectors: true,
symbol: true, symbol: true,
SymbolProfileOverrides: true,
tags: true tags: true
} }
}), }),
@ -315,11 +336,10 @@ export class AdminService {
Order, Order,
sectors, sectors,
symbol, symbol,
SymbolProfileOverrides,
tags tags
}) => { }) => {
const countriesCount = countries let countriesCount = countries ? Object.keys(countries).length : 0;
? Object.keys(countries).length
: 0;
const lastMarketPrice = lastMarketPriceMap.get( const lastMarketPrice = lastMarketPriceMap.get(
getAssetProfileIdentifier({ dataSource, symbol }) getAssetProfileIdentifier({ dataSource, symbol })
@ -333,7 +353,34 @@ export class AdminService {
); );
})?._count ?? 0; })?._count ?? 0;
const sectorsCount = sectors ? Object.keys(sectors).length : 0; let sectorsCount = sectors ? Object.keys(sectors).length : 0;
if (SymbolProfileOverrides) {
assetClass = SymbolProfileOverrides.assetClass ?? assetClass;
assetSubClass =
SymbolProfileOverrides.assetSubClass ?? assetSubClass;
if (
(
SymbolProfileOverrides.countries as unknown as Prisma.JsonArray
)?.length > 0
) {
countriesCount = (
SymbolProfileOverrides.countries as unknown as Prisma.JsonArray
).length;
}
name = SymbolProfileOverrides.name ?? name;
if (
(SymbolProfileOverrides.sectors as unknown as Sector[])
?.length > 0
) {
sectorsCount = (
SymbolProfileOverrides.sectors as unknown as Prisma.JsonArray
).length;
}
}
return { return {
assetClass, assetClass,

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

@ -29,13 +29,14 @@ import { AppController } from './app.controller';
import { AssetModule } from './asset/asset.module'; import { AssetModule } from './asset/asset.module';
import { AuthDeviceModule } from './auth-device/auth-device.module'; import { AuthDeviceModule } from './auth-device/auth-device.module';
import { AuthModule } from './auth/auth.module'; import { AuthModule } from './auth/auth.module';
import { BenchmarkModule } from './benchmark/benchmark.module';
import { CacheModule } from './cache/cache.module'; import { CacheModule } from './cache/cache.module';
import { AiModule } from './endpoints/ai/ai.module'; import { AiModule } from './endpoints/ai/ai.module';
import { ApiKeysModule } from './endpoints/api-keys/api-keys.module'; import { ApiKeysModule } from './endpoints/api-keys/api-keys.module';
import { BenchmarksModule } from './endpoints/benchmarks/benchmarks.module';
import { GhostfolioModule } from './endpoints/data-providers/ghostfolio/ghostfolio.module'; import { GhostfolioModule } from './endpoints/data-providers/ghostfolio/ghostfolio.module';
import { MarketDataModule } from './endpoints/market-data/market-data.module'; import { MarketDataModule } from './endpoints/market-data/market-data.module';
import { PublicModule } from './endpoints/public/public.module'; import { PublicModule } from './endpoints/public/public.module';
import { TagsModule } from './endpoints/tags/tags.module';
import { ExchangeRateModule } from './exchange-rate/exchange-rate.module'; import { ExchangeRateModule } from './exchange-rate/exchange-rate.module';
import { ExportModule } from './export/export.module'; import { ExportModule } from './export/export.module';
import { HealthModule } from './health/health.module'; import { HealthModule } from './health/health.module';
@ -49,7 +50,6 @@ import { RedisCacheModule } from './redis-cache/redis-cache.module';
import { SitemapModule } from './sitemap/sitemap.module'; import { SitemapModule } from './sitemap/sitemap.module';
import { SubscriptionModule } from './subscription/subscription.module'; import { SubscriptionModule } from './subscription/subscription.module';
import { SymbolModule } from './symbol/symbol.module'; import { SymbolModule } from './symbol/symbol.module';
import { TagModule } from './tag/tag.module';
import { UserModule } from './user/user.module'; import { UserModule } from './user/user.module';
@Module({ @Module({
@ -63,7 +63,7 @@ import { UserModule } from './user/user.module';
AssetModule, AssetModule,
AuthDeviceModule, AuthDeviceModule,
AuthModule, AuthModule,
BenchmarkModule, BenchmarksModule,
BullModule.forRoot({ BullModule.forRoot({
redis: { redis: {
db: parseInt(process.env.REDIS_DB ?? '0', 10), db: parseInt(process.env.REDIS_DB ?? '0', 10),
@ -124,7 +124,7 @@ import { UserModule } from './user/user.module';
SitemapModule, SitemapModule,
SubscriptionModule, SubscriptionModule,
SymbolModule, SymbolModule,
TagModule, TagsModule,
TwitterBotModule, TwitterBotModule,
UserModule UserModule
], ],

33
apps/api/src/app/auth/web-auth.service.ts

@ -24,6 +24,7 @@ import {
verifyRegistrationResponse, verifyRegistrationResponse,
VerifyRegistrationResponseOpts VerifyRegistrationResponseOpts
} from '@simplewebauthn/server'; } from '@simplewebauthn/server';
import { isoBase64URL, isoUint8Array } from '@simplewebauthn/server/helpers';
import { import {
AssertionCredentialJSON, AssertionCredentialJSON,
@ -54,10 +55,9 @@ export class WebAuthService {
const opts: GenerateRegistrationOptionsOpts = { const opts: GenerateRegistrationOptionsOpts = {
rpName: 'Ghostfolio', rpName: 'Ghostfolio',
rpID: this.rpID, rpID: this.rpID,
userID: user.id, userID: isoUint8Array.fromUTF8String(user.id),
userName: '', userName: '',
timeout: 60000, timeout: 60000,
attestationType: 'indirect',
authenticatorSelection: { authenticatorSelection: {
authenticatorAttachment: 'platform', authenticatorAttachment: 'platform',
requireResidentKey: false, requireResidentKey: false,
@ -111,11 +111,17 @@ export class WebAuthService {
where: { userId: user.id } where: { userId: user.id }
}); });
if (registrationInfo && verified) { if (registrationInfo && verified) {
const { counter, credentialID, credentialPublicKey } = registrationInfo; const {
credential: {
counter,
id: credentialId,
publicKey: credentialPublicKey
}
} = registrationInfo;
let existingDevice = devices.find( let existingDevice = devices.find((device) => {
(device) => device.credentialId === credentialID return isoBase64URL.fromBuffer(device.credentialId) === credentialId;
); });
if (!existingDevice) { if (!existingDevice) {
/** /**
@ -123,7 +129,7 @@ export class WebAuthService {
*/ */
existingDevice = await this.deviceService.createAuthDevice({ existingDevice = await this.deviceService.createAuthDevice({
counter, counter,
credentialId: Buffer.from(credentialID), credentialId: Buffer.from(credentialId),
credentialPublicKey: Buffer.from(credentialPublicKey), credentialPublicKey: Buffer.from(credentialPublicKey),
User: { connect: { id: user.id } } User: { connect: { id: user.id } }
}); });
@ -148,9 +154,8 @@ export class WebAuthService {
const opts: GenerateAuthenticationOptionsOpts = { const opts: GenerateAuthenticationOptionsOpts = {
allowCredentials: [ allowCredentials: [
{ {
id: device.credentialId, id: isoBase64URL.fromBuffer(device.credentialId),
transports: ['internal'], transports: ['internal']
type: 'public-key'
} }
], ],
rpID: this.rpID, rpID: this.rpID,
@ -187,10 +192,10 @@ export class WebAuthService {
let verification: VerifiedAuthenticationResponse; let verification: VerifiedAuthenticationResponse;
try { try {
const opts: VerifyAuthenticationResponseOpts = { const opts: VerifyAuthenticationResponseOpts = {
authenticator: { credential: {
credentialID: device.credentialId, counter: device.counter,
credentialPublicKey: device.credentialPublicKey, id: isoBase64URL.fromBuffer(device.credentialId),
counter: device.counter publicKey: device.credentialPublicKey
}, },
expectedChallenge: `${user.authChallenge}`, expectedChallenge: `${user.authChallenge}`,
expectedOrigin: this.expectedOrigin, expectedOrigin: this.expectedOrigin,

36
apps/api/src/app/benchmark/benchmark.module.ts

@ -1,36 +0,0 @@
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.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 { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.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 { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
import { Module } from '@nestjs/common';
import { BenchmarkController } from './benchmark.controller';
import { BenchmarkService } from './benchmark.service';
@Module({
controllers: [BenchmarkController],
exports: [BenchmarkService],
imports: [
ConfigurationModule,
DataProviderModule,
ExchangeRateDataModule,
MarketDataModule,
PrismaModule,
PropertyModule,
RedisCacheModule,
SymbolModule,
SymbolProfileModule,
TransformDataSourceInRequestModule,
TransformDataSourceInResponseModule
],
providers: [BenchmarkService]
})
export class BenchmarkModule {}

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

@ -6,9 +6,9 @@ import {
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { AiPromptResponse } from '@ghostfolio/common/interfaces'; import { AiPromptResponse } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type { AiPromptMode, RequestWithUser } from '@ghostfolio/common/types';
import { Controller, Get, Inject, UseGuards } from '@nestjs/common'; import { Controller, Get, Inject, Param, UseGuards } from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
@ -21,11 +21,14 @@ export class AiController {
@Inject(REQUEST) private readonly request: RequestWithUser @Inject(REQUEST) private readonly request: RequestWithUser
) {} ) {}
@Get('prompt') @Get('prompt/:mode')
@HasPermission(permissions.readAiPrompt) @HasPermission(permissions.readAiPrompt)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getPrompt(): Promise<AiPromptResponse> { public async getPrompt(
@Param('mode') mode: AiPromptMode
): Promise<AiPromptResponse> {
const prompt = await this.aiService.getPrompt({ const prompt = await this.aiService.getPrompt({
mode,
impersonationId: undefined, impersonationId: undefined,
languageCode: languageCode:
this.request.user.Settings.settings.language ?? DEFAULT_LANGUAGE_CODE, this.request.user.Settings.settings.language ?? DEFAULT_LANGUAGE_CODE,

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

@ -1,4 +1,5 @@
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
import type { AiPromptMode } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
@ -9,11 +10,13 @@ export class AiService {
public async getPrompt({ public async getPrompt({
impersonationId, impersonationId,
languageCode, languageCode,
mode,
userCurrency, userCurrency,
userId userId
}: { }: {
impersonationId: string; impersonationId: string;
languageCode: string; languageCode: string;
mode: AiPromptMode;
userCurrency: string; userCurrency: string;
userId: string; userId: string;
}) { }) {
@ -43,6 +46,10 @@ export class AiService {
) )
]; ];
if (mode === 'portfolio') {
return holdingsTable.join('\n');
}
return [ return [
`You are a neutral financial assistant. Please analyze the following investment portfolio (base currency being ${userCurrency}) in simple words.`, `You are a neutral financial assistant. Please analyze the following investment portfolio (base currency being ${userCurrency}) in simple words.`,
...holdingsTable, ...holdingsTable,

40
apps/api/src/app/benchmark/benchmark.controller.ts → apps/api/src/app/endpoints/benchmarks/benchmarks.controller.ts

@ -2,7 +2,10 @@ import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorat
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor'; import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
import { 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 { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service';
import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper'; import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper';
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
import type { import type {
AssetProfileIdentifier, AssetProfileIdentifier,
BenchmarkMarketDataDetails, BenchmarkMarketDataDetails,
@ -16,6 +19,7 @@ import {
Controller, Controller,
Delete, Delete,
Get, Get,
Headers,
HttpException, HttpException,
Inject, Inject,
Param, Param,
@ -29,12 +33,14 @@ import { AuthGuard } from '@nestjs/passport';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { BenchmarkService } from './benchmark.service'; import { BenchmarksService } from './benchmarks.service';
@Controller('benchmark') @Controller('benchmarks')
export class BenchmarkController { export class BenchmarksController {
public constructor( public constructor(
private readonly apiService: ApiService,
private readonly benchmarkService: BenchmarkService, private readonly benchmarkService: BenchmarkService,
private readonly benchmarksService: BenchmarksService,
@Inject(REQUEST) private readonly request: RequestWithUser @Inject(REQUEST) private readonly request: RequestWithUser
) {} ) {}
@ -108,23 +114,43 @@ export class BenchmarkController {
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
public async getBenchmarkMarketDataForUser( public async getBenchmarkMarketDataForUser(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@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 = 'max' @Query('range') dateRange: DateRange = 'max',
@Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string,
@Query('dataSource') filterByDataSource?: string,
@Query('symbol') filterBySymbol?: string,
@Query('tags') filterByTags?: string,
@Query('withExcludedAccounts') withExcludedAccountsParam = 'false'
): Promise<BenchmarkMarketDataDetails> { ): Promise<BenchmarkMarketDataDetails> {
const { endDate, startDate } = getIntervalFromDateRange( const { endDate, startDate } = getIntervalFromDateRange(
dateRange, dateRange,
new Date(startDateString) new Date(startDateString)
); );
const userCurrency = this.request.user.Settings.settings.baseCurrency;
return this.benchmarkService.getMarketDataForUser({ const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts,
filterByAssetClasses,
filterByDataSource,
filterBySymbol,
filterByTags
});
const withExcludedAccounts = withExcludedAccountsParam === 'true';
return this.benchmarksService.getMarketDataForUser({
dataSource, dataSource,
dateRange,
endDate, endDate,
filters,
impersonationId,
startDate, startDate,
symbol, symbol,
userCurrency withExcludedAccounts,
user: this.request.user
}); });
} }
} }

63
apps/api/src/app/endpoints/benchmarks/benchmarks.module.ts

@ -0,0 +1,63 @@
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
import { RulesService } from '@ghostfolio/api/app/portfolio/rules.service';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module';
import { UserModule } from '@ghostfolio/api/app/user/user.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 { ApiModule } from '@ghostfolio/api/services/api/api.module';
import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.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 { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module';
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
import { Module } from '@nestjs/common';
import { BenchmarksController } from './benchmarks.controller';
import { BenchmarksService } from './benchmarks.service';
@Module({
controllers: [BenchmarksController],
imports: [
ApiModule,
ConfigurationModule,
DataProviderModule,
ExchangeRateDataModule,
ImpersonationModule,
MarketDataModule,
OrderModule,
PortfolioSnapshotQueueModule,
PrismaModule,
PropertyModule,
RedisCacheModule,
SymbolModule,
SymbolProfileModule,
TransformDataSourceInRequestModule,
TransformDataSourceInResponseModule,
UserModule
],
providers: [
AccountBalanceService,
AccountService,
BenchmarkService,
BenchmarksService,
CurrentRateService,
MarketDataService,
PortfolioCalculatorFactory,
PortfolioService,
RulesService
]
})
export class BenchmarksModule {}

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

@ -0,0 +1,163 @@
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service';
import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.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 { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper';
import {
AssetProfileIdentifier,
BenchmarkMarketDataDetails,
Filter
} from '@ghostfolio/common/interfaces';
import { DateRange, UserWithSettings } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common';
import { format, isSameDay } from 'date-fns';
import { isNumber } from 'lodash';
@Injectable()
export class BenchmarksService {
public constructor(
private readonly benchmarkService: BenchmarkService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly marketDataService: MarketDataService,
private readonly portfolioService: PortfolioService,
private readonly symbolService: SymbolService
) {}
public async getMarketDataForUser({
dataSource,
dateRange,
endDate = new Date(),
filters,
impersonationId,
startDate,
symbol,
user,
withExcludedAccounts
}: {
dateRange: DateRange;
endDate?: Date;
filters?: Filter[];
impersonationId: string;
startDate: Date;
user: UserWithSettings;
withExcludedAccounts?: boolean;
} & AssetProfileIdentifier): Promise<BenchmarkMarketDataDetails> {
const marketData: { date: string; value: number }[] = [];
const userCurrency = user.Settings.settings.baseCurrency;
const userId = user.id;
const { chart } = await this.portfolioService.getPerformance({
dateRange,
filters,
impersonationId,
userId,
withExcludedAccounts
});
const [currentSymbolItem, marketDataItems] = await Promise.all([
this.symbolService.get({
dataGatheringItem: {
dataSource,
symbol
}
}),
this.marketDataService.marketDataItems({
orderBy: {
date: 'asc'
},
where: {
dataSource,
symbol,
date: {
in: chart.map(({ date }) => {
return resetHours(parseDate(date));
})
}
}
})
]);
const exchangeRates =
await this.exchangeRateDataService.getExchangeRatesByCurrency({
startDate,
currencies: [currentSymbolItem.currency],
targetCurrency: userCurrency
});
const exchangeRateAtStartDate =
exchangeRates[`${currentSymbolItem.currency}${userCurrency}`]?.[
format(startDate, DATE_FORMAT)
];
const marketPriceAtStartDate = marketDataItems?.find(({ date }) => {
return isSameDay(date, startDate);
})?.marketPrice;
if (!marketPriceAtStartDate) {
Logger.error(
`No historical market data has been found for ${symbol} (${dataSource}) at ${format(
startDate,
DATE_FORMAT
)}`,
'BenchmarkService'
);
return { marketData };
}
for (const marketDataItem of marketDataItems) {
const exchangeRate =
exchangeRates[`${currentSymbolItem.currency}${userCurrency}`]?.[
format(marketDataItem.date, DATE_FORMAT)
];
const exchangeRateFactor =
isNumber(exchangeRateAtStartDate) && isNumber(exchangeRate)
? exchangeRate / exchangeRateAtStartDate
: 1;
marketData.push({
date: format(marketDataItem.date, DATE_FORMAT),
value:
marketPriceAtStartDate === 0
? 0
: this.benchmarkService.calculateChangeInPercentage(
marketPriceAtStartDate,
marketDataItem.marketPrice * exchangeRateFactor
) * 100
});
}
const includesEndDate = isSameDay(
parseDate(marketData.at(-1).date),
endDate
);
if (currentSymbolItem?.marketPrice && !includesEndDate) {
const exchangeRate =
exchangeRates[`${currentSymbolItem.currency}${userCurrency}`]?.[
format(endDate, DATE_FORMAT)
];
const exchangeRateFactor =
isNumber(exchangeRateAtStartDate) && isNumber(exchangeRate)
? exchangeRate / exchangeRateAtStartDate
: 1;
marketData.push({
date: format(endDate, DATE_FORMAT),
value:
this.benchmarkService.calculateChangeInPercentage(
marketPriceAtStartDate,
currentSymbolItem.marketPrice * exchangeRateFactor
) * 100
});
}
return {
marketData
};
}
}

36
apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.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 { parseDate } from '@ghostfolio/common/helper'; import { parseDate } from '@ghostfolio/common/helper';
import { import {
DataProviderGhostfolioAssetProfileResponse,
DataProviderGhostfolioStatusResponse, DataProviderGhostfolioStatusResponse,
DividendsResponse, DividendsResponse,
HistoricalResponse, HistoricalResponse,
@ -37,6 +38,41 @@ export class GhostfolioController {
@Inject(REQUEST) private readonly request: RequestWithUser @Inject(REQUEST) private readonly request: RequestWithUser
) {} ) {}
@Get('asset-profile/:symbol')
@HasPermission(permissions.enableDataProviderGhostfolio)
@UseGuards(AuthGuard('api-key'), HasPermissionGuard)
public async getAssetProfile(
@Param('symbol') symbol: string
): Promise<DataProviderGhostfolioAssetProfileResponse> {
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests();
if (
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests
) {
throw new HttpException(
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
StatusCodes.TOO_MANY_REQUESTS
);
}
try {
const assetProfile = await this.ghostfolioService.getAssetProfile({
symbol
});
await this.ghostfolioService.incrementDailyRequests({
userId: this.request.user.id
});
return assetProfile;
} catch {
throw new HttpException(
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
StatusCodes.INTERNAL_SERVER_ERROR
);
}
}
/** /**
* @deprecated * @deprecated
*/ */

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

@ -1,6 +1,7 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { import {
GetAssetProfileParams,
GetDividendsParams, GetDividendsParams,
GetHistoricalParams, GetHistoricalParams,
GetQuotesParams, GetQuotesParams,
@ -15,6 +16,7 @@ import {
} 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 { import {
DataProviderGhostfolioAssetProfileResponse,
DataProviderInfo, DataProviderInfo,
DividendsResponse, DividendsResponse,
HistoricalResponse, HistoricalResponse,
@ -25,7 +27,7 @@ import {
import { UserWithSettings } from '@ghostfolio/common/types'; import { UserWithSettings } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { DataSource } from '@prisma/client'; import { DataSource, SymbolProfile } from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
@Injectable() @Injectable()
@ -37,6 +39,44 @@ export class GhostfolioService {
private readonly propertyService: PropertyService private readonly propertyService: PropertyService
) {} ) {}
public async getAssetProfile({
requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'),
symbol
}: GetAssetProfileParams) {
let result: DataProviderGhostfolioAssetProfileResponse = {};
try {
const promises: Promise<Partial<SymbolProfile>>[] = [];
for (const dataProviderService of this.getDataProviderServices()) {
promises.push(
dataProviderService
.getAssetProfile({
requestTimeout,
symbol
})
.then((assetProfile) => {
result = {
...result,
...assetProfile,
dataSource: DataSource.GHOSTFOLIO
};
return assetProfile;
})
);
}
await Promise.all(promises);
return result;
} catch (error) {
Logger.error(error, 'GhostfolioService');
throw error;
}
}
public async getDividends({ public async getDividends({
from, from,
granularity, granularity,
@ -277,6 +317,7 @@ export class GhostfolioService {
}); });
results.items = filteredItems; results.items = filteredItems;
return results; return results;
} catch (error) { } catch (error) {
Logger.error(error, 'GhostfolioService'); Logger.error(error, 'GhostfolioService');

5
apps/api/src/app/endpoints/market-data/market-data.controller.ts

@ -1,6 +1,7 @@
import { AdminService } from '@ghostfolio/api/app/admin/admin.service'; import { AdminService } from '@ghostfolio/api/app/admin/admin.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { getCurrencyFromSymbol, isCurrency } from '@ghostfolio/common/helper';
import { MarketDataDetailsResponse } from '@ghostfolio/common/interfaces'; import { MarketDataDetailsResponse } from '@ghostfolio/common/interfaces';
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';
@ -42,7 +43,7 @@ export class MarketDataController {
{ dataSource, symbol } { dataSource, symbol }
]); ]);
if (!assetProfile) { if (!assetProfile && !isCurrency(getCurrencyFromSymbol(symbol))) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND), getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND StatusCodes.NOT_FOUND
@ -55,7 +56,7 @@ export class MarketDataController {
); );
const canReadOwnAssetProfile = const canReadOwnAssetProfile =
assetProfile.userId === this.request.user.id && assetProfile?.userId === this.request.user.id &&
hasPermission( hasPermission(
this.request.user.permissions, this.request.user.permissions,
permissions.readMarketDataOfOwnAssetProfile permissions.readMarketDataOfOwnAssetProfile

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

@ -57,7 +57,7 @@ export class PublicController {
} }
const [ const [
{ holdings, markets }, { createdAt, holdings, markets },
{ performance: performance1d }, { performance: performance1d },
{ performance: performanceMax }, { performance: performanceMax },
{ performance: performanceYtd } { performance: performanceYtd }
@ -81,6 +81,7 @@ export class PublicController {
}); });
const publicPortfolioResponse: PublicPortfolioResponse = { const publicPortfolioResponse: PublicPortfolioResponse = {
createdAt,
hasDetails, hasDetails,
markets, markets,
alias: access.alias, alias: access.alias,

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

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

87
apps/api/src/app/tag/tag.controller.ts → apps/api/src/app/endpoints/tags/tags.controller.ts

@ -1,6 +1,8 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { permissions } from '@ghostfolio/common/permissions'; import { TagService } from '@ghostfolio/api/services/tag/tag.service';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Body, Body,
@ -8,41 +10,63 @@ import {
Delete, Delete,
Get, Get,
HttpException, HttpException,
Inject,
Param, Param,
Post, Post,
Put, Put,
UseGuards UseGuards
} from '@nestjs/common'; } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { Tag } from '@prisma/client'; import { Tag } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { CreateTagDto } from './create-tag.dto'; import { CreateTagDto } from './create-tag.dto';
import { TagService } from './tag.service';
import { UpdateTagDto } from './update-tag.dto'; import { UpdateTagDto } from './update-tag.dto';
@Controller('tag') @Controller('tags')
export class TagController { export class TagsController {
public constructor(private readonly tagService: TagService) {} public constructor(
@Inject(REQUEST) private readonly request: RequestWithUser,
@Get() private readonly tagService: TagService
@HasPermission(permissions.readTags) ) {}
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getTags() {
return this.tagService.getTagsWithActivityCount();
}
@Post() @Post()
@HasPermission(permissions.createTag) @UseGuards(AuthGuard('jwt'))
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async createTag(@Body() data: CreateTagDto): Promise<Tag> { public async createTag(@Body() data: CreateTagDto): Promise<Tag> {
const canCreateOwnTag = hasPermission(
this.request.user.permissions,
permissions.createOwnTag
);
const canCreateTag = hasPermission(
this.request.user.permissions,
permissions.createTag
);
if (!canCreateOwnTag && !canCreateTag) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
if (canCreateOwnTag && !canCreateTag) {
if (data.userId !== this.request.user.id) {
throw new HttpException(
getReasonPhrase(StatusCodes.BAD_REQUEST),
StatusCodes.BAD_REQUEST
);
}
}
return this.tagService.createTag(data); return this.tagService.createTag(data);
} }
@HasPermission(permissions.updateTag) @Delete(':id')
@Put(':id') @HasPermission(permissions.deleteTag)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async updateTag(@Param('id') id: string, @Body() data: UpdateTagDto) { public async deleteTag(@Param('id') id: string) {
const originalTag = await this.tagService.getTag({ const originalTag = await this.tagService.getTag({
id id
}); });
@ -54,20 +78,20 @@ export class TagController {
); );
} }
return this.tagService.updateTag({ return this.tagService.deleteTag({ id });
data: {
...data
},
where: {
id
} }
});
@Get()
@HasPermission(permissions.readTags)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getTags() {
return this.tagService.getTagsWithActivityCount();
} }
@Delete(':id') @HasPermission(permissions.updateTag)
@HasPermission(permissions.deleteTag) @Put(':id')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async deleteTag(@Param('id') id: string) { public async updateTag(@Param('id') id: string, @Body() data: UpdateTagDto) {
const originalTag = await this.tagService.getTag({ const originalTag = await this.tagService.getTag({
id id
}); });
@ -79,6 +103,13 @@ export class TagController {
); );
} }
return this.tagService.deleteTag({ id }); return this.tagService.updateTag({
data: {
...data
},
where: {
id
}
});
} }
} }

12
apps/api/src/app/endpoints/tags/tags.module.ts

@ -0,0 +1,12 @@
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { TagModule } from '@ghostfolio/api/services/tag/tag.module';
import { Module } from '@nestjs/common';
import { TagsController } from './tags.controller';
@Module({
controllers: [TagsController],
imports: [PrismaModule, TagModule]
})
export class TagsModule {}

13
apps/api/src/app/endpoints/tags/update-tag.dto.ts

@ -0,0 +1,13 @@
import { IsOptional, IsString } from 'class-validator';
export class UpdateTagDto {
@IsString()
id: string;
@IsString()
name: string;
@IsOptional()
@IsString()
userId?: string;
}

3
apps/api/src/app/export/export.module.ts

@ -1,6 +1,7 @@
import { AccountModule } from '@ghostfolio/api/app/account/account.module'; import { AccountModule } from '@ghostfolio/api/app/account/account.module';
import { OrderModule } from '@ghostfolio/api/app/order/order.module'; import { OrderModule } from '@ghostfolio/api/app/order/order.module';
import { ApiModule } from '@ghostfolio/api/services/api/api.module'; import { ApiModule } from '@ghostfolio/api/services/api/api.module';
import { TagModule } from '@ghostfolio/api/services/tag/tag.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
@ -8,7 +9,7 @@ import { ExportController } from './export.controller';
import { ExportService } from './export.service'; import { ExportService } from './export.service';
@Module({ @Module({
imports: [AccountModule, ApiModule, OrderModule], imports: [AccountModule, ApiModule, OrderModule, TagModule],
controllers: [ExportController], controllers: [ExportController],
providers: [ExportService] providers: [ExportService]
}) })

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

@ -1,15 +1,18 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service'; import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { OrderService } from '@ghostfolio/api/app/order/order.service'; import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { environment } from '@ghostfolio/api/environments/environment'; import { environment } from '@ghostfolio/api/environments/environment';
import { TagService } from '@ghostfolio/api/services/tag/tag.service';
import { Filter, Export } from '@ghostfolio/common/interfaces'; import { Filter, Export } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Platform } from '@prisma/client';
@Injectable() @Injectable()
export class ExportService { export class ExportService {
public constructor( public constructor(
private readonly accountService: AccountService, private readonly accountService: AccountService,
private readonly orderService: OrderService private readonly orderService: OrderService,
private readonly tagService: TagService
) {} ) {}
public async export({ public async export({
@ -23,17 +26,40 @@ export class ExportService {
userCurrency: string; userCurrency: string;
userId: string; userId: string;
}): Promise<Export> { }): Promise<Export> {
const platformsMap: { [platformId: string]: Platform } = {};
const accounts = ( const accounts = (
await this.accountService.accounts({ await this.accountService.accounts({
include: {
balances: true,
Platform: true
},
orderBy: { orderBy: {
name: 'asc' name: 'asc'
}, },
where: { userId } where: { userId }
}) })
).map( ).map(
({ balance, comment, currency, id, isExcluded, name, platformId }) => { ({
balance,
balances,
comment,
currency,
id,
isExcluded,
name,
Platform: platform,
platformId
}) => {
if (platformId) {
platformsMap[platformId] = platform;
}
return { return {
balance, balance,
balances: balances.map(({ date, value }) => {
return { date: date.toISOString(), value };
}),
comment, comment,
currency, currency,
id, id,
@ -60,9 +86,22 @@ export class ExportService {
}); });
} }
const tags = (await this.tagService.getTagsForUser(userId))
.filter(({ isUsed }) => {
return isUsed;
})
.map(({ id, name }) => {
return {
id,
name
};
});
return { return {
meta: { date: new Date().toISOString(), version: environment.version }, meta: { date: new Date().toISOString(), version: environment.version },
accounts, accounts,
platforms: Object.values(platformsMap),
tags,
activities: activities.map( activities: activities.map(
({ ({
accountId, accountId,
@ -72,6 +111,7 @@ export class ExportService {
id, id,
quantity, quantity,
SymbolProfile, SymbolProfile,
tags: currentTags,
type, type,
unitPrice unitPrice
}) => { }) => {
@ -86,13 +126,12 @@ export class ExportService {
currency: SymbolProfile.currency, currency: SymbolProfile.currency,
dataSource: SymbolProfile.dataSource, dataSource: SymbolProfile.dataSource,
date: date.toISOString(), date: date.toISOString(),
symbol: symbol: ['FEE', 'INTEREST', 'ITEM', 'LIABILITY'].includes(type)
type === 'FEE' ||
type === 'INTEREST' ||
type === 'ITEM' ||
type === 'LIABILITY'
? SymbolProfile.name ? SymbolProfile.name
: SymbolProfile.symbol : SymbolProfile.symbol,
tags: currentTags.map(({ id: tagId }) => {
return tagId;
})
}; };
} }
), ),

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

@ -293,6 +293,7 @@ export class ImportService {
assetSubClass, assetSubClass,
countries, countries,
createdAt, createdAt,
cusip,
dataSource, dataSource,
figi, figi,
figiComposite, figiComposite,
@ -367,6 +368,7 @@ export class ImportService {
assetSubClass, assetSubClass,
countries, countries,
createdAt, createdAt,
cusip,
dataSource, dataSource,
figi, figi,
figiComposite, figiComposite,

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

@ -1,8 +1,8 @@
import { BenchmarkModule } from '@ghostfolio/api/app/benchmark/benchmark.module';
import { PlatformModule } from '@ghostfolio/api/app/platform/platform.module'; import { PlatformModule } from '@ghostfolio/api/app/platform/platform.module';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { UserModule } from '@ghostfolio/api/app/user/user.module'; import { UserModule } from '@ghostfolio/api/app/user/user.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 { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.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';

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

@ -1,7 +1,7 @@
import { BenchmarkService } from '@ghostfolio/api/app/benchmark/benchmark.service';
import { PlatformService } from '@ghostfolio/api/app/platform/platform.service'; import { PlatformService } from '@ghostfolio/api/app/platform/platform.service';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { UserService } from '@ghostfolio/api/app/user/user.service'; import { UserService } from '@ghostfolio/api/app/user/user.service';
import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';

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

@ -172,6 +172,8 @@ export abstract class PortfolioCalculator {
if (!transactionPoints.length) { if (!transactionPoints.length) {
return { return {
activitiesCount: 0,
createdAt: new Date(),
currentValueInBaseCurrency: new Big(0), currentValueInBaseCurrency: new Big(0),
errors: [], errors: [],
hasErrors: false, hasErrors: false,

4
apps/api/src/app/portfolio/calculator/twr/portfolio-calculator.ts

@ -101,6 +101,10 @@ export class TWRPortfolioCalculator extends PortfolioCalculator {
totalInterestWithCurrencyEffect, totalInterestWithCurrencyEffect,
totalInvestment, totalInvestment,
totalInvestmentWithCurrencyEffect, totalInvestmentWithCurrencyEffect,
activitiesCount: this.activities.filter(({ type }) => {
return ['BUY', 'SELL'].includes(type);
}).length,
createdAt: new Date(),
errors: [], errors: [],
historicalData: [], historicalData: [],
totalLiabilitiesWithCurrencyEffect: new Big(0), totalLiabilitiesWithCurrencyEffect: new Big(0),

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

@ -108,6 +108,7 @@ export class PortfolioController {
const { const {
accounts, accounts,
createdAt,
hasErrors, hasErrors,
holdings, holdings,
markets, markets,
@ -254,6 +255,7 @@ export class PortfolioController {
return { return {
accounts, accounts,
createdAt,
hasError, hasError,
holdings, holdings,
platforms, platforms,

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

@ -16,8 +16,10 @@ import { EconomicMarketClusterRiskDevelopedMarkets } from '@ghostfolio/api/model
import { EconomicMarketClusterRiskEmergingMarkets } from '@ghostfolio/api/models/rules/economic-market-cluster-risk/emerging-markets'; import { EconomicMarketClusterRiskEmergingMarkets } from '@ghostfolio/api/models/rules/economic-market-cluster-risk/emerging-markets';
import { EmergencyFundSetup } from '@ghostfolio/api/models/rules/emergency-fund/emergency-fund-setup'; import { EmergencyFundSetup } from '@ghostfolio/api/models/rules/emergency-fund/emergency-fund-setup';
import { FeeRatioInitialInvestment } from '@ghostfolio/api/models/rules/fees/fee-ratio-initial-investment'; import { FeeRatioInitialInvestment } from '@ghostfolio/api/models/rules/fees/fee-ratio-initial-investment';
import { RegionalMarketClusterRiskAsiaPacific } from '@ghostfolio/api/models/rules/regional-market-cluster-risk/asia-pacific';
import { RegionalMarketClusterRiskEmergingMarkets } from '@ghostfolio/api/models/rules/regional-market-cluster-risk/emerging-markets'; import { RegionalMarketClusterRiskEmergingMarkets } from '@ghostfolio/api/models/rules/regional-market-cluster-risk/emerging-markets';
import { RegionalMarketClusterRiskEurope } from '@ghostfolio/api/models/rules/regional-market-cluster-risk/europe'; import { RegionalMarketClusterRiskEurope } from '@ghostfolio/api/models/rules/regional-market-cluster-risk/europe';
import { RegionalMarketClusterRiskJapan } from '@ghostfolio/api/models/rules/regional-market-cluster-risk/japan';
import { RegionalMarketClusterRiskNorthAmerica } from '@ghostfolio/api/models/rules/regional-market-cluster-risk/north-america'; import { RegionalMarketClusterRiskNorthAmerica } from '@ghostfolio/api/models/rules/regional-market-cluster-risk/north-america';
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';
@ -381,7 +383,7 @@ export class PortfolioService {
currency: userCurrency currency: userCurrency
}); });
const { currentValueInBaseCurrency, hasErrors, positions } = const { createdAt, currentValueInBaseCurrency, hasErrors, positions } =
await portfolioCalculator.getSnapshot(); await portfolioCalculator.getSnapshot();
const cashDetails = await this.accountService.getCashDetails({ const cashDetails = await this.accountService.getCashDetails({
@ -626,6 +628,7 @@ export class PortfolioService {
return { return {
accounts, accounts,
createdAt,
hasErrors, hasErrors,
holdings, holdings,
markets, markets,
@ -1108,19 +1111,18 @@ export class PortfolioService {
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 accountBalanceItems = const [accountBalanceItems, { activities }] = await Promise.all([
await this.accountBalanceService.getAccountBalanceItems({ this.accountBalanceService.getAccountBalanceItems({
filters, filters,
userId, userId,
userCurrency userCurrency
}); }),
this.orderService.getOrdersForPortfolioCalculator({
const { activities } =
await this.orderService.getOrdersForPortfolioCalculator({
filters, filters,
userCurrency, userCurrency,
userId userId
}); })
]);
if (accountBalanceItems.length === 0 && activities.length === 0) { if (accountBalanceItems.length === 0 && activities.length === 0) {
return { return {
@ -1309,6 +1311,11 @@ export class PortfolioService {
summary.ordersCount > 0 summary.ordersCount > 0
? await this.rulesService.evaluate( ? await this.rulesService.evaluate(
[ [
new RegionalMarketClusterRiskAsiaPacific(
this.exchangeRateDataService,
marketsAdvancedTotalInBaseCurrency,
marketsAdvanced.asiaPacific.valueInBaseCurrency
),
new RegionalMarketClusterRiskEmergingMarkets( new RegionalMarketClusterRiskEmergingMarkets(
this.exchangeRateDataService, this.exchangeRateDataService,
marketsAdvancedTotalInBaseCurrency, marketsAdvancedTotalInBaseCurrency,
@ -1319,6 +1326,11 @@ export class PortfolioService {
marketsAdvancedTotalInBaseCurrency, marketsAdvancedTotalInBaseCurrency,
marketsAdvanced.europe.valueInBaseCurrency marketsAdvanced.europe.valueInBaseCurrency
), ),
new RegionalMarketClusterRiskJapan(
this.exchangeRateDataService,
marketsAdvancedTotalInBaseCurrency,
marketsAdvanced.japan.valueInBaseCurrency
),
new RegionalMarketClusterRiskNorthAmerica( new RegionalMarketClusterRiskNorthAmerica(
this.exchangeRateDataService, this.exchangeRateDataService,
marketsAdvancedTotalInBaseCurrency, marketsAdvancedTotalInBaseCurrency,

6
apps/api/src/app/tag/create-tag.dto.ts

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

14
apps/api/src/app/tag/tag.module.ts

@ -1,14 +0,0 @@
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { Module } from '@nestjs/common';
import { TagController } from './tag.controller';
import { TagService } from './tag.service';
@Module({
controllers: [TagController],
exports: [TagService],
imports: [PrismaModule],
providers: [TagService]
})
export class TagModule {}

9
apps/api/src/app/tag/update-tag.dto.ts

@ -1,9 +0,0 @@
import { IsString } from 'class-validator';
export class UpdateTagDto {
@IsString()
id: string;
@IsString()
name: string;
}

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

@ -13,8 +13,10 @@ import { EconomicMarketClusterRiskDevelopedMarkets } from '@ghostfolio/api/model
import { EconomicMarketClusterRiskEmergingMarkets } from '@ghostfolio/api/models/rules/economic-market-cluster-risk/emerging-markets'; import { EconomicMarketClusterRiskEmergingMarkets } from '@ghostfolio/api/models/rules/economic-market-cluster-risk/emerging-markets';
import { EmergencyFundSetup } from '@ghostfolio/api/models/rules/emergency-fund/emergency-fund-setup'; import { EmergencyFundSetup } from '@ghostfolio/api/models/rules/emergency-fund/emergency-fund-setup';
import { FeeRatioInitialInvestment } from '@ghostfolio/api/models/rules/fees/fee-ratio-initial-investment'; import { FeeRatioInitialInvestment } from '@ghostfolio/api/models/rules/fees/fee-ratio-initial-investment';
import { RegionalMarketClusterRiskAsiaPacific } from '@ghostfolio/api/models/rules/regional-market-cluster-risk/asia-pacific';
import { RegionalMarketClusterRiskEmergingMarkets } from '@ghostfolio/api/models/rules/regional-market-cluster-risk/emerging-markets'; import { RegionalMarketClusterRiskEmergingMarkets } from '@ghostfolio/api/models/rules/regional-market-cluster-risk/emerging-markets';
import { RegionalMarketClusterRiskEurope } from '@ghostfolio/api/models/rules/regional-market-cluster-risk/europe'; import { RegionalMarketClusterRiskEurope } from '@ghostfolio/api/models/rules/regional-market-cluster-risk/europe';
import { RegionalMarketClusterRiskJapan } from '@ghostfolio/api/models/rules/regional-market-cluster-risk/japan';
import { RegionalMarketClusterRiskNorthAmerica } from '@ghostfolio/api/models/rules/regional-market-cluster-risk/north-america'; import { RegionalMarketClusterRiskNorthAmerica } from '@ghostfolio/api/models/rules/regional-market-cluster-risk/north-america';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service'; import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service';
@ -84,6 +86,9 @@ export class UserService {
orderBy: { alias: 'asc' }, orderBy: { alias: 'asc' },
where: { GranteeUser: { id } } where: { GranteeUser: { id } }
}), }),
this.prismaService.order.count({
where: { userId: id }
}),
this.prismaService.order.findFirst({ this.prismaService.order.findFirst({
orderBy: { orderBy: {
date: 'asc' date: 'asc'
@ -94,8 +99,9 @@ export class UserService {
]); ]);
const access = userData[0]; const access = userData[0];
const firstActivity = userData[1]; const activitiesCount = userData[1];
let tags = userData[2]; const firstActivity = userData[2];
let tags = userData[3];
let systemMessage: SystemMessage; let systemMessage: SystemMessage;
@ -115,6 +121,7 @@ export class UserService {
} }
return { return {
activitiesCount,
id, id,
permissions, permissions,
subscription, subscription,
@ -272,6 +279,12 @@ export class UserService {
undefined, undefined,
undefined undefined
).getSettings(user.Settings.settings), ).getSettings(user.Settings.settings),
RegionalMarketClusterRiskAsiaPacific:
new RegionalMarketClusterRiskAsiaPacific(
undefined,
undefined,
undefined
).getSettings(user.Settings.settings),
RegionalMarketClusterRiskEmergingMarkets: RegionalMarketClusterRiskEmergingMarkets:
new RegionalMarketClusterRiskEmergingMarkets( new RegionalMarketClusterRiskEmergingMarkets(
undefined, undefined,
@ -283,6 +296,11 @@ export class UserService {
undefined, undefined,
undefined undefined
).getSettings(user.Settings.settings), ).getSettings(user.Settings.settings),
RegionalMarketClusterRiskJapan: new RegionalMarketClusterRiskJapan(
undefined,
undefined,
undefined
).getSettings(user.Settings.settings),
RegionalMarketClusterRiskNorthAmerica: RegionalMarketClusterRiskNorthAmerica:
new RegionalMarketClusterRiskNorthAmerica( new RegionalMarketClusterRiskNorthAmerica(
undefined, undefined,
@ -333,7 +351,11 @@ export class UserService {
currentPermissions, currentPermissions,
permissions.accessHoldingsChart, permissions.accessHoldingsChart,
permissions.createAccess, permissions.createAccess,
permissions.readAiPrompt permissions.createMarketDataOfOwnAssetProfile,
permissions.createOwnTag,
permissions.readAiPrompt,
permissions.readMarketDataOfOwnAssetProfile,
permissions.updateMarketDataOfOwnAssetProfile
); );
// Reset benchmark // Reset benchmark

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

@ -33,9 +33,12 @@ export class TransformDataSourceInResponseInterceptor<T>
attribute: 'dataSource', attribute: 'dataSource',
valueMap: Object.keys(DataSource).reduce( valueMap: Object.keys(DataSource).reduce(
(valueMap, dataSource) => { (valueMap, dataSource) => {
if (!['MANUAL'].includes(dataSource)) {
valueMap[dataSource] = encodeDataSource( valueMap[dataSource] = encodeDataSource(
DataSource[dataSource] DataSource[dataSource]
); );
}
return valueMap; return valueMap;
}, },
{} {}

1
apps/api/src/middlewares/html-template.middleware.ts

@ -129,6 +129,7 @@ export const HtmlTemplateMiddleware = async (
if ( if (
path.startsWith('/api/') || path.startsWith('/api/') ||
path.startsWith('/development/storybook') ||
isFileRequest(path) || isFileRequest(path) ||
!environment.production !environment.production
) { ) {

77
apps/api/src/models/rules/regional-market-cluster-risk/asia-pacific.ts

@ -0,0 +1,77 @@
import { Rule } from '@ghostfolio/api/models/rule';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { UserSettings } from '@ghostfolio/common/interfaces';
import { Settings } from './interfaces/rule-settings.interface';
export class RegionalMarketClusterRiskAsiaPacific extends Rule<Settings> {
private asiaPacificValueInBaseCurrency: number;
private currentValueInBaseCurrency: number;
public constructor(
protected exchangeRateDataService: ExchangeRateDataService,
currentValueInBaseCurrency: number,
asiaPacificValueInBaseCurrency: number
) {
super(exchangeRateDataService, {
key: RegionalMarketClusterRiskAsiaPacific.name,
name: 'Asia-Pacific'
});
this.asiaPacificValueInBaseCurrency = asiaPacificValueInBaseCurrency;
this.currentValueInBaseCurrency = currentValueInBaseCurrency;
}
public evaluate(ruleSettings: Settings) {
const asiaPacificMarketValueRatio = this.currentValueInBaseCurrency
? this.asiaPacificValueInBaseCurrency / this.currentValueInBaseCurrency
: 0;
if (asiaPacificMarketValueRatio > ruleSettings.thresholdMax) {
return {
evaluation: `The Asia-Pacific market contribution of your current investment (${(asiaPacificMarketValueRatio * 100).toPrecision(3)}%) exceeds ${(
ruleSettings.thresholdMax * 100
).toPrecision(3)}%`,
value: false
};
} else if (asiaPacificMarketValueRatio < ruleSettings.thresholdMin) {
return {
evaluation: `The Asia-Pacific market contribution of your current investment (${(asiaPacificMarketValueRatio * 100).toPrecision(3)}%) is below ${(
ruleSettings.thresholdMin * 100
).toPrecision(3)}%`,
value: false
};
}
return {
evaluation: `The Asia-Pacific market contribution of your current investment (${(asiaPacificMarketValueRatio * 100).toPrecision(3)}%) is within the range of ${(
ruleSettings.thresholdMin * 100
).toPrecision(
3
)}% and ${(ruleSettings.thresholdMax * 100).toPrecision(3)}%`,
value: true
};
}
public getConfiguration() {
return {
threshold: {
max: 1,
min: 0,
step: 0.01,
unit: '%'
},
thresholdMax: true,
thresholdMin: true
};
}
public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings {
return {
baseCurrency,
isActive: xRayRules?.[this.getKey()]?.isActive ?? true,
thresholdMax: xRayRules?.[this.getKey()]?.thresholdMax ?? 0.03,
thresholdMin: xRayRules?.[this.getKey()]?.thresholdMin ?? 0.02
};
}
}

77
apps/api/src/models/rules/regional-market-cluster-risk/japan.ts

@ -0,0 +1,77 @@
import { Rule } from '@ghostfolio/api/models/rule';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { UserSettings } from '@ghostfolio/common/interfaces';
import { Settings } from './interfaces/rule-settings.interface';
export class RegionalMarketClusterRiskJapan extends Rule<Settings> {
private currentValueInBaseCurrency: number;
private japanValueInBaseCurrency: number;
public constructor(
protected exchangeRateDataService: ExchangeRateDataService,
currentValueInBaseCurrency: number,
japanValueInBaseCurrency: number
) {
super(exchangeRateDataService, {
key: RegionalMarketClusterRiskJapan.name,
name: 'Japan'
});
this.currentValueInBaseCurrency = currentValueInBaseCurrency;
this.japanValueInBaseCurrency = japanValueInBaseCurrency;
}
public evaluate(ruleSettings: Settings) {
const japanMarketValueRatio = this.currentValueInBaseCurrency
? this.japanValueInBaseCurrency / this.currentValueInBaseCurrency
: 0;
if (japanMarketValueRatio > ruleSettings.thresholdMax) {
return {
evaluation: `The Japan market contribution of your current investment (${(japanMarketValueRatio * 100).toPrecision(3)}%) exceeds ${(
ruleSettings.thresholdMax * 100
).toPrecision(3)}%`,
value: false
};
} else if (japanMarketValueRatio < ruleSettings.thresholdMin) {
return {
evaluation: `The Japan market contribution of your current investment (${(japanMarketValueRatio * 100).toPrecision(3)}%) is below ${(
ruleSettings.thresholdMin * 100
).toPrecision(3)}%`,
value: false
};
}
return {
evaluation: `The Japan market contribution of your current investment (${(japanMarketValueRatio * 100).toPrecision(3)}%) is within the range of ${(
ruleSettings.thresholdMin * 100
).toPrecision(
3
)}% and ${(ruleSettings.thresholdMax * 100).toPrecision(3)}%`,
value: true
};
}
public getConfiguration() {
return {
threshold: {
max: 1,
min: 0,
step: 0.01,
unit: '%'
},
thresholdMax: true,
thresholdMin: true
};
}
public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings {
return {
baseCurrency,
isActive: xRayRules?.[this.getKey()]?.isActive ?? true,
thresholdMax: xRayRules?.[this.getKey()]?.thresholdMax ?? 0.06,
thresholdMin: xRayRules?.[this.getKey()]?.thresholdMin ?? 0.04
};
}
}

24
apps/api/src/services/benchmark/benchmark.module.ts

@ -0,0 +1,24 @@
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
import { Module } from '@nestjs/common';
import { BenchmarkService } from './benchmark.service';
@Module({
exports: [BenchmarkService],
imports: [
DataProviderModule,
MarketDataModule,
PrismaModule,
PropertyModule,
RedisCacheModule,
SymbolProfileModule
],
providers: [BenchmarkService]
})
export class BenchmarkModule {}

12
apps/api/src/app/benchmark/benchmark.service.spec.ts → apps/api/src/services/benchmark/benchmark.service.spec.ts

@ -4,17 +4,7 @@ describe('BenchmarkService', () => {
let benchmarkService: BenchmarkService; let benchmarkService: BenchmarkService;
beforeAll(async () => { beforeAll(async () => {
benchmarkService = new BenchmarkService( benchmarkService = new BenchmarkService(null, null, null, null, null, null);
null,
null,
null,
null,
null,
null,
null,
null,
null
);
}); });
it('calculateChangeInPercentage', async () => { it('calculateChangeInPercentage', async () => {

162
apps/api/src/app/benchmark/benchmark.service.ts → apps/api/src/services/benchmark/benchmark.service.ts

@ -1,8 +1,5 @@
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.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 { 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 { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
@ -11,16 +8,10 @@ import {
CACHE_TTL_INFINITE, CACHE_TTL_INFINITE,
PROPERTY_BENCHMARKS PROPERTY_BENCHMARKS
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { import { calculateBenchmarkTrend } from '@ghostfolio/common/helper';
DATE_FORMAT,
calculateBenchmarkTrend,
parseDate,
resetHours
} from '@ghostfolio/common/helper';
import { import {
AssetProfileIdentifier, AssetProfileIdentifier,
Benchmark, Benchmark,
BenchmarkMarketDataDetails,
BenchmarkProperty, BenchmarkProperty,
BenchmarkResponse BenchmarkResponse
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
@ -29,16 +20,8 @@ import { BenchmarkTrend } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { SymbolProfile } from '@prisma/client'; import { SymbolProfile } from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { import { addHours, isAfter, subDays } from 'date-fns';
addHours, import { uniqBy } from 'lodash';
differenceInDays,
eachDayOfInterval,
format,
isAfter,
isSameDay,
subDays
} from 'date-fns';
import { isNumber, uniqBy } from 'lodash';
import ms from 'ms'; import ms from 'ms';
import { BenchmarkValue } from './interfaces/benchmark-value.interface'; import { BenchmarkValue } from './interfaces/benchmark-value.interface';
@ -48,15 +31,12 @@ export class BenchmarkService {
private readonly CACHE_KEY_BENCHMARKS = 'BENCHMARKS'; private readonly CACHE_KEY_BENCHMARKS = 'BENCHMARKS';
public constructor( public constructor(
private readonly configurationService: ConfigurationService,
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly marketDataService: MarketDataService, private readonly marketDataService: MarketDataService,
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService, private readonly propertyService: PropertyService,
private readonly redisCacheService: RedisCacheService, private readonly redisCacheService: RedisCacheService,
private readonly symbolProfileService: SymbolProfileService, private readonly symbolProfileService: SymbolProfileService
private readonly symbolService: SymbolService
) {} ) {}
public calculateChangeInPercentage(baseValue: number, currentValue: number) { public calculateChangeInPercentage(baseValue: number, currentValue: number) {
@ -153,139 +133,9 @@ export class BenchmarkService {
symbol symbol
}; };
}) })
.sort((a, b) => a.name.localeCompare(b.name)); .sort((a, b) => {
} return a.name?.localeCompare(b?.name) ?? 0;
public async getMarketDataForUser({
dataSource,
endDate = new Date(),
startDate,
symbol,
userCurrency
}: {
endDate?: Date;
startDate: Date;
userCurrency: string;
} & AssetProfileIdentifier): Promise<BenchmarkMarketDataDetails> {
const marketData: { date: string; value: number }[] = [];
const days = differenceInDays(endDate, startDate) + 1;
const dates = eachDayOfInterval(
{
start: startDate,
end: endDate
},
{
step: Math.round(
days /
Math.min(days, this.configurationService.get('MAX_CHART_ITEMS'))
)
}
).map((date) => {
return resetHours(date);
}); });
const [currentSymbolItem, marketDataItems] = await Promise.all([
this.symbolService.get({
dataGatheringItem: {
dataSource,
symbol
}
}),
this.marketDataService.marketDataItems({
orderBy: {
date: 'asc'
},
where: {
dataSource,
symbol,
date: {
in: dates
}
}
})
]);
const exchangeRates =
await this.exchangeRateDataService.getExchangeRatesByCurrency({
startDate,
currencies: [currentSymbolItem.currency],
targetCurrency: userCurrency
});
const exchangeRateAtStartDate =
exchangeRates[`${currentSymbolItem.currency}${userCurrency}`]?.[
format(startDate, DATE_FORMAT)
];
const marketPriceAtStartDate = marketDataItems?.find(({ date }) => {
return isSameDay(date, startDate);
})?.marketPrice;
if (!marketPriceAtStartDate) {
Logger.error(
`No historical market data has been found for ${symbol} (${dataSource}) at ${format(
startDate,
DATE_FORMAT
)}`,
'BenchmarkService'
);
return { marketData };
}
for (const marketDataItem of marketDataItems) {
const exchangeRate =
exchangeRates[`${currentSymbolItem.currency}${userCurrency}`]?.[
format(marketDataItem.date, DATE_FORMAT)
];
const exchangeRateFactor =
isNumber(exchangeRateAtStartDate) && isNumber(exchangeRate)
? exchangeRate / exchangeRateAtStartDate
: 1;
marketData.push({
date: format(marketDataItem.date, DATE_FORMAT),
value:
marketPriceAtStartDate === 0
? 0
: this.calculateChangeInPercentage(
marketPriceAtStartDate,
marketDataItem.marketPrice * exchangeRateFactor
) * 100
});
}
const includesEndDate = isSameDay(
parseDate(marketData.at(-1).date),
endDate
);
if (currentSymbolItem?.marketPrice && !includesEndDate) {
const exchangeRate =
exchangeRates[`${currentSymbolItem.currency}${userCurrency}`]?.[
format(endDate, DATE_FORMAT)
];
const exchangeRateFactor =
isNumber(exchangeRateAtStartDate) && isNumber(exchangeRate)
? exchangeRate / exchangeRateAtStartDate
: 1;
marketData.push({
date: format(endDate, DATE_FORMAT),
value:
this.calculateChangeInPercentage(
marketPriceAtStartDate,
currentSymbolItem.marketPrice * exchangeRateFactor
) * 100
});
}
return {
marketData
};
} }
public async addBenchmark({ public async addBenchmark({

0
apps/api/src/app/benchmark/interfaces/benchmark-value.interface.ts → apps/api/src/services/benchmark/interfaces/benchmark-value.interface.ts

5
apps/api/src/services/data-provider/alpha-vantage/alpha-vantage.service.ts

@ -1,6 +1,7 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { import {
DataProviderInterface, DataProviderInterface,
GetAssetProfileParams,
GetDividendsParams, GetDividendsParams,
GetHistoricalParams, GetHistoricalParams,
GetQuotesParams, GetQuotesParams,
@ -41,9 +42,7 @@ export class AlphaVantageService implements DataProviderInterface {
public async getAssetProfile({ public async getAssetProfile({
symbol symbol
}: { }: GetAssetProfileParams): Promise<Partial<SymbolProfile>> {
symbol: string;
}): Promise<Partial<SymbolProfile>> {
return { return {
symbol, symbol,
dataSource: this.getName() dataSource: this.getName()

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

@ -1,6 +1,7 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { import {
DataProviderInterface, DataProviderInterface,
GetAssetProfileParams,
GetDividendsParams, GetDividendsParams,
GetHistoricalParams, GetHistoricalParams,
GetQuotesParams, GetQuotesParams,
@ -56,9 +57,7 @@ export class CoinGeckoService implements DataProviderInterface {
public async getAssetProfile({ public async getAssetProfile({
symbol symbol
}: { }: GetAssetProfileParams): Promise<Partial<SymbolProfile>> {
symbol: string;
}): Promise<Partial<SymbolProfile>> {
const response: Partial<SymbolProfile> = { const response: Partial<SymbolProfile> = {
symbol, symbol,
assetClass: AssetClass.LIQUIDITY, assetClass: AssetClass.LIQUIDITY,

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

@ -43,61 +43,55 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
return response; return response;
} }
let trackinsightSymbol = await this.searchTrackinsightSymbol({
requestTimeout,
symbol
});
if (!trackinsightSymbol) {
trackinsightSymbol = await this.searchTrackinsightSymbol({
requestTimeout,
symbol: symbol.split('.')?.[0]
});
}
if (!trackinsightSymbol) {
return response;
}
const profile = await fetch( const profile = await fetch(
`${TrackinsightDataEnhancerService.baseUrl}/funds/${symbol}.json`, `${TrackinsightDataEnhancerService.baseUrl}/funds/${trackinsightSymbol}.json`,
{ {
signal: AbortSignal.timeout(requestTimeout) signal: AbortSignal.timeout(requestTimeout)
} }
)
.then((res) => res.json())
.catch(() => {
return fetch(
`${TrackinsightDataEnhancerService.baseUrl}/funds/${
symbol.split('.')?.[0]
}.json`,
{
signal: AbortSignal.timeout(
this.configurationService.get('REQUEST_TIMEOUT')
)
}
) )
.then((res) => res.json()) .then((res) => res.json())
.catch(() => { .catch(() => {
return {}; return {};
}); });
});
const isin = profile?.isin?.split(';')?.[0]; const cusip = profile?.cusip;
if (cusip) {
response.cusip = cusip;
}
const isin = profile?.isins?.[0];
if (isin) { if (isin) {
response.isin = isin; response.isin = isin;
} }
const holdings = await fetch( const holdings = await fetch(
`${TrackinsightDataEnhancerService.baseUrl}/holdings/${symbol}.json`, `${TrackinsightDataEnhancerService.baseUrl}/holdings/${trackinsightSymbol}.json`,
{ {
signal: AbortSignal.timeout( signal: AbortSignal.timeout(requestTimeout)
this.configurationService.get('REQUEST_TIMEOUT')
)
}
)
.then((res) => res.json())
.catch(() => {
return fetch(
`${TrackinsightDataEnhancerService.baseUrl}/holdings/${
symbol.split('.')?.[0]
}.json`,
{
signal: AbortSignal.timeout(
this.configurationService.get('REQUEST_TIMEOUT')
)
} }
) )
.then((res) => res.json()) .then((res) => res.json())
.catch(() => { .catch(() => {
return {}; return {};
}); });
});
if (holdings?.weight < 1 - Math.min(holdings?.count * 0.000015, 0.95)) { if (holdings?.weight < 1 - Math.min(holdings?.count * 0.000015, 0.95)) {
// Skip if data is inaccurate, dependent on holdings count there might be rounding issues // Skip if data is inaccurate, dependent on holdings count there might be rounding issues
@ -177,4 +171,36 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
public getTestSymbol() { public getTestSymbol() {
return 'QQQ'; return 'QQQ';
} }
private async searchTrackinsightSymbol({
requestTimeout,
symbol
}: {
requestTimeout: number;
symbol: string;
}) {
return fetch(
`https://www.trackinsight.com/search-api/search_v2/${symbol}/_/ticker/default/0/3`,
{
signal: AbortSignal.timeout(requestTimeout)
}
)
.then((res) => res.json())
.then((jsonRes) => {
if (
jsonRes['results']?.['count'] === 1 ||
// Allow exact match
jsonRes['results']?.['docs']?.[0]?.['ticker'] === symbol ||
// Allow EXCHANGE:SYMBOL
jsonRes['results']?.['docs']?.[0]?.['ticker']?.endsWith(`:${symbol}`)
) {
return jsonRes['results']['docs'][0]['ticker'];
}
return undefined;
})
.catch(() => {
return undefined;
});
}
} }

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

@ -1,6 +1,7 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { import {
DataProviderInterface, DataProviderInterface,
GetAssetProfileParams,
GetDividendsParams, GetDividendsParams,
GetHistoricalParams, GetHistoricalParams,
GetQuotesParams, GetQuotesParams,
@ -51,9 +52,7 @@ export class EodHistoricalDataService implements DataProviderInterface {
public async getAssetProfile({ public async getAssetProfile({
symbol symbol
}: { }: GetAssetProfileParams): Promise<Partial<SymbolProfile>> {
symbol: string;
}): Promise<Partial<SymbolProfile>> {
const [searchResult] = await this.getSearchResult(symbol); const [searchResult] = await this.getSearchResult(symbol);
return { return {

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

@ -2,6 +2,7 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration/con
import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service'; import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service';
import { import {
DataProviderInterface, DataProviderInterface,
GetAssetProfileParams,
GetDividendsParams, GetDividendsParams,
GetHistoricalParams, GetHistoricalParams,
GetQuotesParams, GetQuotesParams,
@ -56,10 +57,9 @@ export class FinancialModelingPrepService implements DataProviderInterface {
} }
public async getAssetProfile({ public async getAssetProfile({
requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'),
symbol symbol
}: { }: GetAssetProfileParams): Promise<Partial<SymbolProfile>> {
symbol: string;
}): Promise<Partial<SymbolProfile>> {
const response: Partial<SymbolProfile> = { const response: Partial<SymbolProfile> = {
symbol, symbol,
dataSource: this.getName() dataSource: this.getName()
@ -70,9 +70,7 @@ export class FinancialModelingPrepService implements DataProviderInterface {
const [quote] = await fetch( const [quote] = await fetch(
`${this.URL}/quote/${symbol}?apikey=${this.apiKey}`, `${this.URL}/quote/${symbol}?apikey=${this.apiKey}`,
{ {
signal: AbortSignal.timeout( signal: AbortSignal.timeout(requestTimeout)
this.configurationService.get('REQUEST_TIMEOUT')
)
} }
).then((res) => res.json()); ).then((res) => res.json());
@ -84,9 +82,7 @@ export class FinancialModelingPrepService implements DataProviderInterface {
const [assetProfile] = await fetch( const [assetProfile] = await fetch(
`${this.URL}/profile/${symbol}?apikey=${this.apiKey}`, `${this.URL}/profile/${symbol}?apikey=${this.apiKey}`,
{ {
signal: AbortSignal.timeout( signal: AbortSignal.timeout(requestTimeout)
this.configurationService.get('REQUEST_TIMEOUT')
)
} }
).then((res) => res.json()); ).then((res) => res.json());
@ -100,9 +96,7 @@ export class FinancialModelingPrepService implements DataProviderInterface {
const etfCountryWeightings = await fetch( const etfCountryWeightings = await fetch(
`${this.URL}/etf-country-weightings/${symbol}?apikey=${this.apiKey}`, `${this.URL}/etf-country-weightings/${symbol}?apikey=${this.apiKey}`,
{ {
signal: AbortSignal.timeout( signal: AbortSignal.timeout(requestTimeout)
this.configurationService.get('REQUEST_TIMEOUT')
)
} }
).then((res) => res.json()); ).then((res) => res.json());
@ -127,9 +121,7 @@ export class FinancialModelingPrepService implements DataProviderInterface {
const [etfInformation] = await fetch( const [etfInformation] = await fetch(
`${this.getUrl({ version: 4 })}/etf-info?symbol=${symbol}&apikey=${this.apiKey}`, `${this.getUrl({ version: 4 })}/etf-info?symbol=${symbol}&apikey=${this.apiKey}`,
{ {
signal: AbortSignal.timeout( signal: AbortSignal.timeout(requestTimeout)
this.configurationService.get('REQUEST_TIMEOUT')
)
} }
).then((res) => res.json()); ).then((res) => res.json());
@ -140,9 +132,7 @@ export class FinancialModelingPrepService implements DataProviderInterface {
const [portfolioDate] = await fetch( const [portfolioDate] = await fetch(
`${this.getUrl({ version: 4 })}/etf-holdings/portfolio-date?symbol=${symbol}&apikey=${this.apiKey}`, `${this.getUrl({ version: 4 })}/etf-holdings/portfolio-date?symbol=${symbol}&apikey=${this.apiKey}`,
{ {
signal: AbortSignal.timeout( signal: AbortSignal.timeout(requestTimeout)
this.configurationService.get('REQUEST_TIMEOUT')
)
} }
).then((res) => res.json()); ).then((res) => res.json());
@ -150,9 +140,7 @@ export class FinancialModelingPrepService implements DataProviderInterface {
const etfHoldings = await fetch( const etfHoldings = await fetch(
`${this.getUrl({ version: 4 })}/etf-holdings?date=${portfolioDate.date}&symbol=${symbol}&apikey=${this.apiKey}`, `${this.getUrl({ version: 4 })}/etf-holdings?date=${portfolioDate.date}&symbol=${symbol}&apikey=${this.apiKey}`,
{ {
signal: AbortSignal.timeout( signal: AbortSignal.timeout(requestTimeout)
this.configurationService.get('REQUEST_TIMEOUT')
)
} }
).then((res) => res.json()); ).then((res) => res.json());
@ -170,9 +158,7 @@ export class FinancialModelingPrepService implements DataProviderInterface {
const etfSectorWeightings = await fetch( const etfSectorWeightings = await fetch(
`${this.URL}/etf-sector-weightings/${symbol}?apikey=${this.apiKey}`, `${this.URL}/etf-sector-weightings/${symbol}?apikey=${this.apiKey}`,
{ {
signal: AbortSignal.timeout( signal: AbortSignal.timeout(requestTimeout)
this.configurationService.get('REQUEST_TIMEOUT')
)
} }
).then((res) => res.json()); ).then((res) => res.json());
@ -211,7 +197,7 @@ export class FinancialModelingPrepService implements DataProviderInterface {
if (error?.name === 'AbortError') { if (error?.name === 'AbortError') {
message = `RequestError: The operation to get the asset profile for ${symbol} was aborted because the request to the data provider took more than ${( message = `RequestError: The operation to get the asset profile for ${symbol} was aborted because the request to the data provider took more than ${(
this.configurationService.get('REQUEST_TIMEOUT') / 1000 requestTimeout / 1000
).toFixed(3)} seconds`; ).toFixed(3)} seconds`;
} }
@ -244,7 +230,7 @@ export class FinancialModelingPrepService implements DataProviderInterface {
[date: string]: IDataProviderHistoricalResponse; [date: string]: IDataProviderHistoricalResponse;
} = {}; } = {};
const { historical } = await fetch( const { historical = [] } = await fetch(
`${this.URL}/historical-price-full/stock_dividend/${symbol}?apikey=${this.apiKey}`, `${this.URL}/historical-price-full/stock_dividend/${symbol}?apikey=${this.apiKey}`,
{ {
signal: AbortSignal.timeout(requestTimeout) signal: AbortSignal.timeout(requestTimeout)
@ -305,7 +291,7 @@ export class FinancialModelingPrepService implements DataProviderInterface {
? addYears(currentFrom, MAX_YEARS_PER_REQUEST) ? addYears(currentFrom, MAX_YEARS_PER_REQUEST)
: to; : to;
const { historical } = await fetch( const { historical = [] } = await fetch(
`${this.URL}/historical-price-full/${symbol}?apikey=${this.apiKey}&from=${format(currentFrom, DATE_FORMAT)}&to=${format(currentTo, DATE_FORMAT)}`, `${this.URL}/historical-price-full/${symbol}?apikey=${this.apiKey}&from=${format(currentFrom, DATE_FORMAT)}&to=${format(currentTo, DATE_FORMAT)}`,
{ {
signal: AbortSignal.timeout(requestTimeout) signal: AbortSignal.timeout(requestTimeout)
@ -376,7 +362,7 @@ export class FinancialModelingPrepService implements DataProviderInterface {
if (error?.name === 'AbortError') { if (error?.name === 'AbortError') {
message = `RequestError: The operation to get the quotes was aborted because the request to the data provider took more than ${( message = `RequestError: The operation to get the quotes was aborted because the request to the data provider took more than ${(
this.configurationService.get('REQUEST_TIMEOUT') / 1000 requestTimeout / 1000
).toFixed(3)} seconds`; ).toFixed(3)} seconds`;
} }

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

@ -2,6 +2,7 @@ import { environment } from '@ghostfolio/api/environments/environment';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { import {
DataProviderInterface, DataProviderInterface,
GetAssetProfileParams,
GetDividendsParams, GetDividendsParams,
GetHistoricalParams, GetHistoricalParams,
GetQuotesParams, GetQuotesParams,
@ -18,6 +19,7 @@ import {
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { import {
DataProviderGhostfolioAssetProfileResponse,
DataProviderInfo, DataProviderInfo,
DividendsResponse, DividendsResponse,
HistoricalResponse, HistoricalResponse,
@ -46,21 +48,46 @@ export class GhostfolioService implements DataProviderInterface {
} }
public async getAssetProfile({ public async getAssetProfile({
requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'),
symbol symbol
}: { }: GetAssetProfileParams): Promise<Partial<SymbolProfile>> {
symbol: string; let response: DataProviderGhostfolioAssetProfileResponse = {};
}): Promise<Partial<SymbolProfile>> {
const { items } = await this.search({ query: symbol });
const searchResult = items?.[0];
return { try {
symbol, const assetProfile = (await fetch(
assetClass: searchResult?.assetClass, `${this.URL}/v1/data-providers/ghostfolio/asset-profile/${symbol}`,
assetSubClass: searchResult?.assetSubClass, {
currency: searchResult?.currency, headers: await this.getRequestHeaders(),
dataSource: this.getName(), signal: AbortSignal.timeout(requestTimeout)
name: searchResult?.name }
}; ).then((res) =>
res.json()
)) as DataProviderGhostfolioAssetProfileResponse;
response = assetProfile;
} catch (error) {
let message = error;
if (error.name === 'AbortError') {
message = `RequestError: The operation to get the quotes was aborted because the request to the data provider took more than ${(
requestTimeout / 1000
).toFixed(3)} seconds`;
} else if (error.response?.statusCode === StatusCodes.TOO_MANY_REQUESTS) {
message = 'RequestError: The daily request limit has been exceeded';
} else if (error.response?.statusCode === StatusCodes.UNAUTHORIZED) {
if (!error.request?.options?.headers?.authorization?.includes('-')) {
message =
'RequestError: The provided API key is invalid. Please update it in the Settings section of the Admin Control panel.';
} else {
message =
'RequestError: The provided API key has expired. Please request a new one and update it in the Settings section of the Admin Control panel.';
}
}
Logger.error(message, 'GhostfolioService');
}
return response;
} }
public getDataProviderInfo(): DataProviderInfo { public getDataProviderInfo(): DataProviderInfo {
@ -203,7 +230,7 @@ export class GhostfolioService implements DataProviderInterface {
if (error.name === 'AbortError') { if (error.name === 'AbortError') {
message = `RequestError: The operation to get the quotes was aborted because the request to the data provider took more than ${( message = `RequestError: The operation to get the quotes was aborted because the request to the data provider took more than ${(
this.configurationService.get('REQUEST_TIMEOUT') / 1000 requestTimeout / 1000
).toFixed(3)} seconds`; ).toFixed(3)} seconds`;
} else if (error.response?.statusCode === StatusCodes.TOO_MANY_REQUESTS) { } else if (error.response?.statusCode === StatusCodes.TOO_MANY_REQUESTS) {
message = 'RequestError: The daily request limit has been exceeded'; message = 'RequestError: The daily request limit has been exceeded';
@ -224,10 +251,13 @@ export class GhostfolioService implements DataProviderInterface {
} }
public getTestSymbol() { public getTestSymbol() {
return 'AAPL.US'; return 'AAPL';
} }
public async search({ query }: GetSearchParams): Promise<LookupResponse> { public async search({
requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'),
query
}: GetSearchParams): Promise<LookupResponse> {
let searchResult: LookupResponse = { items: [] }; let searchResult: LookupResponse = { items: [] };
try { try {
@ -235,9 +265,7 @@ export class GhostfolioService implements DataProviderInterface {
`${this.URL}/v2/data-providers/ghostfolio/lookup?query=${query}`, `${this.URL}/v2/data-providers/ghostfolio/lookup?query=${query}`,
{ {
headers: await this.getRequestHeaders(), headers: await this.getRequestHeaders(),
signal: AbortSignal.timeout( signal: AbortSignal.timeout(requestTimeout)
this.configurationService.get('REQUEST_TIMEOUT')
)
} }
).then((res) => res.json())) as LookupResponse; ).then((res) => res.json())) as LookupResponse;
} catch (error) { } catch (error) {
@ -245,7 +273,7 @@ export class GhostfolioService implements DataProviderInterface {
if (error.name === 'AbortError') { if (error.name === 'AbortError') {
message = `RequestError: The operation to search for ${query} was aborted because the request to the data provider took more than ${( message = `RequestError: The operation to search for ${query} was aborted because the request to the data provider took more than ${(
this.configurationService.get('REQUEST_TIMEOUT') / 1000 requestTimeout / 1000
).toFixed(3)} seconds`; ).toFixed(3)} seconds`;
} else if (error.response?.statusCode === StatusCodes.TOO_MANY_REQUESTS) { } else if (error.response?.statusCode === StatusCodes.TOO_MANY_REQUESTS) {
message = 'RequestError: The daily request limit has been exceeded'; message = 'RequestError: The daily request limit has been exceeded';

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

@ -1,6 +1,7 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { import {
DataProviderInterface, DataProviderInterface,
GetAssetProfileParams,
GetDividendsParams, GetDividendsParams,
GetHistoricalParams, GetHistoricalParams,
GetQuotesParams, GetQuotesParams,
@ -37,9 +38,7 @@ export class GoogleSheetsService implements DataProviderInterface {
public async getAssetProfile({ public async getAssetProfile({
symbol symbol
}: { }: GetAssetProfileParams): Promise<Partial<SymbolProfile>> {
symbol: string;
}): Promise<Partial<SymbolProfile>> {
return { return {
symbol, symbol,
dataSource: this.getName() dataSource: this.getName()

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

@ -15,9 +15,7 @@ export interface DataProviderInterface {
getAssetProfile({ getAssetProfile({
symbol symbol
}: { }: GetAssetProfileParams): Promise<Partial<SymbolProfile>>;
symbol: string;
}): Promise<Partial<SymbolProfile>>;
getDataProviderInfo(): DataProviderInfo; getDataProviderInfo(): DataProviderInfo;
@ -55,6 +53,11 @@ export interface DataProviderInterface {
search({ includeIndices, query }: GetSearchParams): Promise<LookupResponse>; search({ includeIndices, query }: GetSearchParams): Promise<LookupResponse>;
} }
export interface GetAssetProfileParams {
requestTimeout?: number;
symbol: string;
}
export interface GetDividendsParams { export interface GetDividendsParams {
from: Date; from: Date;
granularity?: Granularity; granularity?: Granularity;
@ -79,5 +82,6 @@ export interface GetQuotesParams {
export interface GetSearchParams { export interface GetSearchParams {
includeIndices?: boolean; includeIndices?: boolean;
query: string; query: string;
requestTimeout?: number;
userId?: string; userId?: string;
} }

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

@ -1,6 +1,7 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { import {
DataProviderInterface, DataProviderInterface,
GetAssetProfileParams,
GetDividendsParams, GetDividendsParams,
GetHistoricalParams, GetHistoricalParams,
GetQuotesParams, GetQuotesParams,
@ -44,9 +45,7 @@ export class ManualService implements DataProviderInterface {
public async getAssetProfile({ public async getAssetProfile({
symbol symbol
}: { }: GetAssetProfileParams): Promise<Partial<SymbolProfile>> {
symbol: string;
}): Promise<Partial<SymbolProfile>> {
const assetProfile: Partial<SymbolProfile> = { const assetProfile: Partial<SymbolProfile> = {
symbol, symbol,
dataSource: this.getName() dataSource: this.getName()

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

@ -1,6 +1,7 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { import {
DataProviderInterface, DataProviderInterface,
GetAssetProfileParams,
GetDividendsParams, GetDividendsParams,
GetHistoricalParams, GetHistoricalParams,
GetQuotesParams, GetQuotesParams,
@ -33,9 +34,7 @@ export class RapidApiService implements DataProviderInterface {
public async getAssetProfile({ public async getAssetProfile({
symbol symbol
}: { }: GetAssetProfileParams): Promise<Partial<SymbolProfile>> {
symbol: string;
}): Promise<Partial<SymbolProfile>> {
return { return {
symbol, symbol,
dataSource: this.getName() dataSource: this.getName()

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

@ -2,6 +2,7 @@ import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/c
import { YahooFinanceDataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service'; import { YahooFinanceDataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service';
import { import {
DataProviderInterface, DataProviderInterface,
GetAssetProfileParams,
GetDividendsParams, GetDividendsParams,
GetHistoricalParams, GetHistoricalParams,
GetQuotesParams, GetQuotesParams,
@ -43,9 +44,7 @@ export class YahooFinanceService implements DataProviderInterface {
public async getAssetProfile({ public async getAssetProfile({
symbol symbol
}: { }: GetAssetProfileParams): Promise<Partial<SymbolProfile>> {
symbol: string;
}): Promise<Partial<SymbolProfile>> {
return this.yahooFinanceDataEnhancerService.getAssetProfile(symbol); return this.yahooFinanceDataEnhancerService.getAssetProfile(symbol);
} }

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

@ -220,6 +220,7 @@ export class DataGatheringService {
assetSubClass, assetSubClass,
countries, countries,
currency, currency,
cusip,
dataSource, dataSource,
figi, figi,
figiComposite, figiComposite,
@ -238,6 +239,7 @@ export class DataGatheringService {
assetSubClass, assetSubClass,
countries, countries,
currency, currency,
cusip,
dataSource, dataSource,
figi, figi,
figiComposite, figiComposite,
@ -254,6 +256,7 @@ export class DataGatheringService {
assetSubClass, assetSubClass,
countries, countries,
currency, currency,
cusip,
figi, figi,
figiComposite, figiComposite,
figiShareClass, figiShareClass,

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

@ -217,8 +217,7 @@ export class SymbolProfileService {
?.length > 0 ?.length > 0
) { ) {
item.countries = this.getCountries( item.countries = this.getCountries(
item.SymbolProfileOverrides item.SymbolProfileOverrides.countries as unknown as Prisma.JsonArray
?.countries as unknown as Prisma.JsonArray
); );
} }
@ -227,22 +226,22 @@ export class SymbolProfileService {
?.length > 0 ?.length > 0
) { ) {
item.holdings = this.getHoldings( item.holdings = this.getHoldings(
item.SymbolProfileOverrides?.holdings as unknown as Prisma.JsonArray item.SymbolProfileOverrides.holdings as unknown as Prisma.JsonArray
); );
} }
item.name = item.SymbolProfileOverrides?.name ?? item.name; item.name = item.SymbolProfileOverrides.name ?? item.name;
if ( if (
(item.SymbolProfileOverrides.sectors as unknown as Sector[])?.length > (item.SymbolProfileOverrides.sectors as unknown as Sector[])?.length >
0 0
) { ) {
item.sectors = this.getSectors( item.sectors = this.getSectors(
item.SymbolProfileOverrides?.sectors as unknown as Prisma.JsonArray item.SymbolProfileOverrides.sectors as unknown as Prisma.JsonArray
); );
} }
item.url = item.SymbolProfileOverrides?.url ?? item.url; item.url = item.SymbolProfileOverrides.url ?? item.url;
delete item.SymbolProfileOverrides; delete item.SymbolProfileOverrides;
} }

73
apps/api/src/services/tag/tag.service.ts

@ -1,11 +1,52 @@
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Prisma, Tag } from '@prisma/client';
@Injectable() @Injectable()
export class TagService { export class TagService {
public constructor(private readonly prismaService: PrismaService) {} public constructor(private readonly prismaService: PrismaService) {}
public async createTag(data: Prisma.TagCreateInput) {
return this.prismaService.tag.create({
data
});
}
public async deleteTag(where: Prisma.TagWhereUniqueInput): Promise<Tag> {
return this.prismaService.tag.delete({ where });
}
public async getTag(
tagWhereUniqueInput: Prisma.TagWhereUniqueInput
): Promise<Tag> {
return this.prismaService.tag.findUnique({
where: tagWhereUniqueInput
});
}
public async getTags({
cursor,
orderBy,
skip,
take,
where
}: {
cursor?: Prisma.TagWhereUniqueInput;
orderBy?: Prisma.TagOrderByWithRelationInput;
skip?: number;
take?: number;
where?: Prisma.TagWhereInput;
} = {}) {
return this.prismaService.tag.findMany({
cursor,
orderBy,
skip,
take,
where
});
}
public async getTagsForUser(userId: string) { public async getTagsForUser(userId: string) {
const tags = await this.prismaService.tag.findMany({ const tags = await this.prismaService.tag.findMany({
include: { include: {
@ -42,4 +83,36 @@ export class TagService {
isUsed: true isUsed: true
})); }));
} }
public async getTagsWithActivityCount() {
const tagsWithOrderCount = await this.prismaService.tag.findMany({
include: {
_count: {
select: { orders: true }
}
}
});
return tagsWithOrderCount.map(({ _count, id, name, userId }) => {
return {
id,
name,
userId,
activityCount: _count.orders
};
});
}
public async updateTag({
data,
where
}: {
data: Prisma.TagUpdateInput;
where: Prisma.TagWhereUniqueInput;
}): Promise<Tag> {
return this.prismaService.tag.update({
data,
where
});
}
} }

2
apps/api/src/services/twitter-bot/twitter-bot.module.ts

@ -1,5 +1,5 @@
import { BenchmarkModule } from '@ghostfolio/api/app/benchmark/benchmark.module';
import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module'; import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module';
import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { TwitterBotService } from '@ghostfolio/api/services/twitter-bot/twitter-bot.service'; import { TwitterBotService } from '@ghostfolio/api/services/twitter-bot/twitter-bot.service';

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

@ -1,5 +1,5 @@
import { BenchmarkService } from '@ghostfolio/api/app/benchmark/benchmark.service';
import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service'; import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service';
import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { import {
ghostfolioFearAndGreedIndexDataSource, ghostfolioFearAndGreedIndexDataSource,

34
apps/client/localhost.cert

@ -1,18 +1,20 @@
-----BEGIN CERTIFICATE----- -----BEGIN CERTIFICATE-----
MIIC5TCCAc2gAwIBAgIJAJAMHOFnJ6oyMA0GCSqGSIb3DQEBCwUAMBQxEjAQBgNV MIIDSDCCAjACCQCQ2ForVhz+uDANBgkqhkiG9w0BAQsFADBmMQswCQYDVQQGEwJD
BAMMCWxvY2FsaG9zdDAeFw0yNDAyMjcxNTQ2MzFaFw0yNDAzMjgxNTQ2MzFaMBQx SDEOMAwGA1UECAwFU3RhdGUxDTALBgNVBAcMBENpdHkxFTATBgNVBAoMDE9yZ2Fu
EjAQBgNVBAMMCWxvY2FsaG9zdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoC aXphdGlvbjENMAsGA1UECwwEVW5pdDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI1
ggEBAJ0hRjViikEKVIyukXR4CfuYVvFEFzB6AwAQ9Jrz2MseqpLacLTXFFAS54mp MDMwOTE2MzQxM1oXDTI2MDMwOTE2MzQxM1owZjELMAkGA1UEBhMCQ0gxDjAMBgNV
iDuqPBzs9ta40mlSrqSBuAWKikpW5kTNnmqUnDZ6iSJezbYWx9YyULGqqb1q3C4/ BAgMBVN0YXRlMQ0wCwYDVQQHDARDaXR5MRUwEwYDVQQKDAxPcmdhbml6YXRpb24x
5pH9m6NHJ+2uaUNKlDxYNKbntqs3drQEdxH9yv672Z53nvogTcf9jz6zjutEQGSV DTALBgNVBAsMBFVuaXQxEjAQBgNVBAMMCWxvY2FsaG9zdDCCASIwDQYJKoZIhvcN
HgVkCTTQmzf3+3st+VJ5D8JeYFR+tpZ6yleqgXFaTMtPZRfKLvTkQ+KeyCJLnsUJ AQEBBQADggEPADCCAQoCggEBAMkJRKPgV8NDcoIakPc7sZVXQ9VK2PGb8+lF/1Lv
BQvdCKI0PGsG6d6ygXFmSePolD9KW3VTKKDPCsndID89vAnRWDj9UhzvycxmKpcF NcIZpD40+p4DzuEw0bjRn17IDClyLMaLbZNtIyTPSkFaffL+rJ0JvnKdG50s+HId
GrUPH5+Pis1PM1R7OnAvnFygnyECAwEAAaM6MDgwFAYDVR0RBA0wC4IJbG9jYWxo YNuCwKkgHg4hTXFzOPpT3HMG3UxyEwFOm25GMFiikfT96ukMAAkanMqYKZQOClRU
b3N0MAsGA1UdDwQEAwIHgDATBgNVHSUEDDAKBggrBgEFBQcDATANBgkqhkiG9w0B Cw4LP3g0Oks58obbRy4Wltp88K8LJrR+j81+AjElTIGXHhChXzV/NjJ14TMNy5hZ
AQsFAAOCAQEAOdzrY3RTAPnBVAd3/GKIEkielYyOgKPnvd+RcOB+je8cXZY+vaxX lwV4xUSwvNqOvWGMIR7J77fINF130ghTSnvzCS52dCeom2I4Lvncz3m37lDttCOs
uEFP0526G00+kRZ2Tx9t0SmjoSpwg3lHUPMko0dIxgRlqISDAohdrEptHpcVujsD Wm/i651ro7pwFEs/lJmrnFHPtph2ayPcHBmrQCgLc5xMUMcCAwEAATANBgkqhkiG
ak88LLnAurr60JNjWX2wbEoJ18KLtqGSnATdmCgKwDPIN5a7+ssp44BGyzH6VYCg 9w0BAQsFAAOCAQEAhRA1/+Gl2VH34yN/FvrE5cY0W4ghSCuTdK9pGeo8AcN+TScU
wV3VjJl0pp4C5M0Jfu0p1FrQjzIVhgqR7JFYmvqIogWrGwYAQK/3XRXq8t48J5X3 7O+hVsEwZDrYKuDvG8Ab//A+uv5gbfGbYPJVIdJ3Q8HKijNZmbwAgANJU/c0WwOx
IsfWiLAA2ZdCoWAnZ6PAGBOoGivtkJm967pHjd/28qYY6mQo4sN2ksEOjx6/YslF XBQ9mCzWRcJxQeUUgh4DT4lZCOfR5pIvAJpKScTcF/yp5gOgrgJH1GHFEYYPoXWO
2mOJdLV/DzqoggUsTpPEG0dRhzQLTGHKDQ== ezPPMwCNbfamUPlZZnHu74fUrFrDPI9c/YSu8Ex/LegZXJAEzA+8I0g64rjGtzJp
fkRDyQcBuT5SVa+USBlALQmdIuT/fN6R729DcGzvV8JqdoG9sLra4hrRCn3+A3c9
izZguW1BQNQ2N7II6QCDnWkdUFSQCiQunX/xsg==
-----END CERTIFICATE----- -----END CERTIFICATE-----

52
apps/client/localhost.pem

@ -1,28 +1,28 @@
-----BEGIN PRIVATE KEY----- -----BEGIN PRIVATE KEY-----
MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCdIUY1YopBClSM MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDJCUSj4FfDQ3KC
rpF0eAn7mFbxRBcwegMAEPSa89jLHqqS2nC01xRQEueJqYg7qjwc7PbWuNJpUq6k GpD3O7GVV0PVStjxm/PpRf9S7zXCGaQ+NPqeA87hMNG40Z9eyAwpcizGi22TbSMk
gbgFiopKVuZEzZ5qlJw2eokiXs22FsfWMlCxqqm9atwuP+aR/ZujRyftrmlDSpQ8 z0pBWn3y/qydCb5ynRudLPhyHWDbgsCpIB4OIU1xczj6U9xzBt1MchMBTptuRjBY
WDSm57arN3a0BHcR/cr+u9med576IE3H/Y8+s47rREBklR4FZAk00Js39/t7LflS opH0/erpDAAJGpzKmCmUDgpUVAsOCz94NDpLOfKG20cuFpbafPCvCya0fo/NfgIx
eQ/CXmBUfraWespXqoFxWkzLT2UXyi705EPinsgiS57FCQUL3QiiNDxrBunesoFx JUyBlx4QoV81fzYydeEzDcuYWZcFeMVEsLzajr1hjCEeye+3yDRdd9IIU0p78wku
Zknj6JQ/Slt1UyigzwrJ3SA/PbwJ0Vg4/VIc78nMZiqXBRq1Dx+fj4rNTzNUezpw dnQnqJtiOC753M95t+5Q7bQjrFpv4uuda6O6cBRLP5SZq5xRz7aYdmsj3BwZq0Ao
L5xcoJ8hAgMBAAECggEAPU5YOEgELTA8oM8TjV+wdWuQsH2ilpVkSkhTR4nQkh+a C3OcTFDHAgMBAAECggEBALJQqDN7QB0QbDb+fWrt5bwDJUXBF+BmZdiZn7jeOJ6r
6cU0qDoqgLt/fySYNL9MyPRjso9V+SX7YdAC3paZMjwJh9q57lehQ1g33SMkG+Fz w8TxlQIneo6/kKYQOP4HDtKMVS7eaRkFCtERlFmXfHPWdSDtjaF3vRCS3OPLLyhF
gs0K0ucFZxQkaB8idN+ANAp1N7UO+ORGRe0cTeqmSNNRCxea5XgiFZVxaPS/IFOR N8JLnJ0H6PsiKn3PeJAGnK+71yOnp7IOS7+yoyfdOUnwvO9WTZBdmzOZqIvX595R
vVdXS1DulgwHh4H0ljKmkj7jc9zPBSc9ccW5Ml2q4a26Atu4IC/Mlp/DF4GNELbD g7R5yjSYjzFMmaCpyab6kiD7b4bHzDIrB0XuouT2W/fS/i1srwc3eDk78ZyioyiB
ebi9ZOZG33ip2bdhj3WX7NW9GJaaViKtVUpcpR6u+6BfvTXQ6xrqdoxXk9fnPzzf g6GDuOwqDfPmkUqKo2oXSoSR8yCwSSdlClc7aOowoNxbsksTDjXf1k02n0lG5MHU
sSoLPTt8yO4RavP1zQU22PhgIcWudhCiy/2Nv+uLqQKBgQDMPh1/xwdFl8yES8dy ldCX02WdA0JFW8Os0Ig+YBq7wSkB5oNVt7gEek/MB0ECgYEA+ATkyfX9/5X2kUMY
f0xJyCDWPiB+xzGwcOAC90FrNZaqG0WWs3VHE4cilaWjCflvdR6aAEDEY68sZJhl MatUqKOvLUtyIfHeYUy/Dm+9JlZlrxD0dRAKWhnhRR16v5cwN2RVtEvMDsV1AGHN
h+ChT/g61QLMOI+VIDQ1kJXKYgfS/B+BE1PZ0EkuymKFOvbNO8agHodB8CqnZaQh e/fh315aAq+I6/eY6syXfkeHHs5UIRPrOlIcp1Ogfg99xpOT0/TZy9bB7lKvtYes
bLuZaDnZ0JLK4im3KPt70+aKYwKBgQDE8s6xl0SLu+yJLo3VklCRD67Z8/jlvx2t GmKO8n7md1TptdxilSNORI60KvECgYEAz4FX6vH76HgV/seY+vePrj5nFCZnDru7
h3DF6NG8pB3QmvKdJWFKuLAWLGflJwbUW9Pt3hXkc0649KQrtiIYC0ZMh9KMaVCk 16w5LYoHaX0hABJ0qZCqZozdPf9mqM5Ldc8PUbvVsFqXyaHwBAKUsH44a3aLxcXU
WmjS/n2eIUQZ7ZUlwFesi4p4iGynVBavIY8TJ6Y+K3TvsJgXP3IZ96r689PQJo8E JMsQanU5I87SWP/S8Xu2Yxc10L66Oc5VdAeraZvb+wJqTkYKhDYOJVMjyuI/vkAw
KbSeyYzFqwKBgGQTS4EAlJ+U8bEhMGj51veQCAbyChoUoFRD+n95h6RwbZKMKlzd fqMPI6wShzcCgYEAndYnb6uf4Eakap9jR0C8mLHKaq3nzVhqaEt6DwrnOf2jqnzE
MenRt7VKfg6VJJNoX8Y1uYaBEaQ+5i1Zlsdz1718AhLu4+u+C9bzMXIo9ox63TTx xbbWj66GoQB4vHLP2YB91kaibwgURJD5PxpqYUdfSvRA08J3S322L0P/5ofyHDbb
s3RWioVSxVNiwOtvDrQGQWAdvcioFPQLwyA34aDIgiTHDImimxbhjWThAoGAWOqW 7PqSh539thvPtE74tdvNux5Jvoxai9Dyorv0Mri1nF2qefTtu/GC/rg+SlECgYB2
Tq9QjxWk0Lpn5ohMP3GpK1VuhasnJvUDARb/uf8ORuPtrOz3Y9jGBvy9W0OnXbCn FaYhhomTVls1/QIat6zlPI/OULhPExineFOljaoAJvwTnW0UXcYKy9jPgjs6jwM0
mbiugZldbTtl8yYjdl+AuYSIlkPl2I3IzZl/9Shnqp0MvSJ9crT9KzXMeC8Knr6z TJvsKFdHn5ZHYUdEEO/qrDmRNgn+h0Ddm02BN6pHrVfY2+SAFaXKKBgw7YjugnPw
7Z30/BR6ksxTngtS5E5grzPt6Qe/gc2ich3kpEkCgYBfBHUhVIKVrDW/8a7U2679 rrimRdLeuhYi6wrrCBPuu6xftXcO3lp6hnKEG1UD6wKBgEh/C7HQ6cjb7Rr15eRq
Wj4i9us/M3iPMxcTv7/ZAg08TEvNBQYtvVQLu0NfDKXx8iGKJJ6evIYgNXVm2sPq 2VOgeuz7o2v/OC+jO6yFGRrs2VKoBuJpw/6jx806Cbi2jLEwim21iNYW/2McOWP3
VzSgwGCALi1X0443amZU+mnX+/9RnBqyM+PJU8570mM83jqKlY/BEsi0aqxTioFG YUvni7qHXfll8d4sSAuCTA4K/N0MJ/3XbGBPDm/83J2o7uz2GFkQRruruaERvDMF
an3xbjjN+Rq9iKLzmPxIMg== x26H2i3DOUFzdgbkoNB0ifHd
-----END PRIVATE KEY----- -----END PRIVATE KEY-----

9
apps/client/src/app/app.component.html

@ -129,6 +129,15 @@
>GitHub<ion-icon class="ml-1" name="open-outline" >GitHub<ion-icon class="ml-1" name="open-outline"
/></a> /></a>
</li> </li>
<li>
<a
class="align-items-baseline d-flex"
href="https://linkedin.com/company/ghostfolio"
target="_blank"
title="Follow Ghostfolio on LinkedIn"
>LinkedIn<ion-icon class="ml-1" name="open-outline"
/></a>
</li>
<li> <li>
<a <a
class="align-items-baseline d-flex" class="align-items-baseline d-flex"

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

@ -4,7 +4,8 @@ import { AdminService } from '@ghostfolio/client/services/admin.service';
import { ghostfolioScraperApiSymbolPrefix } from '@ghostfolio/common/config'; import { ghostfolioScraperApiSymbolPrefix } from '@ghostfolio/common/config';
import { import {
getCurrencyFromSymbol, getCurrencyFromSymbol,
isDerivedCurrency isDerivedCurrency,
isRootCurrency
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
import { import {
AssetProfileIdentifier, AssetProfileIdentifier,
@ -77,6 +78,7 @@ export class AdminMarketDataService {
activitiesCount === 0 && activitiesCount === 0 &&
!isBenchmark && !isBenchmark &&
!isDerivedCurrency(getCurrencyFromSymbol(symbol)) && !isDerivedCurrency(getCurrencyFromSymbol(symbol)) &&
!isRootCurrency(getCurrencyFromSymbol(symbol)) &&
!symbol.startsWith(ghostfolioScraperApiSymbolPrefix) !symbol.startsWith(ghostfolioScraperApiSymbolPrefix)
); );
} }

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

@ -15,9 +15,11 @@ import {
FormControl, FormControl,
FormGroup, FormGroup,
ValidationErrors, ValidationErrors,
ValidatorFn,
Validators Validators
} from '@angular/forms'; } from '@angular/forms';
import { MatDialogRef } from '@angular/material/dialog'; import { MatDialogRef } from '@angular/material/dialog';
import { isISO4217CurrencyCode } from 'class-validator';
import { uniq } from 'lodash'; import { uniq } from 'lodash';
import { Subject, takeUntil } from 'rxjs'; import { Subject, takeUntil } from 'rxjs';
@ -52,9 +54,7 @@ export class CreateAssetProfileDialog implements OnInit, OnDestroy {
this.createAssetProfileForm = this.formBuilder.group( this.createAssetProfileForm = this.formBuilder.group(
{ {
addCurrency: new FormControl(null, [ addCurrency: new FormControl(null, [
Validators.maxLength(3), this.iso4217CurrencyCodeValidator()
Validators.minLength(3),
Validators.required
]), ]),
addSymbol: new FormControl(null, [Validators.required]), addSymbol: new FormControl(null, [Validators.required]),
searchSymbol: new FormControl(null, [Validators.required]) searchSymbol: new FormControl(null, [Validators.required])
@ -83,11 +83,11 @@ export class CreateAssetProfileDialog implements OnInit, OnDestroy {
symbol: this.createAssetProfileForm.get('searchSymbol').value.symbol symbol: this.createAssetProfileForm.get('searchSymbol').value.symbol
}); });
} else if (this.mode === 'currency') { } else if (this.mode === 'currency') {
const currency = this.createAssetProfileForm const currency = (
.get('addCurrency') this.createAssetProfileForm.get('addCurrency').value as string
.value.toUpperCase(); ).toUpperCase();
const currencies = uniq([...this.customCurrencies, currency]); const currencies = uniq([...this.customCurrencies, currency]).sort();
this.dataService this.dataService
.putAdminSetting(PROPERTY_CURRENCIES, { .putAdminSetting(PROPERTY_CURRENCIES, {
@ -109,10 +109,7 @@ export class CreateAssetProfileDialog implements OnInit, OnDestroy {
const addCurrencyFormControl = const addCurrencyFormControl =
this.createAssetProfileForm.get('addCurrency'); this.createAssetProfileForm.get('addCurrency');
if ( if (addCurrencyFormControl.hasError('invalidCurrency')) {
addCurrencyFormControl.hasError('maxlength') ||
addCurrencyFormControl.hasError('minlength')
) {
return true; return true;
} }
@ -161,4 +158,14 @@ export class CreateAssetProfileDialog implements OnInit, OnDestroy {
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
}); });
} }
private iso4217CurrencyCodeValidator(): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
if (!isISO4217CurrencyCode(control.value?.toUpperCase())) {
return { invalidCurrency: true };
}
return null;
};
}
} }

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

@ -28,7 +28,6 @@ import {
formatDistanceToNowStrict, formatDistanceToNowStrict,
parseISO parseISO
} from 'date-fns'; } from 'date-fns';
import { uniq } from 'lodash';
import { StringValue } from 'ms'; import { StringValue } from 'ms';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators'; import { takeUntil } from 'rxjs/operators';
@ -122,24 +121,6 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
this.putAdminSetting({ key: PROPERTY_COUPONS, value: coupons }); this.putAdminSetting({ key: PROPERTY_COUPONS, value: coupons });
} }
public onAddCurrency() {
const currency = prompt($localize`Please add a currency:`);
if (currency) {
if (currency.length === 3) {
const currencies = uniq([
...this.customCurrencies,
currency.toUpperCase()
]);
this.putAdminSetting({ key: PROPERTY_CURRENCIES, value: currencies });
} else {
this.notificationService.alert({
title: $localize`${currency} is an invalid currency!`
});
}
}
}
public onChangeCouponDuration(aCouponDuration: StringValue) { public onChangeCouponDuration(aCouponDuration: StringValue) {
this.couponDuration = aCouponDuration; this.couponDuration = aCouponDuration;
} }

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

@ -95,16 +95,6 @@
</tr> </tr>
} }
</table> </table>
<div class="mt-2">
<button
color="primary"
mat-flat-button
(click)="onAddCurrency()"
>
<ion-icon class="mr-1" name="add-outline" />
<span i18n>Add Currency</span>
</button>
</div>
</div> </div>
</div> </div>
<div class="d-flex my-3"> <div class="d-flex my-3">

2
apps/client/src/app/components/admin-settings/ghostfolio-premium-api-dialog/ghostfolio-premium-api-dialog.component.ts

@ -3,7 +3,6 @@ import { DataService } from '@ghostfolio/client/services/data.service';
import { PROPERTY_API_KEY_GHOSTFOLIO } from '@ghostfolio/common/config'; import { PROPERTY_API_KEY_GHOSTFOLIO } from '@ghostfolio/common/config';
import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator'; import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator';
import { CommonModule } from '@angular/common';
import { Component, Inject } from '@angular/core'; import { Component, Inject } from '@angular/core';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { import {
@ -18,7 +17,6 @@ import { GhostfolioPremiumApiDialogParams } from './interfaces/interfaces';
@Component({ @Component({
imports: [ imports: [
CommonModule,
GfDialogFooterModule, GfDialogFooterModule,
GfDialogHeaderModule, GfDialogHeaderModule,
GfPremiumIndicatorComponent, GfPremiumIndicatorComponent,

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

@ -1,8 +1,7 @@
import { CreateTagDto } from '@ghostfolio/api/app/tag/create-tag.dto'; import { CreateTagDto } from '@ghostfolio/api/app/endpoints/tags/create-tag.dto';
import { UpdateTagDto } from '@ghostfolio/api/app/tag/update-tag.dto'; import { UpdateTagDto } from '@ghostfolio/api/app/endpoints/tags/update-tag.dto';
import { ConfirmationDialogType } from '@ghostfolio/client/core/notification/confirmation-dialog/confirmation-dialog.type'; import { ConfirmationDialogType } from '@ghostfolio/client/core/notification/confirmation-dialog/confirmation-dialog.type';
import { NotificationService } from '@ghostfolio/client/core/notification/notification.service'; import { NotificationService } from '@ghostfolio/client/core/notification/notification.service';
import { AdminService } from '@ghostfolio/client/services/admin.service';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
@ -49,7 +48,6 @@ export class AdminTagComponent implements OnInit, OnDestroy {
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
public constructor( public constructor(
private adminService: AdminService,
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService, private dataService: DataService,
private deviceService: DeviceDetectorService, private deviceService: DeviceDetectorService,
@ -106,7 +104,7 @@ export class AdminTagComponent implements OnInit, OnDestroy {
} }
private deleteTag(aId: string) { private deleteTag(aId: string) {
this.adminService this.dataService
.deleteTag(aId) .deleteTag(aId)
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe({ .subscribe({
@ -122,7 +120,7 @@ export class AdminTagComponent implements OnInit, OnDestroy {
} }
private fetchTags() { private fetchTags() {
this.adminService this.dataService
.fetchTags() .fetchTags()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe((tags) => { .subscribe((tags) => {
@ -154,7 +152,7 @@ export class AdminTagComponent implements OnInit, OnDestroy {
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe((tag: CreateTagDto | null) => { .subscribe((tag: CreateTagDto | null) => {
if (tag) { if (tag) {
this.adminService this.dataService
.postTag(tag) .postTag(tag)
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe({ .subscribe({
@ -190,7 +188,7 @@ export class AdminTagComponent implements OnInit, OnDestroy {
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe((tag: UpdateTagDto | null) => { .subscribe((tag: UpdateTagDto | null) => {
if (tag) { if (tag) {
this.adminService this.dataService
.putTag(tag) .putTag(tag)
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe({ .subscribe({

4
apps/client/src/app/components/admin-tag/create-or-update-tag-dialog/create-or-update-tag-dialog.component.ts

@ -1,5 +1,5 @@
import { CreateTagDto } from '@ghostfolio/api/app/tag/create-tag.dto'; import { CreateTagDto } from '@ghostfolio/api/app/endpoints/tags/create-tag.dto';
import { UpdateTagDto } from '@ghostfolio/api/app/tag/update-tag.dto'; import { UpdateTagDto } from '@ghostfolio/api/app/endpoints/tags/update-tag.dto';
import { validateObjectForForm } from '@ghostfolio/client/util/form.util'; import { validateObjectForForm } from '@ghostfolio/client/util/form.util';
import { import {

2
apps/client/src/app/components/asset-profile-icon/asset-profile-icon.component.ts

@ -1,4 +1,3 @@
import { CommonModule } from '@angular/common';
import { import {
CUSTOM_ELEMENTS_SCHEMA, CUSTOM_ELEMENTS_SCHEMA,
ChangeDetectionStrategy, ChangeDetectionStrategy,
@ -10,7 +9,6 @@ import { DataSource } from '@prisma/client';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule],
schemas: [CUSTOM_ELEMENTS_SCHEMA], schemas: [CUSTOM_ELEMENTS_SCHEMA],
selector: 'gf-asset-profile-icon', selector: 'gf-asset-profile-icon',
styleUrls: ['./asset-profile-icon.component.scss'], styleUrls: ['./asset-profile-icon.component.scss'],

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

@ -13,8 +13,10 @@ import {
LineChartItem, LineChartItem,
User User
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { GfActivitiesTableComponent } from '@ghostfolio/ui/activities-table'; import { GfActivitiesTableComponent } from '@ghostfolio/ui/activities-table';
import { GfDataProviderCreditsComponent } from '@ghostfolio/ui/data-provider-credits'; import { GfDataProviderCreditsComponent } from '@ghostfolio/ui/data-provider-credits';
import { GfHistoricalMarketDataEditorComponent } from '@ghostfolio/ui/historical-market-data-editor';
import { translate } from '@ghostfolio/ui/i18n'; import { translate } from '@ghostfolio/ui/i18n';
import { GfLineChartComponent } from '@ghostfolio/ui/line-chart'; import { GfLineChartComponent } from '@ghostfolio/ui/line-chart';
import { GfPortfolioProportionChartComponent } from '@ghostfolio/ui/portfolio-proportion-chart'; import { GfPortfolioProportionChartComponent } from '@ghostfolio/ui/portfolio-proportion-chart';
@ -44,11 +46,11 @@ import { SortDirection } from '@angular/material/sort';
import { MatTableDataSource } from '@angular/material/table'; import { MatTableDataSource } from '@angular/material/table';
import { MatTabsModule } from '@angular/material/tabs'; import { MatTabsModule } from '@angular/material/tabs';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { Account, Tag } from '@prisma/client'; import { Account, MarketData, Tag } from '@prisma/client';
import { format, isSameMonth, isToday, parseISO } from 'date-fns'; import { format, isSameMonth, isToday, parseISO } from 'date-fns';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators'; import { switchMap, takeUntil } from 'rxjs/operators';
import { HoldingDetailDialogParams } from './interfaces/interfaces'; import { HoldingDetailDialogParams } from './interfaces/interfaces';
@ -62,6 +64,7 @@ import { HoldingDetailDialogParams } from './interfaces/interfaces';
GfDataProviderCreditsComponent, GfDataProviderCreditsComponent,
GfDialogFooterModule, GfDialogFooterModule,
GfDialogHeaderModule, GfDialogHeaderModule,
GfHistoricalMarketDataEditorComponent,
GfLineChartComponent, GfLineChartComponent,
GfPortfolioProportionChartComponent, GfPortfolioProportionChartComponent,
GfTagsSelectorComponent, GfTagsSelectorComponent,
@ -96,9 +99,12 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
public dividendYieldPercentWithCurrencyEffect: number; public dividendYieldPercentWithCurrencyEffect: number;
public feeInBaseCurrency: number; public feeInBaseCurrency: number;
public firstBuyDate: string; public firstBuyDate: string;
public hasPermissionToCreateOwnTag: boolean;
public hasPermissionToReadMarketDataOfOwnAssetProfile: boolean;
public historicalDataItems: LineChartItem[]; public historicalDataItems: LineChartItem[];
public investment: number; public investment: number;
public investmentPrecision = 2; public investmentPrecision = 2;
public marketDataItems: MarketData[] = [];
public marketPrice: number; public marketPrice: number;
public maxPrice: number; public maxPrice: number;
public minPrice: number; public minPrice: number;
@ -150,7 +156,34 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
this.activityForm this.activityForm
.get('tags') .get('tags')
.valueChanges.pipe(takeUntil(this.unsubscribeSubject)) .valueChanges.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((tags) => { .subscribe((tags: Tag[]) => {
const newTag = tags.find(({ id }) => {
return id === undefined;
});
if (newTag && this.hasPermissionToCreateOwnTag) {
this.dataService
.postTag({ ...newTag, userId: this.user.id })
.pipe(
switchMap((createdTag) => {
return this.dataService.putHoldingTags({
dataSource: this.data.dataSource,
symbol: this.data.symbol,
tags: [
...tags.filter(({ id }) => {
return id !== undefined;
}),
createdTag
]
});
}),
switchMap(() => {
return this.userService.get(true);
}),
takeUntil(this.unsubscribeSubject)
)
.subscribe();
} else {
this.dataService this.dataService
.putHoldingTags({ .putHoldingTags({
tags, tags,
@ -159,6 +192,7 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
}) })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe(); .subscribe();
}
}); });
this.dataService this.dataService
@ -235,6 +269,14 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
this.feeInBaseCurrency = feeInBaseCurrency; this.feeInBaseCurrency = feeInBaseCurrency;
this.firstBuyDate = firstBuyDate; this.firstBuyDate = firstBuyDate;
this.hasPermissionToReadMarketDataOfOwnAssetProfile =
hasPermission(
this.user?.permissions,
permissions.readMarketDataOfOwnAssetProfile
) &&
SymbolProfile?.dataSource === 'MANUAL' &&
SymbolProfile?.userId === this.user?.id;
this.historicalDataItems = historicalData.map( this.historicalDataItems = historicalData.map(
({ averagePrice, date, marketPrice }) => { ({ averagePrice, date, marketPrice }) => {
this.benchmarkDataItems.push({ this.benchmarkDataItems.push({
@ -398,6 +440,10 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
} }
); );
if (this.hasPermissionToReadMarketDataOfOwnAssetProfile) {
this.fetchMarketData();
}
if (Number.isInteger(this.quantity)) { if (Number.isInteger(this.quantity)) {
this.quantityPrecision = 0; this.quantityPrecision = 0;
} else if (this.SymbolProfile?.assetSubClass === 'CRYPTOCURRENCY') { } else if (this.SymbolProfile?.assetSubClass === 'CRYPTOCURRENCY') {
@ -421,6 +467,11 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
if (state?.user) { if (state?.user) {
this.user = state.user; this.user = state.user;
this.hasPermissionToCreateOwnTag = hasPermission(
this.user.permissions,
permissions.createOwnTag
);
this.tagsAvailable = this.tagsAvailable =
this.user?.tags?.map((tag) => { this.user?.tags?.map((tag) => {
return { return {
@ -466,6 +517,12 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
}); });
} }
public onMarketDataChanged(withRefresh = false) {
if (withRefresh) {
this.fetchMarketData();
}
}
public onTagsChanged(tags: Tag[]) { public onTagsChanged(tags: Tag[]) {
this.activityForm.get('tags').setValue(tags); this.activityForm.get('tags').setValue(tags);
} }
@ -482,4 +539,27 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
this.unsubscribeSubject.next(); this.unsubscribeSubject.next();
this.unsubscribeSubject.complete(); this.unsubscribeSubject.complete();
} }
private fetchMarketData() {
this.dataService
.fetchMarketDataBySymbol({
dataSource: this.data.dataSource,
symbol: this.data.symbol
})
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ marketData }) => {
this.marketDataItems = marketData;
this.historicalDataItems = this.marketDataItems.map(
({ date, marketPrice }) => {
return {
date: format(date, DATE_FORMAT),
value: marketPrice
};
}
);
this.changeDetectorRef.markForCheck();
});
}
} }

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

@ -368,9 +368,33 @@
[showValueInBaseCurrency]="false" [showValueInBaseCurrency]="false"
/> />
</mat-tab> </mat-tab>
@if (
hasPermissionToReadMarketDataOfOwnAssetProfile &&
user?.settings?.isExperimentalFeatures
) {
<mat-tab>
<ng-template mat-tab-label>
<ion-icon name="server-outline" />
<div class="d-none d-sm-block ml-2" i18n>Market Data</div>
</ng-template>
<gf-historical-market-data-editor
[currency]="SymbolProfile?.currency"
[dataSource]="SymbolProfile?.dataSource"
[dateOfFirstActivity]="firstBuyDate"
[locale]="data.locale"
[marketData]="marketDataItems"
[symbol]="SymbolProfile?.symbol"
[user]="user"
(marketDataChanged)="onMarketDataChanged($event)"
/>
</mat-tab>
}
</mat-tab-group> </mat-tab-group>
<gf-tags-selector <gf-tags-selector
[hasPermissionToCreateTag]="
hasPermissionToCreateOwnTag && user?.settings?.isExperimentalFeatures
"
[readonly]="!data.hasPermissionToUpdateOrder" [readonly]="!data.hasPermissionToUpdateOrder"
[tags]="activityForm.get('tags')?.value" [tags]="activityForm.get('tags')?.value"
[tagsAvailable]="tagsAvailable" [tagsAvailable]="tagsAvailable"

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

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

2
apps/client/src/app/components/home-overview/home-overview.html

@ -1,7 +1,7 @@
<div <div
class="align-items-center container d-flex flex-column h-100 justify-content-center overview p-0 position-relative" class="align-items-center container d-flex flex-column h-100 justify-content-center overview p-0 position-relative"
> >
@if (hasPermissionToCreateOrder && historicalDataItems?.length === 0) { @if (hasPermissionToCreateOrder && user?.activitiesCount === 0) {
<div class="justify-content-center row w-100"> <div class="justify-content-center row w-100">
<div class="col introduction"> <div class="col introduction">
<h4 i18n>Welcome to Ghostfolio</h4> <h4 i18n>Welcome to Ghostfolio</h4>

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

@ -1,6 +1,5 @@
import { XRayRulesSettings } from '@ghostfolio/common/interfaces'; import { XRayRulesSettings } from '@ghostfolio/common/interfaces';
import { CommonModule } from '@angular/common';
import { Component, Inject } from '@angular/core'; import { Component, Inject } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
@ -14,13 +13,7 @@ import { MatSliderModule } from '@angular/material/slider';
import { IRuleSettingsDialogParams } from './interfaces/interfaces'; import { IRuleSettingsDialogParams } from './interfaces/interfaces';
@Component({ @Component({
imports: [ imports: [FormsModule, MatButtonModule, MatDialogModule, MatSliderModule],
CommonModule,
FormsModule,
MatButtonModule,
MatDialogModule,
MatSliderModule
],
selector: 'gf-rule-settings-dialog', selector: 'gf-rule-settings-dialog',
styleUrls: ['./rule-settings-dialog.scss'], styleUrls: ['./rule-settings-dialog.scss'],
templateUrl: './rule-settings-dialog.html' templateUrl: './rule-settings-dialog.html'

2
apps/client/src/app/components/rule/rule.component.scss

@ -2,7 +2,7 @@
display: block; display: block;
.icon-container { .icon-container {
background-color: rgba(var(--dark-primary-text), 0.05); background-color: rgba(var(--palette-foreground-base), 0.02);
border-radius: 0.25rem; border-radius: 0.25rem;
height: 2rem; height: 2rem;

4
apps/client/src/app/core/http-response.interceptor.ts

@ -108,12 +108,14 @@ export class HttpResponseInterceptor implements HttpInterceptor {
}); });
} }
} else if (error.status === StatusCodes.UNAUTHORIZED) { } else if (error.status === StatusCodes.UNAUTHORIZED) {
if (!error.url.includes('/data-providers/ghostfolio/status')) {
if (this.webAuthnService.isEnabled()) { if (this.webAuthnService.isEnabled()) {
this.router.navigate(['/webauthn']); this.router.navigate(['/webauthn']);
} else if (!error.url.includes('/data-providers/ghostfolio/status')) { } else {
this.tokenStorageService.signOut(); this.tokenStorageService.signOut();
} }
} }
}
return throwError(error); return throwError(error);
}) })

3
apps/client/src/app/core/notification/alert-dialog/alert-dialog.component.ts

@ -1,4 +1,3 @@
import { CommonModule } from '@angular/common';
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatDialogModule, MatDialogRef } from '@angular/material/dialog'; import { MatDialogModule, MatDialogRef } from '@angular/material/dialog';
@ -6,7 +5,7 @@ import { MatDialogModule, MatDialogRef } from '@angular/material/dialog';
import { IAlertDialogParams } from './interfaces/interfaces'; import { IAlertDialogParams } from './interfaces/interfaces';
@Component({ @Component({
imports: [CommonModule, MatButtonModule, MatDialogModule], imports: [MatButtonModule, MatDialogModule],
selector: 'gf-alert-dialog', selector: 'gf-alert-dialog',
styleUrls: ['./alert-dialog.scss'], styleUrls: ['./alert-dialog.scss'],
templateUrl: './alert-dialog.html' templateUrl: './alert-dialog.html'

3
apps/client/src/app/core/notification/confirmation-dialog/confirmation-dialog.component.ts

@ -1,4 +1,3 @@
import { CommonModule } from '@angular/common';
import { Component, HostListener } from '@angular/core'; import { Component, HostListener } from '@angular/core';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatDialogModule, MatDialogRef } from '@angular/material/dialog'; import { MatDialogModule, MatDialogRef } from '@angular/material/dialog';
@ -7,7 +6,7 @@ import { ConfirmationDialogType } from './confirmation-dialog.type';
import { IConfirmDialogParams } from './interfaces/interfaces'; import { IConfirmDialogParams } from './interfaces/interfaces';
@Component({ @Component({
imports: [CommonModule, MatButtonModule, MatDialogModule], imports: [MatButtonModule, MatDialogModule],
selector: 'gf-confirmation-dialog', selector: 'gf-confirmation-dialog',
styleUrls: ['./confirmation-dialog.scss'], styleUrls: ['./confirmation-dialog.scss'],
templateUrl: './confirmation-dialog.html' templateUrl: './confirmation-dialog.html'

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

@ -1,4 +1,3 @@
import { CommonModule } from '@angular/common';
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
@ -8,7 +7,6 @@ import { MatInputModule } from '@angular/material/input';
@Component({ @Component({
imports: [ imports: [
CommonModule,
FormsModule, FormsModule,
MatButtonModule, MatButtonModule,
MatDialogModule, MatDialogModule,

20
apps/client/src/app/pages/about/overview/about-overview-page.html

@ -73,6 +73,14 @@
>. >.
</p> </p>
<p class="align-items-center d-flex justify-content-center"> <p class="align-items-center d-flex justify-content-center">
<a
class="mx-2"
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
mat-icon-button
title="Join the Ghostfolio Slack community"
>
<ion-icon name="logo-slack" />
</a>
<a <a
class="mx-2" class="mx-2"
href="https://x.com/ghostfolio_" href="https://x.com/ghostfolio_"
@ -93,19 +101,19 @@
} }
<a <a
class="mx-2" class="mx-2"
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg" href="https://github.com/ghostfolio/ghostfolio"
mat-icon-button mat-icon-button
title="Join the Ghostfolio Slack community" title="Find Ghostfolio on GitHub"
> >
<ion-icon name="logo-slack" /> <ion-icon name="logo-github" />
</a> </a>
<a <a
class="mx-2" class="mx-2"
href="https://github.com/ghostfolio/ghostfolio" href="https://linkedin.com/company/ghostfolio"
mat-icon-button mat-icon-button
title="Find Ghostfolio on GitHub" title="Follow Ghostfolio on LinkedIn"
> >
<ion-icon name="logo-github" /> <ion-icon name="logo-linkedin" />
</a> </a>
</p> </p>
@if (hasPermissionForSubscription) { @if (hasPermissionForSubscription) {

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

@ -40,8 +40,8 @@ export class GfApiPageComponent implements OnInit {
this.apiKey = prompt($localize`Please enter your Ghostfolio API key:`); this.apiKey = prompt($localize`Please enter your Ghostfolio API key:`);
this.dividends$ = this.fetchDividends({ symbol: 'KO' }); this.dividends$ = this.fetchDividends({ symbol: 'KO' });
this.historicalData$ = this.fetchHistoricalData({ symbol: 'AAPL.US' }); this.historicalData$ = this.fetchHistoricalData({ symbol: 'AAPL' });
this.quotes$ = this.fetchQuotes({ symbols: ['AAPL.US', 'VOO.US'] }); this.quotes$ = this.fetchQuotes({ symbols: ['AAPL', 'VOO.US'] });
this.status$ = this.fetchStatus(); this.status$ = this.fetchStatus();
this.symbols$ = this.fetchSymbols({ query: 'apple' }); this.symbols$ = this.fetchSymbols({ query: 'apple' });
} }

5
apps/client/src/app/pages/faq/self-hosting/self-hosting-page.html

@ -56,6 +56,11 @@
<li>Click on the <i>+</i> button</li> <li>Click on the <i>+</i> button</li>
<li>Switch to <i>Add Currency</i></li> <li>Switch to <i>Add Currency</i></li>
<li>Insert e.g. <code>EUR</code> for Euro</li> <li>Insert e.g. <code>EUR</code> for Euro</li>
<li>Select <i>Filter by Currencies</i></li>
<li>Find the entry <i>USDEUR</i></li>
<li>
Click the menu item <i>Gather Historical Data</i> in the dialog
</li>
</ol> </ol>
</mat-card-content> </mat-card-content>
</mat-card> </mat-card>

30
apps/client/src/app/pages/portfolio/activities/activities-page.component.ts

@ -125,7 +125,10 @@ export class ActivitiesPageComponent implements OnDestroy, OnInit {
this.dataSource = new MatTableDataSource(activities); this.dataSource = new MatTableDataSource(activities);
this.totalItems = count; this.totalItems = count;
if (this.hasPermissionToCreateActivity && this.totalItems <= 0) { if (
this.hasPermissionToCreateActivity &&
this.user?.activitiesCount === 0
) {
this.router.navigate([], { queryParams: { createDialog: true } }); this.router.navigate([], { queryParams: { createDialog: true } });
} }
@ -160,6 +163,11 @@ export class ActivitiesPageComponent implements OnDestroy, OnInit {
}) })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => { .subscribe(() => {
this.userService
.get(true)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe();
this.fetchActivities(); this.fetchActivities();
}); });
} }
@ -169,6 +177,11 @@ export class ActivitiesPageComponent implements OnDestroy, OnInit {
.deleteActivity(aId) .deleteActivity(aId)
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => { .subscribe(() => {
this.userService
.get(true)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe();
this.fetchActivities(); this.fetchActivities();
}); });
} }
@ -230,6 +243,11 @@ export class ActivitiesPageComponent implements OnDestroy, OnInit {
.afterClosed() .afterClosed()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => { .subscribe(() => {
this.userService
.get(true)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe();
this.fetchActivities(); this.fetchActivities();
}); });
} }
@ -248,6 +266,11 @@ export class ActivitiesPageComponent implements OnDestroy, OnInit {
.afterClosed() .afterClosed()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => { .subscribe(() => {
this.userService
.get(true)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe();
this.fetchActivities(); this.fetchActivities();
}); });
} }
@ -333,6 +356,11 @@ export class ActivitiesPageComponent implements OnDestroy, OnInit {
if (transaction) { if (transaction) {
this.dataService.postOrder(transaction).subscribe({ this.dataService.postOrder(transaction).subscribe({
next: () => { next: () => {
this.userService
.get(true)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe();
this.fetchActivities(); this.fetchActivities();
} }
}); });

1
apps/client/src/app/pages/portfolio/activities/activities-page.html

@ -6,6 +6,7 @@
[baseCurrency]="user?.settings?.baseCurrency" [baseCurrency]="user?.settings?.baseCurrency"
[dataSource]="dataSource" [dataSource]="dataSource"
[deviceType]="deviceType" [deviceType]="deviceType"
[hasActivities]="user?.activitiesCount > 0"
[hasPermissionToCreateActivity]="hasPermissionToCreateActivity" [hasPermissionToCreateActivity]="hasPermissionToCreateActivity"
[hasPermissionToDeleteActivity]="hasPermissionToDeleteActivity" [hasPermissionToDeleteActivity]="hasPermissionToDeleteActivity"
[hasPermissionToExportActivities]="!hasImpersonationId" [hasPermissionToExportActivities]="!hasImpersonationId"

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

@ -268,6 +268,7 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
this.platforms = {}; this.platforms = {};
this.portfolioDetails = { this.portfolioDetails = {
accounts: {}, accounts: {},
createdAt: undefined,
holdings: {}, holdings: {},
platforms: {}, platforms: {},
summary: undefined summary: undefined

11
apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts

@ -12,7 +12,11 @@ import {
User User
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { DateRange, GroupBy } from '@ghostfolio/common/types'; import type {
AiPromptMode,
DateRange,
GroupBy
} from '@ghostfolio/common/types';
import { translate } from '@ghostfolio/ui/i18n'; import { translate } from '@ghostfolio/ui/i18n';
import { Clipboard } from '@angular/cdk/clipboard'; import { Clipboard } from '@angular/cdk/clipboard';
@ -169,9 +173,9 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
this.fetchDividendsAndInvestments(); this.fetchDividendsAndInvestments();
} }
public onCopyPromptToClipboard() { public onCopyPromptToClipboard(mode: AiPromptMode) {
this.dataService this.dataService
.fetchPrompt() .fetchPrompt(mode)
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ prompt }) => { .subscribe(({ prompt }) => {
this.clipboard.copy(prompt); this.clipboard.copy(prompt);
@ -349,6 +353,7 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
.fetchBenchmarkForUser({ .fetchBenchmarkForUser({
dataSource, dataSource,
symbol, symbol,
filters: this.userService.getFilters(),
range: this.user?.settings?.dateRange, range: this.user?.settings?.dateRange,
startDate: this.firstOrderDate startDate: this.firstOrderDate
}) })

22
apps/client/src/app/pages/portfolio/analysis/analysis-page.html

@ -16,7 +16,7 @@
<button <button
mat-menu-item mat-menu-item
[disabled]="!hasPermissionToReadAiPrompt" [disabled]="!hasPermissionToReadAiPrompt"
(click)="onCopyPromptToClipboard()" (click)="onCopyPromptToClipboard('portfolio')"
> >
<span class="align-items-center d-flex"> <span class="align-items-center d-flex">
@if (user?.subscription?.type === 'Basic') { @if (user?.subscription?.type === 'Basic') {
@ -24,7 +24,25 @@
} @else { } @else {
<ion-icon class="mr-2" name="copy-outline" /> <ion-icon class="mr-2" name="copy-outline" />
} }
<ng-container i18n>Copy AI prompt to clipboard</ng-container> <ng-container i18n
>Copy portfolio data to clipboard for AI prompt</ng-container
>
</span>
</button>
<button
mat-menu-item
[disabled]="!hasPermissionToReadAiPrompt"
(click)="onCopyPromptToClipboard('analysis')"
>
<span class="align-items-center d-flex">
@if (user?.subscription?.type === 'Basic') {
<gf-premium-indicator class="mr-2" />
} @else {
<ion-icon class="mr-2" name="copy-outline" />
}
<ng-container i18n
>Copy AI prompt to clipboard for analysis</ng-container
>
</span> </span>
</button> </button>
</mat-menu> </mat-menu>

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

@ -7,23 +7,58 @@
risks in your portfolio. Adjust the rules below and set custom risks in your portfolio. Adjust the rules below and set custom
thresholds to align with your personal investment strategy. thresholds to align with your personal investment strategy.
</p> </p>
<p class="mb-4"> <div class="d-flex pb-3">
@if (isLoading) { @if (isLoading) {
<div class="d-flex">
<ngx-skeleton-loader <ngx-skeleton-loader
animation="pulse" animation="pulse"
class="w-100" class="mr-2"
[theme]="{
height: '2rem',
width: '2rem'
}"
/>
<ngx-skeleton-loader
animation="pulse"
class="mt-2 mb-1"
[theme]="{ [theme]="{
height: '1rem', height: '1rem',
width: '100%' width: '15rem'
}" }"
/> />
</div>
} @else {
<div
class="align-items-center d-flex icon-container mr-2 px-2"
[ngClass]="{
okay:
statistics?.rulesFulfilledCount ===
statistics?.rulesActiveCount,
warn:
statistics?.rulesFulfilledCount !== statistics?.rulesActiveCount
}"
>
@if (
statistics?.rulesActiveCount === 0 ||
statistics?.rulesFulfilledCount === 0
) {
<ion-icon class="text-muted" name="remove-circle-outline" />
} @else if (
statistics?.rulesFulfilledCount === statistics?.rulesActiveCount
) {
<ion-icon name="checkmark-circle-outline" />
} @else { } @else {
{{ statistics?.rulesFulfilledCount }} <ion-icon name="warning-outline" />
}
</div>
<div class="d-flex align-items-center">
<ng-container>{{ statistics?.rulesFulfilledCount }} </ng-container>
<ng-container i18n>out of</ng-container> <ng-container i18n>out of</ng-container>
{{ statistics?.rulesActiveCount }} {{ statistics?.rulesActiveCount }}
<ng-container i18n>rules align with your portfolio.</ng-container> <ng-container i18n>rules align with your portfolio.</ng-container>
</div>
} }
</p> </div>
<div <div
class="mb-4" class="mb-4"
[ngClass]="{ [ngClass]="{

14
apps/client/src/app/pages/portfolio/x-ray/x-ray-page.component.scss

@ -1,3 +1,17 @@
:host { :host {
display: block; display: block;
.icon-container {
background-color: rgba(var(--palette-foreground-base), 0.02);
border-radius: 0.25rem;
height: 2rem;
&.okay {
color: var(--success);
}
&.warn {
color: var(--danger);
}
}
} }

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

@ -7,7 +7,6 @@ import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { Component, OnDestroy, OnInit } from '@angular/core'; import { Component, OnDestroy, OnInit } from '@angular/core';
import { MatDialog } from '@angular/material/dialog'; import { MatDialog } from '@angular/material/dialog';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { Role } from '@prisma/client';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators'; import { takeUntil } from 'rxjs/operators';
@ -59,15 +58,6 @@ export class RegisterPageComponent implements OnDestroy, OnInit {
); );
} }
public async createAccount() {
this.dataService
.postUser()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ accessToken, authToken, role }) => {
this.openShowAccessTokenDialog(accessToken, authToken, role);
});
}
public async onLoginWithInternetIdentity() { public async onLoginWithInternetIdentity() {
try { try {
const { authToken } = await this.internetIdentityService.login(); const { authToken } = await this.internetIdentityService.login();
@ -76,17 +66,8 @@ export class RegisterPageComponent implements OnDestroy, OnInit {
} catch {} } catch {}
} }
public openShowAccessTokenDialog( public openShowAccessTokenDialog() {
accessToken: string,
authToken: string,
role: Role
) {
const dialogRef = this.dialog.open(ShowAccessTokenDialog, { const dialogRef = this.dialog.open(ShowAccessTokenDialog, {
data: {
accessToken,
authToken,
role
},
disableClose: true, disableClose: true,
width: '30rem' width: '30rem'
}); });
@ -94,8 +75,8 @@ export class RegisterPageComponent implements OnDestroy, OnInit {
dialogRef dialogRef
.afterClosed() .afterClosed()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe((data) => { .subscribe((authToken) => {
if (data?.authToken) { if (authToken) {
this.tokenStorageService.saveToken(authToken, true); this.tokenStorageService.saveToken(authToken, true);
this.router.navigate(['/']); this.router.navigate(['/']);

2
apps/client/src/app/pages/register/register-page.html

@ -22,7 +22,7 @@
class="d-inline-block" class="d-inline-block"
color="primary" color="primary"
mat-flat-button mat-flat-button
(click)="createAccount()" (click)="openShowAccessTokenDialog()"
> >
<ng-container i18n>Create Account</ng-container> <ng-container i18n>Create Account</ng-container>
</button> </button>

57
apps/client/src/app/pages/register/show-access-token-dialog/show-access-token-dialog.component.ts

@ -1,19 +1,58 @@
import { ChangeDetectionStrategy, Component, Inject } from '@angular/core'; import { DataService } from '@ghostfolio/client/services/data.service';
import { MAT_DIALOG_DATA } from '@angular/material/dialog';
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
ViewChild
} from '@angular/core';
import { MatStepper } from '@angular/material/stepper';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@Component({ @Component({
selector: 'gf-show-access-token-dialog',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'gf-show-access-token-dialog',
standalone: false,
styleUrls: ['./show-access-token-dialog.scss'], styleUrls: ['./show-access-token-dialog.scss'],
templateUrl: 'show-access-token-dialog.html', templateUrl: 'show-access-token-dialog.html'
standalone: false
}) })
export class ShowAccessTokenDialog { export class ShowAccessTokenDialog {
public isAgreeButtonDisabled = true; @ViewChild(MatStepper) stepper!: MatStepper;
public accessToken: string;
public authToken: string;
public isCreateAccountButtonDisabled = true;
public isDisclaimerChecked = false;
public role: string;
private unsubscribeSubject = new Subject<void>();
public constructor(
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService
) {}
public constructor(@Inject(MAT_DIALOG_DATA) public data: any) {} public createAccount() {
this.dataService
.postUser()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ accessToken, authToken, role }) => {
this.accessToken = accessToken;
this.authToken = authToken;
this.role = role;
this.stepper.next();
this.changeDetectorRef.markForCheck();
});
}
public enableCreateAccountButton() {
this.isCreateAccountButtonDisabled = false;
}
public enableAgreeButton() { public onChangeDislaimerChecked() {
this.isAgreeButtonDisabled = false; this.isDisclaimerChecked = !this.isDisclaimerChecked;
} }
} }

89
apps/client/src/app/pages/register/show-access-token-dialog/show-access-token-dialog.html

@ -1,48 +1,93 @@
<h1 mat-dialog-title> <h1 mat-dialog-title>
<span i18n>Create Account</span> <span i18n>Create Account</span>
@if (data.role === 'ADMIN') { @if (role === 'ADMIN') {
<span class="badge badge-light ml-2">{{ data.role }}</span> <span class="badge badge-light ml-2">{{ role }}</span>
} }
</h1> </h1>
<div class="py-3" mat-dialog-content> <div class="px-0" mat-dialog-content>
<mat-stepper #stepper animationDuration="0ms" linear>
<mat-step editable="false" [completed]="isDisclaimerChecked">
<ng-template i18n matStepLabel>Terms and Conditions</ng-template>
<div class="pt-2">
<ng-container i18n
>Please keep your security token safe. If you lose it, you will not be
able to recover your account.</ng-container
>
</div>
<mat-checkbox
class="pt-2"
color="primary"
(change)="onChangeDislaimerChecked()"
>
<ng-container i18n
>I understand that if I lose my security token, I cannot recover my
account.</ng-container
>
</mat-checkbox>
<div class="mt-3" mat-dialog-actions>
<div class="flex-grow-1">
<button i18n mat-button [mat-dialog-close]="undefined">Cancel</button>
</div>
<div> <div>
<mat-form-field appearance="outline" class="w-100"> <button
color="primary"
mat-flat-button
[disabled]="!isDisclaimerChecked"
(click)="createAccount()"
>
<ng-container i18n>Continue</ng-container>
</button>
</div>
</div>
</mat-step>
<mat-step editable="false">
<ng-template i18n matStepLabel>Security Token</ng-template>
<div class="pt-2">
<ng-container i18n
>Here is your security token. It is only visible once, please store
and keep it in a safe place.</ng-container
>
</div>
<mat-form-field appearance="outline" class="pt-3 w-100 without-hint">
<mat-label i18n>Security Token</mat-label> <mat-label i18n>Security Token</mat-label>
<textarea <textarea
cdkTextareaAutosize cdkTextareaAutosize
matInput matInput
readonly readonly
type="text" type="text"
[(value)]="data.accessToken" [(value)]="accessToken"
></textarea> ></textarea>
<div class="float-right mt-3"> <div class="float-right mt-1">
<button <button
color="secondary" color="secondary"
mat-flat-button mat-flat-button
[cdkCopyToClipboard]="data.accessToken" [cdkCopyToClipboard]="accessToken"
(click)="enableAgreeButton()" (click)="enableCreateAccountButton()"
>
<ion-icon class="mr-1" name="copy-outline" /><span i18n
>Copy to clipboard</span
> >
<ion-icon class="mr-1" name="copy-outline" />
<span i18n>Copy to clipboard</span>
</button> </button>
</div> </div>
</mat-form-field> </mat-form-field>
</div> <div align="end" class="mt-1" mat-dialog-actions>
<p i18n> <div>
I agree to have stored my <i>Security Token</i> from above in a secure
place. If I lose it, I cannot get my account back.
</p>
</div>
<div class="justify-content-end" mat-dialog-actions>
<button i18n mat-flat-button [mat-dialog-close]="undefined">Cancel</button>
<button <button
color="primary" color="primary"
mat-flat-button mat-flat-button
[disabled]="isAgreeButtonDisabled" matStepperNext
[mat-dialog-close]="data" [disabled]="isCreateAccountButtonDisabled"
[mat-dialog-close]="authToken"
> >
<span i18n>Agree and continue</span> <span i18n>Create Account</span>
<ion-icon class="ml-1" name="arrow-forward-outline" /> <ion-icon class="ml-1" name="arrow-forward-outline" />
</button> </button>
</div>
</div>
</mat-step>
<ng-template matStepperIcon="done">
<ion-icon name="checkmark-outline"></ion-icon>
</ng-template>
</mat-stepper>
<div></div>
</div> </div>

4
apps/client/src/app/pages/register/show-access-token-dialog/show-access-token-dialog.module.ts

@ -4,9 +4,11 @@ import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatDialogModule } from '@angular/material/dialog'; import { MatDialogModule } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field'; import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input'; import { MatInputModule } from '@angular/material/input';
import { MatStepperModule } from '@angular/material/stepper';
import { ShowAccessTokenDialog } from './show-access-token-dialog.component'; import { ShowAccessTokenDialog } from './show-access-token-dialog.component';
@ -17,9 +19,11 @@ import { ShowAccessTokenDialog } from './show-access-token-dialog.component';
CommonModule, CommonModule,
FormsModule, FormsModule,
MatButtonModule, MatButtonModule,
MatCheckboxModule,
MatDialogModule, MatDialogModule,
MatFormFieldModule, MatFormFieldModule,
MatInputModule, MatInputModule,
MatStepperModule,
ReactiveFormsModule, ReactiveFormsModule,
TextFieldModule TextFieldModule
], ],

4
apps/client/src/app/pages/register/show-access-token-dialog/show-access-token-dialog.scss

@ -1,2 +1,6 @@
:host { :host {
.mat-mdc-dialog-actions {
padding-left: 0 !important;
padding-right: 0 !important;
}
} }

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

@ -3,14 +3,13 @@ import { Product } from '@ghostfolio/common/interfaces';
import { personalFinanceTools } from '@ghostfolio/common/personal-finance-tools'; import { personalFinanceTools } from '@ghostfolio/common/personal-finance-tools';
import { translate } from '@ghostfolio/ui/i18n'; import { translate } from '@ghostfolio/ui/i18n';
import { CommonModule } from '@angular/common';
import { Component, OnInit } from '@angular/core'; import { Component, OnInit } from '@angular/core';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { ActivatedRoute, RouterModule } from '@angular/router'; import { ActivatedRoute, RouterModule } from '@angular/router';
@Component({ @Component({
host: { class: 'page' }, host: { class: 'page' },
imports: [CommonModule, MatButtonModule, RouterModule], imports: [MatButtonModule, RouterModule],
selector: 'gf-product-page', selector: 'gf-product-page',
styleUrls: ['./product-page.scss'], styleUrls: ['./product-page.scss'],
templateUrl: './product-page.html' templateUrl: './product-page.html'

20
apps/client/src/app/services/admin.service.ts

@ -1,8 +1,6 @@
import { UpdateAssetProfileDto } from '@ghostfolio/api/app/admin/update-asset-profile.dto'; import { UpdateAssetProfileDto } from '@ghostfolio/api/app/admin/update-asset-profile.dto';
import { CreatePlatformDto } from '@ghostfolio/api/app/platform/create-platform.dto'; import { CreatePlatformDto } from '@ghostfolio/api/app/platform/create-platform.dto';
import { UpdatePlatformDto } from '@ghostfolio/api/app/platform/update-platform.dto'; import { UpdatePlatformDto } from '@ghostfolio/api/app/platform/update-platform.dto';
import { CreateTagDto } from '@ghostfolio/api/app/tag/create-tag.dto';
import { UpdateTagDto } from '@ghostfolio/api/app/tag/update-tag.dto';
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces'; import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
import { import {
HEADER_KEY_SKIP_INTERCEPTOR, HEADER_KEY_SKIP_INTERCEPTOR,
@ -25,7 +23,7 @@ import {
import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http'; import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { SortDirection } from '@angular/material/sort'; import { SortDirection } from '@angular/material/sort';
import { DataSource, MarketData, Platform, Tag } from '@prisma/client'; import { DataSource, MarketData, Platform } from '@prisma/client';
import { JobStatus } from 'bull'; import { JobStatus } from 'bull';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { switchMap } from 'rxjs'; import { switchMap } from 'rxjs';
@ -75,10 +73,6 @@ export class AdminService {
); );
} }
public deleteTag(aId: string) {
return this.http.delete<void>(`/api/v1/tag/${aId}`);
}
public executeJob(aId: string) { public executeJob(aId: string) {
return this.http.get<void>(`/api/v1/admin/queue/job/${aId}/execute`); return this.http.get<void>(`/api/v1/admin/queue/job/${aId}/execute`);
} }
@ -155,10 +149,6 @@ export class AdminService {
return this.http.get<Platform[]>('/api/v1/platform'); return this.http.get<Platform[]>('/api/v1/platform');
} }
public fetchTags() {
return this.http.get<Tag[]>('/api/v1/tag');
}
public fetchUsers({ public fetchUsers({
skip, skip,
take = DEFAULT_PAGE_SIZE take = DEFAULT_PAGE_SIZE
@ -285,10 +275,6 @@ export class AdminService {
return this.http.post<Platform>(`/api/v1/platform`, aPlatform); return this.http.post<Platform>(`/api/v1/platform`, aPlatform);
} }
public postTag(aTag: CreateTagDto) {
return this.http.post<Tag>(`/api/v1/tag`, aTag);
}
public putPlatform(aPlatform: UpdatePlatformDto) { public putPlatform(aPlatform: UpdatePlatformDto) {
return this.http.put<Platform>( return this.http.put<Platform>(
`/api/v1/platform/${aPlatform.id}`, `/api/v1/platform/${aPlatform.id}`,
@ -296,10 +282,6 @@ export class AdminService {
); );
} }
public putTag(aTag: UpdateTagDto) {
return this.http.put<Tag>(`/api/v1/tag/${aTag.id}`, aTag);
}
public testMarketData({ public testMarketData({
dataSource, dataSource,
scraperConfiguration, scraperConfiguration,

49
apps/client/src/app/services/data.service.ts

@ -4,6 +4,8 @@ import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto
import { TransferBalanceDto } from '@ghostfolio/api/app/account/transfer-balance.dto'; import { TransferBalanceDto } from '@ghostfolio/api/app/account/transfer-balance.dto';
import { UpdateAccountDto } from '@ghostfolio/api/app/account/update-account.dto'; import { UpdateAccountDto } from '@ghostfolio/api/app/account/update-account.dto';
import { UpdateBulkMarketDataDto } from '@ghostfolio/api/app/admin/update-bulk-market-data.dto'; import { UpdateBulkMarketDataDto } from '@ghostfolio/api/app/admin/update-bulk-market-data.dto';
import { CreateTagDto } from '@ghostfolio/api/app/endpoints/tags/create-tag.dto';
import { UpdateTagDto } from '@ghostfolio/api/app/endpoints/tags/update-tag.dto';
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto'; import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { import {
Activities, Activities,
@ -44,7 +46,12 @@ import {
User User
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { filterGlobalPermissions } from '@ghostfolio/common/permissions'; import { filterGlobalPermissions } from '@ghostfolio/common/permissions';
import { AccountWithValue, DateRange, GroupBy } from '@ghostfolio/common/types'; import type {
AccountWithValue,
AiPromptMode,
DateRange,
GroupBy
} from '@ghostfolio/common/types';
import { translate } from '@ghostfolio/ui/i18n'; import { translate } from '@ghostfolio/ui/i18n';
import { HttpClient, HttpParams } from '@angular/common/http'; import { HttpClient, HttpParams } from '@angular/common/http';
@ -301,13 +308,17 @@ export class DataService {
} }
public deleteBenchmark({ dataSource, symbol }: AssetProfileIdentifier) { public deleteBenchmark({ dataSource, symbol }: AssetProfileIdentifier) {
return this.http.delete<any>(`/api/v1/benchmark/${dataSource}/${symbol}`); return this.http.delete<any>(`/api/v1/benchmarks/${dataSource}/${symbol}`);
} }
public deleteOwnUser(aData: DeleteOwnUserDto) { public deleteOwnUser(aData: DeleteOwnUserDto) {
return this.http.delete<any>(`/api/v1/user`, { body: aData }); return this.http.delete<any>(`/api/v1/user`, { body: aData });
} }
public deleteTag(aId: string) {
return this.http.delete<void>(`/api/v1/tags/${aId}`);
}
public deleteUser(aId: string) { public deleteUser(aId: string) {
return this.http.delete<any>(`/api/v1/user/${aId}`); return this.http.delete<any>(`/api/v1/user/${aId}`);
} }
@ -332,21 +343,27 @@ export class DataService {
public fetchBenchmarkForUser({ public fetchBenchmarkForUser({
dataSource, dataSource,
filters,
range, range,
startDate, startDate,
symbol symbol,
withExcludedAccounts
}: { }: {
filters?: Filter[];
range: DateRange; range: DateRange;
startDate: Date; startDate: Date;
withExcludedAccounts?: boolean;
} & AssetProfileIdentifier): Observable<BenchmarkMarketDataDetails> { } & AssetProfileIdentifier): Observable<BenchmarkMarketDataDetails> {
let params = new HttpParams(); let params = this.buildFiltersAsQueryParams({ filters });
if (range) {
params = params.append('range', range); params = params.append('range', range);
if (withExcludedAccounts) {
params = params.append('withExcludedAccounts', withExcludedAccounts);
} }
return this.http.get<BenchmarkMarketDataDetails>( return this.http.get<BenchmarkMarketDataDetails>(
`/api/v1/benchmark/${dataSource}/${symbol}/${format( `/api/v1/benchmarks/${dataSource}/${symbol}/${format(
startDate, startDate,
DATE_FORMAT DATE_FORMAT
)}`, )}`,
@ -355,7 +372,7 @@ export class DataService {
} }
public fetchBenchmarks() { public fetchBenchmarks() {
return this.http.get<BenchmarkResponse>('/api/v1/benchmark'); return this.http.get<BenchmarkResponse>('/api/v1/benchmarks');
} }
public fetchExport({ public fetchExport({
@ -649,8 +666,8 @@ export class DataService {
return this.http.get<PortfolioReportResponse>('/api/v1/portfolio/report'); return this.http.get<PortfolioReportResponse>('/api/v1/portfolio/report');
} }
public fetchPrompt() { public fetchPrompt(mode: AiPromptMode) {
return this.http.get<AiPromptResponse>('/api/v1/ai/prompt'); return this.http.get<AiPromptResponse>(`/api/v1/ai/prompt/${mode}`);
} }
public fetchPublicPortfolio(aAccessId: string) { public fetchPublicPortfolio(aAccessId: string) {
@ -673,6 +690,10 @@ export class DataService {
); );
} }
public fetchTags() {
return this.http.get<Tag[]>('/api/v1/tags');
}
public loginAnonymous(accessToken: string) { public loginAnonymous(accessToken: string) {
return this.http.post<OAuthResponse>('/api/v1/auth/anonymous', { return this.http.post<OAuthResponse>('/api/v1/auth/anonymous', {
accessToken accessToken
@ -699,7 +720,7 @@ export class DataService {
} }
public postBenchmark(benchmark: AssetProfileIdentifier) { public postBenchmark(benchmark: AssetProfileIdentifier) {
return this.http.post('/api/v1/benchmark', benchmark); return this.http.post('/api/v1/benchmarks', benchmark);
} }
public postMarketData({ public postMarketData({
@ -720,6 +741,10 @@ export class DataService {
return this.http.post<OrderModel>('/api/v1/order', aOrder); return this.http.post<OrderModel>('/api/v1/order', aOrder);
} }
public postTag(aTag: CreateTagDto) {
return this.http.post<Tag>(`/api/v1/tags`, aTag);
}
public postUser() { public postUser() {
return this.http.post<UserItem>('/api/v1/user', {}); return this.http.post<UserItem>('/api/v1/user', {});
} }
@ -747,6 +772,10 @@ export class DataService {
return this.http.put<UserItem>(`/api/v1/order/${aOrder.id}`, aOrder); return this.http.put<UserItem>(`/api/v1/order/${aOrder.id}`, aOrder);
} }
public putTag(aTag: UpdateTagDto) {
return this.http.put<Tag>(`/api/v1/tags/${aTag.id}`, aTag);
}
public putUserSetting(aData: UpdateUserSettingDto) { public putUserSetting(aData: UpdateUserSettingDto) {
return this.http.put<User>('/api/v1/user/setting', aData); return this.http.put<User>('/api/v1/user/setting', aData);
} }

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

Loading…
Cancel
Save