diff --git a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts index fb0da8123..70b782a06 100644 --- a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts @@ -12,7 +12,6 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate- import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces'; import { PortfolioSnapshotService } from '@ghostfolio/api/services/portfolio-snapshot/portfolio-snapshot.service'; import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper'; -import { CACHE_TTL_INFINITE } from '@ghostfolio/common/config'; import { PORTFOLIO_SNAPSHOT_PROCESS_JOB_NAME, PORTFOLIO_SNAPSHOT_PROCESS_JOB_OPTIONS @@ -39,7 +38,6 @@ import { Logger } from '@nestjs/common'; import { Big } from 'big.js'; import { plainToClass } from 'class-transformer'; import { - addMilliseconds, differenceInDays, eachDayOfInterval, endOfDay, @@ -875,29 +873,6 @@ export abstract class PortfolioCalculator { return chartDateMap; } - private async computeAndCacheSnapshot() { - const snapshot = await this.computeSnapshot(); - - const expiration = addMilliseconds( - new Date(), - this.configurationService.get('CACHE_QUOTES_TTL') - ); - - this.redisCacheService.set( - this.redisCacheService.getPortfolioSnapshotKey({ - filters: this.filters, - userId: this.userId - }), - JSON.stringify(({ - expiration: expiration.getTime(), - portfolioSnapshot: snapshot - })), - CACHE_TTL_INFINITE - ); - - return snapshot; - } - @LogPerformance private computeTransactionPoints() { this.transactionPoints = []; @@ -1080,6 +1055,7 @@ export abstract class PortfolioCalculator { // Compute in the background this.portfolioSnapshotService.addJobToQueue({ data: { + filters: this.filters, userId: this.userId }, name: PORTFOLIO_SNAPSHOT_PROCESS_JOB_NAME, @@ -1089,13 +1065,13 @@ export abstract class PortfolioCalculator { // priority } }); - this.computeAndCacheSnapshot(); } } else { // Wait for computation // TODO const job = await this.portfolioSnapshotService.addJobToQueue({ data: { + filters: this.filters, userId: this.userId }, name: PORTFOLIO_SNAPSHOT_PROCESS_JOB_NAME, @@ -1108,7 +1084,7 @@ export abstract class PortfolioCalculator { await job.finished(); - this.snapshot = await this.computeAndCacheSnapshot(); + await this.initialize(); } } } diff --git a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-novn-buy-and-sell.spec.ts b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-novn-buy-and-sell.spec.ts index 068246eb6..859df340c 100644 --- a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-novn-buy-and-sell.spec.ts +++ b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-novn-buy-and-sell.spec.ts @@ -14,6 +14,9 @@ import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.s 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 { PortfolioSnapshotProcessor } from '@ghostfolio/api/services/portfolio-snapshot/portfolio-snapshot.processor'; +import { PortfolioSnapshotService } from '@ghostfolio/api/services/portfolio-snapshot/portfolio-snapshot.service'; +import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/portfolio-snapshot/portfolio-snapshot.service.mock'; import { parseDate } from '@ghostfolio/common/helper'; import { Big } from 'big.js'; @@ -28,6 +31,18 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { }; }); +jest.mock( + '@ghostfolio/api/services/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 @@ -42,6 +57,7 @@ describe('PortfolioCalculator', () => { let currentRateService: CurrentRateService; let exchangeRateDataService: ExchangeRateDataService; let factory: PortfolioCalculatorFactory; + let portfolioSnapshotService: PortfolioSnapshotService; let redisCacheService: RedisCacheService; beforeEach(() => { @@ -56,12 +72,15 @@ describe('PortfolioCalculator', () => { null ); + portfolioSnapshotService = new PortfolioSnapshotService(null); + redisCacheService = new RedisCacheService(null, null); factory = new PortfolioCalculatorFactory( configurationService, currentRateService, exchangeRateDataService, + portfolioSnapshotService, redisCacheService ); }); @@ -110,7 +129,13 @@ describe('PortfolioCalculator', () => { userId: userDummyData.id }); - const portfolioSnapshot = await portfolioCalculator.getSnapshot(); + const portfolioSnapshotProcessor = new PortfolioSnapshotProcessor( + null, + null + ); + + const portfolioSnapshot = + await portfolioSnapshotProcessor.computeSnapshot(); const investments = portfolioCalculator.getInvestments(); diff --git a/apps/api/src/app/redis-cache/redis-cache.service.mock.ts b/apps/api/src/app/redis-cache/redis-cache.service.mock.ts index 094c7e6a0..f3a0d156b 100644 --- a/apps/api/src/app/redis-cache/redis-cache.service.mock.ts +++ b/apps/api/src/app/redis-cache/redis-cache.service.mock.ts @@ -1,13 +1,28 @@ +import { Filter } from '@ghostfolio/common/interfaces'; + import { Milliseconds } from 'cache-manager'; export const RedisCacheServiceMock = { + cache: new Map(), get: (key: string): Promise => { - return Promise.resolve(null); + const value = RedisCacheServiceMock.cache.get(key) || null; + + return Promise.resolve(value); }, - getPortfolioSnapshotKey: (userId: string): string => { - return `portfolio-snapshot-${userId}`; + getPortfolioSnapshotKey: ({ + filters, + userId + }: { + filters?: Filter[]; + userId: string; + }): string => { + const filtersHash = filters?.length; + + return `portfolio-snapshot-${userId}-${filtersHash}`; }, set: (key: string, value: string, ttl?: Milliseconds): Promise => { + RedisCacheServiceMock.cache.set(key, value); + return Promise.resolve(value); } }; diff --git a/apps/api/src/services/portfolio-snapshot/interfaces/portfolio-snapshot-queue-job.interface.ts b/apps/api/src/services/portfolio-snapshot/interfaces/portfolio-snapshot-queue-job.interface.ts index 10e6d133d..700fed657 100644 --- a/apps/api/src/services/portfolio-snapshot/interfaces/portfolio-snapshot-queue-job.interface.ts +++ b/apps/api/src/services/portfolio-snapshot/interfaces/portfolio-snapshot-queue-job.interface.ts @@ -1,3 +1,6 @@ +import { Filter } from '@ghostfolio/common/interfaces'; + export interface IPortfolioSnapshotQueueJob { + filters: Filter[]; userId: string; } diff --git a/apps/api/src/services/portfolio-snapshot/portfolio-snapshot.module.ts b/apps/api/src/services/portfolio-snapshot/portfolio-snapshot.module.ts index 818048d47..9f7ad2cd3 100644 --- a/apps/api/src/services/portfolio-snapshot/portfolio-snapshot.module.ts +++ b/apps/api/src/services/portfolio-snapshot/portfolio-snapshot.module.ts @@ -1,3 +1,4 @@ +import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; @@ -28,6 +29,7 @@ import { PortfolioSnapshotProcessor } from './portfolio-snapshot.processor'; MarketDataModule, PrismaModule, PropertyModule, + RedisCacheModule, SymbolProfileModule ], providers: [PortfolioSnapshotProcessor, PortfolioSnapshotService], diff --git a/apps/api/src/services/portfolio-snapshot/portfolio-snapshot.processor.ts b/apps/api/src/services/portfolio-snapshot/portfolio-snapshot.processor.ts index 0feaca302..4bc9c4ab7 100644 --- a/apps/api/src/services/portfolio-snapshot/portfolio-snapshot.processor.ts +++ b/apps/api/src/services/portfolio-snapshot/portfolio-snapshot.processor.ts @@ -1,20 +1,28 @@ +import { PortfolioSnapshotValue } from '@ghostfolio/api/app/portfolio/interfaces/snapshot-value.interface'; +import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { + CACHE_TTL_INFINITE, PORTFOLIO_SNAPSHOT_PROCESS_JOB_NAME, PORTFOLIO_SNAPSHOT_QUEUE } from '@ghostfolio/common/config'; +import { PortfolioSnapshot } from '@ghostfolio/common/models'; import { Process, Processor } from '@nestjs/bull'; import { Injectable, Logger } from '@nestjs/common'; +import * as Big from 'big.js'; import { Job } from 'bull'; -import ms from 'ms'; -import { setTimeout } from 'timers/promises'; +import { addMilliseconds } from 'date-fns'; import { IPortfolioSnapshotQueueJob } from './interfaces/portfolio-snapshot-queue-job.interface'; @Injectable() @Processor(PORTFOLIO_SNAPSHOT_QUEUE) export class PortfolioSnapshotProcessor { - public constructor() {} + public constructor( + private readonly configurationService: ConfigurationService, + private readonly redisCacheService: RedisCacheService + ) {} @Process({ concurrency: 1, name: PORTFOLIO_SNAPSHOT_PROCESS_JOB_NAME }) public async calculatePortfolioSnapshot( @@ -26,13 +34,31 @@ export class PortfolioSnapshotProcessor { `PortfolioSnapshotProcessor (${PORTFOLIO_SNAPSHOT_PROCESS_JOB_NAME})` ); - // TODO: Do something - await setTimeout(ms('1 second')); + const snapshot = await this.computeSnapshot(); Logger.log( `Portfolio snapshot calculation of user ${job.data.userId} has been completed`, `PortfolioSnapshotProcessor (${PORTFOLIO_SNAPSHOT_PROCESS_JOB_NAME})` ); + + const expiration = addMilliseconds( + new Date(), + this.configurationService.get('CACHE_QUOTES_TTL') + ); + + this.redisCacheService.set( + this.redisCacheService.getPortfolioSnapshotKey({ + filters: job.data.filters, + userId: job.data.userId + }), + JSON.stringify(({ + expiration: expiration.getTime(), + portfolioSnapshot: snapshot + })), + CACHE_TTL_INFINITE + ); + + return snapshot; } catch (error) { Logger.error( error, @@ -42,4 +68,20 @@ export class PortfolioSnapshotProcessor { throw new Error(error); } } + + // TODO + public async computeSnapshot(): Promise { + return { + currentValueInBaseCurrency: new Big(0), + hasErrors: false, + historicalData: [], + positions: [], + totalFeesWithCurrencyEffect: new Big(0), + totalInterestWithCurrencyEffect: new Big(0), + totalInvestment: new Big(0), + totalInvestmentWithCurrencyEffect: new Big(0), + totalLiabilitiesWithCurrencyEffect: new Big(0), + totalValuablesWithCurrencyEffect: new Big(0) + }; + } } diff --git a/apps/api/src/services/portfolio-snapshot/portfolio-snapshot.service.mock.ts b/apps/api/src/services/portfolio-snapshot/portfolio-snapshot.service.mock.ts new file mode 100644 index 000000000..004eb5ad8 --- /dev/null +++ b/apps/api/src/services/portfolio-snapshot/portfolio-snapshot.service.mock.ts @@ -0,0 +1,28 @@ +import * as Bull from 'bull'; +import { setTimeout } from 'timers/promises'; + +import { IPortfolioSnapshotQueueJob } from './interfaces/portfolio-snapshot-queue-job.interface'; + +export const PortfolioSnapshotServiceMock = { + addJobToQueue({ + data, + name, + opts + }: { + data: IPortfolioSnapshotQueueJob; + name: string; + opts?: Bull.JobOptions; + }): Promise> { + // Mock the Job object with a finished method + const mockJob: Partial> = { + // Mock the finished method to return a resolved promise + finished: async () => { + await setTimeout(100); + + return Promise.resolve('Mocked job finished result'); + } + }; + + return Promise.resolve(mockJob as Bull.Job); + } +};