diff --git a/CHANGELOG.md b/CHANGELOG.md index b9f63bd27..cec75923f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,41 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Disabled zoom in PWA +- Added support for data gathering by date range in the asset profile details dialog of the admin control panel + +### Changed + +- Formatted the holdings table in the _Copy AI prompt to clipboard for analysis_ action on the analysis page (experimental) +- Formatted the holdings table in the _Copy portfolio data to clipboard for AI prompt_ action of the analysis page (experimental) +- Improved the language localization for German (`de`) + +## 2.209.0 - 2025-10-18 + +### Added + +- Extended the glossary of the resources page by _Stealth Wealth_ +- Extended the content of the pricing page +- Added a _Storybook_ story for the holdings table component + +### Changed + +- Disabled the zoom functionality in the _Progressive Web App_ (PWA) +- Improved the currency validation in the get asset profiles functionality of the data provider service +- Improved the currency validation in the search functionality of the data provider service +- Optimized the get quotes functionality by utilizing the asset profile resolutions in the _Financial Modeling Prep_ service +- Extracted the footer to a component +- Refactored the blog page component to standalone +- Improved the portfolio calculator unit tests to load the user currency from the exported file +- Improved the language localization for German (`de`) + +### Fixed + +- Fixed an issue in the `csv` file import where custom asset profiles failed due to validation errors +- Fixed an issue with the total buy and sell calculation in the summary related to activities in a custom currency +- Respected the include indices flag in the search functionality of the _Financial Modeling Prep_ service +- Fixed an issue where the scroll position was not restored when changing pages +- Fixed the word wrap in the menus of the activities table component +- Fixed the dark mode in the _As seen in_ section on the landing page ## 2.208.0 - 2025-10-11 diff --git a/apps/api/src/app/admin/admin.controller.ts b/apps/api/src/app/admin/admin.controller.ts index 66f8483b4..d7c4c5d3d 100644 --- a/apps/api/src/app/admin/admin.controller.ts +++ b/apps/api/src/app/admin/admin.controller.ts @@ -169,7 +169,7 @@ export class AdminController { let date: Date; if (dateRange) { - const { startDate } = getIntervalFromDateRange(dateRange, new Date()); + const { startDate } = getIntervalFromDateRange(dateRange); date = startDate; } diff --git a/apps/api/src/app/endpoints/ai/ai.service.ts b/apps/api/src/app/endpoints/ai/ai.service.ts index b479d74ea..d1e1b413f 100644 --- a/apps/api/src/app/endpoints/ai/ai.service.ts +++ b/apps/api/src/app/endpoints/ai/ai.service.ts @@ -10,6 +10,7 @@ import type { AiPromptMode } from '@ghostfolio/common/types'; import { Injectable } from '@nestjs/common'; import { createOpenRouter } from '@openrouter/ai-sdk-provider'; import { generateText } from 'ai'; +import tablemark, { ColumnDescriptor } from 'tablemark'; @Injectable() export class AiService { @@ -58,34 +59,50 @@ export class AiService { userId }); - const holdingsTable = [ - '| Name | Symbol | Currency | Asset Class | Asset Sub Class | Allocation in Percentage |', - '| --- | --- | --- | --- | --- | --- |', - ...Object.values(holdings) - .sort((a, b) => { - return b.allocationInPercentage - a.allocationInPercentage; - }) - .map( - ({ - allocationInPercentage, - assetClass, - assetSubClass, - currency, - name, - symbol - }) => { - return `| ${name} | ${symbol} | ${currency} | ${assetClass} | ${assetSubClass} | ${(allocationInPercentage * 100).toFixed(3)}% |`; - } - ) + const holdingsTableColumns: ColumnDescriptor[] = [ + { name: 'Name' }, + { name: 'Symbol' }, + { name: 'Currency' }, + { name: 'Asset Class' }, + { name: 'Asset Sub Class' }, + { align: 'right', name: 'Allocation in Percentage' } ]; + const holdingsTableRows = Object.values(holdings) + .sort((a, b) => { + return b.allocationInPercentage - a.allocationInPercentage; + }) + .map( + ({ + allocationInPercentage, + assetClass, + assetSubClass, + currency, + name, + symbol + }) => { + return { + Name: name, + Symbol: symbol, + Currency: currency, + 'Asset Class': assetClass ?? '', + 'Asset Sub Class': assetSubClass ?? '', + 'Allocation in Percentage': `${(allocationInPercentage * 100).toFixed(3)}%` + }; + } + ); + + const holdingsTableString = tablemark(holdingsTableRows, { + columns: holdingsTableColumns + }); + if (mode === 'portfolio') { - return holdingsTable.join('\n'); + return holdingsTableString; } return [ `You are a neutral financial assistant. Please analyze the following investment portfolio (base currency being ${userCurrency}) in simple words.`, - ...holdingsTable, + holdingsTableString, 'Structure your answer with these sections:', 'Overview: Briefly summarize the portfolio’s composition and allocation rationale.', 'Risk Assessment: Identify potential risks, including market volatility, concentration, and sectoral imbalances.', diff --git a/apps/api/src/app/endpoints/benchmarks/benchmarks.controller.ts b/apps/api/src/app/endpoints/benchmarks/benchmarks.controller.ts index 69383a30d..629d90928 100644 --- a/apps/api/src/app/endpoints/benchmarks/benchmarks.controller.ts +++ b/apps/api/src/app/endpoints/benchmarks/benchmarks.controller.ts @@ -8,7 +8,7 @@ import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper' import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config'; import type { AssetProfileIdentifier, - BenchmarkMarketDataDetails, + BenchmarkMarketDataDetailsResponse, BenchmarkResponse } from '@ghostfolio/common/interfaces'; import { permissions } from '@ghostfolio/common/permissions'; @@ -125,7 +125,7 @@ export class BenchmarksController { @Query('symbol') filterBySymbol?: string, @Query('tags') filterByTags?: string, @Query('withExcludedAccounts') withExcludedAccountsParam = 'false' - ): Promise { + ): Promise { const { endDate, startDate } = getIntervalFromDateRange( dateRange, new Date(startDateString) diff --git a/apps/api/src/app/endpoints/benchmarks/benchmarks.service.ts b/apps/api/src/app/endpoints/benchmarks/benchmarks.service.ts index aa53564b7..03ff32c21 100644 --- a/apps/api/src/app/endpoints/benchmarks/benchmarks.service.ts +++ b/apps/api/src/app/endpoints/benchmarks/benchmarks.service.ts @@ -6,7 +6,7 @@ import { MarketDataService } from '@ghostfolio/api/services/market-data/market-d import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper'; import { AssetProfileIdentifier, - BenchmarkMarketDataDetails, + BenchmarkMarketDataDetailsResponse, Filter } from '@ghostfolio/common/interfaces'; import { DateRange, UserWithSettings } from '@ghostfolio/common/types'; @@ -43,7 +43,7 @@ export class BenchmarksService { startDate: Date; user: UserWithSettings; withExcludedAccounts?: boolean; - } & AssetProfileIdentifier): Promise { + } & AssetProfileIdentifier): Promise { const marketData: { date: string; value: number }[] = []; const userCurrency = user.settings.settings.baseCurrency; const userId = user.id; diff --git a/apps/api/src/app/import/import.service.ts b/apps/api/src/app/import/import.service.ts index 69ec781c3..2725747aa 100644 --- a/apps/api/src/app/import/import.service.ts +++ b/apps/api/src/app/import/import.service.ts @@ -743,14 +743,27 @@ export class ImportService { } if (!assetProfiles[getAssetProfileIdentifier({ dataSource, symbol })]) { - const assetProfile = { - currency, - ...( + if (['FEE', 'INTEREST', 'LIABILITY'].includes(type)) { + // Skip asset profile validation for FEE, INTEREST, and LIABILITY + // as these activity types don't require asset profiles + assetProfiles[getAssetProfileIdentifier({ dataSource, symbol })] = { + currency, + dataSource, + symbol + }; + + continue; + } + + let assetProfile: Partial = { currency }; + + try { + assetProfile = ( await this.dataProviderService.getAssetProfiles([ { dataSource, symbol } ]) - )?.[symbol] - }; + )?.[symbol]; + } catch {} if (!assetProfile?.name) { const assetProfileInImport = assetProfilesWithMarketDataDto?.find( @@ -787,11 +800,7 @@ export class ImportService { } } - if ( - (dataSource !== 'MANUAL' && type === 'BUY') || - type === 'DIVIDEND' || - type === 'SELL' - ) { + if (!['FEE', 'INTEREST', 'LIABILITY'].includes(type)) { if (!assetProfile?.name) { throw new Error( `activities.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")` 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 8850a6874..ccdbafac8 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,3 +1,5 @@ +import { Export } from '@ghostfolio/common/interfaces'; + import { readFileSync } from 'node:fs'; export const activityDummyData = { @@ -37,6 +39,6 @@ export const userDummyData = { id: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx' }; -export function loadActivityExportFile(filePath: string) { - return JSON.parse(readFileSync(filePath, 'utf8')).activities; +export function loadExportFile(filePath: string): Export { + return JSON.parse(readFileSync(filePath, 'utf8')); } diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btceur.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btceur.spec.ts index 1f6f9dc2a..1ac0dcd16 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btceur.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btceur.spec.ts @@ -1,8 +1,7 @@ -import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto'; import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; import { activityDummyData, - loadActivityExportFile, + loadExportFile, symbolProfileDummyData, userDummyData } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; @@ -16,9 +15,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 { Export } from '@ghostfolio/common/interfaces'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; -import { Tag } from '@prisma/client'; import { Big } from 'big.js'; import { join } from 'node:path'; @@ -53,7 +52,7 @@ jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { }); describe('PortfolioCalculator', () => { - let activityDtos: CreateOrderDto[]; + let exportResponse: Export; let configurationService: ConfigurationService; let currentRateService: CurrentRateService; @@ -63,7 +62,7 @@ describe('PortfolioCalculator', () => { let redisCacheService: RedisCacheService; beforeAll(() => { - activityDtos = loadActivityExportFile( + exportResponse = loadExportFile( join(__dirname, '../../../../../../../test/import/ok/btceur.json') ); }); @@ -97,28 +96,27 @@ describe('PortfolioCalculator', () => { it.only('with BTCUSD buy (in EUR)', async () => { jest.useFakeTimers().setSystemTime(parseDate('2022-01-14').getTime()); - const activities: Activity[] = activityDtos.map((activity) => ({ - ...activityDummyData, - ...activity, - date: parseDate(activity.date), - feeInAssetProfileCurrency: 4.46, - SymbolProfile: { - ...symbolProfileDummyData, - currency: 'USD', - dataSource: activity.dataSource, - name: 'Bitcoin', - symbol: activity.symbol - }, - tags: activity.tags?.map((id) => { - return { id } as Tag; - }), - unitPriceInAssetProfileCurrency: 44558.42 - })); + const activities: Activity[] = exportResponse.activities.map( + (activity) => ({ + ...activityDummyData, + ...activity, + date: parseDate(activity.date), + feeInAssetProfileCurrency: 4.46, + SymbolProfile: { + ...symbolProfileDummyData, + currency: 'USD', + dataSource: activity.dataSource, + name: 'Bitcoin', + symbol: activity.symbol + }, + unitPriceInAssetProfileCurrency: 44558.42 + }) + ); const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ activities, calculationType: PerformanceCalculationType.ROAI, - currency: 'USD', + currency: exportResponse.user.settings.currency, 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 a2d7e60d3..29413c6ad 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,8 +1,7 @@ -import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto'; import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; import { activityDummyData, - loadActivityExportFile, + loadExportFile, symbolProfileDummyData, userDummyData } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; @@ -16,9 +15,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 { Export } from '@ghostfolio/common/interfaces'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; -import { Tag } from '@prisma/client'; import { Big } from 'big.js'; import { join } from 'node:path'; @@ -53,7 +52,7 @@ jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { }); describe('PortfolioCalculator', () => { - let activityDtos: CreateOrderDto[]; + let exportResponse: Export; let configurationService: ConfigurationService; let currentRateService: CurrentRateService; @@ -63,7 +62,7 @@ describe('PortfolioCalculator', () => { let redisCacheService: RedisCacheService; beforeAll(() => { - activityDtos = loadActivityExportFile( + exportResponse = loadExportFile( join(__dirname, '../../../../../../../test/import/ok/btcusd-short.json') ); }); @@ -97,28 +96,27 @@ describe('PortfolioCalculator', () => { it.only('with BTCUSD short sell (in USD)', async () => { jest.useFakeTimers().setSystemTime(parseDate('2022-01-14').getTime()); - const activities: Activity[] = activityDtos.map((activity) => ({ - ...activityDummyData, - ...activity, - date: parseDate(activity.date), - feeInAssetProfileCurrency: activity.fee, - SymbolProfile: { - ...symbolProfileDummyData, - currency: 'USD', - dataSource: activity.dataSource, - name: 'Bitcoin', - symbol: activity.symbol - }, - tags: activity.tags?.map((id) => { - return { id } as Tag; - }), - unitPriceInAssetProfileCurrency: activity.unitPrice - })); + const activities: Activity[] = exportResponse.activities.map( + (activity) => ({ + ...activityDummyData, + ...activity, + date: parseDate(activity.date), + feeInAssetProfileCurrency: activity.fee, + SymbolProfile: { + ...symbolProfileDummyData, + currency: 'USD', + dataSource: activity.dataSource, + name: 'Bitcoin', + symbol: activity.symbol + }, + unitPriceInAssetProfileCurrency: activity.unitPrice + }) + ); const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ activities, calculationType: PerformanceCalculationType.ROAI, - currency: 'USD', + currency: exportResponse.user.settings.currency, 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 bdccb23e0..26b3325c2 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,8 +1,7 @@ -import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto'; import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; import { activityDummyData, - loadActivityExportFile, + loadExportFile, symbolProfileDummyData, userDummyData } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; @@ -16,9 +15,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 { Export } from '@ghostfolio/common/interfaces'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; -import { Tag } from '@prisma/client'; import { Big } from 'big.js'; import { join } from 'node:path'; @@ -53,7 +52,7 @@ jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { }); describe('PortfolioCalculator', () => { - let activityDtos: CreateOrderDto[]; + let exportResponse: Export; let configurationService: ConfigurationService; let currentRateService: CurrentRateService; @@ -63,7 +62,7 @@ describe('PortfolioCalculator', () => { let redisCacheService: RedisCacheService; beforeAll(() => { - activityDtos = loadActivityExportFile( + exportResponse = loadExportFile( join(__dirname, '../../../../../../../test/import/ok/btcusd.json') ); }); @@ -97,28 +96,27 @@ describe('PortfolioCalculator', () => { it.only('with BTCUSD buy (in USD)', async () => { jest.useFakeTimers().setSystemTime(parseDate('2022-01-14').getTime()); - const activities: Activity[] = activityDtos.map((activity) => ({ - ...activityDummyData, - ...activity, - date: parseDate(activity.date), - feeInAssetProfileCurrency: 4.46, - SymbolProfile: { - ...symbolProfileDummyData, - currency: 'USD', - dataSource: activity.dataSource, - name: 'Bitcoin', - symbol: activity.symbol - }, - tags: activity.tags?.map((id) => { - return { id } as Tag; - }), - unitPriceInAssetProfileCurrency: 44558.42 - })); + const activities: Activity[] = exportResponse.activities.map( + (activity) => ({ + ...activityDummyData, + ...activity, + date: parseDate(activity.date), + feeInAssetProfileCurrency: 4.46, + SymbolProfile: { + ...symbolProfileDummyData, + currency: 'USD', + dataSource: activity.dataSource, + name: 'Bitcoin', + symbol: activity.symbol + }, + unitPriceInAssetProfileCurrency: 44558.42 + }) + ); const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ activities, calculationType: PerformanceCalculationType.ROAI, - currency: 'USD', + currency: exportResponse.user.settings.currency, 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 4872a1004..0f1cdfff7 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,8 +1,7 @@ -import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto'; import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; import { activityDummyData, - loadActivityExportFile, + loadExportFile, symbolProfileDummyData, userDummyData } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; @@ -16,9 +15,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 { Export } from '@ghostfolio/common/interfaces'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; -import { Tag } from '@prisma/client'; import { Big } from 'big.js'; import { join } from 'node:path'; @@ -53,7 +52,7 @@ jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { }); describe('PortfolioCalculator', () => { - let activityDtos: CreateOrderDto[]; + let exportResponse: Export; let configurationService: ConfigurationService; let currentRateService: CurrentRateService; @@ -63,7 +62,7 @@ describe('PortfolioCalculator', () => { let redisCacheService: RedisCacheService; beforeAll(() => { - activityDtos = loadActivityExportFile( + exportResponse = loadExportFile( join( __dirname, '../../../../../../../test/import/ok/novn-buy-and-sell-partially.json' @@ -100,28 +99,27 @@ describe('PortfolioCalculator', () => { it.only('with NOVN.SW buy and sell partially', async () => { jest.useFakeTimers().setSystemTime(parseDate('2022-04-11').getTime()); - const activities: Activity[] = activityDtos.map((activity) => ({ - ...activityDummyData, - ...activity, - date: parseDate(activity.date), - feeInAssetProfileCurrency: activity.fee, - SymbolProfile: { - ...symbolProfileDummyData, - currency: activity.currency, - dataSource: activity.dataSource, - name: 'Novartis AG', - symbol: activity.symbol - }, - tags: activity.tags?.map((id) => { - return { id } as Tag; - }), - unitPriceInAssetProfileCurrency: activity.unitPrice - })); + const activities: Activity[] = exportResponse.activities.map( + (activity) => ({ + ...activityDummyData, + ...activity, + date: parseDate(activity.date), + feeInAssetProfileCurrency: activity.fee, + SymbolProfile: { + ...symbolProfileDummyData, + currency: activity.currency, + dataSource: activity.dataSource, + name: 'Novartis AG', + symbol: activity.symbol + }, + unitPriceInAssetProfileCurrency: activity.unitPrice + }) + ); const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ activities, calculationType: PerformanceCalculationType.ROAI, - currency: 'CHF', + currency: exportResponse.user.settings.currency, 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 e6c71230b..e426a68fa 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,8 +1,7 @@ -import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto'; import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; import { activityDummyData, - loadActivityExportFile, + loadExportFile, symbolProfileDummyData, userDummyData } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; @@ -16,9 +15,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 { Export } from '@ghostfolio/common/interfaces'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; -import { Tag } from '@prisma/client'; import { Big } from 'big.js'; import { join } from 'node:path'; @@ -53,7 +52,7 @@ jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { }); describe('PortfolioCalculator', () => { - let activityDtos: CreateOrderDto[]; + let exportResponse: Export; let configurationService: ConfigurationService; let currentRateService: CurrentRateService; @@ -63,7 +62,7 @@ describe('PortfolioCalculator', () => { let redisCacheService: RedisCacheService; beforeAll(() => { - activityDtos = loadActivityExportFile( + exportResponse = loadExportFile( join( __dirname, '../../../../../../../test/import/ok/novn-buy-and-sell.json' @@ -100,28 +99,27 @@ describe('PortfolioCalculator', () => { it.only('with NOVN.SW buy and sell', async () => { jest.useFakeTimers().setSystemTime(parseDate('2022-04-11').getTime()); - const activities: Activity[] = activityDtos.map((activity) => ({ - ...activityDummyData, - ...activity, - date: parseDate(activity.date), - feeInAssetProfileCurrency: activity.fee, - SymbolProfile: { - ...symbolProfileDummyData, - currency: activity.currency, - dataSource: activity.dataSource, - name: 'Novartis AG', - symbol: activity.symbol - }, - tags: activity.tags?.map((id) => { - return { id } as Tag; - }), - unitPriceInAssetProfileCurrency: activity.unitPrice - })); + const activities: Activity[] = exportResponse.activities.map( + (activity) => ({ + ...activityDummyData, + ...activity, + date: parseDate(activity.date), + feeInAssetProfileCurrency: activity.fee, + SymbolProfile: { + ...symbolProfileDummyData, + currency: activity.currency, + dataSource: activity.dataSource, + name: 'Novartis AG', + symbol: activity.symbol + }, + unitPriceInAssetProfileCurrency: activity.unitPrice + }) + ); const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ activities, calculationType: PerformanceCalculationType.ROAI, - currency: 'CHF', + currency: exportResponse.user.settings.currency, userId: userDummyData.id }); diff --git a/apps/api/src/app/portfolio/portfolio.controller.ts b/apps/api/src/app/portfolio/portfolio.controller.ts index 19b0636c7..03796dad6 100644 --- a/apps/api/src/app/portfolio/portfolio.controller.ts +++ b/apps/api/src/app/portfolio/portfolio.controller.ts @@ -19,10 +19,10 @@ import { } from '@ghostfolio/common/config'; import { PortfolioDetails, - PortfolioDividends, + PortfolioDividendsResponse, PortfolioHoldingResponse, PortfolioHoldingsResponse, - PortfolioInvestments, + PortfolioInvestmentsResponse, PortfolioPerformanceResponse, PortfolioReportResponse } from '@ghostfolio/common/interfaces'; @@ -197,7 +197,7 @@ export class PortfolioController { 'filteredValueInBaseCurrency', 'grossPerformance', 'grossPerformanceWithCurrencyEffect', - 'interest', + 'interestInBaseCurrency', 'items', 'liabilities', 'netPerformance', @@ -305,7 +305,7 @@ export class PortfolioController { @Query('range') dateRange: DateRange = 'max', @Query('symbol') filterBySymbol?: string, @Query('tags') filterByTags?: string - ): Promise { + ): Promise { const filters = this.apiService.buildFiltersFromQueryParams({ filterByAccounts, filterByAssetClasses, @@ -439,7 +439,7 @@ export class PortfolioController { @Query('range') dateRange: DateRange = 'max', @Query('symbol') filterBySymbol?: string, @Query('tags') filterByTags?: string - ): Promise { + ): Promise { const filters = this.apiService.buildFiltersFromQueryParams({ filterByAccounts, filterByAssetClasses, diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index a5bc10fbd..b74b779f6 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -46,7 +46,7 @@ import { InvestmentItem, PortfolioDetails, PortfolioHoldingResponse, - PortfolioInvestments, + PortfolioInvestmentsResponse, PortfolioPerformanceResponse, PortfolioPosition, PortfolioReportResponse, @@ -397,7 +397,7 @@ export class PortfolioService { impersonationId: string; savingsRate: number; userId: string; - }): Promise { + }): Promise { userId = await this.getUserId(impersonationId, userId); const user = await this.userService.user({ id: userId }); const userCurrency = this.getUserCurrency(user); @@ -448,7 +448,7 @@ export class PortfolioService { }); } - let streaks: PortfolioInvestments['streaks']; + let streaks: PortfolioInvestmentsResponse['streaks']; if (savingsRate) { streaks = this.getStreaks({ @@ -2105,7 +2105,7 @@ export class PortfolioService { ) .plus(fees) .toNumber(), - interest: interest.toNumber(), + interestInBaseCurrency: interest.toNumber(), liabilitiesInBaseCurrency: liabilities.toNumber(), totalInvestment: totalInvestment.toNumber(), totalValueInBaseCurrency: netWorth @@ -2126,11 +2126,11 @@ export class PortfolioService { .filter(({ isDraft, type }) => { return isDraft === false && type === activityType; }) - .map(({ quantity, SymbolProfile, unitPrice }) => { + .map(({ currency, quantity, SymbolProfile, unitPrice }) => { return new Big( this.exchangeRateDataService.toCurrency( new Big(quantity).mul(unitPrice).toNumber(), - SymbolProfile.currency, + currency ?? SymbolProfile.currency, userCurrency ) ); diff --git a/apps/api/src/helper/object.helper.spec.ts b/apps/api/src/helper/object.helper.spec.ts index d7caf9bc9..433490325 100644 --- a/apps/api/src/helper/object.helper.spec.ts +++ b/apps/api/src/helper/object.helper.spec.ts @@ -1536,7 +1536,7 @@ describe('redactAttributes', () => { fireWealth: null, grossPerformance: null, grossPerformanceWithCurrencyEffect: null, - interest: null, + interestInBaseCurrency: null, items: null, liabilities: null, totalInvestment: null, @@ -3039,7 +3039,7 @@ describe('redactAttributes', () => { fireWealth: null, grossPerformance: null, grossPerformanceWithCurrencyEffect: null, - interest: null, + interestInBaseCurrency: null, items: null, liabilities: null, totalInvestment: null, diff --git a/apps/client/src/app/app-routing.module.ts b/apps/client/src/app/app-routing.module.ts index 0ceee3725..fb045a174 100644 --- a/apps/client/src/app/app-routing.module.ts +++ b/apps/client/src/app/app-routing.module.ts @@ -48,7 +48,7 @@ const routes: Routes = [ { path: publicRoutes.blog.path, loadChildren: () => - import('./pages/blog/blog-page.module').then((m) => m.BlogPageModule) + import('./pages/blog/blog-page.routes').then((m) => m.routes) }, { canActivate: [AuthGuard], diff --git a/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts b/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts index 3fd9e506f..a56f6dec5 100644 --- a/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts +++ b/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts @@ -18,6 +18,7 @@ import { ScraperConfiguration, User } from '@ghostfolio/common/interfaces'; +import { DateRange } from '@ghostfolio/common/types'; import { GfCurrencySelectorComponent } from '@ghostfolio/ui/currency-selector'; import { GfEntityLogoComponent } from '@ghostfolio/ui/entity-logo'; import { GfHistoricalMarketDataEditorComponent } from '@ghostfolio/ui/historical-market-data-editor'; @@ -190,6 +191,32 @@ export class GfAssetProfileDialogComponent implements OnDestroy, OnInit { }; public currencies: string[] = []; + public dateRangeOptions = [ + { + label: $localize`Current week` + ' (' + $localize`WTD` + ')', + value: 'wtd' + }, + { + label: $localize`Current month` + ' (' + $localize`MTD` + ')', + value: 'mtd' + }, + { + label: $localize`Current year` + ' (' + $localize`YTD` + ')', + value: 'ytd' + }, + { + label: '1 ' + $localize`year` + ' (' + $localize`1Y` + ')', + value: '1y' + }, + { + label: '5 ' + $localize`years` + ' (' + $localize`5Y` + ')', + value: '5y' + }, + { + label: $localize`Max`, + value: 'max' + } + ]; public historicalDataItems: LineChartItem[]; public isBenchmark = false; public isDataGatheringEnabled: boolean; @@ -405,9 +432,15 @@ export class GfAssetProfileDialogComponent implements OnDestroy, OnInit { .subscribe(); } - public onGatherSymbol({ dataSource, symbol }: AssetProfileIdentifier) { + public onGatherSymbol({ + dataSource, + range, + symbol + }: { + range?: DateRange; + } & AssetProfileIdentifier) { this.adminService - .gatherSymbol({ dataSource, symbol }) + .gatherSymbol({ dataSource, range, symbol }) .pipe(takeUntil(this.unsubscribeSubject)) .subscribe(); } diff --git a/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html b/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html index 301287cf5..b2c063684 100644 --- a/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html +++ b/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html @@ -26,12 +26,30 @@ [disabled]=" assetProfileForm.dirty || !assetProfileForm.controls.isActive.value " + [matMenuTriggerFor]="gatherHistoricalMarketDataMenu" (click)=" onGatherSymbol({ dataSource: data.dataSource, symbol: data.symbol }) " > Gather Historical Market Data + + @for (dateRange of dateRangeOptions; track dateRange.value) { + + } + } - + @if (hasPermissionToCreateActivity) { } - +