diff --git a/CHANGELOG.md b/CHANGELOG.md index 97ecebf87c..901944abba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed + +### Added + +- Extended the content of the _General_ section by information about the import of activities from _Interactive Brokers_ on the Frequently Asked Questions (FAQ) page + +### Changed + +- Extended the user account deletion flow in the user settings of the user account page to users without a _Security Token_ within 14 days after the registration - Removed an unnecessary index from the market data database table +- Improved the _Storybook_ stories of the account selector, accounts table, activities table and holdings table components +- Improved the language localization for Spanish (`es`) + +### Fixed + +- Fixed the missing list numbers in the top and bottom holdings on the analysis page in Safari +- Fixed the missing first data point in the portfolio evolution chart on the analysis page +- Fixed the end date of calendar year date ranges in the portfolio performance calculation for instances in time zones with a negative UTC offset +- Fixed the missing mapping for Korea in the country weightings of the _Financial Modeling Prep_ service ## 3.81.0 - 2026-10-07 diff --git a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts index 7a8bbcc741..ca440c6d2a 100644 --- a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts @@ -1407,7 +1407,7 @@ export abstract class PortfolioCalculator { return this.snapshot.totalLiabilitiesWithCurrencyEffect; } - public async getPerformance({ end, start }) { + public async getPerformance({ end, start }: { end: Date; start: Date }) { await this.snapshotPromise; const { historicalData } = this.snapshot; @@ -1418,14 +1418,13 @@ export abstract class PortfolioCalculator { let netPerformanceAtStartDate: number; let netPerformanceWithCurrencyEffectAtStartDate: number; - const startDate = isEqual(start, endOfDay(start)) - ? resetHours(start) - : start; + const endDate = this.toChartDate(end); + const startDate = this.toChartDate(start); for (const historicalDataItem of historicalData) { const date = resetHours(parseDate(historicalDataItem.date)); - if (!isBefore(date, startDate) && !isAfter(date, end)) { + if (!isBefore(date, startDate) && !isAfter(date, endDate)) { // Take the values at the start date from the first day of the date // range if (historicalDataItemsOfDateRange.length === 0) { @@ -1915,4 +1914,8 @@ export abstract class PortfolioCalculator { await this.initialize(attempt + 1); } } + + private toChartDate(aDate: Date) { + return isEqual(aDate, endOfDay(aDate)) ? resetHours(aDate) : aDate; + } } diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.spec.ts index 5f8d9cdc44..43ff160a78 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.spec.ts @@ -1,3 +1,7 @@ +/** + * @jest-environment /jest-environment-tz.js + * @jest-environment-options {"timeZone": "America/New_York"} + */ import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator'; import { userDummyData } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; @@ -9,6 +13,7 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration/con import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; +import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper'; import { HistoricalDataItem } from '@ghostfolio/common/interfaces'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; @@ -183,4 +188,43 @@ describe('PortfolioCalculator', () => { ).toBeCloseTo(0.23, 10); }); }); + + describe('get performance', () => { + it('with a calendar year date range', async () => { + const snapshot = await portfolioCalculator.getSnapshot(); + + snapshot.historicalData = [ + getHistoricalDataItem({ + date: '2016-12-31', + investment: 100, + netPerformance: 0 + }), + getHistoricalDataItem({ + date: '2017-12-31', + investment: 100, + netPerformance: 10 + }), + getHistoricalDataItem({ + date: '2018-01-01', + investment: 100, + netPerformance: 20 + }) + ]; + + const { endDate, startDate } = getIntervalFromDateRange({ + dateRange: '2017' + }); + + const { chart } = await portfolioCalculator.getPerformance({ + end: endDate, + start: startDate + }); + + expect( + chart.map(({ date }) => { + return date; + }) + ).toEqual(['2016-12-31', '2017-12-31']); + }); + }); }); diff --git a/apps/api/src/app/user/user.controller.ts b/apps/api/src/app/user/user.controller.ts index 32ac353351..c4824fe7d6 100644 --- a/apps/api/src/app/user/user.controller.ts +++ b/apps/api/src/app/user/user.controller.ts @@ -71,14 +71,21 @@ export class UserController { @Delete() @HasPermission(permissions.deleteOwnUser) - @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + @UseGuards(AuthGuard('jwt'), HasPermissionGuard, ImpersonationGuard) public async deleteOwnUser( - @Body() data: DeleteOwnUserDto + @Body() data: DeleteOwnUserDto, + @Impersonation() { isActive }: ImpersonationContext ): Promise { - const user = await this.validateAccessToken( - data.accessToken, - this.request.user.id - ); + const user = this.request.user; + + if (user.provider === 'ANONYMOUS') { + await this.validateAccessToken(data.accessToken, user.id); + } else if (isActive) { + throw new HttpException( + getReasonPhrase(StatusCodes.FORBIDDEN), + StatusCodes.FORBIDDEN + ); + } return this.userService.deleteUser({ id: user.id @@ -260,6 +267,13 @@ export class UserController { accessToken: string, userId: string ): Promise { + if (!accessToken) { + throw new HttpException( + getReasonPhrase(StatusCodes.FORBIDDEN), + StatusCodes.FORBIDDEN + ); + } + const hashedAccessToken = this.userService.createAccessToken({ password: accessToken, salt: this.configurationService.get('ACCESS_TOKEN_SALT') diff --git a/apps/api/src/app/user/user.service.ts b/apps/api/src/app/user/user.service.ts index 2e54a7ddc3..227d04da80 100644 --- a/apps/api/src/app/user/user.service.ts +++ b/apps/api/src/app/user/user.service.ts @@ -14,6 +14,7 @@ import { DEFAULT_CURRENCY, DEFAULT_DATE_RANGE, DEFAULT_LOCALE, + DELETE_OWN_USER_PERIOD, PROPERTY_API_KEY_GHOSTFOLIO, PROPERTY_IS_READ_ONLY_MODE, PROPERTY_MAX_DAILY_REQUESTS, @@ -45,7 +46,7 @@ import { Injectable, Logger } from '@nestjs/common'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { InjectThrottlerStorage, ThrottlerStorage } from '@nestjs/throttler'; import { Prisma, Role, User } from '@prisma/client'; -import { differenceInDays, subDays } from 'date-fns'; +import { addMilliseconds, differenceInDays, isBefore, subDays } from 'date-fns'; import { isNil, without } from 'lodash-es'; import { createHmac } from 'node:crypto'; @@ -438,6 +439,17 @@ export class UserService { currentPermissions.push(permissions.enableSubscriptionInterstitial); } + if ( + !hasRole(user, Role.DEMO) && + user.provider !== 'ANONYMOUS' && + isBefore( + new Date(), + addMilliseconds(user.createdAt, DELETE_OWN_USER_PERIOD) + ) + ) { + currentPermissions.push(permissions.deleteOwnUser); + } + currentPermissions = without( currentPermissions, permissions.accessHoldingsChart, diff --git a/apps/api/src/services/data-provider/financial-modeling-prep/financial-modeling-prep.service.ts b/apps/api/src/services/data-provider/financial-modeling-prep/financial-modeling-prep.service.ts index d3b72ab349..e6ad639b84 100644 --- a/apps/api/src/services/data-provider/financial-modeling-prep/financial-modeling-prep.service.ts +++ b/apps/api/src/services/data-provider/financial-modeling-prep/financial-modeling-prep.service.ts @@ -58,6 +58,7 @@ export class FinancialModelingPrepService 'Congo (Dem. Rep. of the)': 'CD', 'Congo (Rep. of)': 'CG', 'Czech Republic': 'CZ', + Korea: 'KR', 'Korea (the Republic of)': 'KR', Macau: 'MO', 'Russian Federation': 'RU', 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 618626b63e..14fe4d228f 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 @@ -6,123 +6,131 @@ (ngSubmit)="onSubmit()" >
- @if ( - data.rule.configuration?.thresholdMin && - data.rule.configuration?.thresholdMax - ) { -
-
- Threshold range: - - - - -
-
- - - - - - + @if (data.rule.configuration?.threshold; as threshold) { + @if ( + data.rule.configuration.thresholdMin && + data.rule.configuration.thresholdMax + ) { +
+
+ Threshold range: + + - + +
+
+ + + + + + +
-
- } @else { -
-
- Threshold Min: - -
-
- - - - - + } @else { +
+
+ Threshold Min: + +
+
+ + + + + +
-
-
-
- Threshold Max: - -
-
- - - - - +
+
+ Threshold Max: + +
+
+ + + + + +
-
+ } }
diff --git a/apps/client/src/app/components/user-account-settings/user-account-settings.component.ts b/apps/client/src/app/components/user-account-settings/user-account-settings.component.ts index 83f7ddd9ae..3c95652e44 100644 --- a/apps/client/src/app/components/user-account-settings/user-account-settings.component.ts +++ b/apps/client/src/app/components/user-account-settings/user-account-settings.component.ts @@ -94,6 +94,7 @@ export class GfUserAccountSettingsComponent implements OnInit { protected hasImpersonationId: boolean; protected hasPermissionToDeleteOwnUser: boolean; protected hasPermissionToRequestOwnUserDeletion: boolean; + protected hasPermissionToUpdateOwnAccessToken: boolean; protected hasPermissionToUpdateViewMode: boolean; protected hasPermissionToUpdateUserSettings: boolean; protected isAccessTokenHidden = true; @@ -194,6 +195,11 @@ export class GfUserAccountSettingsComponent implements OnInit { permissions.requestOwnUserDeletion ); + this.hasPermissionToUpdateOwnAccessToken = hasPermission( + this.user.permissions, + permissions.updateOwnAccessToken + ); + this.hasPermissionToUpdateUserSettings = hasPermission( this.user.permissions, permissions.updateUserSettings @@ -271,13 +277,19 @@ export class GfUserAccountSettingsComponent implements OnInit { this.notificationService.confirm({ confirmFn: () => { this.dataService - .deleteOwnUser({ - accessToken: this.deleteOwnUserForm.controls.accessToken.value - }) + .deleteOwnUser( + this.hasPermissionToUpdateOwnAccessToken + ? { + accessToken: this.deleteOwnUserForm.controls.accessToken.value + } + : {} + ) .pipe( catchError(() => { this.notificationService.alert({ - title: $localize`Oops! Incorrect Security Token.` + title: this.hasPermissionToUpdateOwnAccessToken + ? $localize`Oops! Incorrect Security Token.` + : $localize`Oops! Something went wrong.` }); return EMPTY; diff --git a/apps/client/src/app/components/user-account-settings/user-account-settings.html b/apps/client/src/app/components/user-account-settings/user-account-settings.html index 2d4c090dd2..bf18373447 100644 --- a/apps/client/src/app/components/user-account-settings/user-account-settings.html +++ b/apps/client/src/app/components/user-account-settings/user-account-settings.html @@ -318,7 +318,10 @@
Danger Zone
- @if (hasPermissionToDeleteOwnUser) { + @if ( + hasPermissionToDeleteOwnUser && + hasPermissionToUpdateOwnAccessToken + ) {
+ } @else if (hasPermissionToDeleteOwnUser && !hasImpersonationId) { + } @else if ( hasPermissionToRequestOwnUserDeletion && (user?.accounts?.length > 0 || user?.activitiesCount > 0) diff --git a/apps/client/src/app/pages/faq/overview/faq-overview-page.html b/apps/client/src/app/pages/faq/overview/faq-overview-page.html index 66b416fbab..cca488898e 100644 --- a/apps/client/src/app/pages/faq/overview/faq-overview-page.html +++ b/apps/client/src/app/pages/faq/overview/faq-overview-page.html @@ -56,6 +56,65 @@ return). + + + How can I import activities from + Interactive Brokers (IBKR)? + + +
    +
  1. Log in to your Interactive Brokers Client Portal
  2. +
  3. + Navigate to Performance & Reports in the top + menu, then select Flex Queries +
  4. +
  5. + Click the + button next to Activity Flex Query +
  6. +
  7. Select Trades under Sections
  8. +
  9. Select Executions on the pop-up window
  10. +
  11. + Click SELECT ALL and then Save +
  12. +
  13. Enter a Query Name (e.g., "Ghostfolio Export")
  14. +
  15. + Under Delivery Configuration, set Format to + CSV, + Include header and trailer records to + No, Include column headers to + Yes and + Display single column header row to + Yes +
  16. +
  17. Click Create
  18. +
  19. Run the Activity Flex Query
  20. +
  21. + Under Period, select Custom Date Range and choose + the dates you want to export. Run separate exports for longer + histories if necessary +
  22. +
  23. + Set Format to CSV, click + Run and download your trades +
  24. +
  25. + Import the CSV file in Ghostfolio: Portfolio → + Activities → + Import Activities... +
  26. +
+

+ Please note: The export contains trades only. It does not contain + dividends, interest or taxes. Keep only rows with the asset class + STK. Remove all other rows before the import, for + example currency conversions (CASH, such as + EUR.USD). Symbols from exchanges outside the US might + need a manual change. +

+
+
Can I use Ghostfolio anonymously? diff --git a/apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts b/apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts index 5629965006..62debb78a5 100644 --- a/apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts +++ b/apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts @@ -329,36 +329,30 @@ export class GfAnalysisPageComponent implements OnInit { this.performanceDataItems = []; this.performanceDataItemsInPercentage = []; - for (const [ - index, - { - date, - dividendInPercentageWithCurrencyEffect, - netPerformanceInPercentageWithCurrencyEffect, - totalInvestmentValueWithCurrencyEffect, - valueInPercentage, - valueWithCurrencyEffect + for (const { + date, + dividendInPercentageWithCurrencyEffect, + netPerformanceInPercentageWithCurrencyEffect, + totalInvestmentValueWithCurrencyEffect, + valueInPercentage, + valueWithCurrencyEffect + } of chart ?? []) { + if (totalInvestmentValueWithCurrencyEffect !== undefined) { + this.investments.push({ + date, + investment: totalInvestmentValueWithCurrencyEffect + }); } - ] of (chart ?? []).entries()) { - // Ignore first item where value is 0 - if (index > 0 || this.user?.settings?.dateRange === 'max') { - if (totalInvestmentValueWithCurrencyEffect !== undefined) { - this.investments.push({ - date, - investment: totalInvestmentValueWithCurrencyEffect - }); - } - const value = isNumber(valueWithCurrencyEffect) - ? valueWithCurrencyEffect - : valueInPercentage; + const value = isNumber(valueWithCurrencyEffect) + ? valueWithCurrencyEffect + : valueInPercentage; - if (isNumber(value)) { - this.performanceDataItems.push({ - date, - value - }); - } + if (isNumber(value)) { + this.performanceDataItems.push({ + date, + value + }); } if (isNumber(netPerformanceInPercentageWithCurrencyEffect)) { diff --git a/apps/client/src/app/pages/portfolio/analysis/analysis-page.html b/apps/client/src/app/pages/portfolio/analysis/analysis-page.html index 96dfe24c45..c39b774f4b 100644 --- a/apps/client/src/app/pages/portfolio/analysis/analysis-page.html +++ b/apps/client/src/app/pages/portfolio/analysis/analysis-page.html @@ -330,7 +330,7 @@ > -
    +
      @for (holding of top3; track holding) {
    1. -
      - {{ holding.assetProfile.name }} +
      + + {{ holding.assetProfile.name }} +
      -
        +
          @for (holding of bottom3; track holding) {
        1. -
          - {{ holding.assetProfile.name }} +
          + + {{ holding.assetProfile.name }} +