mirror of https://github.com/ghostfolio/ghostfolio
183 changed files with 6635 additions and 3726 deletions
@ -0,0 +1,134 @@ |
|||||
|
import { AccessService } from '@ghostfolio/api/app/access/access.service'; |
||||
|
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; |
||||
|
import { UserService } from '@ghostfolio/api/app/user/user.service'; |
||||
|
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor'; |
||||
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; |
||||
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; |
||||
|
import { DEFAULT_CURRENCY } from '@ghostfolio/common/config'; |
||||
|
import { getSum } from '@ghostfolio/common/helper'; |
||||
|
import { PublicPortfolioResponse } from '@ghostfolio/common/interfaces'; |
||||
|
import type { RequestWithUser } from '@ghostfolio/common/types'; |
||||
|
|
||||
|
import { |
||||
|
Controller, |
||||
|
Get, |
||||
|
HttpException, |
||||
|
Inject, |
||||
|
Param, |
||||
|
UseInterceptors |
||||
|
} from '@nestjs/common'; |
||||
|
import { REQUEST } from '@nestjs/core'; |
||||
|
import { Big } from 'big.js'; |
||||
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; |
||||
|
|
||||
|
@Controller('public') |
||||
|
export class PublicController { |
||||
|
public constructor( |
||||
|
private readonly accessService: AccessService, |
||||
|
private readonly configurationService: ConfigurationService, |
||||
|
private readonly exchangeRateDataService: ExchangeRateDataService, |
||||
|
private readonly portfolioService: PortfolioService, |
||||
|
@Inject(REQUEST) private readonly request: RequestWithUser, |
||||
|
private readonly userService: UserService |
||||
|
) {} |
||||
|
|
||||
|
@Get(':accessId/portfolio') |
||||
|
@UseInterceptors(TransformDataSourceInResponseInterceptor) |
||||
|
public async getPublicPortfolio( |
||||
|
@Param('accessId') accessId |
||||
|
): Promise<PublicPortfolioResponse> { |
||||
|
const access = await this.accessService.access({ id: accessId }); |
||||
|
|
||||
|
if (!access) { |
||||
|
throw new HttpException( |
||||
|
getReasonPhrase(StatusCodes.NOT_FOUND), |
||||
|
StatusCodes.NOT_FOUND |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
let hasDetails = true; |
||||
|
|
||||
|
const user = await this.userService.user({ |
||||
|
id: access.userId |
||||
|
}); |
||||
|
|
||||
|
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { |
||||
|
hasDetails = user.subscription.type === 'Premium'; |
||||
|
} |
||||
|
|
||||
|
const [ |
||||
|
{ holdings }, |
||||
|
{ performance: performance1d }, |
||||
|
{ performance: performanceMax }, |
||||
|
{ performance: performanceYtd } |
||||
|
] = await Promise.all([ |
||||
|
this.portfolioService.getDetails({ |
||||
|
impersonationId: access.userId, |
||||
|
userId: user.id, |
||||
|
withMarkets: true |
||||
|
}), |
||||
|
...['1d', 'max', 'ytd'].map((dateRange) => { |
||||
|
return this.portfolioService.getPerformance({ |
||||
|
dateRange, |
||||
|
impersonationId: undefined, |
||||
|
userId: user.id |
||||
|
}); |
||||
|
}) |
||||
|
]); |
||||
|
|
||||
|
const publicPortfolioResponse: PublicPortfolioResponse = { |
||||
|
hasDetails, |
||||
|
alias: access.alias, |
||||
|
holdings: {}, |
||||
|
performance: { |
||||
|
'1d': { |
||||
|
relativeChange: |
||||
|
performance1d.netPerformancePercentageWithCurrencyEffect |
||||
|
}, |
||||
|
max: { |
||||
|
relativeChange: |
||||
|
performanceMax.netPerformancePercentageWithCurrencyEffect |
||||
|
}, |
||||
|
ytd: { |
||||
|
relativeChange: |
||||
|
performanceYtd.netPerformancePercentageWithCurrencyEffect |
||||
|
} |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
const totalValue = getSum( |
||||
|
Object.values(holdings).map(({ currency, marketPrice, quantity }) => { |
||||
|
return new Big( |
||||
|
this.exchangeRateDataService.toCurrency( |
||||
|
quantity * marketPrice, |
||||
|
currency, |
||||
|
this.request.user?.Settings?.settings.baseCurrency ?? |
||||
|
DEFAULT_CURRENCY |
||||
|
) |
||||
|
); |
||||
|
}) |
||||
|
).toNumber(); |
||||
|
|
||||
|
for (const [symbol, portfolioPosition] of Object.entries(holdings)) { |
||||
|
publicPortfolioResponse.holdings[symbol] = { |
||||
|
allocationInPercentage: |
||||
|
portfolioPosition.valueInBaseCurrency / totalValue, |
||||
|
assetClass: hasDetails ? portfolioPosition.assetClass : undefined, |
||||
|
countries: hasDetails ? portfolioPosition.countries : [], |
||||
|
currency: hasDetails ? portfolioPosition.currency : undefined, |
||||
|
dataSource: portfolioPosition.dataSource, |
||||
|
dateOfFirstActivity: portfolioPosition.dateOfFirstActivity, |
||||
|
markets: hasDetails ? portfolioPosition.markets : undefined, |
||||
|
name: portfolioPosition.name, |
||||
|
netPerformancePercentWithCurrencyEffect: |
||||
|
portfolioPosition.netPerformancePercentWithCurrencyEffect, |
||||
|
sectors: hasDetails ? portfolioPosition.sectors : [], |
||||
|
symbol: portfolioPosition.symbol, |
||||
|
url: portfolioPosition.url, |
||||
|
valueInPercentage: portfolioPosition.valueInBaseCurrency / totalValue |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
return publicPortfolioResponse; |
||||
|
} |
||||
|
} |
@ -0,0 +1,49 @@ |
|||||
|
import { AccessModule } from '@ghostfolio/api/app/access/access.module'; |
||||
|
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service'; |
||||
|
import { AccountService } from '@ghostfolio/api/app/account/account.service'; |
||||
|
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 { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; |
||||
|
import { RulesService } from '@ghostfolio/api/app/portfolio/rules.service'; |
||||
|
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; |
||||
|
import { UserModule } from '@ghostfolio/api/app/user/user.module'; |
||||
|
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.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 { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module'; |
||||
|
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module'; |
||||
|
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; |
||||
|
import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.module'; |
||||
|
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; |
||||
|
|
||||
|
import { Module } from '@nestjs/common'; |
||||
|
|
||||
|
import { PublicController } from './public.controller'; |
||||
|
|
||||
|
@Module({ |
||||
|
controllers: [PublicController], |
||||
|
imports: [ |
||||
|
AccessModule, |
||||
|
DataProviderModule, |
||||
|
ExchangeRateDataModule, |
||||
|
ImpersonationModule, |
||||
|
MarketDataModule, |
||||
|
OrderModule, |
||||
|
PortfolioSnapshotQueueModule, |
||||
|
PrismaModule, |
||||
|
RedisCacheModule, |
||||
|
SymbolProfileModule, |
||||
|
TransformDataSourceInRequestModule, |
||||
|
UserModule |
||||
|
], |
||||
|
providers: [ |
||||
|
AccountBalanceService, |
||||
|
AccountService, |
||||
|
CurrentRateService, |
||||
|
PortfolioCalculatorFactory, |
||||
|
PortfolioService, |
||||
|
RulesService |
||||
|
] |
||||
|
}) |
||||
|
export class PublicModule {} |
@ -0,0 +1,4 @@ |
|||||
|
export interface PortfolioSnapshotValue { |
||||
|
expiration: number; |
||||
|
portfolioSnapshot: string; |
||||
|
} |
@ -1,7 +0,0 @@ |
|||||
import { Cache } from 'cache-manager'; |
|
||||
|
|
||||
import type { RedisStore } from './redis-store.interface'; |
|
||||
|
|
||||
export interface RedisCache extends Cache { |
|
||||
store: RedisStore; |
|
||||
} |
|
@ -1,8 +0,0 @@ |
|||||
import { Store } from 'cache-manager'; |
|
||||
import { createClient } from 'redis'; |
|
||||
|
|
||||
export interface RedisStore extends Store { |
|
||||
getClient: () => ReturnType<typeof createClient>; |
|
||||
isCacheableValue: (value: any) => boolean; |
|
||||
name: 'redis'; |
|
||||
} |
|
@ -1,13 +1,28 @@ |
|||||
import { RedisCacheService } from './redis-cache.service'; |
import { Filter } from '@ghostfolio/common/interfaces'; |
||||
|
|
||||
|
import { Milliseconds } from 'cache-manager'; |
||||
|
|
||||
export const RedisCacheServiceMock = { |
export const RedisCacheServiceMock = { |
||||
|
cache: new Map<string, string>(), |
||||
get: (key: string): Promise<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 => { |
getPortfolioSnapshotKey: ({ |
||||
return `portfolio-snapshot-${userId}`; |
filters, |
||||
|
userId |
||||
|
}: { |
||||
|
filters?: Filter[]; |
||||
|
userId: string; |
||||
|
}): string => { |
||||
|
const filtersHash = filters?.length; |
||||
|
|
||||
|
return `portfolio-snapshot-${userId}${filtersHash > 0 ? `-${filtersHash}` : ''}`; |
||||
}, |
}, |
||||
set: (key: string, value: string, ttlInSeconds?: number): Promise<string> => { |
set: (key: string, value: string, ttl?: Milliseconds): Promise<string> => { |
||||
|
RedisCacheServiceMock.cache.set(key, value); |
||||
|
|
||||
return Promise.resolve(value); |
return Promise.resolve(value); |
||||
} |
} |
||||
}; |
}; |
||||
|
@ -0,0 +1,80 @@ |
|||||
|
import { |
||||
|
Injectable, |
||||
|
NestInterceptor, |
||||
|
ExecutionContext, |
||||
|
CallHandler |
||||
|
} from '@nestjs/common'; |
||||
|
import { Observable } from 'rxjs'; |
||||
|
import { tap } from 'rxjs/operators'; |
||||
|
|
||||
|
import { PerformanceLoggingService } from './performance-logging.service'; |
||||
|
|
||||
|
@Injectable() |
||||
|
export class PerformanceLoggingInterceptor implements NestInterceptor { |
||||
|
public constructor( |
||||
|
private readonly performanceLoggingService: PerformanceLoggingService |
||||
|
) {} |
||||
|
|
||||
|
public intercept( |
||||
|
context: ExecutionContext, |
||||
|
next: CallHandler |
||||
|
): Observable<any> { |
||||
|
const startTime = performance.now(); |
||||
|
|
||||
|
const className = context.getClass().name; |
||||
|
const methodName = context.getHandler().name; |
||||
|
|
||||
|
return next.handle().pipe( |
||||
|
tap(() => { |
||||
|
return this.performanceLoggingService.logPerformance({ |
||||
|
className, |
||||
|
methodName, |
||||
|
startTime |
||||
|
}); |
||||
|
}) |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
export function LogPerformance( |
||||
|
target: any, |
||||
|
propertyKey: string, |
||||
|
descriptor: PropertyDescriptor |
||||
|
) { |
||||
|
const originalMethod = descriptor.value; |
||||
|
|
||||
|
descriptor.value = async function (...args: any[]) { |
||||
|
const startTime = performance.now(); |
||||
|
const performanceLoggingService = new PerformanceLoggingService(); |
||||
|
|
||||
|
const result = originalMethod.apply(this, args); |
||||
|
|
||||
|
if (result instanceof Promise) { |
||||
|
// Handle async method
|
||||
|
return result |
||||
|
.then((res: any) => { |
||||
|
performanceLoggingService.logPerformance({ |
||||
|
startTime, |
||||
|
className: target.constructor.name, |
||||
|
methodName: propertyKey |
||||
|
}); |
||||
|
|
||||
|
return res; |
||||
|
}) |
||||
|
.catch((error: any) => { |
||||
|
throw error; |
||||
|
}); |
||||
|
} else { |
||||
|
// Handle sync method
|
||||
|
performanceLoggingService.logPerformance({ |
||||
|
startTime, |
||||
|
className: target.constructor.name, |
||||
|
methodName: propertyKey |
||||
|
}); |
||||
|
|
||||
|
return result; |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
return descriptor; |
||||
|
} |
@ -0,0 +1,10 @@ |
|||||
|
import { Module } from '@nestjs/common'; |
||||
|
|
||||
|
import { PerformanceLoggingInterceptor } from './performance-logging.interceptor'; |
||||
|
import { PerformanceLoggingService } from './performance-logging.service'; |
||||
|
|
||||
|
@Module({ |
||||
|
exports: [PerformanceLoggingInterceptor, PerformanceLoggingService], |
||||
|
providers: [PerformanceLoggingInterceptor, PerformanceLoggingService] |
||||
|
}) |
||||
|
export class PerformanceLoggingModule {} |
@ -0,0 +1,21 @@ |
|||||
|
import { Injectable, Logger } from '@nestjs/common'; |
||||
|
|
||||
|
@Injectable() |
||||
|
export class PerformanceLoggingService { |
||||
|
public logPerformance({ |
||||
|
className, |
||||
|
methodName, |
||||
|
startTime |
||||
|
}: { |
||||
|
className: string; |
||||
|
methodName: string; |
||||
|
startTime: number; |
||||
|
}) { |
||||
|
const endTime = performance.now(); |
||||
|
|
||||
|
Logger.debug( |
||||
|
`Completed execution of ${methodName}() in ${((endTime - startTime) / 1000).toFixed(3)} seconds`, |
||||
|
className |
||||
|
); |
||||
|
} |
||||
|
} |
@ -1,11 +1,11 @@ |
|||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; |
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 { DataEnhancerModule } from '@ghostfolio/api/services/data-provider/data-enhancer/data-enhancer.module'; |
||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.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 { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; |
||||
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module'; |
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module'; |
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; |
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; |
||||
import { PropertyModule } from '@ghostfolio/api/services/property/property.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 { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; |
||||
import { DATA_GATHERING_QUEUE } from '@ghostfolio/common/config'; |
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,39 @@ |
|||||
|
import { AccountBalanceModule } from '@ghostfolio/api/app/account-balance/account-balance.module'; |
||||
|
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: [ |
||||
|
AccountBalanceModule, |
||||
|
BullModule.registerQueue({ |
||||
|
name: PORTFOLIO_SNAPSHOT_QUEUE |
||||
|
}), |
||||
|
ConfigurationModule, |
||||
|
DataProviderModule, |
||||
|
ExchangeRateDataModule, |
||||
|
MarketDataModule, |
||||
|
OrderModule, |
||||
|
RedisCacheModule |
||||
|
], |
||||
|
providers: [ |
||||
|
CurrentRateService, |
||||
|
PortfolioCalculatorFactory, |
||||
|
PortfolioSnapshotProcessor, |
||||
|
PortfolioSnapshotService |
||||
|
] |
||||
|
}) |
||||
|
export class PortfolioSnapshotQueueModule {} |
@ -0,0 +1,114 @@ |
|||||
|
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service'; |
||||
|
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, |
||||
|
DEFAULT_PROCESSOR_CONCURRENCY_PORTFOLIO_SNAPSHOT, |
||||
|
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 accountBalanceService: AccountBalanceService, |
||||
|
private readonly calculatorFactory: PortfolioCalculatorFactory, |
||||
|
private readonly configurationService: ConfigurationService, |
||||
|
private readonly orderService: OrderService, |
||||
|
private readonly redisCacheService: RedisCacheService |
||||
|
) {} |
||||
|
|
||||
|
@Process({ |
||||
|
concurrency: parseInt( |
||||
|
process.env.PROCESSOR_CONCURRENCY_PORTFOLIO_SNAPSHOT ?? |
||||
|
DEFAULT_PROCESSOR_CONCURRENCY_PORTFOLIO_SNAPSHOT.toString(), |
||||
|
10 |
||||
|
), |
||||
|
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 accountBalanceItems = |
||||
|
await this.accountBalanceService.getAccountBalanceItems({ |
||||
|
filters: job.data.filters, |
||||
|
userCurrency: job.data.userCurrency, |
||||
|
userId: job.data.userId |
||||
|
}); |
||||
|
|
||||
|
const portfolioCalculator = this.calculatorFactory.createCalculator({ |
||||
|
accountBalanceItems, |
||||
|
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); |
||||
|
} |
||||
|
} |
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue