Merge branch 'main' of gitea.suda.codes:giteauser/ghostfolio-mirror
This commit is contained in:
commit
3eba400d21
32
CHANGELOG.md
32
CHANGELOG.md
@ -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
|
||||
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
@ -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';
|
||||
|
@ -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 = {
|
||||
|
@ -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 {}
|
||||
|
@ -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;
|
||||
})
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -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';
|
||||
|
@ -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 {
|
||||
|
@ -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';
|
||||
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
});
|
||||
|
||||
|
@ -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';
|
||||
|
@ -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 } },
|
||||
|
@ -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:
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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();
|
||||
|
||||
|
@ -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();
|
||||
|
||||
|
@ -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();
|
||||
|
||||
|
@ -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();
|
||||
|
||||
|
@ -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'),
|
||||
|
@ -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();
|
||||
|
||||
|
@ -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'),
|
||||
|
@ -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)
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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: [],
|
||||
|
@ -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();
|
||||
|
||||
|
@ -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();
|
||||
|
||||
|
@ -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();
|
||||
|
||||
|
@ -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
|
||||
);
|
||||
});
|
||||
|
@ -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,
|
||||
|
@ -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();
|
||||
|
@ -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);
|
||||
}
|
||||
};
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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({
|
||||
|
@ -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()
|
||||
|
@ -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,
|
||||
|
@ -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(() => {
|
||||
|
@ -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';
|
||||
|
@ -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);
|
@ -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,
|
@ -0,0 +1,7 @@
|
||||
import { Filter } from '@ghostfolio/common/interfaces';
|
||||
|
||||
export interface IPortfolioSnapshotQueueJob {
|
||||
filters: Filter[];
|
||||
userCurrency: string;
|
||||
userId: string;
|
||||
}
|
@ -0,0 +1,37 @@
|
||||
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
|
||||
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
|
||||
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
||||
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
|
||||
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
|
||||
import { PORTFOLIO_SNAPSHOT_QUEUE } from '@ghostfolio/common/config';
|
||||
|
||||
import { BullModule } from '@nestjs/bull';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { PortfolioSnapshotProcessor } from './portfolio-snapshot.processor';
|
||||
|
||||
@Module({
|
||||
exports: [BullModule, PortfolioSnapshotService],
|
||||
imports: [
|
||||
BullModule.registerQueue({
|
||||
name: PORTFOLIO_SNAPSHOT_QUEUE
|
||||
}),
|
||||
ConfigurationModule,
|
||||
DataProviderModule,
|
||||
ExchangeRateDataModule,
|
||||
MarketDataModule,
|
||||
OrderModule,
|
||||
RedisCacheModule
|
||||
],
|
||||
providers: [
|
||||
CurrentRateService,
|
||||
PortfolioCalculatorFactory,
|
||||
PortfolioSnapshotProcessor,
|
||||
PortfolioSnapshotService
|
||||
]
|
||||
})
|
||||
export class PortfolioSnapshotQueueModule {}
|
@ -0,0 +1,96 @@
|
||||
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
||||
import {
|
||||
PerformanceCalculationType,
|
||||
PortfolioCalculatorFactory
|
||||
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
|
||||
import { PortfolioSnapshotValue } from '@ghostfolio/api/app/portfolio/interfaces/snapshot-value.interface';
|
||||
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import {
|
||||
CACHE_TTL_INFINITE,
|
||||
PORTFOLIO_SNAPSHOT_PROCESS_JOB_NAME,
|
||||
PORTFOLIO_SNAPSHOT_QUEUE
|
||||
} from '@ghostfolio/common/config';
|
||||
|
||||
import { Process, Processor } from '@nestjs/bull';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Job } from 'bull';
|
||||
import { addMilliseconds } from 'date-fns';
|
||||
|
||||
import { IPortfolioSnapshotQueueJob } from './interfaces/portfolio-snapshot-queue-job.interface';
|
||||
|
||||
@Injectable()
|
||||
@Processor(PORTFOLIO_SNAPSHOT_QUEUE)
|
||||
export class PortfolioSnapshotProcessor {
|
||||
public constructor(
|
||||
private readonly calculatorFactory: PortfolioCalculatorFactory,
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly orderService: OrderService,
|
||||
private readonly redisCacheService: RedisCacheService
|
||||
) {}
|
||||
|
||||
@Process({ concurrency: 1, name: PORTFOLIO_SNAPSHOT_PROCESS_JOB_NAME })
|
||||
public async calculatePortfolioSnapshot(
|
||||
job: Job<IPortfolioSnapshotQueueJob>
|
||||
) {
|
||||
try {
|
||||
const startTime = performance.now();
|
||||
|
||||
Logger.log(
|
||||
`Portfolio snapshot calculation of user '${job.data.userId}' has been started`,
|
||||
`PortfolioSnapshotProcessor (${PORTFOLIO_SNAPSHOT_PROCESS_JOB_NAME})`
|
||||
);
|
||||
|
||||
const { activities } =
|
||||
await this.orderService.getOrdersForPortfolioCalculator({
|
||||
filters: job.data.filters,
|
||||
userCurrency: job.data.userCurrency,
|
||||
userId: job.data.userId
|
||||
});
|
||||
|
||||
const portfolioCalculator = this.calculatorFactory.createCalculator({
|
||||
activities,
|
||||
calculationType: PerformanceCalculationType.TWR,
|
||||
currency: job.data.userCurrency,
|
||||
filters: job.data.filters,
|
||||
userId: job.data.userId
|
||||
});
|
||||
|
||||
const snapshot = await portfolioCalculator.computeSnapshot();
|
||||
|
||||
Logger.log(
|
||||
`Portfolio snapshot calculation of user '${job.data.userId}' has been completed in ${(
|
||||
(performance.now() - startTime) /
|
||||
1000
|
||||
).toFixed(3)} seconds`,
|
||||
`PortfolioSnapshotProcessor (${PORTFOLIO_SNAPSHOT_PROCESS_JOB_NAME})`
|
||||
);
|
||||
|
||||
const expiration = addMilliseconds(
|
||||
new Date(),
|
||||
this.configurationService.get('CACHE_QUOTES_TTL')
|
||||
);
|
||||
|
||||
this.redisCacheService.set(
|
||||
this.redisCacheService.getPortfolioSnapshotKey({
|
||||
filters: job.data.filters,
|
||||
userId: job.data.userId
|
||||
}),
|
||||
JSON.stringify(<PortfolioSnapshotValue>(<unknown>{
|
||||
expiration: expiration.getTime(),
|
||||
portfolioSnapshot: snapshot
|
||||
})),
|
||||
CACHE_TTL_INFINITE
|
||||
);
|
||||
|
||||
return snapshot;
|
||||
} catch (error) {
|
||||
Logger.error(
|
||||
error,
|
||||
`PortfolioSnapshotProcessor (${PORTFOLIO_SNAPSHOT_PROCESS_JOB_NAME})`
|
||||
);
|
||||
|
||||
throw new Error(error);
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,34 @@
|
||||
import { Job, JobOptions } from 'bull';
|
||||
import { setTimeout } from 'timers/promises';
|
||||
|
||||
import { IPortfolioSnapshotQueueJob } from './interfaces/portfolio-snapshot-queue-job.interface';
|
||||
|
||||
export const PortfolioSnapshotServiceMock = {
|
||||
addJobToQueue({
|
||||
data,
|
||||
name,
|
||||
opts
|
||||
}: {
|
||||
data: IPortfolioSnapshotQueueJob;
|
||||
name: string;
|
||||
opts?: JobOptions;
|
||||
}): Promise<Job<any>> {
|
||||
const mockJob: Partial<Job<any>> = {
|
||||
finished: async () => {
|
||||
await setTimeout(100);
|
||||
|
||||
return Promise.resolve();
|
||||
}
|
||||
};
|
||||
|
||||
this.jobsStore.set(opts?.jobId, mockJob);
|
||||
|
||||
return Promise.resolve(mockJob as Job<any>);
|
||||
},
|
||||
getJob(jobId: string): Promise<Job<any>> {
|
||||
const job = this.jobsStore.get(jobId);
|
||||
|
||||
return Promise.resolve(job as Job<any>);
|
||||
},
|
||||
jobsStore: new Map<string, Partial<Job<any>>>()
|
||||
};
|
@ -0,0 +1,31 @@
|
||||
import { PORTFOLIO_SNAPSHOT_QUEUE } from '@ghostfolio/common/config';
|
||||
|
||||
import { InjectQueue } from '@nestjs/bull';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { JobOptions, Queue } from 'bull';
|
||||
|
||||
import { IPortfolioSnapshotQueueJob } from './interfaces/portfolio-snapshot-queue-job.interface';
|
||||
|
||||
@Injectable()
|
||||
export class PortfolioSnapshotService {
|
||||
public constructor(
|
||||
@InjectQueue(PORTFOLIO_SNAPSHOT_QUEUE)
|
||||
private readonly portfolioSnapshotQueue: Queue
|
||||
) {}
|
||||
|
||||
public async addJobToQueue({
|
||||
data,
|
||||
name,
|
||||
opts
|
||||
}: {
|
||||
data: IPortfolioSnapshotQueueJob;
|
||||
name: string;
|
||||
opts?: JobOptions;
|
||||
}) {
|
||||
return this.portfolioSnapshotQueue.add(name, data, opts);
|
||||
}
|
||||
|
||||
public async getJob(jobId: string) {
|
||||
return this.portfolioSnapshotQueue.getJob(jobId);
|
||||
}
|
||||
}
|
@ -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
|
||||
};
|
||||
|
@ -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();
|
||||
});
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -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]
|
||||
|
@ -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([
|
||||
|
@ -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();
|
||||
});
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -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]
|
||||
})
|
||||
|
@ -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 (
|
||||
|
@ -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">
|
||||
|
@ -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"
|
||||
|
@ -108,8 +108,6 @@ export class ActivitiesPageComponent implements OnDestroy, OnInit {
|
||||
this.changeDetectorRef.markForCheck();
|
||||
}
|
||||
});
|
||||
|
||||
this.fetchActivities();
|
||||
}
|
||||
|
||||
public fetchActivities() {
|
||||
|
@ -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', {});
|
||||
}
|
||||
|
@ -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',
|
||||
|
@ -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
@ -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';
|
||||
|
@ -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;
|
||||
}
|
||||
|
14
libs/common/src/lib/interfaces/admin-users.interface.ts
Normal file
14
libs/common/src/lib/interfaces/admin-users.interface.ts
Normal 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;
|
||||
}[];
|
||||
}
|
@ -5,6 +5,7 @@ export interface Filter {
|
||||
| 'ACCOUNT'
|
||||
| 'ASSET_CLASS'
|
||||
| 'ASSET_SUB_CLASS'
|
||||
| 'DATA_SOURCE'
|
||||
| 'HOLDING_TYPE'
|
||||
| 'PRESET_ID'
|
||||
| 'SEARCH_QUERY'
|
||||
|
@ -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,
|
||||
|
@ -2,6 +2,7 @@ export interface ScraperConfiguration {
|
||||
defaultMarketPrice?: number;
|
||||
headers?: { [key: string]: string };
|
||||
locale?: string;
|
||||
mode?: 'instant' | 'lazy';
|
||||
selector: string;
|
||||
url: string;
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -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
29
package-lock.json
generated
@ -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",
|
||||
|
@ -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",
|
||||
|
Loading…
x
Reference in New Issue
Block a user