diff --git a/CHANGELOG.md b/CHANGELOG.md index b873385ab..c3a701459 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Added support for data gathering by date range in the asset profile details dialog of the admin control panel - Extended the holdings endpoint to include the performance with currency effect for cash + +### 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 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/portfolio/portfolio.controller.ts b/apps/api/src/app/portfolio/portfolio.controller.ts index 7d8ceecda..03796dad6 100644 --- a/apps/api/src/app/portfolio/portfolio.controller.ts +++ b/apps/api/src/app/portfolio/portfolio.controller.ts @@ -22,7 +22,7 @@ import { PortfolioDividendsResponse, PortfolioHoldingResponse, PortfolioHoldingsResponse, - PortfolioInvestments, + PortfolioInvestmentsResponse, PortfolioPerformanceResponse, PortfolioReportResponse } from '@ghostfolio/common/interfaces'; @@ -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 cf9aee8ce..e2836e643 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -47,7 +47,7 @@ import { InvestmentItem, PortfolioDetails, PortfolioHoldingResponse, - PortfolioInvestments, + PortfolioInvestmentsResponse, PortfolioPerformanceResponse, PortfolioPosition, PortfolioReportResponse, @@ -398,7 +398,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); @@ -449,7 +449,7 @@ export class PortfolioService { }); } - let streaks: PortfolioInvestments['streaks']; + let streaks: PortfolioInvestmentsResponse['streaks']; if (savingsRate) { streaks = this.getStreaks({ 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) { + + } +