From 430f2fe06ff5fb3f89693643a890d42d41314727 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Thu, 1 Oct 2026 11:06:59 +0200 Subject: [PATCH 01/26] Task/load activities of portfolio calculator tests from export files (#7992) Load activities from export files --- .../portfolio-calculator-test-utils.ts | 40 ++++++++ ...tfolio-calculator-baln-buy-and-buy.spec.ts | 43 +-------- ...folio-calculator-baln-buy-and-sell.spec.ts | 43 +-------- .../portfolio-calculator-baln-buy.spec.ts | 94 +++---------------- ...ator-btcusd-buy-and-sell-partially.spec.ts | 43 +-------- .../portfolio-calculator-btcusd-short.spec.ts | 35 +------ .../roai/portfolio-calculator-btcusd.spec.ts | 35 +------ ...jnug-buy-and-sell-and-buy-and-sell.spec.ts | 37 +------- ...-calculator-msft-buy-with-dividend.spec.ts | 43 +-------- ...ulator-novn-buy-and-sell-partially.spec.ts | 37 +------- ...folio-calculator-novn-buy-and-sell.spec.ts | 37 +------- test/import/ok/baln-buy-and-buy.json | 36 +++++++ test/import/ok/baln-buy-and-sell.json | 36 +++++++ ...h-unit-price-lower-than-closing-price.json | 25 +++++ test/import/ok/baln-buy.json | 25 +++++ .../ok/btcusd-buy-and-sell-partially.json | 36 +++++++ test/import/ok/msft-buy-with-dividend.json | 36 +++++++ 17 files changed, 289 insertions(+), 392 deletions(-) create mode 100644 test/import/ok/baln-buy-and-buy.json create mode 100644 test/import/ok/baln-buy-and-sell.json create mode 100644 test/import/ok/baln-buy-with-unit-price-lower-than-closing-price.json create mode 100644 test/import/ok/baln-buy.json create mode 100644 test/import/ok/btcusd-buy-and-sell-partially.json create mode 100644 test/import/ok/msft-buy-with-dividend.json diff --git a/apps/api/src/app/portfolio/calculator/portfolio-calculator-test-utils.ts b/apps/api/src/app/portfolio/calculator/portfolio-calculator-test-utils.ts index 87474e806c..fb88c8a7f1 100644 --- a/apps/api/src/app/portfolio/calculator/portfolio-calculator-test-utils.ts +++ b/apps/api/src/app/portfolio/calculator/portfolio-calculator-test-utils.ts @@ -1,12 +1,15 @@ import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator'; import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper'; +import { parseDate } from '@ghostfolio/common/helper'; import { + Activity, ExportResponse, HistoricalDataItem } from '@ghostfolio/common/interfaces'; import { DateRange } from '@ghostfolio/common/types'; import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; export const activityDummyData = { accountId: undefined, @@ -71,6 +74,43 @@ export async function getPerformanceByDateRange({ return performanceByDateRange; } +/** + * Loads the activities of an export file in test/import/ok for the portfolio + * calculator. The user currency of the file is the base currency of the test. + * + * The activity currency must be the asset profile currency. The helper does + * not convert the fee to the base currency and does not load the tags. + */ +export function loadActivitiesFromExportFile(fileName: string): { + activities: Activity[]; + userCurrency: string; +} { + const exportResponse = loadExportFile( + join(__dirname, '../../../../../../test/import/ok', fileName) + ); + + const activities: Activity[] = exportResponse.activities.map((activity) => { + return { + ...activityDummyData, + ...activity, + assetProfile: { + ...assetProfileDummyData, + currency: activity.currency, + dataSource: activity.dataSource, + name: activity.symbol, + symbol: activity.symbol + }, + date: parseDate(activity.date), + feeInAssetProfileCurrency: activity.fee, + feeInBaseCurrency: activity.fee, + tags: [], + unitPriceInAssetProfileCurrency: activity.unitPrice + }; + }); + + return { activities, userCurrency: exportResponse.user.settings.currency }; +} + export function loadExportFile(filePath: string): ExportResponse { return JSON.parse(readFileSync(filePath, 'utf8')); } diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-buy.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-buy.spec.ts index 1b2fee98d9..5d3cbcf320 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-buy.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-buy.spec.ts @@ -1,6 +1,5 @@ import { - activityDummyData, - assetProfileDummyData, + loadActivitiesFromExportFile, userDummyData } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; @@ -13,7 +12,6 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate- 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 { parseDate } from '@ghostfolio/common/helper'; -import { Activity } from '@ghostfolio/common/interfaces'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; import { Big } from 'big.js'; @@ -85,45 +83,14 @@ describe('PortfolioCalculator', () => { it.only('with BALN.SW buy and buy', async () => { jest.useFakeTimers().setSystemTime(parseDate('2021-12-18').getTime()); - const activities: Activity[] = [ - { - ...activityDummyData, - assetProfile: { - ...assetProfileDummyData, - currency: 'CHF', - dataSource: 'YAHOO', - name: 'Bâloise Holding AG', - symbol: 'BALN.SW' - }, - date: new Date('2021-11-22'), - feeInAssetProfileCurrency: 1.55, - feeInBaseCurrency: 1.55, - quantity: 2, - type: 'BUY', - unitPriceInAssetProfileCurrency: 142.9 - }, - { - ...activityDummyData, - assetProfile: { - ...assetProfileDummyData, - currency: 'CHF', - dataSource: 'YAHOO', - name: 'Bâloise Holding AG', - symbol: 'BALN.SW' - }, - date: new Date('2021-11-30'), - feeInAssetProfileCurrency: 1.65, - feeInBaseCurrency: 1.65, - quantity: 2, - type: 'BUY', - unitPriceInAssetProfileCurrency: 136.6 - } - ]; + const { activities, userCurrency } = loadActivitiesFromExportFile( + 'baln-buy-and-buy.json' + ); const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ activities, calculationType: PerformanceCalculationType.ROAI, - currency: 'CHF', + currency: userCurrency, userId: userDummyData.id }); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell.spec.ts index 408076717f..9ee4877180 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell.spec.ts @@ -1,7 +1,6 @@ import { - activityDummyData, - assetProfileDummyData, getPerformanceByDateRange, + loadActivitiesFromExportFile, userDummyData } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; @@ -14,7 +13,6 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate- 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 { parseDate } from '@ghostfolio/common/helper'; -import { Activity } from '@ghostfolio/common/interfaces'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; import { Big } from 'big.js'; @@ -86,45 +84,14 @@ describe('PortfolioCalculator', () => { it.only('with BALN.SW buy and sell', async () => { jest.useFakeTimers().setSystemTime(parseDate('2021-12-18').getTime()); - const activities: Activity[] = [ - { - ...activityDummyData, - assetProfile: { - ...assetProfileDummyData, - currency: 'CHF', - dataSource: 'YAHOO', - name: 'Bâloise Holding AG', - symbol: 'BALN.SW' - }, - date: new Date('2021-11-22'), - feeInAssetProfileCurrency: 1.55, - feeInBaseCurrency: 1.55, - quantity: 2, - type: 'BUY', - unitPriceInAssetProfileCurrency: 142.9 - }, - { - ...activityDummyData, - assetProfile: { - ...assetProfileDummyData, - currency: 'CHF', - dataSource: 'YAHOO', - name: 'Bâloise Holding AG', - symbol: 'BALN.SW' - }, - date: new Date('2021-11-30'), - feeInAssetProfileCurrency: 1.65, - feeInBaseCurrency: 1.65, - quantity: 2, - type: 'SELL', - unitPriceInAssetProfileCurrency: 136.6 - } - ]; + const { activities, userCurrency } = loadActivitiesFromExportFile( + 'baln-buy-and-sell.json' + ); const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ activities, calculationType: PerformanceCalculationType.ROAI, - currency: 'CHF', + currency: userCurrency, usePortfolioSnapshotCache: false, userId: userDummyData.id }); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy.spec.ts index 19f3c64369..afae22c1c6 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy.spec.ts @@ -1,6 +1,5 @@ import { - activityDummyData, - assetProfileDummyData, + loadActivitiesFromExportFile, userDummyData } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; @@ -13,7 +12,6 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate- 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 { parseDate } from '@ghostfolio/common/helper'; -import { Activity } from '@ghostfolio/common/interfaces'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; import { Big } from 'big.js'; @@ -85,29 +83,13 @@ describe('PortfolioCalculator', () => { it.only('with BALN.SW buy', async () => { jest.useFakeTimers().setSystemTime(parseDate('2021-12-18').getTime()); - const activities: Activity[] = [ - { - ...activityDummyData, - assetProfile: { - ...assetProfileDummyData, - currency: 'CHF', - dataSource: 'YAHOO', - name: 'Bâloise Holding AG', - symbol: 'BALN.SW' - }, - date: new Date('2021-11-30'), - feeInAssetProfileCurrency: 1.55, - feeInBaseCurrency: 1.55, - quantity: 2, - type: 'BUY', - unitPriceInAssetProfileCurrency: 136.6 - } - ]; + const { activities, userCurrency } = + loadActivitiesFromExportFile('baln-buy.json'); const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ activities, calculationType: PerformanceCalculationType.ROAI, - currency: 'CHF', + currency: userCurrency, userId: userDummyData.id }); @@ -217,29 +199,14 @@ describe('PortfolioCalculator', () => { it.only('with BALN.SW buy (with unit price lower than closing price)', async () => { jest.useFakeTimers().setSystemTime(parseDate('2021-12-18').getTime()); - const activities: Activity[] = [ - { - ...activityDummyData, - assetProfile: { - ...assetProfileDummyData, - currency: 'CHF', - dataSource: 'YAHOO', - name: 'Bâloise Holding AG', - symbol: 'BALN.SW' - }, - date: new Date('2021-11-30'), - feeInAssetProfileCurrency: 1.55, - feeInBaseCurrency: 1.55, - quantity: 2, - type: 'BUY', - unitPriceInAssetProfileCurrency: 135.0 - } - ]; + const { activities, userCurrency } = loadActivitiesFromExportFile( + 'baln-buy-with-unit-price-lower-than-closing-price.json' + ); const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ activities, calculationType: PerformanceCalculationType.ROAI, - currency: 'CHF', + currency: userCurrency, userId: userDummyData.id }); @@ -257,29 +224,14 @@ describe('PortfolioCalculator', () => { it.only('with BALN.SW buy (with unit price lower than closing price), calculated on buy date', async () => { jest.useFakeTimers().setSystemTime(parseDate('2021-11-30').getTime()); - const activities: Activity[] = [ - { - ...activityDummyData, - assetProfile: { - ...assetProfileDummyData, - currency: 'CHF', - dataSource: 'YAHOO', - name: 'Bâloise Holding AG', - symbol: 'BALN.SW' - }, - date: new Date('2021-11-30'), - feeInAssetProfileCurrency: 1.55, - feeInBaseCurrency: 1.55, - quantity: 2, - type: 'BUY', - unitPriceInAssetProfileCurrency: 135.0 - } - ]; + const { activities, userCurrency } = loadActivitiesFromExportFile( + 'baln-buy-with-unit-price-lower-than-closing-price.json' + ); const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ activities, calculationType: PerformanceCalculationType.ROAI, - currency: 'CHF', + currency: userCurrency, userId: userDummyData.id }); @@ -297,29 +249,13 @@ describe('PortfolioCalculator', () => { it.only('with BALN.SW buy (on a date without historical market data)', async () => { jest.useFakeTimers().setSystemTime(parseDate('2021-12-18').getTime()); - const activities: Activity[] = [ - { - ...activityDummyData, - assetProfile: { - ...assetProfileDummyData, - currency: 'CHF', - dataSource: 'YAHOO', - name: 'Bâloise Holding AG', - symbol: 'BALN.SW' - }, - date: new Date('2021-11-30'), - feeInAssetProfileCurrency: 1.55, - feeInBaseCurrency: 1.55, - quantity: 2, - type: 'BUY', - unitPriceInAssetProfileCurrency: 136.6 - } - ]; + const { activities, userCurrency } = + loadActivitiesFromExportFile('baln-buy.json'); const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ activities, calculationType: PerformanceCalculationType.ROAI, - currency: 'CHF', + currency: userCurrency, userId: userDummyData.id }); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts index 6a9ed1f71c..15474a0236 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts @@ -1,7 +1,6 @@ import { - activityDummyData, - assetProfileDummyData, getPerformanceByDateRange, + loadActivitiesFromExportFile, userDummyData } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; @@ -15,7 +14,6 @@ import { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-r 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 { parseDate } from '@ghostfolio/common/helper'; -import { Activity } from '@ghostfolio/common/interfaces'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; import { Big } from 'big.js'; @@ -98,45 +96,14 @@ describe('PortfolioCalculator', () => { it.only('with BTCUSD buy and sell partially', async () => { jest.useFakeTimers().setSystemTime(parseDate('2018-01-01').getTime()); - const activities: Activity[] = [ - { - ...activityDummyData, - assetProfile: { - ...assetProfileDummyData, - currency: 'USD', - dataSource: 'YAHOO', - name: 'Bitcoin USD', - symbol: 'BTCUSD' - }, - date: new Date('2015-01-01'), - feeInAssetProfileCurrency: 0, - feeInBaseCurrency: 0, - quantity: 2, - type: 'BUY', - unitPriceInAssetProfileCurrency: 320.43 - }, - { - ...activityDummyData, - assetProfile: { - ...assetProfileDummyData, - currency: 'USD', - dataSource: 'YAHOO', - name: 'Bitcoin USD', - symbol: 'BTCUSD' - }, - date: new Date('2017-12-31'), - feeInAssetProfileCurrency: 0, - feeInBaseCurrency: 0, - quantity: 1, - type: 'SELL', - unitPriceInAssetProfileCurrency: 14156.4 - } - ]; + const { activities, userCurrency } = loadActivitiesFromExportFile( + 'btcusd-buy-and-sell-partially.json' + ); const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ activities, calculationType: PerformanceCalculationType.ROAI, - currency: 'CHF', + currency: userCurrency, usePortfolioSnapshotCache: false, userId: userDummyData.id }); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-short.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-short.spec.ts index 58dc545e49..356fdebd03 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-short.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-short.spec.ts @@ -1,7 +1,5 @@ import { - activityDummyData, - assetProfileDummyData, - loadExportFile, + loadActivitiesFromExportFile, userDummyData } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; @@ -14,11 +12,9 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate- 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 { parseDate } from '@ghostfolio/common/helper'; -import { Activity, ExportResponse } from '@ghostfolio/common/interfaces'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; import { Big } from 'big.js'; -import { join } from 'node:path'; jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { return { @@ -48,8 +44,6 @@ jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { }); describe('PortfolioCalculator', () => { - let exportResponse: ExportResponse; - let configurationService: ConfigurationService; let currentRateService: CurrentRateService; let exchangeRateDataService: ExchangeRateDataService; @@ -57,12 +51,6 @@ describe('PortfolioCalculator', () => { let portfolioSnapshotService: PortfolioSnapshotService; let redisCacheService: RedisCacheService; - beforeAll(() => { - exportResponse = loadExportFile( - join(__dirname, '../../../../../../../test/import/ok/btcusd-short.json') - ); - }); - beforeEach(() => { PortfolioSnapshotServiceMock.reset(); RedisCacheServiceMock.reset(); @@ -95,28 +83,13 @@ describe('PortfolioCalculator', () => { it.only('with BTCUSD short sell (in USD)', async () => { jest.useFakeTimers().setSystemTime(parseDate('2022-01-14').getTime()); - const activities: Activity[] = exportResponse.activities.map( - (activity) => ({ - ...activityDummyData, - ...activity, - assetProfile: { - ...assetProfileDummyData, - currency: 'USD', - dataSource: activity.dataSource, - name: 'Bitcoin', - symbol: activity.symbol - }, - date: parseDate(activity.date), - feeInAssetProfileCurrency: activity.fee, - feeInBaseCurrency: activity.fee, - unitPriceInAssetProfileCurrency: activity.unitPrice - }) - ); + const { activities, userCurrency } = + loadActivitiesFromExportFile('btcusd-short.json'); const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ activities, calculationType: PerformanceCalculationType.ROAI, - currency: exportResponse.user.settings.currency, + currency: userCurrency, userId: userDummyData.id }); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd.spec.ts index e0cfeb8227..0d0f6cfc3b 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd.spec.ts @@ -1,7 +1,5 @@ import { - activityDummyData, - assetProfileDummyData, - loadExportFile, + loadActivitiesFromExportFile, userDummyData } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; @@ -14,11 +12,9 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate- 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 { parseDate } from '@ghostfolio/common/helper'; -import { Activity, ExportResponse } from '@ghostfolio/common/interfaces'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; import { Big } from 'big.js'; -import { join } from 'node:path'; jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { return { @@ -48,8 +44,6 @@ jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { }); describe('PortfolioCalculator', () => { - let exportResponse: ExportResponse; - let configurationService: ConfigurationService; let currentRateService: CurrentRateService; let exchangeRateDataService: ExchangeRateDataService; @@ -57,12 +51,6 @@ describe('PortfolioCalculator', () => { let portfolioSnapshotService: PortfolioSnapshotService; let redisCacheService: RedisCacheService; - beforeAll(() => { - exportResponse = loadExportFile( - join(__dirname, '../../../../../../../test/import/ok/btcusd.json') - ); - }); - beforeEach(() => { PortfolioSnapshotServiceMock.reset(); RedisCacheServiceMock.reset(); @@ -95,28 +83,13 @@ describe('PortfolioCalculator', () => { it.only('with BTCUSD buy (in USD)', async () => { jest.useFakeTimers().setSystemTime(parseDate('2022-01-14').getTime()); - const activities: Activity[] = exportResponse.activities.map( - (activity) => ({ - ...activityDummyData, - ...activity, - assetProfile: { - ...assetProfileDummyData, - currency: 'USD', - dataSource: activity.dataSource, - name: 'Bitcoin', - symbol: activity.symbol - }, - date: parseDate(activity.date), - feeInAssetProfileCurrency: 4.46, - feeInBaseCurrency: 4.46, - unitPriceInAssetProfileCurrency: 44558.42 - }) - ); + const { activities, userCurrency } = + loadActivitiesFromExportFile('btcusd.json'); const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ activities, calculationType: PerformanceCalculationType.ROAI, - currency: exportResponse.user.settings.currency, + currency: userCurrency, userId: userDummyData.id }); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-jnug-buy-and-sell-and-buy-and-sell.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-jnug-buy-and-sell-and-buy-and-sell.spec.ts index ba2b7d05c6..b2e0ffab6b 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-jnug-buy-and-sell-and-buy-and-sell.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-jnug-buy-and-sell-and-buy-and-sell.spec.ts @@ -1,7 +1,5 @@ import { - activityDummyData, - assetProfileDummyData, - loadExportFile, + loadActivitiesFromExportFile, userDummyData } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; @@ -14,11 +12,9 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate- 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 { parseDate } from '@ghostfolio/common/helper'; -import { Activity, ExportResponse } from '@ghostfolio/common/interfaces'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; import { Big } from 'big.js'; -import { join } from 'node:path'; jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { return { @@ -48,8 +44,6 @@ jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { }); describe('PortfolioCalculator', () => { - let exportResponse: ExportResponse; - let configurationService: ConfigurationService; let currentRateService: CurrentRateService; let exchangeRateDataService: ExchangeRateDataService; @@ -57,15 +51,6 @@ describe('PortfolioCalculator', () => { let portfolioSnapshotService: PortfolioSnapshotService; let redisCacheService: RedisCacheService; - beforeAll(() => { - exportResponse = loadExportFile( - join( - __dirname, - '../../../../../../../test/import/ok/jnug-buy-and-sell-and-buy-and-sell.json' - ) - ); - }); - beforeEach(() => { PortfolioSnapshotServiceMock.reset(); RedisCacheServiceMock.reset(); @@ -98,28 +83,14 @@ describe('PortfolioCalculator', () => { it.only('with JNUG buy and sell', async () => { jest.useFakeTimers().setSystemTime(parseDate('2025-12-28').getTime()); - const activities: Activity[] = exportResponse.activities.map( - (activity) => ({ - ...activityDummyData, - ...activity, - assetProfile: { - ...assetProfileDummyData, - currency: activity.currency, - dataSource: activity.dataSource, - name: 'Direxion Daily Junior Gold Miners Index Bull 2X Shares', - symbol: activity.symbol - }, - date: parseDate(activity.date), - feeInAssetProfileCurrency: activity.fee, - feeInBaseCurrency: activity.fee, - unitPriceInAssetProfileCurrency: activity.unitPrice - }) + const { activities, userCurrency } = loadActivitiesFromExportFile( + 'jnug-buy-and-sell-and-buy-and-sell.json' ); const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ activities, calculationType: PerformanceCalculationType.ROAI, - currency: exportResponse.user.settings.currency, + currency: userCurrency, userId: userDummyData.id }); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-with-dividend.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-with-dividend.spec.ts index 2c6ff79b6d..649542e325 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-with-dividend.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-with-dividend.spec.ts @@ -1,7 +1,6 @@ import { - activityDummyData, - assetProfileDummyData, getPerformanceByDateRange, + loadActivitiesFromExportFile, userDummyData } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; @@ -14,7 +13,6 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate- 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 { parseDate } from '@ghostfolio/common/helper'; -import { Activity } from '@ghostfolio/common/interfaces'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; import { Big } from 'big.js'; @@ -86,45 +84,14 @@ describe('PortfolioCalculator', () => { it.only('with MSFT buy', async () => { jest.useFakeTimers().setSystemTime(parseDate('2023-07-10').getTime()); - const activities: Activity[] = [ - { - ...activityDummyData, - assetProfile: { - ...assetProfileDummyData, - currency: 'USD', - dataSource: 'YAHOO', - name: 'Microsoft Inc.', - symbol: 'MSFT' - }, - date: new Date('2021-09-16'), - feeInAssetProfileCurrency: 19, - feeInBaseCurrency: 19, - quantity: 1, - type: 'BUY', - unitPriceInAssetProfileCurrency: 298.58 - }, - { - ...activityDummyData, - assetProfile: { - ...assetProfileDummyData, - currency: 'USD', - dataSource: 'YAHOO', - name: 'Microsoft Inc.', - symbol: 'MSFT' - }, - date: new Date('2021-11-16'), - feeInAssetProfileCurrency: 0, - feeInBaseCurrency: 0, - quantity: 1, - type: 'DIVIDEND', - unitPriceInAssetProfileCurrency: 0.62 - } - ]; + const { activities, userCurrency } = loadActivitiesFromExportFile( + 'msft-buy-with-dividend.json' + ); const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ activities, calculationType: PerformanceCalculationType.ROAI, - currency: 'USD', + currency: userCurrency, usePortfolioSnapshotCache: false, userId: userDummyData.id }); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell-partially.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell-partially.spec.ts index 92d078d811..e85a053e61 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell-partially.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell-partially.spec.ts @@ -1,7 +1,5 @@ import { - activityDummyData, - assetProfileDummyData, - loadExportFile, + loadActivitiesFromExportFile, userDummyData } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; @@ -14,11 +12,9 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate- 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 { parseDate } from '@ghostfolio/common/helper'; -import { Activity, ExportResponse } from '@ghostfolio/common/interfaces'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; import { Big } from 'big.js'; -import { join } from 'node:path'; jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { return { @@ -48,8 +44,6 @@ jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { }); describe('PortfolioCalculator', () => { - let exportResponse: ExportResponse; - let configurationService: ConfigurationService; let currentRateService: CurrentRateService; let exchangeRateDataService: ExchangeRateDataService; @@ -57,15 +51,6 @@ describe('PortfolioCalculator', () => { let portfolioSnapshotService: PortfolioSnapshotService; let redisCacheService: RedisCacheService; - beforeAll(() => { - exportResponse = loadExportFile( - join( - __dirname, - '../../../../../../../test/import/ok/novn-buy-and-sell-partially.json' - ) - ); - }); - beforeEach(() => { PortfolioSnapshotServiceMock.reset(); RedisCacheServiceMock.reset(); @@ -98,28 +83,14 @@ describe('PortfolioCalculator', () => { it.only('with NOVN.SW buy and sell partially', async () => { jest.useFakeTimers().setSystemTime(parseDate('2022-04-11').getTime()); - const activities: Activity[] = exportResponse.activities.map( - (activity) => ({ - ...activityDummyData, - ...activity, - assetProfile: { - ...assetProfileDummyData, - currency: activity.currency, - dataSource: activity.dataSource, - name: 'Novartis AG', - symbol: activity.symbol - }, - date: parseDate(activity.date), - feeInAssetProfileCurrency: activity.fee, - feeInBaseCurrency: activity.fee, - unitPriceInAssetProfileCurrency: activity.unitPrice - }) + const { activities, userCurrency } = loadActivitiesFromExportFile( + 'novn-buy-and-sell-partially.json' ); const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ activities, calculationType: PerformanceCalculationType.ROAI, - currency: exportResponse.user.settings.currency, + currency: userCurrency, userId: userDummyData.id }); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell.spec.ts index 46e1b14aa4..a0174b048e 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell.spec.ts @@ -1,7 +1,5 @@ import { - activityDummyData, - assetProfileDummyData, - loadExportFile, + loadActivitiesFromExportFile, userDummyData } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; @@ -14,11 +12,9 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate- 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 { parseDate } from '@ghostfolio/common/helper'; -import { Activity, ExportResponse } from '@ghostfolio/common/interfaces'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; import { Big } from 'big.js'; -import { join } from 'node:path'; jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { return { @@ -48,8 +44,6 @@ jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { }); describe('PortfolioCalculator', () => { - let exportResponse: ExportResponse; - let configurationService: ConfigurationService; let currentRateService: CurrentRateService; let exchangeRateDataService: ExchangeRateDataService; @@ -57,15 +51,6 @@ describe('PortfolioCalculator', () => { let portfolioSnapshotService: PortfolioSnapshotService; let redisCacheService: RedisCacheService; - beforeAll(() => { - exportResponse = loadExportFile( - join( - __dirname, - '../../../../../../../test/import/ok/novn-buy-and-sell.json' - ) - ); - }); - beforeEach(() => { PortfolioSnapshotServiceMock.reset(); RedisCacheServiceMock.reset(); @@ -98,28 +83,14 @@ describe('PortfolioCalculator', () => { it.only('with NOVN.SW buy and sell', async () => { jest.useFakeTimers().setSystemTime(parseDate('2022-04-11').getTime()); - const activities: Activity[] = exportResponse.activities.map( - (activity) => ({ - ...activityDummyData, - ...activity, - assetProfile: { - ...assetProfileDummyData, - currency: activity.currency, - dataSource: activity.dataSource, - name: 'Novartis AG', - symbol: activity.symbol - }, - date: parseDate(activity.date), - feeInAssetProfileCurrency: activity.fee, - feeInBaseCurrency: activity.fee, - unitPriceInAssetProfileCurrency: activity.unitPrice - }) + const { activities, userCurrency } = loadActivitiesFromExportFile( + 'novn-buy-and-sell.json' ); const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ activities, calculationType: PerformanceCalculationType.ROAI, - currency: exportResponse.user.settings.currency, + currency: userCurrency, userId: userDummyData.id }); diff --git a/test/import/ok/baln-buy-and-buy.json b/test/import/ok/baln-buy-and-buy.json new file mode 100644 index 0000000000..c5c33764b1 --- /dev/null +++ b/test/import/ok/baln-buy-and-buy.json @@ -0,0 +1,36 @@ +{ + "meta": { + "date": "2026-10-01T00:00:00.000Z", + "version": "dev" + }, + "activities": [ + { + "currency": "CHF", + "dataSource": "YAHOO", + "date": "2021-11-22T00:00:00.000Z", + "fee": 1.55, + "id": "fe7d42ee-e607-58b7-a183-9f2f894fe204", + "quantity": 2, + "symbol": "BALN.SW", + "type": "BUY", + "unitPrice": 142.9 + }, + { + "currency": "CHF", + "dataSource": "YAHOO", + "date": "2021-11-30T00:00:00.000Z", + "fee": 1.65, + "id": "716b3742-6326-5fb1-a420-5f21b97316ea", + "quantity": 2, + "symbol": "BALN.SW", + "type": "BUY", + "unitPrice": 136.6 + } + ], + "user": { + "settings": { + "currency": "CHF", + "performanceCalculationType": "ROAI" + } + } +} diff --git a/test/import/ok/baln-buy-and-sell.json b/test/import/ok/baln-buy-and-sell.json new file mode 100644 index 0000000000..7bb096e51c --- /dev/null +++ b/test/import/ok/baln-buy-and-sell.json @@ -0,0 +1,36 @@ +{ + "meta": { + "date": "2026-10-01T00:00:00.000Z", + "version": "dev" + }, + "activities": [ + { + "currency": "CHF", + "dataSource": "YAHOO", + "date": "2021-11-22T00:00:00.000Z", + "fee": 1.55, + "id": "9269a332-dec6-51b5-9c9d-60dec1cf3d25", + "quantity": 2, + "symbol": "BALN.SW", + "type": "BUY", + "unitPrice": 142.9 + }, + { + "currency": "CHF", + "dataSource": "YAHOO", + "date": "2021-11-30T00:00:00.000Z", + "fee": 1.65, + "id": "aafb6d62-3486-5106-bff5-9e06b91b8d89", + "quantity": 2, + "symbol": "BALN.SW", + "type": "SELL", + "unitPrice": 136.6 + } + ], + "user": { + "settings": { + "currency": "CHF", + "performanceCalculationType": "ROAI" + } + } +} diff --git a/test/import/ok/baln-buy-with-unit-price-lower-than-closing-price.json b/test/import/ok/baln-buy-with-unit-price-lower-than-closing-price.json new file mode 100644 index 0000000000..bef2f1eaa8 --- /dev/null +++ b/test/import/ok/baln-buy-with-unit-price-lower-than-closing-price.json @@ -0,0 +1,25 @@ +{ + "meta": { + "date": "2026-10-01T00:00:00.000Z", + "version": "dev" + }, + "activities": [ + { + "currency": "CHF", + "dataSource": "YAHOO", + "date": "2021-11-30T00:00:00.000Z", + "fee": 1.55, + "id": "99a0844c-53ff-58b9-b120-e9339ea3ba22", + "quantity": 2, + "symbol": "BALN.SW", + "type": "BUY", + "unitPrice": 135.0 + } + ], + "user": { + "settings": { + "currency": "CHF", + "performanceCalculationType": "ROAI" + } + } +} diff --git a/test/import/ok/baln-buy.json b/test/import/ok/baln-buy.json new file mode 100644 index 0000000000..8435b79137 --- /dev/null +++ b/test/import/ok/baln-buy.json @@ -0,0 +1,25 @@ +{ + "meta": { + "date": "2026-10-01T00:00:00.000Z", + "version": "dev" + }, + "activities": [ + { + "currency": "CHF", + "dataSource": "YAHOO", + "date": "2021-11-30T00:00:00.000Z", + "fee": 1.55, + "id": "d82cae72-04b1-5beb-9d84-8e7740cd1b44", + "quantity": 2, + "symbol": "BALN.SW", + "type": "BUY", + "unitPrice": 136.6 + } + ], + "user": { + "settings": { + "currency": "CHF", + "performanceCalculationType": "ROAI" + } + } +} diff --git a/test/import/ok/btcusd-buy-and-sell-partially.json b/test/import/ok/btcusd-buy-and-sell-partially.json new file mode 100644 index 0000000000..b91741245d --- /dev/null +++ b/test/import/ok/btcusd-buy-and-sell-partially.json @@ -0,0 +1,36 @@ +{ + "meta": { + "date": "2026-10-01T00:00:00.000Z", + "version": "dev" + }, + "activities": [ + { + "currency": "USD", + "dataSource": "YAHOO", + "date": "2015-01-01T00:00:00.000Z", + "fee": 0, + "id": "0d9ac134-2329-5234-8b59-8aaa6424e79d", + "quantity": 2, + "symbol": "BTCUSD", + "type": "BUY", + "unitPrice": 320.43 + }, + { + "currency": "USD", + "dataSource": "YAHOO", + "date": "2017-12-31T00:00:00.000Z", + "fee": 0, + "id": "f7a55970-57b0-52b3-9846-c7c306645050", + "quantity": 1, + "symbol": "BTCUSD", + "type": "SELL", + "unitPrice": 14156.4 + } + ], + "user": { + "settings": { + "currency": "CHF", + "performanceCalculationType": "ROAI" + } + } +} diff --git a/test/import/ok/msft-buy-with-dividend.json b/test/import/ok/msft-buy-with-dividend.json new file mode 100644 index 0000000000..c4f20e2898 --- /dev/null +++ b/test/import/ok/msft-buy-with-dividend.json @@ -0,0 +1,36 @@ +{ + "meta": { + "date": "2026-10-01T00:00:00.000Z", + "version": "dev" + }, + "activities": [ + { + "currency": "USD", + "dataSource": "YAHOO", + "date": "2021-09-16T00:00:00.000Z", + "fee": 19, + "id": "dcec53cf-61d7-522b-985f-fd94b1f074a0", + "quantity": 1, + "symbol": "MSFT", + "type": "BUY", + "unitPrice": 298.58 + }, + { + "currency": "USD", + "dataSource": "YAHOO", + "date": "2021-11-16T00:00:00.000Z", + "fee": 0, + "id": "ac5970cf-4bb9-5385-8faf-9dd1efaca883", + "quantity": 1, + "symbol": "MSFT", + "type": "DIVIDEND", + "unitPrice": 0.62 + } + ], + "user": { + "settings": { + "currency": "USD", + "performanceCalculationType": "ROAI" + } + } +} From a168e77eb64149ac79bd6763ca866c5eb730af15 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Thu, 1 Oct 2026 19:42:38 +0200 Subject: [PATCH 02/26] Task/upgrade prettier to version 3.9.9 (#7988) * Update prettier to version 3.9.9 * Update changelog --- CHANGELOG.md | 6 ++++++ package-lock.json | 8 ++++---- package.json | 2 +- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index db21112dce..8e4b4ca6df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased + +### Changed + +- Upgraded `prettier` from version `3.9.6` to `3.9.9` + ## 3.76.0 - 2026-09-30 ### Added diff --git a/package-lock.json b/package-lock.json index a5dad5f892..44193a3624 100644 --- a/package-lock.json +++ b/package-lock.json @@ -162,7 +162,7 @@ "jest-environment-node": "30.3.0", "jest-preset-angular": "17.0.0", "nx": "23.1.1", - "prettier": "3.9.6", + "prettier": "3.9.9", "prettier-plugin-organize-attributes": "1.0.0", "prisma": "7.10.0", "react": "18.2.0", @@ -30600,9 +30600,9 @@ } }, "node_modules/prettier": { - "version": "3.9.6", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", - "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", + "version": "3.9.9", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.9.tgz", + "integrity": "sha512-Z/CJHIkdujO/OtN7nXUii0Rf3VT5SRuhjBA82Xvu2XhBUgX3nhP67T0LHceBdQLex7OOFGTox+Q5Yg8Jk2Qivg==", "dev": true, "license": "MIT", "bin": { diff --git a/package.json b/package.json index fcb94759f7..1d87566678 100644 --- a/package.json +++ b/package.json @@ -206,7 +206,7 @@ "jest-environment-node": "30.3.0", "jest-preset-angular": "17.0.0", "nx": "23.1.1", - "prettier": "3.9.6", + "prettier": "3.9.9", "prettier-plugin-organize-attributes": "1.0.0", "prisma": "7.10.0", "react": "18.2.0", From 4beba1de3def9b7e7eab616c090e52d709c34a32 Mon Sep 17 00:00:00 2001 From: Kenrick Tandrian <60643640+KenTandrian@users.noreply.github.com> Date: Fri, 2 Oct 2026 01:10:59 +0700 Subject: [PATCH 03/26] Task/remove deprecated undefined loading state from value component (#7964) Remove deprecated undefined loading state --- libs/ui/src/lib/value/value.component.html | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/libs/ui/src/lib/value/value.component.html b/libs/ui/src/lib/value/value.component.html index c5ebe4e2cf..385a49ac10 100644 --- a/libs/ui/src/lib/value/value.component.html +++ b/libs/ui/src/lib/value/value.component.html @@ -27,11 +27,7 @@ } - - @if (isLoading() || value === undefined) { + @if (isLoading()) { =22.19.0" diff --git a/package.json b/package.json index 1d87566678..4ef3317529 100644 --- a/package.json +++ b/package.json @@ -148,7 +148,7 @@ "svgmap": "2.21.0", "tablemark": "4.1.0", "twitter-api-v2": "1.29.1", - "undici": "8.10.0", + "undici": "8.11.2", "uuid": "14.0.2", "yahoo-finance2": "4.0.2", "zod": "4.6.5", From 4fc30a69119c761e194b5845a61d8148974a2611 Mon Sep 17 00:00:00 2001 From: Kenrick Tandrian <60643640+KenTandrian@users.noreply.github.com> Date: Fri, 2 Oct 2026 14:30:07 +0700 Subject: [PATCH 08/26] Task/align chart data array types with LineChartItem and make groupBy optional (#7996) Align chart data array types with LineChartItem and make groupBy optional --- .../account-detail-dialog.component.ts | 18 +++++++---- .../investment-chart.component.ts | 2 +- .../analysis/analysis-page.component.ts | 32 +++++++++++-------- 3 files changed, 31 insertions(+), 21 deletions(-) diff --git a/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts b/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts index 44f7280473..c95dfe7bc4 100644 --- a/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts +++ b/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts @@ -10,7 +10,7 @@ import { DATE_FORMAT, downloadAsFile } from '@ghostfolio/common/helper'; import { AccountBalancesResponse, Activity, - HistoricalDataItem, + LineChartItem, PortfolioPosition, User } from '@ghostfolio/common/interfaces'; @@ -99,7 +99,7 @@ export class GfAccountDetailDialogComponent implements OnInit { protected equity: number | null; protected equityPrecision = 2; protected hasPermissionToDeleteAccountBalance: boolean; - protected historicalDataItems: HistoricalDataItem[]; + protected historicalDataItems: LineChartItem[]; protected holdings: PortfolioPosition[]; protected interestInBaseCurrency: number; protected interestInBaseCurrencyPrecision = 2; @@ -364,12 +364,16 @@ export class GfAccountDetailDialogComponent implements OnInit { portfolioPerformance.chart && portfolioPerformance.chart.length > 0 ) { - this.historicalDataItems = portfolioPerformance.chart.map( - ({ date, netWorth, netWorthInPercentage }) => ({ - date, - value: isNumber(netWorth) ? netWorth : netWorthInPercentage + this.historicalDataItems = portfolioPerformance.chart + .map(({ date, netWorth, netWorthInPercentage }) => { + return { + date, + value: isNumber(netWorth) ? netWorth : netWorthInPercentage + }; }) - ); + .filter((item): item is LineChartItem => { + return isNumber(item.value); + }); } else { this.historicalDataItems = this.accountBalances.map( ({ date, valueInBaseCurrency }) => { diff --git a/apps/client/src/app/components/investment-chart/investment-chart.component.ts b/apps/client/src/app/components/investment-chart/investment-chart.component.ts index a1dc6a28f9..34500422ed 100644 --- a/apps/client/src/app/components/investment-chart/investment-chart.component.ts +++ b/apps/client/src/app/components/investment-chart/investment-chart.component.ts @@ -59,7 +59,7 @@ export class GfInvestmentChartComponent implements OnChanges, OnDestroy { @Input() public benchmarkDataLabel = ''; @Input() public colorScheme?: ColorScheme; @Input() public currency?: string; - @Input() public groupBy: GroupBy; + @Input() public groupBy?: GroupBy; @Input() public historicalDataItems: LineChartItem[] = []; @Input() public isInPercentage?: boolean = false; @Input() public isLoading = false; 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 880753cce5..a08c9d9d52 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 @@ -7,8 +7,8 @@ import { } from '@ghostfolio/common/config'; import { canOpenHoldingDetail } from '@ghostfolio/common/helper'; import { - HistoricalDataItem, InvestmentItem, + LineChartItem, PortfolioInvestmentsResponse, PortfolioPerformance, PortfolioPosition, @@ -79,7 +79,7 @@ import { forkJoin } from 'rxjs'; }) export class GfAnalysisPageComponent implements OnInit { protected benchmark?: Partial; - protected benchmarkDataItems: HistoricalDataItem[] = []; + protected benchmarkDataItems: LineChartItem[] = []; protected readonly benchmarks: Partial[]; protected bottom3: PortfolioPosition[]; protected dividendsByGroup: InvestmentItem[]; @@ -101,8 +101,8 @@ export class GfAnalysisPageComponent implements OnInit { ]; protected performance: PortfolioPerformance; protected readonly PerformanceCalculationType = PerformanceCalculationType; - protected performanceDataItems: HistoricalDataItem[]; - protected performanceDataItemsInPercentage: HistoricalDataItem[]; + protected performanceDataItems: LineChartItem[]; + protected performanceDataItemsInPercentage: LineChartItem[]; protected readonly portfolioEvolutionDataLabel = $localize`Investment`; protected precision = 2; protected savingsRatePerMonth: number | undefined; @@ -378,18 +378,24 @@ export class GfAnalysisPageComponent implements OnInit { }); } - this.performanceDataItems.push({ + const value = isNumber(valueWithCurrencyEffect) + ? valueWithCurrencyEffect + : valueInPercentage; + + if (isNumber(value)) { + this.performanceDataItems.push({ + date, + value + }); + } + } + + if (isNumber(netPerformanceInPercentageWithCurrencyEffect)) { + this.performanceDataItemsInPercentage.push({ date, - value: isNumber(valueWithCurrencyEffect) - ? valueWithCurrencyEffect - : valueInPercentage + value: netPerformanceInPercentageWithCurrencyEffect }); } - - this.performanceDataItemsInPercentage.push({ - date, - value: netPerformanceInPercentageWithCurrencyEffect - }); } if ( From 452674eceecf5226cfb82dc70bb1d027edb85a74 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Fri, 2 Oct 2026 10:00:34 +0200 Subject: [PATCH 09/26] Task/improve symbol validation of MCP import tool (#8000) Improve symbol validation --- .../src/app/endpoints/mcp/mcp.schemas.spec.ts | 35 ++++++++++++++++++- apps/api/src/app/endpoints/mcp/mcp.schemas.ts | 16 +++++---- 2 files changed, 43 insertions(+), 8 deletions(-) diff --git a/apps/api/src/app/endpoints/mcp/mcp.schemas.spec.ts b/apps/api/src/app/endpoints/mcp/mcp.schemas.spec.ts index 2039e7cd91..a5109d225f 100644 --- a/apps/api/src/app/endpoints/mcp/mcp.schemas.spec.ts +++ b/apps/api/src/app/endpoints/mcp/mcp.schemas.spec.ts @@ -2,7 +2,8 @@ import { DEFAULT_DATE_RANGE, MCP_MAX_ACTIVITIES, SEARCH_QUERY_MAXIMUM_LENGTH, - SEARCH_QUERY_MINIMUM_LENGTH + SEARCH_QUERY_MINIMUM_LENGTH, + SYMBOL_MAXIMUM_LENGTH } from '@ghostfolio/common/config'; import { DataSource } from '@prisma/client'; @@ -107,6 +108,38 @@ describe('IMPORT_ACTIVITIES_PARAMETERS', () => { expect(parse([createActivity({ symbol: '' })])).toBe(false); }); + it('Refuses a symbol that contains only spaces', () => { + expect(parse([createActivity({ symbol: ' ' })])).toBe(false); + }); + + it(`Accepts a symbol of ${SYMBOL_MAXIMUM_LENGTH} characters`, () => { + expect( + parse([createActivity({ symbol: 'A'.repeat(SYMBOL_MAXIMUM_LENGTH) })]) + ).toBe(true); + }); + + it(`Accepts a symbol of ${SYMBOL_MAXIMUM_LENGTH} characters with spaces at the start and the end`, () => { + expect( + parse([ + createActivity({ symbol: ` ${'A'.repeat(SYMBOL_MAXIMUM_LENGTH)} ` }) + ]) + ).toBe(true); + }); + + it(`Refuses a symbol longer than ${SYMBOL_MAXIMUM_LENGTH} characters`, () => { + expect( + parse([createActivity({ symbol: 'A'.repeat(SYMBOL_MAXIMUM_LENGTH + 1) })]) + ).toBe(false); + }); + + it('Removes spaces at the start and the end of a symbol', () => { + expect( + IMPORT_ACTIVITIES_PARAMETERS.parse({ + activities: [createActivity({ symbol: ' AAPL ' })] + }).activities[0].symbol + ).toBe('AAPL'); + }); + it('Refuses an empty identifier of an account', () => { expect(parse([createActivity({ accountId: '' })])).toBe(false); }); diff --git a/apps/api/src/app/endpoints/mcp/mcp.schemas.ts b/apps/api/src/app/endpoints/mcp/mcp.schemas.ts index 4bcc385b35..dc075377d0 100644 --- a/apps/api/src/app/endpoints/mcp/mcp.schemas.ts +++ b/apps/api/src/app/endpoints/mcp/mcp.schemas.ts @@ -16,16 +16,18 @@ import { import { AssetClass, DataSource, Type as ActivityType } from '@prisma/client'; import { z } from 'zod'; +const SYMBOL_PARAMETER = z + .string() + .trim() + .min(1) + .max(SYMBOL_MAXIMUM_LENGTH) + .describe('The symbol of the asset profile'); + const HOLDING_PARAMETER = z.object({ dataSource: z .enum(DataSource) .describe('The data source of the asset profile'), - symbol: z - .string() - .trim() - .min(1) - .max(SYMBOL_MAXIMUM_LENGTH) - .describe('The symbol of the asset profile') + symbol: SYMBOL_PARAMETER }); export const GET_ACCOUNTS_PARAMETERS = z.object({ @@ -141,7 +143,7 @@ export const IMPORT_ACTIVITIES_PARAMETERS = z.object({ ), fee: z.number().min(0).describe('The fee of the activity'), quantity: z.number().min(0).describe('The quantity of the activity'), - symbol: z.string().min(1).describe('The symbol of the asset profile'), + symbol: SYMBOL_PARAMETER, type: z.enum(ActivityType).describe('The type of the activity'), unitPrice: z.number().min(0).describe('The unit price of the activity') }) From bdacd08db2dbcd17321bb157e4b3e3c468033c96 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Fri, 2 Oct 2026 10:33:33 +0200 Subject: [PATCH 10/26] Task/improve comment validation of MCP import tool (#8004) Improve comment validation --- .../src/app/endpoints/mcp/mcp.schemas.spec.ts | 25 +++++++++++++++++++ apps/api/src/app/endpoints/mcp/mcp.schemas.ts | 8 +++++- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/apps/api/src/app/endpoints/mcp/mcp.schemas.spec.ts b/apps/api/src/app/endpoints/mcp/mcp.schemas.spec.ts index a5109d225f..dbc5bc698c 100644 --- a/apps/api/src/app/endpoints/mcp/mcp.schemas.spec.ts +++ b/apps/api/src/app/endpoints/mcp/mcp.schemas.spec.ts @@ -1,4 +1,5 @@ import { + COMMENT_MAXIMUM_LENGTH, DEFAULT_DATE_RANGE, MCP_MAX_ACTIVITIES, SEARCH_QUERY_MAXIMUM_LENGTH, @@ -92,6 +93,30 @@ describe('IMPORT_ACTIVITIES_PARAMETERS', () => { return IMPORT_ACTIVITIES_PARAMETERS.safeParse({ activities }).success; } + it(`Accepts a comment of ${COMMENT_MAXIMUM_LENGTH} characters with spaces at the start and the end`, () => { + expect( + parse([ + createActivity({ comment: ` ${'A'.repeat(COMMENT_MAXIMUM_LENGTH)} ` }) + ]) + ).toBe(true); + }); + + it(`Refuses a comment longer than ${COMMENT_MAXIMUM_LENGTH} characters`, () => { + expect( + parse([ + createActivity({ comment: 'A'.repeat(COMMENT_MAXIMUM_LENGTH + 1) }) + ]) + ).toBe(false); + }); + + it('Removes spaces at the start and the end of a comment', () => { + expect( + IMPORT_ACTIVITIES_PARAMETERS.parse({ + activities: [createActivity({ comment: ' note ' })] + }).activities[0].comment + ).toBe('note'); + }); + it('Refuses a currency in lower case', () => { expect(parse([createActivity({ currency: 'usd' })])).toBe(false); }); diff --git a/apps/api/src/app/endpoints/mcp/mcp.schemas.ts b/apps/api/src/app/endpoints/mcp/mcp.schemas.ts index dc075377d0..ce66699e91 100644 --- a/apps/api/src/app/endpoints/mcp/mcp.schemas.ts +++ b/apps/api/src/app/endpoints/mcp/mcp.schemas.ts @@ -1,5 +1,6 @@ import { DATE_RANGE_PATTERN } from '@ghostfolio/api/dtos/date-range-filter.dto'; import { + COMMENT_MAXIMUM_LENGTH, DATE_RANGES, DEFAULT_DATE_RANGE, MCP_MAX_ACCOUNTS, @@ -124,7 +125,12 @@ export const IMPORT_ACTIVITIES_PARAMETERS = z.object({ .min(1) .optional() .describe('The identifier of the account of the activity'), - comment: z.string().optional().describe('The comment of the activity'), + comment: z + .string() + .trim() + .max(COMMENT_MAXIMUM_LENGTH) + .optional() + .describe('The comment of the activity'), currency: z .string() .refine(isValidCurrencyCode) From df935e65223ee176cefac5bb5c0b6c5888f411ef Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Fri, 2 Oct 2026 10:36:56 +0200 Subject: [PATCH 11/26] Release 3.77.0 (#8006) --- CHANGELOG.md | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index df97699608..2e0ad31db0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## Unreleased +## 3.77.0 - 2026-10-02 ### Added diff --git a/package-lock.json b/package-lock.json index e4562f996d..877a61cb0d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ghostfolio", - "version": "3.76.0", + "version": "3.77.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ghostfolio", - "version": "3.76.0", + "version": "3.77.0", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { diff --git a/package.json b/package.json index 4ef3317529..264cf0a21b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ghostfolio", - "version": "3.76.0", + "version": "3.77.0", "homepage": "https://ghostfol.io", "license": "AGPL-3.0", "repository": "https://github.com/ghostfolio/ghostfolio", From 6cba7744b572021ab42306e233bc297aa6bc5a60 Mon Sep 17 00:00:00 2001 From: Kenrick Tandrian <60643640+KenTandrian@users.noreply.github.com> Date: Sat, 3 Oct 2026 02:16:41 +0700 Subject: [PATCH 12/26] Task/widen input types in portfolio summary component (#8010) feat(client): widen input types in portfolio summary component --- .../portfolio-summary.component.ts | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/apps/client/src/app/components/portfolio-summary/portfolio-summary.component.ts b/apps/client/src/app/components/portfolio-summary/portfolio-summary.component.ts index 13686624f7..6b10d7d271 100644 --- a/apps/client/src/app/components/portfolio-summary/portfolio-summary.component.ts +++ b/apps/client/src/app/components/portfolio-summary/portfolio-summary.component.ts @@ -39,15 +39,15 @@ import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; templateUrl: './portfolio-summary.component.html' }) export class GfPortfolioSummaryComponent implements OnChanges { - @Input() baseCurrency: string; - @Input() deviceType: string; - @Input() hasImpersonationId: boolean; - @Input() hasPermissionToUpdateUserSettings: boolean; - @Input() isLoading: boolean; - @Input() language: string; - @Input() locale = getLocale(); - @Input() summary: PortfolioSummary; - @Input() user: User; + @Input() public baseCurrency?: string; + @Input() public deviceType: string; + @Input() public hasImpersonationId: boolean; + @Input() public hasPermissionToUpdateUserSettings: boolean; + @Input() public isLoading: boolean; + @Input() public language?: string; + @Input() public locale?: string = getLocale(); + @Input() public summary?: PortfolioSummary; + @Input() public user?: User; public emergencyFundChanged = output(); @@ -99,11 +99,11 @@ export class GfPortfolioSummaryComponent implements OnChanges { } protected get hasCashBreakdown() { - return !this.isLoading && this.summary?.emergencyFund?.cash > 0; + return !this.isLoading && (this.summary?.emergencyFund?.cash ?? 0) > 0; } protected get hasHoldingsBreakdown() { - return !this.isLoading && this.summary?.emergencyFund?.assets > 0; + return !this.isLoading && (this.summary?.emergencyFund?.assets ?? 0) > 0; } protected get holdingsInBaseCurrency() { @@ -133,7 +133,7 @@ export class GfPortfolioSummaryComponent implements OnChanges { } return ( - this.holdingsInBaseCurrency - (this.summary.emergencyFund?.assets ?? 0) + this.holdingsInBaseCurrency - (this.summary?.emergencyFund?.assets ?? 0) ); } @@ -200,7 +200,7 @@ export class GfPortfolioSummaryComponent implements OnChanges { this.emergencyFundChanged.emit(emergencyFund); }, confirmLabel: $localize`Save`, - defaultValue: this.summary.emergencyFund?.total?.toString() ?? '0', + defaultValue: this.summary?.emergencyFund?.total?.toString() ?? '0', title: $localize`Please set the amount of your emergency fund.`, valueSuffix: this.baseCurrency }); From 951c01c5bc1a56743bf584be6d5714c374186b2d Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Fri, 2 Oct 2026 21:33:35 +0200 Subject: [PATCH 13/26] Task/upgrade dotenv to version 18 (#8008) * Update dotenv to version 18.0.3 * Update changelog --- CHANGELOG.md | 6 ++++++ package-lock.json | 36 ++++++++++++++++++++++++++++++++---- package.json | 2 +- 3 files changed, 39 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e0ad31db0..987e11433a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased + +### Changed + +- Upgraded `dotenv` from version `17.4.2` to `18.0.3` + ## 3.77.0 - 2026-10-02 ### Added diff --git a/package-lock.json b/package-lock.json index 877a61cb0d..b01c143378 100644 --- a/package-lock.json +++ b/package-lock.json @@ -71,7 +71,7 @@ "countries-list": "3.4.1", "countup.js": "2.10.1", "date-fns": "4.4.0", - "dotenv": "17.4.2", + "dotenv": "18.0.3", "dotenv-expand": "13.0.0", "envalid": "8.2.0", "express": "5.2.1", @@ -18022,6 +18022,19 @@ "devOptional": true, "license": "MIT" }, + "node_modules/c12/node_modules/dotenv": { + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "devOptional": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/c12/node_modules/jiti": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", @@ -20710,10 +20723,13 @@ } }, "node_modules/dotenv": { - "version": "17.4.2", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", - "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "version": "18.0.3", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-18.0.3.tgz", + "integrity": "sha512-/8CJTyL817qPnfbQrJgOEcrU3fDrJkRIoXBE566wHuvIXKOCHUZhPqGPbCPi9SogjdGnreF0TlvnTgK6J/8zpA==", "license": "BSD-2-Clause", + "bin": { + "dotenv": "dist/index.cjs" + }, "engines": { "node": ">=12" }, @@ -20736,6 +20752,18 @@ "url": "https://dotenvx.com" } }, + "node_modules/dotenv-expand/node_modules/dotenv": { + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", diff --git a/package.json b/package.json index 264cf0a21b..f40bfb50cf 100644 --- a/package.json +++ b/package.json @@ -115,7 +115,7 @@ "countries-list": "3.4.1", "countup.js": "2.10.1", "date-fns": "4.4.0", - "dotenv": "17.4.2", + "dotenv": "18.0.3", "dotenv-expand": "13.0.0", "envalid": "8.2.0", "express": "5.2.1", From c84fd0df8d7329598c835010f8de9443cc69e16d Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Fri, 2 Oct 2026 21:48:52 +0200 Subject: [PATCH 14/26] Task/upgrade ai to version 7.0.114 (#8011) * Update ai to version 7.0.114 * Update changelog --- CHANGELOG.md | 2 ++ package-lock.json | 58 +++++++++++++++++++++++++++-------------------- package.json | 4 ++-- 3 files changed, 38 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 987e11433a..ff764bf4a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Upgraded `@openrouter/ai-sdk-provider` from version `3.0.0` to `3.1.0` +- Upgraded `ai` from version `7.0.37` to `7.0.114` - Upgraded `dotenv` from version `17.4.2` to `18.0.3` ## 3.77.0 - 2026-10-02 diff --git a/package-lock.json b/package-lock.json index b01c143378..be96328456 100644 --- a/package-lock.json +++ b/package-lock.json @@ -46,13 +46,13 @@ "@nestjs/schedule": "6.1.3", "@nestjs/serve-static": "5.0.5", "@nestjs/throttler": "6.5.0", - "@openrouter/ai-sdk-provider": "3.0.0", + "@openrouter/ai-sdk-provider": "3.1.0", "@prisma/adapter-pg": "7.10.0", "@prisma/client": "7.10.0", "@rekog/mcp-nest": "2.0.7", "@simplewebauthn/browser": "13.3.0", "@simplewebauthn/server": "13.3.1", - "ai": "7.0.37", + "ai": "7.0.114", "alphavantage": "2.2.0", "big.js": "7.0.1", "bootstrap": "4.6.2", @@ -189,13 +189,13 @@ "license": "MIT" }, "node_modules/@ai-sdk/gateway": { - "version": "4.0.28", - "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-4.0.28.tgz", - "integrity": "sha512-ee9TsNO3mkgHDWmTmJ8Fvltr6PFh52zhrV2+FaKJY3F3iu7kWZi5tCRJCR1HVhhlpj6lHniUb28nLQdPHkA/1Q==", + "version": "4.0.92", + "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-4.0.92.tgz", + "integrity": "sha512-awX24+QedfcIDTAdwkCUNG08vPJjkQI9KZLGh4Ua+vNbgeaNFJre96OIbBefOD9XPHSVpdXjwqtWmo4Yh7oczw==", "license": "Apache-2.0", "dependencies": { - "@ai-sdk/provider": "4.0.3", - "@ai-sdk/provider-utils": "5.0.12", + "@ai-sdk/provider": "4.0.18", + "@ai-sdk/provider-utils": "5.0.47", "@vercel/oidc": "3.2.0" }, "engines": { @@ -206,9 +206,9 @@ } }, "node_modules/@ai-sdk/provider": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-4.0.3.tgz", - "integrity": "sha512-e0CpNWJUY7OxAFAnCZkw+ri9QOHWwTs1tXP42782KFGCU07qt8NiXCrCVowyCB5dP2r5/Uls+g2oPd8kOJn9dw==", + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-4.0.18.tgz", + "integrity": "sha512-+GZJIgz1jk86pwEbb3f1BD2bdoSKyWE4Jg4YUc7NMnMozbWemSKYZcw2F4nMaO5qwsL5A8RmioAKW81YktRpIQ==", "license": "Apache-2.0", "dependencies": { "json-schema": "^0.4.0" @@ -218,15 +218,16 @@ } }, "node_modules/@ai-sdk/provider-utils": { - "version": "5.0.12", - "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-5.0.12.tgz", - "integrity": "sha512-bbhlOgHeYwrIGheLkM6fhS8hVger8uFPmcOLg+kxc9EFh7y30XYorWhthlYAgpadO3SJhFZrIcEknN7qEqEVvA==", + "version": "5.0.47", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-5.0.47.tgz", + "integrity": "sha512-NZQ1hMxY6Hd4ZiinjpU5HUOsyCHiTFkHw7AiM1EVN+Cd3kNTi47flZWNvFwGqnBojntB1+2eBRTeHRuVLtPhEg==", "license": "Apache-2.0", "dependencies": { - "@ai-sdk/provider": "4.0.3", + "@ai-sdk/provider": "4.0.18", "@standard-schema/spec": "^1.1.0", "@workflow/serde": "4.1.0", - "eventsource-parser": "^3.0.8" + "eventsource-parser": "^3.0.8", + "undici": "^7.29.0" }, "engines": { "node": ">=22" @@ -235,6 +236,15 @@ "zod": "^3.25.76 || ^4.1.8" } }, + "node_modules/@ai-sdk/provider-utils/node_modules/undici": { + "version": "7.30.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.30.0.tgz", + "integrity": "sha512-dkrQXeHSaoamnItlYbmzG0wFYrM0ZwDxCIg0A7aKjTyyhh9svRzCNFEzV+Vm05/yehjCzjDZ31KXfGEjYSztDQ==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/@ampproject/remapping": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", @@ -11951,9 +11961,9 @@ } }, "node_modules/@openrouter/ai-sdk-provider": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@openrouter/ai-sdk-provider/-/ai-sdk-provider-3.0.0.tgz", - "integrity": "sha512-m9XTSWoODH2RM5OsZpaGiN7QRR8cdP5paBWq699Tu3JVmGPBKT8xF8XwV0ZBVVsjikD/JgWfak4VSsTR4wAVbg==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@openrouter/ai-sdk-provider/-/ai-sdk-provider-3.1.0.tgz", + "integrity": "sha512-BQy1TA9fKrh47s9ivsO6FY5QimN7GUF0i8Qon3pkxQclEgShso0dClpcWn8OIyPLEZZPFaFKgI8TW4S/O0p0Dw==", "license": "Apache-2.0", "engines": { "node": ">=22" @@ -16840,14 +16850,14 @@ } }, "node_modules/ai": { - "version": "7.0.37", - "resolved": "https://registry.npmjs.org/ai/-/ai-7.0.37.tgz", - "integrity": "sha512-stF+SEQJgKY3Qfe3FwNzqUrehHviOp2l7LemoI8YMOa0Zk5PxKsRNgUk3cHXz0RAue9RRyCAis2fz6LSCP6EKw==", + "version": "7.0.114", + "resolved": "https://registry.npmjs.org/ai/-/ai-7.0.114.tgz", + "integrity": "sha512-jRMOw7tPMWVYomoHWY5xHBXPQDyKTY1Pn3BX0Rh39UOP6y9FYq6xCIB6khgIC/b9CRg4+IxadwIbie+Eu7yU3g==", "license": "Apache-2.0", "dependencies": { - "@ai-sdk/gateway": "4.0.28", - "@ai-sdk/provider": "4.0.3", - "@ai-sdk/provider-utils": "5.0.12" + "@ai-sdk/gateway": "4.0.92", + "@ai-sdk/provider": "4.0.18", + "@ai-sdk/provider-utils": "5.0.47" }, "engines": { "node": ">=22" diff --git a/package.json b/package.json index f40bfb50cf..7a32e9bdf5 100644 --- a/package.json +++ b/package.json @@ -90,13 +90,13 @@ "@nestjs/schedule": "6.1.3", "@nestjs/serve-static": "5.0.5", "@nestjs/throttler": "6.5.0", - "@openrouter/ai-sdk-provider": "3.0.0", + "@openrouter/ai-sdk-provider": "3.1.0", "@prisma/adapter-pg": "7.10.0", "@prisma/client": "7.10.0", "@rekog/mcp-nest": "2.0.7", "@simplewebauthn/browser": "13.3.0", "@simplewebauthn/server": "13.3.1", - "ai": "7.0.37", + "ai": "7.0.114", "alphavantage": "2.2.0", "big.js": "7.0.1", "bootstrap": "4.6.2", From beb8f9de78950f373375193bb090ee9587d70881 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sat, 3 Oct 2026 09:13:08 +0200 Subject: [PATCH 15/26] Bugfix/carry forward of market prices between chart dates (#8009) * Carry forward of market prices between chart dates * Update changelog --- CHANGELOG.md | 4 + .../calculator/portfolio-calculator.ts | 33 ++-- ...h-market-price-between-chart-dates.spec.ts | 170 ++++++++++++++++++ .../portfolio/current-rate.service.mock.ts | 14 ++ 4 files changed, 211 insertions(+), 10 deletions(-) create mode 100644 apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-manual-buy-with-market-price-between-chart-dates.spec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ff764bf4a8..0d3af3346e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Upgraded `ai` from version `7.0.37` to `7.0.114` - Upgraded `dotenv` from version `17.4.2` to `18.0.3` +### Fixed + +- Fixed the portfolio calculation for holdings with historical market prices between the chart dates + ## 3.77.0 - 2026-10-02 ### Added diff --git a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts index e03b530258..eb1e6f9fac 100644 --- a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts @@ -791,6 +791,16 @@ export abstract class PortfolioCalculator { activitiesByDate[activity.date].push(activity); } + // Carry forward the market prices of all dates, not only of the chart + // dates, as sparse market data (e.g. MANUAL) can be between two chart dates + const marketPriceDates = Object.keys(marketSymbolMap) + .filter((date) => { + return !!marketSymbolMap[date][assetProfileIdentifier]; + }) + .sort(); + + let indexOfMarketPriceDate = 0; + for (const dateString of chartDates) { if (dateString < startDateString) { continue; @@ -798,6 +808,18 @@ export abstract class PortfolioCalculator { break; } + while ( + indexOfMarketPriceDate < marketPriceDates.length && + marketPriceDates[indexOfMarketPriceDate] <= dateString + ) { + lastMarketPrice = + marketSymbolMap[marketPriceDates[indexOfMarketPriceDate]][ + assetProfileIdentifier + ]; + + indexOfMarketPriceDate += 1; + } + const activitiesOfDate = activitiesByDate[dateString]; if (!lastMarketPrice && activitiesOfDate?.length > 0) { @@ -808,13 +830,8 @@ export abstract class PortfolioCalculator { } } - const marketPrice = marketSymbolMap[dateString]?.[assetProfileIdentifier]; - const unitPrice = - marketPrice ?? - lastMarketPrice ?? - lastActivityUnitPrice ?? - unitPriceAtEndDate; + lastMarketPrice ?? lastActivityUnitPrice ?? unitPriceAtEndDate; if (activitiesOfDate?.length > 0) { for (const activity of activitiesOfDate) { @@ -832,10 +849,6 @@ export abstract class PortfolioCalculator { unitPriceFromMarketData: unitPrice }); } - - if (marketPrice) { - lastMarketPrice = marketPrice; - } } // Sort the activities so that the start and end placeholder activities diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-manual-buy-with-market-price-between-chart-dates.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-manual-buy-with-market-price-between-chart-dates.spec.ts new file mode 100644 index 0000000000..989409e77b --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-manual-buy-with-market-price-between-chart-dates.spec.ts @@ -0,0 +1,170 @@ +import { + activityDummyData, + assetProfileDummyData, + userDummyData +} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; +import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; +import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; +import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; +import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; +import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +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 { parseDate } from '@ghostfolio/common/helper'; +import { Activity } from '@ghostfolio/common/interfaces'; +import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; + +import { Big } from 'big.js'; + +jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { + return { + CurrentRateService: jest.fn().mockImplementation(() => { + return CurrentRateServiceMock; + }) + }; +}); + +jest.mock( + '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service', + () => { + return { + PortfolioSnapshotService: jest.fn().mockImplementation(() => { + return PortfolioSnapshotServiceMock; + }) + }; + } +); + +jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { + return { + RedisCacheService: jest.fn().mockImplementation(() => { + return RedisCacheServiceMock; + }) + }; +}); + +describe('PortfolioCalculator', () => { + let configurationService: ConfigurationService; + let currentRateService: CurrentRateService; + let exchangeRateDataService: ExchangeRateDataService; + let portfolioCalculatorFactory: PortfolioCalculatorFactory; + let portfolioSnapshotService: PortfolioSnapshotService; + let redisCacheService: RedisCacheService; + + beforeEach(() => { + PortfolioSnapshotServiceMock.reset(); + RedisCacheServiceMock.reset(); + + configurationService = new ConfigurationService(); + + currentRateService = new CurrentRateService(null, null, null); + + exchangeRateDataService = new ExchangeRateDataService( + null, + null, + null, + null + ); + + portfolioSnapshotService = new PortfolioSnapshotService(null, null); + + redisCacheService = new RedisCacheService(null, null); + + portfolioCalculatorFactory = new PortfolioCalculatorFactory( + configurationService, + currentRateService, + exchangeRateDataService, + portfolioSnapshotService, + redisCacheService + ); + }); + + describe('get current positions', () => { + it('with MANUAL buy and market price between two chart dates', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2024-01-31').getTime()); + + const activities: Activity[] = [ + { + ...activityDummyData, + assetProfile: { + ...assetProfileDummyData, + currency: 'USD', + dataSource: 'MANUAL', + name: 'Private Investment', + symbol: '6c0c5cee-0208-4975-b473-03baf2518497' + }, + date: parseDate('2021-01-04'), + feeInAssetProfileCurrency: 0, + feeInBaseCurrency: 0, + quantity: 10, + type: 'BUY', + unitPriceInAssetProfileCurrency: 50 + } + ]; + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.ROAI, + currency: 'USD', + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); + + const historicalDataByDate = Object.fromEntries( + portfolioSnapshot.historicalData.map((historicalDataItem) => { + return [historicalDataItem.date, historicalDataItem]; + }) + ); + + /** + * The only historical market prices are on 2023-06-14 and 2023-06-15, + * which are not chart dates (every third day from 2021-01-03) + */ + expect(historicalDataByDate['2023-06-14']).toBeUndefined(); + expect(historicalDataByDate['2023-06-15']).toBeUndefined(); + + /** + * The chart dates before the market prices use the unit price of the + * activity: 50 + */ + expect(historicalDataByDate['2023-06-13']).toMatchObject({ + netPerformance: 0, // 10 * (50 - 50) = 0 + totalInvestment: 500, + value: 500 // 10 * 50 = 500 + }); + + /** + * The chart dates after the market prices carry the latest one forward + * (100), not the first one (80) + */ + expect(historicalDataByDate['2023-06-16']).toMatchObject({ + netPerformance: 500, // 10 * (100 - 50) = 500 + totalInvestment: 500, + value: 1000 // 10 * 100 = 1000 + }); + + expect(historicalDataByDate['2024-01-30']).toMatchObject({ + netPerformance: 500, // 10 * (100 - 50) = 500 + totalInvestment: 500, + value: 1000 // 10 * 100 = 1000 + }); + + /** + * The market price is unchanged since 2023-06-15, hence there is no + * performance today + */ + expect(portfolioSnapshot.positions[0]).toMatchObject({ + netPerformancePercentageWithCurrencyEffectMap: { + '1d': new Big(0) + }, + netPerformanceWithCurrencyEffectMap: { + '1d': new Big(0), + max: new Big(500) // 10 * (100 - 50) = 500 + } + }); + }); + }); +}); diff --git a/apps/api/src/app/portfolio/current-rate.service.mock.ts b/apps/api/src/app/portfolio/current-rate.service.mock.ts index 7fcacad610..18739ddab5 100644 --- a/apps/api/src/app/portfolio/current-rate.service.mock.ts +++ b/apps/api/src/app/portfolio/current-rate.service.mock.ts @@ -30,6 +30,20 @@ function mockGetValue({ return { marketPrice: 0 }; + case '6c0c5cee-0208-4975-b473-03baf2518497': + // Sparse market data with the quote of today, which falls back to the + // latest market price + if (isSameDay(parseDate('2023-06-14'), date)) { + return { marketPrice: 80 }; + } else if ( + isSameDay(parseDate('2023-06-15'), date) || + isSameDay(parseDate('2024-01-31'), date) + ) { + return { marketPrice: 100 }; + } + + return { marketPrice: 0 }; + case 'BALN.SW': if (isSameDay(parseDate('2021-11-12'), date)) { return { marketPrice: 146 }; From acd14c6298f1e18d3d17d57ea94d87b23e3748e8 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sat, 3 Oct 2026 09:14:54 +0200 Subject: [PATCH 16/26] Task/improve label of cash positions (#8007) * Improve label of cash positions * Update changelog --- CHANGELOG.md | 1 + .../allocations/allocations-page.component.ts | 3 +- .../app/pages/public/public-page.component.ts | 3 +- libs/common/src/lib/helper.spec.ts | 47 +++++++++++++++++++ libs/common/src/lib/helper.ts | 16 +++++++ .../holdings-table.component.html | 13 +++-- .../holdings-table.component.ts | 12 ++++- .../treemap-chart/treemap-chart.component.ts | 14 +++--- 8 files changed, 96 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d3af3346e..959f6f09ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Improved the label of the cash positions in the holdings charts and table - Upgraded `@openrouter/ai-sdk-provider` from version `3.0.0` to `3.1.0` - Upgraded `ai` from version `7.0.37` to `7.0.114` - Upgraded `dotenv` from version `17.4.2` to `18.0.3` diff --git a/apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts b/apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts index cbdacaa3f1..915c6833ab 100644 --- a/apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts +++ b/apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts @@ -11,6 +11,7 @@ import { convertValuesToPercentagesOfTotal, getAssetProfileIdentifier, getCountryName, + getHoldingName, isCashPosition } from '@ghostfolio/common/helper'; import { @@ -533,7 +534,7 @@ export class GfAllocationsPageComponent implements OnInit { value, dataSource: position.assetProfile.dataSource, isClickable: canOpenHoldingDetail(position), - name: position.assetProfile.name ?? '' + name: getHoldingName(position.assetProfile) }; } } diff --git a/apps/client/src/app/pages/public/public-page.component.ts b/apps/client/src/app/pages/public/public-page.component.ts index 46606753e4..8fa8abde18 100644 --- a/apps/client/src/app/pages/public/public-page.component.ts +++ b/apps/client/src/app/pages/public/public-page.component.ts @@ -3,6 +3,7 @@ import { convertValuesToPercentagesOfTotal, getAssetProfileIdentifier, getCountryName, + getHoldingName, isCashPosition } from '@ghostfolio/common/helper'; import { @@ -260,7 +261,7 @@ export class GfPublicPageComponent implements OnInit { this.symbols[symbol] = { symbol, value, - name: position.assetProfile.name ?? symbol + name: getHoldingName(position.assetProfile) }; } } diff --git a/libs/common/src/lib/helper.spec.ts b/libs/common/src/lib/helper.spec.ts index b7e7635670..71b934628c 100644 --- a/libs/common/src/lib/helper.spec.ts +++ b/libs/common/src/lib/helper.spec.ts @@ -7,6 +7,7 @@ import { extractNumberFromString, getCountryCodeFromCurrency, getEmojiFlag, + getHoldingName, getNumberFormatGroup, getStringOrNull, getStringOrUndefined, @@ -143,6 +144,52 @@ describe('Helper', () => { }); }); + describe('Get holding name', () => { + it('Cash position', () => { + expect( + getHoldingName({ + assetSubClass: 'CASH', + assetSubClassLabel: 'Cash', + name: 'CHF', + symbol: 'CHF' + }) + ).toEqual('Cash (CHF)'); + }); + + it('Cash position without asset sub class label', () => { + expect( + getHoldingName({ + assetSubClass: 'CASH', + assetSubClassLabel: undefined, + name: 'CHF', + symbol: 'CHF' + }) + ).toEqual('CHF'); + }); + + it('Holding with name', () => { + expect( + getHoldingName({ + assetSubClass: 'STOCK', + assetSubClassLabel: 'Stock', + name: 'Apple Inc.', + symbol: 'AAPL' + }) + ).toEqual('Apple Inc.'); + }); + + it('Holding without name', () => { + expect( + getHoldingName({ + assetSubClass: 'STOCK', + assetSubClassLabel: 'Stock', + name: undefined, + symbol: 'AAPL' + }) + ).toEqual('AAPL'); + }); + }); + describe('Get number format group', () => { let languageGetter: jest.SpyInstance; diff --git a/libs/common/src/lib/helper.ts b/libs/common/src/lib/helper.ts index 2a76261dae..7e4e13de7e 100644 --- a/libs/common/src/lib/helper.ts +++ b/libs/common/src/lib/helper.ts @@ -453,6 +453,22 @@ export function getEmojiFlag(aCountryCode: string) { }); } +export function getHoldingName({ + assetSubClass, + assetSubClassLabel, + name, + symbol +}: Pick< + PortfolioPosition['assetProfile'], + 'assetSubClass' | 'assetSubClassLabel' | 'name' | 'symbol' +>) { + if (isCashPosition({ assetSubClass }) && assetSubClassLabel) { + return `${assetSubClassLabel} (${symbol})`; + } + + return name ?? symbol; +} + export function getLocale() { return navigator.language ?? DEFAULT_LOCALE; } diff --git a/libs/ui/src/lib/holdings-table/holdings-table.component.html b/libs/ui/src/lib/holdings-table/holdings-table.component.html index 2001a1b990..e1c012ab15 100644 --- a/libs/ui/src/lib/holdings-table/holdings-table.component.html +++ b/libs/ui/src/lib/holdings-table/holdings-table.component.html @@ -20,7 +20,7 @@ [countryCode]="getCountryCodeForCashPosition(element)" [dataSource]="element.assetProfile.dataSource" [symbol]="element.assetProfile.symbol" - [tooltip]="element.assetProfile.name" + [tooltip]="getHoldingName(element.assetProfile)" /> @@ -40,9 +40,14 @@ mat-cell [class.w-100]="mode() === 'simple'" > -
- {{ element.assetProfile.name }} - @if (element.assetProfile.name === element.assetProfile.symbol) { + @let holdingName = getHoldingName(element.assetProfile); + +
+ {{ holdingName }} + @if ( + !isCashPosition(element.assetProfile) && + element.assetProfile.name === element.assetProfile.symbol + ) { ({{ element.assetProfile.assetSubClassLabel }}) }
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 eac1c9d918..1894260b78 100644 --- a/libs/ui/src/lib/holdings-table/holdings-table.component.ts +++ b/libs/ui/src/lib/holdings-table/holdings-table.component.ts @@ -1,6 +1,7 @@ import { canOpenHoldingDetail, getCountryCodeFromCurrency, + getHoldingName, getLocale, getLowercase, isCashPosition @@ -89,6 +90,9 @@ export class GfHoldingsTableComponent { return columns; }); + protected readonly getHoldingName = getHoldingName; + protected readonly isCashPosition = isCashPosition; + protected readonly isLoading = computed(() => { return !this.holdings(); }); @@ -104,7 +108,13 @@ export class GfHoldingsTableComponent { }); public constructor() { - this.dataSource.sortingDataAccessor = getLowercase; + this.dataSource.sortingDataAccessor = (holding, path) => { + if (path === 'assetProfile.name') { + return getHoldingName(holding.assetProfile).toLocaleLowerCase(); + } + + return getLowercase(holding, path) as number | string; + }; // Reactive data update effect(() => { diff --git a/libs/ui/src/lib/treemap-chart/treemap-chart.component.ts b/libs/ui/src/lib/treemap-chart/treemap-chart.component.ts index 83b9916c6e..01037f77f4 100644 --- a/libs/ui/src/lib/treemap-chart/treemap-chart.component.ts +++ b/libs/ui/src/lib/treemap-chart/treemap-chart.component.ts @@ -3,7 +3,11 @@ import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper'; import { getTooltipOptions } from '@ghostfolio/common/chart-helper'; -import { canOpenHoldingDetail, getLocale } from '@ghostfolio/common/helper'; +import { + canOpenHoldingDetail, + getHoldingName, + getLocale +} from '@ghostfolio/common/helper'; import { AssetProfileIdentifier, PortfolioPosition @@ -399,20 +403,18 @@ export class GfTreemapChartComponent const raw = context.raw as GfTreemapDataPoint; const allocationInPercentage = `${(raw._data.allocationInPercentage * 100).toFixed(2)}%`; - const name = raw._data.assetProfile.name; + const name = getHoldingName(raw._data.assetProfile); const sign = raw._data.netPerformancePercentWithCurrencyEffect > 0 ? '+' : ''; - const symbol = raw._data.assetProfile.symbol; - const netPerformanceInPercentageWithSign = `${sign}${(raw._data.netPerformancePercentWithCurrencyEffect * 100).toFixed(2)}%`; if (raw._data.valueInBaseCurrency !== null) { const value = raw._data.valueInBaseCurrency; return [ - `${name ?? symbol} (${allocationInPercentage})`, + `${name} (${allocationInPercentage})`, `${value?.toLocaleString(this.locale(), { maximumFractionDigits: 2, minimumFractionDigits: 2 @@ -429,7 +431,7 @@ export class GfTreemapChartComponent ]; } else { return [ - `${name ?? symbol} (${allocationInPercentage})`, + `${name} (${allocationInPercentage})`, '', $localize`Performance`, netPerformanceInPercentageWithSign From f260f1572ecaf971ff3a4ca530d08510bb5ecf88 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sat, 3 Oct 2026 09:15:30 +0200 Subject: [PATCH 17/26] Task/improve symbol validation of create and update order DTO (#8005) Improve symbol validation --- libs/common/src/lib/dtos/create-order.dto.ts | 5 +++++ libs/common/src/lib/dtos/update-order.dto.ts | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/libs/common/src/lib/dtos/create-order.dto.ts b/libs/common/src/lib/dtos/create-order.dto.ts index d473e8e7b6..d2374e9b1a 100644 --- a/libs/common/src/lib/dtos/create-order.dto.ts +++ b/libs/common/src/lib/dtos/create-order.dto.ts @@ -18,6 +18,7 @@ import { IsBoolean, IsEnum, IsISO8601, + IsNotEmpty, IsNumber, IsOptional, IsString, @@ -71,8 +72,12 @@ export class CreateOrderDto { @Min(0) quantity: number; + @IsNotEmpty() @IsString() @MaxLength(SYMBOL_MAXIMUM_LENGTH) + @Transform(({ value }: TransformFnParams) => + isString(value) ? value.trim() : value + ) symbol: string; @ArrayUnique() diff --git a/libs/common/src/lib/dtos/update-order.dto.ts b/libs/common/src/lib/dtos/update-order.dto.ts index f7dfb0df53..77e4a42286 100644 --- a/libs/common/src/lib/dtos/update-order.dto.ts +++ b/libs/common/src/lib/dtos/update-order.dto.ts @@ -17,6 +17,7 @@ import { IsArray, IsEnum, IsISO8601, + IsNotEmpty, IsNumber, IsOptional, IsString, @@ -72,8 +73,12 @@ export class UpdateOrderDto { @Min(0) quantity: number; + @IsNotEmpty() @IsString() @MaxLength(SYMBOL_MAXIMUM_LENGTH) + @Transform(({ value }: TransformFnParams) => + isString(value) ? value.trim() : value + ) symbol: string; @ArrayUnique() From f987e4160ed80c39d552f9eb9c94f783fe3fb92b Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sat, 3 Oct 2026 09:16:07 +0200 Subject: [PATCH 18/26] Task/improve symbol validation of create asset profile DTO (#8013) Improve symbol validation --- libs/common/src/lib/dtos/create-asset-profile.dto.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/libs/common/src/lib/dtos/create-asset-profile.dto.ts b/libs/common/src/lib/dtos/create-asset-profile.dto.ts index b4fe347071..5d75b39a1d 100644 --- a/libs/common/src/lib/dtos/create-asset-profile.dto.ts +++ b/libs/common/src/lib/dtos/create-asset-profile.dto.ts @@ -10,17 +10,19 @@ import { DataSource } from '@ghostfolio/prisma/enums'; -import { Type } from 'class-transformer'; +import { Transform, TransformFnParams, Type } from 'class-transformer'; import { IsArray, IsBoolean, IsEnum, + IsNotEmpty, IsOptional, IsString, IsUrl, MaxLength, ValidateNested } from 'class-validator'; +import { isString } from 'lodash-es'; import { CountryDto } from './country.dto'; import { HoldingDto } from './holding.dto'; @@ -92,8 +94,12 @@ export class CreateAssetProfileDto { @ValidateNested({ each: true }) sectors?: Prisma.InputJsonArray; + @IsNotEmpty() @IsString() @MaxLength(SYMBOL_MAXIMUM_LENGTH) + @Transform(({ value }: TransformFnParams) => + isString(value) ? value.trim() : value + ) symbol: string; @IsOptional() From 2364489ee4dd9b74df2379858d25f4a49a96852a Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sat, 3 Oct 2026 10:59:37 +0200 Subject: [PATCH 19/26] Feature/data source in MCP tools (#8019) * Extend tools with data source * Update changelog --- CHANGELOG.md | 1 + .../src/app/endpoints/mcp/mcp.schemas.spec.ts | 89 ++++++++ apps/api/src/app/endpoints/mcp/mcp.schemas.ts | 24 ++- .../src/app/endpoints/mcp/mcp.service.spec.ts | 114 +++++++++- apps/api/src/app/endpoints/mcp/mcp.service.ts | 31 +-- .../api/src/helper/data-source.helper.spec.ts | 123 +++++++++++ apps/api/src/helper/data-source.helper.ts | 65 ++++++ ...orm-data-source-in-response.interceptor.ts | 5 +- .../data-source-table-context.interface.ts | 5 + .../holdings-table-context.interface.ts | 4 +- .../portfolio-table/portfolio-table.module.ts | 9 +- .../portfolio-table.service.spec.ts | 196 +++++++++++++++++- .../portfolio-table.service.ts | 142 +++++++++---- 13 files changed, 720 insertions(+), 88 deletions(-) create mode 100644 apps/api/src/helper/data-source.helper.spec.ts create mode 100644 apps/api/src/services/portfolio-table/interfaces/data-source-table-context.interface.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 959f6f09ef..d64f3d12f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Improved the label of the cash positions in the holdings charts and table +- Extended the tools to get the activities, the portfolio and the watchlist in the server of the Model Context Protocol (MCP) to include the data source (experimental) - Upgraded `@openrouter/ai-sdk-provider` from version `3.0.0` to `3.1.0` - Upgraded `ai` from version `7.0.37` to `7.0.114` - Upgraded `dotenv` from version `17.4.2` to `18.0.3` diff --git a/apps/api/src/app/endpoints/mcp/mcp.schemas.spec.ts b/apps/api/src/app/endpoints/mcp/mcp.schemas.spec.ts index dbc5bc698c..e4893c2a7b 100644 --- a/apps/api/src/app/endpoints/mcp/mcp.schemas.spec.ts +++ b/apps/api/src/app/endpoints/mcp/mcp.schemas.spec.ts @@ -1,3 +1,4 @@ +import { encodeDataSource } from '@ghostfolio/api/helper/data-source.helper'; import { COMMENT_MAXIMUM_LENGTH, DEFAULT_DATE_RANGE, @@ -18,6 +19,8 @@ import { } from './mcp.schemas'; import { createActivity } from './mcp.test-utils'; +const UNKNOWN_ENCODED_DATA_SOURCE = 'ffffffff'; + describe('GET_ACCOUNTS_PARAMETERS', () => { it('Refuses an empty symbol of the holding', () => { expect( @@ -34,6 +37,40 @@ describe('GET_ACCOUNTS_PARAMETERS', () => { }).success ).toBe(false); }); + + it('Accepts an encoded data source of the holding', () => { + expect( + GET_ACCOUNTS_PARAMETERS.safeParse({ + holding: { + dataSource: encodeDataSource(DataSource.YAHOO), + symbol: 'AAPL' + } + }).success + ).toBe(true); + }); + + it('Refuses an unknown encoded data source of the holding', () => { + expect( + GET_ACCOUNTS_PARAMETERS.safeParse({ + holding: { dataSource: UNKNOWN_ENCODED_DATA_SOURCE, symbol: 'AAPL' } + }).success + ).toBe(false); + }); + + it('Gives the valid data sources in the error of an unknown data source of the holding', () => { + for (const dataSource of ['yahoo', 1]) { + const { error } = GET_ACCOUNTS_PARAMETERS.safeParse({ + holding: { dataSource, symbol: 'AAPL' } + }); + + expect(error.issues).toEqual([ + expect.objectContaining({ + message: expect.stringContaining(`|${DataSource.YAHOO}`), + path: ['holding', 'dataSource'] + }) + ]); + } + }); }); describe('GET_ACTIVITIES_PARAMETERS', () => { @@ -52,6 +89,25 @@ describe('GET_ACTIVITIES_PARAMETERS', () => { }).success ).toBe(false); }); + + it('Accepts an encoded data source of the holding', () => { + expect( + GET_ACTIVITIES_PARAMETERS.safeParse({ + holding: { + dataSource: encodeDataSource(DataSource.YAHOO), + symbol: 'AAPL' + } + }).success + ).toBe(true); + }); + + it('Refuses an unknown encoded data source of the holding', () => { + expect( + GET_ACTIVITIES_PARAMETERS.safeParse({ + holding: { dataSource: UNKNOWN_ENCODED_DATA_SOURCE, symbol: 'AAPL' } + }).success + ).toBe(false); + }); }); describe('GET_PERFORMANCE_PARAMETERS', () => { @@ -86,6 +142,25 @@ describe('GET_PERFORMANCE_PARAMETERS', () => { }).success ).toBe(false); }); + + it('Accepts an encoded data source of the holding', () => { + expect( + GET_PERFORMANCE_PARAMETERS.safeParse({ + holding: { + dataSource: encodeDataSource(DataSource.YAHOO), + symbol: 'AAPL' + } + }).success + ).toBe(true); + }); + + it('Refuses an unknown encoded data source of the holding', () => { + expect( + GET_PERFORMANCE_PARAMETERS.safeParse({ + holding: { dataSource: UNKNOWN_ENCODED_DATA_SOURCE, symbol: 'AAPL' } + }).success + ).toBe(false); + }); }); describe('IMPORT_ACTIVITIES_PARAMETERS', () => { @@ -125,6 +200,20 @@ describe('IMPORT_ACTIVITIES_PARAMETERS', () => { expect(parse([createActivity({ currency: 'USD' })])).toBe(true); }); + it('Accepts an encoded data source', () => { + expect( + parse([ + createActivity({ dataSource: encodeDataSource(DataSource.YAHOO) }) + ]) + ).toBe(true); + }); + + it('Refuses an unknown encoded data source', () => { + expect( + parse([createActivity({ dataSource: UNKNOWN_ENCODED_DATA_SOURCE })]) + ).toBe(false); + }); + it('Refuses a date at or before the epoch', () => { expect(parse([createActivity({ date: '0000-01-01' })])).toBe(false); }); diff --git a/apps/api/src/app/endpoints/mcp/mcp.schemas.ts b/apps/api/src/app/endpoints/mcp/mcp.schemas.ts index ce66699e91..1b200e0119 100644 --- a/apps/api/src/app/endpoints/mcp/mcp.schemas.ts +++ b/apps/api/src/app/endpoints/mcp/mcp.schemas.ts @@ -1,4 +1,5 @@ import { DATE_RANGE_PATTERN } from '@ghostfolio/api/dtos/date-range-filter.dto'; +import { isValidEncodedDataSource } from '@ghostfolio/api/helper/data-source.helper'; import { COMMENT_MAXIMUM_LENGTH, DATE_RANGES, @@ -17,6 +18,20 @@ import { import { AssetClass, DataSource, Type as ActivityType } from '@prisma/client'; import { z } from 'zod'; +const DATA_SOURCE_PARAMETER_ERROR = `Invalid option: expected one of ${Object.values(DataSource).join('|')} or a data source as given by a tool`; + +const DATA_SOURCE_PARAMETER = z + .union( + [ + z.enum(DataSource), + z.string().refine(isValidEncodedDataSource, { + error: DATA_SOURCE_PARAMETER_ERROR + }) + ], + { error: DATA_SOURCE_PARAMETER_ERROR } + ) + .describe('The data source of the asset profile'); + const SYMBOL_PARAMETER = z .string() .trim() @@ -25,9 +40,7 @@ const SYMBOL_PARAMETER = z .describe('The symbol of the asset profile'); const HOLDING_PARAMETER = z.object({ - dataSource: z - .enum(DataSource) - .describe('The data source of the asset profile'), + dataSource: DATA_SOURCE_PARAMETER, symbol: SYMBOL_PARAMETER }); @@ -137,10 +150,7 @@ export const IMPORT_ACTIVITIES_PARAMETERS = z.object({ .describe( 'The currency of the fee and of the unit price, as an ISO 4217 code in upper case' ), - dataSource: z - .enum(DataSource) - .optional() - .describe('The data source of the asset profile'), + dataSource: DATA_SOURCE_PARAMETER.optional(), date: z .string() .refine(isValidDateAfter1970) diff --git a/apps/api/src/app/endpoints/mcp/mcp.service.spec.ts b/apps/api/src/app/endpoints/mcp/mcp.service.spec.ts index f4841896bd..a013341179 100644 --- a/apps/api/src/app/endpoints/mcp/mcp.service.spec.ts +++ b/apps/api/src/app/endpoints/mcp/mcp.service.spec.ts @@ -2,6 +2,7 @@ import { ImportValidationError } from '@ghostfolio/api/app/import/errors/import- import { ImportService } from '@ghostfolio/api/app/import/import.service'; import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service'; import { UserService } from '@ghostfolio/api/app/user/user.service'; +import { encodeDataSource } from '@ghostfolio/api/helper/data-source.helper'; import { ApiService } from '@ghostfolio/api/services/api/api.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { PortfolioTableService } from '@ghostfolio/api/services/portfolio-table/portfolio-table.service'; @@ -128,6 +129,22 @@ describe('McpService', () => { ); }); + it('Decodes an encoded data source', async () => { + configuration.ENABLE_FEATURE_SUBSCRIPTION = true; + + await mcpService.getAccounts({ + userId, + holding: { + dataSource: encodeDataSource(DataSource.YAHOO), + symbol: 'AAPL' + } + }); + + expect(apiService.buildFiltersFromQueryParams).toHaveBeenCalledWith( + expect.objectContaining({ filterByDataSource: DataSource.YAHOO }) + ); + }); + it('Keeps the data source if the subscription is not enabled', async () => { configuration.DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER = [DataSource.YAHOO]; configuration.ENABLE_FEATURE_SUBSCRIPTION = false; @@ -192,6 +209,21 @@ describe('McpService', () => { ); }); + it('Decodes an encoded data source', async () => { + configuration.ENABLE_FEATURE_SUBSCRIPTION = true; + + await getActivities({ + holding: { + dataSource: encodeDataSource(DataSource.YAHOO), + symbol: 'AAPL' + } + }); + + expect(apiService.buildFiltersFromQueryParams).toHaveBeenCalledWith( + expect.objectContaining({ filterByDataSource: DataSource.YAHOO }) + ); + }); + it('Keeps the data source if the subscription is not enabled', async () => { configuration.DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER = [DataSource.YAHOO]; configuration.ENABLE_FEATURE_SUBSCRIPTION = false; @@ -275,6 +307,23 @@ describe('McpService', () => { ); }); + it('Decodes an encoded data source', async () => { + configuration.ENABLE_FEATURE_SUBSCRIPTION = true; + + await mcpService.getPerformance({ + userId, + holding: { + dataSource: encodeDataSource(DataSource.YAHOO), + symbol: 'AAPL' + }, + range: 'max' + }); + + expect(apiService.buildFiltersFromQueryParams).toHaveBeenCalledWith( + expect.objectContaining({ filterByDataSource: DataSource.YAHOO }) + ); + }); + it('Keeps the data source if the subscription is not enabled', async () => { configuration.DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER = [DataSource.YAHOO]; configuration.ENABLE_FEATURE_SUBSCRIPTION = false; @@ -304,14 +353,15 @@ describe('McpService', () => { }); describe('getPortfolio', () => { - it('Gives the table of the holdings in the default language', async () => { + it('Gives the table of the holdings with the data source in the default language', async () => { expect(await mcpService.getPortfolio({ userId })).toEqual({ content: [{ text: '## Holdings', type: 'text' }] }); expect(portfolioTableService.getHoldingsTable).toHaveBeenCalledWith({ userId, - languageCode: DEFAULT_LANGUAGE_CODE + languageCode: DEFAULT_LANGUAGE_CODE, + withDataSource: true }); }); }); @@ -342,9 +392,6 @@ describe('McpService', () => { it('Gives the import-ready asset profiles available to the user', async () => { const user = setupUser([permissions.createActivity]); - configuration.DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER = [DataSource.YAHOO]; - configuration.ENABLE_FEATURE_SUBSCRIPTION = true; - jest.spyOn(symbolService, 'lookup').mockResolvedValue({ items: [ { @@ -383,12 +430,46 @@ describe('McpService', () => { assetClass: AssetClass.EQUITY, assetSubClass: AssetSubClass.STOCK, currency: 'USD', - dataSource: DataSource.GHOSTFOLIO, + dataSource: DataSource.YAHOO, + name: 'Apple Inc.', + symbol: 'AAPL' + } + ] + }); + }); + + it('Encodes the data source if the subscription is enabled', async () => { + setupUser([permissions.createActivity]); + + configuration.DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER = [DataSource.YAHOO]; + configuration.ENABLE_FEATURE_SUBSCRIPTION = true; + + jest.spyOn(symbolService, 'lookup').mockResolvedValue({ + items: [ + { + assetClass: AssetClass.EQUITY, + assetSubClass: AssetSubClass.STOCK, + currency: 'USD', + dataProviderInfo: { isPremium: false }, + dataSource: DataSource.YAHOO, name: 'Apple Inc.', symbol: 'AAPL' } ] }); + + const result = await mcpService.searchAssetProfiles({ + query: 'Apple', + userId + }); + + expect(JSON.parse(result.content[0].text)).toEqual({ + assetProfiles: [ + expect.objectContaining({ + dataSource: encodeDataSource(DataSource.YAHOO) + }) + ] + }); }); }); @@ -448,6 +529,27 @@ describe('McpService', () => { ); }); + it('Decodes an encoded data source', async () => { + setupUser([permissions.createActivity]); + + configuration.ENABLE_FEATURE_SUBSCRIPTION = true; + + await mcpService.importActivities({ + userId, + activities: [ + createActivity({ dataSource: encodeDataSource(DataSource.YAHOO) }) + ] + }); + + expect(importService.import).toHaveBeenCalledWith( + expect.objectContaining({ + activitiesDto: [ + expect.objectContaining({ dataSource: DataSource.YAHOO }) + ] + }) + ); + }); + it('Keeps the data source if the subscription is not enabled', async () => { setupUser([permissions.createActivity]); diff --git a/apps/api/src/app/endpoints/mcp/mcp.service.ts b/apps/api/src/app/endpoints/mcp/mcp.service.ts index 6a989dc856..70be380060 100644 --- a/apps/api/src/app/endpoints/mcp/mcp.service.ts +++ b/apps/api/src/app/endpoints/mcp/mcp.service.ts @@ -2,8 +2,8 @@ import { ImportService } from '@ghostfolio/api/app/import/import.service'; import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service'; import { UserService } from '@ghostfolio/api/app/user/user.service'; import { - getMaskedGhostfolioDataSource, - getUnmaskedGhostfolioDataSource + transformDataSourceInRequest, + transformDataSourceInResponse } from '@ghostfolio/api/helper/data-source.helper'; import { ApiService } from '@ghostfolio/api/services/api/api.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; @@ -121,7 +121,8 @@ export class McpService { public async getPortfolio({ userId }: { userId: string }) { const table = await this.portfolioTableService.getHoldingsTable({ userId, - languageCode: DEFAULT_LANGUAGE_CODE + languageCode: DEFAULT_LANGUAGE_CODE, + withDataSource: true }); return this.getTextResult(table); @@ -144,13 +145,11 @@ export class McpService { permission: permissions.createActivity }); - const ghostfolioDataSources = this.getGhostfolioDataSources(); - const activitiesDto = activities.map((activity) => { return { ...activity, - dataSource: getUnmaskedGhostfolioDataSource({ - ghostfolioDataSources, + dataSource: transformDataSourceInRequest({ + configurationService: this.configurationService, dataSource: activity.dataSource }) }; @@ -188,8 +187,6 @@ export class McpService { const { items } = await this.symbolService.lookup({ query, user }); - const ghostfolioDataSources = this.getGhostfolioDataSources(); - const assetProfiles = items.flatMap( ({ assetClass, @@ -211,9 +208,9 @@ export class McpService { currency, name, symbol, - dataSource: getMaskedGhostfolioDataSource({ + dataSource: transformDataSourceInResponse({ dataSource, - ghostfolioDataSources + configurationService: this.configurationService }) } ]; @@ -223,19 +220,13 @@ export class McpService { return this.getTextResult(JSON.stringify({ assetProfiles }, null, 2)); } - private getGhostfolioDataSources() { - return this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') - ? this.configurationService.get('DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER') - : []; - } - private getHoldingFilterParameters({ holding }: Pick, 'holding'>) { return { - filterByDataSource: getUnmaskedGhostfolioDataSource({ - dataSource: holding?.dataSource, - ghostfolioDataSources: this.getGhostfolioDataSources() + filterByDataSource: transformDataSourceInRequest({ + configurationService: this.configurationService, + dataSource: holding?.dataSource }), filterBySymbol: holding?.symbol }; diff --git a/apps/api/src/helper/data-source.helper.spec.ts b/apps/api/src/helper/data-source.helper.spec.ts new file mode 100644 index 0000000000..02d979441e --- /dev/null +++ b/apps/api/src/helper/data-source.helper.spec.ts @@ -0,0 +1,123 @@ +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; + +import { DataSource } from '@prisma/client'; + +import { + encodeDataSource, + isValidEncodedDataSource, + transformDataSourceInRequest, + transformDataSourceInResponse +} from './data-source.helper'; + +function createConfigurationService({ + isSubscriptionEnabled +}: { + isSubscriptionEnabled: boolean; +}) { + const configuration: Record = { + DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER: [DataSource.YAHOO], + ENABLE_FEATURE_SUBSCRIPTION: isSubscriptionEnabled + }; + + return { + get: jest.fn((key: string) => { + return configuration[key]; + }) + } as unknown as ConfigurationService; +} + +describe('DataSourceHelper', () => { + describe('isValidEncodedDataSource', () => { + it('Accepts an encoded data source', () => { + expect(isValidEncodedDataSource(encodeDataSource(DataSource.YAHOO))).toBe( + true + ); + }); + + it('Refuses a data source which is not encoded', () => { + expect(isValidEncodedDataSource(DataSource.YAHOO)).toBe(false); + }); + + it('Refuses an unknown encoded data source', () => { + expect(isValidEncodedDataSource('ffffffff')).toBe(false); + }); + }); + + describe('transformDataSourceInRequest', () => { + const configurationService = createConfigurationService({ + isSubscriptionEnabled: true + }); + + it('Decodes an encoded data source', () => { + expect( + transformDataSourceInRequest({ + configurationService, + dataSource: encodeDataSource(DataSource.COINGECKO) + }) + ).toBe(DataSource.COINGECKO); + }); + + it('Resolves the mask of the data source of the Ghostfolio data provider', () => { + expect( + transformDataSourceInRequest({ + configurationService, + dataSource: DataSource.GHOSTFOLIO + }) + ).toBe(DataSource.YAHOO); + }); + + it('Keeps a data source which is not encoded', () => { + expect( + transformDataSourceInRequest({ + configurationService, + dataSource: DataSource.COINGECKO + }) + ).toBe(DataSource.COINGECKO); + }); + + it('Keeps an absent data source', () => { + expect( + transformDataSourceInRequest({ + configurationService, + dataSource: undefined + }) + ).toBeUndefined(); + }); + }); + + describe('transformDataSourceInResponse', () => { + it('Encodes the data source if the subscription is enabled', () => { + expect( + transformDataSourceInResponse({ + configurationService: createConfigurationService({ + isSubscriptionEnabled: true + }), + dataSource: DataSource.YAHOO + }) + ).toBe(encodeDataSource(DataSource.YAHOO)); + }); + + it('Keeps the data sources GHOSTFOLIO and MANUAL if the subscription is enabled', () => { + const configurationService = createConfigurationService({ + isSubscriptionEnabled: true + }); + + for (const dataSource of [DataSource.GHOSTFOLIO, DataSource.MANUAL]) { + expect( + transformDataSourceInResponse({ configurationService, dataSource }) + ).toBe(dataSource); + } + }); + + it('Keeps the data source if the subscription is not enabled', () => { + expect( + transformDataSourceInResponse({ + configurationService: createConfigurationService({ + isSubscriptionEnabled: false + }), + dataSource: DataSource.YAHOO + }) + ).toBe(DataSource.YAHOO); + }); + }); +}); diff --git a/apps/api/src/helper/data-source.helper.ts b/apps/api/src/helper/data-source.helper.ts index fff8eaac1d..79802883e8 100644 --- a/apps/api/src/helper/data-source.helper.ts +++ b/apps/api/src/helper/data-source.helper.ts @@ -1,6 +1,13 @@ +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; + import { DataSource } from '@prisma/client'; import { createHash } from 'node:crypto'; +const DATA_SOURCES_WITHOUT_ENCODING: DataSource[] = [ + DataSource.GHOSTFOLIO, + DataSource.MANUAL +]; + const encodedDataSourceByDataSource = new Map( Object.values(DataSource).map((dataSource) => { return [dataSource, hashDataSource(dataSource)]; @@ -30,6 +37,16 @@ function deprecatedHashDataSource(dataSource: DataSource) { return Buffer.from(dataSource, 'utf-8').toString('hex'); } +function getGhostfolioDataSources({ + configurationService +}: { + configurationService: ConfigurationService; +}) { + return configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') + ? configurationService.get('DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER') + : []; +} + function hashDataSource(dataSource: DataSource) { return createHash('sha256').update(dataSource).digest('hex').slice(0, 8); } @@ -77,3 +94,51 @@ export function getUnmaskedGhostfolioDataSource({ ? (ghostfolioDataSources[0] as DataSource) : dataSource; } + +export function isDataSourceEncodedInResponse(dataSource: DataSource) { + return !DATA_SOURCES_WITHOUT_ENCODING.includes(dataSource); +} + +export function isValidEncodedDataSource(encodedDataSource: string) { + return dataSourceByEncodedDataSource.has(encodedDataSource); +} + +/** + * Gives the data source of a request without a transformation: an encoded data + * source is decoded, and the mask of the Ghostfolio data provider is resolved + * (see the TransformDataSourceInRequestInterceptor) + */ +export function transformDataSourceInRequest({ + configurationService, + dataSource +}: { + configurationService: ConfigurationService; + dataSource?: string; +}) { + if (Object.hasOwn(DataSource, dataSource)) { + return getUnmaskedGhostfolioDataSource({ + dataSource: dataSource as DataSource, + ghostfolioDataSources: getGhostfolioDataSources({ configurationService }) + }); + } + + return decodeDataSource(dataSource) as DataSource; +} + +/** + * Gives the data source as a response gives it to a user who is not an admin: + * encoded if the subscription is enabled, except the data sources GHOSTFOLIO + * and MANUAL (see the TransformDataSourceInResponseInterceptor) + */ +export function transformDataSourceInResponse({ + configurationService, + dataSource +}: { + configurationService: ConfigurationService; + dataSource: DataSource; +}) { + return configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && + isDataSourceEncodedInResponse(dataSource) + ? encodeDataSource(dataSource) + : dataSource; +} diff --git a/apps/api/src/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor.ts b/apps/api/src/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor.ts index 339b91a2f7..968159cbac 100644 --- a/apps/api/src/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor.ts +++ b/apps/api/src/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor.ts @@ -1,6 +1,7 @@ import { encodeDataSource, - getMaskedGhostfolioDataSource + getMaskedGhostfolioDataSource, + isDataSourceEncodedInResponse } from '@ghostfolio/api/helper/data-source.helper'; import { redactPaths } from '@ghostfolio/api/helper/object.helper'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; @@ -30,7 +31,7 @@ export class TransformDataSourceInResponseInterceptor< if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { this.encodedDataSourceMap = Object.keys(DataSource).reduce( (encodedDataSourceMap, dataSource) => { - if (!['GHOSTFOLIO', 'MANUAL'].includes(dataSource)) { + if (isDataSourceEncodedInResponse(DataSource[dataSource])) { encodedDataSourceMap[dataSource] = encodeDataSource( DataSource[dataSource] ); diff --git a/apps/api/src/services/portfolio-table/interfaces/data-source-table-context.interface.ts b/apps/api/src/services/portfolio-table/interfaces/data-source-table-context.interface.ts new file mode 100644 index 0000000000..927f32cda0 --- /dev/null +++ b/apps/api/src/services/portfolio-table/interfaces/data-source-table-context.interface.ts @@ -0,0 +1,5 @@ +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; + +export interface DataSourceTableContext { + configurationService: ConfigurationService; +} diff --git a/apps/api/src/services/portfolio-table/interfaces/holdings-table-context.interface.ts b/apps/api/src/services/portfolio-table/interfaces/holdings-table-context.interface.ts index 4364b492e7..771cd10404 100644 --- a/apps/api/src/services/portfolio-table/interfaces/holdings-table-context.interface.ts +++ b/apps/api/src/services/portfolio-table/interfaces/holdings-table-context.interface.ts @@ -1,6 +1,8 @@ import { AssetClass, AssetSubClass } from '@prisma/client'; -export interface HoldingsTableContext { +import { DataSourceTableContext } from './data-source-table-context.interface'; + +export interface HoldingsTableContext extends DataSourceTableContext { assetClassTranslations: Record; assetSubClassTranslations: Record; } diff --git a/apps/api/src/services/portfolio-table/portfolio-table.module.ts b/apps/api/src/services/portfolio-table/portfolio-table.module.ts index 0995dc9980..01d0ebc64d 100644 --- a/apps/api/src/services/portfolio-table/portfolio-table.module.ts +++ b/apps/api/src/services/portfolio-table/portfolio-table.module.ts @@ -1,6 +1,7 @@ import { ActivitiesModule } from '@ghostfolio/api/app/activities/activities.module'; import { WatchlistModule } from '@ghostfolio/api/app/endpoints/watchlist/watchlist.module'; import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module'; +import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { I18nModule } from '@ghostfolio/api/services/i18n/i18n.module'; import { Module } from '@nestjs/common'; @@ -9,7 +10,13 @@ import { PortfolioTableService } from './portfolio-table.service'; @Module({ exports: [PortfolioTableService], - imports: [ActivitiesModule, I18nModule, PortfolioModule, WatchlistModule], + imports: [ + ActivitiesModule, + ConfigurationModule, + I18nModule, + PortfolioModule, + WatchlistModule + ], providers: [PortfolioTableService] }) export class PortfolioTableModule {} diff --git a/apps/api/src/services/portfolio-table/portfolio-table.service.spec.ts b/apps/api/src/services/portfolio-table/portfolio-table.service.spec.ts index ce92747c40..3cfb9fe856 100644 --- a/apps/api/src/services/portfolio-table/portfolio-table.service.spec.ts +++ b/apps/api/src/services/portfolio-table/portfolio-table.service.spec.ts @@ -1,12 +1,16 @@ +import type { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service'; import type { WatchlistService } from '@ghostfolio/api/app/endpoints/watchlist/watchlist.service'; import type { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; +import { encodeDataSource } from '@ghostfolio/api/helper/data-source.helper'; import type { TableParameters } from '@ghostfolio/api/helper/interfaces/table-parameters.interface'; +import type { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import type { I18nService } from '@ghostfolio/api/services/i18n/i18n.service'; import { DEFAULT_LANGUAGE_CODE, TAG_ID_EXCLUDE_FROM_ANALYSIS } from '@ghostfolio/common/config'; import { + Activity, HistoricalDataItem, PortfolioPerformanceResponse, PortfolioPosition, @@ -60,6 +64,8 @@ jest.mock('@ghostfolio/api/helper/markdown-table.helper', () => { }; }); +const SUBSCRIPTION_CONFIGURATION = { ENABLE_FEATURE_SUBSCRIPTION: true }; + function createAccount({ id = 'account-a-id', isExcluded = false, @@ -82,15 +88,39 @@ function createAccount({ } as unknown as AccountWithValue; } +function createActivity({ + dataSource = DataSource.YAHOO, + symbol = 'AAPL' +}: { + dataSource?: DataSource; + symbol?: string; +} = {}) { + return { + account: { name: 'Account A' }, + assetProfile: { + dataSource, + symbol, + currency: 'CHF', + name: `Name of ${symbol}` + }, + currency: 'CHF', + date: new Date('2024-01-01'), + type: 'BUY', + unitPrice: 100 + } as unknown as Activity; +} + function createHolding({ allocationInPercentage = 0.75, assetClass = AssetClass.EQUITY, assetSubClass = AssetSubClass.STOCK, + dataSource = DataSource.YAHOO, symbol = 'AAPL' }: { allocationInPercentage?: number; assetClass?: AssetClass; assetSubClass?: AssetSubClass; + dataSource?: DataSource; symbol?: string; } = {}) { return { @@ -99,6 +129,7 @@ function createHolding({ assetProfile: { assetClass, assetSubClass, + dataSource, symbol, currency: 'CHF', name: `Name of ${symbol}` @@ -132,18 +163,20 @@ function createPerformance({ } function createWatchlistItem({ + dataSource = DataSource.YAHOO, name = 'Name of AAPL', performancePercent = -0.25, symbol = 'AAPL' }: { + dataSource?: DataSource; name?: string; performancePercent?: number; symbol?: string; } = {}): WatchlistResponse['watchlist'][number] { return { + dataSource, name, symbol, - dataSource: DataSource.YAHOO, marketCondition: 'BEAR_MARKET', performances: { allTimeHigh: { @@ -158,17 +191,33 @@ function createWatchlistItem({ function createPortfolioTableService({ accounts = [], + activities = [], chart = [{ date: '2024-01-01' }], + configuration = { ENABLE_FEATURE_SUBSCRIPTION: false }, holdings = [], performance = createPerformance(), watchlist = [] }: { accounts?: AccountWithValue[]; + activities?: Activity[]; chart?: HistoricalDataItem[]; + configuration?: Record; holdings?: PortfolioPosition[]; performance?: PortfolioPerformanceResponse['performance']; watchlist?: WatchlistResponse['watchlist']; } = {}) { + const activitiesService = { + getActivities: jest + .fn() + .mockResolvedValue({ activities, count: activities.length }) + } as unknown as ActivitiesService; + + const configurationService = { + get: jest.fn((key: string) => { + return configuration[key]; + }) + } as unknown as ConfigurationService; + // The mock gives the identifier of the translation, so that a test can tell // the translation of the asset class from that of the asset sub class const i18nService = { @@ -188,7 +237,8 @@ function createPortfolioTableService({ } as unknown as WatchlistService; return new PortfolioTableService( - null, + activitiesService, + configurationService, i18nService, portfolioService, watchlistService @@ -220,6 +270,7 @@ describe('PortfolioTableService', () => { 'Type', 'Name', 'Symbol', + 'Data Source', 'Currency', 'Unit Price', 'Account' @@ -232,6 +283,7 @@ describe('PortfolioTableService', () => { expect(PortfolioTableService.getHoldingsTableColumnNames()).toEqual([ 'Name', 'Symbol', + 'Data Source', 'Currency', 'Asset Class', 'Asset Sub Class', @@ -257,6 +309,7 @@ describe('PortfolioTableService', () => { expect(PortfolioTableService.getWatchlistTableColumnNames()).toEqual([ 'Name', 'Symbol', + 'Data Source', 'Trend 50 Days', 'Trend 200 Days', 'Date of Last All Time High', @@ -327,14 +380,118 @@ describe('PortfolioTableService', () => { expect(result).toContain('No accounts found.'); }); }); + + describe('getActivitiesTable', () => { + it('gives the data source, encoded if the subscription is enabled', async () => { + const result = await createPortfolioTableService({ + activities: [ + createActivity({ dataSource: DataSource.YAHOO, symbol: 'AAPL' }), + createActivity({ + dataSource: DataSource.MANUAL, + symbol: 'GF_GOLD' + }) + ], + configuration: SUBSCRIPTION_CONFIGURATION + }).getActivitiesTable({ + take: 50, + userCurrency: 'CHF', + userId: 'user-id' + }); + + const [rowOfAapl, rowOfGold] = result.split('\n').filter((line) => { + return line.startsWith('| 2024-01-01'); + }); + + expect(rowOfAapl).toContain(`| ${encodeDataSource(DataSource.YAHOO)} |`); + expect(rowOfGold).toContain(`| ${DataSource.MANUAL} |`); + }); + }); + describe('getHoldingsTable', () => { - function getHoldingsTable(holdings: PortfolioPosition[]) { - return createPortfolioTableService({ holdings }).getHoldingsTable({ + function getHoldingsTable( + holdings: PortfolioPosition[], + { + configuration, + withDataSource + }: { + configuration?: Record; + withDataSource?: boolean; + } = {} + ) { + return createPortfolioTableService({ + configuration, + holdings + }).getHoldingsTable({ + withDataSource, languageCode: DEFAULT_LANGUAGE_CODE, userId: 'user-id' }); } + it('gives no data source by default', async () => { + const result = await getHoldingsTable([createHolding()], { + configuration: SUBSCRIPTION_CONFIGURATION + }); + + expect(result).not.toContain('Data Source'); + expect(result).not.toContain(encodeDataSource(DataSource.YAHOO)); + }); + + it('gives the data source, encoded if the subscription is enabled', async () => { + const result = await getHoldingsTable( + [ + createHolding({ dataSource: DataSource.YAHOO, symbol: 'AAPL' }), + createHolding({ + allocationInPercentage: 0.25, + dataSource: DataSource.MANUAL, + symbol: 'GF_GOLD' + }) + ], + { configuration: SUBSCRIPTION_CONFIGURATION, withDataSource: true } + ); + + const [rowOfAapl, rowOfGold] = result.split('\n').filter((line) => { + return line.startsWith('| Name of'); + }); + + expect(rowOfAapl).toContain(`| ${encodeDataSource(DataSource.YAHOO)} |`); + expect(rowOfGold).toContain(`| ${DataSource.MANUAL} |`); + }); + + it('gives the data source unencoded if the subscription is disabled', async () => { + const result = await getHoldingsTable([createHolding()], { + configuration: { ENABLE_FEATURE_SUBSCRIPTION: false }, + withDataSource: true + }); + + const [row] = result.split('\n').filter((line) => { + return line.startsWith('| Name of AAPL'); + }); + + expect(row).toContain(`| ${DataSource.YAHOO} |`); + }); + + it('gives no data source of a cash position', async () => { + const result = await getHoldingsTable( + [ + createHolding({ + assetClass: AssetClass.LIQUIDITY, + assetSubClass: AssetSubClass.CASH, + dataSource: DataSource.YAHOO, + symbol: 'USD' + }) + ], + { configuration: SUBSCRIPTION_CONFIGURATION, withDataSource: true } + ); + + const [row] = result.split('\n').filter((line) => { + return line.startsWith('| Name of USD'); + }); + + expect(row).toContain('| USD | | CHF |'); + expect(row).not.toContain(encodeDataSource(DataSource.YAHOO)); + }); + it('gives the translation of the asset class and of the asset sub class', async () => { const result = await getHoldingsTable([createHolding()]); @@ -417,12 +574,39 @@ describe('PortfolioTableService', () => { }); describe('getWatchlistTable', () => { - function getWatchlistTable(watchlist: WatchlistResponse['watchlist']) { - return createPortfolioTableService({ watchlist }).getWatchlistTable({ + function getWatchlistTable( + watchlist: WatchlistResponse['watchlist'], + configuration?: Record + ) { + return createPortfolioTableService({ + configuration, + watchlist + }).getWatchlistTable({ userId: 'user-id' }); } + it('gives the data source, encoded if the subscription is enabled', async () => { + const result = await getWatchlistTable( + [ + createWatchlistItem(), + createWatchlistItem({ + dataSource: DataSource.MANUAL, + name: 'Name of GF_GOLD', + symbol: 'GF_GOLD' + }) + ], + SUBSCRIPTION_CONFIGURATION + ); + + const [rowOfAapl, rowOfGold] = result.split('\n').filter((line) => { + return line.startsWith('| Name of'); + }); + + expect(rowOfAapl).toContain(`| ${encodeDataSource(DataSource.YAHOO)} |`); + expect(rowOfGold).toContain(`| ${DataSource.MANUAL} |`); + }); + it('gives the date and the change of the all time high', async () => { const result = await getWatchlistTable([createWatchlistItem()]); diff --git a/apps/api/src/services/portfolio-table/portfolio-table.service.ts b/apps/api/src/services/portfolio-table/portfolio-table.service.ts index a9c4c36121..9e63d133b5 100644 --- a/apps/api/src/services/portfolio-table/portfolio-table.service.ts +++ b/apps/api/src/services/portfolio-table/portfolio-table.service.ts @@ -1,10 +1,16 @@ import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service'; import { WatchlistService } from '@ghostfolio/api/app/endpoints/watchlist/watchlist.service'; import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; +import { transformDataSourceInResponse } from '@ghostfolio/api/helper/data-source.helper'; import { TableColumnDefinition } from '@ghostfolio/api/helper/interfaces/table-column-definition.interface'; import { getMarkdownTable } from '@ghostfolio/api/helper/markdown-table.helper'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service'; -import { DATE_FORMAT, isAccountExcluded } from '@ghostfolio/common/helper'; +import { + DATE_FORMAT, + isAccountExcluded, + isCashPosition +} from '@ghostfolio/common/helper'; import { Activity, Filter, @@ -17,12 +23,31 @@ import { Injectable } from '@nestjs/common'; import { AssetClass, AssetSubClass, + DataSource, Type as ActivityType } from '@prisma/client'; import { format } from 'date-fns'; +import { DataSourceTableContext } from './interfaces/data-source-table-context.interface'; import { HoldingsTableColumnDefinition } from './types/holdings-table-column-definition.type'; +const DATA_SOURCE_COLUMN_NAME = 'Data Source'; + +function getDataSourceColumnDefinition( + getDataSource: (row: T) => DataSource | undefined +): TableColumnDefinition { + return { + getValue: (row, { configurationService }) => { + const dataSource = getDataSource(row); + + return dataSource + ? transformDataSourceInResponse({ configurationService, dataSource }) + : ''; + }, + name: DATA_SOURCE_COLUMN_NAME + }; +} + function getPercentage(value: number) { return `${(value * 100).toFixed(3)}%`; } @@ -83,52 +108,57 @@ export class PortfolioTableService { } ]; - private static readonly ACTIVITIES_TABLE_COLUMN_DEFINITIONS: TableColumnDefinition[] = - [ - { - getValue: ({ date }) => { - return format(date, DATE_FORMAT); - }, - name: 'Date' + private static readonly ACTIVITIES_TABLE_COLUMN_DEFINITIONS: TableColumnDefinition< + Activity, + DataSourceTableContext + >[] = [ + { + getValue: ({ date }) => { + return format(date, DATE_FORMAT); }, - { - getValue: ({ type }) => { - return type; - }, - name: 'Type' + name: 'Date' + }, + { + getValue: ({ type }) => { + return type; }, - { - getValue: ({ assetProfile }) => { - return assetProfile.name ?? ''; - }, - name: 'Name' + name: 'Type' + }, + { + getValue: ({ assetProfile }) => { + return assetProfile.name ?? ''; }, - { - getValue: ({ assetProfile }) => { - return assetProfile.symbol; - }, - name: 'Symbol' + name: 'Name' + }, + { + getValue: ({ assetProfile }) => { + return assetProfile.symbol; }, - { - getValue: ({ assetProfile, currency }) => { - return currency ?? assetProfile.currency; - }, - name: 'Currency' + name: 'Symbol' + }, + getDataSourceColumnDefinition(({ assetProfile }) => { + return assetProfile.dataSource; + }), + { + getValue: ({ assetProfile, currency }) => { + return currency ?? assetProfile.currency; }, - { - align: 'right', - getValue: ({ unitPrice }) => { - return unitPrice.toString(); - }, - name: 'Unit Price' + name: 'Currency' + }, + { + align: 'right', + getValue: ({ unitPrice }) => { + return unitPrice.toString(); }, - { - getValue: ({ account }) => { - return account?.name ?? ''; - }, - name: 'Account' - } - ]; + name: 'Unit Price' + }, + { + getValue: ({ account }) => { + return account?.name ?? ''; + }, + name: 'Account' + } + ]; private static readonly HOLDINGS_TABLE_COLUMN_DEFINITIONS: HoldingsTableColumnDefinition[] = [ @@ -144,6 +174,11 @@ export class PortfolioTableService { }, name: 'Symbol' }, + getDataSourceColumnDefinition(({ assetProfile }) => { + return isCashPosition(assetProfile) + ? undefined + : assetProfile.dataSource; + }), { getValue: ({ assetProfile }) => { return assetProfile.currency; @@ -222,7 +257,8 @@ export class PortfolioTableService { ]; private static readonly WATCHLIST_TABLE_COLUMN_DEFINITIONS: TableColumnDefinition< - WatchlistResponse['watchlist'][number] + WatchlistResponse['watchlist'][number], + DataSourceTableContext >[] = [ { getValue: ({ name }) => { @@ -236,6 +272,9 @@ export class PortfolioTableService { }, name: 'Symbol' }, + getDataSourceColumnDefinition(({ dataSource }) => { + return dataSource; + }), { getValue: ({ trend50d }) => { return trend50d; @@ -273,6 +312,7 @@ export class PortfolioTableService { public constructor( private readonly activitiesService: ActivitiesService, + private readonly configurationService: ConfigurationService, private readonly i18nService: I18nService, private readonly portfolioService: PortfolioService, private readonly watchlistService: WatchlistService @@ -399,6 +439,7 @@ export class PortfolioTableService { await getMarkdownTable({ columnDefinitions: PortfolioTableService.ACTIVITIES_TABLE_COLUMN_DEFINITIONS, + context: { configurationService: this.configurationService }, rows: activities }) ); @@ -410,11 +451,13 @@ export class PortfolioTableService { public async getHoldingsTable({ filters, languageCode, - userId + userId, + withDataSource = false }: { filters?: Filter[]; languageCode: string; userId: string; + withDataSource?: boolean; }) { const { holdings } = await this.portfolioService.getDetails({ filters, @@ -442,8 +485,16 @@ export class PortfolioTableService { '', await getMarkdownTable({ columnDefinitions: - PortfolioTableService.HOLDINGS_TABLE_COLUMN_DEFINITIONS, - context: { assetClassTranslations, assetSubClassTranslations }, + PortfolioTableService.HOLDINGS_TABLE_COLUMN_DEFINITIONS.filter( + ({ name }) => { + return withDataSource || name !== DATA_SOURCE_COLUMN_NAME; + } + ), + context: { + assetClassTranslations, + assetSubClassTranslations, + configurationService: this.configurationService + }, rows: sortedHoldings }) ].join('\n'); @@ -491,6 +542,7 @@ export class PortfolioTableService { await getMarkdownTable({ columnDefinitions: PortfolioTableService.WATCHLIST_TABLE_COLUMN_DEFINITIONS, + context: { configurationService: this.configurationService }, rows: watchlist }) ); From ce68375f6fefbf28a0222341ce64e256907131f0 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sat, 3 Oct 2026 11:01:23 +0200 Subject: [PATCH 20/26] Bugfix/positive performance from all time high in watchlist (#8012) * Fix positive performance from all time high * Update changelog --- CHANGELOG.md | 1 + .../app/endpoints/watchlist/watchlist.service.ts | 7 +++++-- .../services/benchmark/benchmark.service.spec.ts | 10 ++++++++++ .../api/src/services/benchmark/benchmark.service.ts | 13 +++++++++---- 4 files changed, 25 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d64f3d12f3..7149a52583 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Fixed the positive performance from the all time high in the watchlist - Fixed the portfolio calculation for holdings with historical market prices between the chart dates ## 3.77.0 - 2026-10-02 diff --git a/apps/api/src/app/endpoints/watchlist/watchlist.service.ts b/apps/api/src/app/endpoints/watchlist/watchlist.service.ts index c69a31df82..925f49cd6b 100644 --- a/apps/api/src/app/endpoints/watchlist/watchlist.service.ts +++ b/apps/api/src/app/endpoints/watchlist/watchlist.service.ts @@ -140,8 +140,11 @@ export class WatchlistService { name: assetProfile?.name, performances: { allTimeHigh: { - performancePercent, - date: allTimeHigh?.date + date: allTimeHigh?.date, + performancePercent: + this.benchmarkService.getPerformancePercentFromAllTimeHigh( + performancePercent + ) } }, trend50d: trends.trend50d, diff --git a/apps/api/src/services/benchmark/benchmark.service.spec.ts b/apps/api/src/services/benchmark/benchmark.service.spec.ts index 0e7119b045..27124e89e4 100644 --- a/apps/api/src/services/benchmark/benchmark.service.spec.ts +++ b/apps/api/src/services/benchmark/benchmark.service.spec.ts @@ -24,4 +24,14 @@ describe('BenchmarkService', () => { ); expect(benchmarkService.getMarketCondition(-0.2)).toEqual('BEAR_MARKET'); }); + + it('getPerformancePercentFromAllTimeHigh', async () => { + expect(benchmarkService.getPerformancePercentFromAllTimeHigh(0.1)).toEqual( + 0 + ); + expect(benchmarkService.getPerformancePercentFromAllTimeHigh(0)).toEqual(0); + expect(benchmarkService.getPerformancePercentFromAllTimeHigh(-0.1)).toEqual( + -0.1 + ); + }); }); diff --git a/apps/api/src/services/benchmark/benchmark.service.ts b/apps/api/src/services/benchmark/benchmark.service.ts index 4ea27b2f2e..3c39848401 100644 --- a/apps/api/src/services/benchmark/benchmark.service.ts +++ b/apps/api/src/services/benchmark/benchmark.service.ts @@ -216,6 +216,12 @@ export class BenchmarkService { } } + public getPerformancePercentFromAllTimeHigh(performancePercent: number) { + const isAtAllTimeHigh = performancePercent >= 0; + + return isAtAllTimeHigh ? 0 : performancePercent; + } + public async isBenchmark(symbolProfileId: string): Promise { const benchmarks = await this.getBenchmarksProperty(); @@ -288,10 +294,9 @@ export class BenchmarkService { performances: { allTimeHigh: { date: allTimeHigh?.date, - performancePercent: - performancePercentFromAllTimeHigh >= 0 - ? 0 - : performancePercentFromAllTimeHigh + performancePercent: this.getPerformancePercentFromAllTimeHigh( + performancePercentFromAllTimeHigh + ) } }, symbol: benchmarkAssetProfiles[index].symbol, From 5dc53487d38c551ba5a80fea6788485db16add77 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sat, 3 Oct 2026 13:35:54 +0200 Subject: [PATCH 21/26] Bugfix/data gathering of manual activities (#7997) * Fix data gathering of manual activities * Update changelog --- CHANGELOG.md | 1 + .../app/activities/activities.controller.ts | 18 +++++++++------ .../src/app/activities/activities.service.ts | 2 +- apps/api/src/helper/data-source.helper.ts | 15 ++++++++++++- .../data-gathering/data-gathering.service.ts | 22 ++++--------------- 5 files changed, 31 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7149a52583..73f5227a41 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fixed the positive performance from the all time high in the watchlist - Fixed the portfolio calculation for holdings with historical market prices between the chart dates +- Fixed the asset profile identifier in the historical market data gathering of the `POST api/v1/activities` endpoint ## 3.77.0 - 2026-10-02 diff --git a/apps/api/src/app/activities/activities.controller.ts b/apps/api/src/app/activities/activities.controller.ts index 0ed1d781c1..90851b2ca4 100644 --- a/apps/api/src/app/activities/activities.controller.ts +++ b/apps/api/src/app/activities/activities.controller.ts @@ -2,6 +2,7 @@ import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorat import { Impersonation } from '@ghostfolio/api/decorators/impersonation.decorator'; import { RequiresScope } from '@ghostfolio/api/decorators/requires-scope.decorator'; import { isActivityInFuture } from '@ghostfolio/api/helper/activity.helper'; +import { isDataGatheringSupported } from '@ghostfolio/api/helper/data-source.helper'; import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.interceptor'; import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor'; import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor'; @@ -32,7 +33,7 @@ import { Query, UseInterceptors } from '@nestjs/common'; -import { Order } from '@prisma/client'; +import { Order, Prisma } from '@prisma/client'; import { parseISO } from 'date-fns'; import { StatusCodes, getReasonPhrase } from 'http-status-codes'; @@ -216,7 +217,7 @@ export class ActivitiesController { userId, userSubscription }: ImpersonationContext - ): Promise { + ): Promise> { // Evaluate the more restrictive subscription of the authenticated user // and the owner of the activity const subscription = @@ -284,15 +285,18 @@ export class ActivitiesController { user: { connect: { id: userId } } }); - if (dataSource && !isActivityInFuture({ date: activity.date })) { - // Gather symbol data in the background, if data source is set - // (not MANUAL) and the date is not in the future + if ( + isDataGatheringSupported(activity.SymbolProfile) && + !isActivityInFuture({ date: activity.date }) + ) { + // Gather symbol data in the background, if the asset profile supports + // data gathering and the date is not in the future this.dataGatheringService.gatherSymbols({ dataGatheringItems: [ { - dataSource, + dataSource: activity.SymbolProfile.dataSource, date: activity.date, - symbol: data.symbol + symbol: activity.SymbolProfile.symbol } ], priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH diff --git a/apps/api/src/app/activities/activities.service.ts b/apps/api/src/app/activities/activities.service.ts index 59d1ba9c33..75adcbc5d3 100644 --- a/apps/api/src/app/activities/activities.service.ts +++ b/apps/api/src/app/activities/activities.service.ts @@ -183,7 +183,7 @@ export class ActivitiesService { updateAccountBalance?: boolean; userId: string; } - ): Promise { + ): Promise> { const tags = data.tags ?? []; await this.tagService.validateTagIds({ diff --git a/apps/api/src/helper/data-source.helper.ts b/apps/api/src/helper/data-source.helper.ts index 79802883e8..19a26816d9 100644 --- a/apps/api/src/helper/data-source.helper.ts +++ b/apps/api/src/helper/data-source.helper.ts @@ -1,6 +1,7 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; -import { DataSource } from '@prisma/client'; +import { DataSource, Prisma } from '@prisma/client'; +import { isEmpty } from 'lodash-es'; import { createHash } from 'node:crypto'; const DATA_SOURCES_WITHOUT_ENCODING: DataSource[] = [ @@ -95,6 +96,18 @@ export function getUnmaskedGhostfolioDataSource({ : dataSource; } +export function isDataGatheringSupported({ + dataSource, + scraperConfiguration +}: { + dataSource: DataSource; + scraperConfiguration: Prisma.JsonValue; +}) { + // An asset profile with the MANUAL data source supports data gathering only + // with a scraper configuration + return dataSource !== DataSource.MANUAL || !isEmpty(scraperConfiguration); +} + export function isDataSourceEncodedInResponse(dataSource: DataSource) { return !DATA_SOURCES_WITHOUT_ENCODING.includes(dataSource); } diff --git a/apps/api/src/services/queues/data-gathering/data-gathering.service.ts b/apps/api/src/services/queues/data-gathering/data-gathering.service.ts index 3a98757591..1162143421 100644 --- a/apps/api/src/services/queues/data-gathering/data-gathering.service.ts +++ b/apps/api/src/services/queues/data-gathering/data-gathering.service.ts @@ -1,3 +1,4 @@ +import { isDataGatheringSupported } from '@ghostfolio/api/helper/data-source.helper'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; @@ -33,7 +34,6 @@ import { Inject, Injectable, Logger } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { Job, JobOptions, Queue } from 'bull'; import { format, min, subDays, subMilliseconds, subYears } from 'date-fns'; -import { isEmpty } from 'lodash-es'; import ms, { StringValue } from 'ms'; @Injectable() @@ -506,12 +506,7 @@ export class DataGatheringService { return symbolProfiles .filter(({ dataSource, scraperConfiguration }) => { - const manualDataSourceWithScraperConfiguration = - dataSource === 'MANUAL' && !isEmpty(scraperConfiguration); - - return ( - dataSource !== 'MANUAL' || manualDataSourceWithScraperConfiguration - ); + return isDataGatheringSupported({ dataSource, scraperConfiguration }); }) .map(({ dataSource, symbol }) => { return { dataSource, symbol }; @@ -534,14 +529,10 @@ export class DataGatheringService { return symbolProfiles .filter(({ dataSource, scraperConfiguration, symbol }) => { - const manualDataSourceWithScraperConfiguration = - dataSource === 'MANUAL' && !isEmpty(scraperConfiguration); - return ( !assetProfileIdentifiersWithRecentMarketData.some((item) => { return item.dataSource === dataSource && item.symbol === symbol; - }) && - (dataSource !== 'MANUAL' || manualDataSourceWithScraperConfiguration) + }) && isDataGatheringSupported({ dataSource, scraperConfiguration }) ); }) .map((symbolProfile) => { @@ -598,12 +589,7 @@ export class DataGatheringService { }) ) .filter(({ dataSource, scraperConfiguration }) => { - const manualDataSourceWithScraperConfiguration = - dataSource === 'MANUAL' && !isEmpty(scraperConfiguration); - - return ( - dataSource !== 'MANUAL' || manualDataSourceWithScraperConfiguration - ); + return isDataGatheringSupported({ dataSource, scraperConfiguration }); }) .map((symbolProfile) => { let date = symbolProfile.activities?.[0]?.date ?? startDate; From c0aea2ac4aa924c4be8c12925c3a556b319a0aa0 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sat, 3 Oct 2026 14:54:01 +0200 Subject: [PATCH 22/26] Task/exclude cash position in base currency from holdings in overview (#8022) * Exclude cash position in base currency * Update changelog --- CHANGELOG.md | 1 + .../home-overview/home-overview.component.ts | 19 ++++++++++++++++--- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 73f5227a41..6207c1c202 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Improved the label of the cash positions in the holdings charts and table +- Excluded the cash position in the base currency from the holdings table on the overview tab of the home page (experimental) - Extended the tools to get the activities, the portfolio and the watchlist in the server of the Model Context Protocol (MCP) to include the data source (experimental) - Upgraded `@openrouter/ai-sdk-provider` from version `3.0.0` to `3.1.0` - Upgraded `ai` from version `7.0.37` to `7.0.114` diff --git a/apps/client/src/app/components/home-overview/home-overview.component.ts b/apps/client/src/app/components/home-overview/home-overview.component.ts index 7715ef5719..b677f88a39 100644 --- a/apps/client/src/app/components/home-overview/home-overview.component.ts +++ b/apps/client/src/app/components/home-overview/home-overview.component.ts @@ -8,6 +8,7 @@ import { DEFAULT_LOCALE, NUMERICAL_PRECISION_THRESHOLD_6_FIGURES } from '@ghostfolio/common/config'; +import { isCashPosition } from '@ghostfolio/common/helper'; import { AssetProfileIdentifier, LineChartItem, @@ -153,6 +154,8 @@ export class GfHomeOverviewComponent implements OnInit { } private update() { + const userSettings = this.user()?.settings; + this.historicalDataItems.set(null); this.isLoadingPerformance.set(true); @@ -162,17 +165,27 @@ export class GfHomeOverviewComponent implements OnInit { this.dataService .fetchPortfolioHoldings({ filters: [{ id: 'ACTIVE', type: 'HOLDING_TYPE' }], - range: this.user()?.settings?.dateRange + range: userSettings?.dateRange }) .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(({ holdings }) => { - this.holdings.set(holdings); + const baseCurrency = userSettings?.baseCurrency ?? DEFAULT_CURRENCY; + + this.holdings.set( + holdings.filter(({ assetProfile }) => { + return !( + isCashPosition(assetProfile) && + assetProfile.currency === baseCurrency && + assetProfile.symbol === baseCurrency + ); + }) + ); }); } this.dataService .fetchPortfolioPerformance({ - range: this.user()?.settings?.dateRange ?? DEFAULT_DATE_RANGE + range: userSettings?.dateRange ?? DEFAULT_DATE_RANGE }) .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(({ chart, errors, performance }) => { From 9c1e014190fbb3dfaa368c4ba9bc841b8dd47933 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sat, 3 Oct 2026 15:03:33 +0200 Subject: [PATCH 23/26] Task/select required symbol profile fields in activities (#7998) * Select required symbol profile fields * Remove symbol profile relation from activities * Update changelog --- CHANGELOG.md | 1 + .../src/app/activities/activities.service.ts | 12 ++- apps/api/src/app/import/import.service.ts | 2 +- apps/api/src/helper/object.helper.spec.ts | 85 ++++++++++--------- ...orm-data-source-in-response.interceptor.ts | 2 - libs/common/src/lib/config.ts | 3 - 6 files changed, 57 insertions(+), 48 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6207c1c202..43cd5a98eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Improved the label of the cash positions in the holdings charts and table - Excluded the cash position in the base currency from the holdings table on the overview tab of the home page (experimental) - Extended the tools to get the activities, the portfolio and the watchlist in the server of the Model Context Protocol (MCP) to include the data source (experimental) +- Removed the deprecated `SymbolProfile` field from the endpoints `GET api/v1/activities`, `GET api/v1/activities/:id` and `POST api/v1/import` - Upgraded `@openrouter/ai-sdk-provider` from version `3.0.0` to `3.1.0` - Upgraded `ai` from version `7.0.37` to `7.0.114` - Upgraded `dotenv` from version `17.4.2` to `18.0.3` diff --git a/apps/api/src/app/activities/activities.service.ts b/apps/api/src/app/activities/activities.service.ts index 75adcbc5d3..9e1a1b8d4c 100644 --- a/apps/api/src/app/activities/activities.service.ts +++ b/apps/api/src/app/activities/activities.service.ts @@ -60,7 +60,7 @@ import { Type as ActivityType } from '@prisma/client'; import { Big } from 'big.js'; -import { groupBy, uniqBy } from 'lodash-es'; +import { groupBy, omit, uniqBy } from 'lodash-es'; import { randomUUID } from 'node:crypto'; @Injectable() @@ -655,7 +655,13 @@ export class ActivitiesService { } }, // eslint-disable-next-line @typescript-eslint/naming-convention - SymbolProfile: true, + SymbolProfile: { + select: { + currency: true, + dataSource: true, + symbol: true + } + }, tags: true }, orderBy: [...orderBy, { id: sortDirection }] @@ -736,7 +742,7 @@ export class ActivitiesService { ]); return { - ...order, + ...omit(order, ['SymbolProfile']), assetProfile, feeInAssetProfileCurrency, feeInBaseCurrency, diff --git a/apps/api/src/app/import/import.service.ts b/apps/api/src/app/import/import.service.ts index 5afa4c33dd..705066441f 100644 --- a/apps/api/src/app/import/import.service.ts +++ b/apps/api/src/app/import/import.service.ts @@ -1004,7 +1004,7 @@ export class ImportService { )) ?? 0; activities.push({ - ...order, + ...omit(order, ['SymbolProfile']), // @ts-ignore assetProfile, error, diff --git a/apps/api/src/helper/object.helper.spec.ts b/apps/api/src/helper/object.helper.spec.ts index 9776232a0b..5543441dbc 100644 --- a/apps/api/src/helper/object.helper.spec.ts +++ b/apps/api/src/helper/object.helper.spec.ts @@ -3082,20 +3082,6 @@ describe('redactAttributes', () => { feeInBaseCurrency: 18.2, id: '8c623328-6035-4b5f-b6d5-702cc1c9c56b', quantity: 50, - // The relation of the database is part of the response next to the - // asset profile - SymbolProfile: { - comment: 'Reviewed by the administrator', - name: 'Apple Inc', - scraperConfiguration: { - headers: { Authorization: 'Bearer TOKEN' }, - mode: 'lazy', - selector: '.price', - url: 'https://example.org/get_price' - }, - symbol: 'AAPL', - symbolMapping: { YAHOO: 'AAPL' } - }, type: 'BUY', unitPrice: 220.79, value: 11039.5, @@ -3125,6 +3111,49 @@ describe('redactAttributes', () => { feeInBaseCurrency: null, id: '8c623328-6035-4b5f-b6d5-702cc1c9c56b', quantity: null, + type: 'BUY', + // A price per unit stays visible, like the average price and the market + // price of a holding + unitPrice: 220.79, + value: null, + valueInBaseCurrency: null + }); + }); + + // The endpoint POST api/v1/activities returns a row of the database with the + // relation to the symbol profile + it('should redact an activity with the relation to the symbol profile', () => { + expect( + redactPaths({ + object: { + comment: 'Bought on a dip', + currency: 'USD', + fee: 19.9, + id: '8c623328-6035-4b5f-b6d5-702cc1c9c56b', + quantity: 50, + SymbolProfile: { + comment: 'Reviewed by the administrator', + name: 'Apple Inc', + scraperConfiguration: { + headers: { Authorization: 'Bearer TOKEN' }, + mode: 'lazy', + selector: '.price', + url: 'https://example.org/get_price' + }, + symbol: 'AAPL', + symbolMapping: { YAHOO: 'AAPL' } + }, + type: 'BUY', + unitPrice: 220.79 + }, + paths: DEFAULT_REDACTED_PATHS + }) + ).toStrictEqual({ + comment: null, + currency: 'USD', + fee: null, + id: '8c623328-6035-4b5f-b6d5-702cc1c9c56b', + quantity: null, SymbolProfile: { comment: null, name: 'Apple Inc', @@ -3133,15 +3162,12 @@ describe('redactAttributes', () => { symbolMapping: null }, type: 'BUY', - // A price per unit stays visible, like the average price and the market - // price of a holding - unitPrice: 220.79, - value: null, - valueInBaseCurrency: null + unitPrice: 220.79 }); }); - // The write endpoints return a row of the database, which has no relation + // The endpoints PUT and DELETE api/v1/activities/:id return a row of the + // database without the relations it('should redact an activity without the relations', () => { expect( redactPaths({ @@ -3190,18 +3216,6 @@ describe('redactAttributes', () => { currency: 'USD', fee: 19.9, quantity: 50, - SymbolProfile: { - comment: 'Reviewed by the administrator', - name: 'Apple Inc', - scraperConfiguration: { - headers: { Authorization: 'Bearer TOKEN' }, - mode: 'lazy', - selector: '.price', - url: 'https://example.org/get_price' - }, - symbol: 'AAPL', - symbolMapping: { YAHOO: 'AAPL' } - }, type: 'BUY', unitPrice: 220.79, value: 11039.5, @@ -3227,13 +3241,6 @@ describe('redactAttributes', () => { currency: 'USD', fee: null, quantity: null, - SymbolProfile: { - comment: null, - name: 'Apple Inc', - scraperConfiguration: null, - symbol: 'AAPL', - symbolMapping: null - }, type: 'BUY', unitPrice: 220.79, value: null, diff --git a/apps/api/src/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor.ts b/apps/api/src/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor.ts index 968159cbac..eedc808772 100644 --- a/apps/api/src/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor.ts +++ b/apps/api/src/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor.ts @@ -84,7 +84,6 @@ export class TransformDataSourceInResponseInterceptor< '["filters.dataSource"]', 'activities[*].assetProfile.dataSource', 'activities[*].dataSource', - 'activities[*].SymbolProfile.dataSource', 'assetProfile.dataSource', 'benchmarks[*].dataSource', 'errors[*].dataSource', @@ -95,7 +94,6 @@ export class TransformDataSourceInResponseInterceptor< 'items[*].dataSource', 'latestActivities[*].assetProfile.dataSource', 'settings["filters.dataSource"]', - 'SymbolProfile.dataSource', 'watchlist[*].dataSource' ] }); diff --git a/libs/common/src/lib/config.ts b/libs/common/src/lib/config.ts index 5a8aecff36..dbf873df2a 100644 --- a/libs/common/src/lib/config.ts +++ b/libs/common/src/lib/config.ts @@ -138,9 +138,6 @@ export const DEFAULT_REDACTED_PATHS = [ 'activities[*].feeInAssetProfileCurrency', 'activities[*].feeInBaseCurrency', 'activities[*].quantity', - 'activities[*].SymbolProfile.comment', - 'activities[*].SymbolProfile.scraperConfiguration', - 'activities[*].SymbolProfile.symbolMapping', 'activities[*].value', 'activities[*].valueInBaseCurrency', 'assetProfile.comment', From c65e0856bbccd5a2322cdf984edd2ec6810007be Mon Sep 17 00:00:00 2001 From: Kenrick Tandrian <60643640+KenTandrian@users.noreply.github.com> Date: Sat, 3 Oct 2026 21:45:37 +0700 Subject: [PATCH 24/26] Task/widen input types in fire calculator component (#8015) Widen input types --- .../fire-calculator.component.ts | 42 ++++++++++--------- 1 file changed, 23 insertions(+), 19 deletions(-) diff --git a/libs/ui/src/lib/fire-calculator/fire-calculator.component.ts b/libs/ui/src/lib/fire-calculator/fire-calculator.component.ts index bdd65d1668..64bcec2934 100644 --- a/libs/ui/src/lib/fire-calculator/fire-calculator.component.ts +++ b/libs/ui/src/lib/fire-calculator/fire-calculator.component.ts @@ -2,7 +2,7 @@ import { getTooltipOptions, transformTickToAbbreviation } from '@ghostfolio/common/chart-helper'; -import { primaryColorRgb } from '@ghostfolio/common/config'; +import { DEFAULT_CURRENCY, primaryColorRgb } from '@ghostfolio/common/config'; import { formatMonthAndYear, getLocale } from '@ghostfolio/common/helper'; import { FireCalculationCompleteEvent } from '@ghostfolio/common/interfaces'; import { ColorScheme } from '@ghostfolio/common/types'; @@ -53,6 +53,7 @@ import { addDays, addYears, getMonth, + parseISO, setMonth, setYear, startOfMonth, @@ -60,7 +61,7 @@ import { } from 'date-fns'; import { addIcons } from 'ionicons'; import { calendarClearOutline } from 'ionicons/icons'; -import { isNumber } from 'lodash-es'; +import { isNumber, isString } from 'lodash-es'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { debounceTime } from 'rxjs'; @@ -86,16 +87,16 @@ import { FireCalculatorService } from './fire-calculator.service'; templateUrl: './fire-calculator.component.html' }) export class GfFireCalculatorComponent implements OnChanges, OnDestroy { - @Input() annualInterestRate = 0; - @Input() colorScheme?: ColorScheme; - @Input() currency: string; - @Input() deviceType: string; - @Input() fireWealth = 0; - @Input() hasPermissionToUpdateUserSettings: boolean; - @Input() locale = getLocale(); - @Input() projectedTotalAmount = 0; - @Input() retirementDate: Date; - @Input() savingsRate = 0; + @Input() public annualInterestRate?: number = 0; + @Input() public colorScheme?: ColorScheme; + @Input() public currency?: string; + @Input() public deviceType: string; + @Input() public fireWealth = 0; + @Input() public hasPermissionToUpdateUserSettings: boolean; + @Input() public locale?: string = getLocale(); + @Input() public projectedTotalAmount?: number = 0; + @Input() public retirementDate?: Date | string; + @Input() public savingsRate?: number = 0; public calculatorForm = this.formBuilder.group({ annualInterestRate: new FormControl(null), @@ -243,11 +244,13 @@ export class GfFireCalculatorComponent implements OnChanges, OnDestroy { if (isNumber(this.fireWealth) && this.fireWealth >= 0) { this.calculatorForm.setValue( { - annualInterestRate: this.annualInterestRate, - paymentPerPeriod: this.savingsRate, + annualInterestRate: this.annualInterestRate ?? 0, + paymentPerPeriod: this.savingsRate ?? 0, principalInvestmentAmount: this.fireWealth, - projectedTotalAmount: this.projectedTotalAmount, - retirementDate: this.retirementDate ?? this.DEFAULT_RETIREMENT_DATE + projectedTotalAmount: this.projectedTotalAmount ?? 0, + retirementDate: isString(this.retirementDate) + ? parseISO(this.retirementDate) + : (this.retirementDate ?? this.DEFAULT_RETIREMENT_DATE) }, { emitEvent: false @@ -353,7 +356,7 @@ export class GfFireCalculatorComponent implements OnChanges, OnDestroy { ); return `Total: ${new Intl.NumberFormat(this.locale, { - currency: this.currency, + currency: this.currency ?? DEFAULT_CURRENCY, currencyDisplay: 'code', style: 'currency' }).format(totalAmount)}`; @@ -367,7 +370,7 @@ export class GfFireCalculatorComponent implements OnChanges, OnDestroy { if (context.parsed.y !== null) { label += new Intl.NumberFormat(this.locale, { - currency: this.currency, + currency: this.currency ?? DEFAULT_CURRENCY, currencyDisplay: 'code', style: 'currency' }).format(context.parsed.y); @@ -510,7 +513,8 @@ export class GfFireCalculatorComponent implements OnChanges, OnDestroy { } else { const today = new Date(); const retirementDate = - this.retirementDate ?? this.DEFAULT_RETIREMENT_DATE; + this.calculatorForm.get('retirementDate')?.value ?? + this.DEFAULT_RETIREMENT_DATE; return ( 12 * (retirementDate.getFullYear() - today.getFullYear()) + From 93780862063c1e1a7e4d10d2ca171099cbf885c8 Mon Sep 17 00:00:00 2001 From: Kenrick Tandrian <60643640+KenTandrian@users.noreply.github.com> Date: Sat, 3 Oct 2026 21:49:08 +0700 Subject: [PATCH 25/26] Task/widen input types in activities table component (#8016) Widen input types --- .../activities-table.component.html | 10 +++--- .../activities-table.component.ts | 36 +++++++++---------- 2 files changed, 23 insertions(+), 23 deletions(-) diff --git a/libs/ui/src/lib/activities-table/activities-table.component.html b/libs/ui/src/lib/activities-table/activities-table.component.html index ded8fe8c83..a093eef399 100644 --- a/libs/ui/src/lib/activities-table/activities-table.component.html +++ b/libs/ui/src/lib/activities-table/activities-table.component.html @@ -69,7 +69,7 @@ @if (canExportActivities()) { Export {{ totalItems > 1 ? totalItems : '' }} + >Export {{ (totalItems ?? 0) > 1 ? totalItems : '' }} {totalItems, plural, =1 {Activity} other {Activities}} } @else { @@ -107,7 +107,7 @@ @if (canDeleteActivities()) { Delete {{ totalItems > 1 ? totalItems : '' }} + >Delete {{ (totalItems ?? 0) > 1 ? totalItems : '' }} {totalItems, plural, =1 {Activity} other {Activities}} } @else { @@ -126,7 +126,7 @@ class="gf-table w-100" mat-table matSort - [dataSource]="dataSource()" + [dataSource]="dataSource() ?? []" [matSortActive]="sortColumn" [matSortDirection]="sortDirection" [matSortDisabled]="sortDisabled" @@ -574,9 +574,9 @@ } (); @Output() activityClicked = new EventEmitter(); From 933df54b98d18a11fc62a8dffb8ad6dbe2ade2fb Mon Sep 17 00:00:00 2001 From: Kenrick Tandrian <60643640+KenTandrian@users.noreply.github.com> Date: Sat, 3 Oct 2026 21:50:22 +0700 Subject: [PATCH 26/26] Task/widen input types in world map and top holdings components (#8017) Widen input types --- libs/ui/src/lib/top-holdings/top-holdings.component.ts | 10 +++++----- .../lib/world-map-chart/world-map-chart.component.ts | 10 ++++++---- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/libs/ui/src/lib/top-holdings/top-holdings.component.ts b/libs/ui/src/lib/top-holdings/top-holdings.component.ts index 1a675a5531..80caca9185 100644 --- a/libs/ui/src/lib/top-holdings/top-holdings.component.ts +++ b/libs/ui/src/lib/top-holdings/top-holdings.component.ts @@ -38,11 +38,11 @@ import { GfValueComponent } from '../value/value.component'; templateUrl: './top-holdings.component.html' }) export class GfTopHoldingsComponent implements OnChanges { - @Input() baseCurrency: string; - @Input() hasPermissionToShowValues = true; - @Input() locale = getLocale(); - @Input() pageSize = Number.MAX_SAFE_INTEGER; - @Input() topHoldings: HoldingWithParents[]; + @Input() public baseCurrency?: string; + @Input() public hasPermissionToShowValues = true; + @Input() public locale?: string = getLocale(); + @Input() public pageSize = Number.MAX_SAFE_INTEGER; + @Input() public topHoldings?: HoldingWithParents[]; @Output() holdingClicked = new EventEmitter(); diff --git a/libs/ui/src/lib/world-map-chart/world-map-chart.component.ts b/libs/ui/src/lib/world-map-chart/world-map-chart.component.ts index 899441b7be..29c66267a1 100644 --- a/libs/ui/src/lib/world-map-chart/world-map-chart.component.ts +++ b/libs/ui/src/lib/world-map-chart/world-map-chart.component.ts @@ -23,10 +23,12 @@ import svgMap from 'svgmap'; templateUrl: './world-map-chart.component.html' }) export class GfWorldMapChartComponent implements OnChanges, OnDestroy { - @Input() countries: { [code: string]: { name?: string; value: number } }; - @Input() format: string; - @Input() isInPercentage = false; - @Input() locale = getLocale(); + @Input() public countries: { + [code: string]: { name?: string; value: number }; + }; + @Input() public format: string; + @Input() public isInPercentage?: boolean = false; + @Input() public locale?: string = getLocale(); public isLoading = true; public svgMapElement: any;