mirror of https://github.com/ghostfolio/ghostfolio
committed by
GitHub
40 changed files with 621 additions and 119 deletions
@ -1,13 +1,28 @@ |
|||
import { Filter } from '@ghostfolio/common/interfaces'; |
|||
|
|||
import { Milliseconds } from 'cache-manager'; |
|||
|
|||
export const RedisCacheServiceMock = { |
|||
cache: new Map<string, string>(), |
|||
get: (key: string): Promise<string> => { |
|||
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 > 0 ? `-${filtersHash}` : ''}`; |
|||
}, |
|||
set: (key: string, value: string, ttl?: Milliseconds): Promise<string> => { |
|||
RedisCacheServiceMock.cache.set(key, value); |
|||
|
|||
return Promise.resolve(value); |
|||
} |
|||
}; |
|||
|
@ -1,11 +1,11 @@ |
|||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; |
|||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service'; |
|||
import { DataEnhancerModule } from '@ghostfolio/api/services/data-provider/data-enhancer/data-enhancer.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'; |
|||
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module'; |
|||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; |
|||
import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; |
|||
import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service'; |
|||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; |
|||
import { DATA_GATHERING_QUEUE } from '@ghostfolio/common/config'; |
|||
|
@ -0,0 +1,7 @@ |
|||
import { Filter } from '@ghostfolio/common/interfaces'; |
|||
|
|||
export interface IPortfolioSnapshotQueueJob { |
|||
filters: Filter[]; |
|||
userCurrency: string; |
|||
userId: string; |
|||
} |
@ -0,0 +1,37 @@ |
|||
import { OrderModule } from '@ghostfolio/api/app/order/order.module'; |
|||
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; |
|||
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; |
|||
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'; |
|||
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module'; |
|||
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; |
|||
import { PORTFOLIO_SNAPSHOT_QUEUE } from '@ghostfolio/common/config'; |
|||
|
|||
import { BullModule } from '@nestjs/bull'; |
|||
import { Module } from '@nestjs/common'; |
|||
|
|||
import { PortfolioSnapshotProcessor } from './portfolio-snapshot.processor'; |
|||
|
|||
@Module({ |
|||
exports: [BullModule, PortfolioSnapshotService], |
|||
imports: [ |
|||
BullModule.registerQueue({ |
|||
name: PORTFOLIO_SNAPSHOT_QUEUE |
|||
}), |
|||
ConfigurationModule, |
|||
DataProviderModule, |
|||
ExchangeRateDataModule, |
|||
MarketDataModule, |
|||
OrderModule, |
|||
RedisCacheModule |
|||
], |
|||
providers: [ |
|||
CurrentRateService, |
|||
PortfolioCalculatorFactory, |
|||
PortfolioSnapshotProcessor, |
|||
PortfolioSnapshotService |
|||
] |
|||
}) |
|||
export class PortfolioSnapshotQueueModule {} |
@ -0,0 +1,96 @@ |
|||
import { OrderService } from '@ghostfolio/api/app/order/order.service'; |
|||
import { |
|||
PerformanceCalculationType, |
|||
PortfolioCalculatorFactory |
|||
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; |
|||
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 { Process, Processor } from '@nestjs/bull'; |
|||
import { Injectable, Logger } from '@nestjs/common'; |
|||
import { Job } from 'bull'; |
|||
import { addMilliseconds } from 'date-fns'; |
|||
|
|||
import { IPortfolioSnapshotQueueJob } from './interfaces/portfolio-snapshot-queue-job.interface'; |
|||
|
|||
@Injectable() |
|||
@Processor(PORTFOLIO_SNAPSHOT_QUEUE) |
|||
export class PortfolioSnapshotProcessor { |
|||
public constructor( |
|||
private readonly calculatorFactory: PortfolioCalculatorFactory, |
|||
private readonly configurationService: ConfigurationService, |
|||
private readonly orderService: OrderService, |
|||
private readonly redisCacheService: RedisCacheService |
|||
) {} |
|||
|
|||
@Process({ concurrency: 1, name: PORTFOLIO_SNAPSHOT_PROCESS_JOB_NAME }) |
|||
public async calculatePortfolioSnapshot( |
|||
job: Job<IPortfolioSnapshotQueueJob> |
|||
) { |
|||
try { |
|||
const startTime = performance.now(); |
|||
|
|||
Logger.log( |
|||
`Portfolio snapshot calculation of user '${job.data.userId}' has been started`, |
|||
`PortfolioSnapshotProcessor (${PORTFOLIO_SNAPSHOT_PROCESS_JOB_NAME})` |
|||
); |
|||
|
|||
const { activities } = |
|||
await this.orderService.getOrdersForPortfolioCalculator({ |
|||
filters: job.data.filters, |
|||
userCurrency: job.data.userCurrency, |
|||
userId: job.data.userId |
|||
}); |
|||
|
|||
const portfolioCalculator = this.calculatorFactory.createCalculator({ |
|||
activities, |
|||
calculationType: PerformanceCalculationType.TWR, |
|||
currency: job.data.userCurrency, |
|||
filters: job.data.filters, |
|||
userId: job.data.userId |
|||
}); |
|||
|
|||
const snapshot = await portfolioCalculator.computeSnapshot(); |
|||
|
|||
Logger.log( |
|||
`Portfolio snapshot calculation of user '${job.data.userId}' has been completed in ${( |
|||
(performance.now() - startTime) / |
|||
1000 |
|||
).toFixed(3)} seconds`,
|
|||
`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(<PortfolioSnapshotValue>(<unknown>{ |
|||
expiration: expiration.getTime(), |
|||
portfolioSnapshot: snapshot |
|||
})), |
|||
CACHE_TTL_INFINITE |
|||
); |
|||
|
|||
return snapshot; |
|||
} catch (error) { |
|||
Logger.error( |
|||
error, |
|||
`PortfolioSnapshotProcessor (${PORTFOLIO_SNAPSHOT_PROCESS_JOB_NAME})` |
|||
); |
|||
|
|||
throw new Error(error); |
|||
} |
|||
} |
|||
} |
@ -0,0 +1,34 @@ |
|||
import { Job, JobOptions } 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?: JobOptions; |
|||
}): Promise<Job<any>> { |
|||
const mockJob: Partial<Job<any>> = { |
|||
finished: async () => { |
|||
await setTimeout(100); |
|||
|
|||
return Promise.resolve(); |
|||
} |
|||
}; |
|||
|
|||
this.jobsStore.set(opts?.jobId, mockJob); |
|||
|
|||
return Promise.resolve(mockJob as Job<any>); |
|||
}, |
|||
getJob(jobId: string): Promise<Job<any>> { |
|||
const job = this.jobsStore.get(jobId); |
|||
|
|||
return Promise.resolve(job as Job<any>); |
|||
}, |
|||
jobsStore: new Map<string, Partial<Job<any>>>() |
|||
}; |
@ -0,0 +1,31 @@ |
|||
import { PORTFOLIO_SNAPSHOT_QUEUE } from '@ghostfolio/common/config'; |
|||
|
|||
import { InjectQueue } from '@nestjs/bull'; |
|||
import { Injectable } from '@nestjs/common'; |
|||
import { JobOptions, Queue } from 'bull'; |
|||
|
|||
import { IPortfolioSnapshotQueueJob } from './interfaces/portfolio-snapshot-queue-job.interface'; |
|||
|
|||
@Injectable() |
|||
export class PortfolioSnapshotService { |
|||
public constructor( |
|||
@InjectQueue(PORTFOLIO_SNAPSHOT_QUEUE) |
|||
private readonly portfolioSnapshotQueue: Queue |
|||
) {} |
|||
|
|||
public async addJobToQueue({ |
|||
data, |
|||
name, |
|||
opts |
|||
}: { |
|||
data: IPortfolioSnapshotQueueJob; |
|||
name: string; |
|||
opts?: JobOptions; |
|||
}) { |
|||
return this.portfolioSnapshotQueue.add(name, data, opts); |
|||
} |
|||
|
|||
public async getJob(jobId: string) { |
|||
return this.portfolioSnapshotQueue.getJob(jobId); |
|||
} |
|||
} |
Loading…
Reference in new issue