diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c34ac89b..fc4f1ac92 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,9 +10,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Added a message if no results have been found in the create or update activity dialog +- Added support to customize the rule thresholds in the _X-ray_ section (experimental) ### Changed +- Optimized the portfolio calculations with smarter date interval selection - Improved the language localization for German (`de`) ## 2.111.0 - 2024-09-28 diff --git a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator.ts b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator.ts index 3b64cd185..f5e301cba 100644 --- a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator.ts @@ -16,13 +16,14 @@ import { addDays, addMilliseconds, differenceInDays, - eachDayOfInterval, format, isBefore } from 'date-fns'; import { cloneDeep, first, last, sortBy } from 'lodash'; export class TWRPortfolioCalculator extends PortfolioCalculator { + private chartDatesDescending: string[]; + protected calculateOverallPerformance( positions: TimelinePosition[] ): PortfolioSnapshot { @@ -820,31 +821,35 @@ export class TWRPortfolioCalculator extends PortfolioCalculator { startDate = start; } + const endDateString = format(endDate, DATE_FORMAT); + const startDateString = format(startDate, DATE_FORMAT); + const currentValuesAtDateRangeStartWithCurrencyEffect = - currentValuesWithCurrencyEffect[format(startDate, DATE_FORMAT)] ?? - new Big(0); + currentValuesWithCurrencyEffect[startDateString] ?? new Big(0); const investmentValuesAccumulatedAtStartDateWithCurrencyEffect = - investmentValuesAccumulatedWithCurrencyEffect[ - format(startDate, DATE_FORMAT) - ] ?? new Big(0); + investmentValuesAccumulatedWithCurrencyEffect[startDateString] ?? + new Big(0); const grossPerformanceAtDateRangeStartWithCurrencyEffect = currentValuesAtDateRangeStartWithCurrencyEffect.minus( investmentValuesAccumulatedAtStartDateWithCurrencyEffect ); - const dates = eachDayOfInterval({ - end: endDate, - start: startDate - }).map((date) => { - return format(date, DATE_FORMAT); - }); - let average = new Big(0); let dayCount = 0; - for (const date of dates) { + if (!this.chartDatesDescending) { + this.chartDatesDescending = Object.keys(chartDateMap).sort().reverse(); + } + + for (const date of this.chartDatesDescending) { + if (date > endDateString) { + continue; + } else if (date < startDateString) { + break; + } + if ( investmentValuesAccumulatedWithCurrencyEffect[date] instanceof Big && investmentValuesAccumulatedWithCurrencyEffect[date].gt(0) @@ -864,17 +869,14 @@ export class TWRPortfolioCalculator extends PortfolioCalculator { } netPerformanceWithCurrencyEffectMap[dateRange] = - netPerformanceValuesWithCurrencyEffect[ - format(endDate, DATE_FORMAT) - ]?.minus( + netPerformanceValuesWithCurrencyEffect[endDateString]?.minus( // If the date range is 'max', take 0 as a start value. Otherwise, // the value of the end of the day of the start date is taken which // differs from the buying price. dateRange === 'max' ? new Big(0) - : (netPerformanceValuesWithCurrencyEffect[ - format(startDate, DATE_FORMAT) - ] ?? new Big(0)) + : (netPerformanceValuesWithCurrencyEffect[startDateString] ?? + new Big(0)) ) ?? new Big(0); netPerformancePercentageWithCurrencyEffectMap[dateRange] = average.gt(0) diff --git a/apps/api/src/app/user/update-user-setting.dto.ts b/apps/api/src/app/user/update-user-setting.dto.ts index 6ea6d7427..b4d3edb77 100644 --- a/apps/api/src/app/user/update-user-setting.dto.ts +++ b/apps/api/src/app/user/update-user-setting.dto.ts @@ -1,4 +1,5 @@ import { IsCurrencyCode } from '@ghostfolio/api/validators/is-currency-code'; +import { PortfolioReportRule } from '@ghostfolio/common/interfaces'; import type { ColorScheme, DateRange, diff --git a/apps/api/src/models/rules/account-cluster-risk/current-investment.ts b/apps/api/src/models/rules/account-cluster-risk/current-investment.ts index e25bb2f08..13680270e 100644 --- a/apps/api/src/models/rules/account-cluster-risk/current-investment.ts +++ b/apps/api/src/models/rules/account-cluster-risk/current-investment.ts @@ -76,11 +76,11 @@ export class AccountClusterRiskCurrentInvestment extends Rule { }; } - public getSettings(aUserSettings: UserSettings): Settings { + public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings { return { - baseCurrency: aUserSettings.baseCurrency, - isActive: aUserSettings.xRayRules[this.getKey()].isActive, - thresholdMax: 0.5 + baseCurrency, + isActive: xRayRules[this.getKey()].isActive, + thresholdMax: xRayRules[this.getKey()]?.thresholdMax ?? 0.5 }; } } diff --git a/apps/api/src/models/rules/account-cluster-risk/single-account.ts b/apps/api/src/models/rules/account-cluster-risk/single-account.ts index 1f61b9659..feaaf4e38 100644 --- a/apps/api/src/models/rules/account-cluster-risk/single-account.ts +++ b/apps/api/src/models/rules/account-cluster-risk/single-account.ts @@ -34,9 +34,9 @@ export class AccountClusterRiskSingleAccount extends Rule { }; } - public getSettings(aUserSettings: UserSettings): RuleSettings { + public getSettings({ xRayRules }: UserSettings): RuleSettings { return { - isActive: aUserSettings.xRayRules[this.getKey()].isActive + isActive: xRayRules[this.getKey()].isActive }; } } 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 1258eb889..e3050efcc 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 @@ -62,10 +62,10 @@ export class CurrencyClusterRiskBaseCurrencyCurrentInvestment extends Rule { }; } - public getSettings(aUserSettings: UserSettings): Settings { + public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings { return { - baseCurrency: aUserSettings.baseCurrency, - isActive: aUserSettings.xRayRules[this.getKey()].isActive, - thresholdMax: 0.5 + baseCurrency, + isActive: xRayRules[this.getKey()].isActive, + thresholdMax: xRayRules[this.getKey()]?.thresholdMax ?? 0.5 }; } } diff --git a/apps/api/src/models/rules/emergency-fund/emergency-fund-setup.ts b/apps/api/src/models/rules/emergency-fund/emergency-fund-setup.ts index 0ba7a109c..c59701438 100644 --- a/apps/api/src/models/rules/emergency-fund/emergency-fund-setup.ts +++ b/apps/api/src/models/rules/emergency-fund/emergency-fund-setup.ts @@ -19,7 +19,7 @@ export class EmergencyFundSetup extends Rule { } public evaluate(ruleSettings: Settings) { - if (this.emergencyFund < ruleSettings.thresholdMin) { + if (!this.emergencyFund) { return { evaluation: 'No emergency fund has been set up', value: false @@ -32,16 +32,14 @@ export class EmergencyFundSetup extends Rule { }; } - public getSettings(aUserSettings: UserSettings): Settings { + public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings { return { - baseCurrency: aUserSettings.baseCurrency, - isActive: aUserSettings.xRayRules[this.getKey()].isActive, - thresholdMin: 0 + baseCurrency, + isActive: xRayRules[this.getKey()].isActive }; } } interface Settings extends RuleSettings { baseCurrency: string; - thresholdMin: number; } diff --git a/apps/api/src/models/rules/fees/fee-ratio-initial-investment.ts b/apps/api/src/models/rules/fees/fee-ratio-initial-investment.ts index 09029fd3e..9b1961ed6 100644 --- a/apps/api/src/models/rules/fees/fee-ratio-initial-investment.ts +++ b/apps/api/src/models/rules/fees/fee-ratio-initial-investment.ts @@ -43,11 +43,11 @@ export class FeeRatioInitialInvestment extends Rule { }; } - public getSettings(aUserSettings: UserSettings): Settings { + public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings { return { - baseCurrency: aUserSettings.baseCurrency, - isActive: aUserSettings.xRayRules[this.getKey()].isActive, - thresholdMax: 0.01 + baseCurrency, + isActive: xRayRules[this.getKey()].isActive, + thresholdMax: xRayRules[this.getKey()]?.thresholdMax ?? 0.01 }; } } diff --git a/apps/client/src/app/components/home-holdings/home-holdings.component.ts b/apps/client/src/app/components/home-holdings/home-holdings.component.ts index 4f7de03b5..c76638d33 100644 --- a/apps/client/src/app/components/home-holdings/home-holdings.component.ts +++ b/apps/client/src/app/components/home-holdings/home-holdings.component.ts @@ -111,7 +111,7 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit { this.initialize(); } - public onSymbolClicked({ dataSource, symbol }: AssetProfileIdentifier) { + public onHoldingClicked({ dataSource, symbol }: AssetProfileIdentifier) { if (dataSource && symbol) { this.router.navigate([], { queryParams: { dataSource, symbol, holdingDetailDialog: true } diff --git a/apps/client/src/app/components/home-holdings/home-holdings.html b/apps/client/src/app/components/home-holdings/home-holdings.html index bd9e57bb2..01afe31a2 100644 --- a/apps/client/src/app/components/home-holdings/home-holdings.html +++ b/apps/client/src/app/components/home-holdings/home-holdings.html @@ -40,7 +40,7 @@ cursor="pointer" [dateRange]="user?.settings?.dateRange" [holdings]="holdings" - (treemapChartClicked)="onSymbolClicked($event)" + (treemapChartClicked)="onHoldingClicked($event)" /> }
@@ -50,6 +50,7 @@ [hasPermissionToCreateActivity]="hasPermissionToCreateOrder" [holdings]="holdings" [locale]="user?.settings?.locale" + (holdingClicked)="onHoldingClicked($event)" /> @if (hasPermissionToCreateOrder && holdings?.length > 0) {
diff --git a/apps/client/src/app/components/rule/rule-settings-dialog/rule-settings-dialog.component.ts b/apps/client/src/app/components/rule/rule-settings-dialog/rule-settings-dialog.component.ts index 41ebf49db..4fb68e780 100644 --- a/apps/client/src/app/components/rule/rule-settings-dialog/rule-settings-dialog.component.ts +++ b/apps/client/src/app/components/rule/rule-settings-dialog/rule-settings-dialog.component.ts @@ -2,6 +2,7 @@ import { PortfolioReportRule } from '@ghostfolio/common/interfaces'; import { CommonModule } from '@angular/common'; import { Component, Inject } from '@angular/core'; +import { FormsModule } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; import { MAT_DIALOG_DATA, @@ -16,6 +17,7 @@ import { IRuleSettingsDialogParams } from './interfaces/interfaces'; @Component({ imports: [ CommonModule, + FormsModule, MatButtonModule, MatDialogModule, MatFormFieldModule, diff --git a/apps/client/src/app/components/rule/rule-settings-dialog/rule-settings-dialog.html b/apps/client/src/app/components/rule/rule-settings-dialog/rule-settings-dialog.html index e24db29f7..ef86549f6 100644 --- a/apps/client/src/app/components/rule/rule-settings-dialog/rule-settings-dialog.html +++ b/apps/client/src/app/components/rule/rule-settings-dialog/rule-settings-dialog.html @@ -1,23 +1,37 @@
{{ data.rule.name }}
- + Threshold Min - + - + Threshold Max - +
-
diff --git a/apps/client/src/app/components/rule/rule.component.html b/apps/client/src/app/components/rule/rule.component.html index f19436aba..5491933c0 100644 --- a/apps/client/src/app/components/rule/rule.component.html +++ b/apps/client/src/app/components/rule/rule.component.html @@ -62,7 +62,7 @@ - @if (rule?.isActive && !isEmpty(rule.settings) && false) { + @if (rule?.isActive && !isEmpty(rule.settings)) { diff --git a/apps/client/src/app/components/rule/rule.component.ts b/apps/client/src/app/components/rule/rule.component.ts index e6aafdcb1..6e6c368f0 100644 --- a/apps/client/src/app/components/rule/rule.component.ts +++ b/apps/client/src/app/components/rule/rule.component.ts @@ -55,16 +55,15 @@ export class RuleComponent implements OnInit { dialogRef .afterClosed() .pipe(takeUntil(this.unsubscribeSubject)) - .subscribe( - ({ settings }: { settings: PortfolioReportRule['settings'] }) => { - if (settings) { - console.log(settings); - - // TODO - // this.ruleUpdated.emit(settings); - } + .subscribe((settings: PortfolioReportRule['settings']) => { + if (settings) { + this.ruleUpdated.emit({ + xRayRules: { + [rule.key]: settings + } + }); } - ); + }); } public onUpdateRule(rule: PortfolioReportRule) { diff --git a/apps/client/src/app/pages/portfolio/activities/activities-page.component.ts b/apps/client/src/app/pages/portfolio/activities/activities-page.component.ts index a7638c561..427637c2c 100644 --- a/apps/client/src/app/pages/portfolio/activities/activities-page.component.ts +++ b/apps/client/src/app/pages/portfolio/activities/activities-page.component.ts @@ -7,7 +7,7 @@ import { ImpersonationStorageService } from '@ghostfolio/client/services/imperso import { UserService } from '@ghostfolio/client/services/user/user.service'; import { DEFAULT_PAGE_SIZE } from '@ghostfolio/common/config'; import { downloadAsFile } from '@ghostfolio/common/helper'; -import { User } from '@ghostfolio/common/interfaces'; +import { AssetProfileIdentifier, User } from '@ghostfolio/common/interfaces'; import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; @@ -138,6 +138,16 @@ export class ActivitiesPageComponent implements OnDestroy, OnInit { this.fetchActivities(); } + public onClickActivity({ dataSource, symbol }: AssetProfileIdentifier) { + this.router.navigate([], { + queryParams: { + dataSource, + symbol, + holdingDetailDialog: true + } + }); + } + public onCloneActivity(aActivity: Activity) { this.openCreateActivityDialog(aActivity); } diff --git a/apps/client/src/app/pages/portfolio/activities/activities-page.html b/apps/client/src/app/pages/portfolio/activities/activities-page.html index 9edb400ab..c06f7dd75 100644 --- a/apps/client/src/app/pages/portfolio/activities/activities-page.html +++ b/apps/client/src/app/pages/portfolio/activities/activities-page.html @@ -21,6 +21,7 @@ [sortDirection]="sortDirection" [totalItems]="totalItems" (activitiesDeleted)="onDeleteActivities()" + (activityClicked)="onClickActivity($event)" (activityDeleted)="onDeleteActivity($event)" (activityToClone)="onCloneActivity($event)" (activityToUpdate)="onUpdateActivity($event)" diff --git a/apps/client/src/app/pages/portfolio/fire/fire-page.component.ts b/apps/client/src/app/pages/portfolio/fire/fire-page.component.ts index 5aca4b375..10a2eb604 100644 --- a/apps/client/src/app/pages/portfolio/fire/fire-page.component.ts +++ b/apps/client/src/app/pages/portfolio/fire/fire-page.component.ts @@ -134,8 +134,6 @@ export class FirePageComponent implements OnDestroy, OnInit { } public onRulesUpdated(event: UpdateUserSettingDto) { - this.isLoading = true; - this.dataService .putUserSetting(event) .pipe(takeUntil(this.unsubscribeSubject)) diff --git a/libs/common/src/lib/types/x-ray-rules-settings.type.ts b/libs/common/src/lib/types/x-ray-rules-settings.type.ts index bc2782547..a55487f0b 100644 --- a/libs/common/src/lib/types/x-ray-rules-settings.type.ts +++ b/libs/common/src/lib/types/x-ray-rules-settings.type.ts @@ -1,3 +1,5 @@ +import { PortfolioReportRule } from '@ghostfolio/common/interfaces'; + export type XRayRulesSettings = { AccountClusterRiskCurrentInvestment?: RuleSettings; AccountClusterRiskSingleAccount?: RuleSettings; @@ -7,6 +9,6 @@ export type XRayRulesSettings = { FeeRatioInitialInvestment?: RuleSettings; }; -interface RuleSettings { +interface RuleSettings extends Pick { isActive: boolean; } diff --git a/libs/ui/src/lib/activities-table/activities-table.component.ts b/libs/ui/src/lib/activities-table/activities-table.component.ts index 482305bb7..4f8fff904 100644 --- a/libs/ui/src/lib/activities-table/activities-table.component.ts +++ b/libs/ui/src/lib/activities-table/activities-table.component.ts @@ -42,7 +42,6 @@ import { } from '@angular/material/sort'; import { MatTableDataSource, MatTableModule } from '@angular/material/table'; import { MatTooltipModule } from '@angular/material/tooltip'; -import { Router, RouterModule } from '@angular/router'; import { isUUID } from 'class-validator'; import { endOfToday, isAfter } from 'date-fns'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; @@ -64,8 +63,7 @@ import { Subject, Subscription, takeUntil } from 'rxjs'; MatSortModule, MatTableModule, MatTooltipModule, - NgxSkeletonLoaderModule, - RouterModule + NgxSkeletonLoaderModule ], schemas: [CUSTOM_ELEMENTS_SCHEMA], selector: 'gf-activities-table', @@ -95,6 +93,7 @@ export class GfActivitiesTableComponent @Input() totalItems = Number.MAX_SAFE_INTEGER; @Output() activitiesDeleted = new EventEmitter(); + @Output() activityClicked = new EventEmitter(); @Output() activityDeleted = new EventEmitter(); @Output() activityToClone = new EventEmitter(); @Output() activityToUpdate = new EventEmitter(); @@ -122,10 +121,7 @@ export class GfActivitiesTableComponent private unsubscribeSubject = new Subject(); - public constructor( - private notificationService: NotificationService, - private router: Router - ) {} + public constructor(private notificationService: NotificationService) {} public ngOnInit() { if (this.showCheckbox) { @@ -203,7 +199,7 @@ export class GfActivitiesTableComponent activity.isDraft === false && ['BUY', 'DIVIDEND', 'SELL'].includes(activity.type) ) { - this.onOpenPositionDialog({ + this.activityClicked.emit({ dataSource: activity.SymbolProfile.dataSource, symbol: activity.SymbolProfile.symbol }); @@ -268,12 +264,6 @@ export class GfActivitiesTableComponent }); } - public onOpenPositionDialog({ dataSource, symbol }: AssetProfileIdentifier) { - this.router.navigate([], { - queryParams: { dataSource, symbol, holdingDetailDialog: true } - }); - } - public onUpdateActivity(aActivity: OrderWithAccount) { this.activityToUpdate.emit(aActivity); } diff --git a/libs/ui/src/lib/holdings-table/holdings-table.component.ts b/libs/ui/src/lib/holdings-table/holdings-table.component.ts index 39a9baf5c..53e17e5b7 100644 --- a/libs/ui/src/lib/holdings-table/holdings-table.component.ts +++ b/libs/ui/src/lib/holdings-table/holdings-table.component.ts @@ -14,10 +14,12 @@ import { CUSTOM_ELEMENTS_SCHEMA, ChangeDetectionStrategy, Component, + EventEmitter, Input, OnChanges, OnDestroy, OnInit, + Output, ViewChild } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; @@ -25,7 +27,6 @@ import { MatDialogModule } from '@angular/material/dialog'; import { MatPaginator, MatPaginatorModule } from '@angular/material/paginator'; import { MatSort, MatSortModule } from '@angular/material/sort'; import { MatTableDataSource, MatTableModule } from '@angular/material/table'; -import { Router, RouterModule } from '@angular/router'; import { AssetSubClass } from '@prisma/client'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { Subject, Subscription } from 'rxjs'; @@ -44,8 +45,7 @@ import { Subject, Subscription } from 'rxjs'; MatPaginatorModule, MatSortModule, MatTableModule, - NgxSkeletonLoaderModule, - RouterModule + NgxSkeletonLoaderModule ], schemas: [CUSTOM_ELEMENTS_SCHEMA], selector: 'gf-holdings-table', @@ -63,6 +63,8 @@ export class GfHoldingsTableComponent implements OnChanges, OnDestroy, OnInit { @Input() locale = getLocale(); @Input() pageSize = Number.MAX_SAFE_INTEGER; + @Output() holdingClicked = new EventEmitter(); + @ViewChild(MatPaginator) paginator: MatPaginator; @ViewChild(MatSort) sort: MatSort; @@ -75,7 +77,7 @@ export class GfHoldingsTableComponent implements OnChanges, OnDestroy, OnInit { private unsubscribeSubject = new Subject(); - public constructor(private router: Router) {} + public constructor() {} public ngOnInit() {} @@ -107,9 +109,7 @@ export class GfHoldingsTableComponent implements OnChanges, OnDestroy, OnInit { public onOpenHoldingDialog({ dataSource, symbol }: AssetProfileIdentifier) { if (this.hasPermissionToOpenDetails) { - this.router.navigate([], { - queryParams: { dataSource, symbol, holdingDetailDialog: true } - }); + this.holdingClicked.emit({ dataSource, symbol }); } }