From b6b44347b029bad0a896112ee404f0bbc1858bfe Mon Sep 17 00:00:00 2001 From: Kenrick Tandrian <60643640+KenTandrian@users.noreply.github.com> Date: Sun, 31 May 2026 11:51:22 +0700 Subject: [PATCH 1/5] Task/improve type safety in file drop directive (#6961) * fix(client): resolve errors * feat(client): migrate to output signal * feat(client): migrate to host property --- .../file-drop/file-drop.directive.ts | 24 ++++++++++++------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/apps/client/src/app/directives/file-drop/file-drop.directive.ts b/apps/client/src/app/directives/file-drop/file-drop.directive.ts index a7e628bc9..b46357005 100644 --- a/apps/client/src/app/directives/file-drop/file-drop.directive.ts +++ b/apps/client/src/app/directives/file-drop/file-drop.directive.ts @@ -1,28 +1,34 @@ -import { Directive, EventEmitter, HostListener, Output } from '@angular/core'; +import { Directive, output } from '@angular/core'; @Directive({ + host: { + '(dragenter)': 'onDragEnter($event)', + '(dragover)': 'onDragOver($event)', + '(drop)': 'onDrop($event)' + }, selector: '[gfFileDrop]' }) export class GfFileDropDirective { - @Output() filesDropped = new EventEmitter(); + public readonly filesDropped = output(); - @HostListener('dragenter', ['$event']) onDragEnter(event: DragEvent) { + public onDragEnter(event: DragEvent) { event.preventDefault(); event.stopPropagation(); } - @HostListener('dragover', ['$event']) onDragOver(event: DragEvent) { + public onDragOver(event: DragEvent) { event.preventDefault(); event.stopPropagation(); } - @HostListener('drop', ['$event']) onDrop(event: DragEvent) { + public onDrop(event: DragEvent) { event.preventDefault(); event.stopPropagation(); - // Prevent the browser's default behavior for handling the file drop - event.dataTransfer.dropEffect = 'copy'; - - this.filesDropped.emit(event.dataTransfer.files); + if (event.dataTransfer) { + // Prevent the browser's default behavior for handling the file drop + event.dataTransfer.dropEffect = 'copy'; + this.filesDropped.emit(event.dataTransfer.files); + } } } From f9ebb9150abfb49ece0f7d51758d51971c2d3d8e Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sun, 31 May 2026 06:54:51 +0200 Subject: [PATCH 2/5] Task/add portfolio service test (#6949) * Add tests to cover cash positions --- .../app/portfolio/portfolio.service.spec.ts | 272 ++++++++++++++++++ 1 file changed, 272 insertions(+) create mode 100644 apps/api/src/app/portfolio/portfolio.service.spec.ts diff --git a/apps/api/src/app/portfolio/portfolio.service.spec.ts b/apps/api/src/app/portfolio/portfolio.service.spec.ts new file mode 100644 index 000000000..da846c45d --- /dev/null +++ b/apps/api/src/app/portfolio/portfolio.service.spec.ts @@ -0,0 +1,272 @@ +import { AccountService } from '@ghostfolio/api/app/account/account.service'; +import { CashDetails } from '@ghostfolio/api/app/account/interfaces/cash-details.interface'; +import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service'; +import { userDummyData } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; +import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; +import { UserService } from '@ghostfolio/api/app/user/user.service'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service'; +import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; +import { parseDate } from '@ghostfolio/common/helper'; + +import { Account, DataSource } from '@prisma/client'; +import { Big } from 'big.js'; +import { randomUUID } from 'node:crypto'; + +import { PortfolioService } from './portfolio.service'; + +describe('PortfolioService', () => { + let accountService: AccountService; + let activitiesService: ActivitiesService; + let configurationService: ConfigurationService; + let dataProviderService: DataProviderService; + let exchangeRateDataService: ExchangeRateDataService; + let impersonationService: ImpersonationService; + let portfolioCalculatorFactory: PortfolioCalculatorFactory; + let portfolioService: PortfolioService; + let symbolProfileService: SymbolProfileService; + let userService: UserService; + + beforeEach(() => { + configurationService = new ConfigurationService(); + + dataProviderService = new DataProviderService( + configurationService, + null, + null, + null, + null, + null + ); + + exchangeRateDataService = new ExchangeRateDataService( + null, + null, + null, + null + ); + + accountService = new AccountService( + null, + null, + exchangeRateDataService, + null + ); + + activitiesService = new ActivitiesService( + null, + accountService, + null, + dataProviderService, + null, + exchangeRateDataService, + null, + null + ); + + impersonationService = new ImpersonationService(null, null); + + portfolioCalculatorFactory = new PortfolioCalculatorFactory( + configurationService, + null, + exchangeRateDataService, + null, + null + ); + + symbolProfileService = new SymbolProfileService(null); + + userService = new UserService( + null, + null, + null, + null, + null, + null, + null, + null + ); + + portfolioService = new PortfolioService( + null, + accountService, + activitiesService, + null, + portfolioCalculatorFactory, + dataProviderService, + exchangeRateDataService, + null, + impersonationService, + null, + null, + symbolProfileService, + userService + ); + }); + + describe('getCashSymbolProfiles', () => { + it('should use the exchange-rate data source so the symbol-profile join in getDetails matches the calculator positions', () => { + jest + .spyOn(dataProviderService, 'getDataSourceForExchangeRates') + .mockReturnValue(DataSource.YAHOO); + + const cashDetails: CashDetails = { + accounts: [ + { + balance: 2000, + comment: null, + createdAt: parseDate('2024-01-01'), + currency: 'USD', + id: randomUUID(), + isExcluded: false, + name: 'USD', + platformId: null, + updatedAt: parseDate('2024-01-01'), + userId: userDummyData.id + } + ], + balanceInBaseCurrency: 1820 + }; + + const assetProfiles = ( + portfolioService as unknown as { + getCashSymbolProfiles: ( + aCashDetails: CashDetails + ) => { dataSource: DataSource; symbol: string }[]; + } + ).getCashSymbolProfiles(cashDetails); + + expect(assetProfiles).toHaveLength(1); + expect(assetProfiles[0].dataSource).toBe(DataSource.YAHOO); + expect(assetProfiles[0].symbol).toBe('USD'); + }); + }); + + describe('getDetails', () => { + it('should return cash holdings when the calculator emits cash positions with the exchange-rate data source', async () => { + const accountId = randomUUID(); + + const cashAccount: Account = { + balance: 2000, + comment: null, + createdAt: parseDate('2024-01-01'), + currency: 'USD', + id: accountId, + isExcluded: false, + name: 'USD', + platformId: null, + updatedAt: parseDate('2024-01-01'), + userId: userDummyData.id + }; + + jest.spyOn(accountService, 'getCashDetails').mockResolvedValue({ + accounts: [cashAccount], + balanceInBaseCurrency: 1820 + }); + + jest + .spyOn(activitiesService, 'getActivitiesForPortfolioCalculator') + .mockResolvedValue({ activities: [], count: 0 }); + + jest + .spyOn(dataProviderService, 'getDataSourceForExchangeRates') + .mockReturnValue(DataSource.YAHOO); + + jest + .spyOn(impersonationService, 'validateImpersonationId') + .mockResolvedValue(null); + + jest + .spyOn(symbolProfileService, 'getSymbolProfiles') + .mockResolvedValue([]); + + jest.spyOn(userService, 'user').mockResolvedValue({ + accessesGet: [], + accounts: [], + activityCount: 0, + dataProviderGhostfolioDailyRequests: 0, + id: userDummyData.id, + settings: { + settings: { + baseCurrency: 'CHF' + } + } + } as unknown as Awaited>); + + const usdPosition = { + activitiesCount: 1, + averagePrice: new Big(1), + currency: 'USD', + dataSource: DataSource.YAHOO, + dateOfFirstActivity: '2024-01-01', + dividend: new Big(0), + dividendInBaseCurrency: new Big(0), + fee: new Big(0), + feeInBaseCurrency: new Big(0), + grossPerformance: new Big(0), + grossPerformancePercentage: new Big(0), + grossPerformancePercentageWithCurrencyEffect: new Big(0), + grossPerformanceWithCurrencyEffect: new Big(0), + investment: new Big(1820), + investmentWithCurrencyEffect: new Big(1820), + marketPrice: 1, + marketPriceInBaseCurrency: 0.91, + netPerformance: new Big(0), + netPerformancePercentage: new Big(0), + netPerformancePercentageWithCurrencyEffectMap: {}, + netPerformanceWithCurrencyEffectMap: {}, + quantity: new Big(2000), + symbol: 'USD', + tags: [], + timeWeightedInvestment: new Big(0), + timeWeightedInvestmentWithCurrencyEffect: new Big(0), + valueInBaseCurrency: new Big(1820) + }; + + jest + .spyOn(portfolioCalculatorFactory, 'createCalculator') + .mockReturnValue({ + getSnapshot: jest.fn().mockResolvedValue({ + activitiesCount: 1, + createdAt: parseDate('2024-01-01'), + currentValueInBaseCurrency: new Big(1820), + errors: [], + hasErrors: false, + historicalData: [], + positions: [usdPosition], + totalFeesWithCurrencyEffect: new Big(0), + totalInterestWithCurrencyEffect: new Big(0), + totalInvestment: new Big(1820), + totalInvestmentWithCurrencyEffect: new Big(1820), + totalLiabilitiesWithCurrencyEffect: new Big(0) + }) + } as unknown as ReturnType< + typeof portfolioCalculatorFactory.createCalculator + >); + + jest + .spyOn( + portfolioService as unknown as { + getValueOfAccountsAndPlatforms: () => Promise<{ + accounts: object; + platforms: object; + }>; + }, + 'getValueOfAccountsAndPlatforms' + ) + .mockResolvedValue({ accounts: {}, platforms: {} }); + + const { holdings } = await portfolioService.getDetails({ + filters: [], + impersonationId: userDummyData.id, + userId: userDummyData.id + }); + + expect(holdings['USD']).toBeDefined(); + expect(holdings['USD'].assetProfile.dataSource).toBe(DataSource.YAHOO); + expect(holdings['USD'].assetProfile.symbol).toBe('USD'); + }); + }); +}); From aa0ec2622f3474a62d030ae512e906c8c33c6d33 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sun, 31 May 2026 07:03:47 +0200 Subject: [PATCH 3/5] Task/extend countries mapping in Trackinsight data enhancer service (#6959) * Extend countries mapping with USA * Update changelog --- CHANGELOG.md | 6 ++++++ .../data-enhancer/trackinsight/trackinsight.service.ts | 3 ++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 983e9d6ea..9842a2b19 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 + +- Extended the countries mapping in the data enhancer for asset profile data via _Trackinsight_ + ## 3.6.0 - 2026-05-28 ### Added diff --git a/apps/api/src/services/data-provider/data-enhancer/trackinsight/trackinsight.service.ts b/apps/api/src/services/data-provider/data-enhancer/trackinsight/trackinsight.service.ts index 54627f312..eeccf725e 100644 --- a/apps/api/src/services/data-provider/data-enhancer/trackinsight/trackinsight.service.ts +++ b/apps/api/src/services/data-provider/data-enhancer/trackinsight/trackinsight.service.ts @@ -13,7 +13,8 @@ import { countries } from 'countries-list'; export class TrackinsightDataEnhancerService implements DataEnhancerInterface { private static baseUrl = 'https://www.trackinsight.com/data-api'; private static countriesMapping = { - 'Russian Federation': 'Russia' + 'Russian Federation': 'Russia', + USA: 'United States' }; private static holdingsWeightTreshold = 0.85; private static sectorsMapping = { From ab6fdf6c4fe55aa663c4f4d6893780141e39ccab Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sun, 31 May 2026 07:16:56 +0200 Subject: [PATCH 4/5] Task/remove deprecated attributes from portfolio position interface (#6950) * Remove deprecated attributes * Update changelog --- CHANGELOG.md | 1 + .../src/app/portfolio/portfolio.controller.ts | 44 ++-- .../src/app/portfolio/portfolio.service.ts | 34 +-- ...ate-or-update-activity-dialog.component.ts | 2 +- .../import-activities-dialog.component.ts | 3 +- .../allocations/allocations-page.component.ts | 50 ++--- .../portfolio/analysis/analysis-page.html | 16 +- .../app/pages/public/public-page.component.ts | 5 +- .../portfolio-position.interface.ts | 47 +--- .../src/lib/assistant/assistant.component.ts | 41 ++-- libs/ui/src/lib/mocks/holdings.ts | 200 ++++-------------- .../portfolio-filter-form.component.html | 9 +- .../portfolio-filter-form.component.ts | 3 +- 13 files changed, 136 insertions(+), 319 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9842a2b19..e0c416fb0 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 - Extended the countries mapping in the data enhancer for asset profile data via _Trackinsight_ +- Removed the deprecated attributes (`assetClass`, `assetClassLabel`, `assetSubClass`, `assetSubClassLabel`, `countries`, `currency`, `dataSource`, `holdings`, `name`, `sectors`, `symbol` and `url`) from the holdings of the portfolio details endpoint response ## 3.6.0 - 2026-05-28 diff --git a/apps/api/src/app/portfolio/portfolio.controller.ts b/apps/api/src/app/portfolio/portfolio.controller.ts index 8aa94ee92..9515c7a21 100644 --- a/apps/api/src/app/portfolio/portfolio.controller.ts +++ b/apps/api/src/app/portfolio/portfolio.controller.ts @@ -144,10 +144,10 @@ export class PortfolioController { .reduce((a, b) => a + b, 0); const totalValue = Object.values(holdings) - .filter(({ assetClass, assetSubClass }) => { + .filter(({ assetProfile }) => { return ( - assetClass !== AssetClass.LIQUIDITY && - assetSubClass !== AssetSubClass.CASH + assetProfile.assetClass !== AssetClass.LIQUIDITY && + assetProfile.assetSubClass !== AssetSubClass.CASH ); }) .map(({ valueInBaseCurrency }) => { @@ -217,37 +217,41 @@ export class PortfolioController { for (const [symbol, portfolioPosition] of Object.entries(holdings)) { holdings[symbol] = { ...portfolioPosition, - assetClass: - hasDetails || portfolioPosition.assetClass === AssetClass.LIQUIDITY - ? portfolioPosition.assetClass - : undefined, assetProfile: { ...portfolioPosition.assetProfile, + assetClass: + hasDetails || + portfolioPosition.assetProfile.assetClass === AssetClass.LIQUIDITY + ? portfolioPosition.assetProfile.assetClass + : undefined, + assetClassLabel: + hasDetails || + portfolioPosition.assetProfile.assetClass === AssetClass.LIQUIDITY + ? portfolioPosition.assetProfile.assetClassLabel + : undefined, + assetSubClass: + hasDetails || + portfolioPosition.assetProfile.assetSubClass === AssetSubClass.CASH + ? portfolioPosition.assetProfile.assetSubClass + : undefined, + assetSubClassLabel: + hasDetails || + portfolioPosition.assetProfile.assetSubClass === AssetSubClass.CASH + ? portfolioPosition.assetProfile.assetSubClassLabel + : undefined, ...(hasDetails ? {} : { - assetClass: undefined, - assetClassLabel: undefined, - assetSubClass: undefined, - assetSubClassLabel: undefined, countries: [], currency: undefined, holdings: [], sectors: [] }) }, - assetSubClass: - hasDetails || portfolioPosition.assetSubClass === AssetSubClass.CASH - ? portfolioPosition.assetSubClass - : undefined, - countries: hasDetails ? portfolioPosition.countries : [], - currency: hasDetails ? portfolioPosition.currency : undefined, - holdings: hasDetails ? portfolioPosition.holdings : [], markets: hasDetails ? portfolioPosition.markets : undefined, marketsAdvanced: hasDetails ? portfolioPosition.marketsAdvanced - : undefined, - sectors: hasDetails ? portfolioPosition.sectors : [] + : undefined }; } diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index cee36ec27..ab39a00a5 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -584,7 +584,6 @@ export class PortfolioService { for (const { activitiesCount, - currency, dataSource, dateOfFirstActivity, dividend, @@ -638,16 +637,13 @@ export class PortfolioService { holdings[symbol] = { activitiesCount, - currency, markets, marketsAdvanced, marketPrice, - symbol, tags, allocationInPercentage: filteredValueInBaseCurrency.eq(0) ? 0 : valueInBaseCurrency.div(filteredValueInBaseCurrency).toNumber(), - assetClass: assetProfile.assetClass, assetProfile: { assetClass: assetProfile.assetClass, assetSubClass: assetProfile.assetSubClass, @@ -670,9 +666,6 @@ export class PortfolioService { symbol: assetProfile.symbol, url: assetProfile.url }, - assetSubClass: assetProfile.assetSubClass, - countries: assetProfile.countries, - dataSource: assetProfile.dataSource, dateOfFirstActivity: parseDate(dateOfFirstActivity), dividend: dividend?.toNumber() ?? 0, grossPerformance: grossPerformance?.toNumber() ?? 0, @@ -681,19 +674,7 @@ export class PortfolioService { grossPerformancePercentageWithCurrencyEffect?.toNumber() ?? 0, grossPerformanceWithCurrencyEffect: grossPerformanceWithCurrencyEffect?.toNumber() ?? 0, - holdings: assetProfile.holdings.map( - ({ allocationInPercentage, name }) => { - return { - allocationInPercentage, - name, - valueInBaseCurrency: valueInBaseCurrency - .mul(allocationInPercentage) - .toNumber() - }; - } - ), investment: investment.toNumber(), - name: assetProfile.name, netPerformance: netPerformance?.toNumber() ?? 0, netPerformancePercent: netPerformancePercentage?.toNumber() ?? 0, netPerformancePercentWithCurrencyEffect: @@ -703,8 +684,6 @@ export class PortfolioService { netPerformanceWithCurrencyEffect: netPerformanceWithCurrencyEffectMap?.[dateRange]?.toNumber() ?? 0, quantity: quantity.toNumber(), - sectors: assetProfile.sectors, - url: assetProfile.url, valueInBaseCurrency: valueInBaseCurrency.toNumber() }; } @@ -1472,8 +1451,8 @@ export class PortfolioService { for (const [, position] of Object.entries(holdings)) { const value = position.valueInBaseCurrency; - if (position.assetClass !== AssetClass.LIQUIDITY) { - if (position.countries.length > 0) { + if (position.assetProfile.assetClass !== AssetClass.LIQUIDITY) { + if (position.assetProfile.countries.length > 0) { markets.developedMarkets.valueInBaseCurrency += position.markets.developedMarkets * value; markets.emergingMarkets.valueInBaseCurrency += @@ -1719,11 +1698,8 @@ export class PortfolioService { currency: string; }): PortfolioPosition { return { - currency, activitiesCount: 0, allocationInPercentage: 0, - assetClass: AssetClass.LIQUIDITY, - assetSubClass: AssetSubClass.CASH, assetProfile: { currency, assetClass: AssetClass.LIQUIDITY, @@ -1735,25 +1711,19 @@ export class PortfolioService { sectors: [], symbol: currency }, - countries: [], - dataSource: undefined, dateOfFirstActivity: undefined, dividend: 0, grossPerformance: 0, grossPerformancePercent: 0, grossPerformancePercentWithCurrencyEffect: 0, grossPerformanceWithCurrencyEffect: 0, - holdings: [], investment: balance, marketPrice: 0, - name: currency, netPerformance: 0, netPerformancePercent: 0, netPerformancePercentWithCurrencyEffect: 0, netPerformanceWithCurrencyEffect: 0, quantity: 0, - sectors: [], - symbol: currency, tags: [], valueInBaseCurrency: balance }; diff --git a/apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts b/apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts index 1e943824c..decb30682 100644 --- a/apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts +++ b/apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts @@ -139,7 +139,7 @@ export class GfCreateOrUpdateActivityDialogComponent { return !['CASH'].includes(assetProfile.assetSubClass); }) .sort((a, b) => { - return a.name?.localeCompare(b.name); + return a.assetProfile.name?.localeCompare(b.assetProfile.name); }) .map(({ assetProfile }) => { return { diff --git a/apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.component.ts b/apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.component.ts index 42260d648..c3dbe6cf2 100644 --- a/apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.component.ts +++ b/apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.component.ts @@ -226,7 +226,8 @@ export class GfImportActivitiesDialogComponent { this.assetProfileForm.controls.assetProfileIdentifier.disable(); const { dataSource, symbol } = - this.assetProfileForm.controls.assetProfileIdentifier.value ?? {}; + this.assetProfileForm.controls.assetProfileIdentifier.value + ?.assetProfile ?? {}; if (!dataSource || !symbol) { return; 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 a7f8cd2ec..f48b551bb 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 @@ -73,15 +73,14 @@ export class GfAllocationsPageComponent implements OnInit { public hasImpersonationId: boolean; public holdings: { [symbol: string]: Pick< - PortfolioPosition, + PortfolioPosition['assetProfile'], | 'assetClass' | 'assetClassLabel' | 'assetSubClass' | 'assetSubClassLabel' | 'currency' - | 'exchange' | 'name' - > & { etfProvider: string; value: number }; + > & { etfProvider: string; exchange?: string; value: number }; }; public isLoading = false; public markets: { @@ -206,7 +205,7 @@ export class GfAllocationsPageComponent implements OnInit { assetSubClass, name }: { - assetSubClass: PortfolioPosition['assetSubClass']; + assetSubClass: PortfolioPosition['assetProfile']['assetSubClass']; name: string; }) { if (assetSubClass === 'ETF') { @@ -333,24 +332,27 @@ export class GfAllocationsPageComponent implements OnInit { this.holdings[symbol] = { value, - assetClass: position.assetClass || (UNKNOWN_KEY as AssetClass), - assetClassLabel: position.assetClassLabel || UNKNOWN_KEY, - assetSubClass: position.assetSubClass || (UNKNOWN_KEY as AssetSubClass), - assetSubClassLabel: position.assetSubClassLabel || UNKNOWN_KEY, - currency: position.currency, + assetClass: + position.assetProfile.assetClass || (UNKNOWN_KEY as AssetClass), + assetClassLabel: position.assetProfile.assetClassLabel || UNKNOWN_KEY, + assetSubClass: + position.assetProfile.assetSubClass || (UNKNOWN_KEY as AssetSubClass), + assetSubClassLabel: + position.assetProfile.assetSubClassLabel || UNKNOWN_KEY, + currency: position.assetProfile.currency, etfProvider: this.extractEtfProvider({ - assetSubClass: position.assetSubClass, - name: position.name + assetSubClass: position.assetProfile.assetSubClass, + name: position.assetProfile.name }), exchange: position.exchange, - name: position.name + name: position.assetProfile.name }; - if (position.assetClass !== AssetClass.LIQUIDITY) { + if (position.assetProfile.assetClass !== AssetClass.LIQUIDITY) { // Prepare analysis data by continents, countries, holdings and sectors except for liquidity - if (position.countries.length > 0) { - for (const country of position.countries) { + if (position.assetProfile.countries.length > 0) { + for (const country of position.assetProfile.countries) { const { code, continent, name, weight } = country; if (this.continents[continent]?.value) { @@ -401,12 +403,12 @@ export class GfAllocationsPageComponent implements OnInit { : this.portfolioDetails.holdings[symbol].valueInPercentage; } - if (position.holdings.length > 0) { + if (position.assetProfile.holdings.length > 0) { for (const { allocationInPercentage, name, valueInBaseCurrency - } of position.holdings) { + } of position.assetProfile.holdings) { const normalizedAssetName = this.normalizeAssetName(name); if (this.topHoldingsMap[normalizedAssetName]?.value) { @@ -428,8 +430,8 @@ export class GfAllocationsPageComponent implements OnInit { } } - if (position.sectors.length > 0) { - for (const sector of position.sectors) { + if (position.assetProfile.sectors.length > 0) { + for (const sector of position.assetProfile.sectors) { const { name, weight } = sector; if (this.sectors[name]?.value) { @@ -463,8 +465,8 @@ export class GfAllocationsPageComponent implements OnInit { } this.symbols[prettifySymbol(symbol)] = { - dataSource: position.dataSource, - name: position.name, + dataSource: position.assetProfile.dataSource, + name: position.assetProfile.name, symbol: prettifySymbol(symbol), value: isNumber(position.valueInBaseCurrency) ? position.valueInBaseCurrency @@ -517,8 +519,8 @@ export class GfAllocationsPageComponent implements OnInit { this.totalValueInEtf > 0 ? value / this.totalValueInEtf : 0, parents: Object.entries(this.portfolioDetails.holdings) .map(([symbol, holding]) => { - if (holding.holdings.length > 0) { - const currentParentHolding = holding.holdings.find( + if (holding.assetProfile.holdings.length > 0) { + const currentParentHolding = holding.assetProfile.holdings.find( (parentHolding) => { return ( this.normalizeAssetName(parentHolding.name) === @@ -531,7 +533,7 @@ export class GfAllocationsPageComponent implements OnInit { ? { allocationInPercentage: currentParentHolding.valueInBaseCurrency / value, - name: holding.name, + name: holding.assetProfile.name, position: holding, symbol: prettifySymbol(symbol), valueInBaseCurrency: diff --git a/apps/client/src/app/pages/portfolio/analysis/analysis-page.html b/apps/client/src/app/pages/portfolio/analysis/analysis-page.html index 4c5c61bd8..b93be9182 100644 --- a/apps/client/src/app/pages/portfolio/analysis/analysis-page.html +++ b/apps/client/src/app/pages/portfolio/analysis/analysis-page.html @@ -310,13 +310,15 @@ -
{{ holding.name }}
+
+ {{ holding.assetProfile.name }} +
-
{{ holding.name }}
+
+ {{ holding.assetProfile.name }} +
& { + [symbol: string]: Pick< + PortfolioPosition['assetProfile'], + 'currency' | 'name' + > & { value: number; }; }; diff --git a/libs/common/src/lib/interfaces/portfolio-position.interface.ts b/libs/common/src/lib/interfaces/portfolio-position.interface.ts index c4ef2e3dc..c94a1efa5 100644 --- a/libs/common/src/lib/interfaces/portfolio-position.interface.ts +++ b/libs/common/src/lib/interfaces/portfolio-position.interface.ts @@ -1,22 +1,12 @@ import { Market, MarketAdvanced } from '@ghostfolio/common/types'; -import { AssetClass, AssetSubClass, DataSource, Tag } from '@prisma/client'; +import { Tag } from '@prisma/client'; -import { Country } from './country.interface'; import { EnhancedSymbolProfile } from './enhanced-symbol-profile.interface'; -import { Holding } from './holding.interface'; -import { Sector } from './sector.interface'; export interface PortfolioPosition { activitiesCount: number; allocationInPercentage: number; - - /** @deprecated */ - assetClass?: AssetClass; - - /** @deprecated */ - assetClassLabel?: string; - assetProfile: Pick< EnhancedSymbolProfile, | 'assetClass' @@ -33,22 +23,6 @@ export interface PortfolioPosition { assetClassLabel?: string; assetSubClassLabel?: string; }; - - /** @deprecated */ - assetSubClass?: AssetSubClass; - - /** @deprecated */ - assetSubClassLabel?: string; - - /** @deprecated */ - countries: Country[]; - - /** @deprecated */ - currency: string; - - /** @deprecated */ - dataSource: DataSource; - dateOfFirstActivity: Date; dividend: number; exchange?: string; @@ -56,38 +30,19 @@ export interface PortfolioPosition { grossPerformancePercent: number; grossPerformancePercentWithCurrencyEffect: number; grossPerformanceWithCurrencyEffect: number; - - /** @deprecated */ - holdings: Holding[]; - investment: number; marketChange?: number; marketChangePercent?: number; marketPrice: number; markets?: { [key in Market]: number }; marketsAdvanced?: { [key in MarketAdvanced]: number }; - - /** @deprecated */ - name: string; - netPerformance: number; netPerformancePercent: number; netPerformancePercentWithCurrencyEffect: number; netPerformanceWithCurrencyEffect: number; quantity: number; - - /** @deprecated */ - sectors: Sector[]; - - /** @deprecated */ - symbol: string; - tags?: Tag[]; type?: string; - - /** @deprecated */ - url?: string; - valueInBaseCurrency?: number; valueInPercentage?: number; } diff --git a/libs/ui/src/lib/assistant/assistant.component.ts b/libs/ui/src/lib/assistant/assistant.component.ts index a0985a979..3c162a310 100644 --- a/libs/ui/src/lib/assistant/assistant.component.ts +++ b/libs/ui/src/lib/assistant/assistant.component.ts @@ -504,11 +504,16 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit { .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(({ holdings }) => { this.holdings = holdings - .filter(({ assetSubClass }) => { - return assetSubClass && !['CASH'].includes(assetSubClass); + .filter(({ assetProfile }) => { + return ( + assetProfile.assetSubClass && + !['CASH'].includes(assetProfile.assetSubClass) + ); }) .sort((a, b) => { - return a.name?.localeCompare(b.name); + return (a.assetProfile.name ?? '').localeCompare( + b.assetProfile.name ?? '' + ); }); this.setPortfolioFilterFormValues(); @@ -530,11 +535,11 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit { type: 'ASSET_CLASS' }, { - id: filterValue?.holding?.dataSource ?? '', + id: filterValue?.holding?.assetProfile?.dataSource ?? '', type: 'DATA_SOURCE' }, { - id: filterValue?.holding?.symbol ?? '', + id: filterValue?.holding?.assetProfile?.symbol ?? '', type: 'SYMBOL' }, { @@ -718,18 +723,16 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit { return EMPTY; }), map(({ holdings }) => { - return holdings.map( - ({ assetSubClass, currency, dataSource, name, symbol }) => { - return { - currency, - dataSource, - name, - symbol, - assetSubClassString: translate(assetSubClass ?? ''), - mode: SearchMode.HOLDING as const - }; - } - ); + return holdings.map(({ assetProfile }) => { + return { + assetSubClassString: translate(assetProfile.assetSubClass ?? ''), + currency: assetProfile.currency ?? '', + dataSource: assetProfile.dataSource, + mode: SearchMode.HOLDING as const, + name: assetProfile.name ?? '', + symbol: assetProfile.symbol + }; + }); }), takeUntilDestroyed(this.destroyRef) ); @@ -777,8 +780,8 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit { return ( !!(dataSource && symbol) && getAssetProfileIdentifier({ - dataSource: holding.dataSource, - symbol: holding.symbol + dataSource: holding.assetProfile.dataSource, + symbol: holding.assetProfile.symbol }) === getAssetProfileIdentifier({ dataSource, symbol }) ); }); diff --git a/libs/ui/src/lib/mocks/holdings.ts b/libs/ui/src/lib/mocks/holdings.ts index b32eb527a..11f3bec0e 100644 --- a/libs/ui/src/lib/mocks/holdings.ts +++ b/libs/ui/src/lib/mocks/holdings.ts @@ -4,11 +4,11 @@ export const holdings: PortfolioPosition[] = [ { activitiesCount: 1, allocationInPercentage: 0.042990776363386086, - assetClass: 'EQUITY', - assetClassLabel: 'Equity', assetProfile: { assetClass: 'EQUITY', + assetClassLabel: 'Equity', assetSubClass: 'STOCK', + assetSubClassLabel: 'Stock', countries: [ { code: 'US', @@ -20,60 +20,40 @@ export const holdings: PortfolioPosition[] = [ currency: 'USD', dataSource: 'YAHOO', holdings: [], + name: 'Apple Inc', sectors: [ { name: 'Technology', weight: 1 } ], - symbol: 'AAPL' + symbol: 'AAPL', + url: 'https://www.apple.com' }, - assetSubClass: 'STOCK', - assetSubClassLabel: 'Stock', - countries: [ - { - code: 'US', - continent: 'North America', - name: 'United States', - weight: 1 - } - ], - currency: 'USD', - dataSource: 'YAHOO', dateOfFirstActivity: new Date('2021-12-01T00:00:00.000Z'), dividend: 0, grossPerformance: 3856, grossPerformancePercent: 0.46047289228564603, grossPerformancePercentWithCurrencyEffect: 0.46047289228564603, grossPerformanceWithCurrencyEffect: 3856, - holdings: [], investment: 8374, marketPrice: 244.6, - name: 'Apple Inc', netPerformance: 3855, netPerformancePercent: 0.460353475041796, netPerformancePercentWithCurrencyEffect: 0.036440677966101696, netPerformanceWithCurrencyEffect: 430, quantity: 50, - sectors: [ - { - name: 'Technology', - weight: 1 - } - ], - symbol: 'AAPL', tags: [], - url: 'https://www.apple.com', valueInBaseCurrency: 12230 }, { activitiesCount: 2, allocationInPercentage: 0.02377401948293552, - assetClass: 'EQUITY', - assetClassLabel: 'Equity', assetProfile: { assetClass: 'EQUITY', + assetClassLabel: 'Equity', assetSubClass: 'STOCK', + assetSubClassLabel: 'Stock', countries: [ { code: 'DE', @@ -85,60 +65,40 @@ export const holdings: PortfolioPosition[] = [ currency: 'EUR', dataSource: 'YAHOO', holdings: [], + name: 'Allianz SE', sectors: [ { name: 'Financial Services', weight: 1 } ], - symbol: 'ALV.DE' + symbol: 'ALV.DE', + url: 'https://www.allianz.com' }, - assetSubClass: 'STOCK', - assetSubClassLabel: 'Stock', - countries: [ - { - code: 'DE', - continent: 'Europe', - name: 'Germany', - weight: 1 - } - ], - currency: 'EUR', - dataSource: 'YAHOO', dateOfFirstActivity: new Date('2021-04-23T00:00:00.000Z'), dividend: 192, grossPerformance: 2226.700251889169, grossPerformancePercent: 0.49083842309827874, grossPerformancePercentWithCurrencyEffect: 0.29306136948826367, grossPerformanceWithCurrencyEffect: 1532.8272791336772, - holdings: [], investment: 4536.523929471033, marketPrice: 322.2, - name: 'Allianz SE', netPerformance: 2222.2921914357685, netPerformancePercent: 0.48986674069961134, netPerformancePercentWithCurrencyEffect: 0.034489367670592026, netPerformanceWithCurrencyEffect: 225.48257403052068, quantity: 20, - sectors: [ - { - name: 'Financial Services', - weight: 1 - } - ], - symbol: 'ALV.DE', tags: [], - url: 'https://www.allianz.com', valueInBaseCurrency: 6763.224181360202 }, { activitiesCount: 1, allocationInPercentage: 0.08038536990007467, - assetClass: 'EQUITY', - assetClassLabel: 'Equity', assetProfile: { assetClass: 'EQUITY', + assetClassLabel: 'Equity', assetSubClass: 'STOCK', + assetSubClassLabel: 'Stock', countries: [ { code: 'US', @@ -150,101 +110,73 @@ export const holdings: PortfolioPosition[] = [ currency: 'USD', dataSource: 'YAHOO', holdings: [], + name: 'Amazon.com, Inc.', sectors: [ { name: 'Consumer Discretionary', weight: 1 } ], - symbol: 'AMZN' + symbol: 'AMZN', + url: 'https://www.aboutamazon.com' }, - assetSubClass: 'STOCK', - assetSubClassLabel: 'Stock', - countries: [ - { - code: 'US', - continent: 'North America', - name: 'United States', - weight: 1 - } - ], - currency: 'USD', - dataSource: 'YAHOO', dateOfFirstActivity: new Date('2018-10-01T00:00:00.000Z'), dividend: 0, grossPerformance: 12758.05, grossPerformancePercent: 1.2619300787837724, grossPerformancePercentWithCurrencyEffect: 1.2619300787837724, grossPerformanceWithCurrencyEffect: 12758.05, - holdings: [], investment: 10109.95, marketPrice: 228.68, - name: 'Amazon.com, Inc.', netPerformance: 12677.26, netPerformancePercent: 1.253938941339967, netPerformancePercentWithCurrencyEffect: -0.037866008722316276, netPerformanceWithCurrencyEffect: -899.99926757812, quantity: 100, - sectors: [ - { - name: 'Consumer Discretionary', - weight: 1 - } - ], - symbol: 'AMZN', tags: [], - url: 'https://www.aboutamazon.com', valueInBaseCurrency: 22868 }, { activitiesCount: 1, allocationInPercentage: 0.19216416482928922, - assetClass: 'LIQUIDITY', - assetClassLabel: 'Liquidity', assetProfile: { assetClass: 'LIQUIDITY', - assetSubClass: 'CASH', + assetClassLabel: 'Liquidity', + assetSubClass: 'CRYPTOCURRENCY', + assetSubClassLabel: 'Cryptocurrency', countries: [], currency: 'USD', dataSource: 'COINGECKO', holdings: [], + name: 'Bitcoin', sectors: [], - symbol: 'bitcoin' + symbol: 'bitcoin', + url: undefined }, - assetSubClass: 'CRYPTOCURRENCY', - assetSubClassLabel: 'Cryptocurrency', - countries: [], - currency: 'USD', - dataSource: 'COINGECKO', dateOfFirstActivity: new Date('2017-08-16T00:00:00.000Z'), dividend: 0, grossPerformance: 52666.7898248, grossPerformancePercent: 26.333394912400003, grossPerformancePercentWithCurrencyEffect: 26.333394912400003, grossPerformanceWithCurrencyEffect: 52666.7898248, - holdings: [], investment: 1999.9999999999998, marketPrice: 97364, - name: 'Bitcoin', netPerformance: 52636.8898248, netPerformancePercent: 26.3184449124, netPerformancePercentWithCurrencyEffect: -0.04760906442310894, netPerformanceWithCurrencyEffect: -2732.737808972287, quantity: 0.5614682, - sectors: [], - symbol: 'bitcoin', tags: [], - url: undefined, valueInBaseCurrency: 54666.7898248 }, { activitiesCount: 1, allocationInPercentage: 0.04307127421937313, - assetClass: 'EQUITY', - assetClassLabel: 'Equity', assetProfile: { assetClass: 'EQUITY', + assetClassLabel: 'Equity', assetSubClass: 'STOCK', + assetSubClassLabel: 'Stock', countries: [ { code: 'US', @@ -256,60 +188,40 @@ export const holdings: PortfolioPosition[] = [ currency: 'USD', dataSource: 'YAHOO', holdings: [], + name: 'Microsoft Corporation', sectors: [ { name: 'Technology', weight: 1 } ], - symbol: 'MSFT' + symbol: 'MSFT', + url: 'https://www.microsoft.com' }, - assetSubClass: 'STOCK', - assetSubClassLabel: 'Stock', - countries: [ - { - code: 'US', - continent: 'North America', - name: 'United States', - weight: 1 - } - ], - currency: 'USD', - dataSource: 'YAHOO', dateOfFirstActivity: new Date('2023-01-03T00:00:00.000Z'), dividend: 0, grossPerformance: 5065.5, grossPerformancePercent: 0.7047750229568411, grossPerformancePercentWithCurrencyEffect: 0.7047750229568411, grossPerformanceWithCurrencyEffect: 5065.5, - holdings: [], investment: 7187.4, marketPrice: 408.43, - name: 'Microsoft Corporation', netPerformance: 5065.5, netPerformancePercent: 0.7047750229568411, netPerformancePercentWithCurrencyEffect: -0.015973588391056275, netPerformanceWithCurrencyEffect: -198.899926757814, quantity: 30, - sectors: [ - { - name: 'Technology', - weight: 1 - } - ], - symbol: 'MSFT', tags: [], - url: 'https://www.microsoft.com', valueInBaseCurrency: 12252.9 }, { activitiesCount: 1, allocationInPercentage: 0.18762679306394897, - assetClass: 'EQUITY', - assetClassLabel: 'Equity', assetProfile: { assetClass: 'EQUITY', + assetClassLabel: 'Equity', assetSubClass: 'STOCK', + assetSubClassLabel: 'Stock', countries: [ { code: 'US', @@ -321,60 +233,40 @@ export const holdings: PortfolioPosition[] = [ currency: 'USD', dataSource: 'YAHOO', holdings: [], + name: 'Tesla, Inc.', sectors: [ { name: 'Consumer Discretionary', weight: 1 } ], - symbol: 'TSLA' + symbol: 'TSLA', + url: 'https://www.tesla.com' }, - assetSubClass: 'STOCK', - assetSubClassLabel: 'Stock', - countries: [ - { - code: 'US', - continent: 'North America', - name: 'United States', - weight: 1 - } - ], - currency: 'USD', - dataSource: 'YAHOO', dateOfFirstActivity: new Date('2017-01-03T00:00:00.000Z'), dividend: 0, grossPerformance: 51227.500000005, grossPerformancePercent: 23.843379101756675, grossPerformancePercentWithCurrencyEffect: 23.843379101756675, grossPerformanceWithCurrencyEffect: 51227.500000005, - holdings: [], investment: 2148.499999995, marketPrice: 355.84, - name: 'Tesla, Inc.', netPerformance: 51197.500000005, netPerformancePercent: 23.829415871596066, netPerformancePercentWithCurrencyEffect: -0.12051410125545206, netPerformanceWithCurrencyEffect: -7314.00091552734, quantity: 150, - sectors: [ - { - name: 'Consumer Discretionary', - weight: 1 - } - ], - symbol: 'TSLA', tags: [], - url: 'https://www.tesla.com', valueInBaseCurrency: 53376 }, { activitiesCount: 5, allocationInPercentage: 0.053051250766657634, - assetClass: 'EQUITY', - assetClassLabel: 'Equity', assetProfile: { assetClass: 'EQUITY', + assetClassLabel: 'Equity', assetSubClass: 'ETF', + assetSubClassLabel: 'ETF', countries: [ { code: 'US', @@ -386,50 +278,30 @@ export const holdings: PortfolioPosition[] = [ currency: 'USD', dataSource: 'YAHOO', holdings: [], + name: 'Vanguard Total Stock Market Index Fund ETF Shares', sectors: [ { name: 'Equity', weight: 1 } ], - symbol: 'VTI' + symbol: 'VTI', + url: 'https://www.vanguard.com' }, - assetSubClass: 'ETF', - assetSubClassLabel: 'ETF', - countries: [ - { - code: 'US', - weight: 1, - continent: 'North America', - name: 'United States' - } - ], - currency: 'USD', - dataSource: 'YAHOO', dateOfFirstActivity: new Date('2019-03-01T00:00:00.000Z'), dividend: 0, grossPerformance: 6845.8, grossPerformancePercent: 1.0164758094605268, grossPerformancePercentWithCurrencyEffect: 1.0164758094605268, grossPerformanceWithCurrencyEffect: 6845.8, - holdings: [], investment: 8246.2, marketPrice: 301.84, - name: 'Vanguard Total Stock Market Index Fund ETF Shares', netPerformance: 6746.3, netPerformancePercent: 1.0017018833976383, netPerformancePercentWithCurrencyEffect: 0.01085061564051406, netPerformanceWithCurrencyEffect: 161.99969482422, quantity: 50, - sectors: [ - { - name: 'Equity', - weight: 1 - } - ], - symbol: 'VTI', tags: [], - url: 'https://www.vanguard.com', valueInBaseCurrency: 15092 } ]; diff --git a/libs/ui/src/lib/portfolio-filter-form/portfolio-filter-form.component.html b/libs/ui/src/lib/portfolio-filter-form/portfolio-filter-form.component.html index f5dbac698..33bde3fd6 100644 --- a/libs/ui/src/lib/portfolio-filter-form/portfolio-filter-form.component.html +++ b/libs/ui/src/lib/portfolio-filter-form/portfolio-filter-form.component.html @@ -29,18 +29,19 @@ [compareWith]="holdingComparisonFunction" > {{ - filterForm.get('holding')?.value?.name + filterForm.get('holding')?.value?.assetProfile?.name }} - @for (holding of holdings(); track holding.name) { + @for (holding of holdings(); track holding.assetProfile.name) {
{{ holding.name }}{{ holding.assetProfile.name }}
{{ holding.symbol | gfSymbol }} · {{ holding.currency }}{{ holding.assetProfile.symbol | gfSymbol }} · + {{ holding.assetProfile.currency }}
diff --git a/libs/ui/src/lib/portfolio-filter-form/portfolio-filter-form.component.ts b/libs/ui/src/lib/portfolio-filter-form/portfolio-filter-form.component.ts index c1f82315c..20e8b0f0f 100644 --- a/libs/ui/src/lib/portfolio-filter-form/portfolio-filter-form.component.ts +++ b/libs/ui/src/lib/portfolio-filter-form/portfolio-filter-form.component.ts @@ -109,7 +109,8 @@ export class GfPortfolioFilterFormComponent } return ( - getAssetProfileIdentifier(option) === getAssetProfileIdentifier(value) + getAssetProfileIdentifier(option.assetProfile) === + getAssetProfileIdentifier(value.assetProfile) ); } From 697ef59e3b58bebc5c21a9e482e4f5643390f316 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sun, 31 May 2026 07:17:55 +0200 Subject: [PATCH 5/5] Feature/add support for web fetch in fetch service (#6960) * Add support for web fetch * Update changelog --- CHANGELOG.md | 4 + apps/api/src/services/fetch/fetch.module.ts | 2 + apps/api/src/services/fetch/fetch.service.ts | 154 +++++++++++++++++- .../interfaces/web-fetch-route.interface.ts | 19 +++ libs/common/src/lib/config.ts | 1 + 5 files changed, 174 insertions(+), 6 deletions(-) create mode 100644 apps/api/src/services/fetch/interfaces/web-fetch-route.interface.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e0c416fb0..d094f394b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Added + +- Added support for routing selected requests through the _OpenRouter_ `web_fetch` tool in the `FetchService` + ### Changed - Extended the countries mapping in the data enhancer for asset profile data via _Trackinsight_ diff --git a/apps/api/src/services/fetch/fetch.module.ts b/apps/api/src/services/fetch/fetch.module.ts index f98f2f45c..16e6f5f5d 100644 --- a/apps/api/src/services/fetch/fetch.module.ts +++ b/apps/api/src/services/fetch/fetch.module.ts @@ -1,9 +1,11 @@ import { FetchService } from '@ghostfolio/api/services/fetch/fetch.service'; +import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; import { Module } from '@nestjs/common'; @Module({ exports: [FetchService], + imports: [PropertyModule], providers: [FetchService] }) export class FetchModule {} diff --git a/apps/api/src/services/fetch/fetch.service.ts b/apps/api/src/services/fetch/fetch.service.ts index b3bd022d9..f32e56a1c 100644 --- a/apps/api/src/services/fetch/fetch.service.ts +++ b/apps/api/src/services/fetch/fetch.service.ts @@ -1,15 +1,35 @@ import { redactPaths } from '@ghostfolio/api/helper/object.helper'; +import { PropertyService } from '@ghostfolio/api/services/property/property.service'; +import { + PROPERTY_API_KEY_OPENROUTER, + PROPERTY_OPENROUTER_MODEL, + PROPERTY_WEB_FETCH_ROUTES +} from '@ghostfolio/common/config'; -import { Injectable, Logger } from '@nestjs/common'; +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { createOpenRouter } from '@openrouter/ai-sdk-provider'; +import { generateText, jsonSchema, tool } from 'ai'; +import ms from 'ms'; + +import { WebFetchRoute } from './interfaces/web-fetch-route.interface'; @Injectable() -export class FetchService { +export class FetchService implements OnModuleInit { private static readonly REDACTED_QUERY_PARAM_NAMES = ['apikey', 'api_token']; + private static readonly WEB_FETCH_TIMEOUT = ms('30 seconds'); + + private webFetchRoutes: WebFetchRoute[] = []; + + public constructor(private readonly propertyService: PropertyService) {} - public async fetch( - input: RequestInfo | URL, - init?: RequestInit - ): Promise { + public async onModuleInit() { + this.webFetchRoutes = + (await this.propertyService.getByKey( + PROPERTY_WEB_FETCH_ROUTES + )) ?? []; + } + + public async fetch(input: RequestInfo | URL, init?: RequestInit) { const method = ( init?.method ?? (input instanceof Request ? input.method : undefined) ?? @@ -21,6 +41,21 @@ export class FetchService { Logger.debug(`${method} ${urlRedacted}`, 'FetchService'); + if (method === 'GET') { + const webFetchRoute = this.getMatchingWebFetchRoute(url); + + if (webFetchRoute) { + const response = await this.fetchViaWebFetchTool({ + url, + webFetchRoute + }); + + if (response) { + return response; + } + } + } + try { return await globalThis.fetch(input, init); } catch (error) { @@ -40,6 +75,113 @@ export class FetchService { } } + private async fetchViaWebFetchTool({ + url, + webFetchRoute + }: { + url: string; + webFetchRoute: WebFetchRoute; + }) { + const [openRouterApiKey, openRouterModel] = await Promise.all([ + this.propertyService.getByKey(PROPERTY_API_KEY_OPENROUTER), + this.propertyService.getByKey(PROPERTY_OPENROUTER_MODEL) + ]); + + if (!openRouterApiKey || !openRouterModel) { + return undefined; + } + + try { + const openRouterService = createOpenRouter({ apiKey: openRouterApiKey }); + + const { sources, text } = await generateText({ + model: openRouterService.chat(openRouterModel), + prompt: [ + 'You have access to a web_fetch tool. You MUST call it to retrieve the URL below, do not answer from prior knowledge.', + 'Return the fetched response body exactly as received: raw body only, no commentary, no Markdown, and no code fences.', + `URL: ${url}` + ].join('\n'), + timeout: FetchService.WEB_FETCH_TIMEOUT, + tools: { + // Provider-defined tool: lets OpenRouter perform the actual web + // request server-side via its `web_fetch` engine. `id` and `args` + // are the OpenRouter-specific identifiers; the input schema is left + // open as the arguments are supplied by the model. + web_fetch: tool({ + args: { engine: 'openrouter' }, + id: 'openrouter.web_fetch', + inputSchema: jsonSchema({ + additionalProperties: true, + type: 'object' + }), + type: 'provider' + }) + } + }); + + const candidates = [ + ...(sources ?? []).map((source) => { + return source.providerMetadata?.openrouter?.content; + }), + text + ]; + + for (const candidate of candidates) { + if (typeof candidate !== 'string') { + continue; + } + + const body = candidate.trim(); + + if (!body) { + continue; + } + + if (webFetchRoute.responseContentType?.includes('application/json')) { + try { + JSON.parse(body); + } catch { + continue; + } + } + + Logger.debug( + `Routed ${this.redactUrl(url)} via web fetch tool`, + 'FetchService' + ); + + return new Response(body, { + headers: webFetchRoute.responseContentType + ? { 'content-type': webFetchRoute.responseContentType } + : undefined + }); + } + + return undefined; + } catch (error) { + Logger.error( + `Web fetch tool failed for ${this.redactUrl(url)}: ${ + error instanceof Error ? error.message : String(error) + }`, + 'FetchService' + ); + + return undefined; + } + } + + private getMatchingWebFetchRoute(url: string) { + try { + const { hostname } = new URL(url); + + return this.webFetchRoutes.find(({ domain }) => { + return hostname === domain || hostname.endsWith(`.${domain}`); + }); + } catch { + return undefined; + } + } + private redactUrl(rawUrl: string): string { try { const url = new URL(rawUrl); diff --git a/apps/api/src/services/fetch/interfaces/web-fetch-route.interface.ts b/apps/api/src/services/fetch/interfaces/web-fetch-route.interface.ts new file mode 100644 index 000000000..efff09398 --- /dev/null +++ b/apps/api/src/services/fetch/interfaces/web-fetch-route.interface.ts @@ -0,0 +1,19 @@ +/** + * Routes outgoing GET requests for a given domain through the OpenRouter + * `web_fetch` tool instead of a direct network request. + * + * Configured via the `WEB_FETCH_ROUTES` property as a JSON array, e.g. + * + * [ + * { + * "domain": "example.com", + * "responseContentType": "application/json" + * } + * ] + * + * Matches the domain itself and its subdomains (e.g. `api.example.com`). + */ +export interface WebFetchRoute { + domain: string; + responseContentType?: string; +} diff --git a/libs/common/src/lib/config.ts b/libs/common/src/lib/config.ts index 113dffe4a..28d902d71 100644 --- a/libs/common/src/lib/config.ts +++ b/libs/common/src/lib/config.ts @@ -256,6 +256,7 @@ export const PROPERTY_SLACK_COMMUNITY_USERS = 'SLACK_COMMUNITY_USERS'; export const PROPERTY_STRIPE_CONFIG = 'STRIPE_CONFIG'; export const PROPERTY_SYSTEM_MESSAGE = 'SYSTEM_MESSAGE'; export const PROPERTY_UPTIME = 'UPTIME'; +export const PROPERTY_WEB_FETCH_ROUTES = 'WEB_FETCH_ROUTES'; export const QUEUE_JOB_STATUS_LIST = [ 'active',