diff --git a/CHANGELOG.md b/CHANGELOG.md index 08a7ad772..b7ad935a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Introduced fuzzy search for the holdings of the assistant - Introduced fuzzy search for the quick links of the assistant - Improved the search results of the assistant to only display categories with content - Enhanced the sitemap to dynamically compose public routes diff --git a/apps/api/src/app/portfolio/portfolio.controller.ts b/apps/api/src/app/portfolio/portfolio.controller.ts index 5b68f58e0..14d356799 100644 --- a/apps/api/src/app/portfolio/portfolio.controller.ts +++ b/apps/api/src/app/portfolio/portfolio.controller.ts @@ -412,19 +412,19 @@ export class PortfolioController { filterByAssetClasses, filterByDataSource, filterByHoldingType, - filterBySearchQuery, filterBySymbol, filterByTags }); - const { holdings } = await this.portfolioService.getDetails({ + const holdings = await this.portfolioService.getHoldings({ dateRange, filters, impersonationId, + query: filterBySearchQuery, userId: this.request.user.id }); - return { holdings: Object.values(holdings) }; + 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 6f788a832..ddc81fa4a 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'; @@ -92,6 +91,8 @@ import { PortfolioCalculator } from './calculator/portfolio-calculator'; import { PortfolioCalculatorFactory } from './calculator/portfolio-calculator.factory'; import { RulesService } from './rules.service'; +const Fuse = require('fuse.js'); + const asiaPacificMarkets = require('../../assets/countries/asia-pacific-markets.json'); const developedMarkets = require('../../assets/countries/developed-markets.json'); const emergingMarkets = require('../../assets/countries/emerging-markets.json'); @@ -269,6 +270,43 @@ export class PortfolioService { return dividends; } + public async getHoldings({ + dateRange, + filters, + impersonationId, + query, + userId + }: { + dateRange: DateRange; + filters?: Filter[]; + impersonationId: string; + query?: string; + userId: string; + }) { + userId = await this.getUserId(impersonationId, userId); + const { holdings: holdingsMap } = await this.getDetails({ + dateRange, + filters, + impersonationId, + userId + }); + + let holdings = Object.values(holdingsMap); + + if (query) { + const fuse = new Fuse(holdings, { + keys: ['isin', 'name', 'symbol'], + threshold: 0.3 + }); + + holdings = fuse.search(query).map(({ item }) => { + return item; + }); + } + + return holdings; + } + public async getInvestments({ dateRange, filters, @@ -977,155 +1015,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) { - positions = positions.filter(({ symbol }) => { - const enhancedSymbolProfile = symbolProfileMap[symbol]; - - return ( - enhancedSymbolProfile.isin?.toLowerCase().startsWith(searchQuery) || - enhancedSymbolProfile.name?.toLowerCase().startsWith(searchQuery) || - enhancedSymbolProfile.symbol?.toLowerCase().startsWith(searchQuery) - ); - }); - } - - 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,