Merge branch 'main' of gitea.suda.codes:giteauser/ghostfolio-mirror

This commit is contained in:
ksyasuda 2024-09-12 19:05:35 -07:00
commit 3eba400d21
76 changed files with 1513 additions and 845 deletions

View File

@ -5,11 +5,36 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## 2.106.0-beta.5 - 2024-08-31
## 2.107.1 - 2024-09-12
### Fixed
- Fixed an issue in the activities filters that occured during destructuring
## 2.107.0 - 2024-09-10
### Added
- Extended the filters of the activities endpoint by `dataSource` and `symbol`
### Changed
- Migrated the portfolio snapshot calculation to the queue design pattern
- Optimized the asynchronous operations using `Promise.all()` in the info service
- Optimized the asynchronous operations using `Promise.all()` in the admin control panel endpoint
- Extracted the users from the admin control panel endpoint to a dedicated endpoint
- Improved the language localization for French (`fr`)
- Improved the language localization for Italian (`it`)
- Upgraded `bull` from version `4.10.4` to `4.16.2`
## 2.106.0 - 2024-09-07
### Added
- Set up a performance logging service
- Added a loading indicator to the queue jobs table in the admin control panel
- Added a loading indicator to the users table in the admin control panel
- Added the attribute `mode` to the scraper configuration to get quotes instantly
### Changed
@ -20,6 +45,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Changed the data format of the environment variable `CACHE_QUOTES_TTL` from seconds to milliseconds
- Changed the data format of the environment variable `CACHE_TTL` from seconds to milliseconds
- Removed the environment variable `MAX_ITEM_IN_CACHE`
- Improved the error logs of the scraper configuration test in the asset profile details dialog of the admin control
- Improved the language localization for Polish (`pl`)
- Migrated from `cache-manager-redis-store` to `cache-manager-redis-yet`
- Upgraded `cache-manager` from version `3.4.3` to `5.7.6`
@ -28,6 +54,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- Fixed an issue in the view mode toggle of the holdings tab on the home page (experimental)
- Fixed an issue on the portfolio activities page by loading the data only once
- Fixed an issue in the carousel component for the testimonial section on the landing page
- Fixed the historical market data gathering in the _Yahoo Finance_ service by switching from `historical()` to `chart()`
- Handled an exception in the historical market data component of the asset profile details dialog in the admin control panel
## 2.105.0 - 2024-08-21

View File

@ -2,10 +2,10 @@ import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorat
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service';
import { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { PropertyDto } from '@ghostfolio/api/services/property/property.dto';
import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service';
import {
DATA_GATHERING_QUEUE_PRIORITY_HIGH,
DATA_GATHERING_QUEUE_PRIORITY_MEDIUM,
@ -17,6 +17,7 @@ import {
AdminData,
AdminMarketData,
AdminMarketDataDetails,
AdminUsers,
EnhancedSymbolProfile
} from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions';
@ -239,9 +240,11 @@ export class AdminController {
return { price };
}
throw new Error('Could not parse the current market price');
throw new Error(
`Could not parse the current market price for ${symbol} (${dataSource})`
);
} catch (error) {
Logger.error(error);
Logger.error(error, 'AdminController');
throw new HttpException(error.message, StatusCodes.BAD_REQUEST);
}
@ -345,4 +348,11 @@ export class AdminController {
) {
return this.adminService.putSetting(key, data.value);
}
@Get('user')
@HasPermission(permissions.accessAdminControl)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getUsers(): Promise<AdminUsers> {
return this.adminService.getUsers();
}
}

View File

@ -4,12 +4,12 @@ import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscriptio
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.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 { DataGatheringModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
import { Module } from '@nestjs/common';

View File

@ -21,6 +21,7 @@ import {
AdminMarketData,
AdminMarketDataDetails,
AdminMarketDataItem,
AdminUsers,
AssetProfileIdentifier,
EnhancedSymbolProfile,
Filter
@ -107,35 +108,42 @@ export class AdminService {
}
public async get(): Promise<AdminData> {
return {
exchangeRates: this.exchangeRateDataService
.getCurrencies()
.filter((currency) => {
return currency !== DEFAULT_CURRENCY;
})
.map((currency) => {
const label1 = DEFAULT_CURRENCY;
const label2 = currency;
const exchangeRates = this.exchangeRateDataService
.getCurrencies()
.filter((currency) => {
return currency !== DEFAULT_CURRENCY;
})
.map((currency) => {
const label1 = DEFAULT_CURRENCY;
const label2 = currency;
return {
label1,
label2,
dataSource:
DataSource[
this.configurationService.get('DATA_SOURCE_EXCHANGE_RATES')
],
symbol: `${label1}${label2}`,
value: this.exchangeRateDataService.toCurrency(
1,
DEFAULT_CURRENCY,
currency
)
};
}),
settings: await this.propertyService.get(),
transactionCount: await this.prismaService.order.count(),
userCount: await this.prismaService.user.count(),
users: await this.getUsersWithAnalytics(),
return {
label1,
label2,
dataSource:
DataSource[
this.configurationService.get('DATA_SOURCE_EXCHANGE_RATES')
],
symbol: `${label1}${label2}`,
value: this.exchangeRateDataService.toCurrency(
1,
DEFAULT_CURRENCY,
currency
)
};
});
const [settings, transactionCount, userCount] = await Promise.all([
this.propertyService.get(),
this.prismaService.order.count(),
this.prismaService.user.count()
]);
return {
exchangeRates,
settings,
transactionCount,
userCount,
version: environment.version
};
}
@ -377,6 +385,10 @@ export class AdminService {
};
}
public async getUsers(): Promise<AdminUsers> {
return { users: await this.getUsersWithAnalytics() };
}
public async patchAssetProfileData({
assetClass,
assetSubClass,
@ -546,11 +558,11 @@ export class AdminService {
return { marketData, count: marketData.length };
}
private async getUsersWithAnalytics(): Promise<AdminData['users']> {
private async getUsersWithAnalytics(): Promise<AdminUsers['users']> {
let orderBy: any = {
createdAt: 'desc'
};
let where;
let where: Prisma.UserWhereInput;
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
orderBy = {

View File

@ -1,4 +1,5 @@
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
import { DataGatheringModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module';
import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.module';
import { Module } from '@nestjs/common';
@ -7,7 +8,7 @@ import { QueueService } from './queue.service';
@Module({
controllers: [QueueController],
imports: [DataGatheringModule],
imports: [DataGatheringModule, PortfolioSnapshotQueueModule],
providers: [QueueService]
})
export class QueueModule {}

View File

@ -1,5 +1,6 @@
import {
DATA_GATHERING_QUEUE,
PORTFOLIO_SNAPSHOT_QUEUE,
QUEUE_JOB_STATUS_LIST
} from '@ghostfolio/common/config';
import { AdminJobs } from '@ghostfolio/common/interfaces';
@ -12,11 +13,19 @@ import { JobStatus, Queue } from 'bull';
export class QueueService {
public constructor(
@InjectQueue(DATA_GATHERING_QUEUE)
private readonly dataGatheringQueue: Queue
private readonly dataGatheringQueue: Queue,
@InjectQueue(PORTFOLIO_SNAPSHOT_QUEUE)
private readonly portfolioSnapshotQueue: Queue
) {}
public async deleteJob(aId: string) {
return (await this.dataGatheringQueue.getJob(aId))?.remove();
let job = await this.dataGatheringQueue.getJob(aId);
if (!job) {
job = await this.portfolioSnapshotQueue.getJob(aId);
}
return job?.remove();
}
public async deleteJobs({
@ -25,15 +34,21 @@ export class QueueService {
status?: JobStatus[];
}) {
for (const statusItem of status) {
await this.dataGatheringQueue.clean(
300,
statusItem === 'waiting' ? 'wait' : statusItem
);
const queueStatus = statusItem === 'waiting' ? 'wait' : statusItem;
await this.dataGatheringQueue.clean(300, queueStatus);
await this.portfolioSnapshotQueue.clean(300, queueStatus);
}
}
public async executeJob(aId: string) {
return (await this.dataGatheringQueue.getJob(aId))?.promote();
let job = await this.dataGatheringQueue.getJob(aId);
if (!job) {
job = await this.portfolioSnapshotQueue.getJob(aId);
}
return job?.promote();
}
public async getJobs({
@ -43,10 +58,13 @@ export class QueueService {
limit?: number;
status?: JobStatus[];
}): Promise<AdminJobs> {
const jobs = await this.dataGatheringQueue.getJobs(status);
const [dataGatheringJobs, portfolioSnapshotJobs] = await Promise.all([
this.dataGatheringQueue.getJobs(status),
this.portfolioSnapshotQueue.getJobs(status)
]);
const jobsWithState = await Promise.all(
jobs
[...dataGatheringJobs, ...portfolioSnapshotJobs]
.filter((job) => {
return job;
})

View File

@ -1,11 +1,12 @@
import { EventsModule } from '@ghostfolio/api/events/events.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { CronService } from '@ghostfolio/api/services/cron.service';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.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 { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { DataGatheringModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module';
import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.module';
import { TwitterBotModule } from '@ghostfolio/api/services/twitter-bot/twitter-bot.module';
import {
DEFAULT_LANGUAGE_CODE,
@ -81,6 +82,7 @@ import { UserModule } from './user/user.module';
OrderModule,
PlatformModule,
PortfolioModule,
PortfolioSnapshotQueueModule,
PrismaModule,
PropertyModule,
RedisCacheModule,

View File

@ -7,7 +7,10 @@ import { MarketDataService } from '@ghostfolio/api/services/market-data/market-d
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { PROPERTY_BENCHMARKS } from '@ghostfolio/common/config';
import {
CACHE_TTL_INFINITE,
PROPERTY_BENCHMARKS
} from '@ghostfolio/common/config';
import {
DATE_FORMAT,
calculateBenchmarkTrend,
@ -443,7 +446,7 @@ export class BenchmarkService {
benchmarks,
expiration: expiration.getTime()
}),
0
CACHE_TTL_INFINITE
);
}

View File

@ -7,10 +7,10 @@ import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.mo
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.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 { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { DataGatheringModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
import { Module } from '@nestjs/common';

View File

@ -9,9 +9,9 @@ import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { PlatformService } from '@ghostfolio/api/app/platform/platform.service';
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { DATA_GATHERING_QUEUE_PRIORITY_HIGH } from '@ghostfolio/common/config';
import {

View File

@ -4,10 +4,10 @@ import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.mo
import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.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 { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { DataGatheringModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
import { TagModule } from '@ghostfolio/api/services/tag/tag.module';

View File

@ -54,9 +54,6 @@ export class InfoService {
public async get(): Promise<InfoItem> {
const info: Partial<InfoItem> = {};
let isReadOnlyMode: boolean;
const platforms = await this.platformService.getPlatforms({
orderBy: { name: 'asc' }
});
const globalPermissions: string[] = [];
@ -100,22 +97,30 @@ export class InfoService {
globalPermissions.push(permissions.enableSystemMessage);
}
const isUserSignupEnabled =
await this.propertyService.isUserSignupEnabled();
const [
benchmarks,
demoAuthToken,
isUserSignupEnabled,
platforms,
statistics,
subscriptions,
tags
] = await Promise.all([
this.benchmarkService.getBenchmarkAssetProfiles(),
this.getDemoAuthToken(),
this.propertyService.isUserSignupEnabled(),
this.platformService.getPlatforms({
orderBy: { name: 'asc' }
}),
this.getStatistics(),
this.getSubscriptions(),
this.tagService.get()
]);
if (isUserSignupEnabled) {
globalPermissions.push(permissions.createUserAccount);
}
const [benchmarks, demoAuthToken, statistics, subscriptions, tags] =
await Promise.all([
this.benchmarkService.getBenchmarkAssetProfiles(),
this.getDemoAuthToken(),
this.getStatistics(),
this.getSubscriptions(),
this.tagService.get()
]);
return {
...info,
benchmarks,

View File

@ -4,8 +4,8 @@ import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service';
import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper';
import {
DATA_GATHERING_QUEUE_PRIORITY_HIGH,
@ -94,15 +94,18 @@ export class OrderController {
@Get()
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getAllOrders(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId,
@Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string,
@Query('dataSource') filterByDataSource?: string,
@Query('range') dateRange?: DateRange,
@Query('skip') skip?: number,
@Query('sortColumn') sortColumn?: string,
@Query('sortDirection') sortDirection?: Prisma.SortOrder,
@Query('symbol') filterBySymbol?: string,
@Query('tags') filterByTags?: string,
@Query('take') take?: number
): Promise<Activities> {
@ -116,6 +119,8 @@ export class OrderController {
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts,
filterByAssetClasses,
filterByDataSource,
filterBySymbol,
filterByTags
});

View File

@ -6,11 +6,11 @@ import { RedactValuesInResponseModule } from '@ghostfolio/api/interceptors/redac
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module';
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.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 { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { DataGatheringModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
import { Module } from '@nestjs/common';

View File

@ -1,9 +1,9 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event';
import { LogPerformance } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import {
DATA_GATHERING_QUEUE_PRIORITY_HIGH,
@ -350,6 +350,14 @@ export class OrderService {
return type;
});
const filterByDataSource = filters?.find(({ type }) => {
return type === 'DATA_SOURCE';
})?.id;
const filterBySymbol = filters?.find(({ type }) => {
return type === 'SYMBOL';
})?.id;
const searchQuery = filters?.find(({ type }) => {
return type === 'SEARCH_QUERY';
})?.id;
@ -395,6 +403,29 @@ export class OrderService {
};
}
if (filterByDataSource && filterBySymbol) {
if (where.SymbolProfile) {
where.SymbolProfile = {
AND: [
where.SymbolProfile,
{
AND: [
{ dataSource: <DataSource>filterByDataSource },
{ symbol: filterBySymbol }
]
}
]
};
} else {
where.SymbolProfile = {
AND: [
{ dataSource: <DataSource>filterByDataSource },
{ symbol: filterBySymbol }
]
};
}
}
if (searchQuery) {
const searchQueryWhereInput: Prisma.SymbolProfileWhereInput[] = [
{ id: { mode: 'insensitive', startsWith: searchQuery } },

View File

@ -3,6 +3,7 @@ import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.s
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
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 { Filter, HistoricalDataItem } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common';
@ -22,6 +23,7 @@ export class PortfolioCalculatorFactory {
private readonly configurationService: ConfigurationService,
private readonly currentRateService: CurrentRateService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly portfolioSnapshotService: PortfolioSnapshotService,
private readonly redisCacheService: RedisCacheService
) {}
@ -51,6 +53,7 @@ export class PortfolioCalculatorFactory {
configurationService: this.configurationService,
currentRateService: this.currentRateService,
exchangeRateDataService: this.exchangeRateDataService,
portfolioSnapshotService: this.portfolioSnapshotService,
redisCacheService: this.redisCacheService
});
case PerformanceCalculationType.TWR:
@ -63,6 +66,7 @@ export class PortfolioCalculatorFactory {
userId,
configurationService: this.configurationService,
exchangeRateDataService: this.exchangeRateDataService,
portfolioSnapshotService: this.portfolioSnapshotService,
redisCacheService: this.redisCacheService
});
default:

View File

@ -10,7 +10,14 @@ import { LogPerformance } from '@ghostfolio/api/interceptors/performance-logging
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper';
import {
PORTFOLIO_SNAPSHOT_PROCESS_JOB_NAME,
PORTFOLIO_SNAPSHOT_PROCESS_JOB_OPTIONS,
PORTFOLIO_SNAPSHOT_QUEUE_PRIORITY_HIGH,
PORTFOLIO_SNAPSHOT_QUEUE_PRIORITY_LOW
} from '@ghostfolio/common/config';
import {
DATE_FORMAT,
getSum,
@ -33,7 +40,6 @@ import { Logger } from '@nestjs/common';
import { Big } from 'big.js';
import { plainToClass } from 'class-transformer';
import {
addMilliseconds,
differenceInDays,
eachDayOfInterval,
endOfDay,
@ -58,6 +64,7 @@ export abstract class PortfolioCalculator {
private endDate: Date;
private exchangeRateDataService: ExchangeRateDataService;
private filters: Filter[];
private portfolioSnapshotService: PortfolioSnapshotService;
private redisCacheService: RedisCacheService;
private snapshot: PortfolioSnapshot;
private snapshotPromise: Promise<void>;
@ -73,6 +80,7 @@ export abstract class PortfolioCalculator {
currentRateService,
exchangeRateDataService,
filters,
portfolioSnapshotService,
redisCacheService,
userId
}: {
@ -83,6 +91,7 @@ export abstract class PortfolioCalculator {
currentRateService: CurrentRateService;
exchangeRateDataService: ExchangeRateDataService;
filters: Filter[];
portfolioSnapshotService: PortfolioSnapshotService;
redisCacheService: RedisCacheService;
userId: string;
}) {
@ -131,6 +140,7 @@ export abstract class PortfolioCalculator {
return a.date?.localeCompare(b.date);
});
this.portfolioSnapshotService = portfolioSnapshotService;
this.redisCacheService = redisCacheService;
this.userId = userId;
@ -152,7 +162,7 @@ export abstract class PortfolioCalculator {
): PortfolioSnapshot;
@LogPerformance
private async computeSnapshot(): Promise<PortfolioSnapshot> {
public async computeSnapshot(): Promise<PortfolioSnapshot> {
const lastTransactionPoint = last(this.transactionPoints);
const transactionPoints = this.transactionPoints?.filter(({ date }) => {
@ -865,29 +875,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(<PortfolioSnapshotValue>(<unknown>{
expiration: expiration.getTime(),
portfolioSnapshot: snapshot
})),
0
);
return snapshot;
}
@LogPerformance
private computeTransactionPoints() {
this.transactionPoints = [];
@ -1033,6 +1020,7 @@ export abstract class PortfolioCalculator {
let cachedPortfolioSnapshot: PortfolioSnapshot;
let isCachedPortfolioSnapshotExpired = false;
const jobId = this.userId;
try {
const cachedPortfolioSnapshotValue = await this.redisCacheService.get(
@ -1068,11 +1056,43 @@ export abstract class PortfolioCalculator {
if (isCachedPortfolioSnapshotExpired) {
// Compute in the background
this.computeAndCacheSnapshot();
this.portfolioSnapshotService.addJobToQueue({
data: {
filters: this.filters,
userCurrency: this.currency,
userId: this.userId
},
name: PORTFOLIO_SNAPSHOT_PROCESS_JOB_NAME,
opts: {
...PORTFOLIO_SNAPSHOT_PROCESS_JOB_OPTIONS,
jobId,
priority: PORTFOLIO_SNAPSHOT_QUEUE_PRIORITY_LOW
}
});
}
} else {
// Wait for computation
this.snapshot = await this.computeAndCacheSnapshot();
await this.portfolioSnapshotService.addJobToQueue({
data: {
filters: this.filters,
userCurrency: this.currency,
userId: this.userId
},
name: PORTFOLIO_SNAPSHOT_PROCESS_JOB_NAME,
opts: {
...PORTFOLIO_SNAPSHOT_PROCESS_JOB_OPTIONS,
jobId,
priority: PORTFOLIO_SNAPSHOT_QUEUE_PRIORITY_HIGH
}
});
const job = await this.portfolioSnapshotService.getJob(jobId);
if (job) {
await job.finished();
}
await this.initialize();
}
}
}

View File

@ -14,6 +14,8 @@ 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 { 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';
@ -28,6 +30,18 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
};
});
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
@ -41,7 +55,8 @@ describe('PortfolioCalculator', () => {
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
let factory: PortfolioCalculatorFactory;
let portfolioCalculatorFactory: PortfolioCalculatorFactory;
let portfolioSnapshotService: PortfolioSnapshotService;
let redisCacheService: RedisCacheService;
beforeEach(() => {
@ -56,12 +71,15 @@ describe('PortfolioCalculator', () => {
null
);
portfolioSnapshotService = new PortfolioSnapshotService(null);
redisCacheService = new RedisCacheService(null, null);
factory = new PortfolioCalculatorFactory(
portfolioCalculatorFactory = new PortfolioCalculatorFactory(
configurationService,
currentRateService,
exchangeRateDataService,
portfolioSnapshotService,
redisCacheService
);
});
@ -118,14 +136,14 @@ describe('PortfolioCalculator', () => {
}
];
const portfolioCalculator = factory.createCalculator({
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.TWR,
currency: 'CHF',
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.getSnapshot();
const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
const investments = portfolioCalculator.getInvestments();

View File

@ -14,6 +14,8 @@ 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 { 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';
@ -28,6 +30,18 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
};
});
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
@ -41,7 +55,8 @@ describe('PortfolioCalculator', () => {
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
let factory: PortfolioCalculatorFactory;
let portfolioCalculatorFactory: PortfolioCalculatorFactory;
let portfolioSnapshotService: PortfolioSnapshotService;
let redisCacheService: RedisCacheService;
beforeEach(() => {
@ -56,12 +71,15 @@ describe('PortfolioCalculator', () => {
null
);
portfolioSnapshotService = new PortfolioSnapshotService(null);
redisCacheService = new RedisCacheService(null, null);
factory = new PortfolioCalculatorFactory(
portfolioCalculatorFactory = new PortfolioCalculatorFactory(
configurationService,
currentRateService,
exchangeRateDataService,
portfolioSnapshotService,
redisCacheService
);
});
@ -103,14 +121,14 @@ describe('PortfolioCalculator', () => {
}
];
const portfolioCalculator = factory.createCalculator({
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.TWR,
currency: 'CHF',
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.getSnapshot();
const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
const investments = portfolioCalculator.getInvestments();

View File

@ -14,6 +14,8 @@ 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 { 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';
@ -28,6 +30,18 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
};
});
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
@ -41,7 +55,8 @@ describe('PortfolioCalculator', () => {
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
let factory: PortfolioCalculatorFactory;
let portfolioCalculatorFactory: PortfolioCalculatorFactory;
let portfolioSnapshotService: PortfolioSnapshotService;
let redisCacheService: RedisCacheService;
beforeEach(() => {
@ -56,12 +71,15 @@ describe('PortfolioCalculator', () => {
null
);
portfolioSnapshotService = new PortfolioSnapshotService(null);
redisCacheService = new RedisCacheService(null, null);
factory = new PortfolioCalculatorFactory(
portfolioCalculatorFactory = new PortfolioCalculatorFactory(
configurationService,
currentRateService,
exchangeRateDataService,
portfolioSnapshotService,
redisCacheService
);
});
@ -88,14 +106,14 @@ describe('PortfolioCalculator', () => {
}
];
const portfolioCalculator = factory.createCalculator({
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.TWR,
currency: 'CHF',
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.getSnapshot();
const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
const investments = portfolioCalculator.getInvestments();

View File

@ -15,6 +15,8 @@ import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cac
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service.mock';
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';
@ -29,6 +31,18 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
};
});
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
@ -54,7 +68,8 @@ describe('PortfolioCalculator', () => {
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
let factory: PortfolioCalculatorFactory;
let portfolioCalculatorFactory: PortfolioCalculatorFactory;
let portfolioSnapshotService: PortfolioSnapshotService;
let redisCacheService: RedisCacheService;
beforeEach(() => {
@ -69,12 +84,15 @@ describe('PortfolioCalculator', () => {
null
);
portfolioSnapshotService = new PortfolioSnapshotService(null);
redisCacheService = new RedisCacheService(null, null);
factory = new PortfolioCalculatorFactory(
portfolioCalculatorFactory = new PortfolioCalculatorFactory(
configurationService,
currentRateService,
exchangeRateDataService,
portfolioSnapshotService,
redisCacheService
);
});
@ -117,14 +135,14 @@ describe('PortfolioCalculator', () => {
}
];
const portfolioCalculator = factory.createCalculator({
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.TWR,
currency: 'CHF',
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.getSnapshot();
const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
const investments = portfolioCalculator.getInvestments();

View File

@ -14,6 +14,8 @@ 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 { 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';
@ -28,6 +30,18 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
};
});
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
@ -41,7 +55,8 @@ describe('PortfolioCalculator', () => {
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
let factory: PortfolioCalculatorFactory;
let portfolioCalculatorFactory: PortfolioCalculatorFactory;
let portfolioSnapshotService: PortfolioSnapshotService;
let redisCacheService: RedisCacheService;
beforeEach(() => {
@ -56,12 +71,15 @@ describe('PortfolioCalculator', () => {
null
);
portfolioSnapshotService = new PortfolioSnapshotService(null);
redisCacheService = new RedisCacheService(null, null);
factory = new PortfolioCalculatorFactory(
portfolioCalculatorFactory = new PortfolioCalculatorFactory(
configurationService,
currentRateService,
exchangeRateDataService,
portfolioSnapshotService,
redisCacheService
);
});
@ -88,14 +106,14 @@ describe('PortfolioCalculator', () => {
}
];
const portfolioCalculator = factory.createCalculator({
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.TWR,
currency: 'USD',
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.getSnapshot();
const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
expect(portfolioSnapshot).toMatchObject({
currentValueInBaseCurrency: new Big('0'),

View File

@ -15,6 +15,8 @@ import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cac
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service.mock';
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';
@ -29,6 +31,18 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
};
});
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
@ -54,7 +68,8 @@ describe('PortfolioCalculator', () => {
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
let factory: PortfolioCalculatorFactory;
let portfolioCalculatorFactory: PortfolioCalculatorFactory;
let portfolioSnapshotService: PortfolioSnapshotService;
let redisCacheService: RedisCacheService;
beforeEach(() => {
@ -69,12 +84,15 @@ describe('PortfolioCalculator', () => {
null
);
portfolioSnapshotService = new PortfolioSnapshotService(null);
redisCacheService = new RedisCacheService(null, null);
factory = new PortfolioCalculatorFactory(
portfolioCalculatorFactory = new PortfolioCalculatorFactory(
configurationService,
currentRateService,
exchangeRateDataService,
portfolioSnapshotService,
redisCacheService
);
});
@ -101,14 +119,14 @@ describe('PortfolioCalculator', () => {
}
];
const portfolioCalculator = factory.createCalculator({
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.TWR,
currency: 'CHF',
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.getSnapshot();
const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
const investments = portfolioCalculator.getInvestments();

View File

@ -14,6 +14,8 @@ 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 { 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';
@ -28,6 +30,18 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
};
});
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
@ -41,7 +55,8 @@ describe('PortfolioCalculator', () => {
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
let factory: PortfolioCalculatorFactory;
let portfolioCalculatorFactory: PortfolioCalculatorFactory;
let portfolioSnapshotService: PortfolioSnapshotService;
let redisCacheService: RedisCacheService;
beforeEach(() => {
@ -56,12 +71,15 @@ describe('PortfolioCalculator', () => {
null
);
portfolioSnapshotService = new PortfolioSnapshotService(null);
redisCacheService = new RedisCacheService(null, null);
factory = new PortfolioCalculatorFactory(
portfolioCalculatorFactory = new PortfolioCalculatorFactory(
configurationService,
currentRateService,
exchangeRateDataService,
portfolioSnapshotService,
redisCacheService
);
});
@ -88,14 +106,14 @@ describe('PortfolioCalculator', () => {
}
];
const portfolioCalculator = factory.createCalculator({
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.TWR,
currency: 'USD',
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.getSnapshot();
const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
expect(portfolioSnapshot).toMatchObject({
currentValueInBaseCurrency: new Big('0'),

View File

@ -14,6 +14,8 @@ 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 { 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';
@ -27,6 +29,18 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
};
});
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
@ -40,7 +54,8 @@ describe('PortfolioCalculator', () => {
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
let factory: PortfolioCalculatorFactory;
let portfolioCalculatorFactory: PortfolioCalculatorFactory;
let portfolioSnapshotService: PortfolioSnapshotService;
let redisCacheService: RedisCacheService;
beforeEach(() => {
@ -55,12 +70,15 @@ describe('PortfolioCalculator', () => {
null
);
portfolioSnapshotService = new PortfolioSnapshotService(null);
redisCacheService = new RedisCacheService(null, null);
factory = new PortfolioCalculatorFactory(
portfolioCalculatorFactory = new PortfolioCalculatorFactory(
configurationService,
currentRateService,
exchangeRateDataService,
portfolioSnapshotService,
redisCacheService
);
});
@ -87,17 +105,18 @@ describe('PortfolioCalculator', () => {
}
];
const portfolioCalculator = factory.createCalculator({
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.TWR,
currency: 'USD',
userId: userDummyData.id
});
const liabilitiesInBaseCurrency =
await portfolioCalculator.getLiabilitiesInBaseCurrency();
const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
expect(liabilitiesInBaseCurrency).toEqual(new Big(3000));
expect(portfolioSnapshot.totalLiabilitiesWithCurrencyEffect).toEqual(
new Big(3000)
);
});
});
});

View File

@ -15,6 +15,8 @@ import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cac
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service.mock';
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';
@ -29,6 +31,18 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
};
});
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
@ -54,7 +68,8 @@ describe('PortfolioCalculator', () => {
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
let factory: PortfolioCalculatorFactory;
let portfolioCalculatorFactory: PortfolioCalculatorFactory;
let portfolioSnapshotService: PortfolioSnapshotService;
let redisCacheService: RedisCacheService;
beforeEach(() => {
@ -69,12 +84,15 @@ describe('PortfolioCalculator', () => {
null
);
portfolioSnapshotService = new PortfolioSnapshotService(null);
redisCacheService = new RedisCacheService(null, null);
factory = new PortfolioCalculatorFactory(
portfolioCalculatorFactory = new PortfolioCalculatorFactory(
configurationService,
currentRateService,
exchangeRateDataService,
portfolioSnapshotService,
redisCacheService
);
});
@ -116,14 +134,14 @@ describe('PortfolioCalculator', () => {
}
];
const portfolioCalculator = factory.createCalculator({
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.TWR,
currency: 'USD',
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.getSnapshot();
const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
expect(portfolioSnapshot).toMatchObject({
errors: [],

View File

@ -9,11 +9,11 @@ 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 { 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 { subDays } from 'date-fns';
import { last } from 'lodash';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
@ -24,6 +24,18 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
};
});
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
@ -37,7 +49,8 @@ describe('PortfolioCalculator', () => {
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
let factory: PortfolioCalculatorFactory;
let portfolioCalculatorFactory: PortfolioCalculatorFactory;
let portfolioSnapshotService: PortfolioSnapshotService;
let redisCacheService: RedisCacheService;
beforeEach(() => {
@ -52,12 +65,15 @@ describe('PortfolioCalculator', () => {
null
);
portfolioSnapshotService = new PortfolioSnapshotService(null);
redisCacheService = new RedisCacheService(null, null);
factory = new PortfolioCalculatorFactory(
portfolioCalculatorFactory = new PortfolioCalculatorFactory(
configurationService,
currentRateService,
exchangeRateDataService,
portfolioSnapshotService,
redisCacheService
);
});
@ -66,14 +82,14 @@ describe('PortfolioCalculator', () => {
it('with no orders', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2021-12-18').getTime());
const portfolioCalculator = factory.createCalculator({
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities: [],
calculationType: PerformanceCalculationType.TWR,
currency: 'CHF',
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.getSnapshot();
const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
const investments = portfolioCalculator.getInvestments();

View File

@ -14,6 +14,8 @@ 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 { 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';
@ -28,6 +30,18 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
};
});
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
@ -41,7 +55,8 @@ describe('PortfolioCalculator', () => {
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
let factory: PortfolioCalculatorFactory;
let portfolioCalculatorFactory: PortfolioCalculatorFactory;
let portfolioSnapshotService: PortfolioSnapshotService;
let redisCacheService: RedisCacheService;
beforeEach(() => {
@ -56,12 +71,15 @@ describe('PortfolioCalculator', () => {
null
);
portfolioSnapshotService = new PortfolioSnapshotService(null);
redisCacheService = new RedisCacheService(null, null);
factory = new PortfolioCalculatorFactory(
portfolioCalculatorFactory = new PortfolioCalculatorFactory(
configurationService,
currentRateService,
exchangeRateDataService,
portfolioSnapshotService,
redisCacheService
);
});
@ -103,14 +121,14 @@ describe('PortfolioCalculator', () => {
}
];
const portfolioCalculator = factory.createCalculator({
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.TWR,
currency: 'CHF',
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.getSnapshot();
const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
const investments = portfolioCalculator.getInvestments();

View File

@ -14,6 +14,8 @@ 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 { 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';
@ -28,6 +30,18 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
};
});
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
@ -41,7 +55,8 @@ describe('PortfolioCalculator', () => {
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
let factory: PortfolioCalculatorFactory;
let portfolioCalculatorFactory: PortfolioCalculatorFactory;
let portfolioSnapshotService: PortfolioSnapshotService;
let redisCacheService: RedisCacheService;
beforeEach(() => {
@ -56,12 +71,15 @@ describe('PortfolioCalculator', () => {
null
);
portfolioSnapshotService = new PortfolioSnapshotService(null);
redisCacheService = new RedisCacheService(null, null);
factory = new PortfolioCalculatorFactory(
portfolioCalculatorFactory = new PortfolioCalculatorFactory(
configurationService,
currentRateService,
exchangeRateDataService,
portfolioSnapshotService,
redisCacheService
);
});
@ -103,14 +121,14 @@ describe('PortfolioCalculator', () => {
}
];
const portfolioCalculator = factory.createCalculator({
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.TWR,
currency: 'CHF',
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.getSnapshot();
const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
const investments = portfolioCalculator.getInvestments();

View File

@ -3,12 +3,14 @@ import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.s
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
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';
describe('PortfolioCalculator', () => {
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
let factory: PortfolioCalculatorFactory;
let portfolioCalculatorFactory: PortfolioCalculatorFactory;
let portfolioSnapshotService: PortfolioSnapshotService;
let redisCacheService: RedisCacheService;
beforeEach(() => {
@ -23,12 +25,15 @@ describe('PortfolioCalculator', () => {
null
);
portfolioSnapshotService = new PortfolioSnapshotService(null);
redisCacheService = new RedisCacheService(null, null);
factory = new PortfolioCalculatorFactory(
portfolioCalculatorFactory = new PortfolioCalculatorFactory(
configurationService,
currentRateService,
exchangeRateDataService,
portfolioSnapshotService,
redisCacheService
);
});

View File

@ -10,12 +10,13 @@ import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors
import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module';
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.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 { DataGatheringModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.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';
@ -40,6 +41,7 @@ import { RulesService } from './rules.service';
MarketDataModule,
OrderModule,
PerformanceLoggingModule,
PortfolioSnapshotQueueModule,
PrismaModule,
RedactValuesInResponseModule,
RedisCacheModule,

View File

@ -602,14 +602,7 @@ export class PortfolioService {
userId
});
const orders = activities.filter(({ SymbolProfile }) => {
return (
SymbolProfile.dataSource === aDataSource &&
SymbolProfile.symbol === aSymbol
);
});
if (orders.length <= 0) {
if (activities.length === 0) {
return {
accounts: [],
averagePrice: undefined,
@ -646,10 +639,8 @@ export class PortfolioService {
]);
const portfolioCalculator = this.calculatorFactory.createCalculator({
activities,
userId,
activities: orders.filter((order) => {
return ['BUY', 'DIVIDEND', 'ITEM', 'SELL'].includes(order.type);
}),
calculationType: PerformanceCalculationType.TWR,
currency: userCurrency
});
@ -659,8 +650,8 @@ export class PortfolioService {
const { positions } = await portfolioCalculator.getSnapshot();
const position = positions.find(({ symbol }) => {
return symbol === aSymbol;
const position = positions.find(({ dataSource, symbol }) => {
return dataSource === aDataSource && symbol === aSymbol;
});
if (position) {
@ -673,14 +664,22 @@ export class PortfolioService {
firstBuyDate,
marketPrice,
quantity,
symbol,
tags,
timeWeightedInvestment,
timeWeightedInvestmentWithCurrencyEffect,
transactionCount
} = position;
const activitiesOfPosition = activities.filter(({ SymbolProfile }) => {
return (
SymbolProfile.dataSource === dataSource &&
SymbolProfile.symbol === symbol
);
});
const accounts: PortfolioHoldingDetail['accounts'] = uniqBy(
orders.filter(({ Account }) => {
activitiesOfPosition.filter(({ Account }) => {
return Account;
}),
'Account.id'
@ -715,8 +714,8 @@ export class PortfolioService {
);
const historicalDataArray: HistoricalDataItem[] = [];
let maxPrice = Math.max(orders[0].unitPrice, marketPrice);
let minPrice = Math.min(orders[0].unitPrice, marketPrice);
let maxPrice = Math.max(activitiesOfPosition[0].unitPrice, marketPrice);
let minPrice = Math.min(activitiesOfPosition[0].unitPrice, marketPrice);
if (historicalData[aSymbol]) {
let j = -1;
@ -760,10 +759,10 @@ export class PortfolioService {
} else {
// Add historical entry for buy date, if no historical data available
historicalDataArray.push({
averagePrice: orders[0].unitPrice,
averagePrice: activitiesOfPosition[0].unitPrice,
date: firstBuyDate,
marketPrice: orders[0].unitPrice,
quantity: orders[0].quantity
marketPrice: activitiesOfPosition[0].unitPrice,
quantity: activitiesOfPosition[0].quantity
});
}
@ -773,7 +772,6 @@ export class PortfolioService {
marketPrice,
maxPrice,
minPrice,
orders,
SymbolProfile,
tags,
transactionCount,
@ -805,6 +803,7 @@ export class PortfolioService {
]?.toNumber(),
netPerformanceWithCurrencyEffect:
position.netPerformanceWithCurrencyEffectMap?.['max']?.toNumber(),
orders: activitiesOfPosition,
quantity: quantity.toNumber(),
value: this.exchangeRateDataService.toCurrency(
quantity.mul(marketPrice ?? 0).toNumber(),
@ -862,7 +861,6 @@ export class PortfolioService {
marketPrice,
maxPrice,
minPrice,
orders,
SymbolProfile,
accounts: [],
averagePrice: 0,
@ -882,6 +880,7 @@ export class PortfolioService {
netPerformancePercent: undefined,
netPerformancePercentWithCurrencyEffect: undefined,
netPerformanceWithCurrencyEffect: undefined,
orders: [],
quantity: 0,
tags: [],
transactionCount: undefined,
@ -912,7 +911,7 @@ export class PortfolioService {
userCurrency: this.getUserCurrency()
});
if (activities?.length <= 0) {
if (activities.length === 0) {
return {
hasErrors: false,
positions: []
@ -1037,14 +1036,12 @@ export class PortfolioService {
dateRange = 'max',
filters,
impersonationId,
portfolioCalculator,
userId,
withExcludedAccounts = false
}: {
dateRange?: DateRange;
filters?: Filter[];
impersonationId: string;
portfolioCalculator?: PortfolioCalculator;
userId: string;
withExcludedAccounts?: boolean;
}): Promise<PortfolioPerformanceResponse> {
@ -1089,7 +1086,7 @@ export class PortfolioService {
userId
});
if (accountBalanceItems?.length <= 0 && activities?.length <= 0) {
if (accountBalanceItems.length === 0 && activities.length === 0) {
return {
chart: [],
firstOrderDate: undefined,
@ -1106,16 +1103,14 @@ export class PortfolioService {
};
}
portfolioCalculator =
portfolioCalculator ??
this.calculatorFactory.createCalculator({
accountBalanceItems,
activities,
filters,
userId,
calculationType: PerformanceCalculationType.TWR,
currency: userCurrency
});
const portfolioCalculator = this.calculatorFactory.createCalculator({
accountBalanceItems,
activities,
filters,
userId,
calculationType: PerformanceCalculationType.TWR,
currency: userCurrency
});
const { errors, hasErrors, historicalData } =
await portfolioCalculator.getSnapshot();

View File

@ -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);
}
};

View File

@ -39,12 +39,12 @@ export class TransformDataSourceInRequestInterceptor<T>
});
}
if (request.body.dataSource && !DataSource[request.body.dataSource]) {
request.body.dataSource = decodeDataSource(request.body.dataSource);
}
for (const type of ['body', 'params', 'query']) {
const dataSourceValue = request[type]?.dataSource;
if (request.params.dataSource && !DataSource[request.params.dataSource]) {
request.params.dataSource = decodeDataSource(request.params.dataSource);
if (dataSourceValue && !DataSource[dataSourceValue]) {
request[type].dataSource = decodeDataSource(dataSourceValue);
}
}
}

View File

@ -10,22 +10,28 @@ export class ApiService {
filterByAccounts,
filterByAssetClasses,
filterByAssetSubClasses,
filterByDataSource,
filterByHoldingType,
filterBySearchQuery,
filterBySymbol,
filterByTags
}: {
filterByAccounts?: string;
filterByAssetClasses?: string;
filterByAssetSubClasses?: string;
filterByDataSource?: string;
filterByHoldingType?: string;
filterBySearchQuery?: string;
filterBySymbol?: string;
filterByTags?: string;
}): Filter[] {
const accountIds = filterByAccounts?.split(',') ?? [];
const assetClasses = filterByAssetClasses?.split(',') ?? [];
const assetSubClasses = filterByAssetSubClasses?.split(',') ?? [];
const dataSource = filterByDataSource;
const holdingType = filterByHoldingType;
const searchQuery = filterBySearchQuery?.toLowerCase();
const symbol = filterBySymbol;
const tagIds = filterByTags?.split(',') ?? [];
const filters = [
@ -55,6 +61,13 @@ export class ApiService {
})
];
if (dataSource) {
filters.push({
id: dataSource,
type: 'DATA_SOURCE'
});
}
if (holdingType) {
filters.push({
id: holdingType,
@ -69,6 +82,13 @@ export class ApiService {
});
}
if (symbol) {
filters.push({
id: symbol,
type: 'SYMBOL'
});
}
return filters;
}
}

View File

@ -1,5 +1,8 @@
import { Environment } from '@ghostfolio/api/services/interfaces/environment.interface';
import { DEFAULT_ROOT_URL } from '@ghostfolio/common/config';
import {
CACHE_TTL_NO_CACHE,
DEFAULT_ROOT_URL
} from '@ghostfolio/common/config';
import { Injectable } from '@nestjs/common';
import { DataSource } from '@prisma/client';
@ -22,7 +25,7 @@ export class ConfigurationService {
API_KEY_OPEN_FIGI: str({ default: '' }),
API_KEY_RAPID_API: str({ default: '' }),
CACHE_QUOTES_TTL: num({ default: ms('1 minute') }),
CACHE_TTL: num({ default: 1 }),
CACHE_TTL: num({ default: CACHE_TTL_NO_CACHE }),
DATA_SOURCE_EXCHANGE_RATES: str({ default: DataSource.YAHOO }),
DATA_SOURCE_IMPORT: str({ default: DataSource.YAHOO }),
DATA_SOURCES: json({

View File

@ -9,9 +9,9 @@ import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
import { Injectable } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { DataGatheringService } from './data-gathering/data-gathering.service';
import { ExchangeRateDataService } from './exchange-rate-data/exchange-rate-data.service';
import { PropertyService } from './property/property.service';
import { DataGatheringService } from './queues/data-gathering/data-gathering.service';
import { TwitterBotService } from './twitter-bot/twitter-bot.service';
@Injectable()

View File

@ -166,11 +166,42 @@ export class ManualService implements DataProviderInterface {
}
});
const symbolProfilesWithScraperConfigurationAndInstantMode =
symbolProfiles.filter(({ scraperConfiguration }) => {
return scraperConfiguration?.mode === 'instant';
});
const scraperResultPromises =
symbolProfilesWithScraperConfigurationAndInstantMode.map(
async ({ scraperConfiguration, symbol }) => {
try {
const marketPrice = await this.scrape(scraperConfiguration);
return { marketPrice, symbol };
} catch (error) {
Logger.error(
`Could not get quote for ${symbol} (${this.getName()}): [${error.name}] ${error.message}`,
'ManualService'
);
return { symbol, marketPrice: undefined };
}
}
);
// Wait for all scraping requests to complete concurrently
const scraperResults = await Promise.all(scraperResultPromises);
for (const { currency, symbol } of symbolProfiles) {
let marketPrice =
let { marketPrice } =
scraperResults.find((result) => {
return result.symbol === symbol;
}) ?? {};
marketPrice =
marketPrice ??
marketData.find((marketDataItem) => {
return marketDataItem.symbol === symbol;
})?.marketPrice ?? 0;
})?.marketPrice ??
0;
response[symbol] = {
currency,

View File

@ -20,6 +20,11 @@ import { Injectable, Logger } from '@nestjs/common';
import { DataSource, SymbolProfile } from '@prisma/client';
import { addDays, format, isSameDay } from 'date-fns';
import yahooFinance from 'yahoo-finance2';
import { ChartResultArray } from 'yahoo-finance2/dist/esm/src/modules/chart';
import {
HistoricalDividendsResult,
HistoricalHistoryResult
} from 'yahoo-finance2/dist/esm/src/modules/historical';
import { Quote } from 'yahoo-finance2/dist/esm/src/modules/quote';
@Injectable()
@ -60,18 +65,19 @@ export class YahooFinanceService implements DataProviderInterface {
}
try {
const historicalResult = await yahooFinance.historical(
this.yahooFinanceDataEnhancerService.convertToYahooFinanceSymbol(
symbol
),
{
events: 'dividends',
interval: granularity === 'month' ? '1mo' : '1d',
period1: format(from, DATE_FORMAT),
period2: format(to, DATE_FORMAT)
}
const historicalResult = this.convertToDividendResult(
await yahooFinance.chart(
this.yahooFinanceDataEnhancerService.convertToYahooFinanceSymbol(
symbol
),
{
events: 'dividends',
interval: granularity === 'month' ? '1mo' : '1d',
period1: format(from, DATE_FORMAT),
period2: format(to, DATE_FORMAT)
}
)
);
const response: {
[date: string]: IDataProviderHistoricalResponse;
} = {};
@ -108,15 +114,17 @@ export class YahooFinanceService implements DataProviderInterface {
}
try {
const historicalResult = await yahooFinance.historical(
this.yahooFinanceDataEnhancerService.convertToYahooFinanceSymbol(
symbol
),
{
interval: '1d',
period1: format(from, DATE_FORMAT),
period2: format(to, DATE_FORMAT)
}
const historicalResult = this.convertToHistoricalResult(
await yahooFinance.chart(
this.yahooFinanceDataEnhancerService.convertToYahooFinanceSymbol(
symbol
),
{
interval: '1d',
period1: format(from, DATE_FORMAT),
period2: format(to, DATE_FORMAT)
}
)
);
const response: {
@ -302,6 +310,20 @@ export class YahooFinanceService implements DataProviderInterface {
return { items };
}
private convertToDividendResult(
result: ChartResultArray
): HistoricalDividendsResult {
return result.events.dividends.map(({ amount: dividends, date }) => {
return { date, dividends };
});
}
private convertToHistoricalResult(
result: ChartResultArray
): HistoricalHistoryResult {
return result.quotes;
}
private async getQuotesWithQuoteSummary(aYahooFinanceSymbols: string[]) {
const quoteSummaryPromises = aYahooFinanceSymbols.map((symbol) => {
return yahooFinance.quoteSummary(symbol).catch(() => {

View File

@ -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';

View File

@ -4,7 +4,7 @@ import { MarketDataService } from '@ghostfolio/api/services/market-data/market-d
import {
DATA_GATHERING_QUEUE,
GATHER_ASSET_PROFILE_PROCESS,
GATHER_HISTORICAL_MARKET_DATA_PROCESS
GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME
} from '@ghostfolio/common/config';
import { DATE_FORMAT, getStartOfUtcDate } from '@ghostfolio/common/helper';
import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
@ -58,7 +58,10 @@ export class DataGatheringProcessor {
}
}
@Process({ concurrency: 1, name: GATHER_HISTORICAL_MARKET_DATA_PROCESS })
@Process({
concurrency: 1,
name: GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME
})
public async gatherHistoricalMarketData(job: Job<IDataGatheringItem>) {
try {
const { dataSource, date, symbol } = job.data;
@ -69,7 +72,7 @@ export class DataGatheringProcessor {
currentDate,
DATE_FORMAT
)}`,
`DataGatheringProcessor (${GATHER_HISTORICAL_MARKET_DATA_PROCESS})`
`DataGatheringProcessor (${GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME})`
);
const historicalData = await this.dataProviderService.getHistoricalRaw({
@ -123,12 +126,12 @@ export class DataGatheringProcessor {
currentDate,
DATE_FORMAT
)}`,
`DataGatheringProcessor (${GATHER_HISTORICAL_MARKET_DATA_PROCESS})`
`DataGatheringProcessor (${GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME})`
);
} catch (error) {
Logger.error(
error,
`DataGatheringProcessor (${GATHER_HISTORICAL_MARKET_DATA_PROCESS})`
`DataGatheringProcessor (${GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME})`
);
throw new Error(error);

View File

@ -11,8 +11,8 @@ import {
DATA_GATHERING_QUEUE_PRIORITY_HIGH,
DATA_GATHERING_QUEUE_PRIORITY_LOW,
DATA_GATHERING_QUEUE_PRIORITY_MEDIUM,
GATHER_HISTORICAL_MARKET_DATA_PROCESS,
GATHER_HISTORICAL_MARKET_DATA_PROCESS_OPTIONS,
GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME,
GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_OPTIONS,
PROPERTY_BENCHMARKS
} from '@ghostfolio/common/config';
import {
@ -279,9 +279,9 @@ export class DataGatheringService {
date,
symbol
},
name: GATHER_HISTORICAL_MARKET_DATA_PROCESS,
name: GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME,
opts: {
...GATHER_HISTORICAL_MARKET_DATA_PROCESS_OPTIONS,
...GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_OPTIONS,
priority,
jobId: `${getAssetProfileIdentifier({
dataSource,

View File

@ -0,0 +1,7 @@
import { Filter } from '@ghostfolio/common/interfaces';
export interface IPortfolioSnapshotQueueJob {
filters: Filter[];
userCurrency: string;
userId: string;
}

View File

@ -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 {}

View File

@ -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);
}
}
}

View File

@ -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>>>()
};

View File

@ -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);
}
}

View File

@ -275,6 +275,8 @@ export class SymbolProfileService {
headers:
scraperConfiguration.headers as ScraperConfiguration['headers'],
locale: scraperConfiguration.locale as string,
mode:
(scraperConfiguration.mode as ScraperConfiguration['mode']) ?? 'lazy',
selector: scraperConfiguration.selector as string,
url: scraperConfiguration.url as string
};

View File

@ -51,6 +51,7 @@ export class AdminJobsComponent implements OnDestroy, OnInit {
'status',
'actions'
];
public isLoading = false;
public statusFilterOptions = QUEUE_JOB_STATUS_LIST;
public user: User;
@ -138,12 +139,16 @@ export class AdminJobsComponent implements OnDestroy, OnInit {
}
private fetchJobs(aStatus?: JobStatus[]) {
this.isLoading = true;
this.adminService
.fetchJobs({ status: aStatus })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ jobs }) => {
this.dataSource = new MatTableDataSource(jobs);
this.isLoading = false;
this.changeDetectorRef.markForCheck();
});
}

View File

@ -35,6 +35,8 @@
<ng-container i18n>Asset Profile</ng-container>
} @else if (element.name === 'GATHER_HISTORICAL_MARKET_DATA') {
<ng-container i18n>Historical Market Data</ng-container>
} @else if (element.name === 'PORTFOLIO') {
<ng-container i18n>Portfolio Snapshot</ng-container>
}
</td>
</ng-container>
@ -183,6 +185,16 @@
<tr *matHeaderRowDef="displayedColumns" mat-header-row></tr>
<tr *matRowDef="let row; columns: displayedColumns" mat-row></tr>
</table>
@if (isLoading) {
<ngx-skeleton-loader
animation="pulse"
class="px-4 py-3"
[theme]="{
height: '1.5rem',
width: '100%'
}"
/>
}
</div>
</div>
</div>

View File

@ -5,6 +5,7 @@ import { MatButtonModule } from '@angular/material/button';
import { MatMenuModule } from '@angular/material/menu';
import { MatSelectModule } from '@angular/material/select';
import { MatTableModule } from '@angular/material/table';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { AdminJobsComponent } from './admin-jobs.component';
@ -17,6 +18,7 @@ import { AdminJobsComponent } from './admin-jobs.component';
MatMenuModule,
MatSelectModule,
MatTableModule,
NgxSkeletonLoaderModule,
ReactiveFormsModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]

View File

@ -93,52 +93,52 @@ export class AdminMarketDataDetailComponent implements OnChanges, OnInit {
};
});
let date = parseISO(this.dateOfFirstActivity);
const missingMarketData: Partial<MarketData>[] = [];
if (this.historicalDataItems?.[0]?.date) {
while (
isBefore(
date,
parse(this.historicalDataItems[0].date, DATE_FORMAT, new Date())
)
) {
missingMarketData.push({
date,
marketPrice: undefined
});
date = addDays(date, 1);
}
}
const marketDataItems = [...missingMarketData, ...this.marketData];
if (!isToday(last(marketDataItems)?.date)) {
marketDataItems.push({ date: new Date() });
}
this.marketDataByMonth = {};
for (const marketDataItem of marketDataItems) {
const currentDay = parseInt(format(marketDataItem.date, 'd'), 10);
const key = format(marketDataItem.date, 'yyyy-MM');
if (!this.marketDataByMonth[key]) {
this.marketDataByMonth[key] = {};
}
this.marketDataByMonth[key][
currentDay < 10 ? `0${currentDay}` : currentDay
] = {
date: marketDataItem.date,
day: currentDay,
marketPrice: marketDataItem.marketPrice
};
}
if (this.dateOfFirstActivity) {
let date = parseISO(this.dateOfFirstActivity);
const missingMarketData: Partial<MarketData>[] = [];
if (this.historicalDataItems?.[0]?.date) {
while (
isBefore(
date,
parse(this.historicalDataItems[0].date, DATE_FORMAT, new Date())
)
) {
missingMarketData.push({
date,
marketPrice: undefined
});
date = addDays(date, 1);
}
}
const marketDataItems = [...missingMarketData, ...this.marketData];
if (!isToday(last(marketDataItems)?.date)) {
marketDataItems.push({ date: new Date() });
}
this.marketDataByMonth = {};
for (const marketDataItem of marketDataItems) {
const currentDay = parseInt(format(marketDataItem.date, 'd'), 10);
const key = format(marketDataItem.date, 'yyyy-MM');
if (!this.marketDataByMonth[key]) {
this.marketDataByMonth[key] = {};
}
this.marketDataByMonth[key][
currentDay < 10 ? `0${currentDay}` : currentDay
] = {
date: marketDataItem.date,
day: currentDay,
marketPrice: marketDataItem.marketPrice
};
}
// Fill up missing months
const dates = Object.keys(this.marketDataByMonth).sort();
const startDate = min([

View File

@ -5,7 +5,7 @@ import { DataService } from '@ghostfolio/client/services/data.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { getDateFormatString, getEmojiFlag } from '@ghostfolio/common/helper';
import { AdminData, InfoItem, User } from '@ghostfolio/common/interfaces';
import { AdminUsers, InfoItem, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
@ -24,7 +24,7 @@ import { takeUntil } from 'rxjs/operators';
templateUrl: './admin-users.html'
})
export class AdminUsersComponent implements OnDestroy, OnInit {
public dataSource: MatTableDataSource<AdminData['users'][0]> =
public dataSource: MatTableDataSource<AdminUsers['users'][0]> =
new MatTableDataSource();
public defaultDateFormat: string;
public displayedColumns: string[] = [];
@ -32,6 +32,7 @@ export class AdminUsersComponent implements OnDestroy, OnInit {
public hasPermissionForSubscription: boolean;
public hasPermissionToImpersonateAllUsers: boolean;
public info: InfoItem;
public isLoading = false;
public user: User;
private unsubscribeSubject = new Subject<void>();
@ -93,7 +94,7 @@ export class AdminUsersComponent implements OnDestroy, OnInit {
}
public ngOnInit() {
this.fetchAdminData();
this.fetchUsers();
}
public formatDistanceToNow(aDateString: string) {
@ -118,7 +119,7 @@ export class AdminUsersComponent implements OnDestroy, OnInit {
.deleteUser(aId)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.fetchAdminData();
this.fetchUsers();
});
},
confirmType: ConfirmationDialogType.Warn,
@ -141,13 +142,17 @@ export class AdminUsersComponent implements OnDestroy, OnInit {
this.unsubscribeSubject.complete();
}
private fetchAdminData() {
private fetchUsers() {
this.isLoading = true;
this.adminService
.fetchAdminData()
.fetchUsers()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ users }) => {
this.dataSource = new MatTableDataSource(users);
this.isLoading = false;
this.changeDetectorRef.markForCheck();
});
}

View File

@ -245,6 +245,16 @@
></tr>
</table>
</div>
@if (isLoading) {
<ngx-skeleton-loader
animation="pulse"
class="px-4 py-3"
[theme]="{
height: '1.5rem',
width: '100%'
}"
/>
}
</div>
</div>
</div>

View File

@ -6,6 +6,7 @@ import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatMenuModule } from '@angular/material/menu';
import { MatTableModule } from '@angular/material/table';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { AdminUsersComponent } from './admin-users.component';
@ -18,7 +19,8 @@ import { AdminUsersComponent } from './admin-users.component';
GfValueComponent,
MatButtonModule,
MatMenuModule,
MatTableModule
MatTableModule,
NgxSkeletonLoaderModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})

View File

@ -89,7 +89,6 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
public activityForm: FormGroup;
public accounts: Account[];
public activities: Activity[];
public assetClass: string;
public assetSubClass: string;
public averagePrice: number;
@ -174,6 +173,22 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
.subscribe();
});
this.dataService
.fetchActivities({
filters: [
{ id: this.data.dataSource, type: 'DATA_SOURCE' },
{ id: this.data.symbol, type: 'SYMBOL' }
],
sortColumn: this.sortColumn,
sortDirection: this.sortDirection
})
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ activities }) => {
this.dataSource = new MatTableDataSource(activities);
this.changeDetectorRef.markForCheck();
});
this.dataService
.fetchHoldingDetail({
dataSource: this.data.dataSource,
@ -198,7 +213,6 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
netPerformancePercent,
netPerformancePercentWithCurrencyEffect,
netPerformanceWithCurrencyEffect,
orders,
quantity,
SymbolProfile,
tags,
@ -206,12 +220,10 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
value
}) => {
this.accounts = accounts;
this.activities = orders;
this.averagePrice = averagePrice;
this.benchmarkDataItems = [];
this.countries = {};
this.dataProviderInfo = dataProviderInfo;
this.dataSource = new MatTableDataSource(orders.reverse());
this.dividendInBaseCurrency = dividendInBaseCurrency;
if (

View File

@ -311,7 +311,7 @@
animationDuration="0"
class="mb-5"
[mat-stretch-tabs]="false"
[ngClass]="{ 'd-none': !activities?.length }"
[ngClass]="{ 'd-none': !dataSource?.data.length }"
>
<mat-tab>
<ng-template mat-tab-label>
@ -422,7 +422,8 @@
}
@if (
activities?.length > 0 && data.hasPermissionToReportDataGlitch === true
dataSource?.data.length > 0 &&
data.hasPermissionToReportDataGlitch === true
) {
<div class="row">
<div class="col">

View File

@ -331,7 +331,7 @@
<div class="col-md-8 offset-md-2">
<gf-carousel [aria-label]="'Testimonials'">
@for (testimonial of testimonials; track testimonial) {
<div gf-carousel-item>
<div #carouselItem gf-carousel-item>
<div class="d-flex px-4">
<gf-logo
class="mr-3 mt-2 pt-1"

View File

@ -108,8 +108,6 @@ export class ActivitiesPageComponent implements OnDestroy, OnInit {
this.changeDetectorRef.markForCheck();
}
});
this.fetchActivities();
}
public fetchActivities() {

View File

@ -12,6 +12,7 @@ import {
AdminJobs,
AdminMarketData,
AdminMarketDataDetails,
AdminUsers,
EnhancedSymbolProfile,
Filter
} from '@ghostfolio/common/interfaces';
@ -155,6 +156,10 @@ export class AdminService {
return this.http.get<Tag[]>('/api/v1/tag');
}
public fetchUsers() {
return this.http.get<AdminUsers>('/api/v1/admin/user');
}
public gather7Days() {
return this.http.post<void>('/api/v1/admin/gather', {});
}

View File

@ -72,14 +72,24 @@ export class DataService {
ACCOUNT: filtersByAccount,
ASSET_CLASS: filtersByAssetClass,
ASSET_SUB_CLASS: filtersByAssetSubClass,
DATA_SOURCE: [filterByDataSource] = [],
HOLDING_TYPE: filtersByHoldingType,
PRESET_ID: filtersByPresetId,
SEARCH_QUERY: filtersBySearchQuery,
SYMBOL: [filterBySymbol] = [],
TAG: filtersByTag
} = groupBy(filters, (filter) => {
return filter.type;
});
if (filterByDataSource) {
params = params.append('dataSource', filterByDataSource.id);
}
if (filterBySymbol) {
params = params.append('symbol', filterBySymbol.id);
}
if (filtersByAccount) {
params = params.append(
'accounts',

View File

@ -1,5 +1,5 @@
{
"createdAt": "2024-04-09T00:00:00.000Z",
"createdAt": "2024-08-31T00:00:00.000Z",
"data": [
{
"name": "Aptabase",
@ -46,11 +46,6 @@
"description": "dyrector.io is an open-source continuous delivery & deployment platform with version management.",
"href": "https://dyrector.io"
},
{
"name": "Erxes",
"description": "The Open-Source HubSpot Alternative. A single XOS enables to create unique and life-changing experiences that work for all types of business.",
"href": "https://erxes.io"
},
{
"name": "Firecamp",
"description": "vscode for apis, open-source postman/insomnia alternative",
@ -86,11 +81,6 @@
"description": "Open source, end-to-end encrypted platform that lets you securely manage secrets and configs across your team, devices, and infrastructure.",
"href": "https://infisical.com"
},
{
"name": "Keep",
"description": "Open source alert management and AIOps platform.",
"href": "https://keephq.dev"
},
{
"name": "Langfuse",
"description": "Open source LLM engineering platform. Debug, analyze and iterate together.",
@ -116,6 +106,11 @@
"description": "Open-source monitoring platform with beautiful status pages",
"href": "https://www.openstatus.dev"
},
{
"name": "Portkey AI",
"description": "AI Gateway with integrated Guardrails. Route to 250+ LLMs and 50+ Guardrails with 1-fast API. Supports caching, retries, and edge deployment for low latency.",
"href": "https://www.portkey.ai"
},
{
"name": "Prisma",
"description": "Simplify working with databases. Build, optimize, and grow your app easily with an intuitive data model, type-safety, automated migrations, connection pooling, caching, and real-time db subscriptions.",
@ -126,11 +121,6 @@
"description": "Makes frontend development cycle 10x faster with API Client, Mock Server, Intercept & Modify HTTP Requests and Session Replays.",
"href": "https://requestly.com"
},
{
"name": "Revert",
"description": "The open-source unified API to build B2B integrations remarkably fast",
"href": "https://revert.dev"
},
{
"name": "Rivet",
"description": "Open-source solution to deploy, scale, and operate your multiplayer game.",

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -30,6 +30,9 @@ export const warnColorRgb = {
b: 69
};
export const CACHE_TTL_NO_CACHE = 1;
export const CACHE_TTL_INFINITE = 0;
export const DATA_GATHERING_QUEUE = 'DATA_GATHERING_QUEUE';
export const DATA_GATHERING_QUEUE_PRIORITY_HIGH = 1;
export const DATA_GATHERING_QUEUE_PRIORITY_LOW = Number.MAX_SAFE_INTEGER;
@ -37,6 +40,10 @@ export const DATA_GATHERING_QUEUE_PRIORITY_MEDIUM = Math.round(
DATA_GATHERING_QUEUE_PRIORITY_LOW / 2
);
export const PORTFOLIO_SNAPSHOT_QUEUE = 'PORTFOLIO_SNAPSHOT_QUEUE';
export const PORTFOLIO_SNAPSHOT_QUEUE_PRIORITY_HIGH = 1;
export const PORTFOLIO_SNAPSHOT_QUEUE_PRIORITY_LOW = Number.MAX_SAFE_INTEGER;
export const DEFAULT_CURRENCY = 'USD';
export const DEFAULT_DATE_FORMAT_MONTH_YEAR = 'MMM yyyy';
export const DEFAULT_LANGUAGE_CODE = 'en';
@ -73,9 +80,10 @@ export const GATHER_ASSET_PROFILE_PROCESS_OPTIONS: JobOptions = {
},
removeOnComplete: true
};
export const GATHER_HISTORICAL_MARKET_DATA_PROCESS =
export const GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME =
'GATHER_HISTORICAL_MARKET_DATA';
export const GATHER_HISTORICAL_MARKET_DATA_PROCESS_OPTIONS: JobOptions = {
export const GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_OPTIONS: JobOptions = {
attempts: 12,
backoff: {
delay: ms('1 minute'),
@ -84,6 +92,11 @@ export const GATHER_HISTORICAL_MARKET_DATA_PROCESS_OPTIONS: JobOptions = {
removeOnComplete: true
};
export const PORTFOLIO_SNAPSHOT_PROCESS_JOB_NAME = 'PORTFOLIO';
export const PORTFOLIO_SNAPSHOT_PROCESS_JOB_OPTIONS: JobOptions = {
removeOnComplete: true
};
export const HEADER_KEY_IMPERSONATION = 'Impersonation-Id';
export const HEADER_KEY_TIMEZONE = 'Timezone';
export const HEADER_KEY_TOKEN = 'Authorization';

View File

@ -1,7 +1,5 @@
import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
import { Role } from '@prisma/client';
export interface AdminData {
exchangeRates: ({
label1: string;
@ -11,15 +9,5 @@ export interface AdminData {
settings: { [key: string]: boolean | object | string | string[] };
transactionCount: number;
userCount: number;
users: {
accountCount: number;
country: string;
createdAt: Date;
engagement: number;
id: string;
lastActivity: Date;
role: Role;
transactionCount: number;
}[];
version: string;
}

View File

@ -0,0 +1,14 @@
import { Role } from '@prisma/client';
export interface AdminUsers {
users: {
accountCount: number;
country: string;
createdAt: Date;
engagement: number;
id: string;
lastActivity: Date;
role: Role;
transactionCount: number;
}[];
}

View File

@ -5,6 +5,7 @@ export interface Filter {
| 'ACCOUNT'
| 'ASSET_CLASS'
| 'ASSET_SUB_CLASS'
| 'DATA_SOURCE'
| 'HOLDING_TYPE'
| 'PRESET_ID'
| 'SEARCH_QUERY'

View File

@ -7,6 +7,7 @@ import type {
AdminMarketData,
AdminMarketDataItem
} from './admin-market-data.interface';
import type { AdminUsers } from './admin-users.interface';
import type { AssetProfileIdentifier } from './asset-profile-identifier.interface';
import type { BenchmarkMarketDataDetails } from './benchmark-market-data-details.interface';
import type { BenchmarkProperty } from './benchmark-property.interface';
@ -61,6 +62,7 @@ export {
AdminMarketData,
AdminMarketDataDetails,
AdminMarketDataItem,
AdminUsers,
AssetProfileIdentifier,
Benchmark,
BenchmarkMarketDataDetails,

View File

@ -2,6 +2,7 @@ export interface ScraperConfiguration {
defaultMarketPrice?: number;
headers?: { [key: string]: string };
locale?: string;
mode?: 'instant' | 'lazy';
selector: string;
url: string;
}

View File

@ -163,6 +163,14 @@ export const personalFinanceTools: Product[] = [
origin: 'United States',
slogan: 'Portfolio Tracker Designed by Professional Investors'
},
{
founded: 2010,
hasFreePlan: false,
key: 'etops',
name: 'etops',
origin: 'Switzerland',
slogan: 'Your financial superpower'
},
{
founded: 2020,
hasFreePlan: true,

View File

@ -1,16 +1,8 @@
import { FocusableOption } from '@angular/cdk/a11y';
import { Directive, ElementRef, HostBinding } from '@angular/core';
import { Directive, ElementRef } from '@angular/core';
@Directive({
selector: '[gf-carousel-item]'
})
export class CarouselItem implements FocusableOption {
@HostBinding('attr.role') readonly role = 'listitem';
@HostBinding('tabindex') tabindex = '-1';
export class CarouselItem {
public constructor(readonly element: ElementRef<HTMLElement>) {}
public focus() {
this.element.nativeElement.focus({ preventScroll: true });
}
}

View File

@ -11,12 +11,7 @@
</button>
}
<div
#contentWrapper
class="overflow-hidden"
role="region"
(keyup)="onKeydown($event)"
>
<div #contentWrapper class="overflow-hidden" role="region">
<div #list class="d-flex carousel-content" role="list" tabindex="0">
<ng-content></ng-content>
</div>

View File

@ -1,24 +1,18 @@
import { FocusKeyManager } from '@angular/cdk/a11y';
import { LEFT_ARROW, RIGHT_ARROW, TAB } from '@angular/cdk/keycodes';
import {
AfterContentInit,
CUSTOM_ELEMENTS_SCHEMA,
ChangeDetectionStrategy,
Component,
ContentChildren,
contentChildren,
ElementRef,
HostBinding,
Inject,
Input,
Optional,
QueryList,
ViewChild
} from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { ANIMATION_MODULE_TYPE } from '@angular/platform-browser/animations';
import { CarouselItem } from './carousel-item.directive';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [MatButtonModule],
@ -28,9 +22,7 @@ import { CarouselItem } from './carousel-item.directive';
styleUrls: ['./carousel.component.scss'],
templateUrl: './carousel.component.html'
})
export class GfCarouselComponent implements AfterContentInit {
@ContentChildren(CarouselItem) public items!: QueryList<CarouselItem>;
export class GfCarouselComponent {
@HostBinding('class.animations-disabled')
public readonly animationsDisabled: boolean;
@ -38,11 +30,11 @@ export class GfCarouselComponent implements AfterContentInit {
@ViewChild('list') public list!: ElementRef<HTMLElement>;
public items = contentChildren('carouselItem', { read: ElementRef });
public showPrevArrow = false;
public showNextArrow = true;
private index = 0;
private keyManager!: FocusKeyManager<CarouselItem>;
private position = 0;
public constructor(
@ -51,12 +43,8 @@ export class GfCarouselComponent implements AfterContentInit {
this.animationsDisabled = animationsModule === 'NoopAnimations';
}
public ngAfterContentInit() {
this.keyManager = new FocusKeyManager<CarouselItem>(this.items);
}
public next() {
for (let i = this.index; i < this.items.length; i++) {
for (let i = this.index; i < this.items().length; i++) {
if (this.isOutOfView(i)) {
this.index = i;
this.scrollToActiveItem();
@ -65,31 +53,6 @@ export class GfCarouselComponent implements AfterContentInit {
}
}
public onKeydown({ keyCode }: KeyboardEvent) {
const manager = this.keyManager;
const previousActiveIndex = manager.activeItemIndex;
if (keyCode === LEFT_ARROW) {
manager.setPreviousItemActive();
} else if (keyCode === RIGHT_ARROW) {
manager.setNextItemActive();
} else if (keyCode === TAB && !manager.activeItem) {
manager.setFirstItemActive();
}
if (
manager.activeItemIndex != null &&
manager.activeItemIndex !== previousActiveIndex
) {
this.index = manager.activeItemIndex;
this.updateItemTabIndices();
if (this.isOutOfView(this.index)) {
this.scrollToActiveItem();
}
}
}
public previous() {
for (let i = this.index; i > -1; i--) {
if (this.isOutOfView(i)) {
@ -101,8 +64,7 @@ export class GfCarouselComponent implements AfterContentInit {
}
private isOutOfView(index: number, side?: 'start' | 'end') {
const { offsetWidth, offsetLeft } =
this.items.toArray()[index].element.nativeElement;
const { offsetWidth, offsetLeft } = this.items()[index].nativeElement;
if ((!side || side === 'start') && offsetLeft - this.position < 0) {
return true;
@ -120,33 +82,23 @@ export class GfCarouselComponent implements AfterContentInit {
return;
}
const itemsArray = this.items.toArray();
let targetItemIndex = this.index;
if (this.index > 0 && !this.isOutOfView(this.index - 1)) {
targetItemIndex =
itemsArray.findIndex((_, i) => !this.isOutOfView(i)) + 1;
this.items().findIndex((_, i) => !this.isOutOfView(i)) + 1;
}
this.position =
itemsArray[targetItemIndex].element.nativeElement.offsetLeft;
this.position = this.items()[targetItemIndex].nativeElement.offsetLeft;
this.list.nativeElement.style.transform = `translateX(-${this.position}px)`;
this.showPrevArrow = this.index > 0;
this.showNextArrow = false;
for (let i = itemsArray.length - 1; i > -1; i--) {
for (let i = this.items().length - 1; i > -1; i--) {
if (this.isOutOfView(i, 'end')) {
this.showNextArrow = true;
break;
}
}
}
private updateItemTabIndices() {
this.items.forEach((item: CarouselItem) => {
if (this.keyManager != null) {
item.tabindex = item === this.keyManager.activeItem ? '0' : '-1';
}
});
}
}

29
package-lock.json generated
View File

@ -1,12 +1,12 @@
{
"name": "ghostfolio",
"version": "2.106.0-beta.3",
"version": "2.106.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ghostfolio",
"version": "2.106.0-beta.3",
"version": "2.106.0",
"hasInstallScript": true,
"license": "AGPL-3.0",
"dependencies": {
@ -48,7 +48,7 @@
"big.js": "6.2.1",
"body-parser": "1.20.2",
"bootstrap": "4.6.0",
"bull": "4.10.4",
"bull": "4.16.2",
"cache-manager": "5.7.6",
"cache-manager-redis-yet": "5.1.4",
"chart.js": "4.2.0",
@ -14158,17 +14158,17 @@
}
},
"node_modules/bull": {
"version": "4.10.4",
"resolved": "https://registry.npmjs.org/bull/-/bull-4.10.4.tgz",
"integrity": "sha512-o9m/7HjS/Or3vqRd59evBlWCXd9Lp+ALppKseoSKHaykK46SmRjAilX98PgmOz1yeVaurt8D5UtvEt4bUjM3eA==",
"version": "4.16.2",
"resolved": "https://registry.npmjs.org/bull/-/bull-4.16.2.tgz",
"integrity": "sha512-VCy33UdPGiIoZHDTrslGXKXWxcIUHNH5Z82pihr8HicbIfAH4SHug1HxlwKEbibVv85hq8rJ9tKAW/cuxv2T0A==",
"license": "MIT",
"dependencies": {
"cron-parser": "^4.2.1",
"debuglog": "^1.0.0",
"get-port": "^5.1.1",
"ioredis": "^5.0.0",
"ioredis": "^5.3.2",
"lodash": "^4.17.21",
"msgpackr": "^1.5.2",
"semver": "^7.3.2",
"msgpackr": "^1.10.1",
"semver": "^7.5.2",
"uuid": "^8.3.0"
},
"engines": {
@ -16776,15 +16776,6 @@
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
},
"node_modules/debuglog": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/debuglog/-/debuglog-1.0.1.tgz",
"integrity": "sha512-syBZ+rnAK3EgMsH2aYEOLUW7mZSY9Gb+0wUMCFsZvcmiz+HigA0LOcq/HoQqVuGG+EKykunc7QG2bzrponfaSw==",
"deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
"engines": {
"node": "*"
}
},
"node_modules/decimal.js": {
"version": "10.4.3",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.4.3.tgz",

View File

@ -1,6 +1,6 @@
{
"name": "ghostfolio",
"version": "2.106.0-beta.5",
"version": "2.107.1",
"homepage": "https://ghostfol.io",
"license": "AGPL-3.0",
"repository": "https://github.com/ghostfolio/ghostfolio",
@ -92,7 +92,7 @@
"big.js": "6.2.1",
"body-parser": "1.20.2",
"bootstrap": "4.6.0",
"bull": "4.10.4",
"bull": "4.16.2",
"cache-manager": "5.7.6",
"cache-manager-redis-yet": "5.1.4",
"chart.js": "4.2.0",