Browse Source

Merge pull request #132 from dandevaud/mr/Mr-Upstream-changes-2024-10-30

Mr/mr upstream changes 2024 10 30
pull/5027/head
dandevaud 8 months ago
committed by GitHub
parent
commit
1a891ffaee
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 10
      .eslintrc.json
  2. 2
      .husky/pre-commit
  3. 100
      CHANGELOG.md
  4. 1
      Dockerfile
  5. 6
      README.md
  6. 2
      apps/api/src/app/account-balance/account-balance.service.ts
  7. 4
      apps/api/src/app/account/account.service.ts
  8. 2
      apps/api/src/app/account/create-account.dto.ts
  9. 2
      apps/api/src/app/account/update-account.dto.ts
  10. 4
      apps/api/src/app/admin/admin.service.ts
  11. 4
      apps/api/src/app/admin/queue/queue.controller.ts
  12. 4
      apps/api/src/app/admin/queue/queue.service.ts
  13. 13
      apps/api/src/app/auth/auth.controller.ts
  14. 8
      apps/api/src/app/auth/google.strategy.ts
  15. 4
      apps/api/src/app/auth/interfaces/simplewebauthn.ts
  16. 11
      apps/api/src/app/auth/web-auth.service.ts
  17. 4
      apps/api/src/app/benchmark/benchmark.service.ts
  18. 7
      apps/api/src/app/health/health.controller.ts
  19. 6
      apps/api/src/app/order/order.service.ts
  20. 8
      apps/api/src/app/portfolio/calculator/portfolio-calculator.ts
  21. 56
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-novn-buy-and-sell-partially.spec.ts
  22. 4
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator.ts
  23. 129
      apps/api/src/app/portfolio/portfolio.service.ts
  24. 9
      apps/api/src/app/portfolio/rules.service.ts
  25. 4
      apps/api/src/app/redis-cache/redis-cache.module.ts
  26. 4
      apps/api/src/app/subscription/subscription.controller.ts
  27. 4
      apps/api/src/app/subscription/subscription.service.ts
  28. 14
      apps/api/src/app/user/update-user-setting.dto.ts
  29. 2
      apps/api/src/app/user/user.controller.ts
  30. 64
      apps/api/src/app/user/user.service.ts
  31. 24
      apps/api/src/assets/sitemap.xml
  32. 2
      apps/api/src/helper/object.helper.ts
  33. 2
      apps/api/src/interceptors/redact-values-in-response/redact-values-in-response.interceptor.ts
  34. 2
      apps/api/src/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor.ts
  35. 10
      apps/api/src/models/rule.ts
  36. 16
      apps/api/src/models/rules/account-cluster-risk/current-investment.ts
  37. 6
      apps/api/src/models/rules/account-cluster-risk/single-account.ts
  38. 6
      apps/api/src/models/rules/currency-cluster-risk/base-currency-current-investment.ts
  39. 16
      apps/api/src/models/rules/currency-cluster-risk/current-investment.ts
  40. 84
      apps/api/src/models/rules/economic-market-cluster-risk/developed-markets.ts
  41. 84
      apps/api/src/models/rules/economic-market-cluster-risk/emerging-markets.ts
  42. 6
      apps/api/src/models/rules/emergency-fund/emergency-fund-setup.ts
  43. 16
      apps/api/src/models/rules/fees/fee-ratio-initial-investment.ts
  44. 18
      apps/api/src/services/api/api.service.ts
  45. 18
      apps/api/src/services/configuration/configuration.service.ts
  46. 2
      apps/api/src/services/cryptocurrency/cryptocurrency.service.ts
  47. 8
      apps/api/src/services/data-provider/data-enhancer/trackinsight/trackinsight.service.ts
  48. 4
      apps/api/src/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service.spec.ts
  49. 2
      apps/api/src/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service.ts
  50. 10
      apps/api/src/services/data-provider/eod-historical-data/eod-historical-data.service.ts
  51. 2
      apps/api/src/services/data-provider/google-sheets/google-sheets.service.ts
  52. 4
      apps/api/src/services/interfaces/environment.interface.ts
  53. 23
      apps/api/src/services/market-data/market-data.service.ts
  54. 16
      apps/api/src/services/queues/data-gathering/data-gathering.processor.ts
  55. 4
      apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.module.ts
  56. 14
      apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.processor.ts
  57. 4
      apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.service.ts
  58. 2
      apps/api/src/services/symbol-profile/symbol-profile.service.ts
  59. 3
      apps/api/tsconfig.app.json
  60. 11
      apps/client/src/app/app.component.html
  61. 7
      apps/client/src/app/app.component.scss
  62. 9
      apps/client/src/app/app.component.ts
  63. 5
      apps/client/src/app/components/access-table/access-table.component.ts
  64. 8
      apps/client/src/app/components/accounts-table/accounts-table.component.ts
  65. 4
      apps/client/src/app/components/admin-jobs/admin-jobs.component.ts
  66. 11
      apps/client/src/app/components/admin-market-data-detail/admin-market-data-detail.component.ts
  67. 29
      apps/client/src/app/components/admin-market-data/admin-market-data.component.ts
  68. 3
      apps/client/src/app/components/admin-market-data/admin-market-data.service.ts
  69. 6
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts
  70. 4
      apps/client/src/app/components/admin-overview/admin-overview.component.ts
  71. 6
      apps/client/src/app/components/admin-platform/admin-platform.component.ts
  72. 35
      apps/client/src/app/components/admin-settings/admin-settings.component.html
  73. 53
      apps/client/src/app/components/admin-settings/admin-settings.component.ts
  74. 6
      apps/client/src/app/components/admin-settings/admin-settings.module.ts
  75. 39
      apps/client/src/app/components/admin-settings/ghostfolio-premium-api-dialog/ghostfolio-premium-api-dialog.component.ts
  76. 42
      apps/client/src/app/components/admin-settings/ghostfolio-premium-api-dialog/ghostfolio-premium-api-dialog.html
  77. 2
      apps/client/src/app/components/admin-settings/ghostfolio-premium-api-dialog/ghostfolio-premium-api-dialog.scss
  78. 4
      apps/client/src/app/components/admin-settings/ghostfolio-premium-api-dialog/interfaces/interfaces.ts
  79. 6
      apps/client/src/app/components/admin-tag/admin-tag.component.ts
  80. 3
      apps/client/src/app/components/admin-users/admin-users.component.ts
  81. 2
      apps/client/src/app/components/asset-profile-icon/asset-profile-icon.component.ts
  82. 21
      apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.ts
  83. 7
      apps/client/src/app/components/dialog-footer/dialog-footer.component.ts
  84. 7
      apps/client/src/app/components/dialog-header/dialog-header.component.ts
  85. 9
      apps/client/src/app/components/fear-and-greed-index/fear-and-greed-index.component.ts
  86. 3
      apps/client/src/app/components/header/header.component.html
  87. 2
      apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts
  88. 7
      apps/client/src/app/components/home-holdings/home-holdings.component.ts
  89. 3
      apps/client/src/app/components/home-market/home-market.component.ts
  90. 10
      apps/client/src/app/components/home-market/home-market.html
  91. 4
      apps/client/src/app/components/home-market/home-market.module.ts
  92. 35
      apps/client/src/app/components/investment-chart/investment-chart.component.ts
  93. 2
      apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.component.ts
  94. 7
      apps/client/src/app/components/portfolio-summary/portfolio-summary.component.ts
  95. 6
      apps/client/src/app/components/rule/rule-settings-dialog/interfaces/interfaces.ts
  96. 14
      apps/client/src/app/components/rule/rule-settings-dialog/rule-settings-dialog.component.ts
  97. 90
      apps/client/src/app/components/rule/rule-settings-dialog/rule-settings-dialog.html
  98. 2
      apps/client/src/app/components/rule/rule.component.html
  99. 19
      apps/client/src/app/components/rule/rule.component.ts
  100. 1
      apps/client/src/app/components/rules/rules.component.html

10
.eslintrc.json

@ -39,6 +39,7 @@
"plugin:@typescript-eslint/stylistic-type-checked"
],
"rules": {
"@typescript-eslint/consistent-indexed-object-style": "off",
"@typescript-eslint/dot-notation": "off",
"@typescript-eslint/explicit-member-accessibility": [
"off",
@ -142,14 +143,7 @@
// The following rules are part of @typescript-eslint/stylistic-type-checked
// and can be remove once solved
"@typescript-eslint/consistent-type-definitions": "warn",
"@typescript-eslint/prefer-function-type": "warn",
"@typescript-eslint/no-empty-function": "warn",
"@typescript-eslint/prefer-nullish-coalescing": "warn", // TODO: Requires strictNullChecks: true
"@typescript-eslint/consistent-type-assertions": "warn",
"@typescript-eslint/prefer-optional-chain": "warn",
"@typescript-eslint/consistent-indexed-object-style": "warn",
"@typescript-eslint/consistent-generic-constructors": "warn"
"@typescript-eslint/prefer-nullish-coalescing": "warn" // TODO: Requires strictNullChecks: true
}
}
],

2
.husky/pre-commit

@ -1,6 +1,6 @@
# Run linting and stop the commit process if any errors are found
# --quiet suppresses warnings (temporary until all warnings are fixed)
npm run lint --quiet || exit 1
npm run affected:lint --base=main --head=HEAD --parallel=2 --quiet || exit 1
# Check formatting on modified and uncommitted files, stop the commit if issues are found
npm run format:check --uncommitted || exit 1

100
CHANGELOG.md

@ -7,19 +7,119 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Unreleased
### Changed
- Restructured the resources page
- Renamed the static portfolio analysis rule from _Allocation Cluster Risk_ to _Economic Market Cluster Risk_ (Developed Markets and Emerging Markets)
- Improved the language localization for German (`de`)
- Switched the `consistent-generic-constructors` rule from `warn` to `error` in the `eslint` configuration
- Switched the `consistent-indexed-object-style` rule from `warn` to `off` in the `eslint` configuration
- Switched the `consistent-type-assertions` rule from `warn` to `error` in the `eslint` configuration
- Switched the `prefer-optional-chain` rule from `warn` to `error` in the `eslint` configuration
- Upgraded `Nx` from version `20.0.3` to `20.0.6`
## 2.119.0 - 2024-10-26
### Changed
- Switched the `consistent-type-definitions` rule from `warn` to `error` in the `eslint` configuration
- Switched the `no-empty-function` rule from `warn` to `error` in the `eslint` configuration
- Switched the `prefer-function-type` rule from `warn` to `error` in the `eslint` configuration
- Upgraded `prisma` from version `5.20.0` to `5.21.1`
### Fixed
- Fixed an issue with the X-axis scale of the dividend timeline on the analysis page
- Fixed an issue with the X-axis scale of the investment timeline on the analysis page
- Fixed an issue with the X-axis scale of the portfolio evolution chart on the analysis page
- Fixed an issue in the calculation of the static portfolio analysis rule: _Allocation Cluster Risk_ (Developed Markets)
- Fixed an issue in the calculation of the static portfolio analysis rule: _Allocation Cluster Risk_ (Emerging Markets)
## 2.118.0 - 2024-10-23
### Added
- Added a new static portfolio analysis rule: _Allocation Cluster Risk_ (Developed Markets)
- Added a new static portfolio analysis rule: _Allocation Cluster Risk_ (Emerging Markets)
- Added support for mutual funds in the _EOD Historical Data_ service
### Changed
- Improved the font colors of the chart of the holdings tab on the home page (experimental)
- Optimized the dialog sizes for mobile (full screen)
- Optimized the git-hook via `husky` to lint only affected projects before a commit
- Upgraded `angular` from version `18.1.1` to `18.2.8`
- Upgraded `Nx` from version `19.5.6` to `20.0.3`
### Fixed
- Fixed the warning `export was not found` in connection with `GetValuesParams`
- Quoted the password for the _Redis_ service `healthcheck` in the `docker-compose` files (`docker-compose.yml` and `docker-compose.build.yml`)
## 2.117.0 - 2024-10-19
### Added
- Added the logotype to the footer
- Added the data providers management to the admin control panel
### Changed
- Improved the backgrounds of the chart of the holdings tab on the home page (experimental)
- Improved the language localization for German (`de`)
### Fixed
- Fixed an issue in the carousel component for the testimonial section on the landing page
## 2.116.0 - 2024-10-17
### Added
- Extended the content of the _Self-Hosting_ section by the benchmarks concept for _Compare with..._ on the Frequently Asked Questions (FAQ) page
- Extended the content of the _Self-Hosting_ section by the benchmarks concept for _Markets_ on the Frequently Asked Questions (FAQ) page
- Set the permissions (`chmod 0700`) on `entrypoint.sh` in the `Dockerfile`
### Changed
- Improved the empty state in the benchmarks of the markets overview
- Disabled the text hover effect in the chart of the holdings tab on the home page (experimental)
- Improved the usability to customize the rule thresholds in the _X-ray_ section by introducing units (experimental)
- Switched to adjusted market prices (splits and dividends) in the get historical functionality of the _EOD Historical Data_ service
- Improved the language localization for German (`de`)
### Fixed
- Fixed the usage of the environment variable `PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_CONCURRENCY`
## 2.115.0 - 2024-10-14
### Added
- Added the name to the tooltip of the chart of the holdings tab on the home page (experimental)
### Changed
- Improved the backgrounds of the chart of the holdings tab on the home page (experimental)
- Improved the labels of the chart of the holdings tab on the home page (experimental)
- Improved the usability to customize the rule thresholds in the _X-ray_ section by introducing sliders (experimental)
- Refactored the rule thresholds in the _X-ray_ section (experimental)
- Exposed the timeout of the portfolio snapshot computation as an environment variable (`PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_TIMEOUT`)
- Harmonized the processor concurrency environment variables
- Improved the portfolio unit tests to work with exported activity files
- Enabled the `noUnusedLocals` compiler option in the `tsconfig`
- Enabled the `noUnusedParameters` compiler option in the `tsconfig`
### Fixed
- Considered the language of the user settings on login with _Security Token_
### Todo
- Rename the environment variable from `PROCESSOR_CONCURRENCY_GATHER_ASSET_PROFILE` to `PROCESSOR_GATHER_ASSET_PROFILE_CONCURRENCY`
- Rename the environment variable from `PROCESSOR_CONCURRENCY_GATHER_HISTORICAL_MARKET_DATA` to `PROCESSOR_GATHER_HISTORICAL_MARKET_DATA_CONCURRENCY`
- Rename the environment variable from `PROCESSOR_CONCURRENCY_PORTFOLIO_SNAPSHOT` to `PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_CONCURRENCY`
## 2.114.0 - 2024-10-10
### Added

1
Dockerfile

@ -61,6 +61,7 @@ RUN apt-get update && apt-get install -y --no-install-suggests \
COPY --chown=node:node --from=builder /ghostfolio/dist/apps /ghostfolio/apps
COPY --chown=node:node ./docker/entrypoint.sh /ghostfolio/entrypoint.sh
RUN chmod 0700 /ghostfolio/entrypoint.sh
WORKDIR /ghostfolio/apps/api
EXPOSE ${PORT:-3333}
USER node

6
README.md

@ -177,6 +177,12 @@ Deprecated: `GET http://localhost:3333/api/v1/auth/anonymous/<INSERT_SECURITY_TO
`200 OK`
```
{
"status": "OK"
}
```
### Import Activities
#### Prerequisites

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

@ -88,7 +88,7 @@ export class AccountBalanceService {
this.eventEmitter.emit(
PortfolioChangedEvent.getName(),
new PortfolioChangedEvent({
userId: <string>where.userId
userId: where.userId as string
})
);

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

@ -211,8 +211,8 @@ export class AccountService {
const { data, where } = params;
await this.accountBalanceService.createOrUpdateAccountBalance({
accountId: <string>data.id,
balance: <number>data.balance,
accountId: data.id as string,
balance: data.balance as number,
date: format(new Date(), DATE_FORMAT),
userId: aUserId
});

2
apps/api/src/app/account/create-account.dto.ts

@ -36,6 +36,6 @@ export class CreateAccountDto {
name: string;
@IsString()
@ValidateIf((object, value) => value !== null)
@ValidateIf((_object, value) => value !== null)
platformId: string | null;
}

2
apps/api/src/app/account/update-account.dto.ts

@ -35,6 +35,6 @@ export class UpdateAccountDto {
name: string;
@IsString()
@ValidateIf((object, value) => value !== null)
@ValidateIf((_object, value) => value !== null)
platformId: string | null;
}

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

@ -8,7 +8,6 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { SymbolProfileOverwriteService } from '@ghostfolio/api/services/symbol-profile/symbol-profile-overwrite.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import {
DEFAULT_CURRENCY,
@ -58,8 +57,7 @@ export class AdminService {
private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService,
private readonly subscriptionService: SubscriptionService,
private readonly symbolProfileService: SymbolProfileService,
private readonly symbolProfileOverwriteService: SymbolProfileOverwriteService
private readonly symbolProfileService: SymbolProfileService
) {}
public async addAssetProfile({

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

@ -26,7 +26,7 @@ export class QueueController {
public async deleteJobs(
@Query('status') filterByStatus?: string
): Promise<void> {
const status = <JobStatus[]>filterByStatus?.split(',') ?? undefined;
const status = (filterByStatus?.split(',') as JobStatus[]) ?? undefined;
return this.queueService.deleteJobs({ status });
}
@ -36,7 +36,7 @@ export class QueueController {
public async getJobs(
@Query('status') filterByStatus?: string
): Promise<AdminJobs> {
const status = <JobStatus[]>filterByStatus?.split(',') ?? undefined;
const status = (filterByStatus?.split(',') as JobStatus[]) ?? undefined;
return this.queueService.getJobs({ status });
}

4
apps/api/src/app/admin/queue/queue.service.ts

@ -1,6 +1,6 @@
import {
DATA_GATHERING_QUEUE,
PORTFOLIO_SNAPSHOT_QUEUE,
PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE,
QUEUE_JOB_STATUS_LIST
} from '@ghostfolio/common/config';
import { AdminJobs } from '@ghostfolio/common/interfaces';
@ -14,7 +14,7 @@ export class QueueService {
public constructor(
@InjectQueue(DATA_GATHERING_QUEUE)
private readonly dataGatheringQueue: Queue,
@InjectQueue(PORTFOLIO_SNAPSHOT_QUEUE)
@InjectQueue(PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE)
private readonly portfolioSnapshotQueue: Queue
) {}

13
apps/api/src/app/auth/auth.controller.ts

@ -14,12 +14,12 @@ import {
Req,
Res,
UseGuards,
VERSION_NEUTRAL,
Version
Version,
VERSION_NEUTRAL
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { Request, Response } from 'express';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { getReasonPhrase, StatusCodes } from 'http-status-codes';
import { AuthService } from './auth.service';
import {
@ -85,7 +85,7 @@ export class AuthController {
@Res() response: Response
) {
// Handles the Google OAuth2 callback
const jwt: string = (<any>request.user).jwt;
const jwt: string = (request.user as any).jwt;
if (jwt) {
response.redirect(
@ -130,10 +130,7 @@ export class AuthController {
public async verifyAttestation(
@Body() body: { deviceName: string; credential: AttestationCredentialJSON }
) {
return this.webAuthService.verifyAttestation(
body.deviceName,
body.credential
);
return this.webAuthService.verifyAttestation(body.credential);
}
@Post('webauthn/generate-assertion-options')

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

@ -11,7 +11,7 @@ import { AuthService } from './auth.service';
export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
public constructor(
private readonly authService: AuthService,
private readonly configurationService: ConfigurationService
configurationService: ConfigurationService
) {
super({
callbackURL: `${configurationService.get(
@ -25,9 +25,9 @@ export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
}
public async validate(
request: any,
token: string,
refreshToken: string,
_request: any,
_token: string,
_refreshToken: string,
profile: Profile,
done: Function
) {

4
apps/api/src/app/auth/interfaces/simplewebauthn.ts

@ -198,12 +198,12 @@ export interface AuthenticatorAssertionResponseJSON
/**
* A WebAuthn-compatible device and the information needed to verify assertions by it
*/
export declare type AuthenticatorDevice = {
export declare interface AuthenticatorDevice {
credentialPublicKey: Buffer;
credentialID: Buffer;
counter: number;
transports?: AuthenticatorTransport[];
};
}
/**
* An attempt to communicate that this isn't just any string, but a Base64URL-encoded string
*/

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

@ -13,16 +13,16 @@ import {
import { REQUEST } from '@nestjs/core';
import { JwtService } from '@nestjs/jwt';
import {
generateAuthenticationOptions,
GenerateAuthenticationOptionsOpts,
generateRegistrationOptions,
GenerateRegistrationOptionsOpts,
VerifiedAuthenticationResponse,
VerifiedRegistrationResponse,
VerifyAuthenticationResponseOpts,
VerifyRegistrationResponseOpts,
generateAuthenticationOptions,
generateRegistrationOptions,
verifyAuthenticationResponse,
verifyRegistrationResponse
VerifyAuthenticationResponseOpts,
verifyRegistrationResponse,
VerifyRegistrationResponseOpts
} from '@simplewebauthn/server';
import {
@ -80,7 +80,6 @@ export class WebAuthService {
}
public async verifyAttestation(
deviceName: string,
credential: AttestationCredentialJSON
): Promise<AuthDeviceDto> {
const user = this.request.user;

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

@ -442,10 +442,10 @@ export class BenchmarkService {
await this.redisCacheService.set(
this.CACHE_KEY_BENCHMARKS,
JSON.stringify(<BenchmarkValue>{
JSON.stringify({
benchmarks,
expiration: expiration.getTime()
}),
} as BenchmarkValue),
CACHE_TTL_INFINITE
);
}

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

@ -3,7 +3,9 @@ import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interce
import {
Controller,
Get,
HttpCode,
HttpException,
HttpStatus,
Param,
UseInterceptors
} from '@nestjs/common';
@ -17,7 +19,10 @@ export class HealthController {
public constructor(private readonly healthService: HealthService) {}
@Get()
public async getHealth() {}
@HttpCode(HttpStatus.OK)
public getHealth() {
return { status: getReasonPhrase(StatusCodes.OK) };
}
@Get('data-enhancer/:name')
public async getHealthOfDataEnhancer(@Param('name') name: string) {

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

@ -418,7 +418,7 @@ export class OrderService {
where.SymbolProfile,
{
AND: [
{ dataSource: <DataSource>filterByDataSource },
{ dataSource: filterByDataSource as DataSource },
{ symbol: filterBySymbol }
]
}
@ -427,7 +427,7 @@ export class OrderService {
} else {
where.SymbolProfile = {
AND: [
{ dataSource: <DataSource>filterByDataSource },
{ dataSource: filterByDataSource as DataSource },
{ symbol: filterBySymbol }
]
};
@ -671,7 +671,7 @@ export class OrderService {
{
dataSource:
data.SymbolProfile.connect.dataSource_symbol.dataSource,
date: <Date>data.date,
date: data.date as Date,
symbol: data.SymbolProfile.connect.dataSource_symbol.symbol
}
],

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

@ -15,8 +15,8 @@ import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper'
import {
PORTFOLIO_SNAPSHOT_PROCESS_JOB_NAME,
PORTFOLIO_SNAPSHOT_PROCESS_JOB_OPTIONS,
PORTFOLIO_SNAPSHOT_QUEUE_PRIORITY_HIGH,
PORTFOLIO_SNAPSHOT_QUEUE_PRIORITY_LOW
PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE_PRIORITY_HIGH,
PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE_PRIORITY_LOW
} from '@ghostfolio/common/config';
import {
DATE_FORMAT,
@ -1091,7 +1091,7 @@ export abstract class PortfolioCalculator {
opts: {
...PORTFOLIO_SNAPSHOT_PROCESS_JOB_OPTIONS,
jobId,
priority: PORTFOLIO_SNAPSHOT_QUEUE_PRIORITY_LOW
priority: PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE_PRIORITY_LOW
}
});
}
@ -1107,7 +1107,7 @@ export abstract class PortfolioCalculator {
opts: {
...PORTFOLIO_SNAPSHOT_PROCESS_JOB_OPTIONS,
jobId,
priority: PORTFOLIO_SNAPSHOT_QUEUE_PRIORITY_HIGH
priority: PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE_PRIORITY_HIGH
}
});

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

@ -1,6 +1,8 @@
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import {
activityDummyData,
loadActivityExportFile,
symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
@ -20,6 +22,7 @@ import { parseDate } from '@ghostfolio/common/helper';
import { Big } from 'big.js';
import { last } from 'lodash';
import { join } from 'path';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
@ -52,6 +55,8 @@ jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
});
describe('PortfolioCalculator', () => {
let activityDtos: CreateOrderDto[];
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
@ -59,6 +64,15 @@ describe('PortfolioCalculator', () => {
let portfolioSnapshotService: PortfolioSnapshotService;
let redisCacheService: RedisCacheService;
beforeAll(() => {
activityDtos = loadActivityExportFile(
join(
__dirname,
'../../../../../../../test/import/ok-novn-buy-and-sell-partially.json'
)
);
});
beforeEach(() => {
configurationService = new ConfigurationService();
@ -89,38 +103,18 @@ describe('PortfolioCalculator', () => {
it.only('with NOVN.SW buy and sell partially', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2022-04-11').getTime());
const activities: Activity[] = [
{
...activityDummyData,
date: new Date('2022-03-07'),
fee: 1.3,
quantity: 2,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'CHF',
dataSource: 'YAHOO',
name: 'Novartis AG',
symbol: 'NOVN.SW'
},
type: 'BUY',
unitPrice: 75.8
},
{
...activityDummyData,
date: new Date('2022-04-08'),
fee: 2.95,
quantity: 1,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'CHF',
dataSource: 'YAHOO',
name: 'Novartis AG',
symbol: 'NOVN.SW'
},
type: 'SELL',
unitPrice: 85.73
const activities: Activity[] = activityDtos.map((activity) => ({
...activityDummyData,
...activity,
date: parseDate(activity.date),
SymbolProfile: {
...symbolProfileDummyData,
currency: activity.currency,
dataSource: activity.dataSource,
name: 'Novartis AG',
symbol: activity.symbol
}
];
}));
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,

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

@ -810,7 +810,7 @@ export class TWRPortfolioCalculator extends PortfolioCalculator {
[key: DateRange]: Big;
} = {};
for (const dateRange of <DateRange[]>[
for (const dateRange of [
'1d',
'1y',
'5y',
@ -826,7 +826,7 @@ export class TWRPortfolioCalculator extends PortfolioCalculator {
// .map((date) => {
// return format(date, 'yyyy');
// })
]) {
] as DateRange[]) {
const dateInterval = getIntervalFromDateRange(dateRange);
const endDate = dateInterval.endDate;
let startDate = dateInterval.startDate;

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

@ -10,6 +10,8 @@ import { AccountClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rule
import { AccountClusterRiskSingleAccount } from '@ghostfolio/api/models/rules/account-cluster-risk/single-account';
import { CurrencyClusterRiskBaseCurrencyCurrentInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/base-currency-current-investment';
import { CurrencyClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/current-investment';
import { EconomicMarketClusterRiskDevelopedMarkets } from '@ghostfolio/api/models/rules/economic-market-cluster-risk/developed-markets';
import { EconomicMarketClusterRiskEmergingMarkets } from '@ghostfolio/api/models/rules/economic-market-cluster-risk/emerging-markets';
import { EmergencyFundSetup } from '@ghostfolio/api/models/rules/emergency-fund/emergency-fund-setup';
import { FeeRatioInitialInvestment } from '@ghostfolio/api/models/rules/fees/fee-ratio-initial-investment';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
@ -139,7 +141,7 @@ export class PortfolioService {
some: {
SymbolProfile: {
AND: [
{ dataSource: <DataSource>filterByDataSource },
{ dataSource: filterByDataSource as DataSource },
{ symbol: filterBySymbol }
]
}
@ -623,79 +625,6 @@ export class PortfolioService {
};
}
@LogPerformance
private calculateMarketsAllocation(
symbolProfile: EnhancedSymbolProfile,
markets: {
developedMarkets: number;
emergingMarkets: number;
otherMarkets: number;
},
marketsAdvanced: {
asiaPacific: number;
emergingMarkets: number;
europe: number;
japan: number;
northAmerica: number;
otherMarkets: number;
},
value: Big
) {
if (symbolProfile.countries.length > 0) {
for (const country of symbolProfile.countries) {
if (developedMarkets.includes(country.code)) {
markets.developedMarkets = new Big(markets.developedMarkets)
.plus(country.weight)
.toNumber();
} else if (emergingMarkets.includes(country.code)) {
markets.emergingMarkets = new Big(markets.emergingMarkets)
.plus(country.weight)
.toNumber();
} else {
markets.otherMarkets = new Big(markets.otherMarkets)
.plus(country.weight)
.toNumber();
}
if (country.code === 'JP') {
marketsAdvanced.japan = new Big(marketsAdvanced.japan)
.plus(country.weight)
.toNumber();
} else if (country.code === 'CA' || country.code === 'US') {
marketsAdvanced.northAmerica = new Big(marketsAdvanced.northAmerica)
.plus(country.weight)
.toNumber();
} else if (asiaPacificMarkets.includes(country.code)) {
marketsAdvanced.asiaPacific = new Big(marketsAdvanced.asiaPacific)
.plus(country.weight)
.toNumber();
} else if (emergingMarkets.includes(country.code)) {
marketsAdvanced.emergingMarkets = new Big(
marketsAdvanced.emergingMarkets
)
.plus(country.weight)
.toNumber();
} else if (europeMarkets.includes(country.code)) {
marketsAdvanced.europe = new Big(marketsAdvanced.europe)
.plus(country.weight)
.toNumber();
} else {
marketsAdvanced.otherMarkets = new Big(marketsAdvanced.otherMarkets)
.plus(country.weight)
.toNumber();
}
}
} else {
markets[UNKNOWN_KEY] = new Big(markets[UNKNOWN_KEY])
.plus(value)
.toNumber();
marketsAdvanced[UNKNOWN_KEY] = new Big(marketsAdvanced[UNKNOWN_KEY])
.plus(value)
.toNumber();
}
}
@LogPerformance
public async getPosition(
aDataSource: DataSource,
@ -1256,15 +1185,21 @@ export class PortfolioService {
@LogPerformance
public async getReport(impersonationId: string): Promise<PortfolioReport> {
const userId = await this.getUserId(impersonationId, this.request.user.id);
const userSettings = <UserSettings>this.request.user.Settings.settings;
const userSettings = this.request.user.Settings.settings as UserSettings;
const { accounts, holdings, summary } = await this.getDetails({
const { accounts, holdings, markets, summary } = await this.getDetails({
impersonationId,
userId,
withMarkets: true,
withSummary: true
});
const marketsTotalInBaseCurrency = getSum(
Object.values(markets).map(({ valueInBaseCurrency }) => {
return new Big(valueInBaseCurrency);
})
).toNumber();
return {
rules: {
accountClusterRisk:
@ -1283,6 +1218,24 @@ export class PortfolioService {
userSettings
)
: undefined,
economicMarketClusterRisk:
summary.ordersCount > 0
? await this.rulesService.evaluate(
[
new EconomicMarketClusterRiskDevelopedMarkets(
this.exchangeRateDataService,
marketsTotalInBaseCurrency,
markets.developedMarkets.valueInBaseCurrency
),
new EconomicMarketClusterRiskEmergingMarkets(
this.exchangeRateDataService,
marketsTotalInBaseCurrency,
markets.emergingMarkets.valueInBaseCurrency
)
],
userSettings
)
: undefined,
currencyClusterRisk:
summary.ordersCount > 0
? await this.rulesService.evaluate(
@ -1340,9 +1293,7 @@ export class PortfolioService {
await this.orderService.assignTags({ dataSource, symbol, tags, userId });
}
private getAggregatedMarkets(holdings: {
[symbol: string]: PortfolioPosition;
}): {
private getAggregatedMarkets(holdings: Record<string, PortfolioPosition>): {
markets: PortfolioDetails['markets'];
marketsAdvanced: PortfolioDetails['marketsAdvanced'];
} {
@ -1438,20 +1389,20 @@ export class PortfolioService {
}
}
const marketsTotal =
markets.developedMarkets.valueInBaseCurrency +
markets.emergingMarkets.valueInBaseCurrency +
markets.otherMarkets.valueInBaseCurrency +
markets[UNKNOWN_KEY].valueInBaseCurrency;
const marketsTotalInBaseCurrency = getSum(
Object.values(markets).map(({ valueInBaseCurrency }) => {
return new Big(valueInBaseCurrency);
})
).toNumber();
markets.developedMarkets.valueInPercentage =
markets.developedMarkets.valueInBaseCurrency / marketsTotal;
markets.developedMarkets.valueInBaseCurrency / marketsTotalInBaseCurrency;
markets.emergingMarkets.valueInPercentage =
markets.emergingMarkets.valueInBaseCurrency / marketsTotal;
markets.emergingMarkets.valueInBaseCurrency / marketsTotalInBaseCurrency;
markets.otherMarkets.valueInPercentage =
markets.otherMarkets.valueInBaseCurrency / marketsTotal;
markets.otherMarkets.valueInBaseCurrency / marketsTotalInBaseCurrency;
markets[UNKNOWN_KEY].valueInPercentage =
markets[UNKNOWN_KEY].valueInBaseCurrency / marketsTotal;
markets[UNKNOWN_KEY].valueInBaseCurrency / marketsTotalInBaseCurrency;
const marketsAdvancedTotal =
marketsAdvanced.asiaPacific.valueInBaseCurrency +
@ -2012,7 +1963,7 @@ export class PortfolioService {
}: {
activities: Activity[];
filters?: Filter[];
portfolioItemsNow: { [p: string]: TimelinePosition };
portfolioItemsNow: Record<string, TimelinePosition>;
userCurrency: string;
userId: string;
withExcludedAccounts?: boolean;

9
apps/api/src/app/portfolio/rules.service.ts

@ -9,8 +9,6 @@ import { Injectable } from '@nestjs/common';
@Injectable()
export class RulesService {
public constructor() {}
public async evaluate<T extends RuleSettings>(
aRules: Rule<T>[],
aUserSettings: UserSettings
@ -24,13 +22,10 @@ export class RulesService {
return {
evaluation,
value,
configuration: rule.getConfiguration(),
isActive: true,
key: rule.getKey(),
name: rule.getName(),
settings: <PortfolioReportRule['settings']>{
thresholdMax: settings['thresholdMax'],
thresholdMin: settings['thresholdMin']
}
name: rule.getName()
};
} else {
return {

4
apps/api/src/app/redis-cache/redis-cache.module.ts

@ -19,11 +19,11 @@ import { RedisCacheService } from './redis-cache.service';
configurationService.get('REDIS_PASSWORD')
);
return <RedisClientOptions>{
return {
store: redisStore,
ttl: configurationService.get('CACHE_TTL'),
url: `redis://${redisPassword ? `:${redisPassword}` : ''}@${configurationService.get('REDIS_HOST')}:${configurationService.get('REDIS_PORT')}/${configurationService.get('REDIS_DB')}`
};
} as RedisClientOptions;
}
}),
ConfigurationModule

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

@ -95,7 +95,7 @@ export class SubscriptionController {
@Res() response: Response
) {
const userId = await this.subscriptionService.createSubscriptionViaStripe(
<string>request.query.checkoutSessionId
request.query.checkoutSessionId as string
);
Logger.log(
@ -113,7 +113,7 @@ export class SubscriptionController {
@Post('stripe/checkout-session')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async createCheckoutSession(
@Body() { couponId, priceId }: { couponId: string; priceId: string }
@Body() { couponId, priceId }: { couponId?: string; priceId: string }
) {
try {
return this.subscriptionService.createCheckoutSession({

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

@ -124,7 +124,9 @@ export class SubscriptionService {
let offer: SubscriptionOffer = price ? 'renewal' : 'default';
if (isBefore(createdAt, parseDate('2023-01-01'))) {
offer = 'renewal-early-bird';
offer = 'renewal-early-bird-2023';
} else if (isBefore(createdAt, parseDate('2024-01-01'))) {
offer = 'renewal-early-bird-2024';
}
return {

14
apps/api/src/app/user/update-user-setting.dto.ts

@ -1,10 +1,10 @@
import { IsCurrencyCode } from '@ghostfolio/api/validators/is-currency-code';
import { XRayRulesSettings } from '@ghostfolio/common/interfaces';
import type {
ColorScheme,
DateRange,
HoldingsViewMode,
ViewMode,
XRayRulesSettings
ViewMode
} from '@ghostfolio/common/types';
import {
@ -31,11 +31,11 @@ export class UpdateUserSettingDto {
@IsOptional()
benchmark?: string;
@IsIn(<ColorScheme[]>['DARK', 'LIGHT'])
@IsIn(['DARK', 'LIGHT'] as ColorScheme[])
@IsOptional()
colorScheme?: ColorScheme;
@IsIn(<DateRange[]>[
@IsIn([
'1d',
'1w',
'1m',
@ -51,7 +51,7 @@ export class UpdateUserSettingDto {
return format(date, 'yyyy');
}
)
])
] as DateRange[])
@IsOptional()
dateRange?: DateRange;
@ -71,7 +71,7 @@ export class UpdateUserSettingDto {
@IsOptional()
'filters.tags'?: string[];
@IsIn(<HoldingsViewMode[]>['CHART', 'TABLE'])
@IsIn(['CHART', 'TABLE'] as HoldingsViewMode[])
@IsOptional()
holdingsViewMode?: HoldingsViewMode;
@ -103,7 +103,7 @@ export class UpdateUserSettingDto {
@IsOptional()
savingsRate?: number;
@IsIn(<ViewMode[]>['DEFAULT', 'ZEN'])
@IsIn(['DEFAULT', 'ZEN'] as ViewMode[])
@IsOptional()
viewMode?: ViewMode;

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

@ -148,7 +148,7 @@ export class UserController {
const userSettings: UserSettings = merge(
{},
<UserSettings>this.request.user.Settings.settings,
this.request.user.Settings.settings as UserSettings,
data
);

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

@ -2,6 +2,14 @@ import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
import { environment } from '@ghostfolio/api/environments/environment';
import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event';
import { AccountClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/account-cluster-risk/current-investment';
import { AccountClusterRiskSingleAccount } from '@ghostfolio/api/models/rules/account-cluster-risk/single-account';
import { CurrencyClusterRiskBaseCurrencyCurrentInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/base-currency-current-investment';
import { CurrencyClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/current-investment';
import { EconomicMarketClusterRiskDevelopedMarkets } from '@ghostfolio/api/models/rules/economic-market-cluster-risk/developed-markets';
import { EconomicMarketClusterRiskEmergingMarkets } from '@ghostfolio/api/models/rules/economic-market-cluster-risk/emerging-markets';
import { EmergencyFundSetup } from '@ghostfolio/api/models/rules/emergency-fund/emergency-fund-setup';
import { FeeRatioInitialInvestment } from '@ghostfolio/api/models/rules/fees/fee-ratio-initial-investment';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
@ -108,8 +116,8 @@ export class UserService {
accounts: Account,
dateOfFirstActivity: firstActivity?.date ?? new Date(),
settings: {
...(<UserSettings>Settings.settings),
locale: (<UserSettings>Settings.settings)?.locale ?? aLocale
...(Settings.settings as UserSettings),
locale: (Settings.settings as UserSettings)?.locale ?? aLocale
}
};
}
@ -200,17 +208,47 @@ export class UserService {
(user.Settings.settings as UserSettings).viewMode = 'DEFAULT';
}
// Set default values for X-ray rules
if (!(user.Settings.settings as UserSettings).xRayRules) {
(user.Settings.settings as UserSettings).xRayRules = {
AccountClusterRiskCurrentInvestment: { isActive: true },
AccountClusterRiskSingleAccount: { isActive: true },
CurrencyClusterRiskBaseCurrencyCurrentInvestment: { isActive: true },
CurrencyClusterRiskCurrentInvestment: { isActive: true },
EmergencyFundSetup: { isActive: true },
FeeRatioInitialInvestment: { isActive: true }
};
}
(user.Settings.settings as UserSettings).xRayRules = {
AccountClusterRiskCurrentInvestment:
new AccountClusterRiskCurrentInvestment(undefined, {}).getSettings(
user.Settings.settings
),
AccountClusterRiskSingleAccount: new AccountClusterRiskSingleAccount(
undefined,
{}
).getSettings(user.Settings.settings),
EconomicMarketClusterRiskDevelopedMarkets:
new EconomicMarketClusterRiskDevelopedMarkets(
undefined,
undefined,
undefined
).getSettings(user.Settings.settings),
EconomicMarketClusterRiskEmergingMarkets:
new EconomicMarketClusterRiskEmergingMarkets(
undefined,
undefined,
undefined
).getSettings(user.Settings.settings),
CurrencyClusterRiskBaseCurrencyCurrentInvestment:
new CurrencyClusterRiskBaseCurrencyCurrentInvestment(
undefined,
undefined
).getSettings(user.Settings.settings),
CurrencyClusterRiskCurrentInvestment:
new CurrencyClusterRiskCurrentInvestment(
undefined,
undefined
).getSettings(user.Settings.settings),
EmergencyFundSetup: new EmergencyFundSetup(
undefined,
undefined
).getSettings(user.Settings.settings),
FeeRatioInitialInvestment: new FeeRatioInitialInvestment(
undefined,
undefined,
undefined
).getSettings(user.Settings.settings)
};
let currentPermissions = getPermissions(user.role);

24
apps/api/src/assets/sitemap.xml

@ -56,10 +56,22 @@
<loc>https://ghostfol.io/de/ressourcen</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/lexikon</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/maerkte</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/ratgeber</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ueber-uns</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -214,6 +226,18 @@
<loc>https://ghostfol.io/en/resources</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/glossary</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/guides</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/markets</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>

2
apps/api/src/helper/object.helper.ts

@ -40,7 +40,7 @@ export function redactAttributes({
object: any;
options: { attribute: string; valueMap: { [key: string]: any } }[];
}): any {
if (!object || !options || !options.length) {
if (!object || !options?.length) {
return object;
}

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

@ -19,8 +19,6 @@ import { map } from 'rxjs/operators';
export class RedactValuesInResponseInterceptor<T>
implements NestInterceptor<T, any>
{
public constructor() {}
public intercept(
context: ExecutionContext,
next: CallHandler<T>

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

@ -21,7 +21,7 @@ export class TransformDataSourceInResponseInterceptor<T>
) {}
public intercept(
context: ExecutionContext,
_context: ExecutionContext,
next: CallHandler<T>
): Observable<any> {
return next.handle().pipe(

10
apps/api/src/models/rule.ts

@ -1,7 +1,11 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { groupBy } from '@ghostfolio/common/helper';
import { PortfolioPosition, UserSettings } from '@ghostfolio/common/interfaces';
import {
PortfolioPosition,
PortfolioReportRule,
UserSettings
} from '@ghostfolio/common/interfaces';
import { Big } from 'big.js';
@ -65,5 +69,9 @@ export abstract class Rule<T extends RuleSettings> implements RuleInterface<T> {
public abstract evaluate(aRuleSettings: T): EvaluationResult;
public abstract getConfiguration(): Partial<
PortfolioReportRule['configuration']
>;
public abstract getSettings(aUserSettings: UserSettings): T;
}

16
apps/api/src/models/rules/account-cluster-risk/current-investment.ts

@ -76,11 +76,23 @@ export class AccountClusterRiskCurrentInvestment extends Rule<Settings> {
};
}
public getConfiguration() {
return {
threshold: {
max: 1,
min: 0,
step: 0.01,
unit: '%'
},
thresholdMax: true
};
}
public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings {
return {
baseCurrency,
isActive: xRayRules[this.getKey()].isActive,
thresholdMax: xRayRules[this.getKey()]?.thresholdMax ?? 0.5
isActive: xRayRules?.[this.getKey()].isActive ?? true,
thresholdMax: xRayRules?.[this.getKey()]?.thresholdMax ?? 0.5
};
}
}

6
apps/api/src/models/rules/account-cluster-risk/single-account.ts

@ -34,9 +34,13 @@ export class AccountClusterRiskSingleAccount extends Rule<RuleSettings> {
};
}
public getConfiguration() {
return undefined;
}
public getSettings({ xRayRules }: UserSettings): RuleSettings {
return {
isActive: xRayRules[this.getKey()].isActive
isActive: xRayRules?.[this.getKey()].isActive ?? true
};
}
}

6
apps/api/src/models/rules/currency-cluster-risk/base-currency-current-investment.ts

@ -61,10 +61,14 @@ export class CurrencyClusterRiskBaseCurrencyCurrentInvestment extends Rule<Setti
};
}
public getConfiguration() {
return undefined;
}
public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings {
return {
baseCurrency,
isActive: xRayRules[this.getKey()].isActive
isActive: xRayRules?.[this.getKey()].isActive ?? true
};
}
}

16
apps/api/src/models/rules/currency-cluster-risk/current-investment.ts

@ -61,11 +61,23 @@ export class CurrencyClusterRiskCurrentInvestment extends Rule<Settings> {
};
}
public getConfiguration() {
return {
threshold: {
max: 1,
min: 0,
step: 0.01,
unit: '%'
},
thresholdMax: true
};
}
public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings {
return {
baseCurrency,
isActive: xRayRules[this.getKey()].isActive,
thresholdMax: xRayRules[this.getKey()]?.thresholdMax ?? 0.5
isActive: xRayRules?.[this.getKey()].isActive ?? true,
thresholdMax: xRayRules?.[this.getKey()]?.thresholdMax ?? 0.5
};
}
}

84
apps/api/src/models/rules/economic-market-cluster-risk/developed-markets.ts

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

84
apps/api/src/models/rules/economic-market-cluster-risk/emerging-markets.ts

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

6
apps/api/src/models/rules/emergency-fund/emergency-fund-setup.ts

@ -32,10 +32,14 @@ export class EmergencyFundSetup extends Rule<Settings> {
};
}
public getConfiguration() {
return undefined;
}
public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings {
return {
baseCurrency,
isActive: xRayRules[this.getKey()].isActive
isActive: xRayRules?.[this.getKey()].isActive ?? true
};
}
}

16
apps/api/src/models/rules/fees/fee-ratio-initial-investment.ts

@ -43,11 +43,23 @@ export class FeeRatioInitialInvestment extends Rule<Settings> {
};
}
public getConfiguration() {
return {
threshold: {
max: 0.1,
min: 0,
step: 0.0025,
unit: '%'
},
thresholdMax: true
};
}
public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings {
return {
baseCurrency,
isActive: xRayRules[this.getKey()].isActive,
thresholdMax: xRayRules[this.getKey()]?.thresholdMax ?? 0.01
isActive: xRayRules?.[this.getKey()].isActive ?? true,
thresholdMax: xRayRules?.[this.getKey()]?.thresholdMax ?? 0.01
};
}
}

18
apps/api/src/services/api/api.service.ts

@ -4,8 +4,6 @@ import { Injectable } from '@nestjs/common';
@Injectable()
export class ApiService {
public constructor() {}
public buildFiltersFromQueryParams({
filterByAccounts,
filterByAssetClasses,
@ -36,28 +34,28 @@ export class ApiService {
const filters = [
...accountIds.map((accountId) => {
return <Filter>{
return {
id: accountId,
type: 'ACCOUNT'
};
} as Filter;
}),
...assetClasses.map((assetClass) => {
return <Filter>{
return {
id: assetClass,
type: 'ASSET_CLASS'
};
} as Filter;
}),
...assetSubClasses.map((assetClass) => {
return <Filter>{
return {
id: assetClass,
type: 'ASSET_SUB_CLASS'
};
} as Filter;
}),
...tagIds.map((tagId) => {
return <Filter>{
return {
id: tagId,
type: 'TAG'
};
} as Filter;
})
];

18
apps/api/src/services/configuration/configuration.service.ts

@ -1,9 +1,9 @@
import { Environment } from '@ghostfolio/api/services/interfaces/environment.interface';
import {
CACHE_TTL_NO_CACHE,
DEFAULT_PROCESSOR_CONCURRENCY_GATHER_ASSET_PROFILE,
DEFAULT_PROCESSOR_CONCURRENCY_GATHER_HISTORICAL_MARKET_DATA,
DEFAULT_PROCESSOR_CONCURRENCY_PORTFOLIO_SNAPSHOT,
DEFAULT_PROCESSOR_GATHER_ASSET_PROFILE_CONCURRENCY,
DEFAULT_PROCESSOR_GATHER_HISTORICAL_MARKET_DATA_CONCURRENCY,
DEFAULT_PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_CONCURRENCY,
DEFAULT_PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_TIMEOUT,
DEFAULT_ROOT_URL
} from '@ghostfolio/common/config';
@ -51,14 +51,14 @@ export class ConfigurationService {
MAX_ACTIVITIES_TO_IMPORT: num({ default: Number.MAX_SAFE_INTEGER }),
MAX_CHART_ITEMS: num({ default: 365 }),
PORT: port({ default: 3333 }),
PROCESSOR_CONCURRENCY_GATHER_ASSET_PROFILE: num({
default: DEFAULT_PROCESSOR_CONCURRENCY_GATHER_ASSET_PROFILE
PROCESSOR_GATHER_ASSET_PROFILE_CONCURRENCY: num({
default: DEFAULT_PROCESSOR_GATHER_ASSET_PROFILE_CONCURRENCY
}),
PROCESSOR_CONCURRENCY_GATHER_HISTORICAL_MARKET_DATA: num({
default: DEFAULT_PROCESSOR_CONCURRENCY_GATHER_HISTORICAL_MARKET_DATA
PROCESSOR_GATHER_HISTORICAL_MARKET_DATA_CONCURRENCY: num({
default: DEFAULT_PROCESSOR_GATHER_HISTORICAL_MARKET_DATA_CONCURRENCY
}),
PROCESSOR_CONCURRENCY_PORTFOLIO_SNAPSHOT: num({
default: DEFAULT_PROCESSOR_CONCURRENCY_PORTFOLIO_SNAPSHOT
PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_CONCURRENCY: num({
default: DEFAULT_PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_CONCURRENCY
}),
PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_TIMEOUT: num({
default: DEFAULT_PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_TIMEOUT

2
apps/api/src/services/cryptocurrency/cryptocurrency.service.ts

@ -7,8 +7,6 @@ const customCryptocurrencies = require('../../assets/cryptocurrencies/custom.jso
export class CryptocurrencyService {
private combinedCryptocurrencies: string[];
public constructor() {}
public isCryptocurrency(aSymbol = '') {
const cryptocurrencySymbol = aSymbol.substring(0, aSymbol.length - 3);
return this.getCryptocurrencies().includes(cryptocurrencySymbol);

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

@ -15,7 +15,6 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
private static countriesMapping = {
'Russian Federation': 'Russia'
};
private static holdingsWeightTreshold = 0.85;
private static sectorsMapping = {
'Consumer Discretionary': 'Consumer Cyclical',
'Consumer Defensive': 'Consumer Staples',
@ -36,7 +35,12 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
response: Partial<SymbolProfile>;
symbol: string;
}): Promise<Partial<SymbolProfile>> {
if (!(response.assetSubClass === 'ETF')) {
if (
!(
response.assetClass === 'EQUITY' &&
['ETF', 'MUTUALFUND'].includes(response.assetSubClass)
)
) {
return response;
}

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

@ -1,4 +1,3 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service';
import { YahooFinanceDataEnhancerService } from './yahoo-finance.service';
@ -26,16 +25,13 @@ jest.mock(
);
describe('YahooFinanceDataEnhancerService', () => {
let configurationService: ConfigurationService;
let cryptocurrencyService: CryptocurrencyService;
let yahooFinanceDataEnhancerService: YahooFinanceDataEnhancerService;
beforeAll(async () => {
configurationService = new ConfigurationService();
cryptocurrencyService = new CryptocurrencyService();
yahooFinanceDataEnhancerService = new YahooFinanceDataEnhancerService(
configurationService,
cryptocurrencyService
);
});

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

@ -1,4 +1,3 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service';
import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface';
import {
@ -24,7 +23,6 @@ import type { Price } from 'yahoo-finance2/dist/esm/src/modules/quoteSummary-ifa
@Injectable()
export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
public constructor(
private readonly configurationService: ConfigurationService,
private readonly cryptocurrencyService: CryptocurrencyService
) {}

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

@ -163,10 +163,10 @@ export class EodHistoricalDataService implements DataProviderInterface {
).json<any>();
return response.reduce(
(result, { close, date }) => {
if (isNumber(close)) {
(result, { adjusted_close, date }) => {
if (isNumber(adjusted_close)) {
result[this.convertFromEodSymbol(symbol)][date] = {
marketPrice: close
marketPrice: adjusted_close
};
} else {
Logger.error(
@ -500,6 +500,10 @@ export class EodHistoricalDataService implements DataProviderInterface {
assetClass = AssetClass.EQUITY;
assetSubClass = AssetSubClass.ETF;
break;
case 'fund':
assetClass = AssetClass.EQUITY;
assetSubClass = AssetSubClass.MUTUALFUND;
break;
}
return { assetClass, assetSubClass };

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

@ -76,7 +76,7 @@ export class GoogleSheetsService implements DataProviderInterface {
} = {};
rows
.filter((row, index) => {
.filter((_row, index) => {
return index >= 1;
})
.forEach((row) => {

4
apps/api/src/services/interfaces/environment.interface.ts

@ -30,6 +30,10 @@ export interface Environment extends CleanedEnvAccessors {
MAX_ACTIVITIES_TO_IMPORT: number;
MAX_CHART_ITEMS: number;
PORT: number;
PROCESSOR_GATHER_ASSET_PROFILE_CONCURRENCY: number;
PROCESSOR_GATHER_HISTORICAL_MARKET_DATA_CONCURRENCY: number;
PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_CONCURRENCY: number;
PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_TIMEOUT: number;
REDIS_DB: number;
REDIS_HOST: string;
REDIS_PASSWORD: string;

23
apps/api/src/services/market-data/market-data.service.ts

@ -1,6 +1,5 @@
import { UpdateMarketDataDto } from '@ghostfolio/api/app/admin/update-market-data.dto';
import { DateQuery } from '@ghostfolio/api/app/portfolio/interfaces/date-query.interface';
import { DateQueryHelper } from '@ghostfolio/api/helper/dateQueryHelper';
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { resetHours } from '@ghostfolio/common/helper';
@ -21,8 +20,6 @@ export class MarketDataService {
lock = new AwaitLock();
private dateQueryHelper = new DateQueryHelper();
public async deleteMany({ dataSource, symbol }: AssetProfileIdentifier) {
return this.prismaService.marketData.deleteMany({
where: {
@ -149,21 +146,21 @@ export class MarketDataService {
async ({ dataSource, date, marketPrice, symbol, state }) => {
return this.prismaService.marketData.upsert({
create: {
dataSource: <DataSource>dataSource,
date: <Date>date,
marketPrice: <number>marketPrice,
state: <MarketDataState>state,
symbol: <string>symbol
dataSource: dataSource as DataSource,
date: date as Date,
marketPrice: marketPrice as number,
state: state as MarketDataState,
symbol: symbol as string
},
update: {
marketPrice: <number>marketPrice,
state: <MarketDataState>state
marketPrice: marketPrice as number,
state: state as MarketDataState
},
where: {
dataSource_date_symbol: {
dataSource: <DataSource>dataSource,
date: <Date>date,
symbol: <string>symbol
dataSource: dataSource as DataSource,
date: date as Date,
symbol: symbol as string
}
}
});

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

@ -6,8 +6,8 @@ import {
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import {
DATA_GATHERING_QUEUE,
DEFAULT_PROCESSOR_CONCURRENCY_GATHER_ASSET_PROFILE,
DEFAULT_PROCESSOR_CONCURRENCY_GATHER_HISTORICAL_MARKET_DATA,
DEFAULT_PROCESSOR_GATHER_ASSET_PROFILE_CONCURRENCY,
DEFAULT_PROCESSOR_GATHER_HISTORICAL_MARKET_DATA_CONCURRENCY,
GATHER_ASSET_PROFILE_PROCESS,
GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME,
GATHER_MISSING_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME
@ -45,8 +45,8 @@ export class DataGatheringProcessor {
@Process({
concurrency: parseInt(
process.env.PROCESSOR_CONCURRENCY_GATHER_ASSET_PROFILE ??
DEFAULT_PROCESSOR_CONCURRENCY_GATHER_ASSET_PROFILE.toString(),
process.env.PROCESSOR_GATHER_ASSET_PROFILE_CONCURRENCY ??
DEFAULT_PROCESSOR_GATHER_ASSET_PROFILE_CONCURRENCY.toString(),
10
),
name: GATHER_ASSET_PROFILE_PROCESS
@ -76,8 +76,8 @@ export class DataGatheringProcessor {
@Process({
concurrency: parseInt(
process.env.PROCESSOR_CONCURRENCY_GATHER_HISTORICAL_MARKET_DATA ??
DEFAULT_PROCESSOR_CONCURRENCY_GATHER_HISTORICAL_MARKET_DATA.toString(),
process.env.PROCESSOR_GATHER_HISTORICAL_MARKET_DATA_CONCURRENCY ??
DEFAULT_PROCESSOR_GATHER_HISTORICAL_MARKET_DATA_CONCURRENCY.toString(),
10
),
name: GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME
@ -85,7 +85,7 @@ export class DataGatheringProcessor {
public async gatherHistoricalMarketData(job: Job<IDataGatheringItem>) {
try {
const { dataSource, date, symbol } = job.data;
let currentDate = parseISO(<string>(<unknown>date));
let currentDate = parseISO(date as unknown as string);
Logger.log(
`Historical market data gathering has been started for ${symbol} (${dataSource}) at ${format(
@ -160,7 +160,7 @@ export class DataGatheringProcessor {
@Process({
concurrency: parseInt(
process.env.PROCESSOR_CONCURRENCY_GATHER_HISTORICAL_MARKET_DATA ??
DEFAULT_PROCESSOR_CONCURRENCY_GATHER_HISTORICAL_MARKET_DATA.toString(),
DEFAULT_PROCESSOR_GATHER_HISTORICAL_MARKET_DATA_CONCURRENCY.toString(),
10
),
name: GATHER_MISSING_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME

4
apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.module.ts

@ -10,7 +10,7 @@ import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-da
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import {
DEFAULT_PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_TIMEOUT,
PORTFOLIO_SNAPSHOT_QUEUE
PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE
} from '@ghostfolio/common/config';
import { BullModule } from '@nestjs/bull';
@ -23,7 +23,7 @@ import { PortfolioSnapshotProcessor } from './portfolio-snapshot.processor';
imports: [
AccountBalanceModule,
BullModule.registerQueue({
name: PORTFOLIO_SNAPSHOT_QUEUE,
name: PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE,
settings: {
lockDuration: parseInt(
process.env.PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_TIMEOUT ??

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

@ -9,9 +9,9 @@ import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.s
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import {
CACHE_TTL_INFINITE,
DEFAULT_PROCESSOR_CONCURRENCY_PORTFOLIO_SNAPSHOT,
DEFAULT_PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_CONCURRENCY,
PORTFOLIO_SNAPSHOT_PROCESS_JOB_NAME,
PORTFOLIO_SNAPSHOT_QUEUE
PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE
} from '@ghostfolio/common/config';
import { Process, Processor } from '@nestjs/bull';
@ -22,7 +22,7 @@ import { addMilliseconds } from 'date-fns';
import { IPortfolioSnapshotQueueJob } from './interfaces/portfolio-snapshot-queue-job.interface';
@Injectable()
@Processor(PORTFOLIO_SNAPSHOT_QUEUE)
@Processor(PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE)
export class PortfolioSnapshotProcessor {
public constructor(
private readonly accountBalanceService: AccountBalanceService,
@ -34,8 +34,8 @@ export class PortfolioSnapshotProcessor {
@Process({
concurrency: parseInt(
process.env.PROCESSOR_CONCURRENCY_PORTFOLIO_SNAPSHOT ??
DEFAULT_PROCESSOR_CONCURRENCY_PORTFOLIO_SNAPSHOT.toString(),
process.env.PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_CONCURRENCY ??
DEFAULT_PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_CONCURRENCY.toString(),
10
),
name: PORTFOLIO_SNAPSHOT_PROCESS_JOB_NAME
@ -94,10 +94,10 @@ export class PortfolioSnapshotProcessor {
filters: job.data.filters,
userId: job.data.userId
}),
JSON.stringify(<PortfolioSnapshotValue>(<unknown>{
JSON.stringify({
expiration: expiration.getTime(),
portfolioSnapshot: snapshot
})),
} as unknown as PortfolioSnapshotValue),
CACHE_TTL_INFINITE
);

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

@ -1,4 +1,4 @@
import { PORTFOLIO_SNAPSHOT_QUEUE } from '@ghostfolio/common/config';
import { PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE } from '@ghostfolio/common/config';
import { InjectQueue } from '@nestjs/bull';
import { Injectable } from '@nestjs/common';
@ -9,7 +9,7 @@ import { IPortfolioSnapshotQueueJob } from './interfaces/portfolio-snapshot-queu
@Injectable()
export class PortfolioSnapshotService {
public constructor(
@InjectQueue(PORTFOLIO_SNAPSHOT_QUEUE)
@InjectQueue(PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE)
private readonly portfolioSnapshotQueue: Queue
) {}

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

@ -188,7 +188,7 @@ export class SymbolProfileService {
countries: this.getCountries(
symbolProfile?.countries as unknown as Prisma.JsonArray
),
dateOfFirstActivity: <Date>undefined,
dateOfFirstActivity: undefined as Date,
holdings: this.getHoldings(symbolProfile),
scraperConfiguration: this.getScraperConfiguration(symbolProfile),
sectors: this.getSectors(symbolProfile),

3
apps/api/tsconfig.app.json

@ -4,7 +4,8 @@
"outDir": "../../dist/out-tsc",
"types": ["node"],
"emitDecoratorMetadata": true,
"target": "es2021"
"target": "es2021",
"module": "commonjs"
},
"exclude": ["**/*.spec.ts", "**/*.test.ts", "jest.config.ts"],
"include": ["**/*.ts"]

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

@ -46,7 +46,7 @@
</main>
@if (showFooter) {
<footer class="d-flex justify-content-center py-4 w-100">
<footer class="justify-content-center overflow-hidden py-4 w-100">
<div class="container">
<div class="mb-3 row">
<div class="col-sm">
@ -187,7 +187,7 @@
</ul>
</div>
</div>
<div class="row text-center">
<div class="mb-2 row text-center">
<div class="col">
© 2021 - {{ currentYear }}
<a href="https://ghostfol.io">Ghostfolio</a>
@ -195,12 +195,17 @@
</div>
<div class="row text-center text-muted">
<div class="col">
<small i18n
<small class="d-block" i18n
>The risk of loss in trading can be substantial. It is not advisable
to invest money you may need in the short term.</small
>
</div>
</div>
</div>
<div class="container d-none d-md-block mt-5">
<div class="row justify-content-center">
<div class="font-weight-bold line-height-1 logotype">Ghostfolio</div>
</div>
</div>
</footer>
}

7
apps/client/src/app/app.component.scss

@ -35,6 +35,13 @@
footer {
background-color: rgba(var(--palette-foreground-text), 0.05);
font-size: 90%;
.logotype {
font-size: 13vw;
letter-spacing: -0.03em;
margin-bottom: -5svw;
opacity: 0.05;
}
}
header {

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

@ -183,6 +183,7 @@ export class AppComponent implements OnDestroy, OnInit {
this.hasTabs =
(this.currentRoute === this.routerLinkAbout[0].slice(1) ||
this.currentRoute === this.routerLinkFaq[0].slice(1) ||
this.currentRoute === this.routerLinkResources[0].slice(1) ||
this.currentRoute === 'account' ||
this.currentRoute === 'admin' ||
this.currentRoute === 'home' ||
@ -198,7 +199,6 @@ export class AppComponent implements OnDestroy, OnInit {
this.currentRoute === 'p' ||
this.currentRoute === this.routerLinkPricing[0].slice(1) ||
this.currentRoute === this.routerLinkRegister[0].slice(1) ||
this.currentRoute === this.routerLinkResources[0].slice(1) ||
this.currentRoute === 'start') &&
this.deviceType !== 'mobile';
@ -292,7 +292,7 @@ export class AppComponent implements OnDestroy, OnInit {
const dialogRef = this.dialog.open(GfHoldingDetailDialogComponent, {
autoFocus: false,
data: <HoldingDetailDialogParams>{
data: {
dataSource,
symbol,
baseCurrency: this.user?.settings?.baseCurrency,
@ -312,9 +312,8 @@ export class AppComponent implements OnDestroy, OnInit {
hasPermission(this.user?.permissions, permissions.updateOrder) &&
!this.user?.settings?.isRestrictedView,
locale: this.user?.settings?.locale
},
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
maxWidth: this.deviceType === 'mobile' ? '95vw' : '50rem',
} as HoldingDetailDialogParams,
height: this.deviceType === 'mobile' ? '98vh' : '80vh',
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
});

5
apps/client/src/app/components/access-table/access-table.component.ts

@ -10,7 +10,6 @@ import {
EventEmitter,
Input,
OnChanges,
OnInit,
Output
} from '@angular/core';
import { MatTableDataSource } from '@angular/material/table';
@ -21,7 +20,7 @@ import { MatTableDataSource } from '@angular/material/table';
templateUrl: './access-table.component.html',
styleUrls: ['./access-table.component.scss']
})
export class AccessTableComponent implements OnChanges, OnInit {
export class AccessTableComponent implements OnChanges {
@Input() accesses: Access[];
@Input() showActions: boolean;
@Input() user: User;
@ -37,8 +36,6 @@ export class AccessTableComponent implements OnChanges, OnInit {
private notificationService: NotificationService
) {}
public ngOnInit() {}
public ngOnChanges() {
this.displayedColumns = ['alias', 'grantee', 'type', 'details'];

8
apps/client/src/app/components/accounts-table/accounts-table.component.ts

@ -9,7 +9,6 @@ import {
Input,
OnChanges,
OnDestroy,
OnInit,
Output,
ViewChild
} from '@angular/core';
@ -26,7 +25,7 @@ import { Subject, Subscription } from 'rxjs';
templateUrl: './accounts-table.component.html',
styleUrls: ['./accounts-table.component.scss']
})
export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit {
export class AccountsTableComponent implements OnChanges, OnDestroy {
@Input() accounts: AccountModel[];
@Input() baseCurrency: string;
@Input() deviceType: string;
@ -48,8 +47,7 @@ export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit {
@ViewChild(MatSort) sort: MatSort;
public dataSource: MatTableDataSource<AccountModel> =
new MatTableDataSource();
public dataSource = new MatTableDataSource<AccountModel>();
public displayedColumns = [];
public isLoading = true;
public routeQueryParams: Subscription;
@ -61,8 +59,6 @@ export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit {
private router: Router
) {}
public ngOnInit() {}
public ngOnChanges() {
this.displayedColumns = ['status', 'account', 'platform'];

4
apps/client/src/app/components/admin-jobs/admin-jobs.component.ts

@ -35,10 +35,10 @@ export class AdminJobsComponent implements OnDestroy, OnInit {
DATA_GATHERING_QUEUE_PRIORITY_HIGH;
public DATA_GATHERING_QUEUE_PRIORITY_MEDIUM =
DATA_GATHERING_QUEUE_PRIORITY_MEDIUM;
public dataSource = new MatTableDataSource<AdminJobs['jobs'][0]>();
public defaultDateTimeFormat: string;
public filterForm: FormGroup;
public dataSource: MatTableDataSource<AdminJobs['jobs'][0]> =
new MatTableDataSource();
public displayedColumns = [
'index',
'type',

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

@ -12,7 +12,6 @@ import {
EventEmitter,
Input,
OnChanges,
OnInit,
Output
} from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
@ -42,7 +41,7 @@ import { MarketDataDetailDialog } from './market-data-detail-dialog/market-data-
styleUrls: ['./admin-market-data-detail.component.scss'],
templateUrl: './admin-market-data-detail.component.html'
})
export class AdminMarketDataDetailComponent implements OnChanges, OnInit {
export class AdminMarketDataDetailComponent implements OnChanges {
@Input() currency: string;
@Input() dataSource: DataSource;
@Input() dateOfFirstActivity: string;
@ -81,8 +80,6 @@ export class AdminMarketDataDetailComponent implements OnChanges, OnInit {
});
}
public ngOnInit() {}
public ngOnChanges() {
this.defaultDateFormat = getDateFormatString(this.locale);
@ -181,15 +178,15 @@ export class AdminMarketDataDetailComponent implements OnChanges, OnInit {
const marketPrice = this.marketDataByMonth[yearMonth]?.[day]?.marketPrice;
const dialogRef = this.dialog.open(MarketDataDetailDialog, {
data: <MarketDataDetailDialogParams>{
data: {
marketPrice,
currency: this.currency,
dataSource: this.dataSource,
dateString: `${yearMonth}-${day}`,
symbol: this.symbol,
user: this.user
},
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
} as MarketDataDetailDialogParams,
height: this.deviceType === 'mobile' ? '98vh' : '80vh',
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
});

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

@ -71,36 +71,35 @@ export class AdminMarketDataComponent
return {
id: assetSubClass.toString(),
label: translate(assetSubClass),
type: <Filter['type']>'ASSET_SUB_CLASS'
type: 'ASSET_SUB_CLASS' as Filter['type']
};
})
.concat([
{
id: 'BENCHMARKS',
label: $localize`Benchmarks`,
type: <Filter['type']>'PRESET_ID'
type: 'PRESET_ID' as Filter['type']
},
{
id: 'CURRENCIES',
label: $localize`Currencies`,
type: <Filter['type']>'PRESET_ID'
type: 'PRESET_ID' as Filter['type']
},
{
id: 'ETF_WITHOUT_COUNTRIES',
label: $localize`ETFs without Countries`,
type: <Filter['type']>'PRESET_ID'
type: 'PRESET_ID' as Filter['type']
},
{
id: 'ETF_WITHOUT_SECTORS',
label: $localize`ETFs without Sectors`,
type: <Filter['type']>'PRESET_ID'
type: 'PRESET_ID' as Filter['type']
}
]);
public benchmarks: Partial<SymbolProfile>[];
public currentDataSource: DataSource;
public currentSymbol: string;
public dataSource: MatTableDataSource<AdminMarketDataItem> =
new MatTableDataSource();
public dataSource = new MatTableDataSource<AdminMarketDataItem>();
public defaultDateFormat: string;
public deviceType: string;
public displayedColumns: string[] = [];
@ -275,7 +274,7 @@ export class AdminMarketDataComponent
this.adminService
.gatherProfileData()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {});
.subscribe();
}
public onGatherProfileDataBySymbol({
@ -285,14 +284,14 @@ export class AdminMarketDataComponent
this.adminService
.gatherProfileDataBySymbol({ dataSource, symbol })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {});
.subscribe();
}
public onGatherSymbol({ dataSource, symbol }: AssetProfileIdentifier) {
this.adminService
.gatherSymbol({ dataSource, symbol })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {});
.subscribe();
}
public onOpenAssetProfileDialog({
@ -386,14 +385,14 @@ export class AdminMarketDataComponent
const dialogRef = this.dialog.open(AssetProfileDialog, {
autoFocus: false,
data: <AssetProfileDialogParams>{
data: {
dataSource,
symbol,
colorScheme: this.user?.settings.colorScheme,
deviceType: this.deviceType,
locale: this.user?.settings?.locale
},
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
} as AssetProfileDialogParams,
height: this.deviceType === 'mobile' ? '98vh' : '80vh',
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
});
@ -415,10 +414,10 @@ export class AdminMarketDataComponent
const dialogRef = this.dialog.open(CreateAssetProfileDialog, {
autoFocus: false,
data: <CreateAssetProfileDialogParams>{
data: {
deviceType: this.deviceType,
locale: this.user?.settings?.locale
},
} as CreateAssetProfileDialogParams,
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
});

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

@ -59,10 +59,9 @@ export class AdminMarketDataService {
}),
finalize(() => {
window.location.reload();
setTimeout(() => {}, 300);
})
)
.subscribe(() => {});
.subscribe();
},
confirmType: ConfirmationDialogType.Warn,
title: $localize`Do you really want to delete these profiles?`

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

@ -213,14 +213,14 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
this.adminService
.gatherProfileDataBySymbol({ dataSource, symbol })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {});
.subscribe();
}
public onGatherSymbol({ dataSource, symbol }: AssetProfileIdentifier) {
this.adminService
.gatherSymbol({ dataSource, symbol })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {});
.subscribe();
}
public onGatherSymbolMissingOnly({
@ -230,7 +230,7 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
this.adminService
.gatherSymbolMissingOnly({ dataSource, symbol })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {});
.subscribe();
}
public onImportHistoricalData() {

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

@ -225,10 +225,10 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
$localize`Please set your system message:`,
JSON.stringify(
this.systemMessage ??
<SystemMessage>{
({
message: '⚒️ Scheduled maintenance in progress...',
targetGroups: ['Basic', 'Premium']
}
} as SystemMessage)
)
);

6
apps/client/src/app/components/admin-platform/admin-platform.component.ts

@ -34,7 +34,7 @@ import { CreateOrUpdatePlatformDialog } from './create-or-update-platform-dialog
export class AdminPlatformComponent implements OnInit, OnDestroy {
@ViewChild(MatSort) sort: MatSort;
public dataSource: MatTableDataSource<Platform> = new MatTableDataSource();
public dataSource = new MatTableDataSource<Platform>();
public deviceType: string;
public displayedColumns = ['name', 'url', 'accounts', 'actions'];
public platforms: Platform[];
@ -139,7 +139,7 @@ export class AdminPlatformComponent implements OnInit, OnDestroy {
url: null
}
},
height: this.deviceType === 'mobile' ? '97.5vh' : undefined,
height: this.deviceType === 'mobile' ? '98vh' : undefined,
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
});
@ -176,7 +176,7 @@ export class AdminPlatformComponent implements OnInit, OnDestroy {
url
}
},
height: this.deviceType === 'mobile' ? '97.5vh' : undefined,
height: this.deviceType === 'mobile' ? '98vh' : undefined,
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
});

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

@ -1,4 +1,39 @@
<div class="container">
<div class="d-md-block d-none mb-5 row">
<div class="col">
<h2 class="text-center" i18n>Data Providers</h2>
<mat-card appearance="outlined">
<mat-card-content>
<div class="align-items-center d-flex my-3">
<div class="w-50">
<a
class="align-items-center d-inline-flex"
target="_blank"
[href]="pricingUrl"
>
<span class="badge badge-warning mr-1" i18n>NEW</span>
Ghostfolio Premium
<gf-premium-indicator
class="d-inline-block ml-1"
[enableLink]="false"
/>
</a>
</div>
<div class="w-50">
<button
color="accent"
mat-flat-button
(click)="onSetGhostfolioApiKey()"
>
<ion-icon class="mr-1" name="key-outline" />
<span i18n>Set API Key</span>
</button>
</div>
</div>
</mat-card-content>
</mat-card>
</div>
</div>
<div class="mb-5 row">
<div class="col">
<h2 class="text-center" i18n>Platforms</h2>

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

@ -1,10 +1,18 @@
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { User } from '@ghostfolio/common/interfaces';
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
OnDestroy,
OnInit
} from '@angular/core';
import { Subject } from 'rxjs';
import { MatDialog } from '@angular/material/dialog';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject, takeUntil } from 'rxjs';
import { GfGhostfolioPremiumApiDialogComponent } from './ghostfolio-premium-api-dialog/ghostfolio-premium-api-dialog.component';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
@ -12,12 +20,49 @@ import { Subject } from 'rxjs';
styleUrls: ['./admin-settings.component.scss'],
templateUrl: './admin-settings.component.html'
})
export class AdminSettingsComponent implements OnInit, OnDestroy {
export class AdminSettingsComponent implements OnDestroy, OnInit {
public pricingUrl: string;
private deviceType: string;
private unsubscribeSubject = new Subject<void>();
private user: User;
public constructor(
private changeDetectorRef: ChangeDetectorRef,
private deviceService: DeviceDetectorService,
private matDialog: MatDialog,
private userService: UserService
) {}
public ngOnInit() {
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
public constructor() {}
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((state) => {
if (state?.user) {
this.user = state.user;
public ngOnInit() {}
this.pricingUrl =
`https://ghostfol.io/${this.user.settings.language}/` +
$localize`:snake-case:pricing`;
this.changeDetectorRef.markForCheck();
}
});
}
public onSetGhostfolioApiKey() {
this.matDialog.open(GfGhostfolioPremiumApiDialogComponent, {
autoFocus: false,
data: {
deviceType: this.deviceType,
pricingUrl: this.pricingUrl
},
height: this.deviceType === 'mobile' ? '98vh' : undefined,
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
});
}
public ngOnDestroy() {
this.unsubscribeSubject.next();

6
apps/client/src/app/components/admin-settings/admin-settings.module.ts

@ -1,8 +1,11 @@
import { GfAdminPlatformModule } from '@ghostfolio/client/components/admin-platform/admin-platform.module';
import { GfAdminTagModule } from '@ghostfolio/client/components/admin-tag/admin-tag.module';
import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator';
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { RouterModule } from '@angular/router';
import { AdminSettingsComponent } from './admin-settings.component';
@ -13,6 +16,9 @@ import { AdminSettingsComponent } from './admin-settings.component';
CommonModule,
GfAdminPlatformModule,
GfAdminTagModule,
GfPremiumIndicatorComponent,
MatButtonModule,
MatCardModule,
RouterModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]

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

@ -0,0 +1,39 @@
import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator';
import { CommonModule } from '@angular/common';
import { Component, Inject } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import {
MAT_DIALOG_DATA,
MatDialogModule,
MatDialogRef
} from '@angular/material/dialog';
import { GfDialogFooterModule } from '../../dialog-footer/dialog-footer.module';
import { GfDialogHeaderModule } from '../../dialog-header/dialog-header.module';
import { GhostfolioPremiumApiDialogParams } from './interfaces/interfaces';
@Component({
imports: [
CommonModule,
GfDialogFooterModule,
GfDialogHeaderModule,
GfPremiumIndicatorComponent,
MatButtonModule,
MatDialogModule
],
selector: 'gf-ghostfolio-premium-api-dialog',
standalone: true,
styleUrls: ['./ghostfolio-premium-api-dialog.scss'],
templateUrl: './ghostfolio-premium-api-dialog.html'
})
export class GfGhostfolioPremiumApiDialogComponent {
public constructor(
@Inject(MAT_DIALOG_DATA) public data: GhostfolioPremiumApiDialogParams,
public dialogRef: MatDialogRef<GfGhostfolioPremiumApiDialogComponent>
) {}
public onCancel() {
this.dialogRef.close();
}
}

42
apps/client/src/app/components/admin-settings/ghostfolio-premium-api-dialog/ghostfolio-premium-api-dialog.html

@ -0,0 +1,42 @@
<gf-dialog-header
mat-dialog-title
position="center"
title="Ghostfolio Premium Data Provider"
[deviceType]="data.deviceType"
(closeButtonClicked)="onCancel()"
/>
<div class="text-center" mat-dialog-content>
<p class="gf-text-wrap-balance mb-1">
The official
<a
class="align-items-center d-inline-flex"
target="_blank"
[href]="data.pricingUrl"
>Ghostfolio Premium
<gf-premium-indicator class="d-inline-block ml-1" [enableLink]="false" />
</a>
data provider <strong>for self-hosters</strong>, offering
<strong>100’000+ tickers</strong> from over <strong>50 exchanges</strong>,
is coming soon!
</p>
<p i18n>
Want to stay updated? Click below to get notified as soon as it’s available.
</p>
<div>
<a
color="primary"
href="mailto:hi@ghostfol.io?Subject=Ghostfolio Premium Data Provider&body=Hello%0D%0DPlease notify me as soon as the Ghostfolio Premium Data Provider is available.%0D%0DKind regards"
i18n
mat-flat-button
>
Notify me
</a>
</div>
</div>
<gf-dialog-footer
mat-dialog-actions
[deviceType]="data.deviceType"
(closeButtonClicked)="onCancel()"
/>

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

@ -0,0 +1,2 @@
:host {
}

4
apps/client/src/app/components/admin-settings/ghostfolio-premium-api-dialog/interfaces/interfaces.ts

@ -0,0 +1,4 @@
export interface GhostfolioPremiumApiDialogParams {
deviceType: string;
pricingUrl: string;
}

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

@ -34,7 +34,7 @@ import { CreateOrUpdateTagDialog } from './create-or-update-tag-dialog/create-or
export class AdminTagComponent implements OnInit, OnDestroy {
@ViewChild(MatSort) sort: MatSort;
public dataSource: MatTableDataSource<Tag> = new MatTableDataSource();
public dataSource = new MatTableDataSource<Tag>();
public deviceType: string;
public displayedColumns = [
'name',
@ -144,7 +144,7 @@ export class AdminTagComponent implements OnInit, OnDestroy {
name: null
}
},
height: this.deviceType === 'mobile' ? '97.5vh' : undefined,
height: this.deviceType === 'mobile' ? '98vh' : undefined,
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
});
@ -180,7 +180,7 @@ export class AdminTagComponent implements OnInit, OnDestroy {
name
}
},
height: this.deviceType === 'mobile' ? '97.5vh' : undefined,
height: this.deviceType === 'mobile' ? '98vh' : undefined,
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
});

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

@ -24,8 +24,7 @@ import { takeUntil } from 'rxjs/operators';
templateUrl: './admin-users.html'
})
export class AdminUsersComponent implements OnDestroy, OnInit {
public dataSource: MatTableDataSource<AdminUsers['users'][0]> =
new MatTableDataSource();
public dataSource = new MatTableDataSource<AdminUsers['users'][0]>();
public defaultDateFormat: string;
public displayedColumns: string[] = [];
public getEmojiFlag = getEmojiFlag;

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

@ -26,8 +26,6 @@ export class GfAssetProfileIconComponent implements OnChanges {
public src: string;
public constructor() {}
public ngOnChanges() {
if (this.dataSource && this.symbol) {
this.src = `../api/v1/logo/${this.dataSource}/${this.symbol}`;

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

@ -29,12 +29,13 @@ import { SymbolProfile } from '@prisma/client';
import {
Chart,
ChartData,
LinearScale,
LineController,
LineElement,
LinearScale,
PointElement,
TimeScale,
Tooltip
Tooltip,
TooltipPosition
} from 'chart.js';
import 'chartjs-adapter-date-fns';
import annotationPlugin from 'chartjs-plugin-annotation';
@ -50,7 +51,6 @@ export class BenchmarkComparatorComponent implements OnChanges, OnDestroy {
@Input() benchmarkDataItems: LineChartItem[] = [];
@Input() benchmarks: Partial<SymbolProfile>[];
@Input() colorScheme: ColorScheme;
@Input() daysInMarket: number;
@Input() isLoading: boolean;
@Input() locale = getLocale();
@Input() performanceDataItems: LineChartItem[];
@ -75,7 +75,7 @@ export class BenchmarkComparatorComponent implements OnChanges, OnDestroy {
Tooltip
);
Tooltip.positioners['top'] = (elements, position) =>
Tooltip.positioners['top'] = (_elements, position: TooltipPosition) =>
getTooltipPositionerMapTop(this.chart, position);
}
@ -102,7 +102,7 @@ export class BenchmarkComparatorComponent implements OnChanges, OnDestroy {
}
private initialize() {
const benchmarkDataValues: { [date: string]: number } = {};
const benchmarkDataValues: Record<string, number> = {};
for (const { date, value } of this.benchmarkDataItems) {
benchmarkDataValues[date] = value;
@ -147,9 +147,8 @@ export class BenchmarkComparatorComponent implements OnChanges, OnDestroy {
if (this.chartCanvas) {
if (this.chart) {
this.chart.data = data;
this.chart.options.plugins.tooltip = <unknown>(
this.getTooltipPluginConfiguration()
);
this.chart.options.plugins.tooltip =
this.getTooltipPluginConfiguration() as unknown;
this.chart.update();
} else {
this.chart = new Chart(this.chartCanvas.nativeElement, {
@ -168,7 +167,7 @@ export class BenchmarkComparatorComponent implements OnChanges, OnDestroy {
},
interaction: { intersect: false, mode: 'index' },
maintainAspectRatio: true,
plugins: <unknown>{
plugins: {
annotation: {
annotations: {
yAxis: {
@ -187,7 +186,7 @@ export class BenchmarkComparatorComponent implements OnChanges, OnDestroy {
verticalHoverLine: {
color: `rgba(${getTextColor(this.colorScheme)}, 0.1)`
}
},
} as unknown,
responsive: true,
scales: {
x: {
@ -252,7 +251,7 @@ export class BenchmarkComparatorComponent implements OnChanges, OnDestroy {
unit: '%'
}),
mode: 'index',
position: <unknown>'top',
position: 'top' as unknown,
xAlign: 'center',
yAlign: 'bottom'
};

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

@ -3,7 +3,6 @@ import {
Component,
EventEmitter,
Input,
OnInit,
Output
} from '@angular/core';
@ -14,15 +13,11 @@ import {
templateUrl: './dialog-footer.component.html',
styleUrls: ['./dialog-footer.component.scss']
})
export class DialogFooterComponent implements OnInit {
export class DialogFooterComponent {
@Input() deviceType: string;
@Output() closeButtonClicked = new EventEmitter<void>();
public constructor() {}
public ngOnInit() {}
public onClickCloseButton() {
this.closeButtonClicked.emit();
}

7
apps/client/src/app/components/dialog-header/dialog-header.component.ts

@ -3,7 +3,6 @@ import {
Component,
EventEmitter,
Input,
OnInit,
Output
} from '@angular/core';
@ -14,17 +13,13 @@ import {
templateUrl: './dialog-header.component.html',
styleUrls: ['./dialog-header.component.scss']
})
export class DialogHeaderComponent implements OnInit {
export class DialogHeaderComponent {
@Input() deviceType: string;
@Input() position: 'center' | 'left' = 'left';
@Input() title: string;
@Output() closeButtonClicked = new EventEmitter<void>();
public constructor() {}
public ngOnInit() {}
public onClickCloseButton() {
this.closeButtonClicked.emit();
}

9
apps/client/src/app/components/fear-and-greed-index/fear-and-greed-index.component.ts

@ -5,8 +5,7 @@ import {
ChangeDetectionStrategy,
Component,
Input,
OnChanges,
OnInit
OnChanges
} from '@angular/core';
@Component({
@ -15,16 +14,12 @@ import {
templateUrl: './fear-and-greed-index.component.html',
styleUrls: ['./fear-and-greed-index.component.scss']
})
export class FearAndGreedIndexComponent implements OnChanges, OnInit {
export class FearAndGreedIndexComponent implements OnChanges {
@Input() fearAndGreedIndex: number;
public fearAndGreedIndexEmoji: string;
public fearAndGreedIndexText: string;
public constructor() {}
public ngOnInit() {}
public ngOnChanges() {
const { emoji, key } = resolveFearAndGreedIndex(this.fearAndGreedIndex);

3
apps/client/src/app/components/header/header.component.html

@ -180,7 +180,8 @@
<ng-container i18n>Upgrade Plan</ng-container>
} @else if (
user.subscription.offer === 'renewal' ||
user.subscription.offer === 'renewal-early-bird'
user.subscription.offer === 'renewal-early-bird-2023' ||
user.subscription.offer === 'renewal-early-bird-2024'
) {
<ng-container i18n>Renew Plan</ng-container>
}

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

@ -150,7 +150,7 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
public ngOnInit() {
this.activityForm = this.formBuilder.group({
tags: <string[]>[]
tags: [] as string[]
});
const filters: Filter[] = [

7
apps/client/src/app/components/home-holdings/home-holdings.component.ts

@ -4,14 +4,11 @@ import { UserService } from '@ghostfolio/client/services/user/user.service';
import {
AssetProfileIdentifier,
PortfolioPosition,
ToggleOption,
User
} from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import {
HoldingType,
HoldingsViewMode,
ToggleOption
} from '@ghostfolio/common/types';
import { HoldingType, HoldingsViewMode } from '@ghostfolio/common/types';
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { FormControl } from '@angular/forms';

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

@ -29,7 +29,6 @@ export class HomeMarketComponent implements OnDestroy, OnInit {
public hasPermissionToAccessFearAndGreedIndex: boolean;
public historicalDataItems: HistoricalDataItem[];
public info: InfoItem;
public isLoading = true;
public readonly numberOfDays = 365;
public user: User;
@ -43,7 +42,6 @@ export class HomeMarketComponent implements OnDestroy, OnInit {
) {
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
this.info = this.dataService.fetchInfo();
this.isLoading = true;
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
@ -89,7 +87,6 @@ export class HomeMarketComponent implements OnDestroy, OnInit {
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ benchmarks }) => {
this.benchmarks = benchmarks;
this.isLoading = false;
this.changeDetectorRef.markForCheck();
});

10
apps/client/src/app/components/home-market/home-market.html

@ -36,16 +36,6 @@
[locale]="user?.settings?.locale || undefined"
[user]="user"
/>
@if (isLoading) {
<ngx-skeleton-loader
animation="pulse"
class="px-2 py-3"
[theme]="{
height: '1.5rem',
width: '100%'
}"
/>
}
</div>
</div>
</div>

4
apps/client/src/app/components/home-market/home-market.module.ts

@ -4,7 +4,6 @@ import { GfLineChartComponent } from '@ghostfolio/ui/line-chart';
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { HomeMarketComponent } from './home-market.component';
@ -15,8 +14,7 @@ import { HomeMarketComponent } from './home-market.component';
CommonModule,
GfBenchmarkComponent,
GfFearAndGreedIndexModule,
GfLineChartComponent,
NgxSkeletonLoaderModule
GfLineChartComponent
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})

35
apps/client/src/app/components/investment-chart/investment-chart.component.ts

@ -29,17 +29,17 @@ import {
BarElement,
Chart,
ChartData,
LinearScale,
LineController,
LineElement,
LinearScale,
PointElement,
TimeScale,
Tooltip
Tooltip,
TooltipPosition
} from 'chart.js';
import 'chartjs-adapter-date-fns';
import annotationPlugin from 'chartjs-plugin-annotation';
import { isAfter, isValid, min, subDays } from 'date-fns';
import { first } from 'lodash';
import { isAfter } from 'date-fns';
@Component({
selector: 'gf-investment-chart',
@ -52,7 +52,6 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
@Input() benchmarkDataLabel = '';
@Input() colorScheme: ColorScheme;
@Input() currency: string;
@Input() daysInMarket: number;
@Input() groupBy: GroupBy;
@Input() historicalDataItems: LineChartItem[] = [];
@Input() isInPercent = false;
@ -79,7 +78,7 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
Tooltip
);
Tooltip.positioners['top'] = (elements, position) =>
Tooltip.positioners['top'] = (_elements, position: TooltipPosition) =>
getTooltipPositionerMapTop(this.chart, position);
}
@ -153,23 +152,10 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
};
if (this.chartCanvas) {
let scaleXMin: string;
if (this.daysInMarket) {
const minDate = min([
parseDate(first(this.investments)?.date),
subDays(new Date().setHours(0, 0, 0, 0), this.daysInMarket)
]);
scaleXMin = isValid(minDate) ? minDate.toISOString() : undefined;
}
if (this.chart) {
this.chart.data = chartData;
this.chart.options.plugins.tooltip = <unknown>(
this.getTooltipPluginConfiguration()
);
this.chart.options.scales.x.min = scaleXMin;
this.chart.options.plugins.tooltip =
this.getTooltipPluginConfiguration() as unknown;
if (
this.savingsRate &&
@ -199,7 +185,7 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
},
interaction: { intersect: false, mode: 'index' },
maintainAspectRatio: true,
plugins: <unknown>{
plugins: {
annotation: {
annotations: {
savingsRate: this.savingsRate
@ -240,7 +226,7 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
verticalHoverLine: {
color: `rgba(${getTextColor(this.colorScheme)}, 0.1)`
}
},
} as unknown,
responsive: true,
scales: {
x: {
@ -252,7 +238,6 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
grid: {
display: false
},
min: scaleXMin,
type: 'time',
time: {
tooltipFormat: getDateFormatString(this.locale),
@ -308,7 +293,7 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
unit: this.isInPercent ? '%' : undefined
}),
mode: 'index',
position: <unknown>'top',
position: 'top' as unknown,
xAlign: 'center',
yAlign: 'bottom'
};

2
apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.component.ts

@ -28,8 +28,6 @@ export class LoginWithAccessTokenDialog {
private tokenStorageService: TokenStorageService
) {}
ngOnInit() {}
public onChangeStaySignedIn(aValue: MatCheckboxChange) {
this.settingsStorageService.setSetting(
KEY_STAY_SIGNED_IN,

7
apps/client/src/app/components/portfolio-summary/portfolio-summary.component.ts

@ -8,7 +8,6 @@ import {
EventEmitter,
Input,
OnChanges,
OnInit,
Output
} from '@angular/core';
import { formatDistanceToNow } from 'date-fns';
@ -19,7 +18,7 @@ import { formatDistanceToNow } from 'date-fns';
templateUrl: './portfolio-summary.component.html',
styleUrls: ['./portfolio-summary.component.scss']
})
export class PortfolioSummaryComponent implements OnChanges, OnInit {
export class PortfolioSummaryComponent implements OnChanges {
@Input() baseCurrency: string;
@Input() hasPermissionToUpdateUserSettings: boolean;
@Input() isLoading: boolean;
@ -35,10 +34,6 @@ export class PortfolioSummaryComponent implements OnChanges, OnInit {
);
public timeInMarket: string;
public constructor() {}
public ngOnInit() {}
public ngOnChanges() {
if (this.summary) {
if (this.summary.firstOrderDate) {

6
apps/client/src/app/components/rule/rule-settings-dialog/interfaces/interfaces.ts

@ -1,5 +1,9 @@
import { PortfolioReportRule } from '@ghostfolio/common/interfaces';
import {
PortfolioReportRule,
XRayRulesSettings
} from '@ghostfolio/common/interfaces';
export interface IRuleSettingsDialogParams {
rule: PortfolioReportRule;
settings: XRayRulesSettings['AccountClusterRiskCurrentInvestment'];
}

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

@ -1,4 +1,4 @@
import { PortfolioReportRule } from '@ghostfolio/common/interfaces';
import { XRayRulesSettings } from '@ghostfolio/common/interfaces';
import { CommonModule } from '@angular/common';
import { Component, Inject } from '@angular/core';
@ -9,8 +9,7 @@ import {
MatDialogModule,
MatDialogRef
} from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatSliderModule } from '@angular/material/slider';
import { IRuleSettingsDialogParams } from './interfaces/interfaces';
@ -20,8 +19,7 @@ import { IRuleSettingsDialogParams } from './interfaces/interfaces';
FormsModule,
MatButtonModule,
MatDialogModule,
MatFormFieldModule,
MatInputModule
MatSliderModule
],
selector: 'gf-rule-settings-dialog',
standalone: true,
@ -29,12 +27,10 @@ import { IRuleSettingsDialogParams } from './interfaces/interfaces';
templateUrl: './rule-settings-dialog.html'
})
export class GfRuleSettingsDialogComponent {
public settings: PortfolioReportRule['settings'];
public settings: XRayRulesSettings['AccountClusterRiskCurrentInvestment'];
public constructor(
@Inject(MAT_DIALOG_DATA) public data: IRuleSettingsDialogParams,
public dialogRef: MatDialogRef<GfRuleSettingsDialogComponent>
) {
this.settings = this.data.rule.settings;
}
) {}
}

90
apps/client/src/app/components/rule/rule-settings-dialog/rule-settings-dialog.html

@ -1,37 +1,85 @@
<div mat-dialog-title>{{ data.rule.name }}</div>
<div class="py-3" mat-dialog-content>
<mat-form-field
appearance="outline"
<div
class="w-100"
[ngClass]="{ 'd-none': settings.thresholdMin === undefined }"
[ngClass]="{ 'd-none': !data.rule.configuration.thresholdMin }"
>
<mat-label i18n>Threshold Min</mat-label>
<input
matInput
<h6 class="mb-0">
<ng-container i18n>Threshold Min</ng-container>:
@if (data.rule.configuration.threshold.unit === '%') {
{{ data.settings.thresholdMin | percent: '1.2-2' }}
} @else {
{{ data.settings.thresholdMin }}
}
</h6>
@if (data.rule.configuration.threshold.unit === '%') {
<label>{{
data.rule.configuration.threshold.min | percent: '1.2-2'
}}</label>
} @else {
<label>{{ data.rule.configuration.threshold.min }}</label>
}
<mat-slider
name="thresholdMin"
type="number"
[(ngModel)]="settings.thresholdMin"
/>
</mat-form-field>
<mat-form-field
appearance="outline"
[max]="data.rule.configuration.threshold.max"
[min]="data.rule.configuration.threshold.min"
[step]="data.rule.configuration.threshold.step"
>
<input matSliderThumb [(ngModel)]="data.settings.thresholdMin" />
</mat-slider>
@if (data.rule.configuration.threshold.unit === '%') {
<label>{{
data.rule.configuration.threshold.max | percent: '1.2-2'
}}</label>
} @else {
<label>{{ data.rule.configuration.threshold.max }}</label>
}
</div>
<div
class="w-100"
[ngClass]="{ 'd-none': settings.thresholdMax === undefined }"
[ngClass]="{ 'd-none': !data.rule.configuration.thresholdMax }"
>
<mat-label i18n>Threshold Max</mat-label>
<input
matInput
<h6 class="mb-0">
<ng-container i18n>Threshold Max</ng-container>:
@if (data.rule.configuration.threshold.unit === '%') {
{{ data.settings.thresholdMax | percent: '1.2-2' }}
} @else {
{{ data.settings.thresholdMax }}
}
</h6>
@if (data.rule.configuration.threshold.unit === '%') {
<label>{{
data.rule.configuration.threshold.min | percent: '1.2-2'
}}</label>
} @else {
<label>{{ data.rule.configuration.threshold.min }}</label>
}
<mat-slider
name="thresholdMax"
type="number"
[(ngModel)]="settings.thresholdMax"
/>
</mat-form-field>
[max]="data.rule.configuration.threshold.max"
[min]="data.rule.configuration.threshold.min"
[step]="data.rule.configuration.threshold.step"
>
<input matSliderThumb [(ngModel)]="data.settings.thresholdMax" />
</mat-slider>
@if (data.rule.configuration.threshold.unit === '%') {
<label>{{
data.rule.configuration.threshold.max | percent: '1.2-2'
}}</label>
} @else {
<label>{{ data.rule.configuration.threshold.max }}</label>
}
</div>
</div>
<div align="end" mat-dialog-actions>
<button i18n mat-button (click)="dialogRef.close()">Close</button>
<button color="primary" mat-flat-button (click)="dialogRef.close(settings)">
<button
color="primary"
mat-flat-button
(click)="dialogRef.close(data.settings)"
>
<ng-container i18n>Save</ng-container>
</button>
</div>

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

@ -62,7 +62,7 @@
<ion-icon name="ellipsis-horizontal" />
</button>
<mat-menu #rulesMenu="matMenu" xPosition="before">
@if (rule?.isActive && !isEmpty(rule.settings)) {
@if (rule?.isActive && rule?.configuration) {
<button mat-menu-item (click)="onCustomizeRule(rule)">
<ng-container i18n>Customize</ng-container>...
</button>

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

@ -1,5 +1,9 @@
import { UpdateUserSettingDto } from '@ghostfolio/api/app/user/update-user-setting.dto';
import { PortfolioReportRule } from '@ghostfolio/common/interfaces';
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import {
PortfolioReportRule,
XRayRulesSettings
} from '@ghostfolio/common/interfaces';
import {
ChangeDetectionStrategy,
@ -10,7 +14,6 @@ import {
Output
} from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { isEmpty } from 'lodash';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject, takeUntil } from 'rxjs';
@ -27,11 +30,10 @@ export class RuleComponent implements OnInit {
@Input() hasPermissionToUpdateUserSettings: boolean;
@Input() isLoading: boolean;
@Input() rule: PortfolioReportRule;
@Input() settings: XRayRulesSettings['AccountClusterRiskCurrentInvestment'];
@Output() ruleUpdated = new EventEmitter<UpdateUserSettingDto>();
public isEmpty = isEmpty;
private deviceType: string;
private unsubscribeSubject = new Subject<void>();
@ -46,16 +48,17 @@ export class RuleComponent implements OnInit {
public onCustomizeRule(rule: PortfolioReportRule) {
const dialogRef = this.dialog.open(GfRuleSettingsDialogComponent, {
data: <IRuleSettingsDialogParams>{
rule
},
data: {
rule,
settings: this.settings
} as IRuleSettingsDialogParams,
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
});
dialogRef
.afterClosed()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((settings: PortfolioReportRule['settings']) => {
.subscribe((settings: RuleSettings) => {
if (settings) {
this.ruleUpdated.emit({
xRayRules: {

1
apps/client/src/app/components/rules/rules.component.html

@ -12,6 +12,7 @@
hasPermissionToUpdateUserSettings
"
[rule]="rule"
[settings]="settings?.[rule.key]"
(ruleUpdated)="onRuleUpdated($event)"
/>
}

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

Loading…
Cancel
Save