Browse Source

Merge branch 'main' into task/prevent-deletion-of-asset-profiles-in-use

pull/6942/head
Thomas Kaul 3 days ago
committed by GitHub
parent
commit
e60f4bb11f
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 12
      .vscode/launch.json
  2. 42
      CHANGELOG.md
  3. 6
      DEVELOPMENT.md
  4. 11
      apps/api/src/app/account/account.controller.ts
  5. 4
      apps/api/src/app/account/account.module.ts
  6. 4
      apps/api/src/app/admin/admin.controller.ts
  7. 56
      apps/api/src/app/admin/admin.service.ts
  8. 14
      apps/api/src/app/auth/auth.module.ts
  9. 4
      apps/api/src/app/auth/google.strategy.ts
  10. 9
      apps/api/src/app/auth/oidc.strategy.ts
  11. 6
      apps/api/src/app/auth/web-auth.service.ts
  12. 7
      apps/api/src/app/endpoints/benchmarks/benchmarks.service.ts
  13. 2
      apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.module.ts
  14. 15
      apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.service.ts
  15. 4
      apps/api/src/app/endpoints/market-data/market-data.controller.ts
  16. 4
      apps/api/src/app/health/health.controller.ts
  17. 4
      apps/api/src/app/import/import.controller.ts
  18. 2
      apps/api/src/app/logo/logo.module.ts
  19. 8
      apps/api/src/app/logo/logo.service.ts
  20. 7
      apps/api/src/app/portfolio/calculator/portfolio-calculator.ts
  21. 6
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts
  22. 55
      apps/api/src/app/portfolio/portfolio.controller.ts
  23. 272
      apps/api/src/app/portfolio/portfolio.service.spec.ts
  24. 48
      apps/api/src/app/portfolio/portfolio.service.ts
  25. 6
      apps/api/src/app/redis-cache/redis-cache.service.ts
  26. 14
      apps/api/src/app/subscription/subscription.controller.ts
  27. 9
      apps/api/src/app/subscription/subscription.service.ts
  28. 4
      apps/api/src/app/symbol/symbol.service.ts
  29. 31
      apps/api/src/app/user/user.service.ts
  30. 12
      apps/api/src/events/asset-profile-changed.listener.ts
  31. 7
      apps/api/src/events/portfolio-changed.listener.ts
  32. 4
      apps/api/src/interceptors/performance-logging/performance-logging.service.ts
  33. 22
      apps/api/src/main.ts
  34. 8
      apps/api/src/middlewares/html-template.middleware.ts
  35. 6
      apps/api/src/services/benchmark/benchmark.service.ts
  36. 42
      apps/api/src/services/data-provider/coingecko/coingecko.service.ts
  37. 3
      apps/api/src/services/data-provider/data-enhancer/data-enhancer.module.ts
  38. 13
      apps/api/src/services/data-provider/data-enhancer/openfigi/openfigi.service.ts
  39. 23
      apps/api/src/services/data-provider/data-enhancer/trackinsight/trackinsight.service.ts
  40. 6
      apps/api/src/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service.ts
  41. 2
      apps/api/src/services/data-provider/data-provider.module.ts
  42. 31
      apps/api/src/services/data-provider/data-provider.service.ts
  43. 56
      apps/api/src/services/data-provider/eod-historical-data/eod-historical-data.service.ts
  44. 90
      apps/api/src/services/data-provider/financial-modeling-prep/financial-modeling-prep.service.ts
  45. 24
      apps/api/src/services/data-provider/ghostfolio/ghostfolio.service.ts
  46. 4
      apps/api/src/services/data-provider/google-sheets/google-sheets.service.ts
  47. 13
      apps/api/src/services/data-provider/manual/manual.service.ts
  48. 19
      apps/api/src/services/data-provider/rapid-api/rapid-api.service.ts
  49. 23
      apps/api/src/services/data-provider/yahoo-finance/yahoo-finance.service.ts
  50. 19
      apps/api/src/services/exchange-rate-data/exchange-rate-data.service.ts
  51. 11
      apps/api/src/services/fetch/fetch.module.ts
  52. 199
      apps/api/src/services/fetch/fetch.service.ts
  53. 19
      apps/api/src/services/fetch/interfaces/web-fetch-route.interface.ts
  54. 8
      apps/api/src/services/i18n/i18n.service.ts
  55. 4
      apps/api/src/services/prisma/prisma.service.ts
  56. 42
      apps/api/src/services/queues/data-gathering/data-gathering.processor.ts
  57. 44
      apps/api/src/services/queues/data-gathering/data-gathering.service.ts
  58. 17
      apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.processor.ts
  59. 2
      apps/api/src/services/queues/statistics-gathering/statistics-gathering.module.ts
  60. 87
      apps/api/src/services/queues/statistics-gathering/statistics-gathering.processor.ts
  61. 58
      apps/api/src/services/symbol-profile/symbol-profile.service.ts
  62. 9
      apps/api/src/services/twitter-bot/twitter-bot.service.ts
  63. 3
      apps/client/src/app/components/admin-market-data/admin-market-data.component.ts
  64. 12
      apps/client/src/app/components/admin-market-data/admin-market-data.html
  65. 4
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.scss
  66. 12
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html
  67. 20
      apps/client/src/app/components/admin-users/admin-users.component.ts
  68. 10
      apps/client/src/app/components/home-watchlist/home-watchlist.component.ts
  69. 12
      apps/client/src/app/components/home-watchlist/home-watchlist.html
  70. 3
      apps/client/src/app/components/user-account-access/user-account-access.component.ts
  71. 12
      apps/client/src/app/components/user-account-access/user-account-access.html
  72. 12
      apps/client/src/app/core/auth.guard.ts
  73. 16
      apps/client/src/app/core/auth.interceptor.ts
  74. 45
      apps/client/src/app/core/http-response.interceptor.ts
  75. 18
      apps/client/src/app/directives/file-drop/file-drop.directive.ts
  76. 10
      apps/client/src/app/pages/accounts/accounts-page.component.ts
  77. 12
      apps/client/src/app/pages/accounts/accounts-page.html
  78. 11
      apps/client/src/app/pages/portfolio/activities/activities-page.component.ts
  79. 12
      apps/client/src/app/pages/portfolio/activities/activities-page.html
  80. 2
      apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts
  81. 3
      apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.component.ts
  82. 50
      apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts
  83. 145
      apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts
  84. 24
      apps/client/src/app/pages/portfolio/analysis/analysis-page.html
  85. 5
      apps/client/src/app/pages/public/public-page.component.ts
  86. 7
      apps/client/src/assets/oss-friends.json
  87. 94
      apps/client/src/locales/messages.uk.xlf
  88. 36
      apps/client/src/styles.scss
  89. 1
      libs/common/src/lib/config.ts
  90. 45
      libs/common/src/lib/helper.ts
  91. 47
      libs/common/src/lib/interfaces/portfolio-position.interface.ts
  92. 1
      libs/common/src/lib/interfaces/product.ts
  93. 382
      libs/common/src/lib/personal-finance-tools.ts
  94. 37
      libs/ui/src/lib/assistant/assistant.component.ts
  95. 9
      libs/ui/src/lib/fab/fab.component.html
  96. 14
      libs/ui/src/lib/fab/fab.component.scss
  97. 21
      libs/ui/src/lib/fab/fab.component.ts
  98. 1
      libs/ui/src/lib/fab/index.ts
  99. 200
      libs/ui/src/lib/mocks/holdings.ts
  100. 6
      libs/ui/src/lib/page-tabs/page-tabs.component.scss

12
.vscode/launch.json

@ -18,12 +18,20 @@
"autoAttachChildProcesses": true, "autoAttachChildProcesses": true,
"console": "integratedTerminal", "console": "integratedTerminal",
"cwd": "${workspaceFolder}/apps/api", "cwd": "${workspaceFolder}/apps/api",
"envFile": "${workspaceFolder}/.env", "env": {
"GHOSTFOLIO_ENV_FILE": "${workspaceFolder}/.env"
},
"name": "Debug API", "name": "Debug API",
"outFiles": ["${workspaceFolder}/dist/apps/api/**/*.js"], "outFiles": ["${workspaceFolder}/dist/apps/api/**/*.js"],
"program": "${workspaceFolder}/apps/api/src/main.ts", "program": "${workspaceFolder}/apps/api/src/main.ts",
"request": "launch", "request": "launch",
"runtimeArgs": ["--nolazy", "-r", "ts-node/register"], "runtimeArgs": [
"--nolazy",
"-r",
"ts-node/register",
"-r",
"${workspaceFolder}/tools/load-env.ts"
],
"skipFiles": [ "skipFiles": [
"${workspaceFolder}/node_modules/**/*.js", "${workspaceFolder}/node_modules/**/*.js",
"<node_internals>/**/*.js" "<node_internals>/**/*.js"

42
CHANGELOG.md

@ -7,10 +7,52 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Unreleased ## Unreleased
### Added
- Added an automatic refresh every 30 seconds to the users table in the admin control panel
### Changed ### Changed
- Centralized the asset profile override logic for manual adjustments
- Prevented the deletion of asset profiles that are currently in use - Prevented the deletion of asset profiles that are currently in use
- Ensured market data is correctly removed when an asset profile with no remaining activities is deleted - Ensured market data is correctly removed when an asset profile with no remaining activities is deleted
- Refactored the backend logging to use the instance-based `Logger`
- Improved the language localization for Ukrainian (`uk`)
### Fixed
- Fixed an issue where the asset profile override (asset class and asset sub class) was not applied to the data enhancers when gathering asset profiles
- Fixed a layout issue in the asset profile dialog of the admin control by truncating long titles
## 3.7.0 - 2026-06-02
### Added
- Added support for routing selected requests through the _OpenRouter_ `web_fetch` tool in the `FetchService`
### Changed
- Extended the countries mapping in the data enhancer for asset profile data via _Trackinsight_
- Removed the deprecated attributes (`assetClass`, `assetClassLabel`, `assetSubClass`, `assetSubClassLabel`, `countries`, `currency`, `dataSource`, `holdings`, `name`, `sectors`, `symbol` and `url`) from the holdings of the portfolio details endpoint response
- Upgraded `Nx` from version `22.7.2` to `22.7.5`
### Fixed
- Resolved an issue in the impersonation mode where the values did not match the owner’s currency
- Fixed the environment variable expansion in the `.env` file when debugging via _Visual Studio Code_
## 3.6.0 - 2026-05-28
### Added
- Added `HTTP_PROXY`, `HTTPS_PROXY`, and `NO_PROXY` environment variable support to outbound HTTP requests
- Added the `FetchService` to centralize outbound HTTP requests
### Changed
- Extracted the floating action buttons (FAB) to a reusable component
- Upgraded `nestjs` from version `11.1.19` to `11.1.21`
- Upgraded `yahoo-finance2` from version `3.14.0` to `3.14.2`
## 3.5.0 - 2026-05-24 ## 3.5.0 - 2026-05-24

6
DEVELOPMENT.md

@ -84,6 +84,12 @@ https://ghostfol.io/development/storybook
1. Run `npx npm-check-updates --upgrade --target "minor" --filter "/@angular.*/"` 1. Run `npx npm-check-updates --upgrade --target "minor" --filter "/@angular.*/"`
### NestJS
#### Upgrade (minor versions)
1. Run `npx npm-check-updates --upgrade --target "minor" --filter "/@nestjs.*/"`
### Nx ### Nx
#### Upgrade #### Upgrade

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

@ -1,5 +1,6 @@
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service'; import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
import { UserService } from '@ghostfolio/api/app/user/user.service';
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 { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.interceptor'; import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.interceptor';
@ -50,7 +51,8 @@ export class AccountController {
private readonly apiService: ApiService, private readonly apiService: ApiService,
private readonly impersonationService: ImpersonationService, private readonly impersonationService: ImpersonationService,
private readonly portfolioService: PortfolioService, private readonly portfolioService: PortfolioService,
@Inject(REQUEST) private readonly request: RequestWithUser @Inject(REQUEST) private readonly request: RequestWithUser,
private readonly userService: UserService
) {} ) {}
@Delete(':id') @Delete(':id')
@ -137,11 +139,14 @@ export class AccountController {
): Promise<AccountBalancesResponse> { ): Promise<AccountBalancesResponse> {
const impersonationUserId = const impersonationUserId =
await this.impersonationService.validateImpersonationId(impersonationId); await this.impersonationService.validateImpersonationId(impersonationId);
const userId = impersonationUserId || this.request.user.id;
const { settings } = await this.userService.user({ id: userId });
return this.accountBalanceService.getAccountBalances({ return this.accountBalanceService.getAccountBalances({
userId,
filters: [{ id, type: 'ACCOUNT' }], filters: [{ id, type: 'ACCOUNT' }],
userCurrency: this.request.user.settings.settings.baseCurrency, userCurrency: settings.settings.baseCurrency
userId: impersonationUserId || this.request.user.id
}); });
} }

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

@ -1,5 +1,6 @@
import { AccountBalanceModule } from '@ghostfolio/api/app/account-balance/account-balance.module'; import { AccountBalanceModule } from '@ghostfolio/api/app/account-balance/account-balance.module';
import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module'; import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module';
import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { RedactValuesInResponseModule } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.module'; import { RedactValuesInResponseModule } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.module';
import { ApiModule } from '@ghostfolio/api/services/api/api.module'; import { ApiModule } from '@ghostfolio/api/services/api/api.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
@ -23,7 +24,8 @@ import { AccountService } from './account.service';
ImpersonationModule, ImpersonationModule,
PortfolioModule, PortfolioModule,
PrismaModule, PrismaModule,
RedactValuesInResponseModule RedactValuesInResponseModule,
UserModule
], ],
providers: [AccountService] providers: [AccountService]
}) })

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

@ -63,6 +63,8 @@ import { AdminService } from './admin.service';
@Controller('admin') @Controller('admin')
export class AdminController { export class AdminController {
private readonly logger = new Logger(AdminController.name);
public constructor( public constructor(
private readonly adminService: AdminService, private readonly adminService: AdminService,
private readonly apiService: ApiService, private readonly apiService: ApiService,
@ -267,7 +269,7 @@ export class AdminController {
`Could not parse the market price for ${symbol} (${dataSource})` `Could not parse the market price for ${symbol} (${dataSource})`
); );
} catch (error) { } catch (error) {
Logger.error(error, 'AdminController'); this.logger.error(error);
throw new HttpException(error.message, StatusCodes.BAD_REQUEST); throw new HttpException(error.message, StatusCodes.BAD_REQUEST);
} }

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

@ -14,6 +14,7 @@ import {
PROPERTY_IS_USER_SIGNUP_ENABLED PROPERTY_IS_USER_SIGNUP_ENABLED
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { import {
applyAssetProfileOverrides,
getAssetProfileIdentifier, getAssetProfileIdentifier,
getCurrencyFromSymbol, getCurrencyFromSymbol,
isCurrency isCurrency
@ -29,7 +30,6 @@ 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 { import {
@ -349,25 +349,26 @@ export class AdminService {
} }
let marketData: AdminMarketDataItem[] = await Promise.all( let marketData: AdminMarketDataItem[] = await Promise.all(
assetProfiles.map( assetProfiles.map(async (assetProfile) => {
async ({ const {
_count, _count,
activities, activities,
assetClass,
assetSubClass,
comment, comment,
countries,
currency, currency,
dataSource, dataSource,
id, id,
isActive, isActive,
isUsedByUsersWithSubscription, isUsedByUsersWithSubscription,
name, symbol
sectors, } = assetProfile;
symbol,
SymbolProfileOverrides const { assetClass, assetSubClass, countries, name, sectors } =
}) => { applyAssetProfileOverrides(
let countriesCount = countries ? Object.keys(countries).length : 0; assetProfile,
assetProfile.SymbolProfileOverrides
);
const countriesCount = countries ? Object.keys(countries).length : 0;
const lastMarketPrice = lastMarketPriceMap.get( const lastMarketPrice = lastMarketPriceMap.get(
getAssetProfileIdentifier({ dataSource, symbol }) getAssetProfileIdentifier({ dataSource, symbol })
@ -381,33 +382,7 @@ export class AdminService {
); );
})?._count ?? 0; })?._count ?? 0;
let sectorsCount = sectors ? Object.keys(sectors).length : 0; const 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,
@ -428,8 +403,7 @@ export class AdminService {
isUsedByUsersWithSubscription: await isUsedByUsersWithSubscription, isUsedByUsersWithSubscription: await isUsedByUsersWithSubscription,
watchedByCount: _count.watchedBy watchedByCount: _count.watchedBy
}; };
} })
)
); );
if (presetId) { if (presetId) {

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

@ -5,6 +5,8 @@ import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { ApiKeyService } from '@ghostfolio/api/services/api-key/api-key.service'; import { ApiKeyService } from '@ghostfolio/api/services/api-key/api-key.service';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { FetchModule } from '@ghostfolio/api/services/fetch/fetch.module';
import { FetchService } from '@ghostfolio/api/services/fetch/fetch.service';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
@ -23,6 +25,7 @@ import { OidcStrategy } from './oidc.strategy';
controllers: [AuthController], controllers: [AuthController],
imports: [ imports: [
ConfigurationModule, ConfigurationModule,
FetchModule,
JwtModule.register({ JwtModule.register({
secret: process.env.JWT_SECRET_KEY, secret: process.env.JWT_SECRET_KEY,
signOptions: { expiresIn: '180 days' } signOptions: { expiresIn: '180 days' }
@ -40,12 +43,15 @@ import { OidcStrategy } from './oidc.strategy';
GoogleStrategy, GoogleStrategy,
JwtStrategy, JwtStrategy,
{ {
inject: [AuthService, ConfigurationService], inject: [AuthService, ConfigurationService, FetchService],
provide: OidcStrategy, provide: OidcStrategy,
useFactory: async ( useFactory: async (
authService: AuthService, authService: AuthService,
configurationService: ConfigurationService configurationService: ConfigurationService,
fetchService: FetchService
) => { ) => {
const logger = new Logger('OidcStrategy');
const isOidcEnabled = configurationService.get( const isOidcEnabled = configurationService.get(
'ENABLE_FEATURE_AUTH_OIDC' 'ENABLE_FEATURE_AUTH_OIDC'
); );
@ -81,7 +87,7 @@ import { OidcStrategy } from './oidc.strategy';
} else { } else {
// Fetch OIDC configuration from discovery endpoint // Fetch OIDC configuration from discovery endpoint
try { try {
const response = await fetch( const response = await fetchService.fetch(
`${issuer}/.well-known/openid-configuration` `${issuer}/.well-known/openid-configuration`
); );
@ -97,7 +103,7 @@ import { OidcStrategy } from './oidc.strategy';
tokenURL = manualTokenUrl || config.token_endpoint; tokenURL = manualTokenUrl || config.token_endpoint;
userInfoURL = manualUserInfoUrl || config.userinfo_endpoint; userInfoURL = manualUserInfoUrl || config.userinfo_endpoint;
} catch (error) { } catch (error) {
Logger.error(error, 'OidcStrategy'); logger.error(error);
throw new Error('Failed to fetch OIDC configuration from issuer'); throw new Error('Failed to fetch OIDC configuration from issuer');
} }
} }

4
apps/api/src/app/auth/google.strategy.ts

@ -10,6 +10,8 @@ import { AuthService } from './auth.service';
@Injectable() @Injectable()
export class GoogleStrategy extends PassportStrategy(Strategy, 'google') { export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
private readonly logger = new Logger(GoogleStrategy.name);
public constructor( public constructor(
private readonly authService: AuthService, private readonly authService: AuthService,
configurationService: ConfigurationService configurationService: ConfigurationService
@ -40,7 +42,7 @@ export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
done(null, { jwt }); done(null, { jwt });
} catch (error) { } catch (error) {
Logger.error(error, 'GoogleStrategy'); this.logger.error(error);
done(error, false); done(error, false);
} }
} }

9
apps/api/src/app/auth/oidc.strategy.ts

@ -15,6 +15,8 @@ import { OidcStateStore } from './oidc-state.store';
@Injectable() @Injectable()
export class OidcStrategy extends PassportStrategy(Strategy, 'oidc') { export class OidcStrategy extends PassportStrategy(Strategy, 'oidc') {
private readonly logger = new Logger(OidcStrategy.name);
private static readonly stateStore = new OidcStateStore(); private static readonly stateStore = new OidcStateStore();
public constructor( public constructor(
@ -52,9 +54,8 @@ export class OidcStrategy extends PassportStrategy(Strategy, 'oidc') {
}); });
if (!thirdPartyId) { if (!thirdPartyId) {
Logger.error( this.logger.error(
`Missing subject identifier in OIDC response from ${issuer}`, `Missing subject identifier in OIDC response from ${issuer}`
'OidcStrategy'
); );
throw new Error('Missing subject identifier in OIDC response'); throw new Error('Missing subject identifier in OIDC response');
@ -62,7 +63,7 @@ export class OidcStrategy extends PassportStrategy(Strategy, 'oidc') {
return { jwt }; return { jwt };
} catch (error) { } catch (error) {
Logger.error(error, 'OidcStrategy'); this.logger.error(error);
throw error; throw error;
} }
} }

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

@ -33,6 +33,8 @@ import ms from 'ms';
@Injectable() @Injectable()
export class WebAuthService { export class WebAuthService {
private readonly logger = new Logger(WebAuthService.name);
public constructor( public constructor(
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly deviceService: AuthDeviceService, private readonly deviceService: AuthDeviceService,
@ -103,7 +105,7 @@ export class WebAuthService {
verification = await verifyRegistrationResponse(opts); verification = await verifyRegistrationResponse(opts);
} catch (error) { } catch (error) {
Logger.error(error, 'WebAuthService'); this.logger.error(error);
throw new InternalServerErrorException(error.message); throw new InternalServerErrorException(error.message);
} }
@ -210,7 +212,7 @@ export class WebAuthService {
verification = await verifyAuthenticationResponse(opts); verification = await verifyAuthenticationResponse(opts);
} catch (error) { } catch (error) {
Logger.error(error, 'WebAuthService'); this.logger.error(error);
throw new InternalServerErrorException({ error: error.message }); throw new InternalServerErrorException({ error: error.message });
} }

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

@ -17,6 +17,8 @@ import { isNumber } from 'lodash';
@Injectable() @Injectable()
export class BenchmarksService { export class BenchmarksService {
private readonly logger = new Logger(BenchmarksService.name);
public constructor( public constructor(
private readonly benchmarkService: BenchmarkService, private readonly benchmarkService: BenchmarkService,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
@ -96,12 +98,11 @@ export class BenchmarksService {
})?.marketPrice; })?.marketPrice;
if (!marketPriceAtStartDate) { if (!marketPriceAtStartDate) {
Logger.error( this.logger.error(
`No historical market data has been found for ${symbol} (${dataSource}) at ${format( `No historical market data has been found for ${symbol} (${dataSource}) at ${format(
startDate, startDate,
DATE_FORMAT DATE_FORMAT
)}`, )}`
'BenchmarkService'
); );
return { marketData }; return { marketData };

2
apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.module.ts

@ -12,6 +12,7 @@ import { GoogleSheetsService } from '@ghostfolio/api/services/data-provider/goog
import { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.service'; import { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.service';
import { RapidApiService } from '@ghostfolio/api/services/data-provider/rapid-api/rapid-api.service'; import { RapidApiService } from '@ghostfolio/api/services/data-provider/rapid-api/rapid-api.service';
import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service'; import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service';
import { FetchModule } from '@ghostfolio/api/services/fetch/fetch.module';
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module'; import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
@ -27,6 +28,7 @@ import { GhostfolioService } from './ghostfolio.service';
imports: [ imports: [
CryptocurrencyModule, CryptocurrencyModule,
DataProviderModule, DataProviderModule,
FetchModule,
MarketDataModule, MarketDataModule,
PrismaModule, PrismaModule,
PropertyModule, PropertyModule,

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

@ -8,6 +8,7 @@ import {
GetQuotesParams, GetQuotesParams,
GetSearchParams GetSearchParams
} from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface'; } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
import { FetchService } from '@ghostfolio/api/services/fetch/fetch.service';
import { 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';
import { import {
@ -33,9 +34,12 @@ import { Big } from 'big.js';
@Injectable() @Injectable()
export class GhostfolioService { export class GhostfolioService {
private readonly logger = new Logger(GhostfolioService.name);
public constructor( public constructor(
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
private readonly fetchService: FetchService,
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService private readonly propertyService: PropertyService
) {} ) {}
@ -97,7 +101,7 @@ export class GhostfolioService {
return result; return result;
} catch (error) { } catch (error) {
Logger.error(error, 'GhostfolioService'); this.logger.error(error);
throw error; throw error;
} }
@ -139,7 +143,7 @@ export class GhostfolioService {
return result; return result;
} catch (error) { } catch (error) {
Logger.error(error, 'GhostfolioService'); this.logger.error(error);
throw error; throw error;
} }
@ -181,7 +185,7 @@ export class GhostfolioService {
return result; return result;
} catch (error) { } catch (error) {
Logger.error(error, 'GhostfolioService'); this.logger.error(error);
throw error; throw error;
} }
@ -269,7 +273,7 @@ export class GhostfolioService {
return results; return results;
} catch (error) { } catch (error) {
Logger.error(error, 'GhostfolioService'); this.logger.error(error);
throw error; throw error;
} }
@ -346,7 +350,7 @@ export class GhostfolioService {
return results; return results;
} catch (error) { } catch (error) {
Logger.error(error, 'GhostfolioService'); this.logger.error(error);
throw error; throw error;
} }
@ -355,6 +359,7 @@ export class GhostfolioService {
private getDataProviderInfo(): DataProviderInfo { private getDataProviderInfo(): DataProviderInfo {
const ghostfolioDataProviderService = new GhostfolioDataProviderService( const ghostfolioDataProviderService = new GhostfolioDataProviderService(
this.configurationService, this.configurationService,
this.fetchService,
this.propertyService this.propertyService
); );

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

@ -120,10 +120,10 @@ export class MarketDataController {
if (!canReadAllAssetProfiles && !canReadOwnAssetProfile) { if (!canReadAllAssetProfiles && !canReadOwnAssetProfile) {
throw new HttpException( throw new HttpException(
assetProfile.userId assetProfile?.userId
? getReasonPhrase(StatusCodes.NOT_FOUND) ? getReasonPhrase(StatusCodes.NOT_FOUND)
: getReasonPhrase(StatusCodes.FORBIDDEN), : getReasonPhrase(StatusCodes.FORBIDDEN),
assetProfile.userId ? StatusCodes.NOT_FOUND : StatusCodes.FORBIDDEN assetProfile?.userId ? StatusCodes.NOT_FOUND : StatusCodes.FORBIDDEN
); );
} }

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

@ -24,6 +24,8 @@ import { HealthService } from './health.service';
@Controller('health') @Controller('health')
export class HealthController { export class HealthController {
private readonly logger = new Logger(HealthController.name);
public constructor( public constructor(
private readonly aiService: AiService, private readonly aiService: AiService,
private readonly healthService: HealthService private readonly healthService: HealthService
@ -61,7 +63,7 @@ export class HealthController {
.json({ status: getReasonPhrase(StatusCodes.OK) }); .json({ status: getReasonPhrase(StatusCodes.OK) });
} }
} catch (error) { } catch (error) {
Logger.error(error, 'HealthController'); this.logger.error(error);
} }
return response return response

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

@ -31,6 +31,8 @@ import { ImportService } from './import.service';
@Controller('import') @Controller('import')
export class ImportController { export class ImportController {
private readonly logger = new Logger(ImportController.name);
public constructor( public constructor(
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly importService: ImportService, private readonly importService: ImportService,
@ -81,7 +83,7 @@ export class ImportController {
return { activities }; return { activities };
} catch (error) { } catch (error) {
Logger.error(error, ImportController); this.logger.error(error);
throw new HttpException( throw new HttpException(
{ {

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

@ -1,5 +1,6 @@
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 { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { FetchModule } from '@ghostfolio/api/services/fetch/fetch.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
@ -11,6 +12,7 @@ import { LogoService } from './logo.service';
controllers: [LogoController], controllers: [LogoController],
imports: [ imports: [
ConfigurationModule, ConfigurationModule,
FetchModule,
SymbolProfileModule, SymbolProfileModule,
TransformDataSourceInRequestModule TransformDataSourceInRequestModule
], ],

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

@ -1,4 +1,5 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { FetchService } from '@ghostfolio/api/services/fetch/fetch.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces'; import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
@ -10,6 +11,7 @@ import { StatusCodes, getReasonPhrase } from 'http-status-codes';
export class LogoService { export class LogoService {
public constructor( public constructor(
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly fetchService: FetchService,
private readonly symbolProfileService: SymbolProfileService private readonly symbolProfileService: SymbolProfileService
) {} ) {}
@ -43,7 +45,8 @@ export class LogoService {
} }
private async getBuffer(aUrl: string) { private async getBuffer(aUrl: string) {
const blob = await fetch( const blob = await this.fetchService
.fetch(
`https://t0.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${aUrl}&size=64`, `https://t0.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${aUrl}&size=64`,
{ {
headers: { 'User-Agent': 'request' }, headers: { 'User-Agent': 'request' },
@ -51,7 +54,8 @@ export class LogoService {
this.configurationService.get('REQUEST_TIMEOUT') this.configurationService.get('REQUEST_TIMEOUT')
) )
} }
).then((res) => res.blob()); )
.then((res) => res.blob());
return { return {
buffer: await blob.arrayBuffer().then((arrayBuffer) => { buffer: await blob.arrayBuffer().then((arrayBuffer) => {

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

@ -62,6 +62,8 @@ import { isNumber, sortBy, sum, uniqBy } from 'lodash';
export abstract class PortfolioCalculator { export abstract class PortfolioCalculator {
protected static readonly ENABLE_LOGGING = false; protected static readonly ENABLE_LOGGING = false;
protected readonly logger = new Logger(PortfolioCalculator.name);
protected accountBalanceItems: HistoricalDataItem[]; protected accountBalanceItems: HistoricalDataItem[];
protected activities: PortfolioOrder[]; protected activities: PortfolioOrder[];
@ -1119,12 +1121,11 @@ export abstract class PortfolioCalculator {
if (cachedPortfolioSnapshot) { if (cachedPortfolioSnapshot) {
this.snapshot = cachedPortfolioSnapshot; this.snapshot = cachedPortfolioSnapshot;
Logger.debug( this.logger.debug(
`Fetched portfolio snapshot from cache in ${( `Fetched portfolio snapshot from cache in ${(
(performance.now() - startTimeTotal) / (performance.now() - startTimeTotal) /
1000 1000
).toFixed(3)} seconds`, ).toFixed(3)} seconds`
'PortfolioCalculator'
); );
if (isCachedPortfolioSnapshotExpired) { if (isCachedPortfolioSnapshotExpired) {

6
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts

@ -11,7 +11,6 @@ import { PortfolioSnapshot, TimelinePosition } from '@ghostfolio/common/models';
import { DateRange } from '@ghostfolio/common/types'; import { DateRange } from '@ghostfolio/common/types';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Logger } from '@nestjs/common';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { import {
addMilliseconds, addMilliseconds,
@ -96,9 +95,8 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
currentPosition.timeWeightedInvestmentWithCurrencyEffect currentPosition.timeWeightedInvestmentWithCurrencyEffect
); );
} else if (!currentPosition.quantity.eq(0)) { } else if (!currentPosition.quantity.eq(0)) {
Logger.warn( this.logger.warn(
`Missing historical market data for ${currentPosition.symbol} (${currentPosition.dataSource})`, `Missing historical market data for ${currentPosition.symbol} (${currentPosition.dataSource})`
'PortfolioCalculator'
); );
hasErrors = true; hasErrors = true;

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

@ -1,4 +1,5 @@
import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service'; import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service';
import { UserService } from '@ghostfolio/api/app/user/user.service';
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 { import {
@ -70,7 +71,8 @@ export class PortfolioController {
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly impersonationService: ImpersonationService, private readonly impersonationService: ImpersonationService,
private readonly portfolioService: PortfolioService, private readonly portfolioService: PortfolioService,
@Inject(REQUEST) private readonly request: RequestWithUser @Inject(REQUEST) private readonly request: RequestWithUser,
private readonly userService: UserService
) {} ) {}
@Get('details') @Get('details')
@ -144,10 +146,10 @@ export class PortfolioController {
.reduce((a, b) => a + b, 0); .reduce((a, b) => a + b, 0);
const totalValue = Object.values(holdings) const totalValue = Object.values(holdings)
.filter(({ assetClass, assetSubClass }) => { .filter(({ assetProfile }) => {
return ( return (
assetClass !== AssetClass.LIQUIDITY && assetProfile.assetClass !== AssetClass.LIQUIDITY &&
assetSubClass !== AssetSubClass.CASH assetProfile.assetSubClass !== AssetSubClass.CASH
); );
}) })
.map(({ valueInBaseCurrency }) => { .map(({ valueInBaseCurrency }) => {
@ -217,37 +219,41 @@ export class PortfolioController {
for (const [symbol, portfolioPosition] of Object.entries(holdings)) { for (const [symbol, portfolioPosition] of Object.entries(holdings)) {
holdings[symbol] = { holdings[symbol] = {
...portfolioPosition, ...portfolioPosition,
assetClass:
hasDetails || portfolioPosition.assetClass === AssetClass.LIQUIDITY
? portfolioPosition.assetClass
: undefined,
assetProfile: { assetProfile: {
...portfolioPosition.assetProfile, ...portfolioPosition.assetProfile,
assetClass:
hasDetails ||
portfolioPosition.assetProfile.assetClass === AssetClass.LIQUIDITY
? portfolioPosition.assetProfile.assetClass
: undefined,
assetClassLabel:
hasDetails ||
portfolioPosition.assetProfile.assetClass === AssetClass.LIQUIDITY
? portfolioPosition.assetProfile.assetClassLabel
: undefined,
assetSubClass:
hasDetails ||
portfolioPosition.assetProfile.assetSubClass === AssetSubClass.CASH
? portfolioPosition.assetProfile.assetSubClass
: undefined,
assetSubClassLabel:
hasDetails ||
portfolioPosition.assetProfile.assetSubClass === AssetSubClass.CASH
? portfolioPosition.assetProfile.assetSubClassLabel
: undefined,
...(hasDetails ...(hasDetails
? {} ? {}
: { : {
assetClass: undefined,
assetClassLabel: undefined,
assetSubClass: undefined,
assetSubClassLabel: undefined,
countries: [], countries: [],
currency: undefined, currency: undefined,
holdings: [], holdings: [],
sectors: [] sectors: []
}) })
}, },
assetSubClass:
hasDetails || portfolioPosition.assetSubClass === AssetSubClass.CASH
? portfolioPosition.assetSubClass
: undefined,
countries: hasDetails ? portfolioPosition.countries : [],
currency: hasDetails ? portfolioPosition.currency : undefined,
holdings: hasDetails ? portfolioPosition.holdings : [],
markets: hasDetails ? portfolioPosition.markets : undefined, markets: hasDetails ? portfolioPosition.markets : undefined,
marketsAdvanced: hasDetails marketsAdvanced: hasDetails
? portfolioPosition.marketsAdvanced ? portfolioPosition.marketsAdvanced
: undefined, : undefined
sectors: hasDetails ? portfolioPosition.sectors : []
}; };
} }
@ -336,7 +342,10 @@ export class PortfolioController {
const impersonationUserId = const impersonationUserId =
await this.impersonationService.validateImpersonationId(impersonationId); await this.impersonationService.validateImpersonationId(impersonationId);
const userCurrency = this.request.user.settings.settings.baseCurrency; const userId = impersonationUserId || this.request.user.id;
const { settings } = await this.userService.user({ id: userId });
const userCurrency = settings.settings.baseCurrency;
const { endDate, startDate } = getIntervalFromDateRange({ dateRange }); const { endDate, startDate } = getIntervalFromDateRange({ dateRange });
@ -345,7 +354,7 @@ export class PortfolioController {
filters, filters,
startDate, startDate,
userCurrency, userCurrency,
userId: impersonationUserId || this.request.user.id, userId,
types: ['DIVIDEND'] types: ['DIVIDEND']
}); });

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

@ -0,0 +1,272 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { CashDetails } from '@ghostfolio/api/app/account/interfaces/cash-details.interface';
import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service';
import { userDummyData } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { UserService } from '@ghostfolio/api/app/user/user.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.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 { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { parseDate } from '@ghostfolio/common/helper';
import { Account, DataSource } from '@prisma/client';
import { Big } from 'big.js';
import { randomUUID } from 'node:crypto';
import { PortfolioService } from './portfolio.service';
describe('PortfolioService', () => {
let accountService: AccountService;
let activitiesService: ActivitiesService;
let configurationService: ConfigurationService;
let dataProviderService: DataProviderService;
let exchangeRateDataService: ExchangeRateDataService;
let impersonationService: ImpersonationService;
let portfolioCalculatorFactory: PortfolioCalculatorFactory;
let portfolioService: PortfolioService;
let symbolProfileService: SymbolProfileService;
let userService: UserService;
beforeEach(() => {
configurationService = new ConfigurationService();
dataProviderService = new DataProviderService(
configurationService,
null,
null,
null,
null,
null
);
exchangeRateDataService = new ExchangeRateDataService(
null,
null,
null,
null
);
accountService = new AccountService(
null,
null,
exchangeRateDataService,
null
);
activitiesService = new ActivitiesService(
null,
accountService,
null,
dataProviderService,
null,
exchangeRateDataService,
null,
null
);
impersonationService = new ImpersonationService(null, null);
portfolioCalculatorFactory = new PortfolioCalculatorFactory(
configurationService,
null,
exchangeRateDataService,
null,
null
);
symbolProfileService = new SymbolProfileService(null);
userService = new UserService(
null,
null,
null,
null,
null,
null,
null,
null
);
portfolioService = new PortfolioService(
null,
accountService,
activitiesService,
null,
portfolioCalculatorFactory,
dataProviderService,
exchangeRateDataService,
null,
impersonationService,
null,
null,
symbolProfileService,
userService
);
});
describe('getCashSymbolProfiles', () => {
it('should use the exchange-rate data source so the symbol-profile join in getDetails matches the calculator positions', () => {
jest
.spyOn(dataProviderService, 'getDataSourceForExchangeRates')
.mockReturnValue(DataSource.YAHOO);
const cashDetails: CashDetails = {
accounts: [
{
balance: 2000,
comment: null,
createdAt: parseDate('2024-01-01'),
currency: 'USD',
id: randomUUID(),
isExcluded: false,
name: 'USD',
platformId: null,
updatedAt: parseDate('2024-01-01'),
userId: userDummyData.id
}
],
balanceInBaseCurrency: 1820
};
const assetProfiles = (
portfolioService as unknown as {
getCashSymbolProfiles: (
aCashDetails: CashDetails
) => { dataSource: DataSource; symbol: string }[];
}
).getCashSymbolProfiles(cashDetails);
expect(assetProfiles).toHaveLength(1);
expect(assetProfiles[0].dataSource).toBe(DataSource.YAHOO);
expect(assetProfiles[0].symbol).toBe('USD');
});
});
describe('getDetails', () => {
it('should return cash holdings when the calculator emits cash positions with the exchange-rate data source', async () => {
const accountId = randomUUID();
const cashAccount: Account = {
balance: 2000,
comment: null,
createdAt: parseDate('2024-01-01'),
currency: 'USD',
id: accountId,
isExcluded: false,
name: 'USD',
platformId: null,
updatedAt: parseDate('2024-01-01'),
userId: userDummyData.id
};
jest.spyOn(accountService, 'getCashDetails').mockResolvedValue({
accounts: [cashAccount],
balanceInBaseCurrency: 1820
});
jest
.spyOn(activitiesService, 'getActivitiesForPortfolioCalculator')
.mockResolvedValue({ activities: [], count: 0 });
jest
.spyOn(dataProviderService, 'getDataSourceForExchangeRates')
.mockReturnValue(DataSource.YAHOO);
jest
.spyOn(impersonationService, 'validateImpersonationId')
.mockResolvedValue(null);
jest
.spyOn(symbolProfileService, 'getSymbolProfiles')
.mockResolvedValue([]);
jest.spyOn(userService, 'user').mockResolvedValue({
accessesGet: [],
accounts: [],
activityCount: 0,
dataProviderGhostfolioDailyRequests: 0,
id: userDummyData.id,
settings: {
settings: {
baseCurrency: 'CHF'
}
}
} as unknown as Awaited<ReturnType<typeof userService.user>>);
const usdPosition = {
activitiesCount: 1,
averagePrice: new Big(1),
currency: 'USD',
dataSource: DataSource.YAHOO,
dateOfFirstActivity: '2024-01-01',
dividend: new Big(0),
dividendInBaseCurrency: new Big(0),
fee: new Big(0),
feeInBaseCurrency: new Big(0),
grossPerformance: new Big(0),
grossPerformancePercentage: new Big(0),
grossPerformancePercentageWithCurrencyEffect: new Big(0),
grossPerformanceWithCurrencyEffect: new Big(0),
investment: new Big(1820),
investmentWithCurrencyEffect: new Big(1820),
marketPrice: 1,
marketPriceInBaseCurrency: 0.91,
netPerformance: new Big(0),
netPerformancePercentage: new Big(0),
netPerformancePercentageWithCurrencyEffectMap: {},
netPerformanceWithCurrencyEffectMap: {},
quantity: new Big(2000),
symbol: 'USD',
tags: [],
timeWeightedInvestment: new Big(0),
timeWeightedInvestmentWithCurrencyEffect: new Big(0),
valueInBaseCurrency: new Big(1820)
};
jest
.spyOn(portfolioCalculatorFactory, 'createCalculator')
.mockReturnValue({
getSnapshot: jest.fn().mockResolvedValue({
activitiesCount: 1,
createdAt: parseDate('2024-01-01'),
currentValueInBaseCurrency: new Big(1820),
errors: [],
hasErrors: false,
historicalData: [],
positions: [usdPosition],
totalFeesWithCurrencyEffect: new Big(0),
totalInterestWithCurrencyEffect: new Big(0),
totalInvestment: new Big(1820),
totalInvestmentWithCurrencyEffect: new Big(1820),
totalLiabilitiesWithCurrencyEffect: new Big(0)
})
} as unknown as ReturnType<
typeof portfolioCalculatorFactory.createCalculator
>);
jest
.spyOn(
portfolioService as unknown as {
getValueOfAccountsAndPlatforms: () => Promise<{
accounts: object;
platforms: object;
}>;
},
'getValueOfAccountsAndPlatforms'
)
.mockResolvedValue({ accounts: {}, platforms: {} });
const { holdings } = await portfolioService.getDetails({
filters: [],
impersonationId: userDummyData.id,
userId: userDummyData.id
});
expect(holdings['USD']).toBeDefined();
expect(holdings['USD'].assetProfile.dataSource).toBe(DataSource.YAHOO);
expect(holdings['USD'].assetProfile.symbol).toBe('USD');
});
});
});

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

@ -108,6 +108,8 @@ const europeMarkets = require('../../assets/countries/europe-markets.json');
@Injectable() @Injectable()
export class PortfolioService { export class PortfolioService {
private readonly logger = new Logger(PortfolioService.name);
public constructor( public constructor(
private readonly accountBalanceService: AccountBalanceService, private readonly accountBalanceService: AccountBalanceService,
private readonly accountService: AccountService, private readonly accountService: AccountService,
@ -164,7 +166,7 @@ export class PortfolioService {
}; };
} }
const [accounts, details] = await Promise.all([ const [accounts, details, user] = await Promise.all([
this.accountService.accounts({ this.accountService.accounts({
where, where,
include: { include: {
@ -178,10 +180,11 @@ export class PortfolioService {
withExcludedAccounts, withExcludedAccounts,
impersonationId: userId, impersonationId: userId,
userId: this.request.user.id userId: this.request.user.id
}) }),
this.userService.user({ id: userId })
]); ]);
const userCurrency = this.request.user.settings.settings.baseCurrency; const userCurrency = this.getUserCurrency(user);
return Promise.all( return Promise.all(
accounts.map(async (account) => { accounts.map(async (account) => {
@ -584,7 +587,6 @@ export class PortfolioService {
for (const { for (const {
activitiesCount, activitiesCount,
currency,
dataSource, dataSource,
dateOfFirstActivity, dateOfFirstActivity,
dividend, dividend,
@ -619,9 +621,8 @@ export class PortfolioService {
symbolProfileMap[getAssetProfileIdentifier({ dataSource, symbol })]; symbolProfileMap[getAssetProfileIdentifier({ dataSource, symbol })];
if (!assetProfile) { if (!assetProfile) {
Logger.warn( this.logger.warn(
`Asset profile not found for ${symbol} (${dataSource})`, `Asset profile not found for ${symbol} (${dataSource})`
'PortfolioService'
); );
continue; continue;
@ -638,16 +639,13 @@ export class PortfolioService {
holdings[symbol] = { holdings[symbol] = {
activitiesCount, activitiesCount,
currency,
markets, markets,
marketsAdvanced, marketsAdvanced,
marketPrice, marketPrice,
symbol,
tags, tags,
allocationInPercentage: filteredValueInBaseCurrency.eq(0) allocationInPercentage: filteredValueInBaseCurrency.eq(0)
? 0 ? 0
: valueInBaseCurrency.div(filteredValueInBaseCurrency).toNumber(), : valueInBaseCurrency.div(filteredValueInBaseCurrency).toNumber(),
assetClass: assetProfile.assetClass,
assetProfile: { assetProfile: {
assetClass: assetProfile.assetClass, assetClass: assetProfile.assetClass,
assetSubClass: assetProfile.assetSubClass, assetSubClass: assetProfile.assetSubClass,
@ -670,9 +668,6 @@ export class PortfolioService {
symbol: assetProfile.symbol, symbol: assetProfile.symbol,
url: assetProfile.url url: assetProfile.url
}, },
assetSubClass: assetProfile.assetSubClass,
countries: assetProfile.countries,
dataSource: assetProfile.dataSource,
dateOfFirstActivity: parseDate(dateOfFirstActivity), dateOfFirstActivity: parseDate(dateOfFirstActivity),
dividend: dividend?.toNumber() ?? 0, dividend: dividend?.toNumber() ?? 0,
grossPerformance: grossPerformance?.toNumber() ?? 0, grossPerformance: grossPerformance?.toNumber() ?? 0,
@ -681,19 +676,7 @@ export class PortfolioService {
grossPerformancePercentageWithCurrencyEffect?.toNumber() ?? 0, grossPerformancePercentageWithCurrencyEffect?.toNumber() ?? 0,
grossPerformanceWithCurrencyEffect: grossPerformanceWithCurrencyEffect:
grossPerformanceWithCurrencyEffect?.toNumber() ?? 0, grossPerformanceWithCurrencyEffect?.toNumber() ?? 0,
holdings: assetProfile.holdings.map(
({ allocationInPercentage, name }) => {
return {
allocationInPercentage,
name,
valueInBaseCurrency: valueInBaseCurrency
.mul(allocationInPercentage)
.toNumber()
};
}
),
investment: investment.toNumber(), investment: investment.toNumber(),
name: assetProfile.name,
netPerformance: netPerformance?.toNumber() ?? 0, netPerformance: netPerformance?.toNumber() ?? 0,
netPerformancePercent: netPerformancePercentage?.toNumber() ?? 0, netPerformancePercent: netPerformancePercentage?.toNumber() ?? 0,
netPerformancePercentWithCurrencyEffect: netPerformancePercentWithCurrencyEffect:
@ -703,8 +686,6 @@ export class PortfolioService {
netPerformanceWithCurrencyEffect: netPerformanceWithCurrencyEffect:
netPerformanceWithCurrencyEffectMap?.[dateRange]?.toNumber() ?? 0, netPerformanceWithCurrencyEffectMap?.[dateRange]?.toNumber() ?? 0,
quantity: quantity.toNumber(), quantity: quantity.toNumber(),
sectors: assetProfile.sectors,
url: assetProfile.url,
valueInBaseCurrency: valueInBaseCurrency.toNumber() valueInBaseCurrency: valueInBaseCurrency.toNumber()
}; };
} }
@ -1472,8 +1453,8 @@ export class PortfolioService {
for (const [, position] of Object.entries(holdings)) { for (const [, position] of Object.entries(holdings)) {
const value = position.valueInBaseCurrency; const value = position.valueInBaseCurrency;
if (position.assetClass !== AssetClass.LIQUIDITY) { if (position.assetProfile.assetClass !== AssetClass.LIQUIDITY) {
if (position.countries.length > 0) { if (position.assetProfile.countries.length > 0) {
markets.developedMarkets.valueInBaseCurrency += markets.developedMarkets.valueInBaseCurrency +=
position.markets.developedMarkets * value; position.markets.developedMarkets * value;
markets.emergingMarkets.valueInBaseCurrency += markets.emergingMarkets.valueInBaseCurrency +=
@ -1719,11 +1700,8 @@ export class PortfolioService {
currency: string; currency: string;
}): PortfolioPosition { }): PortfolioPosition {
return { return {
currency,
activitiesCount: 0, activitiesCount: 0,
allocationInPercentage: 0, allocationInPercentage: 0,
assetClass: AssetClass.LIQUIDITY,
assetSubClass: AssetSubClass.CASH,
assetProfile: { assetProfile: {
currency, currency,
assetClass: AssetClass.LIQUIDITY, assetClass: AssetClass.LIQUIDITY,
@ -1735,25 +1713,19 @@ export class PortfolioService {
sectors: [], sectors: [],
symbol: currency symbol: currency
}, },
countries: [],
dataSource: undefined,
dateOfFirstActivity: undefined, dateOfFirstActivity: undefined,
dividend: 0, dividend: 0,
grossPerformance: 0, grossPerformance: 0,
grossPerformancePercent: 0, grossPerformancePercent: 0,
grossPerformancePercentWithCurrencyEffect: 0, grossPerformancePercentWithCurrencyEffect: 0,
grossPerformanceWithCurrencyEffect: 0, grossPerformanceWithCurrencyEffect: 0,
holdings: [],
investment: balance, investment: balance,
marketPrice: 0, marketPrice: 0,
name: currency,
netPerformance: 0, netPerformance: 0,
netPerformancePercent: 0, netPerformancePercent: 0,
netPerformancePercentWithCurrencyEffect: 0, netPerformancePercentWithCurrencyEffect: 0,
netPerformanceWithCurrencyEffect: 0, netPerformanceWithCurrencyEffect: 0,
quantity: 0, quantity: 0,
sectors: [],
symbol: currency,
tags: [], tags: [],
valueInBaseCurrency: balance valueInBaseCurrency: balance
}; };

6
apps/api/src/app/redis-cache/redis-cache.service.ts

@ -10,6 +10,8 @@ import { createHash, randomUUID } from 'node:crypto';
@Injectable() @Injectable()
export class RedisCacheService { export class RedisCacheService {
private readonly logger = new Logger(RedisCacheService.name);
private client: Keyv; private client: Keyv;
public constructor( public constructor(
@ -27,7 +29,7 @@ export class RedisCacheService {
}; };
this.client.on('error', (error) => { this.client.on('error', (error) => {
Logger.error(error, 'RedisCacheService'); this.logger.error(error);
}); });
} }
@ -101,7 +103,7 @@ export class RedisCacheService {
return true; return true;
} catch (error) { } catch (error) {
Logger.error(error?.message, 'RedisCacheService'); this.logger.error(error?.message);
return false; return false;
} finally { } finally {

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

@ -33,6 +33,8 @@ import { SubscriptionService } from './subscription.service';
@Controller('subscription') @Controller('subscription')
export class SubscriptionController { export class SubscriptionController {
private readonly logger = new Logger(SubscriptionController.name);
public constructor( public constructor(
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly propertyService: PropertyService, private readonly propertyService: PropertyService,
@ -80,9 +82,8 @@ export class SubscriptionController {
value: JSON.stringify(coupons) value: JSON.stringify(coupons)
}); });
Logger.log( this.logger.log(
`Subscription for user '${this.request.user.id}' has been created with a coupon for ${coupon.duration}`, `Subscription for user '${this.request.user.id}' has been created with a coupon for ${coupon.duration}`
'SubscriptionController'
); );
return { return {
@ -101,9 +102,8 @@ export class SubscriptionController {
); );
if (userId) { if (userId) {
Logger.log( this.logger.log(
`Subscription for user '${userId}' has been created via Stripe`, `Subscription for user '${userId}' has been created via Stripe`
'SubscriptionController'
); );
} }
@ -126,7 +126,7 @@ export class SubscriptionController {
user: this.request.user user: this.request.user
}); });
} catch (error) { } catch (error) {
Logger.error(error, 'SubscriptionController'); this.logger.error(error);
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.BAD_REQUEST), getReasonPhrase(StatusCodes.BAD_REQUEST),

9
apps/api/src/app/subscription/subscription.service.ts

@ -24,6 +24,8 @@ import Stripe from 'stripe';
@Injectable() @Injectable()
export class SubscriptionService { export class SubscriptionService {
private readonly logger = new Logger(SubscriptionService.name);
private stripe: Stripe; private stripe: Stripe;
public constructor( public constructor(
@ -166,9 +168,8 @@ export class SubscriptionService {
error instanceof Prisma.PrismaClientKnownRequestError && error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === 'P2002' error.code === 'P2002'
) { ) {
Logger.log( this.logger.log(
`Stripe Checkout Session '${session.id}' has already been redeemed`, `Stripe Checkout Session '${session.id}' has already been redeemed`
'SubscriptionService'
); );
} else { } else {
throw error; throw error;
@ -177,7 +178,7 @@ export class SubscriptionService {
return session.client_reference_id; return session.client_reference_id;
} catch (error) { } catch (error) {
Logger.error(error, 'SubscriptionService'); this.logger.error(error);
} }
} }

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

@ -15,6 +15,8 @@ import { format, subDays } from 'date-fns';
@Injectable() @Injectable()
export class SymbolService { export class SymbolService {
private readonly logger = new Logger(SymbolService.name);
public constructor( public constructor(
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
private readonly marketDataService: MarketDataService private readonly marketDataService: MarketDataService
@ -119,7 +121,7 @@ export class SymbolService {
results.items = items; results.items = items;
return results; return results;
} catch (error) { } catch (error) {
Logger.error(error, 'SymbolService'); this.logger.error(error);
throw error; throw error;
} }

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

@ -49,7 +49,7 @@ import { PerformanceCalculationType } from '@ghostfolio/common/types/performance
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter'; import { EventEmitter2 } from '@nestjs/event-emitter';
import { Prisma, Role, User } from '@prisma/client'; import { Prisma, Role, Settings, User } from '@prisma/client';
import { differenceInDays, subDays } from 'date-fns'; import { differenceInDays, subDays } from 'date-fns';
import { without } from 'lodash'; import { without } from 'lodash';
import { createHmac } from 'node:crypto'; import { createHmac } from 'node:crypto';
@ -109,7 +109,14 @@ export class UserService {
}): Promise<IUser> { }): Promise<IUser> {
const { id, permissions, settings, subscription } = user; const { id, permissions, settings, subscription } = user;
const userData = await Promise.all([ const [
access,
accounts,
activitiesCount,
firstActivity,
impersonationUserSettings,
tagsForUser
] = await Promise.all([
this.prismaService.access.findMany({ this.prismaService.access.findMany({
include: { include: {
user: true user: true
@ -134,16 +141,17 @@ export class UserService {
}, },
where: { userId: impersonationUserId || user.id } where: { userId: impersonationUserId || user.id }
}), }),
impersonationUserId
? this.prismaService.settings.findUnique({
where: { userId: impersonationUserId }
})
: Promise.resolve<Settings>(null),
this.tagService.getTagsForUser(impersonationUserId || user.id) this.tagService.getTagsForUser(impersonationUserId || user.id)
]); ]);
const access = userData[0]; const baseCurrency =
const accounts = userData[1]; (impersonationUserSettings?.settings as UserSettings)?.baseCurrency ??
const activitiesCount = userData[2]; (settings.settings as UserSettings)?.baseCurrency;
const firstActivity = userData[3];
let tags = userData[4].filter((tag) => {
return tag.id !== TAG_ID_EXCLUDE_FROM_ANALYSIS;
});
let systemMessage: SystemMessage; let systemMessage: SystemMessage;
@ -156,6 +164,10 @@ export class UserService {
systemMessage = systemMessageProperty; systemMessage = systemMessageProperty;
} }
let tags = tagsForUser.filter((tag) => {
return tag.id !== TAG_ID_EXCLUDE_FROM_ANALYSIS;
});
if ( if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
subscription.type === SubscriptionType.Basic subscription.type === SubscriptionType.Basic
@ -183,6 +195,7 @@ export class UserService {
dateOfFirstActivity: firstActivity?.date ?? new Date(), dateOfFirstActivity: firstActivity?.date ?? new Date(),
settings: { settings: {
...(settings.settings as UserSettings), ...(settings.settings as UserSettings),
baseCurrency,
locale: (settings.settings as UserSettings)?.locale ?? locale locale: (settings.settings as UserSettings)?.locale ?? locale
} }
}; };

12
apps/api/src/events/asset-profile-changed.listener.ts

@ -15,6 +15,8 @@ import { AssetProfileChangedEvent } from './asset-profile-changed.event';
@Injectable() @Injectable()
export class AssetProfileChangedListener { export class AssetProfileChangedListener {
private readonly logger = new Logger(AssetProfileChangedListener.name);
private static readonly DEBOUNCE_DELAY = ms('5 seconds'); private static readonly DEBOUNCE_DELAY = ms('5 seconds');
private debounceTimers = new Map<string, NodeJS.Timeout>(); private debounceTimers = new Map<string, NodeJS.Timeout>();
@ -67,10 +69,7 @@ export class AssetProfileChangedListener {
dataSource: DataSource; dataSource: DataSource;
symbol: string; symbol: string;
}) { }) {
Logger.log( this.logger.log(`Asset profile of ${symbol} (${dataSource}) has changed`);
`Asset profile of ${symbol} (${dataSource}) has changed`,
'AssetProfileChangedListener'
);
if ( if (
this.configurationService.get( this.configurationService.get(
@ -84,10 +83,7 @@ export class AssetProfileChangedListener {
const existingCurrencies = this.exchangeRateDataService.getCurrencies(); const existingCurrencies = this.exchangeRateDataService.getCurrencies();
if (!existingCurrencies.includes(currency)) { if (!existingCurrencies.includes(currency)) {
Logger.log( this.logger.log(`New currency ${currency} has been detected`);
`New currency ${currency} has been detected`,
'AssetProfileChangedListener'
);
await this.exchangeRateDataService.initialize(); await this.exchangeRateDataService.initialize();
} }

7
apps/api/src/events/portfolio-changed.listener.ts

@ -8,6 +8,8 @@ import { PortfolioChangedEvent } from './portfolio-changed.event';
@Injectable() @Injectable()
export class PortfolioChangedListener { export class PortfolioChangedListener {
private readonly logger = new Logger(PortfolioChangedListener.name);
private static readonly DEBOUNCE_DELAY = ms('5 seconds'); private static readonly DEBOUNCE_DELAY = ms('5 seconds');
private debounceTimers = new Map<string, NodeJS.Timeout>(); private debounceTimers = new Map<string, NodeJS.Timeout>();
@ -35,10 +37,7 @@ export class PortfolioChangedListener {
} }
private async processPortfolioChanged({ userId }: { userId: string }) { private async processPortfolioChanged({ userId }: { userId: string }) {
Logger.log( this.logger.log(`Portfolio of user '${userId}' has changed`);
`Portfolio of user '${userId}' has changed`,
'PortfolioChangedListener'
);
await this.redisCacheService.removePortfolioSnapshotsByUserId({ userId }); await this.redisCacheService.removePortfolioSnapshotsByUserId({ userId });
} }

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

@ -2,6 +2,8 @@ import { Injectable, Logger } from '@nestjs/common';
@Injectable() @Injectable()
export class PerformanceLoggingService { export class PerformanceLoggingService {
private readonly logger = new Logger(PerformanceLoggingService.name);
public logPerformance({ public logPerformance({
className, className,
methodName, methodName,
@ -13,7 +15,7 @@ export class PerformanceLoggingService {
}) { }) {
const endTime = performance.now(); const endTime = performance.now();
Logger.debug( this.logger.debug(
`Completed execution of ${methodName}() in ${((endTime - startTime) / 1000).toFixed(3)} seconds`, `Completed execution of ${methodName}() in ${((endTime - startTime) / 1000).toFixed(3)} seconds`,
className className
); );

22
apps/api/src/main.ts

@ -18,11 +18,17 @@ import type { NestExpressApplication } from '@nestjs/platform-express';
import cookieParser from 'cookie-parser'; import cookieParser from 'cookie-parser';
import { NextFunction, Request, Response } from 'express'; import { NextFunction, Request, Response } from 'express';
import helmet from 'helmet'; import helmet from 'helmet';
import { EnvHttpProxyAgent, setGlobalDispatcher } from 'undici';
import { AppModule } from './app/app.module'; import { AppModule } from './app/app.module';
import { environment } from './environments/environment'; import { environment } from './environments/environment';
const logger = new Logger('Bootstrap');
async function bootstrap() { async function bootstrap() {
// Respect HTTP_PROXY / HTTPS_PROXY / NO_PROXY for outbound HTTP requests
setGlobalDispatcher(new EnvHttpProxyAgent());
const configApp = await NestFactory.create(AppModule); const configApp = await NestFactory.create(AppModule);
const configService = configApp.get<ConfigService>(ConfigService); const configService = configApp.get<ConfigService>(ConfigService);
let customLogLevels: LogLevel[]; let customLogLevels: LogLevel[];
@ -110,20 +116,20 @@ async function bootstrap() {
address = `${host}:${addressObject.port}`; address = `${host}:${addressObject.port}`;
} }
Logger.log(`Listening at http://${address}`); logger.log(`Listening at http://${address}`);
Logger.log(''); logger.log('');
}); });
} }
function logLogo() { function logLogo() {
Logger.log(' ________ __ ____ ___'); logger.log(' ________ __ ____ ___');
Logger.log(' / ____/ /_ ____ _____/ /_/ __/___ / (_)___'); logger.log(' / ____/ /_ ____ _____/ /_/ __/___ / (_)___');
Logger.log(' / / __/ __ \\/ __ \\/ ___/ __/ /_/ __ \\/ / / __ \\'); logger.log(' / / __/ __ \\/ __ \\/ ___/ __/ /_/ __ \\/ / / __ \\');
Logger.log('/ /_/ / / / / /_/ (__ ) /_/ __/ /_/ / / / /_/ /'); logger.log('/ /_/ / / / / /_/ (__ ) /_/ __/ /_/ / / / /_/ /');
Logger.log( logger.log(
`\\____/_/ /_/\\____/____/\\__/_/ \\____/_/_/\\____/ ${environment.version}` `\\____/_/ /_/\\____/____/\\__/_/ \\____/_/_/\\____/ ${environment.version}`
); );
Logger.log(''); logger.log('');
} }
bootstrap(); bootstrap();

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

@ -92,6 +92,8 @@ const locales = {
@Injectable() @Injectable()
export class HtmlTemplateMiddleware implements NestMiddleware { export class HtmlTemplateMiddleware implements NestMiddleware {
private readonly logger = new Logger(HtmlTemplateMiddleware.name);
private indexHtmlMap: { [languageCode: string]: string } = {}; private indexHtmlMap: { [languageCode: string]: string } = {};
public constructor(private readonly i18nService: I18nService) { public constructor(private readonly i18nService: I18nService) {
@ -107,11 +109,7 @@ export class HtmlTemplateMiddleware implements NestMiddleware {
{} {}
); );
} catch (error) { } catch (error) {
Logger.error( this.logger.error('Failed to initialize index HTML map', error);
'Failed to initialize index HTML map',
error,
'HTMLTemplateMiddleware'
);
} }
} }

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

@ -28,6 +28,8 @@ import { BenchmarkValue } from './interfaces/benchmark-value.interface';
@Injectable() @Injectable()
export class BenchmarkService { export class BenchmarkService {
private readonly logger = new Logger(BenchmarkService.name);
private readonly CACHE_KEY_BENCHMARKS = 'BENCHMARKS'; private readonly CACHE_KEY_BENCHMARKS = 'BENCHMARKS';
public constructor( public constructor(
@ -87,7 +89,7 @@ export class BenchmarkService {
const { benchmarks, expiration }: BenchmarkValue = const { benchmarks, expiration }: BenchmarkValue =
JSON.parse(cachedBenchmarkValue); JSON.parse(cachedBenchmarkValue);
Logger.debug('Fetched benchmarks from cache', 'BenchmarkService'); this.logger.debug('Fetched benchmarks from cache');
if (isAfter(new Date(), new Date(expiration))) { if (isAfter(new Date(), new Date(expiration))) {
this.calculateAndCacheBenchmarks({ this.calculateAndCacheBenchmarks({
@ -227,7 +229,7 @@ export class BenchmarkService {
private async calculateAndCacheBenchmarks({ private async calculateAndCacheBenchmarks({
enableSharing = false enableSharing = false
}): Promise<BenchmarkResponse['benchmarks']> { }): Promise<BenchmarkResponse['benchmarks']> {
Logger.debug('Calculate benchmarks', 'BenchmarkService'); this.logger.debug('Calculate benchmarks');
const benchmarkAssetProfiles = await this.getBenchmarkAssetProfiles({ const benchmarkAssetProfiles = await this.getBenchmarkAssetProfiles({
enableSharing enableSharing

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

@ -7,6 +7,7 @@ import {
GetQuotesParams, GetQuotesParams,
GetSearchParams GetSearchParams
} from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface'; } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
import { FetchService } from '@ghostfolio/api/services/fetch/fetch.service';
import { DEFAULT_CURRENCY } from '@ghostfolio/common/config'; import { DEFAULT_CURRENCY } from '@ghostfolio/common/config';
import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { import {
@ -28,11 +29,14 @@ import { format, fromUnixTime, getUnixTime } from 'date-fns';
@Injectable() @Injectable()
export class CoinGeckoService implements DataProviderInterface, OnModuleInit { export class CoinGeckoService implements DataProviderInterface, OnModuleInit {
private readonly logger = new Logger(CoinGeckoService.name);
private apiUrl: string; private apiUrl: string;
private headers: HeadersInit = {}; private headers: HeadersInit = {};
public constructor( public constructor(
private readonly configurationService: ConfigurationService private readonly configurationService: ConfigurationService,
private readonly fetchService: FetchService
) {} ) {}
public onModuleInit() { public onModuleInit() {
@ -67,12 +71,14 @@ export class CoinGeckoService implements DataProviderInterface, OnModuleInit {
}; };
try { try {
const { name } = await fetch(`${this.apiUrl}/coins/${symbol}`, { const { name } = await this.fetchService
.fetch(`${this.apiUrl}/coins/${symbol}`, {
headers: this.headers, headers: this.headers,
signal: AbortSignal.timeout( signal: AbortSignal.timeout(
this.configurationService.get('REQUEST_TIMEOUT') this.configurationService.get('REQUEST_TIMEOUT')
) )
}).then((res) => res.json()); })
.then((res) => res.json());
response.name = name; response.name = name;
} catch (error) { } catch (error) {
@ -84,7 +90,7 @@ export class CoinGeckoService implements DataProviderInterface, OnModuleInit {
).toFixed(3)} seconds`; ).toFixed(3)} seconds`;
} }
Logger.error(message, 'CoinGeckoService'); this.logger.error(message);
} }
return response; return response;
@ -118,13 +124,15 @@ export class CoinGeckoService implements DataProviderInterface, OnModuleInit {
vs_currency: DEFAULT_CURRENCY.toLowerCase() vs_currency: DEFAULT_CURRENCY.toLowerCase()
}); });
const { error, prices, status } = await fetch( const { error, prices, status } = await this.fetchService
.fetch(
`${this.apiUrl}/coins/${symbol}/market_chart/range?${queryParams.toString()}`, `${this.apiUrl}/coins/${symbol}/market_chart/range?${queryParams.toString()}`,
{ {
headers: this.headers, headers: this.headers,
signal: AbortSignal.timeout(requestTimeout) signal: AbortSignal.timeout(requestTimeout)
} }
).then((res) => res.json()); )
.then((res) => res.json());
if (error?.status) { if (error?.status) {
throw new Error(error.status.error_message); throw new Error(error.status.error_message);
@ -181,13 +189,12 @@ export class CoinGeckoService implements DataProviderInterface, OnModuleInit {
vs_currencies: DEFAULT_CURRENCY.toLowerCase() vs_currencies: DEFAULT_CURRENCY.toLowerCase()
}); });
const quotes = await fetch( const quotes = await this.fetchService
`${this.apiUrl}/simple/price?${queryParams.toString()}`, .fetch(`${this.apiUrl}/simple/price?${queryParams.toString()}`, {
{
headers: this.headers, headers: this.headers,
signal: AbortSignal.timeout(requestTimeout) signal: AbortSignal.timeout(requestTimeout)
} })
).then((res) => res.json()); .then((res) => res.json());
for (const symbol in quotes) { for (const symbol in quotes) {
response[symbol] = { response[symbol] = {
@ -209,7 +216,7 @@ export class CoinGeckoService implements DataProviderInterface, OnModuleInit {
).toFixed(3)} seconds`; ).toFixed(3)} seconds`;
} }
Logger.error(message, 'CoinGeckoService'); this.logger.error(message);
} }
return response; return response;
@ -230,13 +237,12 @@ export class CoinGeckoService implements DataProviderInterface, OnModuleInit {
query query
}); });
const { coins } = await fetch( const { coins } = await this.fetchService
`${this.apiUrl}/search?${queryParams.toString()}`, .fetch(`${this.apiUrl}/search?${queryParams.toString()}`, {
{
headers: this.headers, headers: this.headers,
signal: AbortSignal.timeout(requestTimeout) signal: AbortSignal.timeout(requestTimeout)
} })
).then((res) => res.json()); .then((res) => res.json());
items = coins.map(({ id: symbol, name }) => { items = coins.map(({ id: symbol, name }) => {
return { return {
@ -258,7 +264,7 @@ export class CoinGeckoService implements DataProviderInterface, OnModuleInit {
).toFixed(3)} seconds`; ).toFixed(3)} seconds`;
} }
Logger.error(message, 'CoinGeckoService'); this.logger.error(message);
} }
return { items }; return { items };

3
apps/api/src/services/data-provider/data-enhancer/data-enhancer.module.ts

@ -3,6 +3,7 @@ import { CryptocurrencyModule } from '@ghostfolio/api/services/cryptocurrency/cr
import { OpenFigiDataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/openfigi/openfigi.service'; import { OpenFigiDataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/openfigi/openfigi.service';
import { TrackinsightDataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/trackinsight/trackinsight.service'; import { TrackinsightDataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/trackinsight/trackinsight.service';
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 { FetchModule } from '@ghostfolio/api/services/fetch/fetch.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
@ -16,7 +17,7 @@ import { DataEnhancerService } from './data-enhancer.service';
YahooFinanceDataEnhancerService, YahooFinanceDataEnhancerService,
'DataEnhancers' 'DataEnhancers'
], ],
imports: [ConfigurationModule, CryptocurrencyModule], imports: [ConfigurationModule, CryptocurrencyModule, FetchModule],
providers: [ providers: [
DataEnhancerService, DataEnhancerService,
OpenFigiDataEnhancerService, OpenFigiDataEnhancerService,

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

@ -1,5 +1,6 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface'; import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface';
import { FetchService } from '@ghostfolio/api/services/fetch/fetch.service';
import { parseSymbol } from '@ghostfolio/common/helper'; import { parseSymbol } from '@ghostfolio/common/helper';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
@ -10,7 +11,8 @@ export class OpenFigiDataEnhancerService implements DataEnhancerInterface {
private static baseUrl = 'https://api.openfigi.com'; private static baseUrl = 'https://api.openfigi.com';
public constructor( public constructor(
private readonly configurationService: ConfigurationService private readonly configurationService: ConfigurationService,
private readonly fetchService: FetchService
) {} ) {}
public async enhance({ public async enhance({
@ -42,9 +44,8 @@ export class OpenFigiDataEnhancerService implements DataEnhancerInterface {
this.configurationService.get('API_KEY_OPEN_FIGI'); this.configurationService.get('API_KEY_OPEN_FIGI');
} }
const mappings = (await fetch( const mappings = (await this.fetchService
`${OpenFigiDataEnhancerService.baseUrl}/v3/mapping`, .fetch(`${OpenFigiDataEnhancerService.baseUrl}/v3/mapping`, {
{
body: JSON.stringify([ body: JSON.stringify([
{ exchCode: exchange, idType: 'TICKER', idValue: ticker } { exchCode: exchange, idType: 'TICKER', idValue: ticker }
]), ]),
@ -54,8 +55,8 @@ export class OpenFigiDataEnhancerService implements DataEnhancerInterface {
}, },
method: 'POST', method: 'POST',
signal: AbortSignal.timeout(requestTimeout) signal: AbortSignal.timeout(requestTimeout)
} })
).then((res) => res.json())) as any[]; .then((res) => res.json())) as any[];
if (mappings?.length === 1 && mappings[0].data?.length === 1) { if (mappings?.length === 1 && mappings[0].data?.length === 1) {
const { compositeFIGI, figi, shareClassFIGI } = mappings[0].data[0]; const { compositeFIGI, figi, shareClassFIGI } = mappings[0].data[0];

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

@ -1,5 +1,6 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface'; import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface';
import { FetchService } from '@ghostfolio/api/services/fetch/fetch.service';
import { Holding } from '@ghostfolio/common/interfaces'; import { Holding } from '@ghostfolio/common/interfaces';
import { Country } from '@ghostfolio/common/interfaces/country.interface'; import { Country } from '@ghostfolio/common/interfaces/country.interface';
import { Sector } from '@ghostfolio/common/interfaces/sector.interface'; import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
@ -10,9 +11,12 @@ import { countries } from 'countries-list';
@Injectable() @Injectable()
export class TrackinsightDataEnhancerService implements DataEnhancerInterface { export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
private readonly logger = new Logger(TrackinsightDataEnhancerService.name);
private static baseUrl = 'https://www.trackinsight.com/data-api'; private static baseUrl = 'https://www.trackinsight.com/data-api';
private static countriesMapping = { private static countriesMapping = {
'Russian Federation': 'Russia' 'Russian Federation': 'Russia',
USA: 'United States'
}; };
private static holdingsWeightTreshold = 0.85; private static holdingsWeightTreshold = 0.85;
private static sectorsMapping = { private static sectorsMapping = {
@ -23,7 +27,8 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
}; };
public constructor( public constructor(
private readonly configurationService: ConfigurationService private readonly configurationService: ConfigurationService,
private readonly fetchService: FetchService
) {} ) {}
public async enhance({ public async enhance({
@ -60,7 +65,8 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
return response; return response;
} }
const profile = await fetch( const profile = await this.fetchService
.fetch(
`${TrackinsightDataEnhancerService.baseUrl}/funds/${trackinsightSymbol}.json`, `${TrackinsightDataEnhancerService.baseUrl}/funds/${trackinsightSymbol}.json`,
{ {
signal: AbortSignal.timeout(requestTimeout) signal: AbortSignal.timeout(requestTimeout)
@ -83,7 +89,8 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
response.isin = isin; response.isin = isin;
} }
const holdings = await fetch( const holdings = await this.fetchService
.fetch(
`${TrackinsightDataEnhancerService.baseUrl}/holdings/${trackinsightSymbol}.json`, `${TrackinsightDataEnhancerService.baseUrl}/holdings/${trackinsightSymbol}.json`,
{ {
signal: AbortSignal.timeout(requestTimeout) signal: AbortSignal.timeout(requestTimeout)
@ -182,7 +189,8 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
requestTimeout: number; requestTimeout: number;
symbol: string; symbol: string;
}) { }) {
return fetch( return this.fetchService
.fetch(
`https://www.trackinsight.com/search-api/search_v2/${symbol}/_/ticker/default/0/3`, `https://www.trackinsight.com/search-api/search_v2/${symbol}/_/ticker/default/0/3`,
{ {
signal: AbortSignal.timeout(requestTimeout) signal: AbortSignal.timeout(requestTimeout)
@ -203,9 +211,8 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
return undefined; return undefined;
}) })
.catch(({ message }) => { .catch(({ message }) => {
Logger.error( this.logger.error(
`Failed to search Trackinsight symbol for ${symbol} (${message})`, `Failed to search Trackinsight symbol for ${symbol} (${message})`
'TrackinsightDataEnhancerService'
); );
return undefined; return undefined;

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

@ -23,6 +23,8 @@ import type { Price } from 'yahoo-finance2/esm/src/modules/quoteSummary-iface';
@Injectable() @Injectable()
export class YahooFinanceDataEnhancerService implements DataEnhancerInterface { export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
private readonly logger = new Logger(YahooFinanceDataEnhancerService.name);
private readonly yahooFinance = new YahooFinance({ private readonly yahooFinance = new YahooFinance({
suppressNotices: ['yahooSurvey'] suppressNotices: ['yahooSurvey']
}); });
@ -123,7 +125,7 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
response.url = url; response.url = url;
} }
} catch (error) { } catch (error) {
Logger.error(error, 'YahooFinanceDataEnhancerService'); this.logger.error(error);
} }
return response; return response;
@ -266,7 +268,7 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
`No data found, ${aSymbol} (${this.getName()}) may be delisted` `No data found, ${aSymbol} (${this.getName()}) may be delisted`
); );
} else { } else {
Logger.error(error, 'YahooFinanceService'); this.logger.error(error);
} }
} }

2
apps/api/src/services/data-provider/data-provider.module.ts

@ -10,6 +10,7 @@ import { GoogleSheetsService } from '@ghostfolio/api/services/data-provider/goog
import { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.service'; import { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.service';
import { RapidApiService } from '@ghostfolio/api/services/data-provider/rapid-api/rapid-api.service'; import { RapidApiService } from '@ghostfolio/api/services/data-provider/rapid-api/rapid-api.service';
import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service'; import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service';
import { FetchModule } from '@ghostfolio/api/services/fetch/fetch.module';
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module'; import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
@ -26,6 +27,7 @@ import { DataProviderService } from './data-provider.service';
ConfigurationModule, ConfigurationModule,
CryptocurrencyModule, CryptocurrencyModule,
DataEnhancerModule, DataEnhancerModule,
FetchModule,
MarketDataModule, MarketDataModule,
PrismaModule, PrismaModule,
PropertyModule, PropertyModule,

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

@ -41,6 +41,8 @@ import { AssetProfileInvalidError } from './errors/asset-profile-invalid.error';
@Injectable() @Injectable()
export class DataProviderService implements OnModuleInit { export class DataProviderService implements OnModuleInit {
private readonly logger = new Logger(DataProviderService.name);
private dataProviderMapping: { [dataProviderName: string]: string }; private dataProviderMapping: { [dataProviderName: string]: string };
public constructor( public constructor(
@ -129,7 +131,7 @@ export class DataProviderService implements OnModuleInit {
); );
} }
} catch (error) { } catch (error) {
Logger.error(error, 'DataProviderService'); this.logger.error(error);
throw error; throw error;
} }
@ -391,7 +393,7 @@ export class DataProviderService implements OnModuleInit {
return r; return r;
}, {}); }, {});
} catch (error) { } catch (error) {
Logger.error(error, 'DataProviderService'); this.logger.error(error);
} finally { } finally {
return response; return response;
} }
@ -503,7 +505,7 @@ export class DataProviderService implements OnModuleInit {
result[symbol] = data; result[symbol] = data;
} }
} catch (error) { } catch (error) {
Logger.error(error, 'DataProviderService'); this.logger.error(error);
throw error; throw error;
} }
@ -567,13 +569,12 @@ export class DataProviderService implements OnModuleInit {
const numberOfItemsInCache = Object.keys(response)?.length; const numberOfItemsInCache = Object.keys(response)?.length;
if (numberOfItemsInCache) { if (numberOfItemsInCache) {
Logger.debug( this.logger.debug(
`Fetched ${numberOfItemsInCache} quote${ `Fetched ${numberOfItemsInCache} quote${
numberOfItemsInCache > 1 ? 's' : '' numberOfItemsInCache > 1 ? 's' : ''
} from cache in ${((performance.now() - startTimeTotal) / 1000).toFixed( } from cache in ${((performance.now() - startTimeTotal) / 1000).toFixed(
3 3
)} seconds`, )} seconds`
'DataProviderService'
); );
} }
@ -684,14 +685,13 @@ export class DataProviderService implements OnModuleInit {
} }
} }
Logger.debug( this.logger.debug(
`Fetched ${symbolsChunk.length} quote${ `Fetched ${symbolsChunk.length} quote${
symbolsChunk.length > 1 ? 's' : '' symbolsChunk.length > 1 ? 's' : ''
} from ${dataSource} in ${( } from ${dataSource} in ${(
(performance.now() - startTimeDataSource) / (performance.now() - startTimeDataSource) /
1000 1000
).toFixed(3)} seconds`, ).toFixed(3)} seconds`
'DataProviderService'
); );
try { try {
@ -722,15 +722,18 @@ export class DataProviderService implements OnModuleInit {
await Promise.all(promises); await Promise.all(promises);
Logger.debug('--------------------------------------------------------'); this.logger.debug(
Logger.debug( '--------------------------------------------------------'
);
this.logger.debug(
`Fetched ${items.length} quote${items.length > 1 ? 's' : ''} in ${( `Fetched ${items.length} quote${items.length > 1 ? 's' : ''} in ${(
(performance.now() - startTimeTotal) / (performance.now() - startTimeTotal) /
1000 1000
).toFixed(3)} seconds`, ).toFixed(3)} seconds`
'DataProviderService' );
this.logger.debug(
'========================================================'
); );
Logger.debug('========================================================');
return response; return response;
} }

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

@ -7,6 +7,7 @@ import {
GetQuotesParams, GetQuotesParams,
GetSearchParams GetSearchParams
} from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface'; } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
import { FetchService } from '@ghostfolio/api/services/fetch/fetch.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { import {
DEFAULT_CURRENCY, DEFAULT_CURRENCY,
@ -36,11 +37,14 @@ import { isNumber } from 'lodash';
export class EodHistoricalDataService export class EodHistoricalDataService
implements DataProviderInterface, OnModuleInit implements DataProviderInterface, OnModuleInit
{ {
private readonly logger = new Logger(EodHistoricalDataService.name);
private apiKey: string; private apiKey: string;
private readonly URL = 'https://eodhistoricaldata.com/api'; private readonly URL = 'https://eodhistoricaldata.com/api';
public constructor( public constructor(
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly fetchService: FetchService,
private readonly symbolProfileService: SymbolProfileService private readonly symbolProfileService: SymbolProfileService
) {} ) {}
@ -111,12 +115,11 @@ export class EodHistoricalDataService
[date: string]: DataProviderHistoricalResponse; [date: string]: DataProviderHistoricalResponse;
} = {}; } = {};
const historicalResult = await fetch( const historicalResult = await this.fetchService
`${this.URL}/div/${symbol}?${queryParams.toString()}`, .fetch(`${this.URL}/div/${symbol}?${queryParams.toString()}`, {
{
signal: AbortSignal.timeout(requestTimeout) signal: AbortSignal.timeout(requestTimeout)
} })
).then((res) => res.json()); .then((res) => res.json());
for (const { date, value } of historicalResult) { for (const { date, value } of historicalResult) {
response[date] = { response[date] = {
@ -126,12 +129,11 @@ export class EodHistoricalDataService
return response; return response;
} catch (error) { } catch (error) {
Logger.error( this.logger.error(
`Could not get dividends for ${symbol} (${this.getName()}) from ${format( `Could not get dividends for ${symbol} (${this.getName()}) from ${format(
from, from,
DATE_FORMAT DATE_FORMAT
)} to ${format(to, DATE_FORMAT)}: [${error.name}] ${error.message}`, )} to ${format(to, DATE_FORMAT)}: [${error.name}] ${error.message}`
'EodHistoricalDataService'
); );
return {}; return {};
@ -158,12 +160,11 @@ export class EodHistoricalDataService
to: format(to, DATE_FORMAT) to: format(to, DATE_FORMAT)
}); });
const response = await fetch( const response = await this.fetchService
`${this.URL}/eod/${symbol}?${queryParams.toString()}`, .fetch(`${this.URL}/eod/${symbol}?${queryParams.toString()}`, {
{
signal: AbortSignal.timeout(requestTimeout) signal: AbortSignal.timeout(requestTimeout)
} })
).then((res) => res.json()); .then((res) => res.json());
return response.reduce( return response.reduce(
(result, { adjusted_close, date }) => { (result, { adjusted_close, date }) => {
@ -172,9 +173,8 @@ export class EodHistoricalDataService
marketPrice: adjusted_close marketPrice: adjusted_close
}; };
} else { } else {
Logger.error( this.logger.error(
`Could not get historical market data for ${symbol} (${this.getName()}) at ${date}`, `Could not get historical market data for ${symbol} (${this.getName()}) at ${date}`
'EodHistoricalDataService'
); );
} }
@ -223,12 +223,14 @@ export class EodHistoricalDataService
s: eodHistoricalDataSymbols.join(',') s: eodHistoricalDataSymbols.join(',')
}); });
const realTimeResponse = await fetch( const realTimeResponse = await this.fetchService
.fetch(
`${this.URL}/real-time/${eodHistoricalDataSymbols[0]}?${queryParams.toString()}`, `${this.URL}/real-time/${eodHistoricalDataSymbols[0]}?${queryParams.toString()}`,
{ {
signal: AbortSignal.timeout(requestTimeout) signal: AbortSignal.timeout(requestTimeout)
} }
).then((res) => res.json()); )
.then((res) => res.json());
const quotes: { const quotes: {
close: number; close: number;
@ -290,9 +292,8 @@ export class EodHistoricalDataService
dataSource: this.getName() dataSource: this.getName()
}; };
} else { } else {
Logger.error( this.logger.error(
`Could not get quote for ${this.convertFromEodSymbol(code)} (${this.getName()})`, `Could not get quote for ${this.convertFromEodSymbol(code)} (${this.getName()})`
'EodHistoricalDataService'
); );
} }
} }
@ -309,7 +310,7 @@ export class EodHistoricalDataService
).toFixed(3)} seconds`; ).toFixed(3)} seconds`;
} }
Logger.error(message, 'EodHistoricalDataService'); this.logger.error(message);
} }
return {}; return {};
@ -430,12 +431,11 @@ export class EodHistoricalDataService
api_token: this.apiKey api_token: this.apiKey
}); });
const response = await fetch( const response = await this.fetchService
`${this.URL}/search/${query}?${queryParams.toString()}`, .fetch(`${this.URL}/search/${query}?${queryParams.toString()}`, {
{
signal: AbortSignal.timeout(requestTimeout) signal: AbortSignal.timeout(requestTimeout)
} })
).then((res) => res.json()); .then((res) => res.json());
searchResult = response.map( searchResult = response.map(
({ Code, Currency, Exchange, ISIN: isin, Name: name, Type }) => { ({ Code, Currency, Exchange, ISIN: isin, Name: name, Type }) => {
@ -464,7 +464,7 @@ export class EodHistoricalDataService
).toFixed(3)} seconds`; ).toFixed(3)} seconds`;
} }
Logger.error(message, 'EodHistoricalDataService'); this.logger.error(message);
} }
return searchResult; return searchResult;

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

@ -9,6 +9,7 @@ import {
GetQuotesParams, GetQuotesParams,
GetSearchParams GetSearchParams
} from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface'; } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
import { FetchService } from '@ghostfolio/api/services/fetch/fetch.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { import {
DEFAULT_CURRENCY, DEFAULT_CURRENCY,
@ -48,6 +49,8 @@ import { uniqBy } from 'lodash';
export class FinancialModelingPrepService export class FinancialModelingPrepService
implements DataProviderInterface, OnModuleInit implements DataProviderInterface, OnModuleInit
{ {
private readonly logger = new Logger(FinancialModelingPrepService.name);
private static countriesMapping = { private static countriesMapping = {
'Korea (the Republic of)': 'South Korea', 'Korea (the Republic of)': 'South Korea',
'Russian Federation': 'Russia', 'Russian Federation': 'Russia',
@ -59,6 +62,7 @@ export class FinancialModelingPrepService
public constructor( public constructor(
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly cryptocurrencyService: CryptocurrencyService, private readonly cryptocurrencyService: CryptocurrencyService,
private readonly fetchService: FetchService,
private readonly prismaService: PrismaService private readonly prismaService: PrismaService
) {} ) {}
@ -96,12 +100,14 @@ export class FinancialModelingPrepService
apikey: this.apiKey apikey: this.apiKey
}); });
const [quote] = await fetch( const [quote] = await this.fetchService
.fetch(
`${this.getUrl({ version: 'stable' })}/quote?${queryParams.toString()}`, `${this.getUrl({ version: 'stable' })}/quote?${queryParams.toString()}`,
{ {
signal: AbortSignal.timeout(requestTimeout) signal: AbortSignal.timeout(requestTimeout)
} }
).then((res) => res.json()); )
.then((res) => res.json());
response.assetClass = AssetClass.LIQUIDITY; response.assetClass = AssetClass.LIQUIDITY;
response.assetSubClass = AssetSubClass.CRYPTOCURRENCY; response.assetSubClass = AssetSubClass.CRYPTOCURRENCY;
@ -115,12 +121,14 @@ export class FinancialModelingPrepService
apikey: this.apiKey apikey: this.apiKey
}); });
const [assetProfile] = await fetch( const [assetProfile] = await this.fetchService
.fetch(
`${this.getUrl({ version: 'stable' })}/profile?${queryParams.toString()}`, `${this.getUrl({ version: 'stable' })}/profile?${queryParams.toString()}`,
{ {
signal: AbortSignal.timeout(requestTimeout) signal: AbortSignal.timeout(requestTimeout)
} }
).then((res) => res.json()); )
.then((res) => res.json());
if (!assetProfile) { if (!assetProfile) {
throw new AssetProfileDelistedError( throw new AssetProfileDelistedError(
@ -143,12 +151,14 @@ export class FinancialModelingPrepService
apikey: this.apiKey apikey: this.apiKey
}); });
const etfCountryWeightings = await fetch( const etfCountryWeightings = await this.fetchService
.fetch(
`${this.getUrl({ version: 'stable' })}/etf/country-weightings?${queryParams.toString()}`, `${this.getUrl({ version: 'stable' })}/etf/country-weightings?${queryParams.toString()}`,
{ {
signal: AbortSignal.timeout(requestTimeout) signal: AbortSignal.timeout(requestTimeout)
} }
).then((res) => res.json()); )
.then((res) => res.json());
response.countries = etfCountryWeightings response.countries = etfCountryWeightings
.filter(({ country: countryName }) => { .filter(({ country: countryName }) => {
@ -174,12 +184,14 @@ export class FinancialModelingPrepService
}; };
}); });
const etfHoldings = await fetch( const etfHoldings = await this.fetchService
.fetch(
`${this.getUrl({ version: 'stable' })}/etf/holdings?${queryParams.toString()}`, `${this.getUrl({ version: 'stable' })}/etf/holdings?${queryParams.toString()}`,
{ {
signal: AbortSignal.timeout(requestTimeout) signal: AbortSignal.timeout(requestTimeout)
} }
).then((res) => res.json()); )
.then((res) => res.json());
const sortedTopHoldings = etfHoldings const sortedTopHoldings = etfHoldings
.sort((a, b) => { .sort((a, b) => {
@ -193,23 +205,27 @@ export class FinancialModelingPrepService
} }
); );
const [etfInformation] = await fetch( const [etfInformation] = await this.fetchService
.fetch(
`${this.getUrl({ version: 'stable' })}/etf/info?${queryParams.toString()}`, `${this.getUrl({ version: 'stable' })}/etf/info?${queryParams.toString()}`,
{ {
signal: AbortSignal.timeout(requestTimeout) signal: AbortSignal.timeout(requestTimeout)
} }
).then((res) => res.json()); )
.then((res) => res.json());
if (etfInformation?.website) { if (etfInformation?.website) {
response.url = etfInformation.website; response.url = etfInformation.website;
} }
const etfSectorWeightings = await fetch( const etfSectorWeightings = await this.fetchService
.fetch(
`${this.getUrl({ version: 'stable' })}/etf/sector-weightings?${queryParams.toString()}`, `${this.getUrl({ version: 'stable' })}/etf/sector-weightings?${queryParams.toString()}`,
{ {
signal: AbortSignal.timeout(requestTimeout) signal: AbortSignal.timeout(requestTimeout)
} }
).then((res) => res.json()); )
.then((res) => res.json());
response.sectors = etfSectorWeightings.map( response.sectors = etfSectorWeightings.map(
({ sector, weightPercentage }) => { ({ sector, weightPercentage }) => {
@ -251,7 +267,7 @@ export class FinancialModelingPrepService
).toFixed(3)} seconds`; ).toFixed(3)} seconds`;
} }
Logger.error(message, 'FinancialModelingPrepService'); this.logger.error(message);
} }
return response; return response;
@ -286,12 +302,14 @@ export class FinancialModelingPrepService
[date: string]: DataProviderHistoricalResponse; [date: string]: DataProviderHistoricalResponse;
} = {}; } = {};
const dividends = await fetch( const dividends = await this.fetchService
.fetch(
`${this.getUrl({ version: 'stable' })}/dividends?${queryParams.toString()}`, `${this.getUrl({ version: 'stable' })}/dividends?${queryParams.toString()}`,
{ {
signal: AbortSignal.timeout(requestTimeout) signal: AbortSignal.timeout(requestTimeout)
} }
).then((res) => res.json()); )
.then((res) => res.json());
dividends dividends
.filter(({ date }) => { .filter(({ date }) => {
@ -309,12 +327,11 @@ export class FinancialModelingPrepService
return response; return response;
} catch (error) { } catch (error) {
Logger.error( this.logger.error(
`Could not get dividends for ${symbol} (${this.getName()}) from ${format( `Could not get dividends for ${symbol} (${this.getName()}) from ${format(
from, from,
DATE_FORMAT DATE_FORMAT
)} to ${format(to, DATE_FORMAT)}: [${error.name}] ${error.message}`, )} to ${format(to, DATE_FORMAT)}: [${error.name}] ${error.message}`
'FinancialModelingPrepService'
); );
return {}; return {};
@ -354,12 +371,14 @@ export class FinancialModelingPrepService
to: format(currentTo, DATE_FORMAT) to: format(currentTo, DATE_FORMAT)
}); });
const historical = await fetch( const historical = await this.fetchService
.fetch(
`${this.getUrl({ version: 'stable' })}/historical-price-eod/full?${queryParams.toString()}`, `${this.getUrl({ version: 'stable' })}/historical-price-eod/full?${queryParams.toString()}`,
{ {
signal: AbortSignal.timeout(requestTimeout) signal: AbortSignal.timeout(requestTimeout)
} }
).then((res) => res.json()); )
.then((res) => res.json());
for (const { close, date } of historical) { for (const { close, date } of historical) {
if ( if (
@ -422,13 +441,16 @@ export class FinancialModelingPrepService
symbolTarget: { in: symbols } symbolTarget: { in: symbols }
} }
}), }),
fetch( this.fetchService
.fetch(
`${this.getUrl({ version: 'stable' })}/batch-quote-short?${queryParams.toString()}`, `${this.getUrl({ version: 'stable' })}/batch-quote-short?${queryParams.toString()}`,
{ {
signal: AbortSignal.timeout(requestTimeout) signal: AbortSignal.timeout(requestTimeout)
} }
).then( )
(res) => res.json() as unknown as { price: number; symbol: string }[] .then(
(res) =>
res.json() as unknown as { price: number; symbol: string }[]
) )
]); ]);
@ -497,7 +519,7 @@ export class FinancialModelingPrepService
).toFixed(3)} seconds`; ).toFixed(3)} seconds`;
} }
Logger.error(message, 'FinancialModelingPrepService'); this.logger.error(message);
} }
return response; return response;
@ -525,12 +547,14 @@ export class FinancialModelingPrepService
isin: query.toUpperCase() isin: query.toUpperCase()
}); });
const result = await fetch( const result = await this.fetchService
.fetch(
`${this.getUrl({ version: 'stable' })}/search-isin?${queryParams.toString()}`, `${this.getUrl({ version: 'stable' })}/search-isin?${queryParams.toString()}`,
{ {
signal: AbortSignal.timeout(requestTimeout) signal: AbortSignal.timeout(requestTimeout)
} }
).then((res) => res.json()); )
.then((res) => res.json());
await Promise.all( await Promise.all(
result.map(({ symbol }) => { result.map(({ symbol }) => {
@ -558,18 +582,22 @@ export class FinancialModelingPrepService
}); });
const [nameResults, symbolResults] = await Promise.all([ const [nameResults, symbolResults] = await Promise.all([
fetch( this.fetchService
.fetch(
`${this.getUrl({ version: 'stable' })}/search-name?${queryParams.toString()}`, `${this.getUrl({ version: 'stable' })}/search-name?${queryParams.toString()}`,
{ {
signal: AbortSignal.timeout(requestTimeout) signal: AbortSignal.timeout(requestTimeout)
} }
).then((res) => res.json()), )
fetch( .then((res) => res.json()),
this.fetchService
.fetch(
`${this.getUrl({ version: 'stable' })}/search-symbol?${queryParams.toString()}`, `${this.getUrl({ version: 'stable' })}/search-symbol?${queryParams.toString()}`,
{ {
signal: AbortSignal.timeout(requestTimeout) signal: AbortSignal.timeout(requestTimeout)
} }
).then((res) => res.json()) )
.then((res) => res.json())
]); ]);
const result = uniqBy( const result = uniqBy(
@ -611,7 +639,7 @@ export class FinancialModelingPrepService
).toFixed(3)} seconds`; ).toFixed(3)} seconds`;
} }
Logger.error(message, 'FinancialModelingPrepService'); this.logger.error(message);
} }
return { items }; return { items };

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

@ -8,6 +8,7 @@ import {
GetQuotesParams, GetQuotesParams,
GetSearchParams GetSearchParams
} from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface'; } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
import { FetchService } from '@ghostfolio/api/services/fetch/fetch.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { import {
HEADER_KEY_TOKEN, HEADER_KEY_TOKEN,
@ -32,12 +33,15 @@ import { StatusCodes } from 'http-status-codes';
@Injectable() @Injectable()
export class GhostfolioService implements DataProviderInterface { export class GhostfolioService implements DataProviderInterface {
private readonly logger = new Logger(GhostfolioService.name);
private readonly URL = environment.production private readonly URL = environment.production
? 'https://ghostfol.io/api' ? 'https://ghostfol.io/api'
: `${this.configurationService.get('ROOT_URL')}/api`; : `${this.configurationService.get('ROOT_URL')}/api`;
public constructor( public constructor(
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly fetchService: FetchService,
private readonly propertyService: PropertyService private readonly propertyService: PropertyService
) {} ) {}
@ -52,7 +56,7 @@ export class GhostfolioService implements DataProviderInterface {
let assetProfile: DataProviderGhostfolioAssetProfileResponse; let assetProfile: DataProviderGhostfolioAssetProfileResponse;
try { try {
const response = await fetch( const response = await this.fetchService.fetch(
`${this.URL}/v1/data-providers/ghostfolio/asset-profile/${symbol}`, `${this.URL}/v1/data-providers/ghostfolio/asset-profile/${symbol}`,
{ {
headers: await this.getRequestHeaders(), headers: await this.getRequestHeaders(),
@ -87,7 +91,7 @@ export class GhostfolioService implements DataProviderInterface {
'RequestError: The API key is invalid. Please update it in the Settings section of the Admin Control panel.'; 'RequestError: The API key is invalid. Please update it in the Settings section of the Admin Control panel.';
} }
Logger.error(message, 'GhostfolioService'); this.logger.error(message);
} }
return assetProfile; return assetProfile;
@ -122,7 +126,7 @@ export class GhostfolioService implements DataProviderInterface {
to: format(to, DATE_FORMAT) to: format(to, DATE_FORMAT)
}); });
const response = await fetch( const response = await this.fetchService.fetch(
`${this.URL}/v2/data-providers/ghostfolio/dividends/${symbol}?${queryParams.toString()}`, `${this.URL}/v2/data-providers/ghostfolio/dividends/${symbol}?${queryParams.toString()}`,
{ {
headers: await this.getRequestHeaders(), headers: await this.getRequestHeaders(),
@ -152,7 +156,7 @@ export class GhostfolioService implements DataProviderInterface {
'RequestError: The API key is invalid. Please update it in the Settings section of the Admin Control panel.'; 'RequestError: The API key is invalid. Please update it in the Settings section of the Admin Control panel.';
} }
Logger.error(message, 'GhostfolioService'); this.logger.error(message);
} }
return dividends; return dividends;
@ -174,7 +178,7 @@ export class GhostfolioService implements DataProviderInterface {
to: format(to, DATE_FORMAT) to: format(to, DATE_FORMAT)
}); });
const response = await fetch( const response = await this.fetchService.fetch(
`${this.URL}/v2/data-providers/ghostfolio/historical/${symbol}?${queryParams.toString()}`, `${this.URL}/v2/data-providers/ghostfolio/historical/${symbol}?${queryParams.toString()}`,
{ {
headers: await this.getRequestHeaders(), headers: await this.getRequestHeaders(),
@ -209,7 +213,7 @@ export class GhostfolioService implements DataProviderInterface {
'RequestError: The API key is invalid. Please update it in the Settings section of the Admin Control panel.'; 'RequestError: The API key is invalid. Please update it in the Settings section of the Admin Control panel.';
} }
Logger.error(error.message, 'GhostfolioService'); this.logger.error(error.message);
throw new Error( throw new Error(
`Could not get historical market data for ${symbol} (${this.getName()}) from ${format( `Could not get historical market data for ${symbol} (${this.getName()}) from ${format(
@ -245,7 +249,7 @@ export class GhostfolioService implements DataProviderInterface {
symbols: symbols.join(',') symbols: symbols.join(',')
}); });
const response = await fetch( const response = await this.fetchService.fetch(
`${this.URL}/v2/data-providers/ghostfolio/quotes?${queryParams.toString()}`, `${this.URL}/v2/data-providers/ghostfolio/quotes?${queryParams.toString()}`,
{ {
headers: await this.getRequestHeaders(), headers: await this.getRequestHeaders(),
@ -281,7 +285,7 @@ export class GhostfolioService implements DataProviderInterface {
'RequestError: The API key is invalid. Please update it in the Settings section of the Admin Control panel.'; 'RequestError: The API key is invalid. Please update it in the Settings section of the Admin Control panel.';
} }
Logger.error(message, 'GhostfolioService'); this.logger.error(message);
} }
return quotes; return quotes;
@ -302,7 +306,7 @@ export class GhostfolioService implements DataProviderInterface {
query query
}); });
const response = await fetch( const response = await this.fetchService.fetch(
`${this.URL}/v2/data-providers/ghostfolio/lookup?${queryParams.toString()}`, `${this.URL}/v2/data-providers/ghostfolio/lookup?${queryParams.toString()}`,
{ {
headers: await this.getRequestHeaders(), headers: await this.getRequestHeaders(),
@ -336,7 +340,7 @@ export class GhostfolioService implements DataProviderInterface {
'RequestError: The API key is invalid. Please update it in the Settings section of the Admin Control panel.'; 'RequestError: The API key is invalid. Please update it in the Settings section of the Admin Control panel.';
} }
Logger.error(message, 'GhostfolioService'); this.logger.error(message);
} }
return searchResult; return searchResult;

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

@ -24,6 +24,8 @@ import { GoogleSpreadsheet } from 'google-spreadsheet';
@Injectable() @Injectable()
export class GoogleSheetsService implements DataProviderInterface { export class GoogleSheetsService implements DataProviderInterface {
private readonly logger = new Logger(GoogleSheetsService.name);
public constructor( public constructor(
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
@ -144,7 +146,7 @@ export class GoogleSheetsService implements DataProviderInterface {
return response; return response;
} catch (error) { } catch (error) {
Logger.error(error, 'GoogleSheetsService'); this.logger.error(error);
} }
return {}; return {};

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

@ -8,6 +8,7 @@ import {
GetQuotesParams, GetQuotesParams,
GetSearchParams GetSearchParams
} from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface'; } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
import { FetchService } from '@ghostfolio/api/services/fetch/fetch.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { import {
@ -30,8 +31,11 @@ import { addDays, format, isBefore } from 'date-fns';
@Injectable() @Injectable()
export class ManualService implements DataProviderInterface { export class ManualService implements DataProviderInterface {
private readonly logger = new Logger(ManualService.name);
public constructor( public constructor(
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly fetchService: FetchService,
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
private readonly symbolProfileService: SymbolProfileService private readonly symbolProfileService: SymbolProfileService
) {} ) {}
@ -179,9 +183,8 @@ export class ManualService implements DataProviderInterface {
}); });
return { marketPrice, symbol }; return { marketPrice, symbol };
} catch (error) { } catch (error) {
Logger.error( this.logger.error(
`Could not get quote for ${symbol} (${this.getName()}): [${error.name}] ${error.message}`, `Could not get quote for ${symbol} (${this.getName()}): [${error.name}] ${error.message}`
'ManualService'
); );
return { symbol, marketPrice: undefined }; return { symbol, marketPrice: undefined };
} }
@ -214,7 +217,7 @@ export class ManualService implements DataProviderInterface {
return response; return response;
} catch (error) { } catch (error) {
Logger.error(error, 'ManualService'); this.logger.error(error);
} }
return {}; return {};
@ -292,7 +295,7 @@ export class ManualService implements DataProviderInterface {
}): Promise<number> { }): Promise<number> {
let locale = scraperConfiguration.locale; let locale = scraperConfiguration.locale;
const response = await fetch(scraperConfiguration.url, { const response = await this.fetchService.fetch(scraperConfiguration.url, {
headers: scraperConfiguration.headers as HeadersInit, headers: scraperConfiguration.headers as HeadersInit,
signal: AbortSignal.timeout( signal: AbortSignal.timeout(
this.configurationService.get('REQUEST_TIMEOUT') this.configurationService.get('REQUEST_TIMEOUT')

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

@ -7,6 +7,7 @@ import {
GetQuotesParams, GetQuotesParams,
GetSearchParams GetSearchParams
} from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface'; } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
import { FetchService } from '@ghostfolio/api/services/fetch/fetch.service';
import { import {
ghostfolioFearAndGreedIndexSymbol, ghostfolioFearAndGreedIndexSymbol,
ghostfolioFearAndGreedIndexSymbolStocks ghostfolioFearAndGreedIndexSymbolStocks
@ -25,8 +26,11 @@ import { format } from 'date-fns';
@Injectable() @Injectable()
export class RapidApiService implements DataProviderInterface { export class RapidApiService implements DataProviderInterface {
private readonly logger = new Logger(RapidApiService.name);
public constructor( public constructor(
private readonly configurationService: ConfigurationService private readonly configurationService: ConfigurationService,
private readonly fetchService: FetchService
) {} ) {}
public canHandle() { public canHandle() {
@ -120,7 +124,7 @@ export class RapidApiService implements DataProviderInterface {
}; };
} }
} catch (error) { } catch (error) {
Logger.error(error, 'RapidApiService'); this.logger.error(error);
} }
return {}; return {};
@ -142,9 +146,8 @@ export class RapidApiService implements DataProviderInterface {
oneYearAgo: { value: number; valueText: string }; oneYearAgo: { value: number; valueText: string };
}> { }> {
try { try {
const { fgi } = await fetch( const { fgi } = await this.fetchService
`https://fear-and-greed-index.p.rapidapi.com/v1/fgi`, .fetch(`https://fear-and-greed-index.p.rapidapi.com/v1/fgi`, {
{
headers: { headers: {
useQueryString: 'true', useQueryString: 'true',
'x-rapidapi-host': 'fear-and-greed-index.p.rapidapi.com', 'x-rapidapi-host': 'fear-and-greed-index.p.rapidapi.com',
@ -153,8 +156,8 @@ export class RapidApiService implements DataProviderInterface {
signal: AbortSignal.timeout( signal: AbortSignal.timeout(
this.configurationService.get('REQUEST_TIMEOUT') this.configurationService.get('REQUEST_TIMEOUT')
) )
} })
).then((res) => res.json()); .then((res) => res.json());
return fgi; return fgi;
} catch (error) { } catch (error) {
@ -166,7 +169,7 @@ export class RapidApiService implements DataProviderInterface {
).toFixed(3)} seconds`; ).toFixed(3)} seconds`;
} }
Logger.error(message, 'RapidApiService'); this.logger.error(message);
return undefined; return undefined;
} }

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

@ -41,6 +41,8 @@ import { SearchQuoteNonYahoo } from 'yahoo-finance2/esm/src/modules/search';
@Injectable() @Injectable()
export class YahooFinanceService implements DataProviderInterface { export class YahooFinanceService implements DataProviderInterface {
private readonly logger = new Logger(YahooFinanceService.name);
private readonly yahooFinance = new YahooFinance({ private readonly yahooFinance = new YahooFinance({
suppressNotices: ['yahooSurvey'] suppressNotices: ['yahooSurvey']
}); });
@ -105,12 +107,11 @@ export class YahooFinanceService implements DataProviderInterface {
return response; return response;
} catch (error) { } catch (error) {
Logger.error( this.logger.error(
`Could not get dividends for ${symbol} (${this.getName()}) from ${format( `Could not get dividends for ${symbol} (${this.getName()}) from ${format(
from, from,
DATE_FORMAT DATE_FORMAT
)} to ${format(to, DATE_FORMAT)}: [${error.name}] ${error.message}`, )} to ${format(to, DATE_FORMAT)}: [${error.name}] ${error.message}`
'YahooFinanceService'
); );
return {}; return {};
@ -198,12 +199,9 @@ export class YahooFinanceService implements DataProviderInterface {
try { try {
quotes = await this.yahooFinance.quote(yahooFinanceSymbols); quotes = await this.yahooFinance.quote(yahooFinanceSymbols);
} catch (error) { } catch (error) {
Logger.error(error, 'YahooFinanceService'); this.logger.error(error);
Logger.warn( this.logger.warn('Fallback to yahooFinance.quoteSummary()');
'Fallback to yahooFinance.quoteSummary()',
'YahooFinanceService'
);
quotes = await this.getQuotesWithQuoteSummary(yahooFinanceSymbols); quotes = await this.getQuotesWithQuoteSummary(yahooFinanceSymbols);
} }
@ -229,7 +227,7 @@ export class YahooFinanceService implements DataProviderInterface {
return response; return response;
} catch (error) { } catch (error) {
Logger.error(error, 'YahooFinanceService'); this.logger.error(error);
return {}; return {};
} }
@ -334,7 +332,7 @@ export class YahooFinanceService implements DataProviderInterface {
}); });
} }
} catch (error) { } catch (error) {
Logger.error(error, 'YahooFinanceService'); this.logger.error(error);
} }
return { items }; return { items };
@ -365,10 +363,7 @@ export class YahooFinanceService implements DataProviderInterface {
.filter( .filter(
(result): result is PromiseFulfilledResult<QuoteSummaryResult> => { (result): result is PromiseFulfilledResult<QuoteSummaryResult> => {
if (result.status === 'rejected') { if (result.status === 'rejected') {
Logger.error( this.logger.error(`Could not get quote summary: ${result.reason}`);
`Could not get quote summary: ${result.reason}`,
'YahooFinanceService'
);
return false; return false;
} }

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

@ -30,6 +30,8 @@ import { ExchangeRatesByCurrency } from './interfaces/exchange-rate-data.interfa
@Injectable() @Injectable()
export class ExchangeRateDataService { export class ExchangeRateDataService {
private readonly logger = new Logger(ExchangeRateDataService.name);
private currencies: string[] = []; private currencies: string[] = [];
private currencyPairs: DataGatheringItem[] = []; private currencyPairs: DataGatheringItem[] = [];
private derivedCurrencyFactors: { [currencyPair: string]: number } = {}; private derivedCurrencyFactors: { [currencyPair: string]: number } = {};
@ -110,9 +112,8 @@ export class ExchangeRateDataService {
previousExchangeRate; previousExchangeRate;
if (currency === DEFAULT_CURRENCY && isBefore(date, new Date())) { if (currency === DEFAULT_CURRENCY && isBefore(date, new Date())) {
Logger.error( this.logger.error(
`No exchange rate has been found for ${currency}${targetCurrency} at ${dateString}`, `No exchange rate has been found for ${currency}${targetCurrency} at ${dateString}`
'ExchangeRateDataService'
); );
} }
} else { } else {
@ -253,9 +254,8 @@ export class ExchangeRateDataService {
} }
// Fallback with error, if currencies are not available // Fallback with error, if currencies are not available
Logger.error( this.logger.error(
`No exchange rate has been found for ${aFromCurrency}${aToCurrency}`, `No exchange rate has been found for ${aFromCurrency}${aToCurrency}`
'ExchangeRateDataService'
); );
return aValue; return aValue;
@ -341,12 +341,11 @@ export class ExchangeRateDataService {
return factor * aValue; return factor * aValue;
} }
Logger.error( this.logger.error(
`No exchange rate has been found for ${aFromCurrency}${aToCurrency} at ${format( `No exchange rate has been found for ${aFromCurrency}${aToCurrency} at ${format(
aDate, aDate,
DATE_FORMAT DATE_FORMAT
)}`, )}`
'ExchangeRateDataService'
); );
return undefined; return undefined;
@ -483,7 +482,7 @@ export class ExchangeRateDataService {
errorMessage = `${errorMessage} and ${DEFAULT_CURRENCY}${currencyTo}`; errorMessage = `${errorMessage} and ${DEFAULT_CURRENCY}${currencyTo}`;
} }
Logger.error(`${errorMessage}.`, 'ExchangeRateDataService'); this.logger.error(`${errorMessage}.`);
} }
} }
} }

11
apps/api/src/services/fetch/fetch.module.ts

@ -0,0 +1,11 @@
import { FetchService } from '@ghostfolio/api/services/fetch/fetch.service';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { Module } from '@nestjs/common';
@Module({
exports: [FetchService],
imports: [PropertyModule],
providers: [FetchService]
})
export class FetchModule {}

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

@ -0,0 +1,199 @@
import { redactPaths } from '@ghostfolio/api/helper/object.helper';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import {
PROPERTY_API_KEY_OPENROUTER,
PROPERTY_OPENROUTER_MODEL,
PROPERTY_WEB_FETCH_ROUTES
} from '@ghostfolio/common/config';
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { createOpenRouter } from '@openrouter/ai-sdk-provider';
import { generateText, jsonSchema, tool } from 'ai';
import ms from 'ms';
import { WebFetchRoute } from './interfaces/web-fetch-route.interface';
@Injectable()
export class FetchService implements OnModuleInit {
private readonly logger = new Logger(FetchService.name);
private static readonly REDACTED_QUERY_PARAM_NAMES = ['apikey', 'api_token'];
private static readonly WEB_FETCH_TIMEOUT = ms('30 seconds');
private webFetchRoutes: WebFetchRoute[] = [];
public constructor(private readonly propertyService: PropertyService) {}
public async onModuleInit() {
this.webFetchRoutes =
(await this.propertyService.getByKey<WebFetchRoute[]>(
PROPERTY_WEB_FETCH_ROUTES
)) ?? [];
}
public async fetch(input: RequestInfo | URL, init?: RequestInit) {
const method = (
init?.method ??
(input instanceof Request ? input.method : undefined) ??
'GET'
).toUpperCase();
const url = input instanceof Request ? input.url : input.toString();
const urlRedacted = this.redactUrl(url);
this.logger.debug(`${method} ${urlRedacted}`);
if (method === 'GET') {
const webFetchRoute = this.getMatchingWebFetchRoute(url);
if (webFetchRoute) {
const response = await this.fetchViaWebFetchTool({
url,
webFetchRoute
});
if (response) {
return response;
}
}
}
try {
return await globalThis.fetch(input, init);
} catch (error) {
if (error instanceof Error) {
this.logger.error(
`${method} ${urlRedacted} failed: [${error.name}] ${error.message}`
);
} else {
this.logger.error(`${method} ${urlRedacted} failed: ${String(error)}`);
}
throw error;
}
}
private async fetchViaWebFetchTool({
url,
webFetchRoute
}: {
url: string;
webFetchRoute: WebFetchRoute;
}) {
const [openRouterApiKey, openRouterModel] = await Promise.all([
this.propertyService.getByKey<string>(PROPERTY_API_KEY_OPENROUTER),
this.propertyService.getByKey<string>(PROPERTY_OPENROUTER_MODEL)
]);
if (!openRouterApiKey || !openRouterModel) {
return undefined;
}
try {
const openRouterService = createOpenRouter({ apiKey: openRouterApiKey });
const { sources, text } = await generateText({
model: openRouterService.chat(openRouterModel),
prompt: [
'You have access to a web_fetch tool. You MUST call it to retrieve the URL below, do not answer from prior knowledge.',
'Return the fetched response body exactly as received: raw body only, no commentary, no Markdown, and no code fences.',
`URL: ${url}`
].join('\n'),
timeout: FetchService.WEB_FETCH_TIMEOUT,
tools: {
// Provider-defined tool: lets OpenRouter perform the actual web
// request server-side via its `web_fetch` engine. `id` and `args`
// are the OpenRouter-specific identifiers; the input schema is left
// open as the arguments are supplied by the model.
web_fetch: tool({
args: { engine: 'openrouter' },
id: 'openrouter.web_fetch',
inputSchema: jsonSchema({
additionalProperties: true,
type: 'object'
}),
type: 'provider'
})
}
});
const candidates = [
...(sources ?? []).map((source) => {
return source.providerMetadata?.openrouter?.content;
}),
text
];
for (const candidate of candidates) {
if (typeof candidate !== 'string') {
continue;
}
const body = candidate.trim();
if (!body) {
continue;
}
if (webFetchRoute.responseContentType?.includes('application/json')) {
try {
JSON.parse(body);
} catch {
continue;
}
}
this.logger.debug(`Routed ${this.redactUrl(url)} via web fetch tool`);
return new Response(body, {
headers: webFetchRoute.responseContentType
? { 'content-type': webFetchRoute.responseContentType }
: undefined
});
}
return undefined;
} catch (error) {
this.logger.error(
`Web fetch tool failed for ${this.redactUrl(url)}: ${
error instanceof Error ? error.message : String(error)
}`
);
return undefined;
}
}
private getMatchingWebFetchRoute(url: string) {
try {
const { hostname } = new URL(url);
return this.webFetchRoutes.find(({ domain }) => {
return hostname === domain || hostname.endsWith(`.${domain}`);
});
} catch {
return undefined;
}
}
private redactUrl(rawUrl: string): string {
try {
const url = new URL(rawUrl);
const redacted = redactPaths({
object: Object.fromEntries(url.searchParams),
paths: FetchService.REDACTED_QUERY_PARAM_NAMES
});
for (const [key, value] of Object.entries(redacted)) {
if (value === null) {
url.searchParams.set(key, '*******');
}
}
return url.toString();
} catch {
return rawUrl;
}
}
}

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

@ -0,0 +1,19 @@
/**
* Routes outgoing GET requests for a given domain through the OpenRouter
* `web_fetch` tool instead of a direct network request.
*
* Configured via the `WEB_FETCH_ROUTES` property as a JSON array, e.g.
*
* [
* {
* "domain": "example.com",
* "responseContentType": "application/json"
* }
* ]
*
* Matches the domain itself and its subdomains (e.g. `api.example.com`).
*/
export interface WebFetchRoute {
domain: string;
responseContentType?: string;
}

8
apps/api/src/services/i18n/i18n.service.ts

@ -7,6 +7,8 @@ import { join } from 'node:path';
@Injectable() @Injectable()
export class I18nService implements OnModuleInit { export class I18nService implements OnModuleInit {
private readonly logger = new Logger(I18nService.name);
private localesPath = join(__dirname, 'assets', 'locales'); private localesPath = join(__dirname, 'assets', 'locales');
private translations: { [locale: string]: cheerio.CheerioAPI } = {}; private translations: { [locale: string]: cheerio.CheerioAPI } = {};
@ -26,7 +28,7 @@ export class I18nService implements OnModuleInit {
const $ = this.translations[languageCode]; const $ = this.translations[languageCode];
if (!$) { if (!$) {
Logger.warn(`Translation not found for locale '${languageCode}'`); this.logger.warn(`Translation not found for locale '${languageCode}'`);
} }
let translatedText = $( let translatedText = $(
@ -36,7 +38,7 @@ export class I18nService implements OnModuleInit {
).text(); ).text();
if (!translatedText) { if (!translatedText) {
Logger.warn( this.logger.warn(
`Translation not found for id '${id}' in locale '${languageCode}'` `Translation not found for id '${id}' in locale '${languageCode}'`
); );
} }
@ -60,7 +62,7 @@ export class I18nService implements OnModuleInit {
this.parseXml(xmlData); this.parseXml(xmlData);
} }
} catch (error) { } catch (error) {
Logger.error(error, 'I18nService'); this.logger.error(error);
} }
} }

4
apps/api/src/services/prisma/prisma.service.ts

@ -14,6 +14,8 @@ export class PrismaService
extends PrismaClient extends PrismaClient
implements OnModuleInit, OnModuleDestroy implements OnModuleInit, OnModuleDestroy
{ {
private readonly logger = new Logger(PrismaService.name);
public constructor(configService: ConfigService) { public constructor(configService: ConfigService) {
const adapter = new PrismaPg({ const adapter = new PrismaPg({
connectionString: configService.get<string>('DATABASE_URL') connectionString: configService.get<string>('DATABASE_URL')
@ -43,7 +45,7 @@ export class PrismaService
try { try {
await this.$connect(); await this.$connect();
} catch (error) { } catch (error) {
Logger.error(error, 'PrismaService'); this.logger.error(error);
} }
} }

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

@ -32,6 +32,8 @@ import { DataGatheringService } from './data-gathering.service';
@Injectable() @Injectable()
@Processor(DATA_GATHERING_QUEUE) @Processor(DATA_GATHERING_QUEUE)
export class DataGatheringProcessor { export class DataGatheringProcessor {
private readonly logger = new Logger(DataGatheringProcessor.name);
public constructor( public constructor(
private readonly dataGatheringService: DataGatheringService, private readonly dataGatheringService: DataGatheringService,
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
@ -51,16 +53,14 @@ export class DataGatheringProcessor {
const { dataSource, symbol } = job.data; const { dataSource, symbol } = job.data;
try { try {
Logger.log( this.logger.log(
`Asset profile data gathering has been started for ${symbol} (${dataSource})`, `Asset profile data gathering has been started for ${symbol} (${dataSource})`
`DataGatheringProcessor (${GATHER_ASSET_PROFILE_PROCESS_JOB_NAME})`
); );
await this.dataGatheringService.gatherAssetProfiles([job.data]); await this.dataGatheringService.gatherAssetProfiles([job.data]);
Logger.log( this.logger.log(
`Asset profile data gathering has been completed for ${symbol} (${dataSource})`, `Asset profile data gathering has been completed for ${symbol} (${dataSource})`
`DataGatheringProcessor (${GATHER_ASSET_PROFILE_PROCESS_JOB_NAME})`
); );
} catch (error) { } catch (error) {
if (error instanceof AssetProfileDelistedError) { if (error instanceof AssetProfileDelistedError) {
@ -74,18 +74,14 @@ export class DataGatheringProcessor {
} }
); );
Logger.log( this.logger.log(
`Asset profile data gathering has been discarded for ${symbol} (${dataSource})`, `Asset profile data gathering has been discarded for ${symbol} (${dataSource})`
`DataGatheringProcessor (${GATHER_ASSET_PROFILE_PROCESS_JOB_NAME})`
); );
return job.discard(); return job.discard();
} }
Logger.error( this.logger.error(error);
error,
`DataGatheringProcessor (${GATHER_ASSET_PROFILE_PROCESS_JOB_NAME})`
);
throw error; throw error;
} }
@ -105,12 +101,11 @@ export class DataGatheringProcessor {
try { try {
let currentDate = parseISO(date as unknown as string); let currentDate = parseISO(date as unknown as string);
Logger.log( this.logger.log(
`Historical market data gathering has been started for ${symbol} (${dataSource}) at ${format( `Historical market data gathering has been started for ${symbol} (${dataSource}) at ${format(
currentDate, currentDate,
DATE_FORMAT DATE_FORMAT
)}${force ? ' (forced update)' : ''}`, )}${force ? ' (forced update)' : ''}`
`DataGatheringProcessor (${GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME})`
); );
const historicalData = await this.dataProviderService.getHistoricalRaw({ const historicalData = await this.dataProviderService.getHistoricalRaw({
@ -167,12 +162,11 @@ export class DataGatheringProcessor {
await this.marketDataService.updateMany({ data }); await this.marketDataService.updateMany({ data });
} }
Logger.log( this.logger.log(
`Historical market data gathering has been completed for ${symbol} (${dataSource}) at ${format( `Historical market data gathering has been completed for ${symbol} (${dataSource}) at ${format(
currentDate, currentDate,
DATE_FORMAT DATE_FORMAT
)}`, )}`
`DataGatheringProcessor (${GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME})`
); );
} catch (error) { } catch (error) {
if (error instanceof AssetProfileDelistedError) { if (error instanceof AssetProfileDelistedError) {
@ -186,18 +180,14 @@ export class DataGatheringProcessor {
} }
); );
Logger.log( this.logger.log(
`Historical market data gathering has been discarded for ${symbol} (${dataSource})`, `Historical market data gathering has been discarded for ${symbol} (${dataSource})`
`DataGatheringProcessor (${GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME})`
); );
return job.discard(); return job.discard();
} }
Logger.error( this.logger.error(error);
error,
`DataGatheringProcessor (${GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME})`
);
throw error; throw error;
} }

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

@ -34,6 +34,8 @@ import ms, { StringValue } from 'ms';
@Injectable() @Injectable()
export class DataGatheringService { export class DataGatheringService {
private readonly logger = new Logger(DataGatheringService.name);
public constructor( public constructor(
@Inject('DataEnhancers') @Inject('DataEnhancers')
private readonly dataEnhancers: DataEnhancerInterface[], private readonly dataEnhancers: DataEnhancerInterface[],
@ -145,7 +147,7 @@ export class DataGatheringService {
}); });
} }
} catch (error) { } catch (error) {
Logger.error(error, 'DataGatheringService'); this.logger.error(error);
} finally { } finally {
return undefined; return undefined;
} }
@ -176,30 +178,42 @@ export class DataGatheringService {
); );
for (const [symbol, assetProfile] of Object.entries(assetProfiles)) { for (const [symbol, assetProfile] of Object.entries(assetProfiles)) {
const symbolMapping = symbolProfiles.find((symbolProfile) => { const symbolProfile = symbolProfiles.find(
return symbolProfile.symbol === symbol; ({ symbol: symbolProfileSymbol }) => {
})?.symbolMapping; return symbolProfileSymbol === symbol;
}
);
const symbolMapping = symbolProfile?.symbolMapping;
let enhancedAssetProfile = symbolProfile
? {
...assetProfile,
assetClass: symbolProfile.assetClass ?? assetProfile.assetClass,
assetSubClass:
symbolProfile.assetSubClass ?? assetProfile.assetSubClass
}
: assetProfile;
for (const dataEnhancer of this.dataEnhancers) { for (const dataEnhancer of this.dataEnhancers) {
try { try {
assetProfiles[symbol] = await dataEnhancer.enhance({ enhancedAssetProfile = await dataEnhancer.enhance({
response: assetProfile, response: enhancedAssetProfile,
symbol: symbolMapping?.[dataEnhancer.getName()] ?? symbol symbol: symbolMapping?.[dataEnhancer.getName()] ?? symbol
}); });
} catch (error) { } catch (error) {
Logger.error( this.logger.error(
`Failed to enhance data for ${symbol} (${ `Failed to enhance data for ${symbol} (${
assetProfile.dataSource assetProfile.dataSource
}) by ${dataEnhancer.getName()}`, }) by ${dataEnhancer.getName()}`,
error, error
'DataGatheringService'
); );
} }
} }
const { assetClass, assetSubClass } = assetProfile;
const { const {
assetClass,
assetSubClass,
countries, countries,
currency, currency,
cusip, cusip,
@ -212,7 +226,7 @@ export class DataGatheringService {
name, name,
sectors, sectors,
url url
} = assetProfile; } = enhancedAssetProfile;
try { try {
await this.prismaService.symbolProfile.upsert({ await this.prismaService.symbolProfile.upsert({
@ -256,11 +270,7 @@ export class DataGatheringService {
} }
}); });
} catch (error) { } catch (error) {
Logger.error( this.logger.error(`${symbol}: ${error?.meta?.cause}`, error);
`${symbol}: ${error?.meta?.cause}`,
error,
'DataGatheringService'
);
if (assetProfileIdentifiers.length === 1) { if (assetProfileIdentifiers.length === 1) {
throw error; throw error;

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

@ -21,6 +21,8 @@ import { PortfolioSnapshotQueueJob } from './interfaces/portfolio-snapshot-queue
@Injectable() @Injectable()
@Processor(PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE) @Processor(PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE)
export class PortfolioSnapshotProcessor { export class PortfolioSnapshotProcessor {
private readonly logger = new Logger(PortfolioSnapshotProcessor.name);
public constructor( public constructor(
private readonly accountBalanceService: AccountBalanceService, private readonly accountBalanceService: AccountBalanceService,
private readonly activitiesService: ActivitiesService, private readonly activitiesService: ActivitiesService,
@ -41,9 +43,8 @@ export class PortfolioSnapshotProcessor {
try { try {
const startTime = performance.now(); const startTime = performance.now();
Logger.log( this.logger.log(
`Portfolio snapshot calculation of user '${job.data.userId}' has been started`, `Portfolio snapshot calculation of user '${job.data.userId}' has been started`
`PortfolioSnapshotProcessor (${PORTFOLIO_SNAPSHOT_PROCESS_JOB_NAME})`
); );
const { activities } = const { activities } =
@ -72,12 +73,11 @@ export class PortfolioSnapshotProcessor {
const snapshot = await portfolioCalculator.computeSnapshot(); const snapshot = await portfolioCalculator.computeSnapshot();
Logger.log( this.logger.log(
`Portfolio snapshot calculation of user '${job.data.userId}' has been completed in ${( `Portfolio snapshot calculation of user '${job.data.userId}' has been completed in ${(
(performance.now() - startTime) / (performance.now() - startTime) /
1000 1000
).toFixed(3)} seconds`, ).toFixed(3)} seconds`
`PortfolioSnapshotProcessor (${PORTFOLIO_SNAPSHOT_PROCESS_JOB_NAME})`
); );
const expiration = addMilliseconds( const expiration = addMilliseconds(
@ -101,10 +101,7 @@ export class PortfolioSnapshotProcessor {
return snapshot; return snapshot;
} catch (error) { } catch (error) {
Logger.error( this.logger.error(error);
error,
`PortfolioSnapshotProcessor (${PORTFOLIO_SNAPSHOT_PROCESS_JOB_NAME})`
);
throw new Error(error); throw new Error(error);
} }

2
apps/api/src/services/queues/statistics-gathering/statistics-gathering.module.ts

@ -1,4 +1,5 @@
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { FetchModule } from '@ghostfolio/api/services/fetch/fetch.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { STATISTICS_GATHERING_QUEUE } from '@ghostfolio/common/config'; import { STATISTICS_GATHERING_QUEUE } from '@ghostfolio/common/config';
@ -29,6 +30,7 @@ import { StatisticsGatheringService } from './statistics-gathering.service';
name: STATISTICS_GATHERING_QUEUE name: STATISTICS_GATHERING_QUEUE
}), }),
ConfigurationModule, ConfigurationModule,
FetchModule,
PropertyModule PropertyModule
], ],
providers: [StatisticsGatheringProcessor, StatisticsGatheringService] providers: [StatisticsGatheringProcessor, StatisticsGatheringService]

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

@ -1,4 +1,5 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { FetchService } from '@ghostfolio/api/services/fetch/fetch.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { import {
GATHER_STATISTICS_DOCKER_HUB_PULLS_PROCESS_JOB_NAME, GATHER_STATISTICS_DOCKER_HUB_PULLS_PROCESS_JOB_NAME,
@ -26,17 +27,17 @@ import { format, subDays } from 'date-fns';
@Injectable() @Injectable()
@Processor(STATISTICS_GATHERING_QUEUE) @Processor(STATISTICS_GATHERING_QUEUE)
export class StatisticsGatheringProcessor { export class StatisticsGatheringProcessor {
private readonly logger = new Logger(StatisticsGatheringProcessor.name);
public constructor( public constructor(
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly fetchService: FetchService,
private readonly propertyService: PropertyService private readonly propertyService: PropertyService
) {} ) {}
@Process(GATHER_STATISTICS_DOCKER_HUB_PULLS_PROCESS_JOB_NAME) @Process(GATHER_STATISTICS_DOCKER_HUB_PULLS_PROCESS_JOB_NAME)
public async gatherDockerHubPullsStatistics() { public async gatherDockerHubPullsStatistics() {
Logger.log( this.logger.log('Docker Hub pulls statistics gathering has been started');
'Docker Hub pulls statistics gathering has been started',
'StatisticsGatheringProcessor'
);
const dockerHubPulls = await this.countDockerHubPulls(); const dockerHubPulls = await this.countDockerHubPulls();
@ -45,17 +46,13 @@ export class StatisticsGatheringProcessor {
value: String(dockerHubPulls) value: String(dockerHubPulls)
}); });
Logger.log( this.logger.log('Docker Hub pulls statistics gathering has been completed');
'Docker Hub pulls statistics gathering has been completed',
'StatisticsGatheringProcessor'
);
} }
@Process(GATHER_STATISTICS_GITHUB_CONTRIBUTORS_PROCESS_JOB_NAME) @Process(GATHER_STATISTICS_GITHUB_CONTRIBUTORS_PROCESS_JOB_NAME)
public async gatherGitHubContributorsStatistics() { public async gatherGitHubContributorsStatistics() {
Logger.log( this.logger.log(
'GitHub contributors statistics gathering has been started', 'GitHub contributors statistics gathering has been started'
'StatisticsGatheringProcessor'
); );
const gitHubContributors = await this.countGitHubContributors(); const gitHubContributors = await this.countGitHubContributors();
@ -65,18 +62,14 @@ export class StatisticsGatheringProcessor {
value: String(gitHubContributors) value: String(gitHubContributors)
}); });
Logger.log( this.logger.log(
'GitHub contributors statistics gathering has been completed', 'GitHub contributors statistics gathering has been completed'
'StatisticsGatheringProcessor'
); );
} }
@Process(GATHER_STATISTICS_GITHUB_STARGAZERS_PROCESS_JOB_NAME) @Process(GATHER_STATISTICS_GITHUB_STARGAZERS_PROCESS_JOB_NAME)
public async gatherGitHubStargazersStatistics() { public async gatherGitHubStargazersStatistics() {
Logger.log( this.logger.log('GitHub stargazers statistics gathering has been started');
'GitHub stargazers statistics gathering has been started',
'StatisticsGatheringProcessor'
);
const gitHubStargazers = await this.countGitHubStargazers(); const gitHubStargazers = await this.countGitHubStargazers();
@ -85,9 +78,8 @@ export class StatisticsGatheringProcessor {
value: String(gitHubStargazers) value: String(gitHubStargazers)
}); });
Logger.log( this.logger.log(
'GitHub stargazers statistics gathering has been completed', 'GitHub stargazers statistics gathering has been completed'
'StatisticsGatheringProcessor'
); );
} }
@ -98,18 +90,14 @@ export class StatisticsGatheringProcessor {
); );
if (!monitorId) { if (!monitorId) {
Logger.log( this.logger.log(
`Uptime statistics gathering has been skipped as no ${PROPERTY_BETTER_UPTIME_MONITOR_ID} is configured`, `Uptime statistics gathering has been skipped as no ${PROPERTY_BETTER_UPTIME_MONITOR_ID} is configured`
'StatisticsGatheringProcessor'
); );
return; return;
} }
Logger.log( this.logger.log('Uptime statistics gathering has been started');
'Uptime statistics gathering has been started',
'StatisticsGatheringProcessor'
);
const uptime = await this.getUptime(monitorId); const uptime = await this.getUptime(monitorId);
@ -118,27 +106,23 @@ export class StatisticsGatheringProcessor {
value: String(uptime) value: String(uptime)
}); });
Logger.log( this.logger.log('Uptime statistics gathering has been completed');
'Uptime statistics gathering has been completed',
'StatisticsGatheringProcessor'
);
} }
private async countDockerHubPulls(): Promise<number> { private async countDockerHubPulls(): Promise<number> {
try { try {
const { pull_count } = (await fetch( const { pull_count } = (await this.fetchService
'https://hub.docker.com/v2/repositories/ghostfolio/ghostfolio', .fetch('https://hub.docker.com/v2/repositories/ghostfolio/ghostfolio', {
{
headers: { 'User-Agent': 'request' }, headers: { 'User-Agent': 'request' },
signal: AbortSignal.timeout( signal: AbortSignal.timeout(
this.configurationService.get('REQUEST_TIMEOUT') this.configurationService.get('REQUEST_TIMEOUT')
) )
} })
).then((res) => res.json())) as { pull_count: number }; .then((res) => res.json())) as { pull_count: number };
return pull_count; return pull_count;
} catch (error) { } catch (error) {
Logger.error(error, 'StatisticsGatheringProcessor - DockerHub'); this.logger.error(error);
throw error; throw error;
} }
@ -146,11 +130,13 @@ export class StatisticsGatheringProcessor {
private async countGitHubContributors(): Promise<number> { private async countGitHubContributors(): Promise<number> {
try { try {
const body = await fetch('https://github.com/ghostfolio/ghostfolio', { const body = await this.fetchService
.fetch('https://github.com/ghostfolio/ghostfolio', {
signal: AbortSignal.timeout( signal: AbortSignal.timeout(
this.configurationService.get('REQUEST_TIMEOUT') this.configurationService.get('REQUEST_TIMEOUT')
) )
}).then((res) => res.text()); })
.then((res) => res.text());
const $ = cheerio.load(body); const $ = cheerio.load(body);
@ -166,7 +152,7 @@ export class StatisticsGatheringProcessor {
value value
}); });
} catch (error) { } catch (error) {
Logger.error(error, 'StatisticsGatheringProcessor - GitHub'); this.logger.error(error);
throw error; throw error;
} }
@ -174,19 +160,18 @@ export class StatisticsGatheringProcessor {
private async countGitHubStargazers(): Promise<number> { private async countGitHubStargazers(): Promise<number> {
try { try {
const { stargazers_count } = (await fetch( const { stargazers_count } = (await this.fetchService
'https://api.github.com/repos/ghostfolio/ghostfolio', .fetch('https://api.github.com/repos/ghostfolio/ghostfolio', {
{
headers: { 'User-Agent': 'request' }, headers: { 'User-Agent': 'request' },
signal: AbortSignal.timeout( signal: AbortSignal.timeout(
this.configurationService.get('REQUEST_TIMEOUT') this.configurationService.get('REQUEST_TIMEOUT')
) )
} })
).then((res) => res.json())) as { stargazers_count: number }; .then((res) => res.json())) as { stargazers_count: number };
return stargazers_count; return stargazers_count;
} catch (error) { } catch (error) {
Logger.error(error, 'StatisticsGatheringProcessor - GitHub'); this.logger.error(error);
throw error; throw error;
} }
@ -194,7 +179,8 @@ export class StatisticsGatheringProcessor {
private async getUptime(monitorId: string): Promise<number> { private async getUptime(monitorId: string): Promise<number> {
try { try {
const { data } = await fetch( const { data } = await this.fetchService
.fetch(
`https://uptime.betterstack.com/api/v2/monitors/${monitorId}/sla?from=${format( `https://uptime.betterstack.com/api/v2/monitors/${monitorId}/sla?from=${format(
subDays(new Date(), 90), subDays(new Date(), 90),
DATE_FORMAT DATE_FORMAT
@ -209,11 +195,12 @@ export class StatisticsGatheringProcessor {
this.configurationService.get('REQUEST_TIMEOUT') this.configurationService.get('REQUEST_TIMEOUT')
) )
} }
).then((res) => res.json()); )
.then((res) => res.json());
return data.attributes.availability / 100; return data.attributes.availability / 100;
} catch (error) { } catch (error) {
Logger.error(error, 'StatisticsGatheringProcessor - Better Stack'); this.logger.error(error);
throw error; throw error;
} }

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

@ -1,5 +1,6 @@
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { UNKNOWN_KEY } from '@ghostfolio/common/config'; import { UNKNOWN_KEY } from '@ghostfolio/common/config';
import { applyAssetProfileOverrides } from '@ghostfolio/common/helper';
import { import {
AssetProfileIdentifier, AssetProfileIdentifier,
EnhancedSymbolProfile, EnhancedSymbolProfile,
@ -192,21 +193,28 @@ export class SymbolProfileService {
})[] })[]
): EnhancedSymbolProfile[] { ): EnhancedSymbolProfile[] {
return symbolProfiles.map((symbolProfile) => { return symbolProfiles.map((symbolProfile) => {
const symbolProfileWithOverrides = applyAssetProfileOverrides(
symbolProfile,
symbolProfile.SymbolProfileOverrides
);
const item = { const item = {
...symbolProfile, ...symbolProfileWithOverrides,
activitiesCount: 0, activitiesCount: 0,
countries: this.getCountries( countries: this.getCountries(
symbolProfile?.countries as unknown as Prisma.JsonArray symbolProfileWithOverrides?.countries as unknown as Prisma.JsonArray
), ),
dateOfFirstActivity: undefined as Date, dateOfFirstActivity: undefined as Date,
holdings: this.getHoldings( holdings: this.getHoldings(
symbolProfile?.holdings as unknown as Prisma.JsonArray symbolProfileWithOverrides?.holdings as unknown as Prisma.JsonArray
),
scraperConfiguration: this.getScraperConfiguration(
symbolProfileWithOverrides
), ),
scraperConfiguration: this.getScraperConfiguration(symbolProfile),
sectors: this.getSectors( sectors: this.getSectors(
symbolProfile?.sectors as unknown as Prisma.JsonArray symbolProfileWithOverrides?.sectors as unknown as Prisma.JsonArray
), ),
symbolMapping: this.getSymbolMapping(symbolProfile), symbolMapping: this.getSymbolMapping(symbolProfileWithOverrides),
watchedByCount: 0 watchedByCount: 0
}; };
@ -217,45 +225,7 @@ export class SymbolProfileService {
item.dateOfFirstActivity = symbolProfile.activities?.[0]?.date; item.dateOfFirstActivity = symbolProfile.activities?.[0]?.date;
delete item.activities; delete item.activities;
if (item.SymbolProfileOverrides) {
item.assetClass =
item.SymbolProfileOverrides.assetClass ?? item.assetClass;
item.assetSubClass =
item.SymbolProfileOverrides.assetSubClass ?? item.assetSubClass;
if (
(item.SymbolProfileOverrides.countries as unknown as Prisma.JsonArray)
?.length > 0
) {
item.countries = this.getCountries(
item.SymbolProfileOverrides.countries as unknown as Prisma.JsonArray
);
}
if (
(item.SymbolProfileOverrides.holdings as unknown as Holding[])
?.length > 0
) {
item.holdings = this.getHoldings(
item.SymbolProfileOverrides.holdings as unknown as Prisma.JsonArray
);
}
item.name = item.SymbolProfileOverrides.name ?? item.name;
if (
(item.SymbolProfileOverrides.sectors as unknown as Sector[])?.length >
0
) {
item.sectors = this.getSectors(
item.SymbolProfileOverrides.sectors as unknown as Prisma.JsonArray
);
}
item.url = item.SymbolProfileOverrides.url ?? item.url;
delete item.SymbolProfileOverrides; delete item.SymbolProfileOverrides;
}
return item; return item;
}); });

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

@ -16,6 +16,8 @@ import { TwitterApi, TwitterApiReadWrite } from 'twitter-api-v2';
@Injectable() @Injectable()
export class TwitterBotService implements OnModuleInit { export class TwitterBotService implements OnModuleInit {
private readonly logger = new Logger(TwitterBotService.name);
private twitterClient: TwitterApiReadWrite; private twitterClient: TwitterApiReadWrite;
public constructor( public constructor(
@ -71,13 +73,12 @@ export class TwitterBotService implements OnModuleInit {
const { data: createdTweet } = const { data: createdTweet } =
await this.twitterClient.v2.tweet(status); await this.twitterClient.v2.tweet(status);
Logger.log( this.logger.log(
`Fear & Greed Index has been posted: https://x.com/ghostfolio_/status/${createdTweet.id}`, `Fear & Greed Index has been posted: https://x.com/ghostfolio_/status/${createdTweet.id}`
'TwitterBotService'
); );
} }
} catch (error) { } catch (error) {
Logger.error(error, 'TwitterBotService'); this.logger.error(error);
} }
} }

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

@ -18,6 +18,7 @@ import { AdminMarketDataItem } from '@ghostfolio/common/interfaces/admin-market-
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { GfSymbolPipe } from '@ghostfolio/common/pipes'; import { GfSymbolPipe } from '@ghostfolio/common/pipes';
import { GfActivitiesFilterComponent } from '@ghostfolio/ui/activities-filter'; import { GfActivitiesFilterComponent } from '@ghostfolio/ui/activities-filter';
import { GfFabComponent } from '@ghostfolio/ui/fab';
import { translate } from '@ghostfolio/ui/i18n'; import { translate } from '@ghostfolio/ui/i18n';
import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator'; import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator';
import { AdminService, DataService } from '@ghostfolio/ui/services'; import { AdminService, DataService } from '@ghostfolio/ui/services';
@ -80,10 +81,10 @@ import { CreateAssetProfileDialogParams } from './create-asset-profile-dialog/in
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'has-fab' },
imports: [ imports: [
CommonModule, CommonModule,
GfActivitiesFilterComponent, GfActivitiesFilterComponent,
GfFabComponent,
GfPremiumIndicatorComponent, GfPremiumIndicatorComponent,
GfSymbolPipe, GfSymbolPipe,
GfValueComponent, GfValueComponent,

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

@ -332,15 +332,5 @@
</div> </div>
</div> </div>
<div class="fab-container"> <gf-fab [queryParams]="{ createAssetProfileDialog: true }" />
<a
class="align-items-center d-flex justify-content-center"
color="primary"
mat-fab
[queryParams]="{ createAssetProfileDialog: true }"
[routerLink]="[]"
>
<ion-icon name="add-outline" size="large" />
</a>
</div>
</div> </div>

4
apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.scss

@ -14,4 +14,8 @@
top: 0; top: 0;
} }
} }
.mat-mdc-dialog-title {
padding-right: 0.5rem !important;
}
} }

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

@ -1,10 +1,10 @@
<div class="d-flex flex-column h-100"> <div class="d-flex flex-column h-100">
<div class="d-flex mb-3"> <h1 class="align-items-center d-flex mb-3" mat-dialog-title>
<h1 class="flex-grow-1 m-0" mat-dialog-title> <span class="flex-grow-1 text-truncate">{{
<span>{{ assetProfile?.name ?? data.symbol }}</span> assetProfile?.name ?? data.symbol
</h1> }}</span>
<button <button
class="mx-1 no-min-width px-2" class="ml-1 no-min-width px-2"
mat-button mat-button
type="button" type="button"
[matMenuTriggerFor]="assetProfileActionsMenu" [matMenuTriggerFor]="assetProfileActionsMenu"
@ -87,7 +87,7 @@
<ng-container i18n>Delete</ng-container> <ng-container i18n>Delete</ng-container>
</button> </button>
</mat-menu> </mat-menu>
</div> </h1>
<div class="flex-grow-1" mat-dialog-content> <div class="flex-grow-1" mat-dialog-content>
<gf-line-chart <gf-line-chart

20
apps/client/src/app/components/admin-users/admin-users.component.ts

@ -59,8 +59,10 @@ import {
personOutline, personOutline,
trashOutline trashOutline
} from 'ionicons/icons'; } from 'ionicons/icons';
import ms from 'ms';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { interval } from 'rxjs';
import { switchMap, tap } from 'rxjs/operators'; import { switchMap, tap } from 'rxjs/operators';
@Component({ @Component({
@ -184,6 +186,15 @@ export class GfAdminUsersComponent implements OnInit {
public ngOnInit() { public ngOnInit() {
this.fetchUsers(); this.fetchUsers();
interval(ms('30 seconds'))
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
this.fetchUsers({
pageIndex: this.paginator().pageIndex,
showLoading: false
});
});
} }
protected formatDistanceToNow(aDateString: string) { protected formatDistanceToNow(aDateString: string) {
@ -267,8 +278,13 @@ export class GfAdminUsersComponent implements OnInit {
); );
} }
private fetchUsers({ pageIndex }: { pageIndex: number } = { pageIndex: 0 }) { private fetchUsers({
pageIndex = 0,
showLoading = true
}: { pageIndex?: number; showLoading?: boolean } = {}) {
if (showLoading) {
this.isLoading = true; this.isLoading = true;
}
if (pageIndex === 0 && this.paginator()) { if (pageIndex === 0 && this.paginator()) {
this.paginator().pageIndex = 0; this.paginator().pageIndex = 0;
@ -281,7 +297,7 @@ export class GfAdminUsersComponent implements OnInit {
}) })
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ count, users }) => { .subscribe(({ count, users }) => {
this.dataSource = new MatTableDataSource(users); this.dataSource.data = users;
this.totalItems = count; this.totalItems = count;
this.isLoading = false; this.isLoading = false;

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

@ -8,6 +8,7 @@ import {
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { GfBenchmarkComponent } from '@ghostfolio/ui/benchmark'; import { GfBenchmarkComponent } from '@ghostfolio/ui/benchmark';
import { GfFabComponent } from '@ghostfolio/ui/fab';
import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator'; import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator';
import { DataService } from '@ghostfolio/ui/services'; import { DataService } from '@ghostfolio/ui/services';
@ -22,12 +23,8 @@ import {
OnInit OnInit
} from '@angular/core'; } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog'; import { MatDialog } from '@angular/material/dialog';
import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { IonIcon } from '@ionic/angular/standalone';
import { addIcons } from 'ionicons';
import { addOutline } from 'ionicons/icons';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
import { GfCreateWatchlistItemDialogComponent } from './create-watchlist-item-dialog/create-watchlist-item-dialog.component'; import { GfCreateWatchlistItemDialogComponent } from './create-watchlist-item-dialog/create-watchlist-item-dialog.component';
@ -37,9 +34,8 @@ import { CreateWatchlistItemDialogParams } from './create-watchlist-item-dialog/
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ imports: [
GfBenchmarkComponent, GfBenchmarkComponent,
GfFabComponent,
GfPremiumIndicatorComponent, GfPremiumIndicatorComponent,
IonIcon,
MatButtonModule,
RouterModule RouterModule
], ],
schemas: [CUSTOM_ELEMENTS_SCHEMA], schemas: [CUSTOM_ELEMENTS_SCHEMA],
@ -108,8 +104,6 @@ export class GfHomeWatchlistComponent implements OnInit {
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
} }
}); });
addIcons({ addOutline });
} }
public ngOnInit() { public ngOnInit() {

12
apps/client/src/app/components/home-watchlist/home-watchlist.html

@ -22,15 +22,5 @@
</div> </div>
</div> </div>
@if (!hasImpersonationId && hasPermissionToCreateWatchlistItem) { @if (!hasImpersonationId && hasPermissionToCreateWatchlistItem) {
<div class="fab-container"> <gf-fab [queryParams]="{ createWatchlistItemDialog: true }" />
<a
class="align-items-center d-flex justify-content-center"
color="primary"
mat-fab
[queryParams]="{ createWatchlistItemDialog: true }"
[routerLink]="[]"
>
<ion-icon name="add-outline" size="large" />
</a>
</div>
} }

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

@ -4,6 +4,7 @@ import { CreateAccessDto } from '@ghostfolio/common/dtos';
import { ConfirmationDialogType } from '@ghostfolio/common/enums'; import { ConfirmationDialogType } from '@ghostfolio/common/enums';
import { Access, User } from '@ghostfolio/common/interfaces'; import { Access, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { GfFabComponent } from '@ghostfolio/ui/fab';
import { NotificationService } from '@ghostfolio/ui/notifications'; import { NotificationService } from '@ghostfolio/ui/notifications';
import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator'; import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator';
import { DataService } from '@ghostfolio/ui/services'; import { DataService } from '@ghostfolio/ui/services';
@ -42,9 +43,9 @@ import { CreateOrUpdateAccessDialogParams } from './create-or-update-access-dial
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'has-fab' },
imports: [ imports: [
GfAccessTableComponent, GfAccessTableComponent,
GfFabComponent,
GfPremiumIndicatorComponent, GfPremiumIndicatorComponent,
IonIcon, IonIcon,
MatButtonModule, MatButtonModule,

12
apps/client/src/app/components/user-account-access/user-account-access.html

@ -69,16 +69,6 @@
(accessToUpdate)="onUpdateAccess($event)" (accessToUpdate)="onUpdateAccess($event)"
/> />
@if (hasPermissionToCreateAccess) { @if (hasPermissionToCreateAccess) {
<div class="fab-container"> <gf-fab [queryParams]="{ createDialog: true }" />
<a
class="align-items-center d-flex justify-content-center"
color="primary"
mat-fab
[queryParams]="{ createDialog: true }"
[routerLink]="[]"
>
<ion-icon name="add-outline" size="large" />
</a>
</div>
} }
</div> </div>

12
apps/client/src/app/core/auth.guard.ts

@ -3,7 +3,7 @@ import { UserService } from '@ghostfolio/client/services/user/user.service';
import { internalRoutes, publicRoutes } from '@ghostfolio/common/routes/routes'; import { internalRoutes, publicRoutes } from '@ghostfolio/common/routes/routes';
import { DataService } from '@ghostfolio/ui/services'; import { DataService } from '@ghostfolio/ui/services';
import { Injectable } from '@angular/core'; import { inject, Injectable } from '@angular/core';
import { import {
ActivatedRouteSnapshot, ActivatedRouteSnapshot,
Router, Router,
@ -14,12 +14,10 @@ import { catchError } from 'rxjs/operators';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class AuthGuard { export class AuthGuard {
public constructor( private readonly dataService = inject(DataService);
private dataService: DataService, private readonly router = inject(Router);
private router: Router, private readonly settingsStorageService = inject(SettingsStorageService);
private settingsStorageService: SettingsStorageService, private readonly userService = inject(UserService);
private userService: UserService
) {}
canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot) { canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot) {
const utmSource = route.queryParams?.utm_source; const utmSource = route.queryParams?.utm_source;

16
apps/client/src/app/core/auth.interceptor.ts

@ -13,20 +13,20 @@ import {
HttpInterceptor, HttpInterceptor,
HttpRequest HttpRequest
} from '@angular/common/http'; } from '@angular/common/http';
import { Injectable } from '@angular/core'; import { inject, Injectable } from '@angular/core';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
@Injectable() @Injectable()
export class AuthInterceptor implements HttpInterceptor { export class AuthInterceptor implements HttpInterceptor {
public constructor( private readonly impersonationStorageService = inject(
private impersonationStorageService: ImpersonationStorageService, ImpersonationStorageService
private tokenStorageService: TokenStorageService );
) {} private readonly tokenStorageService = inject(TokenStorageService);
public intercept( public intercept<T>(
req: HttpRequest<any>, req: HttpRequest<T>,
next: HttpHandler next: HttpHandler
): Observable<HttpEvent<any>> { ): Observable<HttpEvent<T>> {
let request = req; let request = req;
if (request.headers.has(HEADER_KEY_SKIP_INTERCEPTOR)) { if (request.headers.has(HEADER_KEY_SKIP_INTERCEPTOR)) {

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

@ -12,7 +12,7 @@ import {
HttpInterceptor, HttpInterceptor,
HttpRequest HttpRequest
} from '@angular/common/http'; } from '@angular/common/http';
import { Injectable } from '@angular/core'; import { inject, Injectable } from '@angular/core';
import { import {
MatSnackBar, MatSnackBar,
MatSnackBarRef, MatSnackBarRef,
@ -22,31 +22,28 @@ import { Router } from '@angular/router';
import { StatusCodes } from 'http-status-codes'; import { StatusCodes } from 'http-status-codes';
import ms from 'ms'; import ms from 'ms';
import { Observable, throwError } from 'rxjs'; import { Observable, throwError } from 'rxjs';
import { catchError, tap } from 'rxjs/operators'; import { catchError } from 'rxjs/operators';
@Injectable() @Injectable()
export class HttpResponseInterceptor implements HttpInterceptor { export class HttpResponseInterceptor implements HttpInterceptor {
public info: InfoItem; private readonly info: InfoItem;
public snackBarRef: MatSnackBarRef<TextOnlySnackBar>; private snackBarRef: MatSnackBarRef<TextOnlySnackBar> | undefined;
public constructor( private readonly dataService = inject(DataService);
private dataService: DataService, private readonly router = inject(Router);
private router: Router, private readonly snackBar = inject(MatSnackBar);
private snackBar: MatSnackBar, private readonly userService = inject(UserService);
private userService: UserService, private readonly webAuthnService = inject(WebAuthnService);
private webAuthnService: WebAuthnService
) { public constructor() {
this.info = this.dataService.fetchInfo(); this.info = this.dataService.fetchInfo();
} }
public intercept( public intercept<T>(
request: HttpRequest<any>, request: HttpRequest<T>,
next: HttpHandler next: HttpHandler
): Observable<HttpEvent<any>> { ): Observable<HttpEvent<T>> {
return next.handle(request).pipe( return next.handle(request).pipe(
tap((event: HttpEvent<any>) => {
return event;
}),
catchError((error: HttpErrorResponse) => { catchError((error: HttpErrorResponse) => {
if (error.status === StatusCodes.FORBIDDEN) { if (error.status === StatusCodes.FORBIDDEN) {
if (!this.snackBarRef) { if (!this.snackBarRef) {
@ -61,7 +58,7 @@ export class HttpResponseInterceptor implements HttpInterceptor {
} }
); );
} else if ( } else if (
!error.url.includes(internalRoutes.auth.routerLink.join('')) !error.url?.includes(internalRoutes.auth.routerLink.join(''))
) { ) {
this.snackBarRef = this.snackBar.open( this.snackBarRef = this.snackBar.open(
$localize`This action is not allowed.`, $localize`This action is not allowed.`,
@ -72,11 +69,11 @@ export class HttpResponseInterceptor implements HttpInterceptor {
); );
} }
this.snackBarRef.afterDismissed().subscribe(() => { this.snackBarRef?.afterDismissed().subscribe(() => {
this.snackBarRef = undefined; this.snackBarRef = undefined;
}); });
this.snackBarRef.onAction().subscribe(() => { this.snackBarRef?.onAction().subscribe(() => {
this.router.navigate(publicRoutes.pricing.routerLink); this.router.navigate(publicRoutes.pricing.routerLink);
}); });
} }
@ -92,11 +89,11 @@ export class HttpResponseInterceptor implements HttpInterceptor {
} }
); );
this.snackBarRef.afterDismissed().subscribe(() => { this.snackBarRef?.afterDismissed().subscribe(() => {
this.snackBarRef = undefined; this.snackBarRef = undefined;
}); });
this.snackBarRef.onAction().subscribe(() => { this.snackBarRef?.onAction().subscribe(() => {
window.location.reload(); window.location.reload();
}); });
} }
@ -106,12 +103,12 @@ export class HttpResponseInterceptor implements HttpInterceptor {
$localize`Oops! It looks like you’re making too many requests. Please slow down a bit.` $localize`Oops! It looks like you’re making too many requests. Please slow down a bit.`
); );
this.snackBarRef.afterDismissed().subscribe(() => { this.snackBarRef?.afterDismissed().subscribe(() => {
this.snackBarRef = undefined; this.snackBarRef = undefined;
}); });
} }
} else if (error.status === StatusCodes.UNAUTHORIZED) { } else if (error.status === StatusCodes.UNAUTHORIZED) {
if (!error.url.includes('/data-providers/ghostfolio/status')) { if (!error.url?.includes('/data-providers/ghostfolio/status')) {
if (this.webAuthnService.isEnabled()) { if (this.webAuthnService.isEnabled()) {
this.router.navigate(internalRoutes.webauthn.routerLink); this.router.navigate(internalRoutes.webauthn.routerLink);
} else { } else {

18
apps/client/src/app/directives/file-drop/file-drop.directive.ts

@ -1,28 +1,34 @@
import { Directive, EventEmitter, HostListener, Output } from '@angular/core'; import { Directive, output } from '@angular/core';
@Directive({ @Directive({
host: {
'(dragenter)': 'onDragEnter($event)',
'(dragover)': 'onDragOver($event)',
'(drop)': 'onDrop($event)'
},
selector: '[gfFileDrop]' selector: '[gfFileDrop]'
}) })
export class GfFileDropDirective { export class GfFileDropDirective {
@Output() filesDropped = new EventEmitter<FileList>(); public readonly filesDropped = output<FileList>();
@HostListener('dragenter', ['$event']) onDragEnter(event: DragEvent) { public onDragEnter(event: DragEvent) {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
} }
@HostListener('dragover', ['$event']) onDragOver(event: DragEvent) { public onDragOver(event: DragEvent) {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
} }
@HostListener('drop', ['$event']) onDrop(event: DragEvent) { public onDrop(event: DragEvent) {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
if (event.dataTransfer) {
// Prevent the browser's default behavior for handling the file drop // Prevent the browser's default behavior for handling the file drop
event.dataTransfer.dropEffect = 'copy'; event.dataTransfer.dropEffect = 'copy';
this.filesDropped.emit(event.dataTransfer.files); this.filesDropped.emit(event.dataTransfer.files);
} }
} }
}

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

@ -10,6 +10,7 @@ import {
import { User } from '@ghostfolio/common/interfaces'; import { User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { GfAccountsTableComponent } from '@ghostfolio/ui/accounts-table'; import { GfAccountsTableComponent } from '@ghostfolio/ui/accounts-table';
import { GfFabComponent } from '@ghostfolio/ui/fab';
import { NotificationService } from '@ghostfolio/ui/notifications'; import { NotificationService } from '@ghostfolio/ui/notifications';
import { DataService } from '@ghostfolio/ui/services'; import { DataService } from '@ghostfolio/ui/services';
@ -20,12 +21,9 @@ import {
OnInit OnInit
} from '@angular/core'; } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog'; import { MatDialog } from '@angular/material/dialog';
import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { Account as AccountModel } from '@prisma/client'; import { Account as AccountModel } from '@prisma/client';
import { addIcons } from 'ionicons';
import { addOutline } from 'ionicons/icons';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
import { EMPTY, Subscription } from 'rxjs'; import { EMPTY, Subscription } from 'rxjs';
import { catchError } from 'rxjs/operators'; import { catchError } from 'rxjs/operators';
@ -36,8 +34,8 @@ import { TransferBalanceDialogParams } from './transfer-balance/interfaces/inter
import { GfTransferBalanceDialogComponent } from './transfer-balance/transfer-balance-dialog.component'; import { GfTransferBalanceDialogComponent } from './transfer-balance/transfer-balance-dialog.component';
@Component({ @Component({
host: { class: 'has-fab page' }, host: { class: 'page' },
imports: [GfAccountsTableComponent, MatButtonModule, RouterModule], imports: [GfAccountsTableComponent, GfFabComponent, RouterModule],
selector: 'gf-accounts-page', selector: 'gf-accounts-page',
styleUrls: ['./accounts-page.scss'], styleUrls: ['./accounts-page.scss'],
templateUrl: './accounts-page.html' templateUrl: './accounts-page.html'
@ -90,8 +88,6 @@ export class GfAccountsPageComponent implements OnInit {
this.openTransferBalanceDialog(); this.openTransferBalanceDialog();
} }
}); });
addIcons({ addOutline });
} }
public ngOnInit() { public ngOnInit() {

12
apps/client/src/app/pages/accounts/accounts-page.html

@ -26,16 +26,6 @@
hasPermissionToCreateAccount && hasPermissionToCreateAccount &&
!user.settings.isRestrictedView !user.settings.isRestrictedView
) { ) {
<div class="fab-container"> <gf-fab [queryParams]="{ createDialog: true }" />
<a
class="align-items-center d-flex justify-content-center"
color="primary"
mat-fab
[queryParams]="{ createDialog: true }"
[routerLink]="[]"
>
<ion-icon name="add-outline" size="large" />
</a>
</div>
} }
</div> </div>

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

@ -12,6 +12,7 @@ import {
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { DateRange } from '@ghostfolio/common/types'; import { DateRange } from '@ghostfolio/common/types';
import { GfActivitiesTableComponent } from '@ghostfolio/ui/activities-table'; import { GfActivitiesTableComponent } from '@ghostfolio/ui/activities-table';
import { GfFabComponent } from '@ghostfolio/ui/fab';
import { DataService } from '@ghostfolio/ui/services'; import { DataService } from '@ghostfolio/ui/services';
import { import {
@ -21,17 +22,13 @@ import {
OnInit OnInit
} from '@angular/core'; } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog'; import { MatDialog } from '@angular/material/dialog';
import { PageEvent } from '@angular/material/paginator'; import { PageEvent } from '@angular/material/paginator';
import { MatSnackBarModule } from '@angular/material/snack-bar'; import { MatSnackBarModule } from '@angular/material/snack-bar';
import { Sort, SortDirection } from '@angular/material/sort'; import { Sort, SortDirection } from '@angular/material/sort';
import { MatTableDataSource } from '@angular/material/table'; import { MatTableDataSource } from '@angular/material/table';
import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { IonIcon } from '@ionic/angular/standalone';
import { format, parseISO } from 'date-fns'; import { format, parseISO } from 'date-fns';
import { addIcons } from 'ionicons';
import { addOutline } from 'ionicons/icons';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
import { Subscription } from 'rxjs'; import { Subscription } from 'rxjs';
@ -41,11 +38,9 @@ import { GfImportActivitiesDialogComponent } from './import-activities-dialog/im
import { ImportActivitiesDialogParams } from './import-activities-dialog/interfaces/interfaces'; import { ImportActivitiesDialogParams } from './import-activities-dialog/interfaces/interfaces';
@Component({ @Component({
host: { class: 'has-fab' },
imports: [ imports: [
GfActivitiesTableComponent, GfActivitiesTableComponent,
IonIcon, GfFabComponent,
MatButtonModule,
MatSnackBarModule, MatSnackBarModule,
RouterModule RouterModule
], ],
@ -107,8 +102,6 @@ export class GfActivitiesPageComponent implements OnInit {
} }
} }
}); });
addIcons({ addOutline });
} }
public ngOnInit() { public ngOnInit() {

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

@ -43,16 +43,6 @@
hasPermissionToCreateActivity && hasPermissionToCreateActivity &&
!user.settings.isRestrictedView !user.settings.isRestrictedView
) { ) {
<div class="fab-container"> <gf-fab [queryParams]="{ createDialog: true }" />
<a
class="align-items-center d-flex justify-content-center"
color="primary"
mat-fab
[queryParams]="{ createDialog: true }"
[routerLink]="[]"
>
<ion-icon name="add-outline" size="large" />
</a>
</div>
} }
</div> </div>

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

@ -139,7 +139,7 @@ export class GfCreateOrUpdateActivityDialogComponent {
return !['CASH'].includes(assetProfile.assetSubClass); return !['CASH'].includes(assetProfile.assetSubClass);
}) })
.sort((a, b) => { .sort((a, b) => {
return a.name?.localeCompare(b.name); return a.assetProfile.name?.localeCompare(b.assetProfile.name);
}) })
.map(({ assetProfile }) => { .map(({ assetProfile }) => {
return { return {

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

@ -226,7 +226,8 @@ export class GfImportActivitiesDialogComponent {
this.assetProfileForm.controls.assetProfileIdentifier.disable(); this.assetProfileForm.controls.assetProfileIdentifier.disable();
const { dataSource, symbol } = const { dataSource, symbol } =
this.assetProfileForm.controls.assetProfileIdentifier.value ?? {}; this.assetProfileForm.controls.assetProfileIdentifier.value
?.assetProfile ?? {};
if (!dataSource || !symbol) { if (!dataSource || !symbol) {
return; return;

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

@ -73,15 +73,14 @@ export class GfAllocationsPageComponent implements OnInit {
public hasImpersonationId: boolean; public hasImpersonationId: boolean;
public holdings: { public holdings: {
[symbol: string]: Pick< [symbol: string]: Pick<
PortfolioPosition, PortfolioPosition['assetProfile'],
| 'assetClass' | 'assetClass'
| 'assetClassLabel' | 'assetClassLabel'
| 'assetSubClass' | 'assetSubClass'
| 'assetSubClassLabel' | 'assetSubClassLabel'
| 'currency' | 'currency'
| 'exchange'
| 'name' | 'name'
> & { etfProvider: string; value: number }; > & { etfProvider: string; exchange?: string; value: number };
}; };
public isLoading = false; public isLoading = false;
public markets: { public markets: {
@ -206,7 +205,7 @@ export class GfAllocationsPageComponent implements OnInit {
assetSubClass, assetSubClass,
name name
}: { }: {
assetSubClass: PortfolioPosition['assetSubClass']; assetSubClass: PortfolioPosition['assetProfile']['assetSubClass'];
name: string; name: string;
}) { }) {
if (assetSubClass === 'ETF') { if (assetSubClass === 'ETF') {
@ -333,24 +332,27 @@ export class GfAllocationsPageComponent implements OnInit {
this.holdings[symbol] = { this.holdings[symbol] = {
value, value,
assetClass: position.assetClass || (UNKNOWN_KEY as AssetClass), assetClass:
assetClassLabel: position.assetClassLabel || UNKNOWN_KEY, position.assetProfile.assetClass || (UNKNOWN_KEY as AssetClass),
assetSubClass: position.assetSubClass || (UNKNOWN_KEY as AssetSubClass), assetClassLabel: position.assetProfile.assetClassLabel || UNKNOWN_KEY,
assetSubClassLabel: position.assetSubClassLabel || UNKNOWN_KEY, assetSubClass:
currency: position.currency, position.assetProfile.assetSubClass || (UNKNOWN_KEY as AssetSubClass),
assetSubClassLabel:
position.assetProfile.assetSubClassLabel || UNKNOWN_KEY,
currency: position.assetProfile.currency,
etfProvider: this.extractEtfProvider({ etfProvider: this.extractEtfProvider({
assetSubClass: position.assetSubClass, assetSubClass: position.assetProfile.assetSubClass,
name: position.name name: position.assetProfile.name
}), }),
exchange: position.exchange, exchange: position.exchange,
name: position.name name: position.assetProfile.name
}; };
if (position.assetClass !== AssetClass.LIQUIDITY) { if (position.assetProfile.assetClass !== AssetClass.LIQUIDITY) {
// Prepare analysis data by continents, countries, holdings and sectors except for liquidity // Prepare analysis data by continents, countries, holdings and sectors except for liquidity
if (position.countries.length > 0) { if (position.assetProfile.countries.length > 0) {
for (const country of position.countries) { for (const country of position.assetProfile.countries) {
const { code, continent, name, weight } = country; const { code, continent, name, weight } = country;
if (this.continents[continent]?.value) { if (this.continents[continent]?.value) {
@ -401,12 +403,12 @@ export class GfAllocationsPageComponent implements OnInit {
: this.portfolioDetails.holdings[symbol].valueInPercentage; : this.portfolioDetails.holdings[symbol].valueInPercentage;
} }
if (position.holdings.length > 0) { if (position.assetProfile.holdings.length > 0) {
for (const { for (const {
allocationInPercentage, allocationInPercentage,
name, name,
valueInBaseCurrency valueInBaseCurrency
} of position.holdings) { } of position.assetProfile.holdings) {
const normalizedAssetName = this.normalizeAssetName(name); const normalizedAssetName = this.normalizeAssetName(name);
if (this.topHoldingsMap[normalizedAssetName]?.value) { if (this.topHoldingsMap[normalizedAssetName]?.value) {
@ -428,8 +430,8 @@ export class GfAllocationsPageComponent implements OnInit {
} }
} }
if (position.sectors.length > 0) { if (position.assetProfile.sectors.length > 0) {
for (const sector of position.sectors) { for (const sector of position.assetProfile.sectors) {
const { name, weight } = sector; const { name, weight } = sector;
if (this.sectors[name]?.value) { if (this.sectors[name]?.value) {
@ -463,8 +465,8 @@ export class GfAllocationsPageComponent implements OnInit {
} }
this.symbols[prettifySymbol(symbol)] = { this.symbols[prettifySymbol(symbol)] = {
dataSource: position.dataSource, dataSource: position.assetProfile.dataSource,
name: position.name, name: position.assetProfile.name,
symbol: prettifySymbol(symbol), symbol: prettifySymbol(symbol),
value: isNumber(position.valueInBaseCurrency) value: isNumber(position.valueInBaseCurrency)
? position.valueInBaseCurrency ? position.valueInBaseCurrency
@ -517,8 +519,8 @@ export class GfAllocationsPageComponent implements OnInit {
this.totalValueInEtf > 0 ? value / this.totalValueInEtf : 0, this.totalValueInEtf > 0 ? value / this.totalValueInEtf : 0,
parents: Object.entries(this.portfolioDetails.holdings) parents: Object.entries(this.portfolioDetails.holdings)
.map(([symbol, holding]) => { .map(([symbol, holding]) => {
if (holding.holdings.length > 0) { if (holding.assetProfile.holdings.length > 0) {
const currentParentHolding = holding.holdings.find( const currentParentHolding = holding.assetProfile.holdings.find(
(parentHolding) => { (parentHolding) => {
return ( return (
this.normalizeAssetName(parentHolding.name) === this.normalizeAssetName(parentHolding.name) ===
@ -531,7 +533,7 @@ export class GfAllocationsPageComponent implements OnInit {
? { ? {
allocationInPercentage: allocationInPercentage:
currentParentHolding.valueInBaseCurrency / value, currentParentHolding.valueInBaseCurrency / value,
name: holding.name, name: holding.assetProfile.name,
position: holding, position: holding,
symbol: prettifySymbol(symbol), symbol: prettifySymbol(symbol),
valueInBaseCurrency: valueInBaseCurrency:

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

@ -2,7 +2,10 @@ import { GfBenchmarkComparatorComponent } from '@ghostfolio/client/components/be
import { GfInvestmentChartComponent } from '@ghostfolio/client/components/investment-chart/investment-chart.component'; import { GfInvestmentChartComponent } from '@ghostfolio/client/components/investment-chart/investment-chart.component';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { NUMERICAL_PRECISION_THRESHOLD_6_FIGURES } from '@ghostfolio/common/config'; import {
DEFAULT_DATE_RANGE,
NUMERICAL_PRECISION_THRESHOLD_6_FIGURES
} from '@ghostfolio/common/config';
import { import {
HistoricalDataItem, HistoricalDataItem,
InvestmentItem, InvestmentItem,
@ -24,9 +27,12 @@ import { Clipboard } from '@angular/cdk/clipboard';
import { import {
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
computed,
DestroyRef, DestroyRef,
inject,
OnInit, OnInit,
ViewChild signal,
viewChild
} from '@angular/core'; } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
@ -64,53 +70,57 @@ import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
templateUrl: './analysis-page.html' templateUrl: './analysis-page.html'
}) })
export class GfAnalysisPageComponent implements OnInit { export class GfAnalysisPageComponent implements OnInit {
@ViewChild(MatMenuTrigger) actionsMenuButton!: MatMenuTrigger; protected benchmark?: Partial<SymbolProfile>;
protected benchmarkDataItems: HistoricalDataItem[] = [];
public benchmark: Partial<SymbolProfile>; protected readonly benchmarks: Partial<SymbolProfile>[];
public benchmarkDataItems: HistoricalDataItem[] = []; protected bottom3: PortfolioPosition[];
public benchmarks: Partial<SymbolProfile>[]; protected dividendsByGroup: InvestmentItem[];
public bottom3: PortfolioPosition[]; protected readonly dividendTimelineDataLabel = $localize`Dividend`;
public deviceType: string; protected hasImpersonationId: boolean;
public dividendsByGroup: InvestmentItem[]; protected hasPermissionToReadAiPrompt: boolean;
public dividendTimelineDataLabel = $localize`Dividend`; protected investments: InvestmentItem[];
public firstOrderDate: Date; protected readonly investmentTimelineDataLabel = $localize`Investment`;
public hasImpersonationId: boolean; protected investmentsByGroup: InvestmentItem[];
public hasPermissionToReadAiPrompt: boolean; protected isLoadingAnalysisPrompt: boolean;
public investments: InvestmentItem[]; protected isLoadingBenchmarkComparator: boolean;
public investmentTimelineDataLabel = $localize`Investment`; protected isLoadingDividendTimelineChart: boolean;
public investmentsByGroup: InvestmentItem[]; protected isLoadingInvestmentChart: boolean;
public isLoadingAnalysisPrompt: boolean; protected isLoadingInvestmentTimelineChart: boolean;
public isLoadingBenchmarkComparator: boolean; protected isLoadingPortfolioPrompt: boolean;
public isLoadingDividendTimelineChart: boolean; protected readonly mode = signal<GroupBy>('month');
public isLoadingInvestmentChart: boolean; protected readonly modeOptions: ToggleOption[] = [
public isLoadingInvestmentTimelineChart: boolean;
public isLoadingPortfolioPrompt: boolean;
public mode: GroupBy = 'month';
public modeOptions: ToggleOption[] = [
{ label: $localize`Monthly`, value: 'month' }, { label: $localize`Monthly`, value: 'month' },
{ label: $localize`Yearly`, value: 'year' } { label: $localize`Yearly`, value: 'year' }
]; ];
public performance: PortfolioPerformance; protected performance: PortfolioPerformance;
public performanceDataItems: HistoricalDataItem[]; protected performanceDataItems: HistoricalDataItem[];
public performanceDataItemsInPercentage: HistoricalDataItem[]; protected performanceDataItemsInPercentage: HistoricalDataItem[];
public portfolioEvolutionDataLabel = $localize`Investment`; protected readonly portfolioEvolutionDataLabel = $localize`Investment`;
public precision = 2; protected precision = 2;
public streaks: PortfolioInvestmentsResponse['streaks']; protected streaks: PortfolioInvestmentsResponse['streaks'];
public top3: PortfolioPosition[]; protected top3: PortfolioPosition[];
public unitCurrentStreak: string; protected unitCurrentStreak: string;
public unitLongestStreak: string; protected unitLongestStreak: string;
public user: User; protected user: User;
public constructor( private readonly actionsMenuButton = viewChild.required(MatMenuTrigger);
private changeDetectorRef: ChangeDetectorRef, private readonly deviceType = computed(
private clipboard: Clipboard, () => this.deviceDetectorService.deviceInfo().deviceType
private dataService: DataService, );
private destroyRef: DestroyRef, private firstOrderDate: Date;
private deviceDetectorService: DeviceDetectorService,
private impersonationStorageService: ImpersonationStorageService, private readonly changeDetectorRef = inject(ChangeDetectorRef);
private snackBar: MatSnackBar, private readonly clipboard = inject(Clipboard);
private userService: UserService private readonly dataService = inject(DataService);
) { private readonly destroyRef = inject(DestroyRef);
private readonly deviceDetectorService = inject(DeviceDetectorService);
private readonly impersonationStorageService = inject(
ImpersonationStorageService
);
private readonly snackBar = inject(MatSnackBar);
private readonly userService = inject(UserService);
public constructor() {
const { benchmarks } = this.dataService.fetchInfo(); const { benchmarks } = this.dataService.fetchInfo();
this.benchmarks = benchmarks; this.benchmarks = benchmarks;
@ -123,14 +133,16 @@ export class GfAnalysisPageComponent implements OnInit {
? undefined ? undefined
: this.user?.settings?.savingsRate; : this.user?.settings?.savingsRate;
return this.mode === 'year' if (savingsRatePerMonth === undefined) {
return undefined;
}
return this.mode() === 'year'
? savingsRatePerMonth * 12 ? savingsRatePerMonth * 12
: savingsRatePerMonth; : savingsRatePerMonth;
} }
public ngOnInit() { public ngOnInit() {
this.deviceType = this.deviceDetectorService.getDeviceInfo().deviceType;
this.impersonationStorageService this.impersonationStorageService
.onChangeHasImpersonation() .onChangeHasImpersonation()
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
@ -158,7 +170,7 @@ export class GfAnalysisPageComponent implements OnInit {
}); });
} }
public onChangeBenchmark(symbolProfileId: string) { protected onChangeBenchmark(symbolProfileId: string) {
this.dataService this.dataService
.putUserSetting({ benchmark: symbolProfileId }) .putUserSetting({ benchmark: symbolProfileId })
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
@ -174,12 +186,12 @@ export class GfAnalysisPageComponent implements OnInit {
}); });
} }
public onChangeGroupBy(aMode: GroupBy) { protected onChangeGroupBy(aMode: GroupBy) {
this.mode = aMode; this.mode.set(aMode);
this.fetchDividendsAndInvestments(); this.fetchDividendsAndInvestments();
} }
public onCopyPromptToClipboard(mode: AiPromptMode) { protected onCopyPromptToClipboard(mode: AiPromptMode) {
if (mode === 'analysis') { if (mode === 'analysis') {
this.isLoadingAnalysisPrompt = true; this.isLoadingAnalysisPrompt = true;
} else if (mode === 'portfolio') { } else if (mode === 'portfolio') {
@ -210,7 +222,7 @@ export class GfAnalysisPageComponent implements OnInit {
window.open('https://duck.ai', '_blank'); window.open('https://duck.ai', '_blank');
}); });
this.actionsMenuButton.closeMenu(); this.actionsMenuButton().closeMenu();
if (mode === 'analysis') { if (mode === 'analysis') {
this.isLoadingAnalysisPrompt = false; this.isLoadingAnalysisPrompt = false;
@ -227,8 +239,8 @@ export class GfAnalysisPageComponent implements OnInit {
this.dataService this.dataService
.fetchDividends({ .fetchDividends({
filters: this.userService.getFilters(), filters: this.userService.getFilters(),
groupBy: this.mode, groupBy: this.mode(),
range: this.user?.settings?.dateRange range: this.user?.settings?.dateRange ?? DEFAULT_DATE_RANGE
}) })
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ dividends }) => { .subscribe(({ dividends }) => {
@ -242,15 +254,15 @@ export class GfAnalysisPageComponent implements OnInit {
this.dataService this.dataService
.fetchInvestments({ .fetchInvestments({
filters: this.userService.getFilters(), filters: this.userService.getFilters(),
groupBy: this.mode, groupBy: this.mode(),
range: this.user?.settings?.dateRange range: this.user?.settings?.dateRange ?? DEFAULT_DATE_RANGE
}) })
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ investments, streaks }) => { .subscribe(({ investments, streaks }) => {
this.investmentsByGroup = investments; this.investmentsByGroup = investments;
this.streaks = streaks; this.streaks = streaks;
this.unitCurrentStreak = this.unitCurrentStreak =
this.mode === 'year' this.mode() === 'year'
? this.streaks?.currentStreak === 1 ? this.streaks?.currentStreak === 1
? translate('YEAR') ? translate('YEAR')
: translate('YEARS') : translate('YEARS')
@ -258,7 +270,7 @@ export class GfAnalysisPageComponent implements OnInit {
? translate('MONTH') ? translate('MONTH')
: translate('MONTHS'); : translate('MONTHS');
this.unitLongestStreak = this.unitLongestStreak =
this.mode === 'year' this.mode() === 'year'
? this.streaks?.longestStreak === 1 ? this.streaks?.longestStreak === 1
? translate('YEAR') ? translate('YEAR')
: translate('YEARS') : translate('YEARS')
@ -278,7 +290,7 @@ export class GfAnalysisPageComponent implements OnInit {
this.dataService this.dataService
.fetchPortfolioPerformance({ .fetchPortfolioPerformance({
filters: this.userService.getFilters(), filters: this.userService.getFilters(),
range: this.user?.settings?.dateRange range: this.user?.settings?.dateRange ?? DEFAULT_DATE_RANGE
}) })
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ chart, firstOrderDate, performance }) => { .subscribe(({ chart, firstOrderDate, performance }) => {
@ -298,13 +310,16 @@ export class GfAnalysisPageComponent implements OnInit {
valueInPercentage, valueInPercentage,
valueWithCurrencyEffect valueWithCurrencyEffect
} }
] of chart.entries()) { ] of (chart ?? []).entries()) {
if (index > 0 || this.user?.settings?.dateRange === 'max') {
// Ignore first item where value is 0 // Ignore first item where value is 0
if (index > 0 || this.user?.settings?.dateRange === 'max') {
if (totalInvestmentValueWithCurrencyEffect !== undefined) {
this.investments.push({ this.investments.push({
date, date,
investment: totalInvestmentValueWithCurrencyEffect investment: totalInvestmentValueWithCurrencyEffect
}); });
}
this.performanceDataItems.push({ this.performanceDataItems.push({
date, date,
value: isNumber(valueWithCurrencyEffect) value: isNumber(valueWithCurrencyEffect)
@ -320,7 +335,7 @@ export class GfAnalysisPageComponent implements OnInit {
} }
if ( if (
this.deviceType === 'mobile' && this.deviceType() === 'mobile' &&
this.performance.currentValueInBaseCurrency >= this.performance.currentValueInBaseCurrency >=
NUMERICAL_PRECISION_THRESHOLD_6_FIGURES NUMERICAL_PRECISION_THRESHOLD_6_FIGURES
) { ) {
@ -387,7 +402,7 @@ export class GfAnalysisPageComponent implements OnInit {
dataSource, dataSource,
symbol, symbol,
filters: this.userService.getFilters(), filters: this.userService.getFilters(),
range: this.user?.settings?.dateRange, range: this.user?.settings?.dateRange ?? DEFAULT_DATE_RANGE,
startDate: this.firstOrderDate startDate: this.firstOrderDate
}) })
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))

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

@ -310,13 +310,15 @@
<a <a
class="d-flex" class="d-flex"
[queryParams]="{ [queryParams]="{
dataSource: holding.dataSource, dataSource: holding.assetProfile.dataSource,
holdingDetailDialog: true, holdingDetailDialog: true,
symbol: holding.symbol symbol: holding.assetProfile.symbol
}" }"
[routerLink]="[]" [routerLink]="[]"
> >
<div class="flex-grow-1 mr-2">{{ holding.name }}</div> <div class="flex-grow-1 mr-2">
{{ holding.assetProfile.name }}
</div>
<div class="d-flex justify-content-end"> <div class="d-flex justify-content-end">
<gf-value <gf-value
class="justify-content-end" class="justify-content-end"
@ -359,13 +361,15 @@
<a <a
class="d-flex" class="d-flex"
[queryParams]="{ [queryParams]="{
dataSource: holding.dataSource, dataSource: holding.assetProfile.dataSource,
holdingDetailDialog: true, holdingDetailDialog: true,
symbol: holding.symbol symbol: holding.assetProfile.symbol
}" }"
[routerLink]="[]" [routerLink]="[]"
> >
<div class="flex-grow-1 mr-2">{{ holding.name }}</div> <div class="flex-grow-1 mr-2">
{{ holding.assetProfile.name }}
</div>
<div class="d-flex justify-content-end"> <div class="d-flex justify-content-end">
<gf-value <gf-value
class="justify-content-end" class="justify-content-end"
@ -438,7 +442,7 @@
</div> </div>
<gf-toggle <gf-toggle
class="d-none d-lg-block" class="d-none d-lg-block"
[defaultValue]="mode" [defaultValue]="mode()"
[isLoading]="false" [isLoading]="false"
[options]="modeOptions" [options]="modeOptions"
(valueChange)="onChangeGroupBy($event.value)" (valueChange)="onChangeGroupBy($event.value)"
@ -472,7 +476,7 @@
[benchmarkDataItems]="investmentsByGroup" [benchmarkDataItems]="investmentsByGroup"
[benchmarkDataLabel]="investmentTimelineDataLabel" [benchmarkDataLabel]="investmentTimelineDataLabel"
[currency]="user?.settings?.baseCurrency" [currency]="user?.settings?.baseCurrency"
[groupBy]="mode" [groupBy]="mode()"
[isInPercentage]=" [isInPercentage]="
hasImpersonationId || user.settings.isRestrictedView hasImpersonationId || user.settings.isRestrictedView
" "
@ -497,7 +501,7 @@
</div> </div>
<gf-toggle <gf-toggle
class="d-none d-lg-block" class="d-none d-lg-block"
[defaultValue]="mode" [defaultValue]="mode()"
[isLoading]="false" [isLoading]="false"
[options]="modeOptions" [options]="modeOptions"
(valueChange)="onChangeGroupBy($event.value)" (valueChange)="onChangeGroupBy($event.value)"
@ -509,7 +513,7 @@
[benchmarkDataItems]="dividendsByGroup" [benchmarkDataItems]="dividendsByGroup"
[benchmarkDataLabel]="dividendTimelineDataLabel" [benchmarkDataLabel]="dividendTimelineDataLabel"
[currency]="user?.settings?.baseCurrency" [currency]="user?.settings?.baseCurrency"
[groupBy]="mode" [groupBy]="mode()"
[isInPercentage]=" [isInPercentage]="
hasImpersonationId || user.settings.isRestrictedView hasImpersonationId || user.settings.isRestrictedView
" "

5
apps/client/src/app/pages/public/public-page.component.ts

@ -74,7 +74,10 @@ export class GfPublicPageComponent implements OnInit {
}; };
protected readonly pageSize = Number.MAX_SAFE_INTEGER; protected readonly pageSize = Number.MAX_SAFE_INTEGER;
protected positions: { protected positions: {
[symbol: string]: Pick<PortfolioPosition, 'currency' | 'name'> & { [symbol: string]: Pick<
PortfolioPosition['assetProfile'],
'currency' | 'name'
> & {
value: number; value: number;
}; };
}; };

7
apps/client/src/assets/oss-friends.json

@ -1,5 +1,5 @@
{ {
"createdAt": "2026-04-21T00:00:00.000Z", "createdAt": "2026-05-26T00:00:00.000Z",
"data": [ "data": [
{ {
"name": "Activepieces", "name": "Activepieces",
@ -101,6 +101,11 @@
"description": "Simplify working with databases. Build, optimize, and grow your app easily with an intuitive data model, type-safety, automated migrations, connection pooling, caching, and real-time db subscriptions.", "description": "Simplify working with databases. Build, optimize, and grow your app easily with an intuitive data model, type-safety, automated migrations, connection pooling, caching, and real-time db subscriptions.",
"href": "https://www.prisma.io" "href": "https://www.prisma.io"
}, },
{
"name": "Rallly",
"description": "Rallly is an open-source scheduling and collaboration tool designed to make organizing events and meetings easier.",
"href": "https://rallly.co"
},
{ {
"name": "Requestly", "name": "Requestly",
"description": "Makes frontend development cycle 10x faster with API Client, Mock Server, Intercept & Modify HTTP Requests and Session Replays.", "description": "Makes frontend development cycle 10x faster with API Client, Mock Server, Intercept & Modify HTTP Requests and Session Replays.",

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

@ -292,7 +292,7 @@
</trans-unit> </trans-unit>
<trans-unit id="9153520284278555926" datatype="html"> <trans-unit id="9153520284278555926" datatype="html">
<source>please</source> <source>please</source>
<target state="new">please</target> <target state="translated">будь ласка</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/pricing/pricing-page.html</context> <context context-type="sourcefile">apps/client/src/app/pages/pricing/pricing-page.html</context>
<context context-type="linenumber">333</context> <context context-type="linenumber">333</context>
@ -360,7 +360,7 @@
</trans-unit> </trans-unit>
<trans-unit id="1351814922314683865" datatype="html"> <trans-unit id="1351814922314683865" datatype="html">
<source>with</source> <source>with</source>
<target state="new">with</target> <target state="translated">з</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/components/subscription-interstitial-dialog/subscription-interstitial-dialog.html</context> <context context-type="sourcefile">apps/client/src/app/components/subscription-interstitial-dialog/subscription-interstitial-dialog.html</context>
<context context-type="linenumber">87</context> <context context-type="linenumber">87</context>
@ -1388,7 +1388,7 @@
</trans-unit> </trans-unit>
<trans-unit id="7702646444963497962" datatype="html"> <trans-unit id="7702646444963497962" datatype="html">
<source>By</source> <source>By</source>
<target state="new">By</target> <target state="translated">До</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/portfolio/fire/fire-page.html</context> <context context-type="sourcefile">apps/client/src/app/pages/portfolio/fire/fire-page.html</context>
<context context-type="linenumber">139</context> <context context-type="linenumber">139</context>
@ -1892,7 +1892,7 @@
</trans-unit> </trans-unit>
<trans-unit id="5303806780432428245" datatype="html"> <trans-unit id="5303806780432428245" datatype="html">
<source>Indonesia</source> <source>Indonesia</source>
<target state="new">Indonesia</target> <target state="translated">Індонезія</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/i18n.ts</context> <context context-type="sourcefile">libs/ui/src/lib/i18n.ts</context>
<context context-type="linenumber">90</context> <context context-type="linenumber">90</context>
@ -2108,7 +2108,7 @@
</trans-unit> </trans-unit>
<trans-unit id="8186013988289067040" datatype="html"> <trans-unit id="8186013988289067040" datatype="html">
<source>Code</source> <source>Code</source>
<target state="new">Code</target> <target state="translated">Код</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/components/admin-overview/admin-overview.html</context> <context context-type="sourcefile">apps/client/src/app/components/admin-overview/admin-overview.html</context>
<context context-type="linenumber">159</context> <context context-type="linenumber">159</context>
@ -2636,7 +2636,7 @@
</trans-unit> </trans-unit>
<trans-unit id="2575998129003872734" datatype="html"> <trans-unit id="2575998129003872734" datatype="html">
<source>Argentina</source> <source>Argentina</source>
<target state="new">Argentina</target> <target state="translated">Аргентина</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/i18n.ts</context> <context context-type="sourcefile">libs/ui/src/lib/i18n.ts</context>
<context context-type="linenumber">78</context> <context context-type="linenumber">78</context>
@ -3204,7 +3204,7 @@
</trans-unit> </trans-unit>
<trans-unit id="8553460997100418147" datatype="html"> <trans-unit id="8553460997100418147" datatype="html">
<source>for</source> <source>for</source>
<target state="new">for</target> <target state="translated">для</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/components/subscription-interstitial-dialog/subscription-interstitial-dialog.html</context> <context context-type="sourcefile">apps/client/src/app/components/subscription-interstitial-dialog/subscription-interstitial-dialog.html</context>
<context context-type="linenumber">128</context> <context context-type="linenumber">128</context>
@ -3560,7 +3560,7 @@
</trans-unit> </trans-unit>
<trans-unit id="7410432243549869948" datatype="html"> <trans-unit id="7410432243549869948" datatype="html">
<source>Duration</source> <source>Duration</source>
<target state="new">Duration</target> <target state="translated">Тривалість</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/components/admin-overview/admin-overview.html</context> <context context-type="sourcefile">apps/client/src/app/components/admin-overview/admin-overview.html</context>
<context context-type="linenumber">172</context> <context context-type="linenumber">172</context>
@ -4897,7 +4897,7 @@
</trans-unit> </trans-unit>
<trans-unit id="858192247408211331" datatype="html"> <trans-unit id="858192247408211331" datatype="html">
<source>here</source> <source>here</source>
<target state="new">here</target> <target state="translated">тут</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/pricing/pricing-page.html</context> <context context-type="sourcefile">apps/client/src/app/pages/pricing/pricing-page.html</context>
<context context-type="linenumber">347</context> <context context-type="linenumber">347</context>
@ -5113,7 +5113,7 @@
</trans-unit> </trans-unit>
<trans-unit id="6962217007874959362" datatype="html"> <trans-unit id="6962217007874959362" datatype="html">
<source>Our official Ghostfolio Premium cloud offering is the easiest way to get started. Due to the time it saves, this will be the best option for most people. Revenue is used to cover operational costs for the hosting infrastructure and professional data providers, and to fund ongoing development.</source> <source>Our official Ghostfolio Premium cloud offering is the easiest way to get started. Due to the time it saves, this will be the best option for most people. Revenue is used to cover operational costs for the hosting infrastructure and professional data providers, and to fund ongoing development.</source>
<target state="new">Наша офіційна хмарна пропозиція Ghostfolio Premium - це найпростіший спосіб почати роботу. Завдяки економії часу, це буде найкращим варіантом для більшості людей. Доходи використовуються для покриття витрат на хостинг-інфраструктуру та фінансування постійної розробки.</target> <target state="translated">Наша офіційна хмарна пропозиція Ghostfolio Premium - це найпростіший спосіб почати роботу. Завдяки економії часу, це буде найкращим варіантом для більшості людей. Доходи використовуються для покриття витрат на хостинг-інфраструктуру та фінансування постійної розробки.</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/pricing/pricing-page.html</context> <context context-type="sourcefile">apps/client/src/app/pages/pricing/pricing-page.html</context>
<context context-type="linenumber">7</context> <context context-type="linenumber">7</context>
@ -6392,7 +6392,7 @@
</trans-unit> </trans-unit>
<trans-unit id="5515771028435710194" datatype="html"> <trans-unit id="5515771028435710194" datatype="html">
<source>Loan</source> <source>Loan</source>
<target state="new">Loan</target> <target state="translated">Позика</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/i18n.ts</context> <context context-type="sourcefile">libs/ui/src/lib/i18n.ts</context>
<context context-type="linenumber">58</context> <context context-type="linenumber">58</context>
@ -6944,7 +6944,7 @@
</trans-unit> </trans-unit>
<trans-unit id="4145496584631696119" datatype="html"> <trans-unit id="4145496584631696119" datatype="html">
<source>Role</source> <source>Role</source>
<target state="new">Role</target> <target state="translated">Роль</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/components/user-detail-dialog/user-detail-dialog.html</context> <context context-type="sourcefile">apps/client/src/app/components/user-detail-dialog/user-detail-dialog.html</context>
<context context-type="linenumber">39</context> <context context-type="linenumber">39</context>
@ -7080,7 +7080,7 @@
</trans-unit> </trans-unit>
<trans-unit id="8966698274727122602" datatype="html"> <trans-unit id="8966698274727122602" datatype="html">
<source>Authentication</source> <source>Authentication</source>
<target state="new">Authentication</target> <target state="translated">Автентифікація</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/components/user-detail-dialog/user-detail-dialog.html</context> <context context-type="sourcefile">apps/client/src/app/components/user-detail-dialog/user-detail-dialog.html</context>
<context context-type="linenumber">60</context> <context context-type="linenumber">60</context>
@ -7476,7 +7476,7 @@
</trans-unit> </trans-unit>
<trans-unit id="8540986733881734625" datatype="html"> <trans-unit id="8540986733881734625" datatype="html">
<source>Lazy</source> <source>Lazy</source>
<target state="new">Lazy</target> <target state="translated">Лінивий</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts</context> <context context-type="sourcefile">apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts</context>
<context context-type="linenumber">235</context> <context context-type="linenumber">235</context>
@ -7484,7 +7484,7 @@
</trans-unit> </trans-unit>
<trans-unit id="6882618704933649036" datatype="html"> <trans-unit id="6882618704933649036" datatype="html">
<source>Instant</source> <source>Instant</source>
<target state="new">Instant</target> <target state="translated">Миттєвий</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts</context> <context context-type="sourcefile">apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts</context>
<context context-type="linenumber">239</context> <context context-type="linenumber">239</context>
@ -7500,7 +7500,7 @@
</trans-unit> </trans-unit>
<trans-unit id="1713271461473302108" datatype="html"> <trans-unit id="1713271461473302108" datatype="html">
<source>Mode</source> <source>Mode</source>
<target state="new">Mode</target> <target state="translated">Режим</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html</context> <context context-type="sourcefile">apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html</context>
<context context-type="linenumber">519</context> <context context-type="linenumber">519</context>
@ -7508,7 +7508,7 @@
</trans-unit> </trans-unit>
<trans-unit id="3540108566782816830" datatype="html"> <trans-unit id="3540108566782816830" datatype="html">
<source>Selector</source> <source>Selector</source>
<target state="new">Selector</target> <target state="translated">Селектор</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html</context> <context context-type="sourcefile">apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html</context>
<context context-type="linenumber">535</context> <context context-type="linenumber">535</context>
@ -7532,7 +7532,7 @@
</trans-unit> </trans-unit>
<trans-unit id="4547068148181074902" datatype="html"> <trans-unit id="4547068148181074902" datatype="html">
<source>real-time</source> <source>real-time</source>
<target state="new">real-time</target> <target state="translated">реальний час</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts</context> <context context-type="sourcefile">apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts</context>
<context context-type="linenumber">239</context> <context context-type="linenumber">239</context>
@ -7548,7 +7548,7 @@
</trans-unit> </trans-unit>
<trans-unit id="5674286808255988565" datatype="html"> <trans-unit id="5674286808255988565" datatype="html">
<source>Create</source> <source>Create</source>
<target state="new">Create</target> <target state="translated">Створити</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/tags-selector/tags-selector.component.html</context> <context context-type="sourcefile">libs/ui/src/lib/tags-selector/tags-selector.component.html</context>
<context context-type="linenumber">50</context> <context context-type="linenumber">50</context>
@ -7556,7 +7556,7 @@
</trans-unit> </trans-unit>
<trans-unit id="1230154438678955604" datatype="html"> <trans-unit id="1230154438678955604" datatype="html">
<source>Change</source> <source>Change</source>
<target state="new">Change</target> <target state="translated">Змінити</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/holdings-table/holdings-table.component.html</context> <context context-type="sourcefile">libs/ui/src/lib/holdings-table/holdings-table.component.html</context>
<context context-type="linenumber">138</context> <context context-type="linenumber">138</context>
@ -7568,7 +7568,7 @@
</trans-unit> </trans-unit>
<trans-unit id="1322586333669103999" datatype="html"> <trans-unit id="1322586333669103999" datatype="html">
<source>Performance</source> <source>Performance</source>
<target state="new">Performance</target> <target state="translated">Дохідність</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.html</context> <context context-type="sourcefile">apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.html</context>
<context context-type="linenumber">6</context> <context context-type="linenumber">6</context>
@ -7624,7 +7624,7 @@
</trans-unit> </trans-unit>
<trans-unit id="5004849258025239958" datatype="html"> <trans-unit id="5004849258025239958" datatype="html">
<source>Armenia</source> <source>Armenia</source>
<target state="new">Armenia</target> <target state="translated">Вірменія</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/i18n.ts</context> <context context-type="sourcefile">libs/ui/src/lib/i18n.ts</context>
<context context-type="linenumber">77</context> <context context-type="linenumber">77</context>
@ -7640,7 +7640,7 @@
</trans-unit> </trans-unit>
<trans-unit id="4830118002486243553" datatype="html"> <trans-unit id="4830118002486243553" datatype="html">
<source>Singapore</source> <source>Singapore</source>
<target state="new">Singapore</target> <target state="translated">Сінгапур</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/i18n.ts</context> <context context-type="sourcefile">libs/ui/src/lib/i18n.ts</context>
<context context-type="linenumber">97</context> <context context-type="linenumber">97</context>
@ -7672,7 +7672,7 @@
</trans-unit> </trans-unit>
<trans-unit id="6962699013778688473" datatype="html"> <trans-unit id="6962699013778688473" datatype="html">
<source>Continue</source> <source>Continue</source>
<target state="new">Continue</target> <target state="translated">Продовжити</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/register/user-account-registration-dialog/user-account-registration-dialog.html</context> <context context-type="sourcefile">apps/client/src/app/pages/register/user-account-registration-dialog/user-account-registration-dialog.html</context>
<context context-type="linenumber">57</context> <context context-type="linenumber">57</context>
@ -7732,7 +7732,7 @@
</trans-unit> </trans-unit>
<trans-unit id="routes.about.termsOfService" datatype="html"> <trans-unit id="routes.about.termsOfService" datatype="html">
<source>terms-of-service</source> <source>terms-of-service</source>
<target state="new">terms-of-service</target> <target state="translated">umovy-nadannia-posluh</target>
<note priority="1" from="description">kebab-case</note> <note priority="1" from="description">kebab-case</note>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">libs/common/src/lib/routes/routes.ts</context> <context context-type="sourcefile">libs/common/src/lib/routes/routes.ts</context>
@ -7781,7 +7781,7 @@
</trans-unit> </trans-unit>
<trans-unit id="4391289919356861627" datatype="html"> <trans-unit id="4391289919356861627" datatype="html">
<source>Apply</source> <source>Apply</source>
<target state="new">Apply</target> <target state="translated">Застосувати</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html</context> <context context-type="sourcefile">apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html</context>
<context context-type="linenumber">154</context> <context context-type="linenumber">154</context>
@ -7837,7 +7837,7 @@
</trans-unit> </trans-unit>
<trans-unit id="322229249598827754" datatype="html"> <trans-unit id="322229249598827754" datatype="html">
<source>someone</source> <source>someone</source>
<target state="new">someone</target> <target state="translated">когось</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/public/public-page.component.ts</context> <context context-type="sourcefile">apps/client/src/app/pages/public/public-page.component.ts</context>
<context context-type="linenumber">62</context> <context context-type="linenumber">62</context>
@ -7853,7 +7853,7 @@
</trans-unit> </trans-unit>
<trans-unit id="4558213855845176930" datatype="html"> <trans-unit id="4558213855845176930" datatype="html">
<source>Watchlist</source> <source>Watchlist</source>
<target state="new">Watchlist</target> <target state="translated">Список спостереження</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/components/home-watchlist/home-watchlist.html</context> <context context-type="sourcefile">apps/client/src/app/components/home-watchlist/home-watchlist.html</context>
<context context-type="linenumber">4</context> <context context-type="linenumber">4</context>
@ -7897,7 +7897,7 @@
</trans-unit> </trans-unit>
<trans-unit id="routes.about.changelog" datatype="html"> <trans-unit id="routes.about.changelog" datatype="html">
<source>changelog</source> <source>changelog</source>
<target state="new">changelog</target> <target state="translated">zhurnal-zmin</target>
<note priority="1" from="description">kebab-case</note> <note priority="1" from="description">kebab-case</note>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">libs/common/src/lib/routes/routes.ts</context> <context context-type="sourcefile">libs/common/src/lib/routes/routes.ts</context>
@ -8030,7 +8030,7 @@
</trans-unit> </trans-unit>
<trans-unit id="routes.resources.personalFinanceTools" datatype="html"> <trans-unit id="routes.resources.personalFinanceTools" datatype="html">
<source>personal-finance-tools</source> <source>personal-finance-tools</source>
<target state="new">personal-finance-tools</target> <target state="translated">instrumenty-osobystykh-finansiv</target>
<note priority="1" from="description">kebab-case</note> <note priority="1" from="description">kebab-case</note>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">libs/common/src/lib/routes/routes.ts</context> <context context-type="sourcefile">libs/common/src/lib/routes/routes.ts</context>
@ -8047,7 +8047,7 @@
</trans-unit> </trans-unit>
<trans-unit id="routes.resources.markets" datatype="html"> <trans-unit id="routes.resources.markets" datatype="html">
<source>markets</source> <source>markets</source>
<target state="new">markets</target> <target state="translated">rynky</target>
<note priority="1" from="description">kebab-case</note> <note priority="1" from="description">kebab-case</note>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">libs/common/src/lib/routes/routes.ts</context> <context context-type="sourcefile">libs/common/src/lib/routes/routes.ts</context>
@ -8108,7 +8108,7 @@
</trans-unit> </trans-unit>
<trans-unit id="3955868613858648955" datatype="html"> <trans-unit id="3955868613858648955" datatype="html">
<source>Available</source> <source>Available</source>
<target state="new">Available</target> <target state="translated">Доступно</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/components/data-provider-status/data-provider-status.component.html</context> <context context-type="sourcefile">apps/client/src/app/components/data-provider-status/data-provider-status.component.html</context>
<context context-type="linenumber">3</context> <context context-type="linenumber">3</context>
@ -8116,7 +8116,7 @@
</trans-unit> </trans-unit>
<trans-unit id="5643561794785412000" datatype="html"> <trans-unit id="5643561794785412000" datatype="html">
<source>Unavailable</source> <source>Unavailable</source>
<target state="new">Unavailable</target> <target state="translated">Недоступно</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/components/data-provider-status/data-provider-status.component.html</context> <context context-type="sourcefile">apps/client/src/app/components/data-provider-status/data-provider-status.component.html</context>
<context context-type="linenumber">5</context> <context context-type="linenumber">5</context>
@ -8132,7 +8132,7 @@
</trans-unit> </trans-unit>
<trans-unit id="7387635272539030076" datatype="html"> <trans-unit id="7387635272539030076" datatype="html">
<source>new</source> <source>new</source>
<target state="new">new</target> <target state="translated">новий</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/components/admin-settings/admin-settings.component.html</context> <context context-type="sourcefile">apps/client/src/app/components/admin-settings/admin-settings.component.html</context>
<context context-type="linenumber">79</context> <context context-type="linenumber">79</context>
@ -8140,7 +8140,7 @@
</trans-unit> </trans-unit>
<trans-unit id="rule.accountClusterRiskCurrentInvestment" datatype="html"> <trans-unit id="rule.accountClusterRiskCurrentInvestment" datatype="html">
<source>Investment</source> <source>Investment</source>
<target state="new">Investment</target> <target state="translated">Інвестиція</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/i18n/i18n-page.html</context> <context context-type="sourcefile">apps/client/src/app/pages/i18n/i18n-page.html</context>
<context context-type="linenumber">15</context> <context context-type="linenumber">15</context>
@ -8164,7 +8164,7 @@
</trans-unit> </trans-unit>
<trans-unit id="rule.assetClassClusterRiskEquity" datatype="html"> <trans-unit id="rule.assetClassClusterRiskEquity" datatype="html">
<source>Equity</source> <source>Equity</source>
<target state="new">Equity</target> <target state="translated">Акції</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/i18n/i18n-page.html</context> <context context-type="sourcefile">apps/client/src/app/pages/i18n/i18n-page.html</context>
<context context-type="linenumber">41</context> <context context-type="linenumber">41</context>
@ -8252,7 +8252,7 @@
</trans-unit> </trans-unit>
<trans-unit id="rule.currencyClusterRiskCurrentInvestment" datatype="html"> <trans-unit id="rule.currencyClusterRiskCurrentInvestment" datatype="html">
<source>Investment</source> <source>Investment</source>
<target state="new">Investment</target> <target state="translated">Інвестиція</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/i18n/i18n-page.html</context> <context context-type="sourcefile">apps/client/src/app/pages/i18n/i18n-page.html</context>
<context context-type="linenumber">95</context> <context context-type="linenumber">95</context>
@ -8276,7 +8276,7 @@
</trans-unit> </trans-unit>
<trans-unit id="routes.start" datatype="html"> <trans-unit id="routes.start" datatype="html">
<source>start</source> <source>start</source>
<target state="new">start</target> <target state="translated">pochatok</target>
<note priority="1" from="description">kebab-case</note> <note priority="1" from="description">kebab-case</note>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">libs/common/src/lib/routes/routes.ts</context> <context context-type="sourcefile">libs/common/src/lib/routes/routes.ts</context>
@ -8297,7 +8297,7 @@
</trans-unit> </trans-unit>
<trans-unit id="5193539160604294602" datatype="html"> <trans-unit id="5193539160604294602" datatype="html">
<source>Generate</source> <source>Generate</source>
<target state="new">Generate</target> <target state="translated">Згенерувати</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/components/user-account-access/user-account-access.html</context> <context context-type="sourcefile">apps/client/src/app/components/user-account-access/user-account-access.html</context>
<context context-type="linenumber">45</context> <context context-type="linenumber">45</context>
@ -8313,7 +8313,7 @@
</trans-unit> </trans-unit>
<trans-unit id="2732382861635368484" datatype="html"> <trans-unit id="2732382861635368484" datatype="html">
<source>Stocks</source> <source>Stocks</source>
<target state="new">Stocks</target> <target state="translated">Акції</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/components/markets/markets.component.ts</context> <context context-type="sourcefile">apps/client/src/app/components/markets/markets.component.ts</context>
<context context-type="linenumber">51</context> <context context-type="linenumber">51</context>
@ -8325,7 +8325,7 @@
</trans-unit> </trans-unit>
<trans-unit id="1419479195323304896" datatype="html"> <trans-unit id="1419479195323304896" datatype="html">
<source>Cryptocurrencies</source> <source>Cryptocurrencies</source>
<target state="new">Cryptocurrencies</target> <target state="translated">Криптовалюти</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/components/markets/markets.component.ts</context> <context context-type="sourcefile">apps/client/src/app/components/markets/markets.component.ts</context>
<context context-type="linenumber">52</context> <context context-type="linenumber">52</context>
@ -8361,7 +8361,7 @@
</trans-unit> </trans-unit>
<trans-unit id="5795124554973640871" datatype="html"> <trans-unit id="5795124554973640871" datatype="html">
<source>Collectible</source> <source>Collectible</source>
<target state="new">Collectible</target> <target state="translated">Колекційний предмет</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/i18n.ts</context> <context context-type="sourcefile">libs/ui/src/lib/i18n.ts</context>
<context context-type="linenumber">55</context> <context context-type="linenumber">55</context>
@ -8421,7 +8421,7 @@
</trans-unit> </trans-unit>
<trans-unit id="rule.fees.category" datatype="html"> <trans-unit id="rule.fees.category" datatype="html">
<source>Fees</source> <source>Fees</source>
<target state="new">Fees</target> <target state="translated">Комісії</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/i18n/i18n-page.html</context> <context context-type="sourcefile">apps/client/src/app/pages/i18n/i18n-page.html</context>
<context context-type="linenumber">161</context> <context context-type="linenumber">161</context>
@ -8429,7 +8429,7 @@
</trans-unit> </trans-unit>
<trans-unit id="rule.liquidity.category" datatype="html"> <trans-unit id="rule.liquidity.category" datatype="html">
<source>Liquidity</source> <source>Liquidity</source>
<target state="new">Liquidity</target> <target state="translated">Ліквідність</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/i18n/i18n-page.html</context> <context context-type="sourcefile">apps/client/src/app/pages/i18n/i18n-page.html</context>
<context context-type="linenumber">70</context> <context context-type="linenumber">70</context>
@ -8565,7 +8565,7 @@
</trans-unit> </trans-unit>
<trans-unit id="rule.regionalMarketClusterRiskAsiaPacific" datatype="html"> <trans-unit id="rule.regionalMarketClusterRiskAsiaPacific" datatype="html">
<source>Asia-Pacific</source> <source>Asia-Pacific</source>
<target state="new">Asia-Pacific</target> <target state="translated">Азіатсько-Тихоокеанський регіон</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/i18n/i18n-page.html</context> <context context-type="sourcefile">apps/client/src/app/pages/i18n/i18n-page.html</context>
<context context-type="linenumber">165</context> <context context-type="linenumber">165</context>
@ -8629,7 +8629,7 @@
</trans-unit> </trans-unit>
<trans-unit id="rule.regionalMarketClusterRiskEurope" datatype="html"> <trans-unit id="rule.regionalMarketClusterRiskEurope" datatype="html">
<source>Europe</source> <source>Europe</source>
<target state="new">Europe</target> <target state="translated">Європа</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/i18n/i18n-page.html</context> <context context-type="sourcefile">apps/client/src/app/pages/i18n/i18n-page.html</context>
<context context-type="linenumber">195</context> <context context-type="linenumber">195</context>
@ -8661,7 +8661,7 @@
</trans-unit> </trans-unit>
<trans-unit id="rule.regionalMarketClusterRiskJapan" datatype="html"> <trans-unit id="rule.regionalMarketClusterRiskJapan" datatype="html">
<source>Japan</source> <source>Japan</source>
<target state="new">Japan</target> <target state="translated">Японія</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/i18n/i18n-page.html</context> <context context-type="sourcefile">apps/client/src/app/pages/i18n/i18n-page.html</context>
<context context-type="linenumber">209</context> <context context-type="linenumber">209</context>

36
apps/client/src/styles.scss

@ -1,10 +1,11 @@
@use '@angular/material' as mat; @use '@angular/material' as mat;
@use 'sass:color';
@import './styles/bootstrap'; @use './styles/bootstrap';
@import './styles/table'; @use './styles/table' as table;
@import './styles/variables'; @use './styles/variables' as variables;
@import 'svgmap/style.min'; @use 'svgmap/style.min';
:root { :root {
--dark-background: rgb(25, 25, 25); --dark-background: rgb(25, 25, 25);
@ -12,8 +13,10 @@
--light-background: rgb(255, 255, 255); --light-background: rgb(255, 255, 255);
--dark-primary-text: --dark-primary-text:
#{red($dark-primary-text)}, #{green($dark-primary-text)}, #{color.channel(variables.$dark-primary-text, 'red')},
#{blue($dark-primary-text)}, #{alpha($dark-primary-text)}; #{color.channel(variables.$dark-primary-text, 'green')},
#{color.channel(variables.$dark-primary-text, 'blue')},
#{color.channel(variables.$dark-primary-text, 'alpha')};
--dark-secondary-text: 0, 0, 0, 0.54; --dark-secondary-text: 0, 0, 0, 0.54;
--dark-accent-text: 0, 0, 0, 0.87; --dark-accent-text: 0, 0, 0, 0.87;
--dark-warn-text: 0, 0, 0, 0.87; --dark-warn-text: 0, 0, 0, 0.87;
@ -21,8 +24,10 @@
--dark-dividers: 0, 0, 0, 0.12; --dark-dividers: 0, 0, 0, 0.12;
--dark-focused: 0, 0, 0, 0.12; --dark-focused: 0, 0, 0, 0.12;
--light-primary-text: --light-primary-text:
#{red($light-primary-text)}, #{green($light-primary-text)}, #{color.channel(variables.$light-primary-text, 'red')},
#{blue($light-primary-text)}, #{alpha($light-primary-text)}; #{color.channel(variables.$light-primary-text, 'green')},
#{color.channel(variables.$light-primary-text, 'blue')},
#{color.channel(variables.$light-primary-text, 'alpha')};
--light-secondary-text: 255, 255, 255, 0.7; --light-secondary-text: 255, 255, 255, 0.7;
--light-accent-text: 255, 255, 255, 1; --light-accent-text: 255, 255, 255, 1;
--light-warn-text: 255, 255, 255, 1; --light-warn-text: 255, 255, 255, 1;
@ -240,7 +245,7 @@ body {
} }
.gf-table { .gf-table {
@include gf-table(true); @include table.gf-table(true);
} }
.mat-mdc-dialog-container { .mat-mdc-dialog-container {
@ -353,17 +358,13 @@ ngx-skeleton-loader {
} }
.gf-table { .gf-table {
@include gf-table; @include table.gf-table;
} }
.gf-text-wrap-balance { .gf-text-wrap-balance {
text-wrap: balance; text-wrap: balance;
} }
.has-fab {
padding-bottom: 3rem !important;
}
.has-info-message { .has-info-message {
// Restrict viewport height of tabbed views when the Live Demo or system announcements banner are displayed // Restrict viewport height of tabbed views when the Live Demo or system announcements banner are displayed
.page:has(gf-page-tabs) { .page:has(gf-page-tabs) {
@ -484,13 +485,6 @@ ngx-skeleton-loader {
padding-bottom: env(safe-area-inset-bottom); padding-bottom: env(safe-area-inset-bottom);
padding-bottom: constant(safe-area-inset-bottom); padding-bottom: constant(safe-area-inset-bottom);
.fab-container {
bottom: 2rem;
position: fixed;
right: 2rem;
z-index: 999;
}
// Restrict viewport height and layout boundaries only when the page hosts tab navigation // Restrict viewport height and layout boundaries only when the page hosts tab navigation
&:has(gf-page-tabs) { &:has(gf-page-tabs) {
height: calc(100svh - var(--mat-toolbar-standard-height)); height: calc(100svh - var(--mat-toolbar-standard-height));

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

@ -256,6 +256,7 @@ export const PROPERTY_SLACK_COMMUNITY_USERS = 'SLACK_COMMUNITY_USERS';
export const PROPERTY_STRIPE_CONFIG = 'STRIPE_CONFIG'; export const PROPERTY_STRIPE_CONFIG = 'STRIPE_CONFIG';
export const PROPERTY_SYSTEM_MESSAGE = 'SYSTEM_MESSAGE'; export const PROPERTY_SYSTEM_MESSAGE = 'SYSTEM_MESSAGE';
export const PROPERTY_UPTIME = 'UPTIME'; export const PROPERTY_UPTIME = 'UPTIME';
export const PROPERTY_WEB_FETCH_ROUTES = 'WEB_FETCH_ROUTES';
export const QUEUE_JOB_STATUS_LIST = [ export const QUEUE_JOB_STATUS_LIST = [
'active', 'active',

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

@ -1,5 +1,12 @@
import { NumberParser } from '@internationalized/number'; import { NumberParser } from '@internationalized/number';
import { Type as ActivityType, DataSource, MarketData } from '@prisma/client'; import {
Type as ActivityType,
DataSource,
MarketData,
Prisma,
SymbolProfile,
SymbolProfileOverrides
} from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { isISO4217CurrencyCode } from 'class-validator'; import { isISO4217CurrencyCode } from 'class-validator';
import { import {
@ -47,6 +54,42 @@ export const DATE_FORMAT = 'yyyy-MM-dd';
export const DATE_FORMAT_MONTHLY = 'MMMM yyyy'; export const DATE_FORMAT_MONTHLY = 'MMMM yyyy';
export const DATE_FORMAT_YEARLY = 'yyyy'; export const DATE_FORMAT_YEARLY = 'yyyy';
export function applyAssetProfileOverrides<T extends Partial<SymbolProfile>>(
assetProfile: T,
assetProfileOverrides: SymbolProfileOverrides | null
): T {
if (!assetProfileOverrides) {
return assetProfile;
}
const assetProfileWithOverrides = { ...assetProfile } as T;
assetProfileWithOverrides.assetClass =
assetProfileOverrides.assetClass ?? assetProfile.assetClass;
assetProfileWithOverrides.assetSubClass =
assetProfileOverrides.assetSubClass ?? assetProfile.assetSubClass;
if ((assetProfileOverrides.countries as Prisma.JsonArray)?.length > 0) {
assetProfileWithOverrides.countries = assetProfileOverrides.countries;
}
if ((assetProfileOverrides.holdings as Prisma.JsonArray)?.length > 0) {
assetProfileWithOverrides.holdings = assetProfileOverrides.holdings;
}
assetProfileWithOverrides.name =
assetProfileOverrides.name ?? assetProfile.name;
if ((assetProfileOverrides.sectors as Prisma.JsonArray)?.length > 0) {
assetProfileWithOverrides.sectors = assetProfileOverrides.sectors;
}
assetProfileWithOverrides.url = assetProfileOverrides.url ?? assetProfile.url;
return assetProfileWithOverrides;
}
export function calculateBenchmarkTrend({ export function calculateBenchmarkTrend({
days, days,
historicalData historicalData

47
libs/common/src/lib/interfaces/portfolio-position.interface.ts

@ -1,22 +1,12 @@
import { Market, MarketAdvanced } from '@ghostfolio/common/types'; import { Market, MarketAdvanced } from '@ghostfolio/common/types';
import { AssetClass, AssetSubClass, DataSource, Tag } from '@prisma/client'; import { Tag } from '@prisma/client';
import { Country } from './country.interface';
import { EnhancedSymbolProfile } from './enhanced-symbol-profile.interface'; import { EnhancedSymbolProfile } from './enhanced-symbol-profile.interface';
import { Holding } from './holding.interface';
import { Sector } from './sector.interface';
export interface PortfolioPosition { export interface PortfolioPosition {
activitiesCount: number; activitiesCount: number;
allocationInPercentage: number; allocationInPercentage: number;
/** @deprecated */
assetClass?: AssetClass;
/** @deprecated */
assetClassLabel?: string;
assetProfile: Pick< assetProfile: Pick<
EnhancedSymbolProfile, EnhancedSymbolProfile,
| 'assetClass' | 'assetClass'
@ -33,22 +23,6 @@ export interface PortfolioPosition {
assetClassLabel?: string; assetClassLabel?: string;
assetSubClassLabel?: string; assetSubClassLabel?: string;
}; };
/** @deprecated */
assetSubClass?: AssetSubClass;
/** @deprecated */
assetSubClassLabel?: string;
/** @deprecated */
countries: Country[];
/** @deprecated */
currency: string;
/** @deprecated */
dataSource: DataSource;
dateOfFirstActivity: Date; dateOfFirstActivity: Date;
dividend: number; dividend: number;
exchange?: string; exchange?: string;
@ -56,38 +30,19 @@ export interface PortfolioPosition {
grossPerformancePercent: number; grossPerformancePercent: number;
grossPerformancePercentWithCurrencyEffect: number; grossPerformancePercentWithCurrencyEffect: number;
grossPerformanceWithCurrencyEffect: number; grossPerformanceWithCurrencyEffect: number;
/** @deprecated */
holdings: Holding[];
investment: number; investment: number;
marketChange?: number; marketChange?: number;
marketChangePercent?: number; marketChangePercent?: number;
marketPrice: number; marketPrice: number;
markets?: { [key in Market]: number }; markets?: { [key in Market]: number };
marketsAdvanced?: { [key in MarketAdvanced]: number }; marketsAdvanced?: { [key in MarketAdvanced]: number };
/** @deprecated */
name: string;
netPerformance: number; netPerformance: number;
netPerformancePercent: number; netPerformancePercent: number;
netPerformancePercentWithCurrencyEffect: number; netPerformancePercentWithCurrencyEffect: number;
netPerformanceWithCurrencyEffect: number; netPerformanceWithCurrencyEffect: number;
quantity: number; quantity: number;
/** @deprecated */
sectors: Sector[];
/** @deprecated */
symbol: string;
tags?: Tag[]; tags?: Tag[];
type?: string; type?: string;
/** @deprecated */
url?: string;
valueInBaseCurrency?: number; valueInBaseCurrency?: number;
valueInPercentage?: number; valueInPercentage?: number;
} }

1
libs/common/src/lib/interfaces/product.ts

@ -13,5 +13,6 @@ export interface Product {
pricingPerYear?: string; pricingPerYear?: string;
regions?: string[]; regions?: string[];
slogan?: string; slogan?: string;
url?: string;
useAnonymously?: boolean; useAnonymously?: boolean;
} }

382
libs/common/src/lib/personal-finance-tools.ts

File diff suppressed because it is too large

37
libs/ui/src/lib/assistant/assistant.component.ts

@ -504,11 +504,16 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ holdings }) => { .subscribe(({ holdings }) => {
this.holdings = holdings this.holdings = holdings
.filter(({ assetSubClass }) => { .filter(({ assetProfile }) => {
return assetSubClass && !['CASH'].includes(assetSubClass); return (
assetProfile.assetSubClass &&
!['CASH'].includes(assetProfile.assetSubClass)
);
}) })
.sort((a, b) => { .sort((a, b) => {
return a.name?.localeCompare(b.name); return (a.assetProfile.name ?? '').localeCompare(
b.assetProfile.name ?? ''
);
}); });
this.setPortfolioFilterFormValues(); this.setPortfolioFilterFormValues();
@ -530,11 +535,11 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
type: 'ASSET_CLASS' type: 'ASSET_CLASS'
}, },
{ {
id: filterValue?.holding?.dataSource ?? '', id: filterValue?.holding?.assetProfile?.dataSource ?? '',
type: 'DATA_SOURCE' type: 'DATA_SOURCE'
}, },
{ {
id: filterValue?.holding?.symbol ?? '', id: filterValue?.holding?.assetProfile?.symbol ?? '',
type: 'SYMBOL' type: 'SYMBOL'
}, },
{ {
@ -718,18 +723,16 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
return EMPTY; return EMPTY;
}), }),
map(({ holdings }) => { map(({ holdings }) => {
return holdings.map( return holdings.map(({ assetProfile }) => {
({ assetSubClass, currency, dataSource, name, symbol }) => {
return { return {
currency, assetSubClassString: translate(assetProfile.assetSubClass ?? ''),
dataSource, currency: assetProfile.currency ?? '',
name, dataSource: assetProfile.dataSource,
symbol, mode: SearchMode.HOLDING as const,
assetSubClassString: translate(assetSubClass ?? ''), name: assetProfile.name ?? '',
mode: SearchMode.HOLDING as const symbol: assetProfile.symbol
}; };
} });
);
}), }),
takeUntilDestroyed(this.destroyRef) takeUntilDestroyed(this.destroyRef)
); );
@ -777,8 +780,8 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
return ( return (
!!(dataSource && symbol) && !!(dataSource && symbol) &&
getAssetProfileIdentifier({ getAssetProfileIdentifier({
dataSource: holding.dataSource, dataSource: holding.assetProfile.dataSource,
symbol: holding.symbol symbol: holding.assetProfile.symbol
}) === getAssetProfileIdentifier({ dataSource, symbol }) }) === getAssetProfileIdentifier({ dataSource, symbol })
); );
}); });

9
libs/ui/src/lib/fab/fab.component.html

@ -0,0 +1,9 @@
<a
class="align-items-center d-flex justify-content-center"
color="primary"
mat-fab
[queryParams]="queryParams()"
[routerLink]="[]"
>
<ion-icon name="add-outline" size="large" />
</a>

14
libs/ui/src/lib/fab/fab.component.scss

@ -0,0 +1,14 @@
:host {
bottom: calc(constant(safe-area-inset-bottom) + 2rem);
bottom: calc(env(safe-area-inset-bottom) + 2rem);
position: fixed;
right: 2rem;
z-index: 999;
}
:host-context(gf-page-tabs) {
@media (max-width: 575.98px) {
bottom: calc(constant(safe-area-inset-bottom) + 5rem);
bottom: calc(env(safe-area-inset-bottom) + 5rem);
}
}

21
libs/ui/src/lib/fab/fab.component.ts

@ -0,0 +1,21 @@
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { Params, RouterModule } from '@angular/router';
import { IonIcon } from '@ionic/angular/standalone';
import { addIcons } from 'ionicons';
import { addOutline } from 'ionicons/icons';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [IonIcon, MatButtonModule, RouterModule],
selector: 'gf-fab',
styleUrls: ['./fab.component.scss'],
templateUrl: './fab.component.html'
})
export class GfFabComponent {
public readonly queryParams = input.required<Params>();
public constructor() {
addIcons({ addOutline });
}
}

1
libs/ui/src/lib/fab/index.ts

@ -0,0 +1 @@
export * from './fab.component';

200
libs/ui/src/lib/mocks/holdings.ts

@ -4,11 +4,11 @@ export const holdings: PortfolioPosition[] = [
{ {
activitiesCount: 1, activitiesCount: 1,
allocationInPercentage: 0.042990776363386086, allocationInPercentage: 0.042990776363386086,
assetClass: 'EQUITY',
assetClassLabel: 'Equity',
assetProfile: { assetProfile: {
assetClass: 'EQUITY', assetClass: 'EQUITY',
assetClassLabel: 'Equity',
assetSubClass: 'STOCK', assetSubClass: 'STOCK',
assetSubClassLabel: 'Stock',
countries: [ countries: [
{ {
code: 'US', code: 'US',
@ -20,60 +20,40 @@ export const holdings: PortfolioPosition[] = [
currency: 'USD', currency: 'USD',
dataSource: 'YAHOO', dataSource: 'YAHOO',
holdings: [], holdings: [],
name: 'Apple Inc',
sectors: [ sectors: [
{ {
name: 'Technology', name: 'Technology',
weight: 1 weight: 1
} }
], ],
symbol: 'AAPL' symbol: 'AAPL',
url: 'https://www.apple.com'
}, },
assetSubClass: 'STOCK',
assetSubClassLabel: 'Stock',
countries: [
{
code: 'US',
continent: 'North America',
name: 'United States',
weight: 1
}
],
currency: 'USD',
dataSource: 'YAHOO',
dateOfFirstActivity: new Date('2021-12-01T00:00:00.000Z'), dateOfFirstActivity: new Date('2021-12-01T00:00:00.000Z'),
dividend: 0, dividend: 0,
grossPerformance: 3856, grossPerformance: 3856,
grossPerformancePercent: 0.46047289228564603, grossPerformancePercent: 0.46047289228564603,
grossPerformancePercentWithCurrencyEffect: 0.46047289228564603, grossPerformancePercentWithCurrencyEffect: 0.46047289228564603,
grossPerformanceWithCurrencyEffect: 3856, grossPerformanceWithCurrencyEffect: 3856,
holdings: [],
investment: 8374, investment: 8374,
marketPrice: 244.6, marketPrice: 244.6,
name: 'Apple Inc',
netPerformance: 3855, netPerformance: 3855,
netPerformancePercent: 0.460353475041796, netPerformancePercent: 0.460353475041796,
netPerformancePercentWithCurrencyEffect: 0.036440677966101696, netPerformancePercentWithCurrencyEffect: 0.036440677966101696,
netPerformanceWithCurrencyEffect: 430, netPerformanceWithCurrencyEffect: 430,
quantity: 50, quantity: 50,
sectors: [
{
name: 'Technology',
weight: 1
}
],
symbol: 'AAPL',
tags: [], tags: [],
url: 'https://www.apple.com',
valueInBaseCurrency: 12230 valueInBaseCurrency: 12230
}, },
{ {
activitiesCount: 2, activitiesCount: 2,
allocationInPercentage: 0.02377401948293552, allocationInPercentage: 0.02377401948293552,
assetClass: 'EQUITY',
assetClassLabel: 'Equity',
assetProfile: { assetProfile: {
assetClass: 'EQUITY', assetClass: 'EQUITY',
assetClassLabel: 'Equity',
assetSubClass: 'STOCK', assetSubClass: 'STOCK',
assetSubClassLabel: 'Stock',
countries: [ countries: [
{ {
code: 'DE', code: 'DE',
@ -85,60 +65,40 @@ export const holdings: PortfolioPosition[] = [
currency: 'EUR', currency: 'EUR',
dataSource: 'YAHOO', dataSource: 'YAHOO',
holdings: [], holdings: [],
name: 'Allianz SE',
sectors: [ sectors: [
{ {
name: 'Financial Services', name: 'Financial Services',
weight: 1 weight: 1
} }
], ],
symbol: 'ALV.DE' symbol: 'ALV.DE',
url: 'https://www.allianz.com'
}, },
assetSubClass: 'STOCK',
assetSubClassLabel: 'Stock',
countries: [
{
code: 'DE',
continent: 'Europe',
name: 'Germany',
weight: 1
}
],
currency: 'EUR',
dataSource: 'YAHOO',
dateOfFirstActivity: new Date('2021-04-23T00:00:00.000Z'), dateOfFirstActivity: new Date('2021-04-23T00:00:00.000Z'),
dividend: 192, dividend: 192,
grossPerformance: 2226.700251889169, grossPerformance: 2226.700251889169,
grossPerformancePercent: 0.49083842309827874, grossPerformancePercent: 0.49083842309827874,
grossPerformancePercentWithCurrencyEffect: 0.29306136948826367, grossPerformancePercentWithCurrencyEffect: 0.29306136948826367,
grossPerformanceWithCurrencyEffect: 1532.8272791336772, grossPerformanceWithCurrencyEffect: 1532.8272791336772,
holdings: [],
investment: 4536.523929471033, investment: 4536.523929471033,
marketPrice: 322.2, marketPrice: 322.2,
name: 'Allianz SE',
netPerformance: 2222.2921914357685, netPerformance: 2222.2921914357685,
netPerformancePercent: 0.48986674069961134, netPerformancePercent: 0.48986674069961134,
netPerformancePercentWithCurrencyEffect: 0.034489367670592026, netPerformancePercentWithCurrencyEffect: 0.034489367670592026,
netPerformanceWithCurrencyEffect: 225.48257403052068, netPerformanceWithCurrencyEffect: 225.48257403052068,
quantity: 20, quantity: 20,
sectors: [
{
name: 'Financial Services',
weight: 1
}
],
symbol: 'ALV.DE',
tags: [], tags: [],
url: 'https://www.allianz.com',
valueInBaseCurrency: 6763.224181360202 valueInBaseCurrency: 6763.224181360202
}, },
{ {
activitiesCount: 1, activitiesCount: 1,
allocationInPercentage: 0.08038536990007467, allocationInPercentage: 0.08038536990007467,
assetClass: 'EQUITY',
assetClassLabel: 'Equity',
assetProfile: { assetProfile: {
assetClass: 'EQUITY', assetClass: 'EQUITY',
assetClassLabel: 'Equity',
assetSubClass: 'STOCK', assetSubClass: 'STOCK',
assetSubClassLabel: 'Stock',
countries: [ countries: [
{ {
code: 'US', code: 'US',
@ -150,101 +110,73 @@ export const holdings: PortfolioPosition[] = [
currency: 'USD', currency: 'USD',
dataSource: 'YAHOO', dataSource: 'YAHOO',
holdings: [], holdings: [],
name: 'Amazon.com, Inc.',
sectors: [ sectors: [
{ {
name: 'Consumer Discretionary', name: 'Consumer Discretionary',
weight: 1 weight: 1
} }
], ],
symbol: 'AMZN' symbol: 'AMZN',
url: 'https://www.aboutamazon.com'
}, },
assetSubClass: 'STOCK',
assetSubClassLabel: 'Stock',
countries: [
{
code: 'US',
continent: 'North America',
name: 'United States',
weight: 1
}
],
currency: 'USD',
dataSource: 'YAHOO',
dateOfFirstActivity: new Date('2018-10-01T00:00:00.000Z'), dateOfFirstActivity: new Date('2018-10-01T00:00:00.000Z'),
dividend: 0, dividend: 0,
grossPerformance: 12758.05, grossPerformance: 12758.05,
grossPerformancePercent: 1.2619300787837724, grossPerformancePercent: 1.2619300787837724,
grossPerformancePercentWithCurrencyEffect: 1.2619300787837724, grossPerformancePercentWithCurrencyEffect: 1.2619300787837724,
grossPerformanceWithCurrencyEffect: 12758.05, grossPerformanceWithCurrencyEffect: 12758.05,
holdings: [],
investment: 10109.95, investment: 10109.95,
marketPrice: 228.68, marketPrice: 228.68,
name: 'Amazon.com, Inc.',
netPerformance: 12677.26, netPerformance: 12677.26,
netPerformancePercent: 1.253938941339967, netPerformancePercent: 1.253938941339967,
netPerformancePercentWithCurrencyEffect: -0.037866008722316276, netPerformancePercentWithCurrencyEffect: -0.037866008722316276,
netPerformanceWithCurrencyEffect: -899.99926757812, netPerformanceWithCurrencyEffect: -899.99926757812,
quantity: 100, quantity: 100,
sectors: [
{
name: 'Consumer Discretionary',
weight: 1
}
],
symbol: 'AMZN',
tags: [], tags: [],
url: 'https://www.aboutamazon.com',
valueInBaseCurrency: 22868 valueInBaseCurrency: 22868
}, },
{ {
activitiesCount: 1, activitiesCount: 1,
allocationInPercentage: 0.19216416482928922, allocationInPercentage: 0.19216416482928922,
assetClass: 'LIQUIDITY',
assetClassLabel: 'Liquidity',
assetProfile: { assetProfile: {
assetClass: 'LIQUIDITY', assetClass: 'LIQUIDITY',
assetSubClass: 'CASH', assetClassLabel: 'Liquidity',
assetSubClass: 'CRYPTOCURRENCY',
assetSubClassLabel: 'Cryptocurrency',
countries: [], countries: [],
currency: 'USD', currency: 'USD',
dataSource: 'COINGECKO', dataSource: 'COINGECKO',
holdings: [], holdings: [],
name: 'Bitcoin',
sectors: [], sectors: [],
symbol: 'bitcoin' symbol: 'bitcoin',
url: undefined
}, },
assetSubClass: 'CRYPTOCURRENCY',
assetSubClassLabel: 'Cryptocurrency',
countries: [],
currency: 'USD',
dataSource: 'COINGECKO',
dateOfFirstActivity: new Date('2017-08-16T00:00:00.000Z'), dateOfFirstActivity: new Date('2017-08-16T00:00:00.000Z'),
dividend: 0, dividend: 0,
grossPerformance: 52666.7898248, grossPerformance: 52666.7898248,
grossPerformancePercent: 26.333394912400003, grossPerformancePercent: 26.333394912400003,
grossPerformancePercentWithCurrencyEffect: 26.333394912400003, grossPerformancePercentWithCurrencyEffect: 26.333394912400003,
grossPerformanceWithCurrencyEffect: 52666.7898248, grossPerformanceWithCurrencyEffect: 52666.7898248,
holdings: [],
investment: 1999.9999999999998, investment: 1999.9999999999998,
marketPrice: 97364, marketPrice: 97364,
name: 'Bitcoin',
netPerformance: 52636.8898248, netPerformance: 52636.8898248,
netPerformancePercent: 26.3184449124, netPerformancePercent: 26.3184449124,
netPerformancePercentWithCurrencyEffect: -0.04760906442310894, netPerformancePercentWithCurrencyEffect: -0.04760906442310894,
netPerformanceWithCurrencyEffect: -2732.737808972287, netPerformanceWithCurrencyEffect: -2732.737808972287,
quantity: 0.5614682, quantity: 0.5614682,
sectors: [],
symbol: 'bitcoin',
tags: [], tags: [],
url: undefined,
valueInBaseCurrency: 54666.7898248 valueInBaseCurrency: 54666.7898248
}, },
{ {
activitiesCount: 1, activitiesCount: 1,
allocationInPercentage: 0.04307127421937313, allocationInPercentage: 0.04307127421937313,
assetClass: 'EQUITY',
assetClassLabel: 'Equity',
assetProfile: { assetProfile: {
assetClass: 'EQUITY', assetClass: 'EQUITY',
assetClassLabel: 'Equity',
assetSubClass: 'STOCK', assetSubClass: 'STOCK',
assetSubClassLabel: 'Stock',
countries: [ countries: [
{ {
code: 'US', code: 'US',
@ -256,60 +188,40 @@ export const holdings: PortfolioPosition[] = [
currency: 'USD', currency: 'USD',
dataSource: 'YAHOO', dataSource: 'YAHOO',
holdings: [], holdings: [],
name: 'Microsoft Corporation',
sectors: [ sectors: [
{ {
name: 'Technology', name: 'Technology',
weight: 1 weight: 1
} }
], ],
symbol: 'MSFT' symbol: 'MSFT',
url: 'https://www.microsoft.com'
}, },
assetSubClass: 'STOCK',
assetSubClassLabel: 'Stock',
countries: [
{
code: 'US',
continent: 'North America',
name: 'United States',
weight: 1
}
],
currency: 'USD',
dataSource: 'YAHOO',
dateOfFirstActivity: new Date('2023-01-03T00:00:00.000Z'), dateOfFirstActivity: new Date('2023-01-03T00:00:00.000Z'),
dividend: 0, dividend: 0,
grossPerformance: 5065.5, grossPerformance: 5065.5,
grossPerformancePercent: 0.7047750229568411, grossPerformancePercent: 0.7047750229568411,
grossPerformancePercentWithCurrencyEffect: 0.7047750229568411, grossPerformancePercentWithCurrencyEffect: 0.7047750229568411,
grossPerformanceWithCurrencyEffect: 5065.5, grossPerformanceWithCurrencyEffect: 5065.5,
holdings: [],
investment: 7187.4, investment: 7187.4,
marketPrice: 408.43, marketPrice: 408.43,
name: 'Microsoft Corporation',
netPerformance: 5065.5, netPerformance: 5065.5,
netPerformancePercent: 0.7047750229568411, netPerformancePercent: 0.7047750229568411,
netPerformancePercentWithCurrencyEffect: -0.015973588391056275, netPerformancePercentWithCurrencyEffect: -0.015973588391056275,
netPerformanceWithCurrencyEffect: -198.899926757814, netPerformanceWithCurrencyEffect: -198.899926757814,
quantity: 30, quantity: 30,
sectors: [
{
name: 'Technology',
weight: 1
}
],
symbol: 'MSFT',
tags: [], tags: [],
url: 'https://www.microsoft.com',
valueInBaseCurrency: 12252.9 valueInBaseCurrency: 12252.9
}, },
{ {
activitiesCount: 1, activitiesCount: 1,
allocationInPercentage: 0.18762679306394897, allocationInPercentage: 0.18762679306394897,
assetClass: 'EQUITY',
assetClassLabel: 'Equity',
assetProfile: { assetProfile: {
assetClass: 'EQUITY', assetClass: 'EQUITY',
assetClassLabel: 'Equity',
assetSubClass: 'STOCK', assetSubClass: 'STOCK',
assetSubClassLabel: 'Stock',
countries: [ countries: [
{ {
code: 'US', code: 'US',
@ -321,60 +233,40 @@ export const holdings: PortfolioPosition[] = [
currency: 'USD', currency: 'USD',
dataSource: 'YAHOO', dataSource: 'YAHOO',
holdings: [], holdings: [],
name: 'Tesla, Inc.',
sectors: [ sectors: [
{ {
name: 'Consumer Discretionary', name: 'Consumer Discretionary',
weight: 1 weight: 1
} }
], ],
symbol: 'TSLA' symbol: 'TSLA',
url: 'https://www.tesla.com'
}, },
assetSubClass: 'STOCK',
assetSubClassLabel: 'Stock',
countries: [
{
code: 'US',
continent: 'North America',
name: 'United States',
weight: 1
}
],
currency: 'USD',
dataSource: 'YAHOO',
dateOfFirstActivity: new Date('2017-01-03T00:00:00.000Z'), dateOfFirstActivity: new Date('2017-01-03T00:00:00.000Z'),
dividend: 0, dividend: 0,
grossPerformance: 51227.500000005, grossPerformance: 51227.500000005,
grossPerformancePercent: 23.843379101756675, grossPerformancePercent: 23.843379101756675,
grossPerformancePercentWithCurrencyEffect: 23.843379101756675, grossPerformancePercentWithCurrencyEffect: 23.843379101756675,
grossPerformanceWithCurrencyEffect: 51227.500000005, grossPerformanceWithCurrencyEffect: 51227.500000005,
holdings: [],
investment: 2148.499999995, investment: 2148.499999995,
marketPrice: 355.84, marketPrice: 355.84,
name: 'Tesla, Inc.',
netPerformance: 51197.500000005, netPerformance: 51197.500000005,
netPerformancePercent: 23.829415871596066, netPerformancePercent: 23.829415871596066,
netPerformancePercentWithCurrencyEffect: -0.12051410125545206, netPerformancePercentWithCurrencyEffect: -0.12051410125545206,
netPerformanceWithCurrencyEffect: -7314.00091552734, netPerformanceWithCurrencyEffect: -7314.00091552734,
quantity: 150, quantity: 150,
sectors: [
{
name: 'Consumer Discretionary',
weight: 1
}
],
symbol: 'TSLA',
tags: [], tags: [],
url: 'https://www.tesla.com',
valueInBaseCurrency: 53376 valueInBaseCurrency: 53376
}, },
{ {
activitiesCount: 5, activitiesCount: 5,
allocationInPercentage: 0.053051250766657634, allocationInPercentage: 0.053051250766657634,
assetClass: 'EQUITY',
assetClassLabel: 'Equity',
assetProfile: { assetProfile: {
assetClass: 'EQUITY', assetClass: 'EQUITY',
assetClassLabel: 'Equity',
assetSubClass: 'ETF', assetSubClass: 'ETF',
assetSubClassLabel: 'ETF',
countries: [ countries: [
{ {
code: 'US', code: 'US',
@ -386,50 +278,30 @@ export const holdings: PortfolioPosition[] = [
currency: 'USD', currency: 'USD',
dataSource: 'YAHOO', dataSource: 'YAHOO',
holdings: [], holdings: [],
name: 'Vanguard Total Stock Market Index Fund ETF Shares',
sectors: [ sectors: [
{ {
name: 'Equity', name: 'Equity',
weight: 1 weight: 1
} }
], ],
symbol: 'VTI' symbol: 'VTI',
url: 'https://www.vanguard.com'
}, },
assetSubClass: 'ETF',
assetSubClassLabel: 'ETF',
countries: [
{
code: 'US',
weight: 1,
continent: 'North America',
name: 'United States'
}
],
currency: 'USD',
dataSource: 'YAHOO',
dateOfFirstActivity: new Date('2019-03-01T00:00:00.000Z'), dateOfFirstActivity: new Date('2019-03-01T00:00:00.000Z'),
dividend: 0, dividend: 0,
grossPerformance: 6845.8, grossPerformance: 6845.8,
grossPerformancePercent: 1.0164758094605268, grossPerformancePercent: 1.0164758094605268,
grossPerformancePercentWithCurrencyEffect: 1.0164758094605268, grossPerformancePercentWithCurrencyEffect: 1.0164758094605268,
grossPerformanceWithCurrencyEffect: 6845.8, grossPerformanceWithCurrencyEffect: 6845.8,
holdings: [],
investment: 8246.2, investment: 8246.2,
marketPrice: 301.84, marketPrice: 301.84,
name: 'Vanguard Total Stock Market Index Fund ETF Shares',
netPerformance: 6746.3, netPerformance: 6746.3,
netPerformancePercent: 1.0017018833976383, netPerformancePercent: 1.0017018833976383,
netPerformancePercentWithCurrencyEffect: 0.01085061564051406, netPerformancePercentWithCurrencyEffect: 0.01085061564051406,
netPerformanceWithCurrencyEffect: 161.99969482422, netPerformanceWithCurrencyEffect: 161.99969482422,
quantity: 50, quantity: 50,
sectors: [
{
name: 'Equity',
weight: 1
}
],
symbol: 'VTI',
tags: [], tags: [],
url: 'https://www.vanguard.com',
valueInBaseCurrency: 15092 valueInBaseCurrency: 15092
} }
]; ];

6
libs/ui/src/lib/page-tabs/page-tabs.component.scss

@ -15,12 +15,6 @@
); );
::ng-deep { ::ng-deep {
.fab-container {
@media (max-width: 575.98px) {
bottom: 5rem;
}
}
.mat-mdc-tab-nav-panel { .mat-mdc-tab-nav-panel {
padding: 2rem 0; padding: 2rem 0;

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

Loading…
Cancel
Save