From 9b5ecbd2772296a01b46a048db450fdda5d43813 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sun, 29 Jun 2025 20:10:09 +0200 Subject: [PATCH] Add fuzzy search to GET holdings endpoint --- .../src/app/portfolio/portfolio.controller.ts | 20 ++- .../src/app/portfolio/portfolio.service.ts | 154 ------------------ 2 files changed, 17 insertions(+), 157 deletions(-) diff --git a/apps/api/src/app/portfolio/portfolio.controller.ts b/apps/api/src/app/portfolio/portfolio.controller.ts index 5b68f58e0..7033e64cc 100644 --- a/apps/api/src/app/portfolio/portfolio.controller.ts +++ b/apps/api/src/app/portfolio/portfolio.controller.ts @@ -60,6 +60,8 @@ import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { PortfolioService } from './portfolio.service'; import { UpdateHoldingTagsDto } from './update-holding-tags.dto'; +const Fuse = require('fuse.js'); + @Controller('portfolio') export class PortfolioController { public constructor( @@ -412,19 +414,31 @@ export class PortfolioController { filterByAssetClasses, filterByDataSource, filterByHoldingType, - filterBySearchQuery, filterBySymbol, filterByTags }); - const { holdings } = await this.portfolioService.getDetails({ + const { holdings: holdingsMap } = await this.portfolioService.getDetails({ dateRange, filters, impersonationId, userId: this.request.user.id }); - return { holdings: Object.values(holdings) }; + let holdings = Object.values(holdingsMap); + + if (filterBySearchQuery) { + const fuse = new Fuse(holdings, { + keys: ['isin', 'name', 'symbol'], + threshold: 0.3 + }); + + holdings = fuse.search(filterBySearchQuery).map(({ item }) => { + return item; + }); + } + + return { holdings }; } @Get('investments') diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index 480d47d52..4b9d9c41b 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -49,7 +49,6 @@ import { PortfolioPosition, PortfolioReportResponse, PortfolioSummary, - Position, UserSettings } from '@ghostfolio/common/interfaces'; import { TimelinePosition } from '@ghostfolio/common/models'; @@ -86,7 +85,6 @@ import { parseISO, set } from 'date-fns'; -import Fuse from 'fuse.js'; import { isEmpty } from 'lodash'; import { PortfolioCalculator } from './calculator/portfolio-calculator'; @@ -978,158 +976,6 @@ export class PortfolioService { } } - public async getHoldings({ - dateRange = 'max', - filters, - impersonationId - }: { - dateRange?: DateRange; - filters?: Filter[]; - impersonationId: string; - }): Promise<{ hasErrors: boolean; positions: Position[] }> { - const searchQuery = filters.find(({ type }) => { - return type === 'SEARCH_QUERY'; - })?.id; - const userId = await this.getUserId(impersonationId, this.request.user.id); - const user = await this.userService.user({ id: userId }); - const userCurrency = this.getUserCurrency(user); - - const { activities } = - await this.orderService.getOrdersForPortfolioCalculator({ - filters, - userCurrency, - userId - }); - - if (activities.length === 0) { - return { - hasErrors: false, - positions: [] - }; - } - - const portfolioCalculator = this.calculatorFactory.createCalculator({ - activities, - filters, - userId, - calculationType: this.getUserPerformanceCalculationType(user), - currency: userCurrency - }); - - const portfolioSnapshot = await portfolioCalculator.getSnapshot(); - const hasErrors = portfolioSnapshot.hasErrors; - let positions = portfolioSnapshot.positions; - - positions = positions.filter(({ quantity }) => { - return !quantity.eq(0); - }); - - const assetProfileIdentifiers = positions.map(({ dataSource, symbol }) => { - return { - dataSource, - symbol - }; - }); - - const [dataProviderResponses, symbolProfiles] = await Promise.all([ - this.dataProviderService.getQuotes({ - user, - items: assetProfileIdentifiers - }), - this.symbolProfileService.getSymbolProfiles( - positions.map(({ dataSource, symbol }) => { - return { dataSource, symbol }; - }) - ) - ]); - - const symbolProfileMap: { [symbol: string]: EnhancedSymbolProfile } = {}; - - for (const symbolProfile of symbolProfiles) { - symbolProfileMap[symbolProfile.symbol] = symbolProfile; - } - - if (searchQuery) { - const fuse = new Fuse(Object.values(symbolProfileMap), { - keys: ['isin', 'name', 'symbol'], - threshold: 0.3 - }); - - const symbolSearchResults = fuse - .search(searchQuery) - .map(({ item }) => item.symbol); - - positions = positions.filter(({ symbol }) => { - return symbolSearchResults.includes(symbol); - }); - } - - return { - hasErrors, - positions: positions.map( - ({ - averagePrice, - currency, - dataSource, - firstBuyDate, - grossPerformance, - grossPerformancePercentage, - grossPerformancePercentageWithCurrencyEffect, - grossPerformanceWithCurrencyEffect, - investment, - investmentWithCurrencyEffect, - netPerformance, - netPerformancePercentage, - netPerformancePercentageWithCurrencyEffectMap, - netPerformanceWithCurrencyEffectMap, - quantity, - symbol, - timeWeightedInvestment, - timeWeightedInvestmentWithCurrencyEffect, - transactionCount - }) => { - return { - currency, - dataSource, - firstBuyDate, - symbol, - transactionCount, - assetClass: symbolProfileMap[symbol].assetClass, - assetSubClass: symbolProfileMap[symbol].assetSubClass, - averagePrice: averagePrice.toNumber(), - grossPerformance: grossPerformance?.toNumber() ?? null, - grossPerformancePercentage: - grossPerformancePercentage?.toNumber() ?? null, - grossPerformancePercentageWithCurrencyEffect: - grossPerformancePercentageWithCurrencyEffect?.toNumber() ?? null, - grossPerformanceWithCurrencyEffect: - grossPerformanceWithCurrencyEffect?.toNumber() ?? null, - investment: investment.toNumber(), - investmentWithCurrencyEffect: - investmentWithCurrencyEffect?.toNumber(), - marketState: - dataProviderResponses[symbol]?.marketState ?? 'delayed', - name: symbolProfileMap[symbol].name, - netPerformance: netPerformance?.toNumber() ?? null, - netPerformancePercentage: - netPerformancePercentage?.toNumber() ?? null, - netPerformancePercentageWithCurrencyEffect: - netPerformancePercentageWithCurrencyEffectMap?.[ - dateRange - ]?.toNumber() ?? null, - netPerformanceWithCurrencyEffect: - netPerformanceWithCurrencyEffectMap?.[dateRange]?.toNumber() ?? - null, - quantity: quantity.toNumber(), - timeWeightedInvestment: timeWeightedInvestment?.toNumber(), - timeWeightedInvestmentWithCurrencyEffect: - timeWeightedInvestmentWithCurrencyEffect?.toNumber() - }; - } - ) - }; - } - public async getPerformance({ dateRange = 'max', filters,