diff --git a/CHANGELOG.md b/CHANGELOG.md index f7e67394e..434b32ef3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -59,15 +59,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- Improved the backgrounds of the chart of the holdings tab on the home page (experimental) -- Improved the labels of the chart of the holdings tab on the home page (experimental) -- Improved the usability to customize the rule thresholds in the _X-ray_ section by introducing sliders (experimental) -- Refactored the rule thresholds in the _X-ray_ section (experimental) -- Exposed the timeout of the portfolio snapshot computation as an environment variable (`PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_TIMEOUT`) -- Harmonized the processor concurrency environment variables -- Improved the portfolio unit tests to work with exported activity files -- Enabled the `noUnusedLocals` compiler option in the `tsconfig` -- Enabled the `noUnusedParameters` compiler option in the `tsconfig` ### Fixed diff --git a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-dynamic-buy-and-sell.spec.ts b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-dynamic-buy-and-sell.spec.ts new file mode 100644 index 000000000..529583fc0 --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-dynamic-buy-and-sell.spec.ts @@ -0,0 +1,252 @@ +import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; +import { userDummyData } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; +import { + PerformanceCalculationType, + 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 { Big } from 'big.js'; +import { existsSync, readFileSync } from 'fs'; +import { last } from 'lodash'; +import { join } from 'path'; + +jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + CurrentRateService: jest.fn().mockImplementation(() => { + return CurrentRateServiceMock; + }) + }; +}); + +jest.mock( + '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service', + () => { + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + PortfolioSnapshotService: jest.fn().mockImplementation(() => { + return PortfolioSnapshotServiceMock; + }) + }; + } +); + +jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + 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(() => { + configurationService = new ConfigurationService(); + + currentRateService = new CurrentRateService(null, null, null, null); + + exchangeRateDataService = new ExchangeRateDataService( + null, + null, + null, + null + ); + + portfolioSnapshotService = new PortfolioSnapshotService(null); + + redisCacheService = new RedisCacheService(null, null); + + portfolioCalculatorFactory = new PortfolioCalculatorFactory( + configurationService, + currentRateService, + exchangeRateDataService, + portfolioSnapshotService, + redisCacheService + ); + }); + + //read from activities json + let activities: any[]; + + beforeAll(() => { + const jsonFilePath = join( + __dirname, + '../../../../../../../test/import/ok-novn-buy-and-sell.json' + ); + + if (!existsSync(jsonFilePath)) + throw new Error('JSON file not found at: ' + jsonFilePath); + + const jsonData = readFileSync(jsonFilePath, 'utf8'); + activities = JSON.parse(jsonData).activities; + }); + + describe('get current positions', () => { + it.only('with NOVN.SW buy and sell', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2022-04-11').getTime()); + + //map activity with json + const mappedactivities: Activity[] = activities.map((activity) => ({ + ...activity, + date: new Date(activity.date), + SymbolProfile: { + currency: activity.currency || 'CHF', + dataSource: activity.dataSource || 'YAHOO', + name: activity.name || 'Default Name', // provide a default name if missing + symbol: activity.symbol || 'UNKNOWN' // provide a default symbol if missing + } + })); + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities: mappedactivities, + calculationType: PerformanceCalculationType.TWR, + currency: 'CHF', + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); + + const investments = portfolioCalculator.getInvestments(); + + const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({ + data: portfolioSnapshot.historicalData, + groupBy: 'month' + }); + + expect(portfolioSnapshot.historicalData[0]).toEqual({ + date: '2022-03-06', + investmentValueWithCurrencyEffect: 0, + netPerformance: 0, + netPerformanceInPercentage: 0, + netPerformanceInPercentageWithCurrencyEffect: 0, + netPerformanceWithCurrencyEffect: 0, + netWorth: 0, + totalAccountBalance: 0, + totalInvestment: 0, + totalInvestmentValueWithCurrencyEffect: 0, + value: 0, + valueWithCurrencyEffect: 0 + }); + + expect(portfolioSnapshot.historicalData[1]).toEqual({ + date: '2022-03-07', + investmentValueWithCurrencyEffect: 151.6, + netPerformance: 0, + netPerformanceInPercentage: 0, + netPerformanceInPercentageWithCurrencyEffect: 0, + netPerformanceWithCurrencyEffect: 0, + netWorth: 151.6, + totalAccountBalance: 0, + totalInvestment: 151.6, + totalInvestmentValueWithCurrencyEffect: 151.6, + value: 151.6, + valueWithCurrencyEffect: 151.6 + }); + + expect( + portfolioSnapshot.historicalData[ + portfolioSnapshot.historicalData.length - 1 + ] + ).toEqual({ + date: '2022-04-11', + investmentValueWithCurrencyEffect: 0, + netPerformance: 19.86, + netPerformanceInPercentage: 0.13100263852242744, + netPerformanceInPercentageWithCurrencyEffect: 0.13100263852242744, + netPerformanceWithCurrencyEffect: 19.86, + netWorth: 0, + totalAccountBalance: 0, + totalInvestment: 0, + totalInvestmentValueWithCurrencyEffect: 0, + value: 0, + valueWithCurrencyEffect: 0 + }); + + expect(portfolioSnapshot).toMatchObject({ + currentValueInBaseCurrency: new Big('0'), + errors: [], + hasErrors: false, + positions: [ + { + averagePrice: new Big('0'), + currency: 'CHF', + dataSource: 'YAHOO', + dividend: new Big('0'), + dividendInBaseCurrency: new Big('0'), + fee: new Big('0'), + feeInBaseCurrency: new Big('0'), + firstBuyDate: '2022-03-07', + grossPerformance: new Big('19.86'), + grossPerformancePercentage: new Big('0.13100263852242744063'), + grossPerformancePercentageWithCurrencyEffect: new Big( + '0.13100263852242744063' + ), + grossPerformanceWithCurrencyEffect: new Big('19.86'), + investment: new Big('0'), + investmentWithCurrencyEffect: new Big('0'), + netPerformance: new Big('19.86'), + netPerformancePercentage: new Big('0.13100263852242744063'), + netPerformancePercentageWithCurrencyEffectMap: { + max: new Big('0.13100263852242744063') + }, + netPerformanceWithCurrencyEffectMap: { + max: new Big('19.86') + }, + marketPrice: 87.8, + marketPriceInBaseCurrency: 87.8, + quantity: new Big('0'), + symbol: 'NOVN.SW', + tags: [], + timeWeightedInvestment: new Big('151.6'), + timeWeightedInvestmentWithCurrencyEffect: new Big('151.6'), + transactionCount: 2, + valueInBaseCurrency: new Big('0') + } + ], + totalFeesWithCurrencyEffect: new Big('0'), + totalInterestWithCurrencyEffect: new Big('0'), + totalInvestment: new Big('0'), + totalInvestmentWithCurrencyEffect: new Big('0'), + totalLiabilitiesWithCurrencyEffect: new Big('0'), + totalValuablesWithCurrencyEffect: new Big('0') + }); + + expect(last(portfolioSnapshot.historicalData)).toMatchObject( + expect.objectContaining({ + netPerformance: 19.86, + netPerformanceInPercentage: 0.13100263852242744063, + netPerformanceInPercentageWithCurrencyEffect: 0.13100263852242744063, + netPerformanceWithCurrencyEffect: 19.86, + totalInvestmentValueWithCurrencyEffect: 0 + }) + ); + + expect(investments).toEqual([ + { date: '2022-03-07', investment: new Big('151.6') }, + { date: '2022-04-08', investment: new Big('0') } + ]); + + expect(investmentsByMonth).toEqual([ + { date: '2022-03-01', investment: 151.6 }, + { date: '2022-04-01', investment: -151.6 } + ]); + }); + }); +}); diff --git a/apps/api/src/helper/object.helper.ts b/apps/api/src/helper/object.helper.ts index c6d825598..f084fb3f2 100644 --- a/apps/api/src/helper/object.helper.ts +++ b/apps/api/src/helper/object.helper.ts @@ -1,35 +1,5 @@ import { Big } from 'big.js'; -import { cloneDeep, isArray, isObject } from 'lodash'; - -export function hasNotDefinedValuesInObject(aObject: Object): boolean { - for (const key in aObject) { - if (aObject[key] === null || aObject[key] === undefined) { - return true; - } else if (isObject(aObject[key])) { - return hasNotDefinedValuesInObject(aObject[key]); - } - } - - return false; -} - -export function nullifyValuesInObject(aObject: T, keys: string[]): T { - const object = cloneDeep(aObject); - - if (object) { - keys.forEach((key) => { - object[key] = null; - }); - } - - return object; -} - -export function nullifyValuesInObjects(aObjects: T[], keys: string[]): T[] { - return aObjects.map((object) => { - return nullifyValuesInObject(object, keys); - }); -} +import { isArray, isObject } from 'lodash'; export function redactAttributes({ isFirstRun = true, @@ -44,42 +14,38 @@ export function redactAttributes({ return object; } - // Create deep clone - const redactedObject = isFirstRun - ? JSON.parse(JSON.stringify(object)) - : object; + const redactedObject = isFirstRun ? { ...object } : object; for (const option of options) { - if (redactedObject.hasOwnProperty(option.attribute)) { - if (option.valueMap['*'] || option.valueMap['*'] === null) { - redactedObject[option.attribute] = option.valueMap['*']; - } else if (option.valueMap[redactedObject[option.attribute]]) { - redactedObject[option.attribute] = - option.valueMap[redactedObject[option.attribute]]; + const { attribute, valueMap } = option; + + // Directly check and redact attributes + // Directly check and redact attributes + if (redactedObject.hasOwnProperty(attribute)) { + const value = redactedObject[attribute]; + // Apply specific value or wildcard ('*') + if (valueMap.hasOwnProperty(value)) { + redactedObject[attribute] = valueMap[value]; + } else if (valueMap.hasOwnProperty('*')) { + redactedObject[attribute] = valueMap['*']; } } else { - // If the attribute is not present on the current object, - // check if it exists on any nested objects - for (const property in redactedObject) { - if (isArray(redactedObject[property])) { - redactedObject[property] = redactedObject[property].map( - (currentObject) => { - return redactAttributes({ - options, - isFirstRun: false, - object: currentObject - }); - } + // Iterate over nested objects or arrays + for (const key in redactedObject) { + const prop = redactedObject[key]; + if (isArray(prop)) { + redactedObject[key] = prop.map((item) => + redactAttributes({ + object: item, + options, + isFirstRun: false + }) ); - } else if ( - isObject(redactedObject[property]) && - !(redactedObject[property] instanceof Big) - ) { - // Recursively call the function on the nested object - redactedObject[property] = redactAttributes({ + } else if (isObject(prop) && !(prop instanceof Big)) { + redactedObject[key] = redactAttributes({ + object: prop, options, - isFirstRun: false, - object: redactedObject[property] + isFirstRun: false }); } }