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. 110
      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. 22
      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. 60
      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. 53
      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. 64
      apps/api/src/services/data-provider/eod-historical-data/eod-historical-data.service.ts
  44. 188
      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. 121
      apps/api/src/services/queues/statistics-gathering/statistics-gathering.processor.ts
  61. 60
      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. 22
      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. 24
      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. 153
      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. 41
      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,
"console": "integratedTerminal",
"cwd": "${workspaceFolder}/apps/api",
"envFile": "${workspaceFolder}/.env",
"env": {
"GHOSTFOLIO_ENV_FILE": "${workspaceFolder}/.env"
},
"name": "Debug API",
"outFiles": ["${workspaceFolder}/dist/apps/api/**/*.js"],
"program": "${workspaceFolder}/apps/api/src/main.ts",
"request": "launch",
"runtimeArgs": ["--nolazy", "-r", "ts-node/register"],
"runtimeArgs": [
"--nolazy",
"-r",
"ts-node/register",
"-r",
"${workspaceFolder}/tools/load-env.ts"
],
"skipFiles": [
"${workspaceFolder}/node_modules/**/*.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
### Added
- Added an automatic refresh every 30 seconds to the users table in the admin control panel
### Changed
- Centralized the asset profile override logic for manual adjustments
- 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
- 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

6
DEVELOPMENT.md

@ -84,6 +84,12 @@ https://ghostfol.io/development/storybook
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
#### 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 { 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 { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
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 impersonationService: ImpersonationService,
private readonly portfolioService: PortfolioService,
@Inject(REQUEST) private readonly request: RequestWithUser
@Inject(REQUEST) private readonly request: RequestWithUser,
private readonly userService: UserService
) {}
@Delete(':id')
@ -137,11 +139,14 @@ export class AccountController {
): Promise<AccountBalancesResponse> {
const impersonationUserId =
await this.impersonationService.validateImpersonationId(impersonationId);
const userId = impersonationUserId || this.request.user.id;
const { settings } = await this.userService.user({ id: userId });
return this.accountBalanceService.getAccountBalances({
userId,
filters: [{ id, type: 'ACCOUNT' }],
userCurrency: this.request.user.settings.settings.baseCurrency,
userId: impersonationUserId || this.request.user.id
userCurrency: settings.settings.baseCurrency
});
}

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 { 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 { ApiModule } from '@ghostfolio/api/services/api/api.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
@ -23,7 +24,8 @@ import { AccountService } from './account.service';
ImpersonationModule,
PortfolioModule,
PrismaModule,
RedactValuesInResponseModule
RedactValuesInResponseModule,
UserModule
],
providers: [AccountService]
})

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

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

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

@ -14,6 +14,7 @@ import {
PROPERTY_IS_USER_SIGNUP_ENABLED
} from '@ghostfolio/common/config';
import {
applyAssetProfileOverrides,
getAssetProfileIdentifier,
getCurrencyFromSymbol,
isCurrency
@ -29,7 +30,6 @@ import {
EnhancedSymbolProfile,
Filter
} from '@ghostfolio/common/interfaces';
import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
import { MarketDataPreset } from '@ghostfolio/common/types';
import {
@ -349,87 +349,61 @@ export class AdminService {
}
let marketData: AdminMarketDataItem[] = await Promise.all(
assetProfiles.map(
async ({
assetProfiles.map(async (assetProfile) => {
const {
_count,
activities,
assetClass,
assetSubClass,
comment,
countries,
currency,
dataSource,
id,
isActive,
isUsedByUsersWithSubscription,
name,
sectors,
symbol,
SymbolProfileOverrides
}) => {
let countriesCount = countries ? Object.keys(countries).length : 0;
symbol
} = assetProfile;
const lastMarketPrice = lastMarketPriceMap.get(
getAssetProfileIdentifier({ dataSource, symbol })
const { assetClass, assetSubClass, countries, name, sectors } =
applyAssetProfileOverrides(
assetProfile,
assetProfile.SymbolProfileOverrides
);
const marketDataItemCount =
marketDataItems.find((marketDataItem) => {
return (
marketDataItem.dataSource === dataSource &&
marketDataItem.symbol === symbol
);
})?._count ?? 0;
let sectorsCount = sectors ? Object.keys(sectors).length : 0;
if (SymbolProfileOverrides) {
assetClass = SymbolProfileOverrides.assetClass ?? assetClass;
assetSubClass =
SymbolProfileOverrides.assetSubClass ?? assetSubClass;
if (
(SymbolProfileOverrides.countries as unknown as Prisma.JsonArray)
?.length > 0
) {
countriesCount = (
SymbolProfileOverrides.countries as unknown as Prisma.JsonArray
).length;
}
const countriesCount = countries ? Object.keys(countries).length : 0;
name = SymbolProfileOverrides.name ?? name;
const lastMarketPrice = lastMarketPriceMap.get(
getAssetProfileIdentifier({ dataSource, symbol })
);
if (
(SymbolProfileOverrides.sectors as unknown as Sector[])?.length >
0
) {
sectorsCount = (
SymbolProfileOverrides.sectors as unknown as Prisma.JsonArray
).length;
}
}
const marketDataItemCount =
marketDataItems.find((marketDataItem) => {
return (
marketDataItem.dataSource === dataSource &&
marketDataItem.symbol === symbol
);
})?._count ?? 0;
return {
assetClass,
assetSubClass,
comment,
countriesCount,
currency,
dataSource,
id,
isActive,
lastMarketPrice,
marketDataItemCount,
name,
sectorsCount,
symbol,
activitiesCount: _count.activities,
date: activities?.[0]?.date,
isUsedByUsersWithSubscription: await isUsedByUsersWithSubscription,
watchedByCount: _count.watchedBy
};
}
)
const sectorsCount = sectors ? Object.keys(sectors).length : 0;
return {
assetClass,
assetSubClass,
comment,
countriesCount,
currency,
dataSource,
id,
isActive,
lastMarketPrice,
marketDataItemCount,
name,
sectorsCount,
symbol,
activitiesCount: _count.activities,
date: activities?.[0]?.date,
isUsedByUsersWithSubscription: await isUsedByUsersWithSubscription,
watchedByCount: _count.watchedBy
};
})
);
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 { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
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 { PropertyModule } from '@ghostfolio/api/services/property/property.module';
@ -23,6 +25,7 @@ import { OidcStrategy } from './oidc.strategy';
controllers: [AuthController],
imports: [
ConfigurationModule,
FetchModule,
JwtModule.register({
secret: process.env.JWT_SECRET_KEY,
signOptions: { expiresIn: '180 days' }
@ -40,12 +43,15 @@ import { OidcStrategy } from './oidc.strategy';
GoogleStrategy,
JwtStrategy,
{
inject: [AuthService, ConfigurationService],
inject: [AuthService, ConfigurationService, FetchService],
provide: OidcStrategy,
useFactory: async (
authService: AuthService,
configurationService: ConfigurationService
configurationService: ConfigurationService,
fetchService: FetchService
) => {
const logger = new Logger('OidcStrategy');
const isOidcEnabled = configurationService.get(
'ENABLE_FEATURE_AUTH_OIDC'
);
@ -81,7 +87,7 @@ import { OidcStrategy } from './oidc.strategy';
} else {
// Fetch OIDC configuration from discovery endpoint
try {
const response = await fetch(
const response = await fetchService.fetch(
`${issuer}/.well-known/openid-configuration`
);
@ -97,7 +103,7 @@ import { OidcStrategy } from './oidc.strategy';
tokenURL = manualTokenUrl || config.token_endpoint;
userInfoURL = manualUserInfoUrl || config.userinfo_endpoint;
} catch (error) {
Logger.error(error, 'OidcStrategy');
logger.error(error);
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()
export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
private readonly logger = new Logger(GoogleStrategy.name);
public constructor(
private readonly authService: AuthService,
configurationService: ConfigurationService
@ -40,7 +42,7 @@ export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
done(null, { jwt });
} catch (error) {
Logger.error(error, 'GoogleStrategy');
this.logger.error(error);
done(error, false);
}
}

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

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

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

@ -33,6 +33,8 @@ import ms from 'ms';
@Injectable()
export class WebAuthService {
private readonly logger = new Logger(WebAuthService.name);
public constructor(
private readonly configurationService: ConfigurationService,
private readonly deviceService: AuthDeviceService,
@ -103,7 +105,7 @@ export class WebAuthService {
verification = await verifyRegistrationResponse(opts);
} catch (error) {
Logger.error(error, 'WebAuthService');
this.logger.error(error);
throw new InternalServerErrorException(error.message);
}
@ -210,7 +212,7 @@ export class WebAuthService {
verification = await verifyAuthenticationResponse(opts);
} catch (error) {
Logger.error(error, 'WebAuthService');
this.logger.error(error);
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()
export class BenchmarksService {
private readonly logger = new Logger(BenchmarksService.name);
public constructor(
private readonly benchmarkService: BenchmarkService,
private readonly exchangeRateDataService: ExchangeRateDataService,
@ -96,12 +98,11 @@ export class BenchmarksService {
})?.marketPrice;
if (!marketPriceAtStartDate) {
Logger.error(
this.logger.error(
`No historical market data has been found for ${symbol} (${dataSource}) at ${format(
startDate,
DATE_FORMAT
)}`,
'BenchmarkService'
)}`
);
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 { 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 { FetchModule } from '@ghostfolio/api/services/fetch/fetch.module';
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
@ -27,6 +28,7 @@ import { GhostfolioService } from './ghostfolio.service';
imports: [
CryptocurrencyModule,
DataProviderModule,
FetchModule,
MarketDataModule,
PrismaModule,
PropertyModule,

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

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

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

@ -120,10 +120,10 @@ export class MarketDataController {
if (!canReadAllAssetProfiles && !canReadOwnAssetProfile) {
throw new HttpException(
assetProfile.userId
assetProfile?.userId
? getReasonPhrase(StatusCodes.NOT_FOUND)
: 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')
export class HealthController {
private readonly logger = new Logger(HealthController.name);
public constructor(
private readonly aiService: AiService,
private readonly healthService: HealthService
@ -61,7 +63,7 @@ export class HealthController {
.json({ status: getReasonPhrase(StatusCodes.OK) });
}
} catch (error) {
Logger.error(error, 'HealthController');
this.logger.error(error);
}
return response

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

@ -31,6 +31,8 @@ import { ImportService } from './import.service';
@Controller('import')
export class ImportController {
private readonly logger = new Logger(ImportController.name);
public constructor(
private readonly configurationService: ConfigurationService,
private readonly importService: ImportService,
@ -81,7 +83,7 @@ export class ImportController {
return { activities };
} catch (error) {
Logger.error(error, ImportController);
this.logger.error(error);
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 { 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 { Module } from '@nestjs/common';
@ -11,6 +12,7 @@ import { LogoService } from './logo.service';
controllers: [LogoController],
imports: [
ConfigurationModule,
FetchModule,
SymbolProfileModule,
TransformDataSourceInRequestModule
],

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

@ -1,4 +1,5 @@
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 { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
@ -10,6 +11,7 @@ import { StatusCodes, getReasonPhrase } from 'http-status-codes';
export class LogoService {
public constructor(
private readonly configurationService: ConfigurationService,
private readonly fetchService: FetchService,
private readonly symbolProfileService: SymbolProfileService
) {}
@ -43,15 +45,17 @@ export class LogoService {
}
private async getBuffer(aUrl: string) {
const blob = await fetch(
`https://t0.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${aUrl}&size=64`,
{
headers: { 'User-Agent': 'request' },
signal: AbortSignal.timeout(
this.configurationService.get('REQUEST_TIMEOUT')
)
}
).then((res) => res.blob());
const blob = await this.fetchService
.fetch(
`https://t0.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${aUrl}&size=64`,
{
headers: { 'User-Agent': 'request' },
signal: AbortSignal.timeout(
this.configurationService.get('REQUEST_TIMEOUT')
)
}
)
.then((res) => res.blob());
return {
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 {
protected static readonly ENABLE_LOGGING = false;
protected readonly logger = new Logger(PortfolioCalculator.name);
protected accountBalanceItems: HistoricalDataItem[];
protected activities: PortfolioOrder[];
@ -1119,12 +1121,11 @@ export abstract class PortfolioCalculator {
if (cachedPortfolioSnapshot) {
this.snapshot = cachedPortfolioSnapshot;
Logger.debug(
this.logger.debug(
`Fetched portfolio snapshot from cache in ${(
(performance.now() - startTimeTotal) /
1000
).toFixed(3)} seconds`,
'PortfolioCalculator'
).toFixed(3)} seconds`
);
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 { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Logger } from '@nestjs/common';
import { Big } from 'big.js';
import {
addMilliseconds,
@ -96,9 +95,8 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
currentPosition.timeWeightedInvestmentWithCurrencyEffect
);
} else if (!currentPosition.quantity.eq(0)) {
Logger.warn(
`Missing historical market data for ${currentPosition.symbol} (${currentPosition.dataSource})`,
'PortfolioCalculator'
this.logger.warn(
`Missing historical market data for ${currentPosition.symbol} (${currentPosition.dataSource})`
);
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 { UserService } from '@ghostfolio/api/app/user/user.service';
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import {
@ -70,7 +71,8 @@ export class PortfolioController {
private readonly configurationService: ConfigurationService,
private readonly impersonationService: ImpersonationService,
private readonly portfolioService: PortfolioService,
@Inject(REQUEST) private readonly request: RequestWithUser
@Inject(REQUEST) private readonly request: RequestWithUser,
private readonly userService: UserService
) {}
@Get('details')
@ -144,10 +146,10 @@ export class PortfolioController {
.reduce((a, b) => a + b, 0);
const totalValue = Object.values(holdings)
.filter(({ assetClass, assetSubClass }) => {
.filter(({ assetProfile }) => {
return (
assetClass !== AssetClass.LIQUIDITY &&
assetSubClass !== AssetSubClass.CASH
assetProfile.assetClass !== AssetClass.LIQUIDITY &&
assetProfile.assetSubClass !== AssetSubClass.CASH
);
})
.map(({ valueInBaseCurrency }) => {
@ -217,37 +219,41 @@ export class PortfolioController {
for (const [symbol, portfolioPosition] of Object.entries(holdings)) {
holdings[symbol] = {
...portfolioPosition,
assetClass:
hasDetails || portfolioPosition.assetClass === AssetClass.LIQUIDITY
? portfolioPosition.assetClass
: undefined,
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
? {}
: {
assetClass: undefined,
assetClassLabel: undefined,
assetSubClass: undefined,
assetSubClassLabel: undefined,
countries: [],
currency: undefined,
holdings: [],
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,
marketsAdvanced: hasDetails
? portfolioPosition.marketsAdvanced
: undefined,
sectors: hasDetails ? portfolioPosition.sectors : []
: undefined
};
}
@ -336,7 +342,10 @@ export class PortfolioController {
const impersonationUserId =
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 });
@ -345,7 +354,7 @@ export class PortfolioController {
filters,
startDate,
userCurrency,
userId: impersonationUserId || this.request.user.id,
userId,
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()
export class PortfolioService {
private readonly logger = new Logger(PortfolioService.name);
public constructor(
private readonly accountBalanceService: AccountBalanceService,
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({
where,
include: {
@ -178,10 +180,11 @@ export class PortfolioService {
withExcludedAccounts,
impersonationId: userId,
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(
accounts.map(async (account) => {
@ -584,7 +587,6 @@ export class PortfolioService {
for (const {
activitiesCount,
currency,
dataSource,
dateOfFirstActivity,
dividend,
@ -619,9 +621,8 @@ export class PortfolioService {
symbolProfileMap[getAssetProfileIdentifier({ dataSource, symbol })];
if (!assetProfile) {
Logger.warn(
`Asset profile not found for ${symbol} (${dataSource})`,
'PortfolioService'
this.logger.warn(
`Asset profile not found for ${symbol} (${dataSource})`
);
continue;
@ -638,16 +639,13 @@ export class PortfolioService {
holdings[symbol] = {
activitiesCount,
currency,
markets,
marketsAdvanced,
marketPrice,
symbol,
tags,
allocationInPercentage: filteredValueInBaseCurrency.eq(0)
? 0
: valueInBaseCurrency.div(filteredValueInBaseCurrency).toNumber(),
assetClass: assetProfile.assetClass,
assetProfile: {
assetClass: assetProfile.assetClass,
assetSubClass: assetProfile.assetSubClass,
@ -670,9 +668,6 @@ export class PortfolioService {
symbol: assetProfile.symbol,
url: assetProfile.url
},
assetSubClass: assetProfile.assetSubClass,
countries: assetProfile.countries,
dataSource: assetProfile.dataSource,
dateOfFirstActivity: parseDate(dateOfFirstActivity),
dividend: dividend?.toNumber() ?? 0,
grossPerformance: grossPerformance?.toNumber() ?? 0,
@ -681,19 +676,7 @@ export class PortfolioService {
grossPerformancePercentageWithCurrencyEffect?.toNumber() ?? 0,
grossPerformanceWithCurrencyEffect:
grossPerformanceWithCurrencyEffect?.toNumber() ?? 0,
holdings: assetProfile.holdings.map(
({ allocationInPercentage, name }) => {
return {
allocationInPercentage,
name,
valueInBaseCurrency: valueInBaseCurrency
.mul(allocationInPercentage)
.toNumber()
};
}
),
investment: investment.toNumber(),
name: assetProfile.name,
netPerformance: netPerformance?.toNumber() ?? 0,
netPerformancePercent: netPerformancePercentage?.toNumber() ?? 0,
netPerformancePercentWithCurrencyEffect:
@ -703,8 +686,6 @@ export class PortfolioService {
netPerformanceWithCurrencyEffect:
netPerformanceWithCurrencyEffectMap?.[dateRange]?.toNumber() ?? 0,
quantity: quantity.toNumber(),
sectors: assetProfile.sectors,
url: assetProfile.url,
valueInBaseCurrency: valueInBaseCurrency.toNumber()
};
}
@ -1472,8 +1453,8 @@ export class PortfolioService {
for (const [, position] of Object.entries(holdings)) {
const value = position.valueInBaseCurrency;
if (position.assetClass !== AssetClass.LIQUIDITY) {
if (position.countries.length > 0) {
if (position.assetProfile.assetClass !== AssetClass.LIQUIDITY) {
if (position.assetProfile.countries.length > 0) {
markets.developedMarkets.valueInBaseCurrency +=
position.markets.developedMarkets * value;
markets.emergingMarkets.valueInBaseCurrency +=
@ -1719,11 +1700,8 @@ export class PortfolioService {
currency: string;
}): PortfolioPosition {
return {
currency,
activitiesCount: 0,
allocationInPercentage: 0,
assetClass: AssetClass.LIQUIDITY,
assetSubClass: AssetSubClass.CASH,
assetProfile: {
currency,
assetClass: AssetClass.LIQUIDITY,
@ -1735,25 +1713,19 @@ export class PortfolioService {
sectors: [],
symbol: currency
},
countries: [],
dataSource: undefined,
dateOfFirstActivity: undefined,
dividend: 0,
grossPerformance: 0,
grossPerformancePercent: 0,
grossPerformancePercentWithCurrencyEffect: 0,
grossPerformanceWithCurrencyEffect: 0,
holdings: [],
investment: balance,
marketPrice: 0,
name: currency,
netPerformance: 0,
netPerformancePercent: 0,
netPerformancePercentWithCurrencyEffect: 0,
netPerformanceWithCurrencyEffect: 0,
quantity: 0,
sectors: [],
symbol: currency,
tags: [],
valueInBaseCurrency: balance
};

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

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

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

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

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

@ -24,6 +24,8 @@ import Stripe from 'stripe';
@Injectable()
export class SubscriptionService {
private readonly logger = new Logger(SubscriptionService.name);
private stripe: Stripe;
public constructor(
@ -166,9 +168,8 @@ export class SubscriptionService {
error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === 'P2002'
) {
Logger.log(
`Stripe Checkout Session '${session.id}' has already been redeemed`,
'SubscriptionService'
this.logger.log(
`Stripe Checkout Session '${session.id}' has already been redeemed`
);
} else {
throw error;
@ -177,7 +178,7 @@ export class SubscriptionService {
return session.client_reference_id;
} 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()
export class SymbolService {
private readonly logger = new Logger(SymbolService.name);
public constructor(
private readonly dataProviderService: DataProviderService,
private readonly marketDataService: MarketDataService
@ -119,7 +121,7 @@ export class SymbolService {
results.items = items;
return results;
} catch (error) {
Logger.error(error, 'SymbolService');
this.logger.error(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 { 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 { without } from 'lodash';
import { createHmac } from 'node:crypto';
@ -109,7 +109,14 @@ export class UserService {
}): Promise<IUser> {
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({
include: {
user: true
@ -134,16 +141,17 @@ export class UserService {
},
where: { userId: impersonationUserId || user.id }
}),
impersonationUserId
? this.prismaService.settings.findUnique({
where: { userId: impersonationUserId }
})
: Promise.resolve<Settings>(null),
this.tagService.getTagsForUser(impersonationUserId || user.id)
]);
const access = userData[0];
const accounts = userData[1];
const activitiesCount = userData[2];
const firstActivity = userData[3];
let tags = userData[4].filter((tag) => {
return tag.id !== TAG_ID_EXCLUDE_FROM_ANALYSIS;
});
const baseCurrency =
(impersonationUserSettings?.settings as UserSettings)?.baseCurrency ??
(settings.settings as UserSettings)?.baseCurrency;
let systemMessage: SystemMessage;
@ -156,6 +164,10 @@ export class UserService {
systemMessage = systemMessageProperty;
}
let tags = tagsForUser.filter((tag) => {
return tag.id !== TAG_ID_EXCLUDE_FROM_ANALYSIS;
});
if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
subscription.type === SubscriptionType.Basic
@ -183,6 +195,7 @@ export class UserService {
dateOfFirstActivity: firstActivity?.date ?? new Date(),
settings: {
...(settings.settings as UserSettings),
baseCurrency,
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()
export class AssetProfileChangedListener {
private readonly logger = new Logger(AssetProfileChangedListener.name);
private static readonly DEBOUNCE_DELAY = ms('5 seconds');
private debounceTimers = new Map<string, NodeJS.Timeout>();
@ -67,10 +69,7 @@ export class AssetProfileChangedListener {
dataSource: DataSource;
symbol: string;
}) {
Logger.log(
`Asset profile of ${symbol} (${dataSource}) has changed`,
'AssetProfileChangedListener'
);
this.logger.log(`Asset profile of ${symbol} (${dataSource}) has changed`);
if (
this.configurationService.get(
@ -84,10 +83,7 @@ export class AssetProfileChangedListener {
const existingCurrencies = this.exchangeRateDataService.getCurrencies();
if (!existingCurrencies.includes(currency)) {
Logger.log(
`New currency ${currency} has been detected`,
'AssetProfileChangedListener'
);
this.logger.log(`New currency ${currency} has been detected`);
await this.exchangeRateDataService.initialize();
}

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

@ -8,6 +8,8 @@ import { PortfolioChangedEvent } from './portfolio-changed.event';
@Injectable()
export class PortfolioChangedListener {
private readonly logger = new Logger(PortfolioChangedListener.name);
private static readonly DEBOUNCE_DELAY = ms('5 seconds');
private debounceTimers = new Map<string, NodeJS.Timeout>();
@ -35,10 +37,7 @@ export class PortfolioChangedListener {
}
private async processPortfolioChanged({ userId }: { userId: string }) {
Logger.log(
`Portfolio of user '${userId}' has changed`,
'PortfolioChangedListener'
);
this.logger.log(`Portfolio of user '${userId}' has changed`);
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()
export class PerformanceLoggingService {
private readonly logger = new Logger(PerformanceLoggingService.name);
public logPerformance({
className,
methodName,
@ -13,7 +15,7 @@ export class PerformanceLoggingService {
}) {
const endTime = performance.now();
Logger.debug(
this.logger.debug(
`Completed execution of ${methodName}() in ${((endTime - startTime) / 1000).toFixed(3)} seconds`,
className
);

22
apps/api/src/main.ts

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

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

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

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

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

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

@ -7,6 +7,7 @@ import {
GetQuotesParams,
GetSearchParams
} 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 { DATE_FORMAT } from '@ghostfolio/common/helper';
import {
@ -28,11 +29,14 @@ import { format, fromUnixTime, getUnixTime } from 'date-fns';
@Injectable()
export class CoinGeckoService implements DataProviderInterface, OnModuleInit {
private readonly logger = new Logger(CoinGeckoService.name);
private apiUrl: string;
private headers: HeadersInit = {};
public constructor(
private readonly configurationService: ConfigurationService
private readonly configurationService: ConfigurationService,
private readonly fetchService: FetchService
) {}
public onModuleInit() {
@ -67,12 +71,14 @@ export class CoinGeckoService implements DataProviderInterface, OnModuleInit {
};
try {
const { name } = await fetch(`${this.apiUrl}/coins/${symbol}`, {
headers: this.headers,
signal: AbortSignal.timeout(
this.configurationService.get('REQUEST_TIMEOUT')
)
}).then((res) => res.json());
const { name } = await this.fetchService
.fetch(`${this.apiUrl}/coins/${symbol}`, {
headers: this.headers,
signal: AbortSignal.timeout(
this.configurationService.get('REQUEST_TIMEOUT')
)
})
.then((res) => res.json());
response.name = name;
} catch (error) {
@ -84,7 +90,7 @@ export class CoinGeckoService implements DataProviderInterface, OnModuleInit {
).toFixed(3)} seconds`;
}
Logger.error(message, 'CoinGeckoService');
this.logger.error(message);
}
return response;
@ -118,13 +124,15 @@ export class CoinGeckoService implements DataProviderInterface, OnModuleInit {
vs_currency: DEFAULT_CURRENCY.toLowerCase()
});
const { error, prices, status } = await fetch(
`${this.apiUrl}/coins/${symbol}/market_chart/range?${queryParams.toString()}`,
{
headers: this.headers,
signal: AbortSignal.timeout(requestTimeout)
}
).then((res) => res.json());
const { error, prices, status } = await this.fetchService
.fetch(
`${this.apiUrl}/coins/${symbol}/market_chart/range?${queryParams.toString()}`,
{
headers: this.headers,
signal: AbortSignal.timeout(requestTimeout)
}
)
.then((res) => res.json());
if (error?.status) {
throw new Error(error.status.error_message);
@ -181,13 +189,12 @@ export class CoinGeckoService implements DataProviderInterface, OnModuleInit {
vs_currencies: DEFAULT_CURRENCY.toLowerCase()
});
const quotes = await fetch(
`${this.apiUrl}/simple/price?${queryParams.toString()}`,
{
const quotes = await this.fetchService
.fetch(`${this.apiUrl}/simple/price?${queryParams.toString()}`, {
headers: this.headers,
signal: AbortSignal.timeout(requestTimeout)
}
).then((res) => res.json());
})
.then((res) => res.json());
for (const symbol in quotes) {
response[symbol] = {
@ -209,7 +216,7 @@ export class CoinGeckoService implements DataProviderInterface, OnModuleInit {
).toFixed(3)} seconds`;
}
Logger.error(message, 'CoinGeckoService');
this.logger.error(message);
}
return response;
@ -230,13 +237,12 @@ export class CoinGeckoService implements DataProviderInterface, OnModuleInit {
query
});
const { coins } = await fetch(
`${this.apiUrl}/search?${queryParams.toString()}`,
{
const { coins } = await this.fetchService
.fetch(`${this.apiUrl}/search?${queryParams.toString()}`, {
headers: this.headers,
signal: AbortSignal.timeout(requestTimeout)
}
).then((res) => res.json());
})
.then((res) => res.json());
items = coins.map(({ id: symbol, name }) => {
return {
@ -258,7 +264,7 @@ export class CoinGeckoService implements DataProviderInterface, OnModuleInit {
).toFixed(3)} seconds`;
}
Logger.error(message, 'CoinGeckoService');
this.logger.error(message);
}
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 { 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 { FetchModule } from '@ghostfolio/api/services/fetch/fetch.module';
import { Module } from '@nestjs/common';
@ -16,7 +17,7 @@ import { DataEnhancerService } from './data-enhancer.service';
YahooFinanceDataEnhancerService,
'DataEnhancers'
],
imports: [ConfigurationModule, CryptocurrencyModule],
imports: [ConfigurationModule, CryptocurrencyModule, FetchModule],
providers: [
DataEnhancerService,
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 { 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 { Injectable } from '@nestjs/common';
@ -10,7 +11,8 @@ export class OpenFigiDataEnhancerService implements DataEnhancerInterface {
private static baseUrl = 'https://api.openfigi.com';
public constructor(
private readonly configurationService: ConfigurationService
private readonly configurationService: ConfigurationService,
private readonly fetchService: FetchService
) {}
public async enhance({
@ -42,9 +44,8 @@ export class OpenFigiDataEnhancerService implements DataEnhancerInterface {
this.configurationService.get('API_KEY_OPEN_FIGI');
}
const mappings = (await fetch(
`${OpenFigiDataEnhancerService.baseUrl}/v3/mapping`,
{
const mappings = (await this.fetchService
.fetch(`${OpenFigiDataEnhancerService.baseUrl}/v3/mapping`, {
body: JSON.stringify([
{ exchCode: exchange, idType: 'TICKER', idValue: ticker }
]),
@ -54,8 +55,8 @@ export class OpenFigiDataEnhancerService implements DataEnhancerInterface {
},
method: 'POST',
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) {
const { compositeFIGI, figi, shareClassFIGI } = mappings[0].data[0];

53
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 { 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 { Country } from '@ghostfolio/common/interfaces/country.interface';
import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
@ -10,9 +11,12 @@ import { countries } from 'countries-list';
@Injectable()
export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
private readonly logger = new Logger(TrackinsightDataEnhancerService.name);
private static baseUrl = 'https://www.trackinsight.com/data-api';
private static countriesMapping = {
'Russian Federation': 'Russia'
'Russian Federation': 'Russia',
USA: 'United States'
};
private static holdingsWeightTreshold = 0.85;
private static sectorsMapping = {
@ -23,7 +27,8 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
};
public constructor(
private readonly configurationService: ConfigurationService
private readonly configurationService: ConfigurationService,
private readonly fetchService: FetchService
) {}
public async enhance({
@ -60,12 +65,13 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
return response;
}
const profile = await fetch(
`${TrackinsightDataEnhancerService.baseUrl}/funds/${trackinsightSymbol}.json`,
{
signal: AbortSignal.timeout(requestTimeout)
}
)
const profile = await this.fetchService
.fetch(
`${TrackinsightDataEnhancerService.baseUrl}/funds/${trackinsightSymbol}.json`,
{
signal: AbortSignal.timeout(requestTimeout)
}
)
.then((res) => res.json())
.catch(() => {
return {};
@ -83,12 +89,13 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
response.isin = isin;
}
const holdings = await fetch(
`${TrackinsightDataEnhancerService.baseUrl}/holdings/${trackinsightSymbol}.json`,
{
signal: AbortSignal.timeout(requestTimeout)
}
)
const holdings = await this.fetchService
.fetch(
`${TrackinsightDataEnhancerService.baseUrl}/holdings/${trackinsightSymbol}.json`,
{
signal: AbortSignal.timeout(requestTimeout)
}
)
.then((res) => res.json())
.catch(() => {
return {};
@ -182,12 +189,13 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
requestTimeout: number;
symbol: string;
}) {
return fetch(
`https://www.trackinsight.com/search-api/search_v2/${symbol}/_/ticker/default/0/3`,
{
signal: AbortSignal.timeout(requestTimeout)
}
)
return this.fetchService
.fetch(
`https://www.trackinsight.com/search-api/search_v2/${symbol}/_/ticker/default/0/3`,
{
signal: AbortSignal.timeout(requestTimeout)
}
)
.then((res) => res.json())
.then((jsonRes) => {
if (
@ -203,9 +211,8 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
return undefined;
})
.catch(({ message }) => {
Logger.error(
`Failed to search Trackinsight symbol for ${symbol} (${message})`,
'TrackinsightDataEnhancerService'
this.logger.error(
`Failed to search Trackinsight symbol for ${symbol} (${message})`
);
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()
export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
private readonly logger = new Logger(YahooFinanceDataEnhancerService.name);
private readonly yahooFinance = new YahooFinance({
suppressNotices: ['yahooSurvey']
});
@ -123,7 +125,7 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
response.url = url;
}
} catch (error) {
Logger.error(error, 'YahooFinanceDataEnhancerService');
this.logger.error(error);
}
return response;
@ -266,7 +268,7 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
`No data found, ${aSymbol} (${this.getName()}) may be delisted`
);
} 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 { 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 { FetchModule } from '@ghostfolio/api/services/fetch/fetch.module';
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
@ -26,6 +27,7 @@ import { DataProviderService } from './data-provider.service';
ConfigurationModule,
CryptocurrencyModule,
DataEnhancerModule,
FetchModule,
MarketDataModule,
PrismaModule,
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()
export class DataProviderService implements OnModuleInit {
private readonly logger = new Logger(DataProviderService.name);
private dataProviderMapping: { [dataProviderName: string]: string };
public constructor(
@ -129,7 +131,7 @@ export class DataProviderService implements OnModuleInit {
);
}
} catch (error) {
Logger.error(error, 'DataProviderService');
this.logger.error(error);
throw error;
}
@ -391,7 +393,7 @@ export class DataProviderService implements OnModuleInit {
return r;
}, {});
} catch (error) {
Logger.error(error, 'DataProviderService');
this.logger.error(error);
} finally {
return response;
}
@ -503,7 +505,7 @@ export class DataProviderService implements OnModuleInit {
result[symbol] = data;
}
} catch (error) {
Logger.error(error, 'DataProviderService');
this.logger.error(error);
throw error;
}
@ -567,13 +569,12 @@ export class DataProviderService implements OnModuleInit {
const numberOfItemsInCache = Object.keys(response)?.length;
if (numberOfItemsInCache) {
Logger.debug(
this.logger.debug(
`Fetched ${numberOfItemsInCache} quote${
numberOfItemsInCache > 1 ? 's' : ''
} from cache in ${((performance.now() - startTimeTotal) / 1000).toFixed(
3
)} seconds`,
'DataProviderService'
)} seconds`
);
}
@ -684,14 +685,13 @@ export class DataProviderService implements OnModuleInit {
}
}
Logger.debug(
this.logger.debug(
`Fetched ${symbolsChunk.length} quote${
symbolsChunk.length > 1 ? 's' : ''
} from ${dataSource} in ${(
(performance.now() - startTimeDataSource) /
1000
).toFixed(3)} seconds`,
'DataProviderService'
).toFixed(3)} seconds`
);
try {
@ -722,15 +722,18 @@ export class DataProviderService implements OnModuleInit {
await Promise.all(promises);
Logger.debug('--------------------------------------------------------');
Logger.debug(
this.logger.debug(
'--------------------------------------------------------'
);
this.logger.debug(
`Fetched ${items.length} quote${items.length > 1 ? 's' : ''} in ${(
(performance.now() - startTimeTotal) /
1000
).toFixed(3)} seconds`,
'DataProviderService'
).toFixed(3)} seconds`
);
this.logger.debug(
'========================================================'
);
Logger.debug('========================================================');
return response;
}

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

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

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

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

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

@ -8,6 +8,7 @@ import {
GetQuotesParams,
GetSearchParams
} 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 {
HEADER_KEY_TOKEN,
@ -32,12 +33,15 @@ import { StatusCodes } from 'http-status-codes';
@Injectable()
export class GhostfolioService implements DataProviderInterface {
private readonly logger = new Logger(GhostfolioService.name);
private readonly URL = environment.production
? 'https://ghostfol.io/api'
: `${this.configurationService.get('ROOT_URL')}/api`;
public constructor(
private readonly configurationService: ConfigurationService,
private readonly fetchService: FetchService,
private readonly propertyService: PropertyService
) {}
@ -52,7 +56,7 @@ export class GhostfolioService implements DataProviderInterface {
let assetProfile: DataProviderGhostfolioAssetProfileResponse;
try {
const response = await fetch(
const response = await this.fetchService.fetch(
`${this.URL}/v1/data-providers/ghostfolio/asset-profile/${symbol}`,
{
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.';
}
Logger.error(message, 'GhostfolioService');
this.logger.error(message);
}
return assetProfile;
@ -122,7 +126,7 @@ export class GhostfolioService implements DataProviderInterface {
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()}`,
{
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.';
}
Logger.error(message, 'GhostfolioService');
this.logger.error(message);
}
return dividends;
@ -174,7 +178,7 @@ export class GhostfolioService implements DataProviderInterface {
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()}`,
{
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.';
}
Logger.error(error.message, 'GhostfolioService');
this.logger.error(error.message);
throw new Error(
`Could not get historical market data for ${symbol} (${this.getName()}) from ${format(
@ -245,7 +249,7 @@ export class GhostfolioService implements DataProviderInterface {
symbols: symbols.join(',')
});
const response = await fetch(
const response = await this.fetchService.fetch(
`${this.URL}/v2/data-providers/ghostfolio/quotes?${queryParams.toString()}`,
{
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.';
}
Logger.error(message, 'GhostfolioService');
this.logger.error(message);
}
return quotes;
@ -302,7 +306,7 @@ export class GhostfolioService implements DataProviderInterface {
query
});
const response = await fetch(
const response = await this.fetchService.fetch(
`${this.URL}/v2/data-providers/ghostfolio/lookup?${queryParams.toString()}`,
{
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.';
}
Logger.error(message, 'GhostfolioService');
this.logger.error(message);
}
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()
export class GoogleSheetsService implements DataProviderInterface {
private readonly logger = new Logger(GoogleSheetsService.name);
public constructor(
private readonly configurationService: ConfigurationService,
private readonly prismaService: PrismaService,
@ -144,7 +146,7 @@ export class GoogleSheetsService implements DataProviderInterface {
return response;
} catch (error) {
Logger.error(error, 'GoogleSheetsService');
this.logger.error(error);
}
return {};

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

@ -8,6 +8,7 @@ import {
GetQuotesParams,
GetSearchParams
} 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 { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import {
@ -30,8 +31,11 @@ import { addDays, format, isBefore } from 'date-fns';
@Injectable()
export class ManualService implements DataProviderInterface {
private readonly logger = new Logger(ManualService.name);
public constructor(
private readonly configurationService: ConfigurationService,
private readonly fetchService: FetchService,
private readonly prismaService: PrismaService,
private readonly symbolProfileService: SymbolProfileService
) {}
@ -179,9 +183,8 @@ export class ManualService implements DataProviderInterface {
});
return { marketPrice, symbol };
} catch (error) {
Logger.error(
`Could not get quote for ${symbol} (${this.getName()}): [${error.name}] ${error.message}`,
'ManualService'
this.logger.error(
`Could not get quote for ${symbol} (${this.getName()}): [${error.name}] ${error.message}`
);
return { symbol, marketPrice: undefined };
}
@ -214,7 +217,7 @@ export class ManualService implements DataProviderInterface {
return response;
} catch (error) {
Logger.error(error, 'ManualService');
this.logger.error(error);
}
return {};
@ -292,7 +295,7 @@ export class ManualService implements DataProviderInterface {
}): Promise<number> {
let locale = scraperConfiguration.locale;
const response = await fetch(scraperConfiguration.url, {
const response = await this.fetchService.fetch(scraperConfiguration.url, {
headers: scraperConfiguration.headers as HeadersInit,
signal: AbortSignal.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,
GetSearchParams
} from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
import { FetchService } from '@ghostfolio/api/services/fetch/fetch.service';
import {
ghostfolioFearAndGreedIndexSymbol,
ghostfolioFearAndGreedIndexSymbolStocks
@ -25,8 +26,11 @@ import { format } from 'date-fns';
@Injectable()
export class RapidApiService implements DataProviderInterface {
private readonly logger = new Logger(RapidApiService.name);
public constructor(
private readonly configurationService: ConfigurationService
private readonly configurationService: ConfigurationService,
private readonly fetchService: FetchService
) {}
public canHandle() {
@ -120,7 +124,7 @@ export class RapidApiService implements DataProviderInterface {
};
}
} catch (error) {
Logger.error(error, 'RapidApiService');
this.logger.error(error);
}
return {};
@ -142,9 +146,8 @@ export class RapidApiService implements DataProviderInterface {
oneYearAgo: { value: number; valueText: string };
}> {
try {
const { fgi } = await fetch(
`https://fear-and-greed-index.p.rapidapi.com/v1/fgi`,
{
const { fgi } = await this.fetchService
.fetch(`https://fear-and-greed-index.p.rapidapi.com/v1/fgi`, {
headers: {
useQueryString: 'true',
'x-rapidapi-host': 'fear-and-greed-index.p.rapidapi.com',
@ -153,8 +156,8 @@ export class RapidApiService implements DataProviderInterface {
signal: AbortSignal.timeout(
this.configurationService.get('REQUEST_TIMEOUT')
)
}
).then((res) => res.json());
})
.then((res) => res.json());
return fgi;
} catch (error) {
@ -166,7 +169,7 @@ export class RapidApiService implements DataProviderInterface {
).toFixed(3)} seconds`;
}
Logger.error(message, 'RapidApiService');
this.logger.error(message);
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()
export class YahooFinanceService implements DataProviderInterface {
private readonly logger = new Logger(YahooFinanceService.name);
private readonly yahooFinance = new YahooFinance({
suppressNotices: ['yahooSurvey']
});
@ -105,12 +107,11 @@ export class YahooFinanceService implements DataProviderInterface {
return response;
} catch (error) {
Logger.error(
this.logger.error(
`Could not get dividends for ${symbol} (${this.getName()}) from ${format(
from,
DATE_FORMAT
)} to ${format(to, DATE_FORMAT)}: [${error.name}] ${error.message}`,
'YahooFinanceService'
)} to ${format(to, DATE_FORMAT)}: [${error.name}] ${error.message}`
);
return {};
@ -198,12 +199,9 @@ export class YahooFinanceService implements DataProviderInterface {
try {
quotes = await this.yahooFinance.quote(yahooFinanceSymbols);
} catch (error) {
Logger.error(error, 'YahooFinanceService');
this.logger.error(error);
Logger.warn(
'Fallback to yahooFinance.quoteSummary()',
'YahooFinanceService'
);
this.logger.warn('Fallback to yahooFinance.quoteSummary()');
quotes = await this.getQuotesWithQuoteSummary(yahooFinanceSymbols);
}
@ -229,7 +227,7 @@ export class YahooFinanceService implements DataProviderInterface {
return response;
} catch (error) {
Logger.error(error, 'YahooFinanceService');
this.logger.error(error);
return {};
}
@ -334,7 +332,7 @@ export class YahooFinanceService implements DataProviderInterface {
});
}
} catch (error) {
Logger.error(error, 'YahooFinanceService');
this.logger.error(error);
}
return { items };
@ -365,10 +363,7 @@ export class YahooFinanceService implements DataProviderInterface {
.filter(
(result): result is PromiseFulfilledResult<QuoteSummaryResult> => {
if (result.status === 'rejected') {
Logger.error(
`Could not get quote summary: ${result.reason}`,
'YahooFinanceService'
);
this.logger.error(`Could not get quote summary: ${result.reason}`);
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()
export class ExchangeRateDataService {
private readonly logger = new Logger(ExchangeRateDataService.name);
private currencies: string[] = [];
private currencyPairs: DataGatheringItem[] = [];
private derivedCurrencyFactors: { [currencyPair: string]: number } = {};
@ -110,9 +112,8 @@ export class ExchangeRateDataService {
previousExchangeRate;
if (currency === DEFAULT_CURRENCY && isBefore(date, new Date())) {
Logger.error(
`No exchange rate has been found for ${currency}${targetCurrency} at ${dateString}`,
'ExchangeRateDataService'
this.logger.error(
`No exchange rate has been found for ${currency}${targetCurrency} at ${dateString}`
);
}
} else {
@ -253,9 +254,8 @@ export class ExchangeRateDataService {
}
// Fallback with error, if currencies are not available
Logger.error(
`No exchange rate has been found for ${aFromCurrency}${aToCurrency}`,
'ExchangeRateDataService'
this.logger.error(
`No exchange rate has been found for ${aFromCurrency}${aToCurrency}`
);
return aValue;
@ -341,12 +341,11 @@ export class ExchangeRateDataService {
return factor * aValue;
}
Logger.error(
this.logger.error(
`No exchange rate has been found for ${aFromCurrency}${aToCurrency} at ${format(
aDate,
DATE_FORMAT
)}`,
'ExchangeRateDataService'
)}`
);
return undefined;
@ -483,7 +482,7 @@ export class ExchangeRateDataService {
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()
export class I18nService implements OnModuleInit {
private readonly logger = new Logger(I18nService.name);
private localesPath = join(__dirname, 'assets', 'locales');
private translations: { [locale: string]: cheerio.CheerioAPI } = {};
@ -26,7 +28,7 @@ export class I18nService implements OnModuleInit {
const $ = this.translations[languageCode];
if (!$) {
Logger.warn(`Translation not found for locale '${languageCode}'`);
this.logger.warn(`Translation not found for locale '${languageCode}'`);
}
let translatedText = $(
@ -36,7 +38,7 @@ export class I18nService implements OnModuleInit {
).text();
if (!translatedText) {
Logger.warn(
this.logger.warn(
`Translation not found for id '${id}' in locale '${languageCode}'`
);
}
@ -60,7 +62,7 @@ export class I18nService implements OnModuleInit {
this.parseXml(xmlData);
}
} 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
implements OnModuleInit, OnModuleDestroy
{
private readonly logger = new Logger(PrismaService.name);
public constructor(configService: ConfigService) {
const adapter = new PrismaPg({
connectionString: configService.get<string>('DATABASE_URL')
@ -43,7 +45,7 @@ export class PrismaService
try {
await this.$connect();
} 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()
@Processor(DATA_GATHERING_QUEUE)
export class DataGatheringProcessor {
private readonly logger = new Logger(DataGatheringProcessor.name);
public constructor(
private readonly dataGatheringService: DataGatheringService,
private readonly dataProviderService: DataProviderService,
@ -51,16 +53,14 @@ export class DataGatheringProcessor {
const { dataSource, symbol } = job.data;
try {
Logger.log(
`Asset profile data gathering has been started for ${symbol} (${dataSource})`,
`DataGatheringProcessor (${GATHER_ASSET_PROFILE_PROCESS_JOB_NAME})`
this.logger.log(
`Asset profile data gathering has been started for ${symbol} (${dataSource})`
);
await this.dataGatheringService.gatherAssetProfiles([job.data]);
Logger.log(
`Asset profile data gathering has been completed for ${symbol} (${dataSource})`,
`DataGatheringProcessor (${GATHER_ASSET_PROFILE_PROCESS_JOB_NAME})`
this.logger.log(
`Asset profile data gathering has been completed for ${symbol} (${dataSource})`
);
} catch (error) {
if (error instanceof AssetProfileDelistedError) {
@ -74,18 +74,14 @@ export class DataGatheringProcessor {
}
);
Logger.log(
`Asset profile data gathering has been discarded for ${symbol} (${dataSource})`,
`DataGatheringProcessor (${GATHER_ASSET_PROFILE_PROCESS_JOB_NAME})`
this.logger.log(
`Asset profile data gathering has been discarded for ${symbol} (${dataSource})`
);
return job.discard();
}
Logger.error(
error,
`DataGatheringProcessor (${GATHER_ASSET_PROFILE_PROCESS_JOB_NAME})`
);
this.logger.error(error);
throw error;
}
@ -105,12 +101,11 @@ export class DataGatheringProcessor {
try {
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(
currentDate,
DATE_FORMAT
)}${force ? ' (forced update)' : ''}`,
`DataGatheringProcessor (${GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME})`
)}${force ? ' (forced update)' : ''}`
);
const historicalData = await this.dataProviderService.getHistoricalRaw({
@ -167,12 +162,11 @@ export class DataGatheringProcessor {
await this.marketDataService.updateMany({ data });
}
Logger.log(
this.logger.log(
`Historical market data gathering has been completed for ${symbol} (${dataSource}) at ${format(
currentDate,
DATE_FORMAT
)}`,
`DataGatheringProcessor (${GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME})`
)}`
);
} catch (error) {
if (error instanceof AssetProfileDelistedError) {
@ -186,18 +180,14 @@ export class DataGatheringProcessor {
}
);
Logger.log(
`Historical market data gathering has been discarded for ${symbol} (${dataSource})`,
`DataGatheringProcessor (${GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME})`
this.logger.log(
`Historical market data gathering has been discarded for ${symbol} (${dataSource})`
);
return job.discard();
}
Logger.error(
error,
`DataGatheringProcessor (${GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME})`
);
this.logger.error(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()
export class DataGatheringService {
private readonly logger = new Logger(DataGatheringService.name);
public constructor(
@Inject('DataEnhancers')
private readonly dataEnhancers: DataEnhancerInterface[],
@ -145,7 +147,7 @@ export class DataGatheringService {
});
}
} catch (error) {
Logger.error(error, 'DataGatheringService');
this.logger.error(error);
} finally {
return undefined;
}
@ -176,30 +178,42 @@ export class DataGatheringService {
);
for (const [symbol, assetProfile] of Object.entries(assetProfiles)) {
const symbolMapping = symbolProfiles.find((symbolProfile) => {
return symbolProfile.symbol === symbol;
})?.symbolMapping;
const symbolProfile = symbolProfiles.find(
({ symbol: symbolProfileSymbol }) => {
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) {
try {
assetProfiles[symbol] = await dataEnhancer.enhance({
response: assetProfile,
enhancedAssetProfile = await dataEnhancer.enhance({
response: enhancedAssetProfile,
symbol: symbolMapping?.[dataEnhancer.getName()] ?? symbol
});
} catch (error) {
Logger.error(
this.logger.error(
`Failed to enhance data for ${symbol} (${
assetProfile.dataSource
}) by ${dataEnhancer.getName()}`,
error,
'DataGatheringService'
error
);
}
}
const { assetClass, assetSubClass } = assetProfile;
const {
assetClass,
assetSubClass,
countries,
currency,
cusip,
@ -212,7 +226,7 @@ export class DataGatheringService {
name,
sectors,
url
} = assetProfile;
} = enhancedAssetProfile;
try {
await this.prismaService.symbolProfile.upsert({
@ -256,11 +270,7 @@ export class DataGatheringService {
}
});
} catch (error) {
Logger.error(
`${symbol}: ${error?.meta?.cause}`,
error,
'DataGatheringService'
);
this.logger.error(`${symbol}: ${error?.meta?.cause}`, error);
if (assetProfileIdentifiers.length === 1) {
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()
@Processor(PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE)
export class PortfolioSnapshotProcessor {
private readonly logger = new Logger(PortfolioSnapshotProcessor.name);
public constructor(
private readonly accountBalanceService: AccountBalanceService,
private readonly activitiesService: ActivitiesService,
@ -41,9 +43,8 @@ export class PortfolioSnapshotProcessor {
try {
const startTime = performance.now();
Logger.log(
`Portfolio snapshot calculation of user '${job.data.userId}' has been started`,
`PortfolioSnapshotProcessor (${PORTFOLIO_SNAPSHOT_PROCESS_JOB_NAME})`
this.logger.log(
`Portfolio snapshot calculation of user '${job.data.userId}' has been started`
);
const { activities } =
@ -72,12 +73,11 @@ export class PortfolioSnapshotProcessor {
const snapshot = await portfolioCalculator.computeSnapshot();
Logger.log(
this.logger.log(
`Portfolio snapshot calculation of user '${job.data.userId}' has been completed in ${(
(performance.now() - startTime) /
1000
).toFixed(3)} seconds`,
`PortfolioSnapshotProcessor (${PORTFOLIO_SNAPSHOT_PROCESS_JOB_NAME})`
).toFixed(3)} seconds`
);
const expiration = addMilliseconds(
@ -101,10 +101,7 @@ export class PortfolioSnapshotProcessor {
return snapshot;
} catch (error) {
Logger.error(
error,
`PortfolioSnapshotProcessor (${PORTFOLIO_SNAPSHOT_PROCESS_JOB_NAME})`
);
this.logger.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 { FetchModule } from '@ghostfolio/api/services/fetch/fetch.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { STATISTICS_GATHERING_QUEUE } from '@ghostfolio/common/config';
@ -29,6 +30,7 @@ import { StatisticsGatheringService } from './statistics-gathering.service';
name: STATISTICS_GATHERING_QUEUE
}),
ConfigurationModule,
FetchModule,
PropertyModule
],
providers: [StatisticsGatheringProcessor, StatisticsGatheringService]

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

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

@ -1,5 +1,6 @@
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
import { applyAssetProfileOverrides } from '@ghostfolio/common/helper';
import {
AssetProfileIdentifier,
EnhancedSymbolProfile,
@ -192,21 +193,28 @@ export class SymbolProfileService {
})[]
): EnhancedSymbolProfile[] {
return symbolProfiles.map((symbolProfile) => {
const symbolProfileWithOverrides = applyAssetProfileOverrides(
symbolProfile,
symbolProfile.SymbolProfileOverrides
);
const item = {
...symbolProfile,
...symbolProfileWithOverrides,
activitiesCount: 0,
countries: this.getCountries(
symbolProfile?.countries as unknown as Prisma.JsonArray
symbolProfileWithOverrides?.countries as unknown as Prisma.JsonArray
),
dateOfFirstActivity: undefined as Date,
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(
symbolProfile?.sectors as unknown as Prisma.JsonArray
symbolProfileWithOverrides?.sectors as unknown as Prisma.JsonArray
),
symbolMapping: this.getSymbolMapping(symbolProfile),
symbolMapping: this.getSymbolMapping(symbolProfileWithOverrides),
watchedByCount: 0
};
@ -217,45 +225,7 @@ export class SymbolProfileService {
item.dateOfFirstActivity = symbolProfile.activities?.[0]?.date;
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;
});

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

@ -16,6 +16,8 @@ import { TwitterApi, TwitterApiReadWrite } from 'twitter-api-v2';
@Injectable()
export class TwitterBotService implements OnModuleInit {
private readonly logger = new Logger(TwitterBotService.name);
private twitterClient: TwitterApiReadWrite;
public constructor(
@ -71,13 +73,12 @@ export class TwitterBotService implements OnModuleInit {
const { data: createdTweet } =
await this.twitterClient.v2.tweet(status);
Logger.log(
`Fear & Greed Index has been posted: https://x.com/ghostfolio_/status/${createdTweet.id}`,
'TwitterBotService'
this.logger.log(
`Fear & Greed Index has been posted: https://x.com/ghostfolio_/status/${createdTweet.id}`
);
}
} 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 { GfSymbolPipe } from '@ghostfolio/common/pipes';
import { GfActivitiesFilterComponent } from '@ghostfolio/ui/activities-filter';
import { GfFabComponent } from '@ghostfolio/ui/fab';
import { translate } from '@ghostfolio/ui/i18n';
import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator';
import { AdminService, DataService } from '@ghostfolio/ui/services';
@ -80,10 +81,10 @@ import { CreateAssetProfileDialogParams } from './create-asset-profile-dialog/in
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'has-fab' },
imports: [
CommonModule,
GfActivitiesFilterComponent,
GfFabComponent,
GfPremiumIndicatorComponent,
GfSymbolPipe,
GfValueComponent,

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

@ -332,15 +332,5 @@
</div>
</div>
<div class="fab-container">
<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>
<gf-fab [queryParams]="{ createAssetProfileDialog: true }" />
</div>

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

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

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

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

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

@ -8,6 +8,7 @@ import {
} from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { GfBenchmarkComponent } from '@ghostfolio/ui/benchmark';
import { GfFabComponent } from '@ghostfolio/ui/fab';
import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator';
import { DataService } from '@ghostfolio/ui/services';
@ -22,12 +23,8 @@ import {
OnInit
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog';
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 { 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,
imports: [
GfBenchmarkComponent,
GfFabComponent,
GfPremiumIndicatorComponent,
IonIcon,
MatButtonModule,
RouterModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
@ -108,8 +104,6 @@ export class GfHomeWatchlistComponent implements OnInit {
this.changeDetectorRef.markForCheck();
}
});
addIcons({ addOutline });
}
public ngOnInit() {

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

@ -22,15 +22,5 @@
</div>
</div>
@if (!hasImpersonationId && hasPermissionToCreateWatchlistItem) {
<div class="fab-container">
<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>
<gf-fab [queryParams]="{ createWatchlistItemDialog: true }" />
}

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

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

@ -69,16 +69,6 @@
(accessToUpdate)="onUpdateAccess($event)"
/>
@if (hasPermissionToCreateAccess) {
<div class="fab-container">
<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>
<gf-fab [queryParams]="{ createDialog: true }" />
}
</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 { DataService } from '@ghostfolio/ui/services';
import { Injectable } from '@angular/core';
import { inject, Injectable } from '@angular/core';
import {
ActivatedRouteSnapshot,
Router,
@ -14,12 +14,10 @@ import { catchError } from 'rxjs/operators';
@Injectable({ providedIn: 'root' })
export class AuthGuard {
public constructor(
private dataService: DataService,
private router: Router,
private settingsStorageService: SettingsStorageService,
private userService: UserService
) {}
private readonly dataService = inject(DataService);
private readonly router = inject(Router);
private readonly settingsStorageService = inject(SettingsStorageService);
private readonly userService = inject(UserService);
canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot) {
const utmSource = route.queryParams?.utm_source;

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

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

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

@ -12,7 +12,7 @@ import {
HttpInterceptor,
HttpRequest
} from '@angular/common/http';
import { Injectable } from '@angular/core';
import { inject, Injectable } from '@angular/core';
import {
MatSnackBar,
MatSnackBarRef,
@ -22,31 +22,28 @@ import { Router } from '@angular/router';
import { StatusCodes } from 'http-status-codes';
import ms from 'ms';
import { Observable, throwError } from 'rxjs';
import { catchError, tap } from 'rxjs/operators';
import { catchError } from 'rxjs/operators';
@Injectable()
export class HttpResponseInterceptor implements HttpInterceptor {
public info: InfoItem;
public snackBarRef: MatSnackBarRef<TextOnlySnackBar>;
private readonly info: InfoItem;
private snackBarRef: MatSnackBarRef<TextOnlySnackBar> | undefined;
public constructor(
private dataService: DataService,
private router: Router,
private snackBar: MatSnackBar,
private userService: UserService,
private webAuthnService: WebAuthnService
) {
private readonly dataService = inject(DataService);
private readonly router = inject(Router);
private readonly snackBar = inject(MatSnackBar);
private readonly userService = inject(UserService);
private readonly webAuthnService = inject(WebAuthnService);
public constructor() {
this.info = this.dataService.fetchInfo();
}
public intercept(
request: HttpRequest<any>,
public intercept<T>(
request: HttpRequest<T>,
next: HttpHandler
): Observable<HttpEvent<any>> {
): Observable<HttpEvent<T>> {
return next.handle(request).pipe(
tap((event: HttpEvent<any>) => {
return event;
}),
catchError((error: HttpErrorResponse) => {
if (error.status === StatusCodes.FORBIDDEN) {
if (!this.snackBarRef) {
@ -61,7 +58,7 @@ export class HttpResponseInterceptor implements HttpInterceptor {
}
);
} else if (
!error.url.includes(internalRoutes.auth.routerLink.join(''))
!error.url?.includes(internalRoutes.auth.routerLink.join(''))
) {
this.snackBarRef = this.snackBar.open(
$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.onAction().subscribe(() => {
this.snackBarRef?.onAction().subscribe(() => {
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.onAction().subscribe(() => {
this.snackBarRef?.onAction().subscribe(() => {
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.`
);
this.snackBarRef.afterDismissed().subscribe(() => {
this.snackBarRef?.afterDismissed().subscribe(() => {
this.snackBarRef = undefined;
});
}
} 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()) {
this.router.navigate(internalRoutes.webauthn.routerLink);
} else {

24
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({
host: {
'(dragenter)': 'onDragEnter($event)',
'(dragover)': 'onDragOver($event)',
'(drop)': 'onDrop($event)'
},
selector: '[gfFileDrop]'
})
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.stopPropagation();
}
@HostListener('dragover', ['$event']) onDragOver(event: DragEvent) {
public onDragOver(event: DragEvent) {
event.preventDefault();
event.stopPropagation();
}
@HostListener('drop', ['$event']) onDrop(event: DragEvent) {
public onDrop(event: DragEvent) {
event.preventDefault();
event.stopPropagation();
// Prevent the browser's default behavior for handling the file drop
event.dataTransfer.dropEffect = 'copy';
this.filesDropped.emit(event.dataTransfer.files);
if (event.dataTransfer) {
// Prevent the browser's default behavior for handling the file drop
event.dataTransfer.dropEffect = 'copy';
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 { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { GfAccountsTableComponent } from '@ghostfolio/ui/accounts-table';
import { GfFabComponent } from '@ghostfolio/ui/fab';
import { NotificationService } from '@ghostfolio/ui/notifications';
import { DataService } from '@ghostfolio/ui/services';
@ -20,12 +21,9 @@ import {
OnInit
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { Account as AccountModel } from '@prisma/client';
import { addIcons } from 'ionicons';
import { addOutline } from 'ionicons/icons';
import { DeviceDetectorService } from 'ngx-device-detector';
import { EMPTY, Subscription } from 'rxjs';
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';
@Component({
host: { class: 'has-fab page' },
imports: [GfAccountsTableComponent, MatButtonModule, RouterModule],
host: { class: 'page' },
imports: [GfAccountsTableComponent, GfFabComponent, RouterModule],
selector: 'gf-accounts-page',
styleUrls: ['./accounts-page.scss'],
templateUrl: './accounts-page.html'
@ -90,8 +88,6 @@ export class GfAccountsPageComponent implements OnInit {
this.openTransferBalanceDialog();
}
});
addIcons({ addOutline });
}
public ngOnInit() {

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

@ -26,16 +26,6 @@
hasPermissionToCreateAccount &&
!user.settings.isRestrictedView
) {
<div class="fab-container">
<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>
<gf-fab [queryParams]="{ createDialog: true }" />
}
</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 { DateRange } from '@ghostfolio/common/types';
import { GfActivitiesTableComponent } from '@ghostfolio/ui/activities-table';
import { GfFabComponent } from '@ghostfolio/ui/fab';
import { DataService } from '@ghostfolio/ui/services';
import {
@ -21,17 +22,13 @@ import {
OnInit
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog';
import { PageEvent } from '@angular/material/paginator';
import { MatSnackBarModule } from '@angular/material/snack-bar';
import { Sort, SortDirection } from '@angular/material/sort';
import { MatTableDataSource } from '@angular/material/table';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { IonIcon } from '@ionic/angular/standalone';
import { format, parseISO } from 'date-fns';
import { addIcons } from 'ionicons';
import { addOutline } from 'ionicons/icons';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Subscription } from 'rxjs';
@ -41,11 +38,9 @@ import { GfImportActivitiesDialogComponent } from './import-activities-dialog/im
import { ImportActivitiesDialogParams } from './import-activities-dialog/interfaces/interfaces';
@Component({
host: { class: 'has-fab' },
imports: [
GfActivitiesTableComponent,
IonIcon,
MatButtonModule,
GfFabComponent,
MatSnackBarModule,
RouterModule
],
@ -107,8 +102,6 @@ export class GfActivitiesPageComponent implements OnInit {
}
}
});
addIcons({ addOutline });
}
public ngOnInit() {

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

@ -43,16 +43,6 @@
hasPermissionToCreateActivity &&
!user.settings.isRestrictedView
) {
<div class="fab-container">
<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>
<gf-fab [queryParams]="{ createDialog: true }" />
}
</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);
})
.sort((a, b) => {
return a.name?.localeCompare(b.name);
return a.assetProfile.name?.localeCompare(b.assetProfile.name);
})
.map(({ assetProfile }) => {
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();
const { dataSource, symbol } =
this.assetProfileForm.controls.assetProfileIdentifier.value ?? {};
this.assetProfileForm.controls.assetProfileIdentifier.value
?.assetProfile ?? {};
if (!dataSource || !symbol) {
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 holdings: {
[symbol: string]: Pick<
PortfolioPosition,
PortfolioPosition['assetProfile'],
| 'assetClass'
| 'assetClassLabel'
| 'assetSubClass'
| 'assetSubClassLabel'
| 'currency'
| 'exchange'
| 'name'
> & { etfProvider: string; value: number };
> & { etfProvider: string; exchange?: string; value: number };
};
public isLoading = false;
public markets: {
@ -206,7 +205,7 @@ export class GfAllocationsPageComponent implements OnInit {
assetSubClass,
name
}: {
assetSubClass: PortfolioPosition['assetSubClass'];
assetSubClass: PortfolioPosition['assetProfile']['assetSubClass'];
name: string;
}) {
if (assetSubClass === 'ETF') {
@ -333,24 +332,27 @@ export class GfAllocationsPageComponent implements OnInit {
this.holdings[symbol] = {
value,
assetClass: position.assetClass || (UNKNOWN_KEY as AssetClass),
assetClassLabel: position.assetClassLabel || UNKNOWN_KEY,
assetSubClass: position.assetSubClass || (UNKNOWN_KEY as AssetSubClass),
assetSubClassLabel: position.assetSubClassLabel || UNKNOWN_KEY,
currency: position.currency,
assetClass:
position.assetProfile.assetClass || (UNKNOWN_KEY as AssetClass),
assetClassLabel: position.assetProfile.assetClassLabel || UNKNOWN_KEY,
assetSubClass:
position.assetProfile.assetSubClass || (UNKNOWN_KEY as AssetSubClass),
assetSubClassLabel:
position.assetProfile.assetSubClassLabel || UNKNOWN_KEY,
currency: position.assetProfile.currency,
etfProvider: this.extractEtfProvider({
assetSubClass: position.assetSubClass,
name: position.name
assetSubClass: position.assetProfile.assetSubClass,
name: position.assetProfile.name
}),
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
if (position.countries.length > 0) {
for (const country of position.countries) {
if (position.assetProfile.countries.length > 0) {
for (const country of position.assetProfile.countries) {
const { code, continent, name, weight } = country;
if (this.continents[continent]?.value) {
@ -401,12 +403,12 @@ export class GfAllocationsPageComponent implements OnInit {
: this.portfolioDetails.holdings[symbol].valueInPercentage;
}
if (position.holdings.length > 0) {
if (position.assetProfile.holdings.length > 0) {
for (const {
allocationInPercentage,
name,
valueInBaseCurrency
} of position.holdings) {
} of position.assetProfile.holdings) {
const normalizedAssetName = this.normalizeAssetName(name);
if (this.topHoldingsMap[normalizedAssetName]?.value) {
@ -428,8 +430,8 @@ export class GfAllocationsPageComponent implements OnInit {
}
}
if (position.sectors.length > 0) {
for (const sector of position.sectors) {
if (position.assetProfile.sectors.length > 0) {
for (const sector of position.assetProfile.sectors) {
const { name, weight } = sector;
if (this.sectors[name]?.value) {
@ -463,8 +465,8 @@ export class GfAllocationsPageComponent implements OnInit {
}
this.symbols[prettifySymbol(symbol)] = {
dataSource: position.dataSource,
name: position.name,
dataSource: position.assetProfile.dataSource,
name: position.assetProfile.name,
symbol: prettifySymbol(symbol),
value: isNumber(position.valueInBaseCurrency)
? position.valueInBaseCurrency
@ -517,8 +519,8 @@ export class GfAllocationsPageComponent implements OnInit {
this.totalValueInEtf > 0 ? value / this.totalValueInEtf : 0,
parents: Object.entries(this.portfolioDetails.holdings)
.map(([symbol, holding]) => {
if (holding.holdings.length > 0) {
const currentParentHolding = holding.holdings.find(
if (holding.assetProfile.holdings.length > 0) {
const currentParentHolding = holding.assetProfile.holdings.find(
(parentHolding) => {
return (
this.normalizeAssetName(parentHolding.name) ===
@ -531,7 +533,7 @@ export class GfAllocationsPageComponent implements OnInit {
? {
allocationInPercentage:
currentParentHolding.valueInBaseCurrency / value,
name: holding.name,
name: holding.assetProfile.name,
position: holding,
symbol: prettifySymbol(symbol),
valueInBaseCurrency:

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

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

@ -310,13 +310,15 @@
<a
class="d-flex"
[queryParams]="{
dataSource: holding.dataSource,
dataSource: holding.assetProfile.dataSource,
holdingDetailDialog: true,
symbol: holding.symbol
symbol: holding.assetProfile.symbol
}"
[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">
<gf-value
class="justify-content-end"
@ -359,13 +361,15 @@
<a
class="d-flex"
[queryParams]="{
dataSource: holding.dataSource,
dataSource: holding.assetProfile.dataSource,
holdingDetailDialog: true,
symbol: holding.symbol
symbol: holding.assetProfile.symbol
}"
[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">
<gf-value
class="justify-content-end"
@ -438,7 +442,7 @@
</div>
<gf-toggle
class="d-none d-lg-block"
[defaultValue]="mode"
[defaultValue]="mode()"
[isLoading]="false"
[options]="modeOptions"
(valueChange)="onChangeGroupBy($event.value)"
@ -472,7 +476,7 @@
[benchmarkDataItems]="investmentsByGroup"
[benchmarkDataLabel]="investmentTimelineDataLabel"
[currency]="user?.settings?.baseCurrency"
[groupBy]="mode"
[groupBy]="mode()"
[isInPercentage]="
hasImpersonationId || user.settings.isRestrictedView
"
@ -497,7 +501,7 @@
</div>
<gf-toggle
class="d-none d-lg-block"
[defaultValue]="mode"
[defaultValue]="mode()"
[isLoading]="false"
[options]="modeOptions"
(valueChange)="onChangeGroupBy($event.value)"
@ -509,7 +513,7 @@
[benchmarkDataItems]="dividendsByGroup"
[benchmarkDataLabel]="dividendTimelineDataLabel"
[currency]="user?.settings?.baseCurrency"
[groupBy]="mode"
[groupBy]="mode()"
[isInPercentage]="
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 positions: {
[symbol: string]: Pick<PortfolioPosition, 'currency' | 'name'> & {
[symbol: string]: Pick<
PortfolioPosition['assetProfile'],
'currency' | 'name'
> & {
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": [
{
"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.",
"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",
"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 id="9153520284278555926" datatype="html">
<source>please</source>
<target state="new">please</target>
<target state="translated">будь ласка</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/pricing/pricing-page.html</context>
<context context-type="linenumber">333</context>
@ -360,7 +360,7 @@
</trans-unit>
<trans-unit id="1351814922314683865" datatype="html">
<source>with</source>
<target state="new">with</target>
<target state="translated">з</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/components/subscription-interstitial-dialog/subscription-interstitial-dialog.html</context>
<context context-type="linenumber">87</context>
@ -1388,7 +1388,7 @@
</trans-unit>
<trans-unit id="7702646444963497962" datatype="html">
<source>By</source>
<target state="new">By</target>
<target state="translated">До</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/portfolio/fire/fire-page.html</context>
<context context-type="linenumber">139</context>
@ -1892,7 +1892,7 @@
</trans-unit>
<trans-unit id="5303806780432428245" datatype="html">
<source>Indonesia</source>
<target state="new">Indonesia</target>
<target state="translated">Індонезія</target>
<context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/i18n.ts</context>
<context context-type="linenumber">90</context>
@ -2108,7 +2108,7 @@
</trans-unit>
<trans-unit id="8186013988289067040" datatype="html">
<source>Code</source>
<target state="new">Code</target>
<target state="translated">Код</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/components/admin-overview/admin-overview.html</context>
<context context-type="linenumber">159</context>
@ -2636,7 +2636,7 @@
</trans-unit>
<trans-unit id="2575998129003872734" datatype="html">
<source>Argentina</source>
<target state="new">Argentina</target>
<target state="translated">Аргентина</target>
<context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/i18n.ts</context>
<context context-type="linenumber">78</context>
@ -3204,7 +3204,7 @@
</trans-unit>
<trans-unit id="8553460997100418147" datatype="html">
<source>for</source>
<target state="new">for</target>
<target state="translated">для</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/components/subscription-interstitial-dialog/subscription-interstitial-dialog.html</context>
<context context-type="linenumber">128</context>
@ -3560,7 +3560,7 @@
</trans-unit>
<trans-unit id="7410432243549869948" datatype="html">
<source>Duration</source>
<target state="new">Duration</target>
<target state="translated">Тривалість</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/components/admin-overview/admin-overview.html</context>
<context context-type="linenumber">172</context>
@ -4897,7 +4897,7 @@
</trans-unit>
<trans-unit id="858192247408211331" datatype="html">
<source>here</source>
<target state="new">here</target>
<target state="translated">тут</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/pricing/pricing-page.html</context>
<context context-type="linenumber">347</context>
@ -5113,7 +5113,7 @@
</trans-unit>
<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>
<target state="new">Наша офіційна хмарна пропозиція Ghostfolio Premium - це найпростіший спосіб почати роботу. Завдяки економії часу, це буде найкращим варіантом для більшості людей. Доходи використовуються для покриття витрат на хостинг-інфраструктуру та фінансування постійної розробки.</target>
<target state="translated">Наша офіційна хмарна пропозиція Ghostfolio Premium - це найпростіший спосіб почати роботу. Завдяки економії часу, це буде найкращим варіантом для більшості людей. Доходи використовуються для покриття витрат на хостинг-інфраструктуру та фінансування постійної розробки.</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/pricing/pricing-page.html</context>
<context context-type="linenumber">7</context>
@ -6392,7 +6392,7 @@
</trans-unit>
<trans-unit id="5515771028435710194" datatype="html">
<source>Loan</source>
<target state="new">Loan</target>
<target state="translated">Позика</target>
<context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/i18n.ts</context>
<context context-type="linenumber">58</context>
@ -6944,7 +6944,7 @@
</trans-unit>
<trans-unit id="4145496584631696119" datatype="html">
<source>Role</source>
<target state="new">Role</target>
<target state="translated">Роль</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/components/user-detail-dialog/user-detail-dialog.html</context>
<context context-type="linenumber">39</context>
@ -7080,7 +7080,7 @@
</trans-unit>
<trans-unit id="8966698274727122602" datatype="html">
<source>Authentication</source>
<target state="new">Authentication</target>
<target state="translated">Автентифікація</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/components/user-detail-dialog/user-detail-dialog.html</context>
<context context-type="linenumber">60</context>
@ -7476,7 +7476,7 @@
</trans-unit>
<trans-unit id="8540986733881734625" datatype="html">
<source>Lazy</source>
<target state="new">Lazy</target>
<target state="translated">Лінивий</target>
<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="linenumber">235</context>
@ -7484,7 +7484,7 @@
</trans-unit>
<trans-unit id="6882618704933649036" datatype="html">
<source>Instant</source>
<target state="new">Instant</target>
<target state="translated">Миттєвий</target>
<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="linenumber">239</context>
@ -7500,7 +7500,7 @@
</trans-unit>
<trans-unit id="1713271461473302108" datatype="html">
<source>Mode</source>
<target state="new">Mode</target>
<target state="translated">Режим</target>
<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="linenumber">519</context>
@ -7508,7 +7508,7 @@
</trans-unit>
<trans-unit id="3540108566782816830" datatype="html">
<source>Selector</source>
<target state="new">Selector</target>
<target state="translated">Селектор</target>
<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="linenumber">535</context>
@ -7532,7 +7532,7 @@
</trans-unit>
<trans-unit id="4547068148181074902" datatype="html">
<source>real-time</source>
<target state="new">real-time</target>
<target state="translated">реальний час</target>
<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="linenumber">239</context>
@ -7548,7 +7548,7 @@
</trans-unit>
<trans-unit id="5674286808255988565" datatype="html">
<source>Create</source>
<target state="new">Create</target>
<target state="translated">Створити</target>
<context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/tags-selector/tags-selector.component.html</context>
<context context-type="linenumber">50</context>
@ -7556,7 +7556,7 @@
</trans-unit>
<trans-unit id="1230154438678955604" datatype="html">
<source>Change</source>
<target state="new">Change</target>
<target state="translated">Змінити</target>
<context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/holdings-table/holdings-table.component.html</context>
<context context-type="linenumber">138</context>
@ -7568,7 +7568,7 @@
</trans-unit>
<trans-unit id="1322586333669103999" datatype="html">
<source>Performance</source>
<target state="new">Performance</target>
<target state="translated">Дохідність</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.html</context>
<context context-type="linenumber">6</context>
@ -7624,7 +7624,7 @@
</trans-unit>
<trans-unit id="5004849258025239958" datatype="html">
<source>Armenia</source>
<target state="new">Armenia</target>
<target state="translated">Вірменія</target>
<context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/i18n.ts</context>
<context context-type="linenumber">77</context>
@ -7640,7 +7640,7 @@
</trans-unit>
<trans-unit id="4830118002486243553" datatype="html">
<source>Singapore</source>
<target state="new">Singapore</target>
<target state="translated">Сінгапур</target>
<context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/i18n.ts</context>
<context context-type="linenumber">97</context>
@ -7672,7 +7672,7 @@
</trans-unit>
<trans-unit id="6962699013778688473" datatype="html">
<source>Continue</source>
<target state="new">Continue</target>
<target state="translated">Продовжити</target>
<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="linenumber">57</context>
@ -7732,7 +7732,7 @@
</trans-unit>
<trans-unit id="routes.about.termsOfService" datatype="html">
<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>
<context-group purpose="location">
<context context-type="sourcefile">libs/common/src/lib/routes/routes.ts</context>
@ -7781,7 +7781,7 @@
</trans-unit>
<trans-unit id="4391289919356861627" datatype="html">
<source>Apply</source>
<target state="new">Apply</target>
<target state="translated">Застосувати</target>
<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="linenumber">154</context>
@ -7837,7 +7837,7 @@
</trans-unit>
<trans-unit id="322229249598827754" datatype="html">
<source>someone</source>
<target state="new">someone</target>
<target state="translated">когось</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/public/public-page.component.ts</context>
<context context-type="linenumber">62</context>
@ -7853,7 +7853,7 @@
</trans-unit>
<trans-unit id="4558213855845176930" datatype="html">
<source>Watchlist</source>
<target state="new">Watchlist</target>
<target state="translated">Список спостереження</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/components/home-watchlist/home-watchlist.html</context>
<context context-type="linenumber">4</context>
@ -7897,7 +7897,7 @@
</trans-unit>
<trans-unit id="routes.about.changelog" datatype="html">
<source>changelog</source>
<target state="new">changelog</target>
<target state="translated">zhurnal-zmin</target>
<note priority="1" from="description">kebab-case</note>
<context-group purpose="location">
<context context-type="sourcefile">libs/common/src/lib/routes/routes.ts</context>
@ -8030,7 +8030,7 @@
</trans-unit>
<trans-unit id="routes.resources.personalFinanceTools" datatype="html">
<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>
<context-group purpose="location">
<context context-type="sourcefile">libs/common/src/lib/routes/routes.ts</context>
@ -8047,7 +8047,7 @@
</trans-unit>
<trans-unit id="routes.resources.markets" datatype="html">
<source>markets</source>
<target state="new">markets</target>
<target state="translated">rynky</target>
<note priority="1" from="description">kebab-case</note>
<context-group purpose="location">
<context context-type="sourcefile">libs/common/src/lib/routes/routes.ts</context>
@ -8108,7 +8108,7 @@
</trans-unit>
<trans-unit id="3955868613858648955" datatype="html">
<source>Available</source>
<target state="new">Available</target>
<target state="translated">Доступно</target>
<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="linenumber">3</context>
@ -8116,7 +8116,7 @@
</trans-unit>
<trans-unit id="5643561794785412000" datatype="html">
<source>Unavailable</source>
<target state="new">Unavailable</target>
<target state="translated">Недоступно</target>
<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="linenumber">5</context>
@ -8132,7 +8132,7 @@
</trans-unit>
<trans-unit id="7387635272539030076" datatype="html">
<source>new</source>
<target state="new">new</target>
<target state="translated">новий</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/components/admin-settings/admin-settings.component.html</context>
<context context-type="linenumber">79</context>
@ -8140,7 +8140,7 @@
</trans-unit>
<trans-unit id="rule.accountClusterRiskCurrentInvestment" datatype="html">
<source>Investment</source>
<target state="new">Investment</target>
<target state="translated">Інвестиція</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/i18n/i18n-page.html</context>
<context context-type="linenumber">15</context>
@ -8164,7 +8164,7 @@
</trans-unit>
<trans-unit id="rule.assetClassClusterRiskEquity" datatype="html">
<source>Equity</source>
<target state="new">Equity</target>
<target state="translated">Акції</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/i18n/i18n-page.html</context>
<context context-type="linenumber">41</context>
@ -8252,7 +8252,7 @@
</trans-unit>
<trans-unit id="rule.currencyClusterRiskCurrentInvestment" datatype="html">
<source>Investment</source>
<target state="new">Investment</target>
<target state="translated">Інвестиція</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/i18n/i18n-page.html</context>
<context context-type="linenumber">95</context>
@ -8276,7 +8276,7 @@
</trans-unit>
<trans-unit id="routes.start" datatype="html">
<source>start</source>
<target state="new">start</target>
<target state="translated">pochatok</target>
<note priority="1" from="description">kebab-case</note>
<context-group purpose="location">
<context context-type="sourcefile">libs/common/src/lib/routes/routes.ts</context>
@ -8297,7 +8297,7 @@
</trans-unit>
<trans-unit id="5193539160604294602" datatype="html">
<source>Generate</source>
<target state="new">Generate</target>
<target state="translated">Згенерувати</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/components/user-account-access/user-account-access.html</context>
<context context-type="linenumber">45</context>
@ -8313,7 +8313,7 @@
</trans-unit>
<trans-unit id="2732382861635368484" datatype="html">
<source>Stocks</source>
<target state="new">Stocks</target>
<target state="translated">Акції</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/components/markets/markets.component.ts</context>
<context context-type="linenumber">51</context>
@ -8325,7 +8325,7 @@
</trans-unit>
<trans-unit id="1419479195323304896" datatype="html">
<source>Cryptocurrencies</source>
<target state="new">Cryptocurrencies</target>
<target state="translated">Криптовалюти</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/components/markets/markets.component.ts</context>
<context context-type="linenumber">52</context>
@ -8361,7 +8361,7 @@
</trans-unit>
<trans-unit id="5795124554973640871" datatype="html">
<source>Collectible</source>
<target state="new">Collectible</target>
<target state="translated">Колекційний предмет</target>
<context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/i18n.ts</context>
<context context-type="linenumber">55</context>
@ -8421,7 +8421,7 @@
</trans-unit>
<trans-unit id="rule.fees.category" datatype="html">
<source>Fees</source>
<target state="new">Fees</target>
<target state="translated">Комісії</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/i18n/i18n-page.html</context>
<context context-type="linenumber">161</context>
@ -8429,7 +8429,7 @@
</trans-unit>
<trans-unit id="rule.liquidity.category" datatype="html">
<source>Liquidity</source>
<target state="new">Liquidity</target>
<target state="translated">Ліквідність</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/i18n/i18n-page.html</context>
<context context-type="linenumber">70</context>
@ -8565,7 +8565,7 @@
</trans-unit>
<trans-unit id="rule.regionalMarketClusterRiskAsiaPacific" datatype="html">
<source>Asia-Pacific</source>
<target state="new">Asia-Pacific</target>
<target state="translated">Азіатсько-Тихоокеанський регіон</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/i18n/i18n-page.html</context>
<context context-type="linenumber">165</context>
@ -8629,7 +8629,7 @@
</trans-unit>
<trans-unit id="rule.regionalMarketClusterRiskEurope" datatype="html">
<source>Europe</source>
<target state="new">Europe</target>
<target state="translated">Європа</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/i18n/i18n-page.html</context>
<context context-type="linenumber">195</context>
@ -8661,7 +8661,7 @@
</trans-unit>
<trans-unit id="rule.regionalMarketClusterRiskJapan" datatype="html">
<source>Japan</source>
<target state="new">Japan</target>
<target state="translated">Японія</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/i18n/i18n-page.html</context>
<context context-type="linenumber">209</context>

36
apps/client/src/styles.scss

@ -1,10 +1,11 @@
@use '@angular/material' as mat;
@use 'sass:color';
@import './styles/bootstrap';
@import './styles/table';
@import './styles/variables';
@use './styles/bootstrap';
@use './styles/table' as table;
@use './styles/variables' as variables;
@import 'svgmap/style.min';
@use 'svgmap/style.min';
:root {
--dark-background: rgb(25, 25, 25);
@ -12,8 +13,10 @@
--light-background: rgb(255, 255, 255);
--dark-primary-text:
#{red($dark-primary-text)}, #{green($dark-primary-text)},
#{blue($dark-primary-text)}, #{alpha($dark-primary-text)};
#{color.channel(variables.$dark-primary-text, 'red')},
#{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-accent-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-focused: 0, 0, 0, 0.12;
--light-primary-text:
#{red($light-primary-text)}, #{green($light-primary-text)},
#{blue($light-primary-text)}, #{alpha($light-primary-text)};
#{color.channel(variables.$light-primary-text, 'red')},
#{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-accent-text: 255, 255, 255, 1;
--light-warn-text: 255, 255, 255, 1;
@ -240,7 +245,7 @@ body {
}
.gf-table {
@include gf-table(true);
@include table.gf-table(true);
}
.mat-mdc-dialog-container {
@ -353,17 +358,13 @@ ngx-skeleton-loader {
}
.gf-table {
@include gf-table;
@include table.gf-table;
}
.gf-text-wrap-balance {
text-wrap: balance;
}
.has-fab {
padding-bottom: 3rem !important;
}
.has-info-message {
// Restrict viewport height of tabbed views when the Live Demo or system announcements banner are displayed
.page:has(gf-page-tabs) {
@ -484,13 +485,6 @@ ngx-skeleton-loader {
padding-bottom: env(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
&:has(gf-page-tabs) {
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_SYSTEM_MESSAGE = 'SYSTEM_MESSAGE';
export const PROPERTY_UPTIME = 'UPTIME';
export const PROPERTY_WEB_FETCH_ROUTES = 'WEB_FETCH_ROUTES';
export const QUEUE_JOB_STATUS_LIST = [
'active',

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

@ -1,5 +1,12 @@
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 { isISO4217CurrencyCode } from 'class-validator';
import {
@ -47,6 +54,42 @@ export const DATE_FORMAT = 'yyyy-MM-dd';
export const DATE_FORMAT_MONTHLY = 'MMMM 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({
days,
historicalData

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

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

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

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

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

File diff suppressed because it is too large

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

@ -504,11 +504,16 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ holdings }) => {
this.holdings = holdings
.filter(({ assetSubClass }) => {
return assetSubClass && !['CASH'].includes(assetSubClass);
.filter(({ assetProfile }) => {
return (
assetProfile.assetSubClass &&
!['CASH'].includes(assetProfile.assetSubClass)
);
})
.sort((a, b) => {
return a.name?.localeCompare(b.name);
return (a.assetProfile.name ?? '').localeCompare(
b.assetProfile.name ?? ''
);
});
this.setPortfolioFilterFormValues();
@ -530,11 +535,11 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
type: 'ASSET_CLASS'
},
{
id: filterValue?.holding?.dataSource ?? '',
id: filterValue?.holding?.assetProfile?.dataSource ?? '',
type: 'DATA_SOURCE'
},
{
id: filterValue?.holding?.symbol ?? '',
id: filterValue?.holding?.assetProfile?.symbol ?? '',
type: 'SYMBOL'
},
{
@ -718,18 +723,16 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
return EMPTY;
}),
map(({ holdings }) => {
return holdings.map(
({ assetSubClass, currency, dataSource, name, symbol }) => {
return {
currency,
dataSource,
name,
symbol,
assetSubClassString: translate(assetSubClass ?? ''),
mode: SearchMode.HOLDING as const
};
}
);
return holdings.map(({ assetProfile }) => {
return {
assetSubClassString: translate(assetProfile.assetSubClass ?? ''),
currency: assetProfile.currency ?? '',
dataSource: assetProfile.dataSource,
mode: SearchMode.HOLDING as const,
name: assetProfile.name ?? '',
symbol: assetProfile.symbol
};
});
}),
takeUntilDestroyed(this.destroyRef)
);
@ -777,8 +780,8 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
return (
!!(dataSource && symbol) &&
getAssetProfileIdentifier({
dataSource: holding.dataSource,
symbol: holding.symbol
dataSource: holding.assetProfile.dataSource,
symbol: holding.assetProfile.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,
allocationInPercentage: 0.042990776363386086,
assetClass: 'EQUITY',
assetClassLabel: 'Equity',
assetProfile: {
assetClass: 'EQUITY',
assetClassLabel: 'Equity',
assetSubClass: 'STOCK',
assetSubClassLabel: 'Stock',
countries: [
{
code: 'US',
@ -20,60 +20,40 @@ export const holdings: PortfolioPosition[] = [
currency: 'USD',
dataSource: 'YAHOO',
holdings: [],
name: 'Apple Inc',
sectors: [
{
name: 'Technology',
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'),
dividend: 0,
grossPerformance: 3856,
grossPerformancePercent: 0.46047289228564603,
grossPerformancePercentWithCurrencyEffect: 0.46047289228564603,
grossPerformanceWithCurrencyEffect: 3856,
holdings: [],
investment: 8374,
marketPrice: 244.6,
name: 'Apple Inc',
netPerformance: 3855,
netPerformancePercent: 0.460353475041796,
netPerformancePercentWithCurrencyEffect: 0.036440677966101696,
netPerformanceWithCurrencyEffect: 430,
quantity: 50,
sectors: [
{
name: 'Technology',
weight: 1
}
],
symbol: 'AAPL',
tags: [],
url: 'https://www.apple.com',
valueInBaseCurrency: 12230
},
{
activitiesCount: 2,
allocationInPercentage: 0.02377401948293552,
assetClass: 'EQUITY',
assetClassLabel: 'Equity',
assetProfile: {
assetClass: 'EQUITY',
assetClassLabel: 'Equity',
assetSubClass: 'STOCK',
assetSubClassLabel: 'Stock',
countries: [
{
code: 'DE',
@ -85,60 +65,40 @@ export const holdings: PortfolioPosition[] = [
currency: 'EUR',
dataSource: 'YAHOO',
holdings: [],
name: 'Allianz SE',
sectors: [
{
name: 'Financial Services',
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'),
dividend: 192,
grossPerformance: 2226.700251889169,
grossPerformancePercent: 0.49083842309827874,
grossPerformancePercentWithCurrencyEffect: 0.29306136948826367,
grossPerformanceWithCurrencyEffect: 1532.8272791336772,
holdings: [],
investment: 4536.523929471033,
marketPrice: 322.2,
name: 'Allianz SE',
netPerformance: 2222.2921914357685,
netPerformancePercent: 0.48986674069961134,
netPerformancePercentWithCurrencyEffect: 0.034489367670592026,
netPerformanceWithCurrencyEffect: 225.48257403052068,
quantity: 20,
sectors: [
{
name: 'Financial Services',
weight: 1
}
],
symbol: 'ALV.DE',
tags: [],
url: 'https://www.allianz.com',
valueInBaseCurrency: 6763.224181360202
},
{
activitiesCount: 1,
allocationInPercentage: 0.08038536990007467,
assetClass: 'EQUITY',
assetClassLabel: 'Equity',
assetProfile: {
assetClass: 'EQUITY',
assetClassLabel: 'Equity',
assetSubClass: 'STOCK',
assetSubClassLabel: 'Stock',
countries: [
{
code: 'US',
@ -150,101 +110,73 @@ export const holdings: PortfolioPosition[] = [
currency: 'USD',
dataSource: 'YAHOO',
holdings: [],
name: 'Amazon.com, Inc.',
sectors: [
{
name: 'Consumer Discretionary',
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'),
dividend: 0,
grossPerformance: 12758.05,
grossPerformancePercent: 1.2619300787837724,
grossPerformancePercentWithCurrencyEffect: 1.2619300787837724,
grossPerformanceWithCurrencyEffect: 12758.05,
holdings: [],
investment: 10109.95,
marketPrice: 228.68,
name: 'Amazon.com, Inc.',
netPerformance: 12677.26,
netPerformancePercent: 1.253938941339967,
netPerformancePercentWithCurrencyEffect: -0.037866008722316276,
netPerformanceWithCurrencyEffect: -899.99926757812,
quantity: 100,
sectors: [
{
name: 'Consumer Discretionary',
weight: 1
}
],
symbol: 'AMZN',
tags: [],
url: 'https://www.aboutamazon.com',
valueInBaseCurrency: 22868
},
{
activitiesCount: 1,
allocationInPercentage: 0.19216416482928922,
assetClass: 'LIQUIDITY',
assetClassLabel: 'Liquidity',
assetProfile: {
assetClass: 'LIQUIDITY',
assetSubClass: 'CASH',
assetClassLabel: 'Liquidity',
assetSubClass: 'CRYPTOCURRENCY',
assetSubClassLabel: 'Cryptocurrency',
countries: [],
currency: 'USD',
dataSource: 'COINGECKO',
holdings: [],
name: 'Bitcoin',
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'),
dividend: 0,
grossPerformance: 52666.7898248,
grossPerformancePercent: 26.333394912400003,
grossPerformancePercentWithCurrencyEffect: 26.333394912400003,
grossPerformanceWithCurrencyEffect: 52666.7898248,
holdings: [],
investment: 1999.9999999999998,
marketPrice: 97364,
name: 'Bitcoin',
netPerformance: 52636.8898248,
netPerformancePercent: 26.3184449124,
netPerformancePercentWithCurrencyEffect: -0.04760906442310894,
netPerformanceWithCurrencyEffect: -2732.737808972287,
quantity: 0.5614682,
sectors: [],
symbol: 'bitcoin',
tags: [],
url: undefined,
valueInBaseCurrency: 54666.7898248
},
{
activitiesCount: 1,
allocationInPercentage: 0.04307127421937313,
assetClass: 'EQUITY',
assetClassLabel: 'Equity',
assetProfile: {
assetClass: 'EQUITY',
assetClassLabel: 'Equity',
assetSubClass: 'STOCK',
assetSubClassLabel: 'Stock',
countries: [
{
code: 'US',
@ -256,60 +188,40 @@ export const holdings: PortfolioPosition[] = [
currency: 'USD',
dataSource: 'YAHOO',
holdings: [],
name: 'Microsoft Corporation',
sectors: [
{
name: 'Technology',
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'),
dividend: 0,
grossPerformance: 5065.5,
grossPerformancePercent: 0.7047750229568411,
grossPerformancePercentWithCurrencyEffect: 0.7047750229568411,
grossPerformanceWithCurrencyEffect: 5065.5,
holdings: [],
investment: 7187.4,
marketPrice: 408.43,
name: 'Microsoft Corporation',
netPerformance: 5065.5,
netPerformancePercent: 0.7047750229568411,
netPerformancePercentWithCurrencyEffect: -0.015973588391056275,
netPerformanceWithCurrencyEffect: -198.899926757814,
quantity: 30,
sectors: [
{
name: 'Technology',
weight: 1
}
],
symbol: 'MSFT',
tags: [],
url: 'https://www.microsoft.com',
valueInBaseCurrency: 12252.9
},
{
activitiesCount: 1,
allocationInPercentage: 0.18762679306394897,
assetClass: 'EQUITY',
assetClassLabel: 'Equity',
assetProfile: {
assetClass: 'EQUITY',
assetClassLabel: 'Equity',
assetSubClass: 'STOCK',
assetSubClassLabel: 'Stock',
countries: [
{
code: 'US',
@ -321,60 +233,40 @@ export const holdings: PortfolioPosition[] = [
currency: 'USD',
dataSource: 'YAHOO',
holdings: [],
name: 'Tesla, Inc.',
sectors: [
{
name: 'Consumer Discretionary',
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'),
dividend: 0,
grossPerformance: 51227.500000005,
grossPerformancePercent: 23.843379101756675,
grossPerformancePercentWithCurrencyEffect: 23.843379101756675,
grossPerformanceWithCurrencyEffect: 51227.500000005,
holdings: [],
investment: 2148.499999995,
marketPrice: 355.84,
name: 'Tesla, Inc.',
netPerformance: 51197.500000005,
netPerformancePercent: 23.829415871596066,
netPerformancePercentWithCurrencyEffect: -0.12051410125545206,
netPerformanceWithCurrencyEffect: -7314.00091552734,
quantity: 150,
sectors: [
{
name: 'Consumer Discretionary',
weight: 1
}
],
symbol: 'TSLA',
tags: [],
url: 'https://www.tesla.com',
valueInBaseCurrency: 53376
},
{
activitiesCount: 5,
allocationInPercentage: 0.053051250766657634,
assetClass: 'EQUITY',
assetClassLabel: 'Equity',
assetProfile: {
assetClass: 'EQUITY',
assetClassLabel: 'Equity',
assetSubClass: 'ETF',
assetSubClassLabel: 'ETF',
countries: [
{
code: 'US',
@ -386,50 +278,30 @@ export const holdings: PortfolioPosition[] = [
currency: 'USD',
dataSource: 'YAHOO',
holdings: [],
name: 'Vanguard Total Stock Market Index Fund ETF Shares',
sectors: [
{
name: 'Equity',
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'),
dividend: 0,
grossPerformance: 6845.8,
grossPerformancePercent: 1.0164758094605268,
grossPerformancePercentWithCurrencyEffect: 1.0164758094605268,
grossPerformanceWithCurrencyEffect: 6845.8,
holdings: [],
investment: 8246.2,
marketPrice: 301.84,
name: 'Vanguard Total Stock Market Index Fund ETF Shares',
netPerformance: 6746.3,
netPerformancePercent: 1.0017018833976383,
netPerformancePercentWithCurrencyEffect: 0.01085061564051406,
netPerformanceWithCurrencyEffect: 161.99969482422,
quantity: 50,
sectors: [
{
name: 'Equity',
weight: 1
}
],
symbol: 'VTI',
tags: [],
url: 'https://www.vanguard.com',
valueInBaseCurrency: 15092
}
];

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

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

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

Loading…
Cancel
Save