diff --git a/CHANGELOG.md b/CHANGELOG.md
index 44e7e088b..d4aaf1573 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,11 +9,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
+- Added a new static portfolio analysis rule: _Asset Class Cluster Risk_ (Equity)
+- Added a new static portfolio analysis rule: _Asset Class Cluster Risk_ (Fixed Income)
- Set up a notification service for prompt dialogs
### Changed
- Improved the usability to edit the emergency fund
+- Extracted the market data management from the admin control panel endpoint to a dedicated endpoint
+- Upgraded `big.js` from version `6.2.1` to `6.2.2`
+
+## 2.129.0 - 2024-12-14
+
+### Added
+
+- Added `userId` to the `SymbolProfile` database schema
+
+### Changed
+
+- Improved the usability of the _X-ray_ page by hiding empty rule categories
+- Improved the language localization for German (`de`)
## 2.128.0 - 2024-12-12
diff --git a/apps/api/src/app/admin/admin.controller.ts b/apps/api/src/app/admin/admin.controller.ts
index 4cc4d467e..a761bbbae 100644
--- a/apps/api/src/app/admin/admin.controller.ts
+++ b/apps/api/src/app/admin/admin.controller.ts
@@ -214,6 +214,9 @@ export class AdminController {
});
}
+ /**
+ * @deprecated
+ */
@Get('market-data/:dataSource/:symbol')
@HasPermission(permissions.accessAdminControl)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@@ -250,6 +253,9 @@ export class AdminController {
}
}
+ /**
+ * @deprecated
+ */
@HasPermission(permissions.accessAdminControl)
@Post('market-data/:dataSource/:symbol')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
diff --git a/apps/api/src/app/app.module.ts b/apps/api/src/app/app.module.ts
index b1a240235..7ac2c5915 100644
--- a/apps/api/src/app/app.module.ts
+++ b/apps/api/src/app/app.module.ts
@@ -33,6 +33,7 @@ import { BenchmarkModule } from './benchmark/benchmark.module';
import { CacheModule } from './cache/cache.module';
import { ApiKeysModule } from './endpoints/api-keys/api-keys.module';
import { GhostfolioModule } from './endpoints/data-providers/ghostfolio/ghostfolio.module';
+import { MarketDataModule } from './endpoints/market-data/market-data.module';
import { PublicModule } from './endpoints/public/public.module';
import { ExchangeRateModule } from './exchange-rate/exchange-rate.module';
import { ExportModule } from './export/export.module';
@@ -84,6 +85,7 @@ import { UserModule } from './user/user.module';
ImportModule,
InfoModule,
LogoModule,
+ MarketDataModule,
OrderModule,
PlatformModule,
PortfolioModule,
diff --git a/apps/api/src/app/endpoints/market-data/market-data.controller.ts b/apps/api/src/app/endpoints/market-data/market-data.controller.ts
new file mode 100644
index 000000000..b4aef807a
--- /dev/null
+++ b/apps/api/src/app/endpoints/market-data/market-data.controller.ts
@@ -0,0 +1,136 @@
+import { AdminService } from '@ghostfolio/api/app/admin/admin.service';
+import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
+import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
+import { MarketDataDetailsResponse } from '@ghostfolio/common/interfaces';
+import { hasPermission, permissions } from '@ghostfolio/common/permissions';
+import { RequestWithUser } from '@ghostfolio/common/types';
+
+import {
+ Body,
+ Controller,
+ Get,
+ HttpException,
+ Inject,
+ Param,
+ Post,
+ UseGuards
+} from '@nestjs/common';
+import { REQUEST } from '@nestjs/core';
+import { AuthGuard } from '@nestjs/passport';
+import { DataSource, Prisma } from '@prisma/client';
+import { parseISO } from 'date-fns';
+import { getReasonPhrase, StatusCodes } from 'http-status-codes';
+
+import { UpdateBulkMarketDataDto } from './update-bulk-market-data.dto';
+
+@Controller('market-data')
+export class MarketDataController {
+ public constructor(
+ private readonly adminService: AdminService,
+ private readonly marketDataService: MarketDataService,
+ @Inject(REQUEST) private readonly request: RequestWithUser,
+ private readonly symbolProfileService: SymbolProfileService
+ ) {}
+
+ @Get(':dataSource/:symbol')
+ @UseGuards(AuthGuard('jwt'))
+ public async getMarketDataBySymbol(
+ @Param('dataSource') dataSource: DataSource,
+ @Param('symbol') symbol: string
+ ): Promise {
+ const [assetProfile] = await this.symbolProfileService.getSymbolProfiles([
+ { dataSource, symbol }
+ ]);
+
+ if (!assetProfile) {
+ throw new HttpException(
+ getReasonPhrase(StatusCodes.NOT_FOUND),
+ StatusCodes.NOT_FOUND
+ );
+ }
+
+ const canReadAllAssetProfiles = hasPermission(
+ this.request.user.permissions,
+ permissions.readMarketData
+ );
+
+ const canReadOwnAssetProfile =
+ assetProfile.userId === this.request.user.id &&
+ hasPermission(
+ this.request.user.permissions,
+ permissions.readMarketDataOfOwnAssetProfile
+ );
+
+ if (!canReadAllAssetProfiles && !canReadOwnAssetProfile) {
+ throw new HttpException(
+ assetProfile.userId
+ ? getReasonPhrase(StatusCodes.NOT_FOUND)
+ : getReasonPhrase(StatusCodes.FORBIDDEN),
+ assetProfile.userId ? StatusCodes.NOT_FOUND : StatusCodes.FORBIDDEN
+ );
+ }
+
+ return this.adminService.getMarketDataBySymbol({ dataSource, symbol });
+ }
+
+ @Post(':dataSource/:symbol')
+ @UseGuards(AuthGuard('jwt'))
+ public async updateMarketData(
+ @Body() data: UpdateBulkMarketDataDto,
+ @Param('dataSource') dataSource: DataSource,
+ @Param('symbol') symbol: string
+ ) {
+ const [assetProfile] = await this.symbolProfileService.getSymbolProfiles([
+ { dataSource, symbol }
+ ]);
+
+ if (!assetProfile) {
+ throw new HttpException(
+ getReasonPhrase(StatusCodes.NOT_FOUND),
+ StatusCodes.NOT_FOUND
+ );
+ }
+
+ const canUpsertAllAssetProfiles =
+ hasPermission(
+ this.request.user.permissions,
+ permissions.createMarketData
+ ) &&
+ hasPermission(
+ this.request.user.permissions,
+ permissions.updateMarketData
+ );
+
+ const canUpsertOwnAssetProfile =
+ assetProfile.userId === this.request.user.id &&
+ hasPermission(
+ this.request.user.permissions,
+ permissions.createMarketDataOfOwnAssetProfile
+ ) &&
+ hasPermission(
+ this.request.user.permissions,
+ permissions.updateMarketDataOfOwnAssetProfile
+ );
+
+ if (!canUpsertAllAssetProfiles && !canUpsertOwnAssetProfile) {
+ throw new HttpException(
+ getReasonPhrase(StatusCodes.FORBIDDEN),
+ StatusCodes.FORBIDDEN
+ );
+ }
+
+ const dataBulkUpdate: Prisma.MarketDataUpdateInput[] = data.marketData.map(
+ ({ date, marketPrice }) => ({
+ dataSource,
+ marketPrice,
+ symbol,
+ date: parseISO(date),
+ state: 'CLOSE'
+ })
+ );
+
+ return this.marketDataService.updateMany({
+ data: dataBulkUpdate
+ });
+ }
+}
diff --git a/apps/api/src/app/endpoints/market-data/market-data.module.ts b/apps/api/src/app/endpoints/market-data/market-data.module.ts
new file mode 100644
index 000000000..2050889fd
--- /dev/null
+++ b/apps/api/src/app/endpoints/market-data/market-data.module.ts
@@ -0,0 +1,13 @@
+import { AdminModule } from '@ghostfolio/api/app/admin/admin.module';
+import { MarketDataModule as MarketDataServiceModule } from '@ghostfolio/api/services/market-data/market-data.module';
+import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
+
+import { Module } from '@nestjs/common';
+
+import { MarketDataController } from './market-data.controller';
+
+@Module({
+ controllers: [MarketDataController],
+ imports: [AdminModule, MarketDataServiceModule, SymbolProfileModule]
+})
+export class MarketDataModule {}
diff --git a/apps/api/src/app/endpoints/market-data/update-bulk-market-data.dto.ts b/apps/api/src/app/endpoints/market-data/update-bulk-market-data.dto.ts
new file mode 100644
index 000000000..d07b189b2
--- /dev/null
+++ b/apps/api/src/app/endpoints/market-data/update-bulk-market-data.dto.ts
@@ -0,0 +1,24 @@
+import { Type } from 'class-transformer';
+import {
+ ArrayNotEmpty,
+ IsArray,
+ IsISO8601,
+ IsNumber,
+ IsOptional
+} from 'class-validator';
+
+export class UpdateBulkMarketDataDto {
+ @ArrayNotEmpty()
+ @IsArray()
+ @Type(() => UpdateMarketDataDto)
+ marketData: UpdateMarketDataDto[];
+}
+
+class UpdateMarketDataDto {
+ @IsISO8601()
+ @IsOptional()
+ date?: string;
+
+ @IsNumber()
+ marketPrice: number;
+}
diff --git a/apps/api/src/app/import/import.service.ts b/apps/api/src/app/import/import.service.ts
index e51696b56..3b7290b43 100644
--- a/apps/api/src/app/import/import.service.ts
+++ b/apps/api/src/app/import/import.service.ts
@@ -224,7 +224,7 @@ export class ImportService {
for (const activity of activitiesDto) {
if (!activity.dataSource) {
- if (activity.type === 'ITEM' || activity.type === 'LIABILITY') {
+ if (['FEE', 'INTEREST', 'ITEM', 'LIABILITY'].includes(activity.type)) {
activity.dataSource = DataSource.MANUAL;
} else {
activity.dataSource =
@@ -356,6 +356,7 @@ export class ImportService {
quantity,
type,
unitPrice,
+ Account: validatedAccount,
accountId: validatedAccount?.id,
accountUserId: undefined,
createdAt: new Date(),
@@ -380,10 +381,10 @@ export class ImportService {
symbolMapping,
updatedAt,
url,
+ comment: assetProfile.comment,
currency: assetProfile.currency,
- comment: assetProfile.comment
+ userId: dataSource === 'MANUAL' ? user.id : undefined
},
- Account: validatedAccount,
symbolProfileId: undefined,
updatedAt: new Date(),
userId: user.id
@@ -406,7 +407,8 @@ export class ImportService {
create: {
dataSource,
symbol,
- currency: assetProfile.currency
+ currency: assetProfile.currency,
+ userId: dataSource === 'MANUAL' ? user.id : undefined
},
where: {
dataSource_symbol: {
diff --git a/apps/api/src/app/order/order.service.ts b/apps/api/src/app/order/order.service.ts
index 5613adc5c..129f3d8a9 100644
--- a/apps/api/src/app/order/order.service.ts
+++ b/apps/api/src/app/order/order.service.ts
@@ -93,7 +93,7 @@ export class OrderService {
userId: string;
}
): Promise {
- let Account;
+ let Account: Prisma.AccountCreateNestedOneWithoutOrderInput;
if (data.accountId) {
Account = {
@@ -124,6 +124,7 @@ export class OrderService {
data.SymbolProfile.connectOrCreate.create.dataSource = dataSource;
data.SymbolProfile.connectOrCreate.create.name = name;
data.SymbolProfile.connectOrCreate.create.symbol = id;
+ data.SymbolProfile.connectOrCreate.create.userId = userId;
data.SymbolProfile.connectOrCreate.where.dataSource_symbol = {
dataSource,
symbol: id
diff --git a/apps/api/src/app/portfolio/portfolio.controller.ts b/apps/api/src/app/portfolio/portfolio.controller.ts
index b15d22268..797d0449a 100644
--- a/apps/api/src/app/portfolio/portfolio.controller.ts
+++ b/apps/api/src/app/portfolio/portfolio.controller.ts
@@ -619,10 +619,13 @@ export class PortfolioController {
this.request.user.subscription.type === 'Basic'
) {
for (const rule in report.rules) {
- if (report.rules[rule]) {
- report.rules[rule] = [];
- }
+ report.rules[rule] = null;
}
+
+ report.statistics = {
+ rulesActiveCount: 0,
+ rulesFulfilledCount: 0
+ };
}
return report;
diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts
index 8ead98a58..400b0c3a9 100644
--- a/apps/api/src/app/portfolio/portfolio.service.ts
+++ b/apps/api/src/app/portfolio/portfolio.service.ts
@@ -7,6 +7,8 @@ import { UserService } from '@ghostfolio/api/app/user/user.service';
import { getFactor } from '@ghostfolio/api/helper/portfolio.helper';
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 { AssetClassClusterRiskEquity } from '@ghostfolio/api/models/rules/asset-class-cluster-risk/equity';
+import { AssetClassClusterRiskFixedIncome } from '@ghostfolio/api/models/rules/asset-class-cluster-risk/fixed-income';
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';
@@ -1198,19 +1200,17 @@ export class PortfolioService {
userSettings
)
: undefined,
- economicMarketClusterRisk:
+ assetClassClusterRisk:
summary.ordersCount > 0
? await this.rulesService.evaluate(
[
- new EconomicMarketClusterRiskDevelopedMarkets(
+ new AssetClassClusterRiskEquity(
this.exchangeRateDataService,
- marketsTotalInBaseCurrency,
- markets.developedMarkets.valueInBaseCurrency
+ Object.values(holdings)
),
- new EconomicMarketClusterRiskEmergingMarkets(
+ new AssetClassClusterRiskFixedIncome(
this.exchangeRateDataService,
- marketsTotalInBaseCurrency,
- markets.emergingMarkets.valueInBaseCurrency
+ Object.values(holdings)
)
],
userSettings
@@ -1232,6 +1232,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,
emergencyFund: await this.rulesService.evaluate(
[
new EmergencyFundSetup(
diff --git a/apps/api/src/app/user/user.service.ts b/apps/api/src/app/user/user.service.ts
index 6676a00b6..bac6ed19b 100644
--- a/apps/api/src/app/user/user.service.ts
+++ b/apps/api/src/app/user/user.service.ts
@@ -5,6 +5,8 @@ import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.
import { getRandomString } from '@ghostfolio/api/helper/string.helper';
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 { AssetClassClusterRiskEquity } from '@ghostfolio/api/models/rules/asset-class-cluster-risk/equity';
+import { AssetClassClusterRiskFixedIncome } from '@ghostfolio/api/models/rules/asset-class-cluster-risk/fixed-income';
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';
@@ -226,25 +228,33 @@ export class UserService {
undefined,
{}
).getSettings(user.Settings.settings),
- EconomicMarketClusterRiskDevelopedMarkets:
- new EconomicMarketClusterRiskDevelopedMarkets(
- undefined,
+ AssetClassClusterRiskEquity: new AssetClassClusterRiskEquity(
+ undefined,
+ undefined
+ ).getSettings(user.Settings.settings),
+ AssetClassClusterRiskFixedIncome: new AssetClassClusterRiskFixedIncome(
+ undefined,
+ undefined
+ ).getSettings(user.Settings.settings),
+ CurrencyClusterRiskBaseCurrencyCurrentInvestment:
+ new CurrencyClusterRiskBaseCurrencyCurrentInvestment(
undefined,
undefined
).getSettings(user.Settings.settings),
- EconomicMarketClusterRiskEmergingMarkets:
- new EconomicMarketClusterRiskEmergingMarkets(
- undefined,
+ CurrencyClusterRiskCurrentInvestment:
+ new CurrencyClusterRiskCurrentInvestment(
undefined,
undefined
).getSettings(user.Settings.settings),
- CurrencyClusterRiskBaseCurrencyCurrentInvestment:
- new CurrencyClusterRiskBaseCurrencyCurrentInvestment(
+ EconomicMarketClusterRiskDevelopedMarkets:
+ new EconomicMarketClusterRiskDevelopedMarkets(
+ undefined,
undefined,
undefined
).getSettings(user.Settings.settings),
- CurrencyClusterRiskCurrentInvestment:
- new CurrencyClusterRiskCurrentInvestment(
+ EconomicMarketClusterRiskEmergingMarkets:
+ new EconomicMarketClusterRiskEmergingMarkets(
+ undefined,
undefined,
undefined
).getSettings(user.Settings.settings),
diff --git a/apps/api/src/models/rules/asset-class-cluster-risk/equity.ts b/apps/api/src/models/rules/asset-class-cluster-risk/equity.ts
new file mode 100644
index 000000000..b67e01e61
--- /dev/null
+++ b/apps/api/src/models/rules/asset-class-cluster-risk/equity.ts
@@ -0,0 +1,95 @@
+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 { PortfolioPosition, UserSettings } from '@ghostfolio/common/interfaces';
+
+export class AssetClassClusterRiskEquity extends Rule {
+ private holdings: PortfolioPosition[];
+
+ public constructor(
+ protected exchangeRateDataService: ExchangeRateDataService,
+ holdings: PortfolioPosition[]
+ ) {
+ super(exchangeRateDataService, {
+ key: AssetClassClusterRiskEquity.name,
+ name: 'Equity'
+ });
+
+ this.holdings = holdings;
+ }
+
+ public evaluate(ruleSettings: Settings) {
+ const holdingsGroupedByAssetClass = this.groupCurrentHoldingsByAttribute(
+ this.holdings,
+ 'assetClass',
+ ruleSettings.baseCurrency
+ );
+ let totalValue = 0;
+
+ const equityValueInBaseCurrency =
+ holdingsGroupedByAssetClass.find(({ groupKey }) => {
+ return groupKey === 'EQUITY';
+ })?.value ?? 0;
+
+ for (const { value } of holdingsGroupedByAssetClass) {
+ totalValue += value;
+ }
+
+ const equityValueRatio = totalValue
+ ? equityValueInBaseCurrency / totalValue
+ : 0;
+
+ if (equityValueRatio > ruleSettings.thresholdMax) {
+ return {
+ evaluation: `The equity contribution of your current investment (${(equityValueRatio * 100).toPrecision(3)}%) exceeds ${(
+ ruleSettings.thresholdMax * 100
+ ).toPrecision(3)}%`,
+ value: false
+ };
+ } else if (equityValueRatio < ruleSettings.thresholdMin) {
+ return {
+ evaluation: `The equity contribution of your current investment (${(equityValueRatio * 100).toPrecision(3)}%) is below ${(
+ ruleSettings.thresholdMin * 100
+ ).toPrecision(3)}%`,
+ value: false
+ };
+ }
+
+ return {
+ evaluation: `The equity contribution of your current investment (${(equityValueRatio * 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.82,
+ thresholdMin: xRayRules?.[this.getKey()]?.thresholdMin ?? 0.78
+ };
+ }
+}
+
+interface Settings extends RuleSettings {
+ baseCurrency: string;
+ thresholdMin: number;
+ thresholdMax: number;
+}
diff --git a/apps/api/src/models/rules/asset-class-cluster-risk/fixed-income.ts b/apps/api/src/models/rules/asset-class-cluster-risk/fixed-income.ts
new file mode 100644
index 000000000..eb744a143
--- /dev/null
+++ b/apps/api/src/models/rules/asset-class-cluster-risk/fixed-income.ts
@@ -0,0 +1,95 @@
+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 { PortfolioPosition, UserSettings } from '@ghostfolio/common/interfaces';
+
+export class AssetClassClusterRiskFixedIncome extends Rule {
+ private holdings: PortfolioPosition[];
+
+ public constructor(
+ protected exchangeRateDataService: ExchangeRateDataService,
+ holdings: PortfolioPosition[]
+ ) {
+ super(exchangeRateDataService, {
+ key: AssetClassClusterRiskFixedIncome.name,
+ name: 'Fixed Income'
+ });
+
+ this.holdings = holdings;
+ }
+
+ public evaluate(ruleSettings: Settings) {
+ const holdingsGroupedByAssetClass = this.groupCurrentHoldingsByAttribute(
+ this.holdings,
+ 'assetClass',
+ ruleSettings.baseCurrency
+ );
+ let totalValue = 0;
+
+ const fixedIncomeValueInBaseCurrency =
+ holdingsGroupedByAssetClass.find(({ groupKey }) => {
+ return groupKey === 'FIXED_INCOME';
+ })?.value ?? 0;
+
+ for (const { value } of holdingsGroupedByAssetClass) {
+ totalValue += value;
+ }
+
+ const fixedIncomeValueRatio = totalValue
+ ? fixedIncomeValueInBaseCurrency / totalValue
+ : 0;
+
+ if (fixedIncomeValueRatio > ruleSettings.thresholdMax) {
+ return {
+ evaluation: `The fixed income contribution of your current investment (${(fixedIncomeValueRatio * 100).toPrecision(3)}%) exceeds ${(
+ ruleSettings.thresholdMax * 100
+ ).toPrecision(3)}%`,
+ value: false
+ };
+ } else if (fixedIncomeValueRatio < ruleSettings.thresholdMin) {
+ return {
+ evaluation: `The fixed income contribution of your current investment (${(fixedIncomeValueRatio * 100).toPrecision(3)}%) is below ${(
+ ruleSettings.thresholdMin * 100
+ ).toPrecision(3)}%`,
+ value: false
+ };
+ }
+
+ return {
+ evaluation: `The fixed income contribution of your current investment (${(fixedIncomeValueRatio * 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.22,
+ thresholdMin: xRayRules?.[this.getKey()]?.thresholdMin ?? 0.18
+ };
+ }
+}
+
+interface Settings extends RuleSettings {
+ baseCurrency: string;
+ thresholdMin: number;
+ thresholdMax: number;
+}
diff --git a/apps/api/src/models/rules/currency-cluster-risk/base-currency-current-investment.ts b/apps/api/src/models/rules/currency-cluster-risk/base-currency-current-investment.ts
index 573795799..90ee29c54 100644
--- a/apps/api/src/models/rules/currency-cluster-risk/base-currency-current-investment.ts
+++ b/apps/api/src/models/rules/currency-cluster-risk/base-currency-current-investment.ts
@@ -28,7 +28,12 @@ export class CurrencyClusterRiskBaseCurrencyCurrentInvestment extends Rule {
+ const baseCurrencyValue =
+ holdingsGroupedByCurrency.find(({ groupKey }) => {
+ return groupKey === ruleSettings.baseCurrency;
+ })?.value ?? 0;
+
+ for (const groupItem of holdingsGroupedByCurrency) {
// Calculate total value
totalValue += groupItem.value;
@@ -36,13 +41,11 @@ export class CurrencyClusterRiskBaseCurrencyCurrentInvestment extends Rule maxItem.investment) {
maxItem = groupItem;
}
- });
-
- const baseCurrencyItem = holdingsGroupedByCurrency.find((item) => {
- return item.groupKey === ruleSettings.baseCurrency;
- });
+ }
- const baseCurrencyValueRatio = baseCurrencyItem?.value / totalValue || 0;
+ const baseCurrencyValueRatio = totalValue
+ ? baseCurrencyValue / totalValue
+ : 0;
if (maxItem?.groupKey !== ruleSettings.baseCurrency) {
return {
diff --git a/apps/client/project.json b/apps/client/project.json
index dd644f8c1..7fd28d5ca 100644
--- a/apps/client/project.json
+++ b/apps/client/project.json
@@ -212,7 +212,7 @@
"extract-i18n": {
"executor": "ng-extract-i18n-merge:ng-extract-i18n-merge",
"options": {
- "browserTarget": "client:build",
+ "buildTarget": "client:build",
"includeContext": true,
"outputPath": "src/locales",
"targetFiles": [
diff --git a/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts b/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts
index 4fdc22986..a271915f3 100644
--- a/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts
+++ b/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts
@@ -121,8 +121,8 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
}
});
- this.adminService
- .fetchAdminMarketDataBySymbol({
+ this.dataService
+ .fetchMarketDataBySymbol({
dataSource: this.data.dataSource,
symbol: this.data.symbol
})
diff --git a/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts b/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts
index 5bb6ca3e5..727263718 100644
--- a/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts
+++ b/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts
@@ -38,7 +38,7 @@ export class CreateOrUpdateAccessDialog implements OnDestroy {
private notificationService: NotificationService
) {}
- ngOnInit() {
+ public ngOnInit() {
this.accessForm = this.formBuilder.group({
alias: [this.data.access.alias],
permissions: [this.data.access.permissions[0], Validators.required],
diff --git a/apps/client/src/app/core/auth.guard.ts b/apps/client/src/app/core/auth.guard.ts
index 548d36473..7a75728ca 100644
--- a/apps/client/src/app/core/auth.guard.ts
+++ b/apps/client/src/app/core/auth.guard.ts
@@ -29,7 +29,7 @@ export class AuthGuard {
`/${paths.resources}`
];
- constructor(
+ public constructor(
private dataService: DataService,
private router: Router,
private settingsStorageService: SettingsStorageService,
diff --git a/apps/client/src/app/pages/portfolio/x-ray/x-ray-page.component.html b/apps/client/src/app/pages/portfolio/x-ray/x-ray-page.component.html
index 7a0a3512c..ceba5f52c 100644
--- a/apps/client/src/app/pages/portfolio/x-ray/x-ray-page.component.html
+++ b/apps/client/src/app/pages/portfolio/x-ray/x-ray-page.component.html
@@ -19,12 +19,17 @@
/>
} @else {
{{ statistics?.rulesFulfilledCount }}
- of
+ out of
{{ statistics?.rulesActiveCount }}
- rules are currently fulfilled.
+ rules align with your portfolio.
}
-
+
Emergency Fund
@if (user?.subscription?.type === 'Basic') {
@@ -43,7 +48,12 @@
(rulesUpdated)="onRulesUpdated($event)"
/>
-
+
Currency Cluster Risks
@if (user?.subscription?.type === 'Basic') {
@@ -62,7 +72,36 @@
(rulesUpdated)="onRulesUpdated($event)"
/>
-
+
+
+ Asset Class Cluster Risks
+ @if (user?.subscription?.type === 'Basic') {
+
+ }
+
+
+
+
Account Cluster Risks
@if (user?.subscription?.type === 'Basic') {
@@ -81,7 +120,12 @@
(rulesUpdated)="onRulesUpdated($event)"
/>
-
+
Economic Market Cluster Risks
@if (user?.subscription?.type === 'Basic') {
@@ -100,7 +144,12 @@
(rulesUpdated)="onRulesUpdated($event)"
/>
-
+
Fees
@if (user?.subscription?.type === 'Basic') {
diff --git a/apps/client/src/app/pages/portfolio/x-ray/x-ray-page.component.ts b/apps/client/src/app/pages/portfolio/x-ray/x-ray-page.component.ts
index 86bc37737..b00f8b938 100644
--- a/apps/client/src/app/pages/portfolio/x-ray/x-ray-page.component.ts
+++ b/apps/client/src/app/pages/portfolio/x-ray/x-ray-page.component.ts
@@ -19,6 +19,7 @@ import { Subject, takeUntil } from 'rxjs';
})
export class XRayPageComponent {
public accountClusterRiskRules: PortfolioReportRule[];
+ public assetClassClusterRiskRules: PortfolioReportRule[];
public currencyClusterRiskRules: PortfolioReportRule[];
public economicMarketClusterRiskRules: PortfolioReportRule[];
public emergencyFundRules: PortfolioReportRule[];
@@ -102,6 +103,11 @@ export class XRayPageComponent {
return isActive;
}) ?? null;
+ this.assetClassClusterRiskRules =
+ rules['assetClassClusterRisk']?.filter(({ isActive }) => {
+ return isActive;
+ }) ?? null;
+
this.currencyClusterRiskRules =
rules['currencyClusterRisk']?.filter(({ isActive }) => {
return isActive;
@@ -134,7 +140,7 @@ export class XRayPageComponent {
let inactiveRules: PortfolioReportRule[] = [];
for (const category in rules) {
- const rulesArray = rules[category];
+ const rulesArray = rules[category] || [];
inactiveRules = inactiveRules.concat(
rulesArray.filter(({ isActive }) => {
diff --git a/apps/client/src/app/pages/webauthn/webauthn-page.component.ts b/apps/client/src/app/pages/webauthn/webauthn-page.component.ts
index 41860014a..77a053258 100644
--- a/apps/client/src/app/pages/webauthn/webauthn-page.component.ts
+++ b/apps/client/src/app/pages/webauthn/webauthn-page.component.ts
@@ -22,7 +22,7 @@ export class GfWebauthnPageComponent implements OnDestroy, OnInit {
private unsubscribeSubject = new Subject();
- constructor(
+ public constructor(
private changeDetectorRef: ChangeDetectorRef,
private router: Router,
private tokenStorageService: TokenStorageService,
diff --git a/apps/client/src/app/services/admin.service.ts b/apps/client/src/app/services/admin.service.ts
index 77d135f57..ec0605bee 100644
--- a/apps/client/src/app/services/admin.service.ts
+++ b/apps/client/src/app/services/admin.service.ts
@@ -1,5 +1,4 @@
import { UpdateAssetProfileDto } from '@ghostfolio/api/app/admin/update-asset-profile.dto';
-import { UpdateBulkMarketDataDto } from '@ghostfolio/api/app/admin/update-bulk-market-data.dto';
import { CreatePlatformDto } from '@ghostfolio/api/app/platform/create-platform.dto';
import { UpdatePlatformDto } from '@ghostfolio/api/app/platform/update-platform.dto';
import { CreateTagDto } from '@ghostfolio/api/app/tag/create-tag.dto';
@@ -17,7 +16,6 @@ import {
AdminData,
AdminJobs,
AdminMarketData,
- AdminMarketDataDetails,
AdminUsers,
DataProviderGhostfolioStatusResponse,
EnhancedSymbolProfile,
@@ -29,8 +27,8 @@ import { Injectable } from '@angular/core';
import { SortDirection } from '@angular/material/sort';
import { DataSource, MarketData, Platform, Tag } from '@prisma/client';
import { JobStatus } from 'bull';
-import { format, parseISO } from 'date-fns';
-import { Observable, map, switchMap } from 'rxjs';
+import { format } from 'date-fns';
+import { switchMap } from 'rxjs';
import { environment } from '../../environments/environment';
import { DataService } from './data.service';
@@ -125,25 +123,6 @@ export class AdminService {
});
}
- public fetchAdminMarketDataBySymbol({
- dataSource,
- symbol
- }: {
- dataSource: DataSource;
- symbol: string;
- }): Observable {
- return this.http
- .get(`/api/v1/admin/market-data/${dataSource}/${symbol}`)
- .pipe(
- map((data) => {
- for (const item of data.marketData) {
- item.date = parseISO(item.date);
- }
- return data;
- })
- );
- }
-
public fetchGhostfolioDataProviderStatus() {
return this.fetchAdminData().pipe(
switchMap(({ settings }) => {
@@ -278,20 +257,6 @@ export class AdminService {
);
}
- public postMarketData({
- dataSource,
- marketData,
- symbol
- }: {
- dataSource: DataSource;
- marketData: UpdateBulkMarketDataDto;
- symbol: string;
- }) {
- const url = `/api/v1/admin/market-data/${dataSource}/${symbol}`;
-
- return this.http.post(url, marketData);
- }
-
public postPlatform(aPlatform: CreatePlatformDto) {
return this.http.post(`/api/v1/platform`, aPlatform);
}
diff --git a/apps/client/src/app/services/data.service.ts b/apps/client/src/app/services/data.service.ts
index eef258a5c..269a03e31 100644
--- a/apps/client/src/app/services/data.service.ts
+++ b/apps/client/src/app/services/data.service.ts
@@ -3,6 +3,7 @@ import { CreateAccountBalanceDto } from '@ghostfolio/api/app/account-balance/cre
import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto';
import { TransferBalanceDto } from '@ghostfolio/api/app/account/transfer-balance.dto';
import { UpdateAccountDto } from '@ghostfolio/api/app/account/update-account.dto';
+import { UpdateBulkMarketDataDto } from '@ghostfolio/api/app/admin/update-bulk-market-data.dto';
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import {
Activities,
@@ -21,7 +22,6 @@ import {
Access,
AccountBalancesResponse,
Accounts,
- AdminMarketDataDetails,
ApiKeyResponse,
AssetProfileIdentifier,
BenchmarkMarketDataDetails,
@@ -31,6 +31,7 @@ import {
ImportResponse,
InfoItem,
LookupResponse,
+ MarketDataDetailsResponse,
OAuthResponse,
PortfolioDetails,
PortfolioDividends,
@@ -51,6 +52,7 @@ import { SortDirection } from '@angular/material/sort';
import {
AccountBalance,
DataSource,
+ MarketData,
Order as OrderModel,
Tag
} from '@prisma/client';
@@ -316,7 +318,7 @@ export class DataService {
public fetchAsset({
dataSource,
symbol
- }: AssetProfileIdentifier): Observable {
+ }: AssetProfileIdentifier): Observable {
return this.http.get(`/api/v1/asset/${dataSource}/${symbol}`).pipe(
map((data) => {
for (const item of data.marketData) {
@@ -431,6 +433,25 @@ export class DataService {
);
}
+ public fetchMarketDataBySymbol({
+ dataSource,
+ symbol
+ }: {
+ dataSource: DataSource;
+ symbol: string;
+ }): Observable {
+ return this.http
+ .get(`/api/v1/market-data/${dataSource}/${symbol}`)
+ .pipe(
+ map((data) => {
+ for (const item of data.marketData) {
+ item.date = parseISO(item.date);
+ }
+ return data;
+ })
+ );
+ }
+
public fetchSymbolItem({
dataSource,
includeHistoricalData,
@@ -665,6 +686,20 @@ export class DataService {
return this.http.post('/api/v1/benchmark', benchmark);
}
+ public postMarketData({
+ dataSource,
+ marketData,
+ symbol
+ }: {
+ dataSource: DataSource;
+ marketData: UpdateBulkMarketDataDto;
+ symbol: string;
+ }) {
+ const url = `/api/v1/market-data/${dataSource}/${symbol}`;
+
+ return this.http.post(url, marketData);
+ }
+
public postOrder(aOrder: CreateOrderDto) {
return this.http.post('/api/v1/order', aOrder);
}
diff --git a/apps/client/src/locales/messages.ca.xlf b/apps/client/src/locales/messages.ca.xlf
index d32f8c1ee..6d9fe62e6 100644
--- a/apps/client/src/locales/messages.ca.xlf
+++ b/apps/client/src/locales/messages.ca.xlf
@@ -2619,7 +2619,7 @@
apps/client/src/app/pages/portfolio/x-ray/x-ray-page.component.html
- 88
+ 105
@@ -2991,7 +2991,7 @@
apps/client/src/app/pages/portfolio/x-ray/x-ray-page.component.html
- 12
+ 29
@@ -3207,7 +3207,7 @@
libs/ui/src/lib/assistant/assistant.component.ts
- 223
+ 221
@@ -3219,7 +3219,7 @@
libs/ui/src/lib/assistant/assistant.component.ts
- 233
+ 231
@@ -3231,7 +3231,7 @@
libs/ui/src/lib/assistant/assistant.component.ts
- 237
+ 235
@@ -3243,7 +3243,7 @@
libs/ui/src/lib/assistant/assistant.component.ts
- 259
+ 257
@@ -3255,7 +3255,7 @@
libs/ui/src/lib/assistant/assistant.component.ts
- 262
+ 260
@@ -5255,7 +5255,7 @@
Currency Cluster Risks
apps/client/src/app/pages/portfolio/x-ray/x-ray-page.component.html
- 31
+ 48
@@ -5263,7 +5263,7 @@
Account Cluster Risks
apps/client/src/app/pages/portfolio/x-ray/x-ray-page.component.html
- 50
+ 67
@@ -6059,7 +6059,7 @@
Week to date
libs/ui/src/lib/assistant/assistant.component.ts
- 225
+ 223
@@ -6067,7 +6067,7 @@
WTD
libs/ui/src/lib/assistant/assistant.component.ts
- 225
+ 223
@@ -6075,7 +6075,7 @@
Month to date
libs/ui/src/lib/assistant/assistant.component.ts
- 229
+ 227
@@ -6083,7 +6083,7 @@
MTD
libs/ui/src/lib/assistant/assistant.component.ts
- 229
+ 227
@@ -6091,7 +6091,7 @@
Year to date
libs/ui/src/lib/assistant/assistant.component.ts
- 233
+ 231
@@ -6099,7 +6099,7 @@
year
libs/ui/src/lib/assistant/assistant.component.ts
- 237
+ 235
@@ -6107,7 +6107,7 @@
years
libs/ui/src/lib/assistant/assistant.component.ts
- 259
+ 257
@@ -7047,7 +7047,7 @@
Inactive
apps/client/src/app/pages/portfolio/x-ray/x-ray-page.component.html
- 107
+ 124
@@ -7457,7 +7457,7 @@
Economic Market Cluster Risks
apps/client/src/app/pages/portfolio/x-ray/x-ray-page.component.html
- 69
+ 86
@@ -7584,6 +7584,22 @@
26
+
+ out of
+ out of
+
+ apps/client/src/app/pages/portfolio/x-ray/x-ray-page.component.html
+ 22
+
+
+
+ rules align with your portfolio.
+ rules align with your portfolio.
+
+ apps/client/src/app/pages/portfolio/x-ray/x-ray-page.component.html
+ 24
+
+