Browse Source

Merge remote-tracking branch 'origin/main' into feature/allow-creating-custom-tags

pull/4308/head
KenTandrian 6 months ago
parent
commit
dee9c8f372
  1. 3
      CHANGELOG.md
  2. 12
      apps/api/src/app/portfolio/portfolio.service.ts
  3. 18
      apps/api/src/app/user/user.service.ts
  4. 9
      apps/api/src/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor.ts
  5. 77
      apps/api/src/models/rules/regional-market-cluster-risk/asia-pacific.ts
  6. 77
      apps/api/src/models/rules/regional-market-cluster-risk/japan.ts
  7. 47
      apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts
  8. 21
      apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html
  9. 2
      libs/common/src/lib/interfaces/x-ray-rules-settings.interface.ts
  10. 1
      libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor.component.html

3
CHANGELOG.md

@ -9,8 +9,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- Added a new static portfolio analysis rule: _Regional Market Cluster Risk_ (Asia-Pacific Markets)
- Added a new static portfolio analysis rule: _Regional Market Cluster Risk_ (Japan)
- Extended the tags selector component by a `readonly` attribute - Extended the tags selector component by a `readonly` attribute
- Extended the tags selector component to support creating custom tags - Extended the tags selector component to support creating custom tags
- Extended the holding detail dialog by the historical market data editor (experimental)
- Added global styles to the _Storybook_ setup - Added global styles to the _Storybook_ setup
### Changed ### Changed

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

@ -15,8 +15,10 @@ import { EconomicMarketClusterRiskDevelopedMarkets } from '@ghostfolio/api/model
import { EconomicMarketClusterRiskEmergingMarkets } from '@ghostfolio/api/models/rules/economic-market-cluster-risk/emerging-markets'; import { EconomicMarketClusterRiskEmergingMarkets } from '@ghostfolio/api/models/rules/economic-market-cluster-risk/emerging-markets';
import { EmergencyFundSetup } from '@ghostfolio/api/models/rules/emergency-fund/emergency-fund-setup'; import { EmergencyFundSetup } from '@ghostfolio/api/models/rules/emergency-fund/emergency-fund-setup';
import { FeeRatioInitialInvestment } from '@ghostfolio/api/models/rules/fees/fee-ratio-initial-investment'; import { FeeRatioInitialInvestment } from '@ghostfolio/api/models/rules/fees/fee-ratio-initial-investment';
import { RegionalMarketClusterRiskAsiaPacific } from '@ghostfolio/api/models/rules/regional-market-cluster-risk/asia-pacific';
import { RegionalMarketClusterRiskEmergingMarkets } from '@ghostfolio/api/models/rules/regional-market-cluster-risk/emerging-markets'; import { RegionalMarketClusterRiskEmergingMarkets } from '@ghostfolio/api/models/rules/regional-market-cluster-risk/emerging-markets';
import { RegionalMarketClusterRiskEurope } from '@ghostfolio/api/models/rules/regional-market-cluster-risk/europe'; import { RegionalMarketClusterRiskEurope } from '@ghostfolio/api/models/rules/regional-market-cluster-risk/europe';
import { RegionalMarketClusterRiskJapan } from '@ghostfolio/api/models/rules/regional-market-cluster-risk/japan';
import { RegionalMarketClusterRiskNorthAmerica } from '@ghostfolio/api/models/rules/regional-market-cluster-risk/north-america'; import { RegionalMarketClusterRiskNorthAmerica } from '@ghostfolio/api/models/rules/regional-market-cluster-risk/north-america';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
@ -1280,6 +1282,11 @@ export class PortfolioService {
summary.ordersCount > 0 summary.ordersCount > 0
? await this.rulesService.evaluate( ? await this.rulesService.evaluate(
[ [
new RegionalMarketClusterRiskAsiaPacific(
this.exchangeRateDataService,
marketsAdvancedTotalInBaseCurrency,
marketsAdvanced.asiaPacific.valueInBaseCurrency
),
new RegionalMarketClusterRiskEmergingMarkets( new RegionalMarketClusterRiskEmergingMarkets(
this.exchangeRateDataService, this.exchangeRateDataService,
marketsAdvancedTotalInBaseCurrency, marketsAdvancedTotalInBaseCurrency,
@ -1290,6 +1297,11 @@ export class PortfolioService {
marketsAdvancedTotalInBaseCurrency, marketsAdvancedTotalInBaseCurrency,
marketsAdvanced.europe.valueInBaseCurrency marketsAdvanced.europe.valueInBaseCurrency
), ),
new RegionalMarketClusterRiskJapan(
this.exchangeRateDataService,
marketsAdvancedTotalInBaseCurrency,
marketsAdvanced.japan.valueInBaseCurrency
),
new RegionalMarketClusterRiskNorthAmerica( new RegionalMarketClusterRiskNorthAmerica(
this.exchangeRateDataService, this.exchangeRateDataService,
marketsAdvancedTotalInBaseCurrency, marketsAdvancedTotalInBaseCurrency,

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

@ -13,8 +13,10 @@ import { EconomicMarketClusterRiskDevelopedMarkets } from '@ghostfolio/api/model
import { EconomicMarketClusterRiskEmergingMarkets } from '@ghostfolio/api/models/rules/economic-market-cluster-risk/emerging-markets'; import { EconomicMarketClusterRiskEmergingMarkets } from '@ghostfolio/api/models/rules/economic-market-cluster-risk/emerging-markets';
import { EmergencyFundSetup } from '@ghostfolio/api/models/rules/emergency-fund/emergency-fund-setup'; import { EmergencyFundSetup } from '@ghostfolio/api/models/rules/emergency-fund/emergency-fund-setup';
import { FeeRatioInitialInvestment } from '@ghostfolio/api/models/rules/fees/fee-ratio-initial-investment'; import { FeeRatioInitialInvestment } from '@ghostfolio/api/models/rules/fees/fee-ratio-initial-investment';
import { RegionalMarketClusterRiskAsiaPacific } from '@ghostfolio/api/models/rules/regional-market-cluster-risk/asia-pacific';
import { RegionalMarketClusterRiskEmergingMarkets } from '@ghostfolio/api/models/rules/regional-market-cluster-risk/emerging-markets'; import { RegionalMarketClusterRiskEmergingMarkets } from '@ghostfolio/api/models/rules/regional-market-cluster-risk/emerging-markets';
import { RegionalMarketClusterRiskEurope } from '@ghostfolio/api/models/rules/regional-market-cluster-risk/europe'; import { RegionalMarketClusterRiskEurope } from '@ghostfolio/api/models/rules/regional-market-cluster-risk/europe';
import { RegionalMarketClusterRiskJapan } from '@ghostfolio/api/models/rules/regional-market-cluster-risk/japan';
import { RegionalMarketClusterRiskNorthAmerica } from '@ghostfolio/api/models/rules/regional-market-cluster-risk/north-america'; import { RegionalMarketClusterRiskNorthAmerica } from '@ghostfolio/api/models/rules/regional-market-cluster-risk/north-america';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service'; import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service';
@ -272,6 +274,12 @@ export class UserService {
undefined, undefined,
undefined undefined
).getSettings(user.Settings.settings), ).getSettings(user.Settings.settings),
RegionalMarketClusterRiskAsiaPacific:
new RegionalMarketClusterRiskAsiaPacific(
undefined,
undefined,
undefined
).getSettings(user.Settings.settings),
RegionalMarketClusterRiskEmergingMarkets: RegionalMarketClusterRiskEmergingMarkets:
new RegionalMarketClusterRiskEmergingMarkets( new RegionalMarketClusterRiskEmergingMarkets(
undefined, undefined,
@ -283,6 +291,11 @@ export class UserService {
undefined, undefined,
undefined undefined
).getSettings(user.Settings.settings), ).getSettings(user.Settings.settings),
RegionalMarketClusterRiskJapan: new RegionalMarketClusterRiskJapan(
undefined,
undefined,
undefined
).getSettings(user.Settings.settings),
RegionalMarketClusterRiskNorthAmerica: RegionalMarketClusterRiskNorthAmerica:
new RegionalMarketClusterRiskNorthAmerica( new RegionalMarketClusterRiskNorthAmerica(
undefined, undefined,
@ -333,7 +346,10 @@ export class UserService {
currentPermissions, currentPermissions,
permissions.accessHoldingsChart, permissions.accessHoldingsChart,
permissions.createAccess, permissions.createAccess,
permissions.readAiPrompt permissions.createMarketDataOfOwnAssetProfile,
permissions.readAiPrompt,
permissions.readMarketDataOfOwnAssetProfile,
permissions.updateMarketDataOfOwnAssetProfile
); );
// Reset benchmark // Reset benchmark

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

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

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

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

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

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

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

@ -17,6 +17,7 @@ import {
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { GfActivitiesTableComponent } from '@ghostfolio/ui/activities-table'; import { GfActivitiesTableComponent } from '@ghostfolio/ui/activities-table';
import { GfDataProviderCreditsComponent } from '@ghostfolio/ui/data-provider-credits'; import { GfDataProviderCreditsComponent } from '@ghostfolio/ui/data-provider-credits';
import { GfHistoricalMarketDataEditorComponent } from '@ghostfolio/ui/historical-market-data-editor';
import { translate } from '@ghostfolio/ui/i18n'; import { translate } from '@ghostfolio/ui/i18n';
import { GfLineChartComponent } from '@ghostfolio/ui/line-chart'; import { GfLineChartComponent } from '@ghostfolio/ui/line-chart';
import { GfPortfolioProportionChartComponent } from '@ghostfolio/ui/portfolio-proportion-chart'; import { GfPortfolioProportionChartComponent } from '@ghostfolio/ui/portfolio-proportion-chart';
@ -46,7 +47,7 @@ import { SortDirection } from '@angular/material/sort';
import { MatTableDataSource } from '@angular/material/table'; import { MatTableDataSource } from '@angular/material/table';
import { MatTabsModule } from '@angular/material/tabs'; import { MatTabsModule } from '@angular/material/tabs';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { Account, Tag } from '@prisma/client'; import { Account, MarketData, Tag } from '@prisma/client';
import { format, isSameMonth, isToday, parseISO } from 'date-fns'; import { format, isSameMonth, isToday, parseISO } from 'date-fns';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { forkJoin, Subject } from 'rxjs'; import { forkJoin, Subject } from 'rxjs';
@ -64,6 +65,7 @@ import { HoldingDetailDialogParams } from './interfaces/interfaces';
GfDataProviderCreditsComponent, GfDataProviderCreditsComponent,
GfDialogFooterModule, GfDialogFooterModule,
GfDialogHeaderModule, GfDialogHeaderModule,
GfHistoricalMarketDataEditorComponent,
GfLineChartComponent, GfLineChartComponent,
GfPortfolioProportionChartComponent, GfPortfolioProportionChartComponent,
GfTagsSelectorComponent, GfTagsSelectorComponent,
@ -98,9 +100,11 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
public feeInBaseCurrency: number; public feeInBaseCurrency: number;
public firstBuyDate: string; public firstBuyDate: string;
public hasPermissionToCreateTags: boolean; public hasPermissionToCreateTags: boolean;
public hasPermissionToReadMarketDataOfOwnAssetProfile: boolean;
public historicalDataItems: LineChartItem[]; public historicalDataItems: LineChartItem[];
public investment: number; public investment: number;
public investmentPrecision = 2; public investmentPrecision = 2;
public marketDataItems: MarketData[] = [];
public marketPrice: number; public marketPrice: number;
public maxPrice: number; public maxPrice: number;
public minPrice: number; public minPrice: number;
@ -265,6 +269,14 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
this.feeInBaseCurrency = feeInBaseCurrency; this.feeInBaseCurrency = feeInBaseCurrency;
this.firstBuyDate = firstBuyDate; this.firstBuyDate = firstBuyDate;
this.hasPermissionToReadMarketDataOfOwnAssetProfile =
hasPermission(
this.user?.permissions,
permissions.readMarketDataOfOwnAssetProfile
) &&
SymbolProfile?.dataSource === 'MANUAL' &&
SymbolProfile?.userId === this.user?.id;
this.historicalDataItems = historicalData.map( this.historicalDataItems = historicalData.map(
({ averagePrice, date, marketPrice }) => { ({ averagePrice, date, marketPrice }) => {
this.benchmarkDataItems.push({ this.benchmarkDataItems.push({
@ -427,6 +439,10 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
} }
); );
if (this.hasPermissionToReadMarketDataOfOwnAssetProfile) {
this.fetchMarketData();
}
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
} }
); );
@ -487,6 +503,12 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
}); });
} }
public onMarketDataChanged(withRefresh = false) {
if (withRefresh) {
this.fetchMarketData();
}
}
public onTagsChanged(tags: Tag[]) { public onTagsChanged(tags: Tag[]) {
this.activityForm.get('tags').setValue(tags); this.activityForm.get('tags').setValue(tags);
} }
@ -503,4 +525,27 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
this.unsubscribeSubject.next(); this.unsubscribeSubject.next();
this.unsubscribeSubject.complete(); this.unsubscribeSubject.complete();
} }
private fetchMarketData() {
this.dataService
.fetchMarketDataBySymbol({
dataSource: this.data.dataSource,
symbol: this.data.symbol
})
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ marketData }) => {
this.marketDataItems = marketData;
this.historicalDataItems = this.marketDataItems.map(
({ date, marketPrice }) => {
return {
date: format(date, DATE_FORMAT),
value: marketPrice
};
}
);
this.changeDetectorRef.markForCheck();
});
}
} }

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

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

2
libs/common/src/lib/interfaces/x-ray-rules-settings.interface.ts

@ -9,8 +9,10 @@ export interface XRayRulesSettings {
EconomicMarketClusterRiskEmergingMarkets?: RuleSettings; EconomicMarketClusterRiskEmergingMarkets?: RuleSettings;
EmergencyFundSetup?: RuleSettings; EmergencyFundSetup?: RuleSettings;
FeeRatioInitialInvestment?: RuleSettings; FeeRatioInitialInvestment?: RuleSettings;
RegionalMarketClusterRiskAsiaPacific?: RuleSettings;
RegionalMarketClusterRiskEmergingMarkets?: RuleSettings; RegionalMarketClusterRiskEmergingMarkets?: RuleSettings;
RegionalMarketClusterRiskEurope?: RuleSettings; RegionalMarketClusterRiskEurope?: RuleSettings;
RegionalMarketClusterRiskJapan?: RuleSettings;
RegionalMarketClusterRiskNorthAmerica?: RuleSettings; RegionalMarketClusterRiskNorthAmerica?: RuleSettings;
} }

1
libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor.component.html

@ -48,6 +48,7 @@
cdkTextareaAutosize cdkTextareaAutosize
formControlName="csvString" formControlName="csvString"
matInput matInput
rows="2"
type="text" type="text"
(keyup.enter)="$event.stopPropagation()" (keyup.enter)="$event.stopPropagation()"
></textarea> ></textarea>

Loading…
Cancel
Save