diff --git a/CHANGELOG.md b/CHANGELOG.md index ff764bf4a8..7149a52583 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,10 +9,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Improved the label of the cash positions in the holdings charts and table +- Extended the tools to get the activities, the portfolio and the watchlist in the server of the Model Context Protocol (MCP) to include the data source (experimental) - Upgraded `@openrouter/ai-sdk-provider` from version `3.0.0` to `3.1.0` - Upgraded `ai` from version `7.0.37` to `7.0.114` - Upgraded `dotenv` from version `17.4.2` to `18.0.3` +### Fixed + +- Fixed the positive performance from the all time high in the watchlist +- Fixed the portfolio calculation for holdings with historical market prices between the chart dates + ## 3.77.0 - 2026-10-02 ### Added diff --git a/apps/api/src/app/endpoints/mcp/mcp.schemas.spec.ts b/apps/api/src/app/endpoints/mcp/mcp.schemas.spec.ts index dbc5bc698c..e4893c2a7b 100644 --- a/apps/api/src/app/endpoints/mcp/mcp.schemas.spec.ts +++ b/apps/api/src/app/endpoints/mcp/mcp.schemas.spec.ts @@ -1,3 +1,4 @@ +import { encodeDataSource } from '@ghostfolio/api/helper/data-source.helper'; import { COMMENT_MAXIMUM_LENGTH, DEFAULT_DATE_RANGE, @@ -18,6 +19,8 @@ import { } from './mcp.schemas'; import { createActivity } from './mcp.test-utils'; +const UNKNOWN_ENCODED_DATA_SOURCE = 'ffffffff'; + describe('GET_ACCOUNTS_PARAMETERS', () => { it('Refuses an empty symbol of the holding', () => { expect( @@ -34,6 +37,40 @@ describe('GET_ACCOUNTS_PARAMETERS', () => { }).success ).toBe(false); }); + + it('Accepts an encoded data source of the holding', () => { + expect( + GET_ACCOUNTS_PARAMETERS.safeParse({ + holding: { + dataSource: encodeDataSource(DataSource.YAHOO), + symbol: 'AAPL' + } + }).success + ).toBe(true); + }); + + it('Refuses an unknown encoded data source of the holding', () => { + expect( + GET_ACCOUNTS_PARAMETERS.safeParse({ + holding: { dataSource: UNKNOWN_ENCODED_DATA_SOURCE, symbol: 'AAPL' } + }).success + ).toBe(false); + }); + + it('Gives the valid data sources in the error of an unknown data source of the holding', () => { + for (const dataSource of ['yahoo', 1]) { + const { error } = GET_ACCOUNTS_PARAMETERS.safeParse({ + holding: { dataSource, symbol: 'AAPL' } + }); + + expect(error.issues).toEqual([ + expect.objectContaining({ + message: expect.stringContaining(`|${DataSource.YAHOO}`), + path: ['holding', 'dataSource'] + }) + ]); + } + }); }); describe('GET_ACTIVITIES_PARAMETERS', () => { @@ -52,6 +89,25 @@ describe('GET_ACTIVITIES_PARAMETERS', () => { }).success ).toBe(false); }); + + it('Accepts an encoded data source of the holding', () => { + expect( + GET_ACTIVITIES_PARAMETERS.safeParse({ + holding: { + dataSource: encodeDataSource(DataSource.YAHOO), + symbol: 'AAPL' + } + }).success + ).toBe(true); + }); + + it('Refuses an unknown encoded data source of the holding', () => { + expect( + GET_ACTIVITIES_PARAMETERS.safeParse({ + holding: { dataSource: UNKNOWN_ENCODED_DATA_SOURCE, symbol: 'AAPL' } + }).success + ).toBe(false); + }); }); describe('GET_PERFORMANCE_PARAMETERS', () => { @@ -86,6 +142,25 @@ describe('GET_PERFORMANCE_PARAMETERS', () => { }).success ).toBe(false); }); + + it('Accepts an encoded data source of the holding', () => { + expect( + GET_PERFORMANCE_PARAMETERS.safeParse({ + holding: { + dataSource: encodeDataSource(DataSource.YAHOO), + symbol: 'AAPL' + } + }).success + ).toBe(true); + }); + + it('Refuses an unknown encoded data source of the holding', () => { + expect( + GET_PERFORMANCE_PARAMETERS.safeParse({ + holding: { dataSource: UNKNOWN_ENCODED_DATA_SOURCE, symbol: 'AAPL' } + }).success + ).toBe(false); + }); }); describe('IMPORT_ACTIVITIES_PARAMETERS', () => { @@ -125,6 +200,20 @@ describe('IMPORT_ACTIVITIES_PARAMETERS', () => { expect(parse([createActivity({ currency: 'USD' })])).toBe(true); }); + it('Accepts an encoded data source', () => { + expect( + parse([ + createActivity({ dataSource: encodeDataSource(DataSource.YAHOO) }) + ]) + ).toBe(true); + }); + + it('Refuses an unknown encoded data source', () => { + expect( + parse([createActivity({ dataSource: UNKNOWN_ENCODED_DATA_SOURCE })]) + ).toBe(false); + }); + it('Refuses a date at or before the epoch', () => { expect(parse([createActivity({ date: '0000-01-01' })])).toBe(false); }); diff --git a/apps/api/src/app/endpoints/mcp/mcp.schemas.ts b/apps/api/src/app/endpoints/mcp/mcp.schemas.ts index ce66699e91..1b200e0119 100644 --- a/apps/api/src/app/endpoints/mcp/mcp.schemas.ts +++ b/apps/api/src/app/endpoints/mcp/mcp.schemas.ts @@ -1,4 +1,5 @@ import { DATE_RANGE_PATTERN } from '@ghostfolio/api/dtos/date-range-filter.dto'; +import { isValidEncodedDataSource } from '@ghostfolio/api/helper/data-source.helper'; import { COMMENT_MAXIMUM_LENGTH, DATE_RANGES, @@ -17,6 +18,20 @@ import { import { AssetClass, DataSource, Type as ActivityType } from '@prisma/client'; import { z } from 'zod'; +const DATA_SOURCE_PARAMETER_ERROR = `Invalid option: expected one of ${Object.values(DataSource).join('|')} or a data source as given by a tool`; + +const DATA_SOURCE_PARAMETER = z + .union( + [ + z.enum(DataSource), + z.string().refine(isValidEncodedDataSource, { + error: DATA_SOURCE_PARAMETER_ERROR + }) + ], + { error: DATA_SOURCE_PARAMETER_ERROR } + ) + .describe('The data source of the asset profile'); + const SYMBOL_PARAMETER = z .string() .trim() @@ -25,9 +40,7 @@ const SYMBOL_PARAMETER = z .describe('The symbol of the asset profile'); const HOLDING_PARAMETER = z.object({ - dataSource: z - .enum(DataSource) - .describe('The data source of the asset profile'), + dataSource: DATA_SOURCE_PARAMETER, symbol: SYMBOL_PARAMETER }); @@ -137,10 +150,7 @@ export const IMPORT_ACTIVITIES_PARAMETERS = z.object({ .describe( 'The currency of the fee and of the unit price, as an ISO 4217 code in upper case' ), - dataSource: z - .enum(DataSource) - .optional() - .describe('The data source of the asset profile'), + dataSource: DATA_SOURCE_PARAMETER.optional(), date: z .string() .refine(isValidDateAfter1970) diff --git a/apps/api/src/app/endpoints/mcp/mcp.service.spec.ts b/apps/api/src/app/endpoints/mcp/mcp.service.spec.ts index f4841896bd..a013341179 100644 --- a/apps/api/src/app/endpoints/mcp/mcp.service.spec.ts +++ b/apps/api/src/app/endpoints/mcp/mcp.service.spec.ts @@ -2,6 +2,7 @@ import { ImportValidationError } from '@ghostfolio/api/app/import/errors/import- import { ImportService } from '@ghostfolio/api/app/import/import.service'; import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service'; import { UserService } from '@ghostfolio/api/app/user/user.service'; +import { encodeDataSource } from '@ghostfolio/api/helper/data-source.helper'; import { ApiService } from '@ghostfolio/api/services/api/api.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { PortfolioTableService } from '@ghostfolio/api/services/portfolio-table/portfolio-table.service'; @@ -128,6 +129,22 @@ describe('McpService', () => { ); }); + it('Decodes an encoded data source', async () => { + configuration.ENABLE_FEATURE_SUBSCRIPTION = true; + + await mcpService.getAccounts({ + userId, + holding: { + dataSource: encodeDataSource(DataSource.YAHOO), + symbol: 'AAPL' + } + }); + + expect(apiService.buildFiltersFromQueryParams).toHaveBeenCalledWith( + expect.objectContaining({ filterByDataSource: DataSource.YAHOO }) + ); + }); + it('Keeps the data source if the subscription is not enabled', async () => { configuration.DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER = [DataSource.YAHOO]; configuration.ENABLE_FEATURE_SUBSCRIPTION = false; @@ -192,6 +209,21 @@ describe('McpService', () => { ); }); + it('Decodes an encoded data source', async () => { + configuration.ENABLE_FEATURE_SUBSCRIPTION = true; + + await getActivities({ + holding: { + dataSource: encodeDataSource(DataSource.YAHOO), + symbol: 'AAPL' + } + }); + + expect(apiService.buildFiltersFromQueryParams).toHaveBeenCalledWith( + expect.objectContaining({ filterByDataSource: DataSource.YAHOO }) + ); + }); + it('Keeps the data source if the subscription is not enabled', async () => { configuration.DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER = [DataSource.YAHOO]; configuration.ENABLE_FEATURE_SUBSCRIPTION = false; @@ -275,6 +307,23 @@ describe('McpService', () => { ); }); + it('Decodes an encoded data source', async () => { + configuration.ENABLE_FEATURE_SUBSCRIPTION = true; + + await mcpService.getPerformance({ + userId, + holding: { + dataSource: encodeDataSource(DataSource.YAHOO), + symbol: 'AAPL' + }, + range: 'max' + }); + + expect(apiService.buildFiltersFromQueryParams).toHaveBeenCalledWith( + expect.objectContaining({ filterByDataSource: DataSource.YAHOO }) + ); + }); + it('Keeps the data source if the subscription is not enabled', async () => { configuration.DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER = [DataSource.YAHOO]; configuration.ENABLE_FEATURE_SUBSCRIPTION = false; @@ -304,14 +353,15 @@ describe('McpService', () => { }); describe('getPortfolio', () => { - it('Gives the table of the holdings in the default language', async () => { + it('Gives the table of the holdings with the data source in the default language', async () => { expect(await mcpService.getPortfolio({ userId })).toEqual({ content: [{ text: '## Holdings', type: 'text' }] }); expect(portfolioTableService.getHoldingsTable).toHaveBeenCalledWith({ userId, - languageCode: DEFAULT_LANGUAGE_CODE + languageCode: DEFAULT_LANGUAGE_CODE, + withDataSource: true }); }); }); @@ -342,9 +392,6 @@ describe('McpService', () => { it('Gives the import-ready asset profiles available to the user', async () => { const user = setupUser([permissions.createActivity]); - configuration.DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER = [DataSource.YAHOO]; - configuration.ENABLE_FEATURE_SUBSCRIPTION = true; - jest.spyOn(symbolService, 'lookup').mockResolvedValue({ items: [ { @@ -383,12 +430,46 @@ describe('McpService', () => { assetClass: AssetClass.EQUITY, assetSubClass: AssetSubClass.STOCK, currency: 'USD', - dataSource: DataSource.GHOSTFOLIO, + dataSource: DataSource.YAHOO, + name: 'Apple Inc.', + symbol: 'AAPL' + } + ] + }); + }); + + it('Encodes the data source if the subscription is enabled', async () => { + setupUser([permissions.createActivity]); + + configuration.DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER = [DataSource.YAHOO]; + configuration.ENABLE_FEATURE_SUBSCRIPTION = true; + + jest.spyOn(symbolService, 'lookup').mockResolvedValue({ + items: [ + { + assetClass: AssetClass.EQUITY, + assetSubClass: AssetSubClass.STOCK, + currency: 'USD', + dataProviderInfo: { isPremium: false }, + dataSource: DataSource.YAHOO, name: 'Apple Inc.', symbol: 'AAPL' } ] }); + + const result = await mcpService.searchAssetProfiles({ + query: 'Apple', + userId + }); + + expect(JSON.parse(result.content[0].text)).toEqual({ + assetProfiles: [ + expect.objectContaining({ + dataSource: encodeDataSource(DataSource.YAHOO) + }) + ] + }); }); }); @@ -448,6 +529,27 @@ describe('McpService', () => { ); }); + it('Decodes an encoded data source', async () => { + setupUser([permissions.createActivity]); + + configuration.ENABLE_FEATURE_SUBSCRIPTION = true; + + await mcpService.importActivities({ + userId, + activities: [ + createActivity({ dataSource: encodeDataSource(DataSource.YAHOO) }) + ] + }); + + expect(importService.import).toHaveBeenCalledWith( + expect.objectContaining({ + activitiesDto: [ + expect.objectContaining({ dataSource: DataSource.YAHOO }) + ] + }) + ); + }); + it('Keeps the data source if the subscription is not enabled', async () => { setupUser([permissions.createActivity]); diff --git a/apps/api/src/app/endpoints/mcp/mcp.service.ts b/apps/api/src/app/endpoints/mcp/mcp.service.ts index 6a989dc856..70be380060 100644 --- a/apps/api/src/app/endpoints/mcp/mcp.service.ts +++ b/apps/api/src/app/endpoints/mcp/mcp.service.ts @@ -2,8 +2,8 @@ import { ImportService } from '@ghostfolio/api/app/import/import.service'; import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service'; import { UserService } from '@ghostfolio/api/app/user/user.service'; import { - getMaskedGhostfolioDataSource, - getUnmaskedGhostfolioDataSource + transformDataSourceInRequest, + transformDataSourceInResponse } from '@ghostfolio/api/helper/data-source.helper'; import { ApiService } from '@ghostfolio/api/services/api/api.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; @@ -121,7 +121,8 @@ export class McpService { public async getPortfolio({ userId }: { userId: string }) { const table = await this.portfolioTableService.getHoldingsTable({ userId, - languageCode: DEFAULT_LANGUAGE_CODE + languageCode: DEFAULT_LANGUAGE_CODE, + withDataSource: true }); return this.getTextResult(table); @@ -144,13 +145,11 @@ export class McpService { permission: permissions.createActivity }); - const ghostfolioDataSources = this.getGhostfolioDataSources(); - const activitiesDto = activities.map((activity) => { return { ...activity, - dataSource: getUnmaskedGhostfolioDataSource({ - ghostfolioDataSources, + dataSource: transformDataSourceInRequest({ + configurationService: this.configurationService, dataSource: activity.dataSource }) }; @@ -188,8 +187,6 @@ export class McpService { const { items } = await this.symbolService.lookup({ query, user }); - const ghostfolioDataSources = this.getGhostfolioDataSources(); - const assetProfiles = items.flatMap( ({ assetClass, @@ -211,9 +208,9 @@ export class McpService { currency, name, symbol, - dataSource: getMaskedGhostfolioDataSource({ + dataSource: transformDataSourceInResponse({ dataSource, - ghostfolioDataSources + configurationService: this.configurationService }) } ]; @@ -223,19 +220,13 @@ export class McpService { return this.getTextResult(JSON.stringify({ assetProfiles }, null, 2)); } - private getGhostfolioDataSources() { - return this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') - ? this.configurationService.get('DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER') - : []; - } - private getHoldingFilterParameters({ holding }: Pick, 'holding'>) { return { - filterByDataSource: getUnmaskedGhostfolioDataSource({ - dataSource: holding?.dataSource, - ghostfolioDataSources: this.getGhostfolioDataSources() + filterByDataSource: transformDataSourceInRequest({ + configurationService: this.configurationService, + dataSource: holding?.dataSource }), filterBySymbol: holding?.symbol }; diff --git a/apps/api/src/app/endpoints/watchlist/watchlist.service.ts b/apps/api/src/app/endpoints/watchlist/watchlist.service.ts index c69a31df82..925f49cd6b 100644 --- a/apps/api/src/app/endpoints/watchlist/watchlist.service.ts +++ b/apps/api/src/app/endpoints/watchlist/watchlist.service.ts @@ -140,8 +140,11 @@ export class WatchlistService { name: assetProfile?.name, performances: { allTimeHigh: { - performancePercent, - date: allTimeHigh?.date + date: allTimeHigh?.date, + performancePercent: + this.benchmarkService.getPerformancePercentFromAllTimeHigh( + performancePercent + ) } }, trend50d: trends.trend50d, diff --git a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts index e03b530258..eb1e6f9fac 100644 --- a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts @@ -791,6 +791,16 @@ export abstract class PortfolioCalculator { activitiesByDate[activity.date].push(activity); } + // Carry forward the market prices of all dates, not only of the chart + // dates, as sparse market data (e.g. MANUAL) can be between two chart dates + const marketPriceDates = Object.keys(marketSymbolMap) + .filter((date) => { + return !!marketSymbolMap[date][assetProfileIdentifier]; + }) + .sort(); + + let indexOfMarketPriceDate = 0; + for (const dateString of chartDates) { if (dateString < startDateString) { continue; @@ -798,6 +808,18 @@ export abstract class PortfolioCalculator { break; } + while ( + indexOfMarketPriceDate < marketPriceDates.length && + marketPriceDates[indexOfMarketPriceDate] <= dateString + ) { + lastMarketPrice = + marketSymbolMap[marketPriceDates[indexOfMarketPriceDate]][ + assetProfileIdentifier + ]; + + indexOfMarketPriceDate += 1; + } + const activitiesOfDate = activitiesByDate[dateString]; if (!lastMarketPrice && activitiesOfDate?.length > 0) { @@ -808,13 +830,8 @@ export abstract class PortfolioCalculator { } } - const marketPrice = marketSymbolMap[dateString]?.[assetProfileIdentifier]; - const unitPrice = - marketPrice ?? - lastMarketPrice ?? - lastActivityUnitPrice ?? - unitPriceAtEndDate; + lastMarketPrice ?? lastActivityUnitPrice ?? unitPriceAtEndDate; if (activitiesOfDate?.length > 0) { for (const activity of activitiesOfDate) { @@ -832,10 +849,6 @@ export abstract class PortfolioCalculator { unitPriceFromMarketData: unitPrice }); } - - if (marketPrice) { - lastMarketPrice = marketPrice; - } } // Sort the activities so that the start and end placeholder activities diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-manual-buy-with-market-price-between-chart-dates.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-manual-buy-with-market-price-between-chart-dates.spec.ts new file mode 100644 index 0000000000..989409e77b --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-manual-buy-with-market-price-between-chart-dates.spec.ts @@ -0,0 +1,170 @@ +import { + activityDummyData, + assetProfileDummyData, + userDummyData +} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; +import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; +import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; +import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; +import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; +import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +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 { Activity } from '@ghostfolio/common/interfaces'; +import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; + +import { Big } from 'big.js'; + +jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { + return { + CurrentRateService: jest.fn().mockImplementation(() => { + return CurrentRateServiceMock; + }) + }; +}); + +jest.mock( + '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service', + () => { + return { + PortfolioSnapshotService: jest.fn().mockImplementation(() => { + return PortfolioSnapshotServiceMock; + }) + }; + } +); + +jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { + return { + RedisCacheService: jest.fn().mockImplementation(() => { + return RedisCacheServiceMock; + }) + }; +}); + +describe('PortfolioCalculator', () => { + let configurationService: ConfigurationService; + let currentRateService: CurrentRateService; + let exchangeRateDataService: ExchangeRateDataService; + let portfolioCalculatorFactory: PortfolioCalculatorFactory; + let portfolioSnapshotService: PortfolioSnapshotService; + let redisCacheService: RedisCacheService; + + beforeEach(() => { + PortfolioSnapshotServiceMock.reset(); + RedisCacheServiceMock.reset(); + + configurationService = new ConfigurationService(); + + currentRateService = new CurrentRateService(null, null, null); + + exchangeRateDataService = new ExchangeRateDataService( + null, + null, + null, + null + ); + + portfolioSnapshotService = new PortfolioSnapshotService(null, null); + + redisCacheService = new RedisCacheService(null, null); + + portfolioCalculatorFactory = new PortfolioCalculatorFactory( + configurationService, + currentRateService, + exchangeRateDataService, + portfolioSnapshotService, + redisCacheService + ); + }); + + describe('get current positions', () => { + it('with MANUAL buy and market price between two chart dates', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2024-01-31').getTime()); + + const activities: Activity[] = [ + { + ...activityDummyData, + assetProfile: { + ...assetProfileDummyData, + currency: 'USD', + dataSource: 'MANUAL', + name: 'Private Investment', + symbol: '6c0c5cee-0208-4975-b473-03baf2518497' + }, + date: parseDate('2021-01-04'), + feeInAssetProfileCurrency: 0, + feeInBaseCurrency: 0, + quantity: 10, + type: 'BUY', + unitPriceInAssetProfileCurrency: 50 + } + ]; + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.ROAI, + currency: 'USD', + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); + + const historicalDataByDate = Object.fromEntries( + portfolioSnapshot.historicalData.map((historicalDataItem) => { + return [historicalDataItem.date, historicalDataItem]; + }) + ); + + /** + * The only historical market prices are on 2023-06-14 and 2023-06-15, + * which are not chart dates (every third day from 2021-01-03) + */ + expect(historicalDataByDate['2023-06-14']).toBeUndefined(); + expect(historicalDataByDate['2023-06-15']).toBeUndefined(); + + /** + * The chart dates before the market prices use the unit price of the + * activity: 50 + */ + expect(historicalDataByDate['2023-06-13']).toMatchObject({ + netPerformance: 0, // 10 * (50 - 50) = 0 + totalInvestment: 500, + value: 500 // 10 * 50 = 500 + }); + + /** + * The chart dates after the market prices carry the latest one forward + * (100), not the first one (80) + */ + expect(historicalDataByDate['2023-06-16']).toMatchObject({ + netPerformance: 500, // 10 * (100 - 50) = 500 + totalInvestment: 500, + value: 1000 // 10 * 100 = 1000 + }); + + expect(historicalDataByDate['2024-01-30']).toMatchObject({ + netPerformance: 500, // 10 * (100 - 50) = 500 + totalInvestment: 500, + value: 1000 // 10 * 100 = 1000 + }); + + /** + * The market price is unchanged since 2023-06-15, hence there is no + * performance today + */ + expect(portfolioSnapshot.positions[0]).toMatchObject({ + netPerformancePercentageWithCurrencyEffectMap: { + '1d': new Big(0) + }, + netPerformanceWithCurrencyEffectMap: { + '1d': new Big(0), + max: new Big(500) // 10 * (100 - 50) = 500 + } + }); + }); + }); +}); diff --git a/apps/api/src/app/portfolio/current-rate.service.mock.ts b/apps/api/src/app/portfolio/current-rate.service.mock.ts index 7fcacad610..18739ddab5 100644 --- a/apps/api/src/app/portfolio/current-rate.service.mock.ts +++ b/apps/api/src/app/portfolio/current-rate.service.mock.ts @@ -30,6 +30,20 @@ function mockGetValue({ return { marketPrice: 0 }; + case '6c0c5cee-0208-4975-b473-03baf2518497': + // Sparse market data with the quote of today, which falls back to the + // latest market price + if (isSameDay(parseDate('2023-06-14'), date)) { + return { marketPrice: 80 }; + } else if ( + isSameDay(parseDate('2023-06-15'), date) || + isSameDay(parseDate('2024-01-31'), date) + ) { + return { marketPrice: 100 }; + } + + return { marketPrice: 0 }; + case 'BALN.SW': if (isSameDay(parseDate('2021-11-12'), date)) { return { marketPrice: 146 }; diff --git a/apps/api/src/helper/data-source.helper.spec.ts b/apps/api/src/helper/data-source.helper.spec.ts new file mode 100644 index 0000000000..02d979441e --- /dev/null +++ b/apps/api/src/helper/data-source.helper.spec.ts @@ -0,0 +1,123 @@ +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; + +import { DataSource } from '@prisma/client'; + +import { + encodeDataSource, + isValidEncodedDataSource, + transformDataSourceInRequest, + transformDataSourceInResponse +} from './data-source.helper'; + +function createConfigurationService({ + isSubscriptionEnabled +}: { + isSubscriptionEnabled: boolean; +}) { + const configuration: Record = { + DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER: [DataSource.YAHOO], + ENABLE_FEATURE_SUBSCRIPTION: isSubscriptionEnabled + }; + + return { + get: jest.fn((key: string) => { + return configuration[key]; + }) + } as unknown as ConfigurationService; +} + +describe('DataSourceHelper', () => { + describe('isValidEncodedDataSource', () => { + it('Accepts an encoded data source', () => { + expect(isValidEncodedDataSource(encodeDataSource(DataSource.YAHOO))).toBe( + true + ); + }); + + it('Refuses a data source which is not encoded', () => { + expect(isValidEncodedDataSource(DataSource.YAHOO)).toBe(false); + }); + + it('Refuses an unknown encoded data source', () => { + expect(isValidEncodedDataSource('ffffffff')).toBe(false); + }); + }); + + describe('transformDataSourceInRequest', () => { + const configurationService = createConfigurationService({ + isSubscriptionEnabled: true + }); + + it('Decodes an encoded data source', () => { + expect( + transformDataSourceInRequest({ + configurationService, + dataSource: encodeDataSource(DataSource.COINGECKO) + }) + ).toBe(DataSource.COINGECKO); + }); + + it('Resolves the mask of the data source of the Ghostfolio data provider', () => { + expect( + transformDataSourceInRequest({ + configurationService, + dataSource: DataSource.GHOSTFOLIO + }) + ).toBe(DataSource.YAHOO); + }); + + it('Keeps a data source which is not encoded', () => { + expect( + transformDataSourceInRequest({ + configurationService, + dataSource: DataSource.COINGECKO + }) + ).toBe(DataSource.COINGECKO); + }); + + it('Keeps an absent data source', () => { + expect( + transformDataSourceInRequest({ + configurationService, + dataSource: undefined + }) + ).toBeUndefined(); + }); + }); + + describe('transformDataSourceInResponse', () => { + it('Encodes the data source if the subscription is enabled', () => { + expect( + transformDataSourceInResponse({ + configurationService: createConfigurationService({ + isSubscriptionEnabled: true + }), + dataSource: DataSource.YAHOO + }) + ).toBe(encodeDataSource(DataSource.YAHOO)); + }); + + it('Keeps the data sources GHOSTFOLIO and MANUAL if the subscription is enabled', () => { + const configurationService = createConfigurationService({ + isSubscriptionEnabled: true + }); + + for (const dataSource of [DataSource.GHOSTFOLIO, DataSource.MANUAL]) { + expect( + transformDataSourceInResponse({ configurationService, dataSource }) + ).toBe(dataSource); + } + }); + + it('Keeps the data source if the subscription is not enabled', () => { + expect( + transformDataSourceInResponse({ + configurationService: createConfigurationService({ + isSubscriptionEnabled: false + }), + dataSource: DataSource.YAHOO + }) + ).toBe(DataSource.YAHOO); + }); + }); +}); diff --git a/apps/api/src/helper/data-source.helper.ts b/apps/api/src/helper/data-source.helper.ts index fff8eaac1d..79802883e8 100644 --- a/apps/api/src/helper/data-source.helper.ts +++ b/apps/api/src/helper/data-source.helper.ts @@ -1,6 +1,13 @@ +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; + import { DataSource } from '@prisma/client'; import { createHash } from 'node:crypto'; +const DATA_SOURCES_WITHOUT_ENCODING: DataSource[] = [ + DataSource.GHOSTFOLIO, + DataSource.MANUAL +]; + const encodedDataSourceByDataSource = new Map( Object.values(DataSource).map((dataSource) => { return [dataSource, hashDataSource(dataSource)]; @@ -30,6 +37,16 @@ function deprecatedHashDataSource(dataSource: DataSource) { return Buffer.from(dataSource, 'utf-8').toString('hex'); } +function getGhostfolioDataSources({ + configurationService +}: { + configurationService: ConfigurationService; +}) { + return configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') + ? configurationService.get('DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER') + : []; +} + function hashDataSource(dataSource: DataSource) { return createHash('sha256').update(dataSource).digest('hex').slice(0, 8); } @@ -77,3 +94,51 @@ export function getUnmaskedGhostfolioDataSource({ ? (ghostfolioDataSources[0] as DataSource) : dataSource; } + +export function isDataSourceEncodedInResponse(dataSource: DataSource) { + return !DATA_SOURCES_WITHOUT_ENCODING.includes(dataSource); +} + +export function isValidEncodedDataSource(encodedDataSource: string) { + return dataSourceByEncodedDataSource.has(encodedDataSource); +} + +/** + * Gives the data source of a request without a transformation: an encoded data + * source is decoded, and the mask of the Ghostfolio data provider is resolved + * (see the TransformDataSourceInRequestInterceptor) + */ +export function transformDataSourceInRequest({ + configurationService, + dataSource +}: { + configurationService: ConfigurationService; + dataSource?: string; +}) { + if (Object.hasOwn(DataSource, dataSource)) { + return getUnmaskedGhostfolioDataSource({ + dataSource: dataSource as DataSource, + ghostfolioDataSources: getGhostfolioDataSources({ configurationService }) + }); + } + + return decodeDataSource(dataSource) as DataSource; +} + +/** + * Gives the data source as a response gives it to a user who is not an admin: + * encoded if the subscription is enabled, except the data sources GHOSTFOLIO + * and MANUAL (see the TransformDataSourceInResponseInterceptor) + */ +export function transformDataSourceInResponse({ + configurationService, + dataSource +}: { + configurationService: ConfigurationService; + dataSource: DataSource; +}) { + return configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && + isDataSourceEncodedInResponse(dataSource) + ? encodeDataSource(dataSource) + : dataSource; +} diff --git a/apps/api/src/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor.ts b/apps/api/src/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor.ts index 339b91a2f7..968159cbac 100644 --- a/apps/api/src/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor.ts +++ b/apps/api/src/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor.ts @@ -1,6 +1,7 @@ import { encodeDataSource, - getMaskedGhostfolioDataSource + getMaskedGhostfolioDataSource, + isDataSourceEncodedInResponse } from '@ghostfolio/api/helper/data-source.helper'; import { redactPaths } from '@ghostfolio/api/helper/object.helper'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; @@ -30,7 +31,7 @@ export class TransformDataSourceInResponseInterceptor< if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { this.encodedDataSourceMap = Object.keys(DataSource).reduce( (encodedDataSourceMap, dataSource) => { - if (!['GHOSTFOLIO', 'MANUAL'].includes(dataSource)) { + if (isDataSourceEncodedInResponse(DataSource[dataSource])) { encodedDataSourceMap[dataSource] = encodeDataSource( DataSource[dataSource] ); diff --git a/apps/api/src/services/benchmark/benchmark.service.spec.ts b/apps/api/src/services/benchmark/benchmark.service.spec.ts index 0e7119b045..27124e89e4 100644 --- a/apps/api/src/services/benchmark/benchmark.service.spec.ts +++ b/apps/api/src/services/benchmark/benchmark.service.spec.ts @@ -24,4 +24,14 @@ describe('BenchmarkService', () => { ); expect(benchmarkService.getMarketCondition(-0.2)).toEqual('BEAR_MARKET'); }); + + it('getPerformancePercentFromAllTimeHigh', async () => { + expect(benchmarkService.getPerformancePercentFromAllTimeHigh(0.1)).toEqual( + 0 + ); + expect(benchmarkService.getPerformancePercentFromAllTimeHigh(0)).toEqual(0); + expect(benchmarkService.getPerformancePercentFromAllTimeHigh(-0.1)).toEqual( + -0.1 + ); + }); }); diff --git a/apps/api/src/services/benchmark/benchmark.service.ts b/apps/api/src/services/benchmark/benchmark.service.ts index 4ea27b2f2e..3c39848401 100644 --- a/apps/api/src/services/benchmark/benchmark.service.ts +++ b/apps/api/src/services/benchmark/benchmark.service.ts @@ -216,6 +216,12 @@ export class BenchmarkService { } } + public getPerformancePercentFromAllTimeHigh(performancePercent: number) { + const isAtAllTimeHigh = performancePercent >= 0; + + return isAtAllTimeHigh ? 0 : performancePercent; + } + public async isBenchmark(symbolProfileId: string): Promise { const benchmarks = await this.getBenchmarksProperty(); @@ -288,10 +294,9 @@ export class BenchmarkService { performances: { allTimeHigh: { date: allTimeHigh?.date, - performancePercent: - performancePercentFromAllTimeHigh >= 0 - ? 0 - : performancePercentFromAllTimeHigh + performancePercent: this.getPerformancePercentFromAllTimeHigh( + performancePercentFromAllTimeHigh + ) } }, symbol: benchmarkAssetProfiles[index].symbol, diff --git a/apps/api/src/services/portfolio-table/interfaces/data-source-table-context.interface.ts b/apps/api/src/services/portfolio-table/interfaces/data-source-table-context.interface.ts new file mode 100644 index 0000000000..927f32cda0 --- /dev/null +++ b/apps/api/src/services/portfolio-table/interfaces/data-source-table-context.interface.ts @@ -0,0 +1,5 @@ +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; + +export interface DataSourceTableContext { + configurationService: ConfigurationService; +} diff --git a/apps/api/src/services/portfolio-table/interfaces/holdings-table-context.interface.ts b/apps/api/src/services/portfolio-table/interfaces/holdings-table-context.interface.ts index 4364b492e7..771cd10404 100644 --- a/apps/api/src/services/portfolio-table/interfaces/holdings-table-context.interface.ts +++ b/apps/api/src/services/portfolio-table/interfaces/holdings-table-context.interface.ts @@ -1,6 +1,8 @@ import { AssetClass, AssetSubClass } from '@prisma/client'; -export interface HoldingsTableContext { +import { DataSourceTableContext } from './data-source-table-context.interface'; + +export interface HoldingsTableContext extends DataSourceTableContext { assetClassTranslations: Record; assetSubClassTranslations: Record; } diff --git a/apps/api/src/services/portfolio-table/portfolio-table.module.ts b/apps/api/src/services/portfolio-table/portfolio-table.module.ts index 0995dc9980..01d0ebc64d 100644 --- a/apps/api/src/services/portfolio-table/portfolio-table.module.ts +++ b/apps/api/src/services/portfolio-table/portfolio-table.module.ts @@ -1,6 +1,7 @@ import { ActivitiesModule } from '@ghostfolio/api/app/activities/activities.module'; import { WatchlistModule } from '@ghostfolio/api/app/endpoints/watchlist/watchlist.module'; import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module'; +import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { I18nModule } from '@ghostfolio/api/services/i18n/i18n.module'; import { Module } from '@nestjs/common'; @@ -9,7 +10,13 @@ import { PortfolioTableService } from './portfolio-table.service'; @Module({ exports: [PortfolioTableService], - imports: [ActivitiesModule, I18nModule, PortfolioModule, WatchlistModule], + imports: [ + ActivitiesModule, + ConfigurationModule, + I18nModule, + PortfolioModule, + WatchlistModule + ], providers: [PortfolioTableService] }) export class PortfolioTableModule {} diff --git a/apps/api/src/services/portfolio-table/portfolio-table.service.spec.ts b/apps/api/src/services/portfolio-table/portfolio-table.service.spec.ts index ce92747c40..3cfb9fe856 100644 --- a/apps/api/src/services/portfolio-table/portfolio-table.service.spec.ts +++ b/apps/api/src/services/portfolio-table/portfolio-table.service.spec.ts @@ -1,12 +1,16 @@ +import type { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service'; import type { WatchlistService } from '@ghostfolio/api/app/endpoints/watchlist/watchlist.service'; import type { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; +import { encodeDataSource } from '@ghostfolio/api/helper/data-source.helper'; import type { TableParameters } from '@ghostfolio/api/helper/interfaces/table-parameters.interface'; +import type { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import type { I18nService } from '@ghostfolio/api/services/i18n/i18n.service'; import { DEFAULT_LANGUAGE_CODE, TAG_ID_EXCLUDE_FROM_ANALYSIS } from '@ghostfolio/common/config'; import { + Activity, HistoricalDataItem, PortfolioPerformanceResponse, PortfolioPosition, @@ -60,6 +64,8 @@ jest.mock('@ghostfolio/api/helper/markdown-table.helper', () => { }; }); +const SUBSCRIPTION_CONFIGURATION = { ENABLE_FEATURE_SUBSCRIPTION: true }; + function createAccount({ id = 'account-a-id', isExcluded = false, @@ -82,15 +88,39 @@ function createAccount({ } as unknown as AccountWithValue; } +function createActivity({ + dataSource = DataSource.YAHOO, + symbol = 'AAPL' +}: { + dataSource?: DataSource; + symbol?: string; +} = {}) { + return { + account: { name: 'Account A' }, + assetProfile: { + dataSource, + symbol, + currency: 'CHF', + name: `Name of ${symbol}` + }, + currency: 'CHF', + date: new Date('2024-01-01'), + type: 'BUY', + unitPrice: 100 + } as unknown as Activity; +} + function createHolding({ allocationInPercentage = 0.75, assetClass = AssetClass.EQUITY, assetSubClass = AssetSubClass.STOCK, + dataSource = DataSource.YAHOO, symbol = 'AAPL' }: { allocationInPercentage?: number; assetClass?: AssetClass; assetSubClass?: AssetSubClass; + dataSource?: DataSource; symbol?: string; } = {}) { return { @@ -99,6 +129,7 @@ function createHolding({ assetProfile: { assetClass, assetSubClass, + dataSource, symbol, currency: 'CHF', name: `Name of ${symbol}` @@ -132,18 +163,20 @@ function createPerformance({ } function createWatchlistItem({ + dataSource = DataSource.YAHOO, name = 'Name of AAPL', performancePercent = -0.25, symbol = 'AAPL' }: { + dataSource?: DataSource; name?: string; performancePercent?: number; symbol?: string; } = {}): WatchlistResponse['watchlist'][number] { return { + dataSource, name, symbol, - dataSource: DataSource.YAHOO, marketCondition: 'BEAR_MARKET', performances: { allTimeHigh: { @@ -158,17 +191,33 @@ function createWatchlistItem({ function createPortfolioTableService({ accounts = [], + activities = [], chart = [{ date: '2024-01-01' }], + configuration = { ENABLE_FEATURE_SUBSCRIPTION: false }, holdings = [], performance = createPerformance(), watchlist = [] }: { accounts?: AccountWithValue[]; + activities?: Activity[]; chart?: HistoricalDataItem[]; + configuration?: Record; holdings?: PortfolioPosition[]; performance?: PortfolioPerformanceResponse['performance']; watchlist?: WatchlistResponse['watchlist']; } = {}) { + const activitiesService = { + getActivities: jest + .fn() + .mockResolvedValue({ activities, count: activities.length }) + } as unknown as ActivitiesService; + + const configurationService = { + get: jest.fn((key: string) => { + return configuration[key]; + }) + } as unknown as ConfigurationService; + // The mock gives the identifier of the translation, so that a test can tell // the translation of the asset class from that of the asset sub class const i18nService = { @@ -188,7 +237,8 @@ function createPortfolioTableService({ } as unknown as WatchlistService; return new PortfolioTableService( - null, + activitiesService, + configurationService, i18nService, portfolioService, watchlistService @@ -220,6 +270,7 @@ describe('PortfolioTableService', () => { 'Type', 'Name', 'Symbol', + 'Data Source', 'Currency', 'Unit Price', 'Account' @@ -232,6 +283,7 @@ describe('PortfolioTableService', () => { expect(PortfolioTableService.getHoldingsTableColumnNames()).toEqual([ 'Name', 'Symbol', + 'Data Source', 'Currency', 'Asset Class', 'Asset Sub Class', @@ -257,6 +309,7 @@ describe('PortfolioTableService', () => { expect(PortfolioTableService.getWatchlistTableColumnNames()).toEqual([ 'Name', 'Symbol', + 'Data Source', 'Trend 50 Days', 'Trend 200 Days', 'Date of Last All Time High', @@ -327,14 +380,118 @@ describe('PortfolioTableService', () => { expect(result).toContain('No accounts found.'); }); }); + + describe('getActivitiesTable', () => { + it('gives the data source, encoded if the subscription is enabled', async () => { + const result = await createPortfolioTableService({ + activities: [ + createActivity({ dataSource: DataSource.YAHOO, symbol: 'AAPL' }), + createActivity({ + dataSource: DataSource.MANUAL, + symbol: 'GF_GOLD' + }) + ], + configuration: SUBSCRIPTION_CONFIGURATION + }).getActivitiesTable({ + take: 50, + userCurrency: 'CHF', + userId: 'user-id' + }); + + const [rowOfAapl, rowOfGold] = result.split('\n').filter((line) => { + return line.startsWith('| 2024-01-01'); + }); + + expect(rowOfAapl).toContain(`| ${encodeDataSource(DataSource.YAHOO)} |`); + expect(rowOfGold).toContain(`| ${DataSource.MANUAL} |`); + }); + }); + describe('getHoldingsTable', () => { - function getHoldingsTable(holdings: PortfolioPosition[]) { - return createPortfolioTableService({ holdings }).getHoldingsTable({ + function getHoldingsTable( + holdings: PortfolioPosition[], + { + configuration, + withDataSource + }: { + configuration?: Record; + withDataSource?: boolean; + } = {} + ) { + return createPortfolioTableService({ + configuration, + holdings + }).getHoldingsTable({ + withDataSource, languageCode: DEFAULT_LANGUAGE_CODE, userId: 'user-id' }); } + it('gives no data source by default', async () => { + const result = await getHoldingsTable([createHolding()], { + configuration: SUBSCRIPTION_CONFIGURATION + }); + + expect(result).not.toContain('Data Source'); + expect(result).not.toContain(encodeDataSource(DataSource.YAHOO)); + }); + + it('gives the data source, encoded if the subscription is enabled', async () => { + const result = await getHoldingsTable( + [ + createHolding({ dataSource: DataSource.YAHOO, symbol: 'AAPL' }), + createHolding({ + allocationInPercentage: 0.25, + dataSource: DataSource.MANUAL, + symbol: 'GF_GOLD' + }) + ], + { configuration: SUBSCRIPTION_CONFIGURATION, withDataSource: true } + ); + + const [rowOfAapl, rowOfGold] = result.split('\n').filter((line) => { + return line.startsWith('| Name of'); + }); + + expect(rowOfAapl).toContain(`| ${encodeDataSource(DataSource.YAHOO)} |`); + expect(rowOfGold).toContain(`| ${DataSource.MANUAL} |`); + }); + + it('gives the data source unencoded if the subscription is disabled', async () => { + const result = await getHoldingsTable([createHolding()], { + configuration: { ENABLE_FEATURE_SUBSCRIPTION: false }, + withDataSource: true + }); + + const [row] = result.split('\n').filter((line) => { + return line.startsWith('| Name of AAPL'); + }); + + expect(row).toContain(`| ${DataSource.YAHOO} |`); + }); + + it('gives no data source of a cash position', async () => { + const result = await getHoldingsTable( + [ + createHolding({ + assetClass: AssetClass.LIQUIDITY, + assetSubClass: AssetSubClass.CASH, + dataSource: DataSource.YAHOO, + symbol: 'USD' + }) + ], + { configuration: SUBSCRIPTION_CONFIGURATION, withDataSource: true } + ); + + const [row] = result.split('\n').filter((line) => { + return line.startsWith('| Name of USD'); + }); + + expect(row).toContain('| USD | | CHF |'); + expect(row).not.toContain(encodeDataSource(DataSource.YAHOO)); + }); + it('gives the translation of the asset class and of the asset sub class', async () => { const result = await getHoldingsTable([createHolding()]); @@ -417,12 +574,39 @@ describe('PortfolioTableService', () => { }); describe('getWatchlistTable', () => { - function getWatchlistTable(watchlist: WatchlistResponse['watchlist']) { - return createPortfolioTableService({ watchlist }).getWatchlistTable({ + function getWatchlistTable( + watchlist: WatchlistResponse['watchlist'], + configuration?: Record + ) { + return createPortfolioTableService({ + configuration, + watchlist + }).getWatchlistTable({ userId: 'user-id' }); } + it('gives the data source, encoded if the subscription is enabled', async () => { + const result = await getWatchlistTable( + [ + createWatchlistItem(), + createWatchlistItem({ + dataSource: DataSource.MANUAL, + name: 'Name of GF_GOLD', + symbol: 'GF_GOLD' + }) + ], + SUBSCRIPTION_CONFIGURATION + ); + + const [rowOfAapl, rowOfGold] = result.split('\n').filter((line) => { + return line.startsWith('| Name of'); + }); + + expect(rowOfAapl).toContain(`| ${encodeDataSource(DataSource.YAHOO)} |`); + expect(rowOfGold).toContain(`| ${DataSource.MANUAL} |`); + }); + it('gives the date and the change of the all time high', async () => { const result = await getWatchlistTable([createWatchlistItem()]); diff --git a/apps/api/src/services/portfolio-table/portfolio-table.service.ts b/apps/api/src/services/portfolio-table/portfolio-table.service.ts index a9c4c36121..9e63d133b5 100644 --- a/apps/api/src/services/portfolio-table/portfolio-table.service.ts +++ b/apps/api/src/services/portfolio-table/portfolio-table.service.ts @@ -1,10 +1,16 @@ import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service'; import { WatchlistService } from '@ghostfolio/api/app/endpoints/watchlist/watchlist.service'; import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; +import { transformDataSourceInResponse } from '@ghostfolio/api/helper/data-source.helper'; import { TableColumnDefinition } from '@ghostfolio/api/helper/interfaces/table-column-definition.interface'; import { getMarkdownTable } from '@ghostfolio/api/helper/markdown-table.helper'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service'; -import { DATE_FORMAT, isAccountExcluded } from '@ghostfolio/common/helper'; +import { + DATE_FORMAT, + isAccountExcluded, + isCashPosition +} from '@ghostfolio/common/helper'; import { Activity, Filter, @@ -17,12 +23,31 @@ import { Injectable } from '@nestjs/common'; import { AssetClass, AssetSubClass, + DataSource, Type as ActivityType } from '@prisma/client'; import { format } from 'date-fns'; +import { DataSourceTableContext } from './interfaces/data-source-table-context.interface'; import { HoldingsTableColumnDefinition } from './types/holdings-table-column-definition.type'; +const DATA_SOURCE_COLUMN_NAME = 'Data Source'; + +function getDataSourceColumnDefinition( + getDataSource: (row: T) => DataSource | undefined +): TableColumnDefinition { + return { + getValue: (row, { configurationService }) => { + const dataSource = getDataSource(row); + + return dataSource + ? transformDataSourceInResponse({ configurationService, dataSource }) + : ''; + }, + name: DATA_SOURCE_COLUMN_NAME + }; +} + function getPercentage(value: number) { return `${(value * 100).toFixed(3)}%`; } @@ -83,52 +108,57 @@ export class PortfolioTableService { } ]; - private static readonly ACTIVITIES_TABLE_COLUMN_DEFINITIONS: TableColumnDefinition[] = - [ - { - getValue: ({ date }) => { - return format(date, DATE_FORMAT); - }, - name: 'Date' + private static readonly ACTIVITIES_TABLE_COLUMN_DEFINITIONS: TableColumnDefinition< + Activity, + DataSourceTableContext + >[] = [ + { + getValue: ({ date }) => { + return format(date, DATE_FORMAT); }, - { - getValue: ({ type }) => { - return type; - }, - name: 'Type' + name: 'Date' + }, + { + getValue: ({ type }) => { + return type; }, - { - getValue: ({ assetProfile }) => { - return assetProfile.name ?? ''; - }, - name: 'Name' + name: 'Type' + }, + { + getValue: ({ assetProfile }) => { + return assetProfile.name ?? ''; }, - { - getValue: ({ assetProfile }) => { - return assetProfile.symbol; - }, - name: 'Symbol' + name: 'Name' + }, + { + getValue: ({ assetProfile }) => { + return assetProfile.symbol; }, - { - getValue: ({ assetProfile, currency }) => { - return currency ?? assetProfile.currency; - }, - name: 'Currency' + name: 'Symbol' + }, + getDataSourceColumnDefinition(({ assetProfile }) => { + return assetProfile.dataSource; + }), + { + getValue: ({ assetProfile, currency }) => { + return currency ?? assetProfile.currency; }, - { - align: 'right', - getValue: ({ unitPrice }) => { - return unitPrice.toString(); - }, - name: 'Unit Price' + name: 'Currency' + }, + { + align: 'right', + getValue: ({ unitPrice }) => { + return unitPrice.toString(); }, - { - getValue: ({ account }) => { - return account?.name ?? ''; - }, - name: 'Account' - } - ]; + name: 'Unit Price' + }, + { + getValue: ({ account }) => { + return account?.name ?? ''; + }, + name: 'Account' + } + ]; private static readonly HOLDINGS_TABLE_COLUMN_DEFINITIONS: HoldingsTableColumnDefinition[] = [ @@ -144,6 +174,11 @@ export class PortfolioTableService { }, name: 'Symbol' }, + getDataSourceColumnDefinition(({ assetProfile }) => { + return isCashPosition(assetProfile) + ? undefined + : assetProfile.dataSource; + }), { getValue: ({ assetProfile }) => { return assetProfile.currency; @@ -222,7 +257,8 @@ export class PortfolioTableService { ]; private static readonly WATCHLIST_TABLE_COLUMN_DEFINITIONS: TableColumnDefinition< - WatchlistResponse['watchlist'][number] + WatchlistResponse['watchlist'][number], + DataSourceTableContext >[] = [ { getValue: ({ name }) => { @@ -236,6 +272,9 @@ export class PortfolioTableService { }, name: 'Symbol' }, + getDataSourceColumnDefinition(({ dataSource }) => { + return dataSource; + }), { getValue: ({ trend50d }) => { return trend50d; @@ -273,6 +312,7 @@ export class PortfolioTableService { public constructor( private readonly activitiesService: ActivitiesService, + private readonly configurationService: ConfigurationService, private readonly i18nService: I18nService, private readonly portfolioService: PortfolioService, private readonly watchlistService: WatchlistService @@ -399,6 +439,7 @@ export class PortfolioTableService { await getMarkdownTable({ columnDefinitions: PortfolioTableService.ACTIVITIES_TABLE_COLUMN_DEFINITIONS, + context: { configurationService: this.configurationService }, rows: activities }) ); @@ -410,11 +451,13 @@ export class PortfolioTableService { public async getHoldingsTable({ filters, languageCode, - userId + userId, + withDataSource = false }: { filters?: Filter[]; languageCode: string; userId: string; + withDataSource?: boolean; }) { const { holdings } = await this.portfolioService.getDetails({ filters, @@ -442,8 +485,16 @@ export class PortfolioTableService { '', await getMarkdownTable({ columnDefinitions: - PortfolioTableService.HOLDINGS_TABLE_COLUMN_DEFINITIONS, - context: { assetClassTranslations, assetSubClassTranslations }, + PortfolioTableService.HOLDINGS_TABLE_COLUMN_DEFINITIONS.filter( + ({ name }) => { + return withDataSource || name !== DATA_SOURCE_COLUMN_NAME; + } + ), + context: { + assetClassTranslations, + assetSubClassTranslations, + configurationService: this.configurationService + }, rows: sortedHoldings }) ].join('\n'); @@ -491,6 +542,7 @@ export class PortfolioTableService { await getMarkdownTable({ columnDefinitions: PortfolioTableService.WATCHLIST_TABLE_COLUMN_DEFINITIONS, + context: { configurationService: this.configurationService }, rows: watchlist }) ); 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 cbdacaa3f1..915c6833ab 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 @@ -11,6 +11,7 @@ import { convertValuesToPercentagesOfTotal, getAssetProfileIdentifier, getCountryName, + getHoldingName, isCashPosition } from '@ghostfolio/common/helper'; import { @@ -533,7 +534,7 @@ export class GfAllocationsPageComponent implements OnInit { value, dataSource: position.assetProfile.dataSource, isClickable: canOpenHoldingDetail(position), - name: position.assetProfile.name ?? '' + name: getHoldingName(position.assetProfile) }; } } diff --git a/apps/client/src/app/pages/public/public-page.component.ts b/apps/client/src/app/pages/public/public-page.component.ts index 46606753e4..8fa8abde18 100644 --- a/apps/client/src/app/pages/public/public-page.component.ts +++ b/apps/client/src/app/pages/public/public-page.component.ts @@ -3,6 +3,7 @@ import { convertValuesToPercentagesOfTotal, getAssetProfileIdentifier, getCountryName, + getHoldingName, isCashPosition } from '@ghostfolio/common/helper'; import { @@ -260,7 +261,7 @@ export class GfPublicPageComponent implements OnInit { this.symbols[symbol] = { symbol, value, - name: position.assetProfile.name ?? symbol + name: getHoldingName(position.assetProfile) }; } } diff --git a/libs/common/src/lib/dtos/create-asset-profile.dto.ts b/libs/common/src/lib/dtos/create-asset-profile.dto.ts index b4fe347071..5d75b39a1d 100644 --- a/libs/common/src/lib/dtos/create-asset-profile.dto.ts +++ b/libs/common/src/lib/dtos/create-asset-profile.dto.ts @@ -10,17 +10,19 @@ import { DataSource } from '@ghostfolio/prisma/enums'; -import { Type } from 'class-transformer'; +import { Transform, TransformFnParams, Type } from 'class-transformer'; import { IsArray, IsBoolean, IsEnum, + IsNotEmpty, IsOptional, IsString, IsUrl, MaxLength, ValidateNested } from 'class-validator'; +import { isString } from 'lodash-es'; import { CountryDto } from './country.dto'; import { HoldingDto } from './holding.dto'; @@ -92,8 +94,12 @@ export class CreateAssetProfileDto { @ValidateNested({ each: true }) sectors?: Prisma.InputJsonArray; + @IsNotEmpty() @IsString() @MaxLength(SYMBOL_MAXIMUM_LENGTH) + @Transform(({ value }: TransformFnParams) => + isString(value) ? value.trim() : value + ) symbol: string; @IsOptional() diff --git a/libs/common/src/lib/dtos/create-order.dto.ts b/libs/common/src/lib/dtos/create-order.dto.ts index d473e8e7b6..d2374e9b1a 100644 --- a/libs/common/src/lib/dtos/create-order.dto.ts +++ b/libs/common/src/lib/dtos/create-order.dto.ts @@ -18,6 +18,7 @@ import { IsBoolean, IsEnum, IsISO8601, + IsNotEmpty, IsNumber, IsOptional, IsString, @@ -71,8 +72,12 @@ export class CreateOrderDto { @Min(0) quantity: number; + @IsNotEmpty() @IsString() @MaxLength(SYMBOL_MAXIMUM_LENGTH) + @Transform(({ value }: TransformFnParams) => + isString(value) ? value.trim() : value + ) symbol: string; @ArrayUnique() diff --git a/libs/common/src/lib/dtos/update-order.dto.ts b/libs/common/src/lib/dtos/update-order.dto.ts index f7dfb0df53..77e4a42286 100644 --- a/libs/common/src/lib/dtos/update-order.dto.ts +++ b/libs/common/src/lib/dtos/update-order.dto.ts @@ -17,6 +17,7 @@ import { IsArray, IsEnum, IsISO8601, + IsNotEmpty, IsNumber, IsOptional, IsString, @@ -72,8 +73,12 @@ export class UpdateOrderDto { @Min(0) quantity: number; + @IsNotEmpty() @IsString() @MaxLength(SYMBOL_MAXIMUM_LENGTH) + @Transform(({ value }: TransformFnParams) => + isString(value) ? value.trim() : value + ) symbol: string; @ArrayUnique() diff --git a/libs/common/src/lib/helper.spec.ts b/libs/common/src/lib/helper.spec.ts index b7e7635670..71b934628c 100644 --- a/libs/common/src/lib/helper.spec.ts +++ b/libs/common/src/lib/helper.spec.ts @@ -7,6 +7,7 @@ import { extractNumberFromString, getCountryCodeFromCurrency, getEmojiFlag, + getHoldingName, getNumberFormatGroup, getStringOrNull, getStringOrUndefined, @@ -143,6 +144,52 @@ describe('Helper', () => { }); }); + describe('Get holding name', () => { + it('Cash position', () => { + expect( + getHoldingName({ + assetSubClass: 'CASH', + assetSubClassLabel: 'Cash', + name: 'CHF', + symbol: 'CHF' + }) + ).toEqual('Cash (CHF)'); + }); + + it('Cash position without asset sub class label', () => { + expect( + getHoldingName({ + assetSubClass: 'CASH', + assetSubClassLabel: undefined, + name: 'CHF', + symbol: 'CHF' + }) + ).toEqual('CHF'); + }); + + it('Holding with name', () => { + expect( + getHoldingName({ + assetSubClass: 'STOCK', + assetSubClassLabel: 'Stock', + name: 'Apple Inc.', + symbol: 'AAPL' + }) + ).toEqual('Apple Inc.'); + }); + + it('Holding without name', () => { + expect( + getHoldingName({ + assetSubClass: 'STOCK', + assetSubClassLabel: 'Stock', + name: undefined, + symbol: 'AAPL' + }) + ).toEqual('AAPL'); + }); + }); + describe('Get number format group', () => { let languageGetter: jest.SpyInstance; diff --git a/libs/common/src/lib/helper.ts b/libs/common/src/lib/helper.ts index 2a76261dae..7e4e13de7e 100644 --- a/libs/common/src/lib/helper.ts +++ b/libs/common/src/lib/helper.ts @@ -453,6 +453,22 @@ export function getEmojiFlag(aCountryCode: string) { }); } +export function getHoldingName({ + assetSubClass, + assetSubClassLabel, + name, + symbol +}: Pick< + PortfolioPosition['assetProfile'], + 'assetSubClass' | 'assetSubClassLabel' | 'name' | 'symbol' +>) { + if (isCashPosition({ assetSubClass }) && assetSubClassLabel) { + return `${assetSubClassLabel} (${symbol})`; + } + + return name ?? symbol; +} + export function getLocale() { return navigator.language ?? DEFAULT_LOCALE; } diff --git a/libs/ui/src/lib/holdings-table/holdings-table.component.html b/libs/ui/src/lib/holdings-table/holdings-table.component.html index 2001a1b990..e1c012ab15 100644 --- a/libs/ui/src/lib/holdings-table/holdings-table.component.html +++ b/libs/ui/src/lib/holdings-table/holdings-table.component.html @@ -20,7 +20,7 @@ [countryCode]="getCountryCodeForCashPosition(element)" [dataSource]="element.assetProfile.dataSource" [symbol]="element.assetProfile.symbol" - [tooltip]="element.assetProfile.name" + [tooltip]="getHoldingName(element.assetProfile)" /> @@ -40,9 +40,14 @@ mat-cell [class.w-100]="mode() === 'simple'" > -
- {{ element.assetProfile.name }} - @if (element.assetProfile.name === element.assetProfile.symbol) { + @let holdingName = getHoldingName(element.assetProfile); + +
+ {{ holdingName }} + @if ( + !isCashPosition(element.assetProfile) && + element.assetProfile.name === element.assetProfile.symbol + ) { ({{ element.assetProfile.assetSubClassLabel }}) }
diff --git a/libs/ui/src/lib/holdings-table/holdings-table.component.ts b/libs/ui/src/lib/holdings-table/holdings-table.component.ts index eac1c9d918..1894260b78 100644 --- a/libs/ui/src/lib/holdings-table/holdings-table.component.ts +++ b/libs/ui/src/lib/holdings-table/holdings-table.component.ts @@ -1,6 +1,7 @@ import { canOpenHoldingDetail, getCountryCodeFromCurrency, + getHoldingName, getLocale, getLowercase, isCashPosition @@ -89,6 +90,9 @@ export class GfHoldingsTableComponent { return columns; }); + protected readonly getHoldingName = getHoldingName; + protected readonly isCashPosition = isCashPosition; + protected readonly isLoading = computed(() => { return !this.holdings(); }); @@ -104,7 +108,13 @@ export class GfHoldingsTableComponent { }); public constructor() { - this.dataSource.sortingDataAccessor = getLowercase; + this.dataSource.sortingDataAccessor = (holding, path) => { + if (path === 'assetProfile.name') { + return getHoldingName(holding.assetProfile).toLocaleLowerCase(); + } + + return getLowercase(holding, path) as number | string; + }; // Reactive data update effect(() => { diff --git a/libs/ui/src/lib/treemap-chart/treemap-chart.component.ts b/libs/ui/src/lib/treemap-chart/treemap-chart.component.ts index 83b9916c6e..01037f77f4 100644 --- a/libs/ui/src/lib/treemap-chart/treemap-chart.component.ts +++ b/libs/ui/src/lib/treemap-chart/treemap-chart.component.ts @@ -3,7 +3,11 @@ import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper'; import { getTooltipOptions } from '@ghostfolio/common/chart-helper'; -import { canOpenHoldingDetail, getLocale } from '@ghostfolio/common/helper'; +import { + canOpenHoldingDetail, + getHoldingName, + getLocale +} from '@ghostfolio/common/helper'; import { AssetProfileIdentifier, PortfolioPosition @@ -399,20 +403,18 @@ export class GfTreemapChartComponent const raw = context.raw as GfTreemapDataPoint; const allocationInPercentage = `${(raw._data.allocationInPercentage * 100).toFixed(2)}%`; - const name = raw._data.assetProfile.name; + const name = getHoldingName(raw._data.assetProfile); const sign = raw._data.netPerformancePercentWithCurrencyEffect > 0 ? '+' : ''; - const symbol = raw._data.assetProfile.symbol; - const netPerformanceInPercentageWithSign = `${sign}${(raw._data.netPerformancePercentWithCurrencyEffect * 100).toFixed(2)}%`; if (raw._data.valueInBaseCurrency !== null) { const value = raw._data.valueInBaseCurrency; return [ - `${name ?? symbol} (${allocationInPercentage})`, + `${name} (${allocationInPercentage})`, `${value?.toLocaleString(this.locale(), { maximumFractionDigits: 2, minimumFractionDigits: 2 @@ -429,7 +431,7 @@ export class GfTreemapChartComponent ]; } else { return [ - `${name ?? symbol} (${allocationInPercentage})`, + `${name} (${allocationInPercentage})`, '', $localize`Performance`, netPerformanceInPercentageWithSign