This commit is contained in:
sudacode 2024-12-04 22:19:54 -08:00
commit f97075d82b
167 changed files with 9146 additions and 3823 deletions

View File

@ -39,6 +39,7 @@
"plugin:@typescript-eslint/stylistic-type-checked" "plugin:@typescript-eslint/stylistic-type-checked"
], ],
"rules": { "rules": {
"@typescript-eslint/consistent-indexed-object-style": "off",
"@typescript-eslint/dot-notation": "off", "@typescript-eslint/dot-notation": "off",
"@typescript-eslint/explicit-member-accessibility": [ "@typescript-eslint/explicit-member-accessibility": [
"off", "off",
@ -142,8 +143,7 @@
// The following rules are part of @typescript-eslint/stylistic-type-checked // The following rules are part of @typescript-eslint/stylistic-type-checked
// and can be remove once solved // and can be remove once solved
"@typescript-eslint/prefer-nullish-coalescing": "warn", // TODO: Requires strictNullChecks: true "@typescript-eslint/prefer-nullish-coalescing": "warn" // TODO: Requires strictNullChecks: true
"@typescript-eslint/consistent-indexed-object-style": "warn"
} }
} }
], ],

View File

@ -7,13 +7,103 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Unreleased ## Unreleased
### Added
- Added pagination to the users table of the admin control panel
### Changed
- Extracted the historical market data editor to a reusable component
## 2.125.0 - 2024-11-30
### Changed
- Improved the style of the symbol search component
- Extended the users table in the admin control panel
- Refreshed the cryptocurrencies list
- Increased the default request timeout (`REQUEST_TIMEOUT`)
- Upgraded `cheerio` from version `1.0.0-rc.12` to `1.0.0`
- Upgraded `prisma` from version `5.22.0` to `6.0.0`
## 2.124.1 - 2024-11-25
### Fixed
- Fixed the tables style related to sticky columns
## 2.124.0 - 2024-11-24
### Added
- Added pagination parameters (`skip`, `take`) to the endpoint `GET api/v1/admin/user`
- Added pagination response (`count`) to the endpoint `GET api/v1/admin/user`
- Added `GHOSTFOLIO` as a new data source type
### Changed
- Extended the allocations by ETF holding on the allocations page by the parent ETFs (experimental)
- Improved the language localization for German (`de`)
- Upgraded `countries-and-timezones` from version `3.4.1` to `3.7.2`
- Upgraded `Nx` from version `20.0.6` to `20.1.2`
## 2.123.0 - 2024-11-16
### Added
- Added a blog post: _Black Weeks 2024_
### Changed
- Moved the chart of the holdings tab on the home page from experimental to general availability
- Extended the assistant by a holding selector
- Separated the _FIRE_ / _X-ray_ page
- Improved the usability to customize the rule thresholds in the _X-ray_ page by introducing range sliders (experimental)
- Improved the language localization for German (`de`)
- Improved the language localization for Italian (`it`)
- Upgraded `ngx-skeleton-loader` from version `7.0.0` to `9.0.0`
- Upgraded `prisma` from version `5.21.1` to `5.22.0`
- Upgraded `uuid` from version `9.0.1` to `11.0.2`
## 2.122.0 - 2024-11-07
### Changed
- Upgraded `countries-list` from version `3.1.0` to `3.1.1`
### Fixed
- Fixed an issue with the algebraic sign in the chart of the holdings tab on the home page (experimental)
- Improved the exception handling in the user authorization service
- Disabled the caching of the benchmarks in the markets overview if sharing the _Fear & Greed Index_ (market mood) is enabled
## 2.121.1 - 2024-11-02
### Added
- Set the stack and container names in the `docker-compose` files (`docker-compose.yml`, `docker-compose.build.yml` and `docker-compose.dev.yml`)
### Changed
- Reverted the permissions (`chmod 0700`) on `entrypoint.sh` in the `Dockerfile`
- Upgraded the _Stripe_ dependencies
## 2.120.0 - 2024-10-30
### Added
- Added support for log levels (`LOG_LEVELS`) to conditionally log `prisma` query events (`debug` or `verbose`)
### Changed ### Changed
- Restructured the resources page - Restructured the resources page
- Renamed the static portfolio analysis rule from _Allocation Cluster Risk_ to _Economic Market Cluster Risk_ (Developed Markets and Emerging Markets)
- Improved the language localization for German (`de`) - Improved the language localization for German (`de`)
- Switched the `consistent-generic-constructors` rule from `warn` to `error` in the `eslint` configuration - Switched the `consistent-generic-constructors` rule from `warn` to `error` in the `eslint` configuration
- Switched the `consistent-indexed-object-style` rule from `warn` to `off` in the `eslint` configuration
- Switched the `consistent-type-assertions` rule from `warn` to `error` in the `eslint` configuration - Switched the `consistent-type-assertions` rule from `warn` to `error` in the `eslint` configuration
- Switched the `prefer-optional-chain` rule from `warn` to `error` in the `eslint` configuration - Switched the `prefer-optional-chain` rule from `warn` to `error` in the `eslint` configuration
- Upgraded `Nx` from version `20.0.3` to `20.0.6`
## 2.119.0 - 2024-10-26 ## 2.119.0 - 2024-10-26
@ -29,15 +119,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Fixed an issue with the X-axis scale of the dividend timeline on the analysis page - Fixed an issue with the X-axis scale of the dividend timeline on the analysis page
- Fixed an issue with the X-axis scale of the investment timeline on the analysis page - Fixed an issue with the X-axis scale of the investment timeline on the analysis page
- Fixed an issue with the X-axis scale of the portfolio evolution chart on the analysis page - Fixed an issue with the X-axis scale of the portfolio evolution chart on the analysis page
- Fixed an issue in the calculation of the static portfolio analysis rule: Allocation Cluster Risk (Developed Markets) - Fixed an issue in the calculation of the static portfolio analysis rule: _Allocation Cluster Risk_ (Developed Markets)
- Fixed an issue in the calculation of the static portfolio analysis rule: Allocation Cluster Risk (Emerging Markets) - Fixed an issue in the calculation of the static portfolio analysis rule: _Allocation Cluster Risk_ (Emerging Markets)
## 2.118.0 - 2024-10-23 ## 2.118.0 - 2024-10-23
### Added ### Added
- Added a new static portfolio analysis rule: Allocation Cluster Risk (Developed Markets) - Added a new static portfolio analysis rule: _Allocation Cluster Risk_ (Developed Markets)
- Added a new static portfolio analysis rule: Allocation Cluster Risk (Emerging Markets) - Added a new static portfolio analysis rule: _Allocation Cluster Risk_ (Emerging Markets)
- Added support for mutual funds in the _EOD Historical Data_ service - Added support for mutual funds in the _EOD Historical Data_ service
### Changed ### Changed

View File

@ -61,7 +61,6 @@ RUN apt-get update && apt-get install -y --no-install-suggests \
COPY --chown=node:node --from=builder /ghostfolio/dist/apps /ghostfolio/apps COPY --chown=node:node --from=builder /ghostfolio/dist/apps /ghostfolio/apps
COPY --chown=node:node ./docker/entrypoint.sh /ghostfolio/entrypoint.sh COPY --chown=node:node ./docker/entrypoint.sh /ghostfolio/entrypoint.sh
RUN chmod 0700 /ghostfolio/entrypoint.sh
WORKDIR /ghostfolio/apps/api WORKDIR /ghostfolio/apps/api
EXPOSE ${PORT:-3333} EXPOSE ${PORT:-3333}
USER node USER node

View File

@ -352,7 +352,13 @@ export class AdminController {
@Get('user') @Get('user')
@HasPermission(permissions.accessAdminControl) @HasPermission(permissions.accessAdminControl)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getUsers(): Promise<AdminUsers> { public async getUsers(
return this.adminService.getUsers(); @Query('skip') skip?: number,
@Query('take') take?: number
): Promise<AdminUsers> {
return this.adminService.getUsers({
skip: isNaN(skip) ? undefined : skip,
take: isNaN(take) ? undefined : take
});
} }
} }

View File

@ -140,7 +140,7 @@ export class AdminService {
const [settings, transactionCount, userCount] = await Promise.all([ const [settings, transactionCount, userCount] = await Promise.all([
this.propertyService.get(), this.propertyService.get(),
this.prismaService.order.count(), this.prismaService.order.count(),
this.prismaService.user.count() this.countUsersWithAnalytics()
]); ]);
return { return {
@ -429,8 +429,19 @@ export class AdminService {
}; };
} }
public async getUsers(): Promise<AdminUsers> { public async getUsers({
return { users: await this.getUsersWithAnalytics() }; skip,
take = Number.MAX_SAFE_INTEGER
}: {
skip?: number;
take?: number;
}): Promise<AdminUsers> {
const [count, users] = await Promise.all([
this.countUsersWithAnalytics(),
this.getUsersWithAnalytics({ skip, take })
]);
return { count, users };
} }
public async patchAssetProfileData({ public async patchAssetProfileData({
@ -508,6 +519,22 @@ export class AdminService {
return response; return response;
} }
private async countUsersWithAnalytics() {
let where: Prisma.UserWhereInput;
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
where = {
NOT: {
Analytics: null
}
};
}
return this.prismaService.user.count({
where
});
}
private getExtendedPrismaClient() { private getExtendedPrismaClient() {
Logger.debug('Connect extended prisma client', 'AdminService'); Logger.debug('Connect extended prisma client', 'AdminService');
@ -640,8 +667,14 @@ export class AdminService {
return { marketData, count: marketData.length }; return { marketData, count: marketData.length };
} }
private async getUsersWithAnalytics(): Promise<AdminUsers['users']> { private async getUsersWithAnalytics({
let orderBy: any = { skip,
take
}: {
skip?: number;
take?: number;
}): Promise<AdminUsers['users']> {
let orderBy: Prisma.UserOrderByWithRelationInput = {
createdAt: 'desc' createdAt: 'desc'
}; };
let where: Prisma.UserWhereInput; let where: Prisma.UserWhereInput;
@ -649,7 +682,7 @@ export class AdminService {
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
orderBy = { orderBy = {
Analytics: { Analytics: {
updatedAt: 'desc' lastRequestAt: 'desc'
} }
}; };
where = { where = {
@ -661,6 +694,8 @@ export class AdminService {
const usersWithAnalytics = await this.prismaService.user.findMany({ const usersWithAnalytics = await this.prismaService.user.findMany({
orderBy, orderBy,
skip,
take,
where, where,
select: { select: {
_count: { _count: {
@ -670,6 +705,7 @@ export class AdminService {
select: { select: {
activityCount: true, activityCount: true,
country: true, country: true,
dataProviderGhostfolioDailyRequests: true,
updatedAt: true updatedAt: true
} }
}, },
@ -677,8 +713,7 @@ export class AdminService {
id: true, id: true,
role: true, role: true,
Subscription: true Subscription: true
}, }
take: 30
}); });
return usersWithAnalytics.map( return usersWithAnalytics.map(
@ -706,6 +741,7 @@ export class AdminService {
subscription, subscription,
accountCount: _count.Account || 0, accountCount: _count.Account || 0,
country: Analytics?.country, country: Analytics?.country,
dailyApiRequests: Analytics?.dataProviderGhostfolioDailyRequests || 0,
lastActivity: Analytics?.updatedAt, lastActivity: Analytics?.updatedAt,
transactionCount: _count.Order || 0 transactionCount: _count.Order || 0
}; };

View File

@ -31,6 +31,7 @@ import { AuthDeviceModule } from './auth-device/auth-device.module';
import { AuthModule } from './auth/auth.module'; import { AuthModule } from './auth/auth.module';
import { BenchmarkModule } from './benchmark/benchmark.module'; import { BenchmarkModule } from './benchmark/benchmark.module';
import { CacheModule } from './cache/cache.module'; import { CacheModule } from './cache/cache.module';
import { GhostfolioModule } from './endpoints/data-providers/ghostfolio/ghostfolio.module';
import { PublicModule } from './endpoints/public/public.module'; import { PublicModule } from './endpoints/public/public.module';
import { ExchangeRateModule } from './exchange-rate/exchange-rate.module'; import { ExchangeRateModule } from './exchange-rate/exchange-rate.module';
import { ExportModule } from './export/export.module'; import { ExportModule } from './export/export.module';
@ -76,6 +77,7 @@ import { UserModule } from './user/user.module';
ExchangeRateModule, ExchangeRateModule,
ExchangeRateDataModule, ExchangeRateDataModule,
ExportModule, ExportModule,
GhostfolioModule,
HealthModule, HealthModule,
ImportModule, ImportModule,
InfoModule, InfoModule,

View File

@ -46,7 +46,7 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
update: { update: {
country, country,
activityCount: { increment: 1 }, activityCount: { increment: 1 },
updatedAt: new Date() lastRequestAt: new Date()
}, },
where: { userId: user.id } where: { userId: user.id }
}); });
@ -60,7 +60,7 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
); );
} }
} catch (error) { } catch (error) {
if (error?.getStatus() === StatusCodes.TOO_MANY_REQUESTS) { if (error?.getStatus?.() === StatusCodes.TOO_MANY_REQUESTS) {
throw error; throw error;
} else { } else {
throw new HttpException( throw new HttpException(

View File

@ -437,7 +437,7 @@ export class BenchmarkService {
}; };
}); });
if (storeInCache) { if (!enableSharing && storeInCache) {
const expiration = addHours(new Date(), 2); const expiration = addHours(new Date(), 2);
await this.redisCacheService.set( await this.redisCacheService.set(

View File

@ -0,0 +1,15 @@
import { Granularity } from '@ghostfolio/common/types';
import { IsIn, IsISO8601, IsOptional } from 'class-validator';
export class GetDividendsDto {
@IsISO8601()
from: string;
@IsIn(['day', 'month'] as Granularity[])
@IsOptional()
granularity: Granularity;
@IsISO8601()
to: string;
}

View File

@ -0,0 +1,15 @@
import { Granularity } from '@ghostfolio/common/types';
import { IsIn, IsISO8601, IsOptional } from 'class-validator';
export class GetHistoricalDto {
@IsISO8601()
from: string;
@IsIn(['day', 'month'] as Granularity[])
@IsOptional()
granularity: Granularity;
@IsISO8601()
to: string;
}

View File

@ -0,0 +1,10 @@
import { Transform } from 'class-transformer';
import { IsString } from 'class-validator';
export class GetQuotesDto {
@IsString({ each: true })
@Transform(({ value }) =>
typeof value === 'string' ? value.split(',') : value
)
symbols: string[];
}

View File

@ -0,0 +1,196 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { parseDate } from '@ghostfolio/common/helper';
import {
DataProviderGhostfolioStatusResponse,
DividendsResponse,
HistoricalResponse,
LookupResponse,
QuotesResponse
} from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types';
import {
Controller,
Get,
HttpException,
Inject,
Param,
Query,
UseGuards
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { getReasonPhrase, StatusCodes } from 'http-status-codes';
import { GetDividendsDto } from './get-dividends.dto';
import { GetHistoricalDto } from './get-historical.dto';
import { GetQuotesDto } from './get-quotes.dto';
import { GhostfolioService } from './ghostfolio.service';
@Controller('data-providers/ghostfolio')
export class GhostfolioController {
public constructor(
private readonly ghostfolioService: GhostfolioService,
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
@Get('dividends/:symbol')
@HasPermission(permissions.enableDataProviderGhostfolio)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getDividends(
@Param('symbol') symbol: string,
@Query() query: GetDividendsDto
): Promise<DividendsResponse> {
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests();
if (
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests
) {
throw new HttpException(
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
StatusCodes.TOO_MANY_REQUESTS
);
}
try {
const dividends = await this.ghostfolioService.getDividends({
symbol,
from: parseDate(query.from),
granularity: query.granularity,
to: parseDate(query.to)
});
await this.ghostfolioService.incrementDailyRequests({
userId: this.request.user.id
});
return dividends;
} catch {
throw new HttpException(
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
StatusCodes.INTERNAL_SERVER_ERROR
);
}
}
@Get('historical/:symbol')
@HasPermission(permissions.enableDataProviderGhostfolio)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getHistorical(
@Param('symbol') symbol: string,
@Query() query: GetHistoricalDto
): Promise<HistoricalResponse> {
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests();
if (
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests
) {
throw new HttpException(
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
StatusCodes.TOO_MANY_REQUESTS
);
}
try {
const historicalData = await this.ghostfolioService.getHistorical({
symbol,
from: parseDate(query.from),
granularity: query.granularity,
to: parseDate(query.to)
});
await this.ghostfolioService.incrementDailyRequests({
userId: this.request.user.id
});
return historicalData;
} catch {
throw new HttpException(
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
StatusCodes.INTERNAL_SERVER_ERROR
);
}
}
@Get('lookup')
@HasPermission(permissions.enableDataProviderGhostfolio)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async lookupSymbol(
@Query('includeIndices') includeIndicesParam = 'false',
@Query('query') query = ''
): Promise<LookupResponse> {
const includeIndices = includeIndicesParam === 'true';
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests();
if (
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests
) {
throw new HttpException(
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
StatusCodes.TOO_MANY_REQUESTS
);
}
try {
const result = await this.ghostfolioService.lookup({
includeIndices,
query: query.toLowerCase()
});
await this.ghostfolioService.incrementDailyRequests({
userId: this.request.user.id
});
return result;
} catch {
throw new HttpException(
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
StatusCodes.INTERNAL_SERVER_ERROR
);
}
}
@Get('quotes')
@HasPermission(permissions.enableDataProviderGhostfolio)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getQuotes(
@Query() query: GetQuotesDto
): Promise<QuotesResponse> {
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests();
if (
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests
) {
throw new HttpException(
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
StatusCodes.TOO_MANY_REQUESTS
);
}
try {
const quotes = await this.ghostfolioService.getQuotes({
symbols: query.symbols
});
await this.ghostfolioService.incrementDailyRequests({
userId: this.request.user.id
});
return quotes;
} catch {
throw new HttpException(
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
StatusCodes.INTERNAL_SERVER_ERROR
);
}
}
@Get('status')
@HasPermission(permissions.enableDataProviderGhostfolio)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getStatus(): Promise<DataProviderGhostfolioStatusResponse> {
return this.ghostfolioService.getStatus({ user: this.request.user });
}
}

View File

@ -0,0 +1,83 @@
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { CryptocurrencyModule } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.module';
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service';
import { CoinGeckoService } from '@ghostfolio/api/services/data-provider/coingecko/coingecko.service';
import { YahooFinanceDataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { EodHistoricalDataService } from '@ghostfolio/api/services/data-provider/eod-historical-data/eod-historical-data.service';
import { FinancialModelingPrepService } from '@ghostfolio/api/services/data-provider/financial-modeling-prep/financial-modeling-prep.service';
import { GoogleSheetsService } from '@ghostfolio/api/services/data-provider/google-sheets/google-sheets.service';
import { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.service';
import { RapidApiService } from '@ghostfolio/api/services/data-provider/rapid-api/rapid-api.service';
import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service';
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 { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
import { Module } from '@nestjs/common';
import { GhostfolioController } from './ghostfolio.controller';
import { GhostfolioService } from './ghostfolio.service';
@Module({
controllers: [GhostfolioController],
imports: [
CryptocurrencyModule,
DataProviderModule,
MarketDataModule,
PrismaModule,
PropertyModule,
RedisCacheModule,
SymbolProfileModule
],
providers: [
AlphaVantageService,
CoinGeckoService,
ConfigurationService,
DataProviderService,
EodHistoricalDataService,
FinancialModelingPrepService,
GhostfolioService,
GoogleSheetsService,
ManualService,
RapidApiService,
YahooFinanceService,
YahooFinanceDataEnhancerService,
{
inject: [
AlphaVantageService,
CoinGeckoService,
EodHistoricalDataService,
FinancialModelingPrepService,
GoogleSheetsService,
ManualService,
RapidApiService,
YahooFinanceService
],
provide: 'DataProviderInterfaces',
useFactory: (
alphaVantageService,
coinGeckoService,
eodHistoricalDataService,
financialModelingPrepService,
googleSheetsService,
manualService,
rapidApiService,
yahooFinanceService
) => [
alphaVantageService,
coinGeckoService,
eodHistoricalDataService,
financialModelingPrepService,
googleSheetsService,
manualService,
rapidApiService,
yahooFinanceService
]
}
]
})
export class GhostfolioModule {}

View File

@ -0,0 +1,304 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import {
GetDividendsParams,
GetHistoricalParams,
GetQuotesParams,
GetSearchParams
} from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import {
DEFAULT_CURRENCY,
DERIVED_CURRENCIES
} from '@ghostfolio/common/config';
import { PROPERTY_DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER_MAX_REQUESTS } from '@ghostfolio/common/config';
import {
DataProviderInfo,
DividendsResponse,
HistoricalResponse,
LookupItem,
LookupResponse,
QuotesResponse
} from '@ghostfolio/common/interfaces';
import { UserWithSettings } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common';
import { DataSource } from '@prisma/client';
import { Big } from 'big.js';
@Injectable()
export class GhostfolioService {
public constructor(
private readonly configurationService: ConfigurationService,
private readonly dataProviderService: DataProviderService,
private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService
) {}
public async getDividends({
from,
granularity,
requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'),
symbol,
to
}: GetDividendsParams) {
const result: DividendsResponse = { dividends: {} };
try {
const promises: Promise<{
[date: string]: IDataProviderHistoricalResponse;
}>[] = [];
for (const dataProviderService of this.getDataProviderServices()) {
promises.push(
dataProviderService
.getDividends({
from,
granularity,
requestTimeout,
symbol,
to
})
.then((dividends) => {
result.dividends = dividends;
return dividends;
})
);
}
await Promise.all(promises);
return result;
} catch (error) {
Logger.error(error, 'GhostfolioService');
throw error;
}
}
public async getHistorical({
from,
granularity,
requestTimeout,
to,
symbol
}: GetHistoricalParams) {
const result: HistoricalResponse = { historicalData: {} };
try {
const promises: Promise<{
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
}>[] = [];
for (const dataProviderService of this.getDataProviderServices()) {
promises.push(
dataProviderService
.getHistorical({
from,
granularity,
requestTimeout,
symbol,
to
})
.then((historicalData) => {
result.historicalData = historicalData[symbol];
return historicalData;
})
);
}
await Promise.all(promises);
return result;
} catch (error) {
Logger.error(error, 'GhostfolioService');
throw error;
}
}
public async getMaxDailyRequests() {
return parseInt(
((await this.propertyService.getByKey(
PROPERTY_DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER_MAX_REQUESTS
)) as string) || '0',
10
);
}
public async getQuotes({ requestTimeout, symbols }: GetQuotesParams) {
const results: QuotesResponse = { quotes: {} };
try {
const promises: Promise<any>[] = [];
for (const dataProvider of this.getDataProviderServices()) {
const maximumNumberOfSymbolsPerRequest =
dataProvider.getMaxNumberOfSymbolsPerRequest?.() ??
Number.MAX_SAFE_INTEGER;
for (
let i = 0;
i < symbols.length;
i += maximumNumberOfSymbolsPerRequest
) {
const symbolsChunk = symbols.slice(
i,
i + maximumNumberOfSymbolsPerRequest
);
const promise = Promise.resolve(
dataProvider.getQuotes({ requestTimeout, symbols: symbolsChunk })
);
promises.push(
promise.then(async (result) => {
for (const [symbol, dataProviderResponse] of Object.entries(
result
)) {
dataProviderResponse.dataSource = 'GHOSTFOLIO';
if (
[
...DERIVED_CURRENCIES.map(({ currency }) => {
return `${DEFAULT_CURRENCY}${currency}`;
}),
`${DEFAULT_CURRENCY}USX`
].includes(symbol)
) {
continue;
}
results.quotes[symbol] = dataProviderResponse;
for (const {
currency,
factor,
rootCurrency
} of DERIVED_CURRENCIES) {
if (symbol === `${DEFAULT_CURRENCY}${rootCurrency}`) {
results.quotes[`${DEFAULT_CURRENCY}${currency}`] = {
...dataProviderResponse,
currency,
marketPrice: new Big(
result[`${DEFAULT_CURRENCY}${rootCurrency}`].marketPrice
)
.mul(factor)
.toNumber(),
marketState: 'open'
};
}
}
}
})
);
}
await Promise.all(promises);
}
return results;
} catch (error) {
Logger.error(error, 'GhostfolioService');
throw error;
}
}
public async getStatus({ user }: { user: UserWithSettings }) {
return {
dailyRequests: user.dataProviderGhostfolioDailyRequests,
dailyRequestsMax: await this.getMaxDailyRequests(),
subscription: user.subscription
};
}
public async incrementDailyRequests({ userId }: { userId: string }) {
await this.prismaService.analytics.update({
data: {
dataProviderGhostfolioDailyRequests: { increment: 1 },
lastRequestAt: new Date()
},
where: { userId }
});
}
public async lookup({
includeIndices = false,
query
}: GetSearchParams): Promise<LookupResponse> {
const results: LookupResponse = { items: [] };
if (!query) {
return results;
}
try {
let lookupItems: LookupItem[] = [];
const promises: Promise<{ items: LookupItem[] }>[] = [];
if (query?.length < 2) {
return { items: lookupItems };
}
for (const dataProviderService of this.getDataProviderServices()) {
promises.push(
dataProviderService.search({
includeIndices,
query
})
);
}
const searchResults = await Promise.all(promises);
for (const { items } of searchResults) {
if (items?.length > 0) {
lookupItems = lookupItems.concat(items);
}
}
const filteredItems = lookupItems
.filter(({ currency }) => {
// Only allow symbols with supported currency
return currency ? true : false;
})
.sort(({ name: name1 }, { name: name2 }) => {
return name1?.toLowerCase().localeCompare(name2?.toLowerCase());
})
.map((lookupItem) => {
lookupItem.dataProviderInfo = this.getDataProviderInfo();
lookupItem.dataSource = 'GHOSTFOLIO';
return lookupItem;
});
results.items = filteredItems;
return results;
} catch (error) {
Logger.error(error, 'GhostfolioService');
throw error;
}
}
private getDataProviderInfo(): DataProviderInfo {
return {
isPremium: false,
name: 'Ghostfolio Premium',
url: 'https://ghostfol.io'
};
}
private getDataProviderServices() {
return this.configurationService
.get('DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER')
.map((dataSource) => {
return this.dataProviderService.getDataProvider(DataSource[dataSource]);
});
}
}

View File

@ -582,12 +582,13 @@ export class ImportService {
const assetProfiles: { const assetProfiles: {
[assetProfileIdentifier: string]: Partial<SymbolProfile>; [assetProfileIdentifier: string]: Partial<SymbolProfile>;
} = {}; } = {};
const dataSources = await this.dataProviderService.getDataSources();
for (const [ for (const [
index, index,
{ currency, dataSource, symbol, type } { currency, dataSource, symbol, type }
] of activitiesDto.entries()) { ] of activitiesDto.entries()) {
if (!this.configurationService.get('DATA_SOURCES').includes(dataSource)) { if (!dataSources.includes(dataSource)) {
throw new Error( throw new Error(
`activities.${index}.dataSource ("${dataSource}") is not valid` `activities.${index}.dataSource ("${dataSource}") is not valid`
); );

View File

@ -7,6 +7,7 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { import {
DEFAULT_CURRENCY, DEFAULT_CURRENCY,
HEADER_KEY_TOKEN,
PROPERTY_BETTER_UPTIME_MONITOR_ID, PROPERTY_BETTER_UPTIME_MONITOR_ID,
PROPERTY_COUNTRIES_OF_SUBSCRIBERS, PROPERTY_COUNTRIES_OF_SUBSCRIBERS,
PROPERTY_DEMO_USER_ID, PROPERTY_DEMO_USER_ID,
@ -23,10 +24,10 @@ import {
import { import {
InfoItem, InfoItem,
Statistics, Statistics,
Subscription SubscriptionOffer
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
import { SubscriptionOffer } from '@ghostfolio/common/types'; import { SubscriptionOfferKey } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
@ -101,7 +102,7 @@ export class InfoService {
isUserSignupEnabled, isUserSignupEnabled,
platforms, platforms,
statistics, statistics,
subscriptions subscriptionOffers
] = await Promise.all([ ] = await Promise.all([
this.benchmarkService.getBenchmarkAssetProfiles(), this.benchmarkService.getBenchmarkAssetProfiles(),
this.getDemoAuthToken(), this.getDemoAuthToken(),
@ -110,7 +111,7 @@ export class InfoService {
orderBy: { name: 'asc' } orderBy: { name: 'asc' }
}), }),
this.getStatistics(), this.getStatistics(),
this.getSubscriptions() this.getSubscriptionOffers()
]); ]);
if (isUserSignupEnabled) { if (isUserSignupEnabled) {
@ -125,7 +126,7 @@ export class InfoService {
isReadOnlyMode, isReadOnlyMode,
platforms, platforms,
statistics, statistics,
subscriptions, subscriptionOffers,
baseCurrency: DEFAULT_CURRENCY, baseCurrency: DEFAULT_CURRENCY,
currencies: this.exchangeRateDataService.getCurrencies() currencies: this.exchangeRateDataService.getCurrencies()
}; };
@ -142,7 +143,7 @@ export class InfoService {
}, },
{ {
Analytics: { Analytics: {
updatedAt: { lastRequestAt: {
gt: subDays(new Date(), aDays) gt: subDays(new Date(), aDays)
} }
} }
@ -314,8 +315,8 @@ export class InfoService {
return statistics; return statistics;
} }
private async getSubscriptions(): Promise<{ private async getSubscriptionOffers(): Promise<{
[offer in SubscriptionOffer]: Subscription; [offer in SubscriptionOfferKey]: SubscriptionOffer;
}> { }> {
if (!this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { if (!this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
return undefined; return undefined;
@ -347,7 +348,7 @@ export class InfoService {
)}&to${format(new Date(), DATE_FORMAT)}`, )}&to${format(new Date(), DATE_FORMAT)}`,
{ {
headers: { headers: {
Authorization: `Bearer ${this.configurationService.get( [HEADER_KEY_TOKEN]: `Bearer ${this.configurationService.get(
'API_KEY_BETTER_UPTIME' 'API_KEY_BETTER_UPTIME'
)}` )}`
}, },

View File

@ -52,27 +52,24 @@ export class CurrentRateService {
.then((dataResultProvider) => { .then((dataResultProvider) => {
const result: GetValueObject[] = []; const result: GetValueObject[] = [];
for (const dataGatheringItem of dataGatheringItems) { for (const { dataSource, symbol } of dataGatheringItems) {
if ( if (dataResultProvider?.[symbol]?.dataProviderInfo) {
dataResultProvider?.[dataGatheringItem.symbol]?.dataProviderInfo
) {
dataProviderInfos.push( dataProviderInfos.push(
dataResultProvider[dataGatheringItem.symbol].dataProviderInfo dataResultProvider[symbol].dataProviderInfo
); );
} }
if (dataResultProvider?.[dataGatheringItem.symbol]?.marketPrice) { if (dataResultProvider?.[symbol]?.marketPrice) {
result.push({ result.push({
dataSource: dataGatheringItem.dataSource, dataSource,
symbol,
date: today, date: today,
marketPrice: marketPrice: dataResultProvider?.[symbol]?.marketPrice
dataResultProvider?.[dataGatheringItem.symbol]?.marketPrice,
symbol: dataGatheringItem.symbol
}); });
} else { } else {
quoteErrors.push({ quoteErrors.push({
dataSource: dataGatheringItem.dataSource, dataSource,
symbol: dataGatheringItem.symbol symbol
}); });
} }
} }

View File

@ -74,12 +74,15 @@ export class PortfolioController {
@Get('details') @Get('details')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getDetails( public async getDetails(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Query('accounts') filterByAccounts?: string, @Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string, @Query('assetClasses') filterByAssetClasses?: string,
@Query('dataSource') filterByDataSource?: string,
@Query('range') dateRange: DateRange = 'max', @Query('range') dateRange: DateRange = 'max',
@Query('symbol') filterBySymbol?: string,
@Query('tags') filterByTags?: string, @Query('tags') filterByTags?: string,
@Query('withMarkets') withMarketsParam = 'false' @Query('withMarkets') withMarketsParam = 'false'
): Promise<PortfolioDetails & { hasError: boolean }> { ): Promise<PortfolioDetails & { hasError: boolean }> {
@ -95,6 +98,8 @@ export class PortfolioController {
const filters = this.apiService.buildFiltersFromQueryParams({ const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts, filterByAccounts,
filterByAssetClasses, filterByAssetClasses,
filterByDataSource,
filterBySymbol,
filterByTags filterByTags
}); });
@ -289,17 +294,22 @@ export class PortfolioController {
@Get('dividends') @Get('dividends')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(TransformDataSourceInRequestInterceptor)
public async getDividends( public async getDividends(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Query('accounts') filterByAccounts?: string, @Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string, @Query('assetClasses') filterByAssetClasses?: string,
@Query('dataSource') filterByDataSource?: string,
@Query('groupBy') groupBy?: GroupBy, @Query('groupBy') groupBy?: GroupBy,
@Query('range') dateRange: DateRange = 'max', @Query('range') dateRange: DateRange = 'max',
@Query('symbol') filterBySymbol?: string,
@Query('tags') filterByTags?: string @Query('tags') filterByTags?: string
): Promise<PortfolioDividends> { ): Promise<PortfolioDividends> {
const filters = this.apiService.buildFiltersFromQueryParams({ const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts, filterByAccounts,
filterByAssetClasses, filterByAssetClasses,
filterByDataSource,
filterBySymbol,
filterByTags filterByTags
}); });
@ -356,21 +366,26 @@ export class PortfolioController {
@Get('holdings') @Get('holdings')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getHoldings( public async getHoldings(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Query('accounts') filterByAccounts?: string, @Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string, @Query('assetClasses') filterByAssetClasses?: string,
@Query('dataSource') filterByDataSource?: string,
@Query('holdingType') filterByHoldingType?: string, @Query('holdingType') filterByHoldingType?: string,
@Query('query') filterBySearchQuery?: string, @Query('query') filterBySearchQuery?: string,
@Query('range') dateRange: DateRange = 'max', @Query('range') dateRange: DateRange = 'max',
@Query('symbol') filterBySymbol?: string,
@Query('tags') filterByTags?: string @Query('tags') filterByTags?: string
): Promise<PortfolioHoldingsResponse> { ): Promise<PortfolioHoldingsResponse> {
const filters = this.apiService.buildFiltersFromQueryParams({ const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts, filterByAccounts,
filterByAssetClasses, filterByAssetClasses,
filterByDataSource,
filterByHoldingType, filterByHoldingType,
filterBySearchQuery, filterBySearchQuery,
filterBySymbol,
filterByTags filterByTags
}); });
@ -386,17 +401,22 @@ export class PortfolioController {
@Get('investments') @Get('investments')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(TransformDataSourceInRequestInterceptor)
public async getInvestments( public async getInvestments(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Query('accounts') filterByAccounts?: string, @Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string, @Query('assetClasses') filterByAssetClasses?: string,
@Query('dataSource') filterByDataSource?: string,
@Query('groupBy') groupBy?: GroupBy, @Query('groupBy') groupBy?: GroupBy,
@Query('range') dateRange: DateRange = 'max', @Query('range') dateRange: DateRange = 'max',
@Query('symbol') filterBySymbol?: string,
@Query('tags') filterByTags?: string @Query('tags') filterByTags?: string
): Promise<PortfolioInvestments> { ): Promise<PortfolioInvestments> {
const filters = this.apiService.buildFiltersFromQueryParams({ const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts, filterByAccounts,
filterByAssetClasses, filterByAssetClasses,
filterByDataSource,
filterBySymbol,
filterByTags filterByTags
}); });
@ -451,13 +471,16 @@ export class PortfolioController {
@Get('performance') @Get('performance')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(PerformanceLoggingInterceptor) @UseInterceptors(PerformanceLoggingInterceptor)
@UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
@Version('2') @Version('2')
public async getPerformanceV2( public async getPerformanceV2(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Query('accounts') filterByAccounts?: string, @Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string, @Query('assetClasses') filterByAssetClasses?: string,
@Query('dataSource') filterByDataSource?: string,
@Query('range') dateRange: DateRange = 'max', @Query('range') dateRange: DateRange = 'max',
@Query('symbol') filterBySymbol?: string,
@Query('tags') filterByTags?: string, @Query('tags') filterByTags?: string,
@Query('withExcludedAccounts') withExcludedAccountsParam = 'false' @Query('withExcludedAccounts') withExcludedAccountsParam = 'false'
): Promise<PortfolioPerformanceResponse> { ): Promise<PortfolioPerformanceResponse> {
@ -466,6 +489,8 @@ export class PortfolioController {
const filters = this.apiService.buildFiltersFromQueryParams({ const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts, filterByAccounts,
filterByAssetClasses, filterByAssetClasses,
filterByDataSource,
filterBySymbol,
filterByTags filterByTags
}); });

View File

@ -7,10 +7,10 @@ import { UserService } from '@ghostfolio/api/app/user/user.service';
import { getFactor } from '@ghostfolio/api/helper/portfolio.helper'; import { getFactor } from '@ghostfolio/api/helper/portfolio.helper';
import { AccountClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/account-cluster-risk/current-investment'; import { AccountClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/account-cluster-risk/current-investment';
import { AccountClusterRiskSingleAccount } from '@ghostfolio/api/models/rules/account-cluster-risk/single-account'; import { AccountClusterRiskSingleAccount } from '@ghostfolio/api/models/rules/account-cluster-risk/single-account';
import { AllocationClusterRiskDevelopedMarkets } from '@ghostfolio/api/models/rules/allocation-cluster-risk/developed-markets';
import { AllocationClusterRiskEmergingMarkets } from '@ghostfolio/api/models/rules/allocation-cluster-risk/emerging-markets';
import { CurrencyClusterRiskBaseCurrencyCurrentInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/base-currency-current-investment'; import { CurrencyClusterRiskBaseCurrencyCurrentInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/base-currency-current-investment';
import { CurrencyClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/current-investment'; import { CurrencyClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/current-investment';
import { EconomicMarketClusterRiskDevelopedMarkets } from '@ghostfolio/api/models/rules/economic-market-cluster-risk/developed-markets';
import { EconomicMarketClusterRiskEmergingMarkets } from '@ghostfolio/api/models/rules/economic-market-cluster-risk/emerging-markets';
import { EmergencyFundSetup } from '@ghostfolio/api/models/rules/emergency-fund/emergency-fund-setup'; import { EmergencyFundSetup } from '@ghostfolio/api/models/rules/emergency-fund/emergency-fund-setup';
import { FeeRatioInitialInvestment } from '@ghostfolio/api/models/rules/fees/fee-ratio-initial-investment'; import { FeeRatioInitialInvestment } from '@ghostfolio/api/models/rules/fees/fee-ratio-initial-investment';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
@ -413,15 +413,16 @@ export class PortfolioService {
); );
} }
const dataGatheringItems = positions.map(({ dataSource, symbol }) => { const assetProfileIdentifiers = positions.map(({ dataSource, symbol }) => {
return { return {
dataSource, dataSource,
symbol symbol
}; };
}); });
const symbolProfiles = const symbolProfiles = await this.symbolProfileService.getSymbolProfiles(
await this.symbolProfileService.getSymbolProfiles(dataGatheringItems); assetProfileIdentifiers
);
const symbolProfileMap: { [symbol: string]: EnhancedSymbolProfile } = {}; const symbolProfileMap: { [symbol: string]: EnhancedSymbolProfile } = {};
for (const symbolProfile of symbolProfiles) { for (const symbolProfile of symbolProfiles) {
@ -848,7 +849,7 @@ export class PortfolioService {
if (isEmpty(historicalData)) { if (isEmpty(historicalData)) {
try { try {
historicalData = await this.dataProviderService.getHistoricalRaw({ historicalData = await this.dataProviderService.getHistoricalRaw({
dataGatheringItems: [ assetProfileIdentifiers: [
{ dataSource: DataSource.YAHOO, symbol: aSymbol } { dataSource: DataSource.YAHOO, symbol: aSymbol }
], ],
from: portfolioStart, from: portfolioStart,
@ -953,7 +954,7 @@ export class PortfolioService {
return !quantity.eq(0); return !quantity.eq(0);
}); });
const dataGatheringItems = positions.map(({ dataSource, symbol }) => { const assetProfileIdentifiers = positions.map(({ dataSource, symbol }) => {
return { return {
dataSource, dataSource,
symbol symbol
@ -961,7 +962,10 @@ export class PortfolioService {
}); });
const [dataProviderResponses, symbolProfiles] = await Promise.all([ const [dataProviderResponses, symbolProfiles] = await Promise.all([
this.dataProviderService.getQuotes({ user, items: dataGatheringItems }), this.dataProviderService.getQuotes({
user,
items: assetProfileIdentifiers
}),
this.symbolProfileService.getSymbolProfiles( this.symbolProfileService.getSymbolProfiles(
positions.map(({ dataSource, symbol }) => { positions.map(({ dataSource, symbol }) => {
return { dataSource, symbol }; return { dataSource, symbol };
@ -1193,16 +1197,16 @@ export class PortfolioService {
userSettings userSettings
) )
: undefined, : undefined,
allocationClusterRisk: economicMarketClusterRisk:
summary.ordersCount > 0 summary.ordersCount > 0
? await this.rulesService.evaluate( ? await this.rulesService.evaluate(
[ [
new AllocationClusterRiskDevelopedMarkets( new EconomicMarketClusterRiskDevelopedMarkets(
this.exchangeRateDataService, this.exchangeRateDataService,
marketsTotalInBaseCurrency, marketsTotalInBaseCurrency,
markets.developedMarkets.valueInBaseCurrency markets.developedMarkets.valueInBaseCurrency
), ),
new AllocationClusterRiskEmergingMarkets( new EconomicMarketClusterRiskEmergingMarkets(
this.exchangeRateDataService, this.exchangeRateDataService,
marketsTotalInBaseCurrency, marketsTotalInBaseCurrency,
markets.emergingMarkets.valueInBaseCurrency markets.emergingMarkets.valueInBaseCurrency

View File

@ -1,8 +1,16 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import {
DEFAULT_LANGUAGE_CODE,
PROPERTY_STRIPE_CONFIG
} from '@ghostfolio/common/config';
import { parseDate } from '@ghostfolio/common/helper'; import { parseDate } from '@ghostfolio/common/helper';
import { SubscriptionOffer, UserWithSettings } from '@ghostfolio/common/types'; import { SubscriptionOffer } from '@ghostfolio/common/interfaces';
import {
SubscriptionOfferKey,
UserWithSettings
} from '@ghostfolio/common/types';
import { SubscriptionType } from '@ghostfolio/common/types/subscription-type.type'; import { SubscriptionType } from '@ghostfolio/common/types/subscription-type.type';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
@ -17,14 +25,17 @@ export class SubscriptionService {
public constructor( public constructor(
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly prismaService: PrismaService private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService
) { ) {
this.stripe = new Stripe( if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
this.configurationService.get('STRIPE_SECRET_KEY'), this.stripe = new Stripe(
{ this.configurationService.get('STRIPE_SECRET_KEY'),
apiVersion: '2024-04-10' {
} apiVersion: '2024-09-30.acacia'
); }
);
}
} }
public async createCheckoutSession({ public async createCheckoutSession({
@ -36,6 +47,18 @@ export class SubscriptionService {
priceId: string; priceId: string;
user: UserWithSettings; user: UserWithSettings;
}) { }) {
const subscriptionOffers: {
[offer in SubscriptionOfferKey]: SubscriptionOffer;
} =
((await this.propertyService.getByKey(PROPERTY_STRIPE_CONFIG)) as any) ??
{};
const subscriptionOffer = Object.values(subscriptionOffers).find(
(subscriptionOffer) => {
return subscriptionOffer.priceId === priceId;
}
);
const checkoutSessionCreateParams: Stripe.Checkout.SessionCreateParams = { const checkoutSessionCreateParams: Stripe.Checkout.SessionCreateParams = {
cancel_url: `${this.configurationService.get('ROOT_URL')}/${ cancel_url: `${this.configurationService.get('ROOT_URL')}/${
user.Settings?.settings?.language ?? DEFAULT_LANGUAGE_CODE user.Settings?.settings?.language ?? DEFAULT_LANGUAGE_CODE
@ -47,6 +70,13 @@ export class SubscriptionService {
quantity: 1 quantity: 1
} }
], ],
locale:
(user.Settings?.settings
?.language as Stripe.Checkout.SessionCreateParams.Locale) ??
DEFAULT_LANGUAGE_CODE,
metadata: subscriptionOffer
? { subscriptionOffer: JSON.stringify(subscriptionOffer) }
: {},
mode: 'payment', mode: 'payment',
payment_method_types: ['card'], payment_method_types: ['card'],
success_url: `${this.configurationService.get( success_url: `${this.configurationService.get(
@ -73,17 +103,25 @@ export class SubscriptionService {
public async createSubscription({ public async createSubscription({
duration = '1 year', duration = '1 year',
durationExtension,
price, price,
userId userId
}: { }: {
duration?: StringValue; duration?: StringValue;
durationExtension?: StringValue;
price: number; price: number;
userId: string; userId: string;
}) { }) {
let expiresAt = addMilliseconds(new Date(), ms(duration));
if (durationExtension) {
expiresAt = addMilliseconds(expiresAt, ms(durationExtension));
}
await this.prismaService.subscription.create({ await this.prismaService.subscription.create({
data: { data: {
expiresAt,
price, price,
expiresAt: addMilliseconds(new Date(), ms(duration)),
User: { User: {
connect: { connect: {
id: userId id: userId
@ -95,10 +133,21 @@ export class SubscriptionService {
public async createSubscriptionViaStripe(aCheckoutSessionId: string) { public async createSubscriptionViaStripe(aCheckoutSessionId: string) {
try { try {
let durationExtension: StringValue;
const session = const session =
await this.stripe.checkout.sessions.retrieve(aCheckoutSessionId); await this.stripe.checkout.sessions.retrieve(aCheckoutSessionId);
const subscriptionOffer: SubscriptionOffer = JSON.parse(
session.metadata.subscriptionOffer ?? '{}'
);
if (subscriptionOffer) {
durationExtension = subscriptionOffer.durationExtension;
}
await this.createSubscription({ await this.createSubscription({
durationExtension,
price: session.amount_total / 100, price: session.amount_total / 100,
userId: session.client_reference_id userId: session.client_reference_id
}); });
@ -121,7 +170,7 @@ export class SubscriptionService {
return new Date(a.expiresAt) > new Date(b.expiresAt) ? a : b; return new Date(a.expiresAt) > new Date(b.expiresAt) ? a : b;
}); });
let offer: SubscriptionOffer = price ? 'renewal' : 'default'; let offer: SubscriptionOfferKey = price ? 'renewal' : 'default';
if (isBefore(createdAt, parseDate('2023-01-01'))) { if (isBefore(createdAt, parseDate('2023-01-01'))) {
offer = 'renewal-early-bird-2023'; offer = 'renewal-early-bird-2023';

View File

@ -2,6 +2,7 @@ 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 { 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 { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces'; import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
import { LookupResponse } from '@ghostfolio/common/interfaces';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
@ -21,7 +22,6 @@ import { parseISO } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { isDate, isEmpty } from 'lodash'; import { isDate, isEmpty } from 'lodash';
import { LookupItem } from './interfaces/lookup-item.interface';
import { SymbolItem } from './interfaces/symbol-item.interface'; import { SymbolItem } from './interfaces/symbol-item.interface';
import { SymbolService } from './symbol.service'; import { SymbolService } from './symbol.service';
@ -41,7 +41,7 @@ export class SymbolController {
public async lookupSymbol( public async lookupSymbol(
@Query('includeIndices') includeIndicesParam = 'false', @Query('includeIndices') includeIndicesParam = 'false',
@Query('query') query = '' @Query('query') query = ''
): Promise<{ items: LookupItem[] }> { ): Promise<LookupResponse> {
const includeIndices = includeIndicesParam === 'true'; const includeIndices = includeIndicesParam === 'true';
try { try {

View File

@ -5,13 +5,15 @@ import {
} from '@ghostfolio/api/services/interfaces/interfaces'; } from '@ghostfolio/api/services/interfaces/interfaces';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { HistoricalDataItem } from '@ghostfolio/common/interfaces'; import {
HistoricalDataItem,
LookupResponse
} from '@ghostfolio/common/interfaces';
import { UserWithSettings } from '@ghostfolio/common/types'; import { UserWithSettings } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { format, subDays } from 'date-fns'; import { format, subDays } from 'date-fns';
import { LookupItem } from './interfaces/lookup-item.interface';
import { SymbolItem } from './interfaces/symbol-item.interface'; import { SymbolItem } from './interfaces/symbol-item.interface';
@Injectable() @Injectable()
@ -84,7 +86,7 @@ export class SymbolService {
try { try {
historicalData = await this.dataProviderService.getHistoricalRaw({ historicalData = await this.dataProviderService.getHistoricalRaw({
dataGatheringItems: [{ dataSource, symbol }], assetProfileIdentifiers: [{ dataSource, symbol }],
from: date, from: date,
to: date to: date
}); });
@ -104,8 +106,8 @@ export class SymbolService {
includeIndices?: boolean; includeIndices?: boolean;
query: string; query: string;
user: UserWithSettings; user: UserWithSettings;
}): Promise<{ items: LookupItem[] }> { }): Promise<LookupResponse> {
const results: { items: LookupItem[] } = { items: [] }; const results: LookupResponse = { items: [] };
if (!query) { if (!query) {
return results; return results;

View File

@ -64,6 +64,14 @@ export class UpdateUserSettingDto {
@IsOptional() @IsOptional()
'filters.assetClasses'?: string[]; 'filters.assetClasses'?: string[];
@IsString()
@IsOptional()
'filters.dataSource'?: string;
@IsString()
@IsOptional()
'filters.symbol'?: string;
@IsArray() @IsArray()
@IsOptional() @IsOptional()
'filters.tags'?: string[]; 'filters.tags'?: string[];

View File

@ -4,10 +4,10 @@ import { environment } from '@ghostfolio/api/environments/environment';
import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event'; import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event';
import { AccountClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/account-cluster-risk/current-investment'; import { AccountClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/account-cluster-risk/current-investment';
import { AccountClusterRiskSingleAccount } from '@ghostfolio/api/models/rules/account-cluster-risk/single-account'; import { AccountClusterRiskSingleAccount } from '@ghostfolio/api/models/rules/account-cluster-risk/single-account';
import { AllocationClusterRiskDevelopedMarkets } from '@ghostfolio/api/models/rules/allocation-cluster-risk/developed-markets';
import { AllocationClusterRiskEmergingMarkets } from '@ghostfolio/api/models/rules/allocation-cluster-risk/emerging-markets';
import { CurrencyClusterRiskBaseCurrencyCurrentInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/base-currency-current-investment'; import { CurrencyClusterRiskBaseCurrencyCurrentInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/base-currency-current-investment';
import { CurrencyClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/current-investment'; import { CurrencyClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/current-investment';
import { EconomicMarketClusterRiskDevelopedMarkets } from '@ghostfolio/api/models/rules/economic-market-cluster-risk/developed-markets';
import { EconomicMarketClusterRiskEmergingMarkets } from '@ghostfolio/api/models/rules/economic-market-cluster-risk/emerging-markets';
import { EmergencyFundSetup } from '@ghostfolio/api/models/rules/emergency-fund/emergency-fund-setup'; import { EmergencyFundSetup } from '@ghostfolio/api/models/rules/emergency-fund/emergency-fund-setup';
import { FeeRatioInitialInvestment } from '@ghostfolio/api/models/rules/fees/fee-ratio-initial-investment'; import { FeeRatioInitialInvestment } from '@ghostfolio/api/models/rules/fees/fee-ratio-initial-investment';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
@ -37,7 +37,7 @@ import { UserWithSettings } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter'; import { EventEmitter2 } from '@nestjs/event-emitter';
import { Prisma, Role, User } from '@prisma/client'; import { Prisma, Role, User } from '@prisma/client';
import { differenceInDays } from 'date-fns'; import { differenceInDays, subDays } from 'date-fns';
import { sortBy, without } from 'lodash'; import { sortBy, without } from 'lodash';
const crypto = require('crypto'); const crypto = require('crypto');
@ -60,6 +60,13 @@ export class UserService {
return this.prismaService.user.count(args); return this.prismaService.user.count(args);
} }
public createAccessToken(password: string, salt: string): string {
const hash = crypto.createHmac('sha512', salt);
hash.update(password);
return hash.digest('hex');
}
public async getUser( public async getUser(
{ Account, id, permissions, Settings, subscription }: UserWithSettings, { Account, id, permissions, Settings, subscription }: UserWithSettings,
aLocale = locale aLocale = locale
@ -176,7 +183,9 @@ export class UserService {
Settings: Settings as UserWithSettings['Settings'], Settings: Settings as UserWithSettings['Settings'],
thirdPartyId, thirdPartyId,
updatedAt, updatedAt,
activityCount: Analytics?.activityCount activityCount: Analytics?.activityCount,
dataProviderGhostfolioDailyRequests:
Analytics?.dataProviderGhostfolioDailyRequests
}; };
if (user?.Settings) { if (user?.Settings) {
@ -217,14 +226,14 @@ export class UserService {
undefined, undefined,
{} {}
).getSettings(user.Settings.settings), ).getSettings(user.Settings.settings),
AllocationClusterRiskDevelopedMarkets: EconomicMarketClusterRiskDevelopedMarkets:
new AllocationClusterRiskDevelopedMarkets( new EconomicMarketClusterRiskDevelopedMarkets(
undefined, undefined,
undefined, undefined,
undefined undefined
).getSettings(user.Settings.settings), ).getSettings(user.Settings.settings),
AllocationClusterRiskEmergingMarkets: EconomicMarketClusterRiskEmergingMarkets:
new AllocationClusterRiskEmergingMarkets( new EconomicMarketClusterRiskEmergingMarkets(
undefined, undefined,
undefined, undefined,
undefined undefined
@ -300,6 +309,7 @@ export class UserService {
// Reset holdings view mode // Reset holdings view mode
user.Settings.settings.holdingsViewMode = undefined; user.Settings.settings.holdingsViewMode = undefined;
} else if (user.subscription?.type === 'Premium') { } else if (user.subscription?.type === 'Premium') {
currentPermissions.push(permissions.enableDataProviderGhostfolio);
currentPermissions.push(permissions.reportDataGlitch); currentPermissions.push(permissions.reportDataGlitch);
currentPermissions = without( currentPermissions = without(
@ -358,13 +368,6 @@ export class UserService {
}); });
} }
public createAccessToken(password: string, salt: string): string {
const hash = crypto.createHmac('sha512', salt);
hash.update(password);
return hash.digest('hex');
}
public async createUser({ public async createUser({
data data
}: { }: {
@ -426,17 +429,6 @@ export class UserService {
return user; return user;
} }
public async updateUser(params: {
where: Prisma.UserWhereUniqueInput;
data: Prisma.UserUpdateInput;
}): Promise<User> {
const { where, data } = params;
return this.prismaService.user.update({
data,
where
});
}
public async deleteUser(where: Prisma.UserWhereUniqueInput): Promise<User> { public async deleteUser(where: Prisma.UserWhereUniqueInput): Promise<User> {
try { try {
await this.prismaService.access.deleteMany({ await this.prismaService.access.deleteMany({
@ -473,6 +465,32 @@ export class UserService {
}); });
} }
public async resetAnalytics() {
return this.prismaService.analytics.updateMany({
data: {
dataProviderGhostfolioDailyRequests: 0
},
where: {
updatedAt: {
gte: subDays(new Date(), 1)
}
}
});
}
public async updateUser({
data,
where
}: {
data: Prisma.UserUpdateInput;
where: Prisma.UserWhereUniqueInput;
}): Promise<User> {
return this.prismaService.user.update({
data,
where
});
}
public async updateUserSetting({ public async updateUserSetting({
emitPortfolioChangedEvent, emitPortfolioChangedEvent,
userId, userId,

File diff suppressed because it is too large Load Diff

View File

@ -56,10 +56,22 @@
<loc>https://ghostfol.io/de/ressourcen</loc> <loc>https://ghostfol.io/de/ressourcen</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/de/ressourcen/lexikon</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/maerkte</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools</loc> <loc>https://ghostfol.io/de/ressourcen/personal-finance-tools</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/de/ressourcen/ratgeber</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/de/ueber-uns</loc> <loc>https://ghostfol.io/de/ueber-uns</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -176,6 +188,10 @@
<loc>https://ghostfol.io/en/blog/2024/09/hacktoberfest-2024</loc> <loc>https://ghostfol.io/en/blog/2024/09/hacktoberfest-2024</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/en/blog/2024/11/black-weeks-2024</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/en/faq</loc> <loc>https://ghostfol.io/en/faq</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>

View File

@ -87,6 +87,10 @@ const locales = {
'/en/blog/2024/09/hacktoberfest-2024': { '/en/blog/2024/09/hacktoberfest-2024': {
featureGraphicPath: 'assets/images/blog/hacktoberfest-2024.png', featureGraphicPath: 'assets/images/blog/hacktoberfest-2024.png',
title: `Hacktoberfest 2024 - ${title}` title: `Hacktoberfest 2024 - ${title}`
},
'/en/blog/2024/11/black-weeks-2024': {
featureGraphicPath: 'assets/images/blog/black-weeks-2024.jpg',
title: `Black Weeks 2024 - ${title}`
} }
}; };

View File

@ -3,7 +3,7 @@ import { Rule } from '@ghostfolio/api/models/rule';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { UserSettings } from '@ghostfolio/common/interfaces'; import { UserSettings } from '@ghostfolio/common/interfaces';
export class AllocationClusterRiskDevelopedMarkets extends Rule<Settings> { export class EconomicMarketClusterRiskDevelopedMarkets extends Rule<Settings> {
private currentValueInBaseCurrency: number; private currentValueInBaseCurrency: number;
private developedMarketsValueInBaseCurrency: number; private developedMarketsValueInBaseCurrency: number;
@ -13,7 +13,7 @@ export class AllocationClusterRiskDevelopedMarkets extends Rule<Settings> {
developedMarketsValueInBaseCurrency: number developedMarketsValueInBaseCurrency: number
) { ) {
super(exchangeRateDataService, { super(exchangeRateDataService, {
key: AllocationClusterRiskDevelopedMarkets.name, key: EconomicMarketClusterRiskDevelopedMarkets.name,
name: 'Developed Markets' name: 'Developed Markets'
}); });

View File

@ -3,7 +3,7 @@ import { Rule } from '@ghostfolio/api/models/rule';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { UserSettings } from '@ghostfolio/common/interfaces'; import { UserSettings } from '@ghostfolio/common/interfaces';
export class AllocationClusterRiskEmergingMarkets extends Rule<Settings> { export class EconomicMarketClusterRiskEmergingMarkets extends Rule<Settings> {
private currentValueInBaseCurrency: number; private currentValueInBaseCurrency: number;
private emergingMarketsValueInBaseCurrency: number; private emergingMarketsValueInBaseCurrency: number;
@ -13,7 +13,7 @@ export class AllocationClusterRiskEmergingMarkets extends Rule<Settings> {
emergingMarketsValueInBaseCurrency: number emergingMarketsValueInBaseCurrency: number
) { ) {
super(exchangeRateDataService, { super(exchangeRateDataService, {
key: AllocationClusterRiskEmergingMarkets.name, key: EconomicMarketClusterRiskEmergingMarkets.name,
name: 'Emerging Markets' name: 'Emerging Markets'
}); });

View File

@ -35,6 +35,9 @@ export class ConfigurationService {
DATA_SOURCES: json({ DATA_SOURCES: json({
default: [DataSource.COINGECKO, DataSource.MANUAL, DataSource.YAHOO] default: [DataSource.COINGECKO, DataSource.MANUAL, DataSource.YAHOO]
}), }),
DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER: json({
default: []
}),
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: bool({ default: false }), ENABLE_FEATURE_FEAR_AND_GREED_INDEX: bool({ default: false }),
ENABLE_FEATURE_READ_ONLY_MODE: bool({ default: false }), ENABLE_FEATURE_READ_ONLY_MODE: bool({ default: false }),
ENABLE_FEATURE_SOCIAL_LOGIN: bool({ default: false }), ENABLE_FEATURE_SOCIAL_LOGIN: bool({ default: false }),
@ -67,7 +70,7 @@ export class ConfigurationService {
REDIS_HOST: str({ default: 'localhost' }), REDIS_HOST: str({ default: 'localhost' }),
REDIS_PASSWORD: str({ default: '' }), REDIS_PASSWORD: str({ default: '' }),
REDIS_PORT: port({ default: 6379 }), REDIS_PORT: port({ default: 6379 }),
REQUEST_TIMEOUT: num({ default: 2000 }), REQUEST_TIMEOUT: num({ default: ms('3 seconds') }),
ROOT_URL: url({ default: DEFAULT_ROOT_URL }), ROOT_URL: url({ default: DEFAULT_ROOT_URL }),
STRIPE_PUBLIC_KEY: str({ default: '' }), STRIPE_PUBLIC_KEY: str({ default: '' }),
STRIPE_SECRET_KEY: str({ default: '' }), STRIPE_SECRET_KEY: str({ default: '' }),

View File

@ -1,3 +1,4 @@
import { UserService } from '@ghostfolio/api/app/user/user.service';
import { import {
DATA_GATHERING_QUEUE_PRIORITY_LOW, DATA_GATHERING_QUEUE_PRIORITY_LOW,
GATHER_ASSET_PROFILE_PROCESS, GATHER_ASSET_PROFILE_PROCESS,
@ -9,6 +10,7 @@ import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule'; import { Cron, CronExpression } from '@nestjs/schedule';
import { ConfigurationService } from './configuration/configuration.service';
import { ExchangeRateDataService } from './exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from './exchange-rate-data/exchange-rate-data.service';
import { PropertyService } from './property/property.service'; import { PropertyService } from './property/property.service';
import { DataGatheringService } from './queues/data-gathering/data-gathering.service'; import { DataGatheringService } from './queues/data-gathering/data-gathering.service';
@ -19,10 +21,12 @@ export class CronService {
private static readonly EVERY_SUNDAY_AT_LUNCH_TIME = '0 12 * * 0'; private static readonly EVERY_SUNDAY_AT_LUNCH_TIME = '0 12 * * 0';
public constructor( public constructor(
private readonly configurationService: ConfigurationService,
private readonly dataGatheringService: DataGatheringService, private readonly dataGatheringService: DataGatheringService,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly propertyService: PropertyService, private readonly propertyService: PropertyService,
private readonly twitterBotService: TwitterBotService private readonly twitterBotService: TwitterBotService,
private readonly userService: UserService
) {} ) {}
@Cron(CronExpression.EVERY_HOUR) @Cron(CronExpression.EVERY_HOUR)
@ -42,6 +46,13 @@ export class CronService {
this.twitterBotService.tweetFearAndGreedIndex(); this.twitterBotService.tweetFearAndGreedIndex();
} }
@Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT)
public async runEveryDayAtMidnight() {
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
this.userService.resetAnalytics();
}
}
@Cron(CronService.EVERY_SUNDAY_AT_LUNCH_TIME) @Cron(CronService.EVERY_SUNDAY_AT_LUNCH_TIME)
public async runEverySundayAtTwelvePm() { public async runEverySundayAtTwelvePm() {
if (await this.isDataGatheringEnabled()) { if (await this.isDataGatheringEnabled()) {

View File

@ -1,4 +1,3 @@
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { import {
DataProviderInterface, DataProviderInterface,
@ -12,7 +11,10 @@ import {
IDataProviderResponse IDataProviderResponse
} from '@ghostfolio/api/services/interfaces/interfaces'; } from '@ghostfolio/api/services/interfaces/interfaces';
import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { DataProviderInfo } from '@ghostfolio/common/interfaces'; import {
DataProviderInfo,
LookupResponse
} from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { DataSource, SymbolProfile } from '@prisma/client'; import { DataSource, SymbolProfile } from '@prisma/client';
@ -119,9 +121,7 @@ export class AlphaVantageService implements DataProviderInterface {
return undefined; return undefined;
} }
public async search({ public async search({ query }: GetSearchParams): Promise<LookupResponse> {
query
}: GetSearchParams): Promise<{ items: LookupItem[] }> {
const result = await this.alphaVantage.data.search(query); const result = await this.alphaVantage.data.search(query);
return { return {

View File

@ -1,4 +1,3 @@
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { import {
DataProviderInterface, DataProviderInterface,
@ -13,7 +12,11 @@ import {
} from '@ghostfolio/api/services/interfaces/interfaces'; } from '@ghostfolio/api/services/interfaces/interfaces';
import { DEFAULT_CURRENCY } from '@ghostfolio/common/config'; import { DEFAULT_CURRENCY } from '@ghostfolio/common/config';
import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { DataProviderInfo } from '@ghostfolio/common/interfaces'; import {
DataProviderInfo,
LookupItem,
LookupResponse
} from '@ghostfolio/common/interfaces';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { import {
@ -83,9 +86,9 @@ export class CoinGeckoService implements DataProviderInterface {
let message = error; let message = error;
if (error?.code === 'ABORT_ERR') { if (error?.code === 'ABORT_ERR') {
message = `RequestError: The operation to get the asset profile for ${symbol} was aborted because the request to the data provider took more than ${this.configurationService.get( message = `RequestError: The operation to get the asset profile for ${symbol} was aborted because the request to the data provider took more than ${(
'REQUEST_TIMEOUT' this.configurationService.get('REQUEST_TIMEOUT') / 1000
)}ms`; ).toFixed(3)} seconds`;
} }
Logger.error(message, 'CoinGeckoService'); Logger.error(message, 'CoinGeckoService');
@ -221,9 +224,7 @@ export class CoinGeckoService implements DataProviderInterface {
return 'bitcoin'; return 'bitcoin';
} }
public async search({ public async search({ query }: GetSearchParams): Promise<LookupResponse> {
query
}: GetSearchParams): Promise<{ items: LookupItem[] }> {
let items: LookupItem[] = []; let items: LookupItem[] = [];
try { try {
@ -254,9 +255,9 @@ export class CoinGeckoService implements DataProviderInterface {
let message = error; let message = error;
if (error?.code === 'ABORT_ERR') { if (error?.code === 'ABORT_ERR') {
message = `RequestError: The operation to search for ${query} was aborted because the request to the data provider took more than ${this.configurationService.get( message = `RequestError: The operation to search for ${query} was aborted because the request to the data provider took more than ${(
'REQUEST_TIMEOUT' this.configurationService.get('REQUEST_TIMEOUT') / 1000
)}ms`; ).toFixed(3)} seconds`;
} }
Logger.error(message, 'CoinGeckoService'); Logger.error(message, 'CoinGeckoService');

View File

@ -5,6 +5,7 @@ import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alph
import { CoinGeckoService } from '@ghostfolio/api/services/data-provider/coingecko/coingecko.service'; import { CoinGeckoService } from '@ghostfolio/api/services/data-provider/coingecko/coingecko.service';
import { EodHistoricalDataService } from '@ghostfolio/api/services/data-provider/eod-historical-data/eod-historical-data.service'; import { EodHistoricalDataService } from '@ghostfolio/api/services/data-provider/eod-historical-data/eod-historical-data.service';
import { FinancialModelingPrepService } from '@ghostfolio/api/services/data-provider/financial-modeling-prep/financial-modeling-prep.service'; import { FinancialModelingPrepService } from '@ghostfolio/api/services/data-provider/financial-modeling-prep/financial-modeling-prep.service';
import { GhostfolioService } from '@ghostfolio/api/services/data-provider/ghostfolio/ghostfolio.service';
import { GoogleSheetsService } from '@ghostfolio/api/services/data-provider/google-sheets/google-sheets.service'; import { GoogleSheetsService } from '@ghostfolio/api/services/data-provider/google-sheets/google-sheets.service';
import { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.service'; import { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.service';
import { RapidApiService } from '@ghostfolio/api/services/data-provider/rapid-api/rapid-api.service'; import { RapidApiService } from '@ghostfolio/api/services/data-provider/rapid-api/rapid-api.service';
@ -37,6 +38,7 @@ import { DataProviderService } from './data-provider.service';
DataProviderService, DataProviderService,
EodHistoricalDataService, EodHistoricalDataService,
FinancialModelingPrepService, FinancialModelingPrepService,
GhostfolioService,
GoogleSheetsService, GoogleSheetsService,
ManualService, ManualService,
RapidApiService, RapidApiService,
@ -47,6 +49,7 @@ import { DataProviderService } from './data-provider.service';
CoinGeckoService, CoinGeckoService,
EodHistoricalDataService, EodHistoricalDataService,
FinancialModelingPrepService, FinancialModelingPrepService,
GhostfolioService,
GoogleSheetsService, GoogleSheetsService,
ManualService, ManualService,
RapidApiService, RapidApiService,
@ -58,6 +61,7 @@ import { DataProviderService } from './data-provider.service';
coinGeckoService, coinGeckoService,
eodHistoricalDataService, eodHistoricalDataService,
financialModelingPrepService, financialModelingPrepService,
ghostfolioService,
googleSheetsService, googleSheetsService,
manualService, manualService,
rapidApiService, rapidApiService,
@ -67,6 +71,7 @@ import { DataProviderService } from './data-provider.service';
coinGeckoService, coinGeckoService,
eodHistoricalDataService, eodHistoricalDataService,
financialModelingPrepService, financialModelingPrepService,
ghostfolioService,
googleSheetsService, googleSheetsService,
manualService, manualService,
rapidApiService, rapidApiService,

View File

@ -1,5 +1,4 @@
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface'; import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
import { import {
@ -12,6 +11,7 @@ import { PropertyService } from '@ghostfolio/api/services/property/property.serv
import { import {
DEFAULT_CURRENCY, DEFAULT_CURRENCY,
DERIVED_CURRENCIES, DERIVED_CURRENCIES,
PROPERTY_API_KEY_GHOSTFOLIO,
PROPERTY_DATA_SOURCE_MAPPING PROPERTY_DATA_SOURCE_MAPPING
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { import {
@ -20,7 +20,11 @@ import {
getStartOfUtcDate, getStartOfUtcDate,
isDerivedCurrency isDerivedCurrency
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces'; import {
AssetProfileIdentifier,
LookupItem,
LookupResponse
} from '@ghostfolio/common/interfaces';
import type { Granularity, UserWithSettings } from '@ghostfolio/common/types'; import type { Granularity, UserWithSettings } from '@ghostfolio/common/types';
import { Inject, Injectable, Logger } from '@nestjs/common'; import { Inject, Injectable, Logger } from '@nestjs/common';
@ -88,11 +92,11 @@ export class DataProviderService {
const promises = []; const promises = [];
for (const [dataSource, dataGatheringItems] of Object.entries( for (const [dataSource, assetProfileIdentifiers] of Object.entries(
itemsGroupedByDataSource itemsGroupedByDataSource
)) { )) {
const symbols = dataGatheringItems.map((dataGatheringItem) => { const symbols = assetProfileIdentifiers.map(({ symbol }) => {
return dataGatheringItem.symbol; return symbol;
}); });
for (const symbol of symbols) { for (const symbol of symbols) {
@ -150,6 +154,24 @@ export class DataProviderService {
return DataSource[this.configurationService.get('DATA_SOURCE_IMPORT')]; return DataSource[this.configurationService.get('DATA_SOURCE_IMPORT')];
} }
public async getDataSources(): Promise<DataSource[]> {
const dataSources: DataSource[] = this.configurationService
.get('DATA_SOURCES')
.map((dataSource) => {
return DataSource[dataSource];
});
const ghostfolioApiKey = (await this.propertyService.getByKey(
PROPERTY_API_KEY_GHOSTFOLIO
)) as string;
if (ghostfolioApiKey) {
dataSources.push('GHOSTFOLIO');
}
return dataSources.sort();
}
public async getDividends({ public async getDividends({
dataSource, dataSource,
from, from,
@ -239,11 +261,11 @@ export class DataProviderService {
} }
public async getHistoricalRaw({ public async getHistoricalRaw({
dataGatheringItems, assetProfileIdentifiers,
from, from,
to to
}: { }: {
dataGatheringItems: AssetProfileIdentifier[]; assetProfileIdentifiers: AssetProfileIdentifier[];
from: Date; from: Date;
to: Date; to: Date;
}): Promise<{ }): Promise<{
@ -252,25 +274,32 @@ export class DataProviderService {
for (const { currency, rootCurrency } of DERIVED_CURRENCIES) { for (const { currency, rootCurrency } of DERIVED_CURRENCIES) {
if ( if (
this.hasCurrency({ this.hasCurrency({
dataGatheringItems, assetProfileIdentifiers,
currency: `${DEFAULT_CURRENCY}${currency}` currency: `${DEFAULT_CURRENCY}${currency}`
}) })
) { ) {
// Skip derived currency // Skip derived currency
dataGatheringItems = dataGatheringItems.filter(({ symbol }) => { assetProfileIdentifiers = assetProfileIdentifiers.filter(
return symbol !== `${DEFAULT_CURRENCY}${currency}`; ({ symbol }) => {
}); return symbol !== `${DEFAULT_CURRENCY}${currency}`;
}
);
// Add root currency // Add root currency
dataGatheringItems.push({ assetProfileIdentifiers.push({
dataSource: this.getDataSourceForExchangeRates(), dataSource: this.getDataSourceForExchangeRates(),
symbol: `${DEFAULT_CURRENCY}${rootCurrency}` symbol: `${DEFAULT_CURRENCY}${rootCurrency}`
}); });
} }
} }
dataGatheringItems = uniqWith(dataGatheringItems, (obj1, obj2) => { assetProfileIdentifiers = uniqWith(
return obj1.dataSource === obj2.dataSource && obj1.symbol === obj2.symbol; assetProfileIdentifiers,
}); (obj1, obj2) => {
return (
obj1.dataSource === obj2.dataSource && obj1.symbol === obj2.symbol
);
}
);
const result: { const result: {
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse }; [symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
@ -280,7 +309,7 @@ export class DataProviderService {
data: { [date: string]: IDataProviderHistoricalResponse }; data: { [date: string]: IDataProviderHistoricalResponse };
symbol: string; symbol: string;
}>[] = []; }>[] = [];
for (const { dataSource, symbol } of dataGatheringItems) { for (const { dataSource, symbol } of assetProfileIdentifiers) {
const dataProvider = this.getDataProvider(dataSource); const dataProvider = this.getDataProvider(dataSource);
if (dataProvider.canHandle(symbol)) { if (dataProvider.canHandle(symbol)) {
if (symbol === `${DEFAULT_CURRENCY}USX`) { if (symbol === `${DEFAULT_CURRENCY}USX`) {
@ -415,7 +444,7 @@ export class DataProviderService {
const promises: Promise<any>[] = []; const promises: Promise<any>[] = [];
for (const [dataSource, dataGatheringItems] of Object.entries( for (const [dataSource, assetProfileIdentifiers] of Object.entries(
itemsGroupedByDataSource itemsGroupedByDataSource
)) { )) {
const dataProvider = this.getDataProvider(DataSource[dataSource]); const dataProvider = this.getDataProvider(DataSource[dataSource]);
@ -428,7 +457,7 @@ export class DataProviderService {
continue; continue;
} }
const symbols = dataGatheringItems const symbols = assetProfileIdentifiers
.filter(({ symbol }) => { .filter(({ symbol }) => {
return !isDerivedCurrency(getCurrencyFromSymbol(symbol)); return !isDerivedCurrency(getCurrencyFromSymbol(symbol));
}) })
@ -571,19 +600,19 @@ export class DataProviderService {
includeIndices?: boolean; includeIndices?: boolean;
query: string; query: string;
user: UserWithSettings; user: UserWithSettings;
}): Promise<{ items: LookupItem[] }> { }): Promise<LookupResponse> {
const promises: Promise<{ items: LookupItem[] }>[] = [];
let lookupItems: LookupItem[] = []; let lookupItems: LookupItem[] = [];
const promises: Promise<LookupResponse>[] = [];
if (query?.length < 2) { if (query?.length < 2) {
return { items: lookupItems }; return { items: lookupItems };
} }
const dataProviderServices = this.configurationService const dataSources = await this.getDataSources();
.get('DATA_SOURCES')
.map((dataSource) => { const dataProviderServices = dataSources.map((dataSource) => {
return this.getDataProvider(DataSource[dataSource]); return this.getDataProvider(DataSource[dataSource]);
}); });
for (const dataProviderService of dataProviderServices) { for (const dataProviderService of dataProviderServices) {
promises.push( promises.push(
@ -596,16 +625,16 @@ export class DataProviderService {
const searchResults = await Promise.all(promises); const searchResults = await Promise.all(promises);
searchResults.forEach(({ items }) => { for (const { items } of searchResults) {
if (items?.length > 0) { if (items?.length > 0) {
lookupItems = lookupItems.concat(items); lookupItems = lookupItems.concat(items);
} }
}); }
const filteredItems = lookupItems const filteredItems = lookupItems
.filter((lookupItem) => { .filter(({ currency }) => {
// Only allow symbols with supported currency // Only allow symbols with supported currency
return lookupItem.currency ? true : false; return currency ? true : false;
}) })
.sort(({ name: name1 }, { name: name2 }) => { .sort(({ name: name1 }, { name: name2 }) => {
return name1?.toLowerCase().localeCompare(name2?.toLowerCase()); return name1?.toLowerCase().localeCompare(name2?.toLowerCase());
@ -631,13 +660,13 @@ export class DataProviderService {
} }
private hasCurrency({ private hasCurrency({
currency, assetProfileIdentifiers,
dataGatheringItems currency
}: { }: {
assetProfileIdentifiers: AssetProfileIdentifier[];
currency: string; currency: string;
dataGatheringItems: AssetProfileIdentifier[];
}) { }) {
return dataGatheringItems.some(({ dataSource, symbol }) => { return assetProfileIdentifiers.some(({ dataSource, symbol }) => {
return ( return (
dataSource === this.getDataSourceForExchangeRates() && dataSource === this.getDataSourceForExchangeRates() &&
symbol === currency symbol === currency

View File

@ -1,4 +1,3 @@
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { import {
DataProviderInterface, DataProviderInterface,
@ -17,7 +16,11 @@ import {
REPLACE_NAME_PARTS REPLACE_NAME_PARTS
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { DATE_FORMAT, isCurrency } from '@ghostfolio/common/helper'; import { DATE_FORMAT, isCurrency } from '@ghostfolio/common/helper';
import { DataProviderInfo } from '@ghostfolio/common/interfaces'; import {
DataProviderInfo,
LookupItem,
LookupResponse
} from '@ghostfolio/common/interfaces';
import { MarketState } from '@ghostfolio/common/types'; import { MarketState } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
@ -317,9 +320,7 @@ export class EodHistoricalDataService implements DataProviderInterface {
return 'AAPL.US'; return 'AAPL.US';
} }
public async search({ public async search({ query }: GetSearchParams): Promise<LookupResponse> {
query
}: GetSearchParams): Promise<{ items: LookupItem[] }> {
const searchResult = await this.getSearchResult(query); const searchResult = await this.getSearchResult(query);
return { return {
@ -409,14 +410,12 @@ export class EodHistoricalDataService implements DataProviderInterface {
return name; return name;
} }
private async getSearchResult(aQuery: string): Promise< private async getSearchResult(aQuery: string) {
(LookupItem & { let searchResult: (LookupItem & {
assetClass: AssetClass; assetClass: AssetClass;
assetSubClass: AssetSubClass; assetSubClass: AssetSubClass;
isin: string; isin: string;
})[] })[] = [];
> {
let searchResult = [];
try { try {
const abortController = new AbortController(); const abortController = new AbortController();
@ -455,9 +454,9 @@ export class EodHistoricalDataService implements DataProviderInterface {
let message = error; let message = error;
if (error?.code === 'ABORT_ERR') { if (error?.code === 'ABORT_ERR') {
message = `RequestError: The operation to search for ${aQuery} was aborted because the request to the data provider took more than ${this.configurationService.get( message = `RequestError: The operation to search for ${aQuery} was aborted because the request to the data provider took more than ${(
'REQUEST_TIMEOUT' this.configurationService.get('REQUEST_TIMEOUT') / 1000
)}ms`; ).toFixed(3)} seconds`;
} }
Logger.error(message, 'EodHistoricalDataService'); Logger.error(message, 'EodHistoricalDataService');

View File

@ -1,4 +1,3 @@
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { import {
DataProviderInterface, DataProviderInterface,
@ -13,7 +12,11 @@ import {
} from '@ghostfolio/api/services/interfaces/interfaces'; } from '@ghostfolio/api/services/interfaces/interfaces';
import { DEFAULT_CURRENCY } from '@ghostfolio/common/config'; import { DEFAULT_CURRENCY } from '@ghostfolio/common/config';
import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper'; import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper';
import { DataProviderInfo } from '@ghostfolio/common/interfaces'; import {
DataProviderInfo,
LookupItem,
LookupResponse
} from '@ghostfolio/common/interfaces';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { DataSource, SymbolProfile } from '@prisma/client'; import { DataSource, SymbolProfile } from '@prisma/client';
@ -169,9 +172,7 @@ export class FinancialModelingPrepService implements DataProviderInterface {
return 'AAPL'; return 'AAPL';
} }
public async search({ public async search({ query }: GetSearchParams): Promise<LookupResponse> {
query
}: GetSearchParams): Promise<{ items: LookupItem[] }> {
let items: LookupItem[] = []; let items: LookupItem[] = [];
try { try {
@ -203,9 +204,9 @@ export class FinancialModelingPrepService implements DataProviderInterface {
let message = error; let message = error;
if (error?.code === 'ABORT_ERR') { if (error?.code === 'ABORT_ERR') {
message = `RequestError: The operation to search for ${query} was aborted because the request to the data provider took more than ${this.configurationService.get( message = `RequestError: The operation to search for ${query} was aborted because the request to the data provider took more than ${(
'REQUEST_TIMEOUT' this.configurationService.get('REQUEST_TIMEOUT') / 1000
)}ms`; ).toFixed(3)} seconds`;
} }
Logger.error(message, 'FinancialModelingPrepService'); Logger.error(message, 'FinancialModelingPrepService');

View File

@ -0,0 +1,284 @@
import { environment } from '@ghostfolio/api/environments/environment';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import {
DataProviderInterface,
GetDividendsParams,
GetHistoricalParams,
GetQuotesParams,
GetSearchParams
} from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
import {
IDataProviderHistoricalResponse,
IDataProviderResponse
} from '@ghostfolio/api/services/interfaces/interfaces';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import {
HEADER_KEY_TOKEN,
PROPERTY_API_KEY_GHOSTFOLIO
} from '@ghostfolio/common/config';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import {
DataProviderInfo,
DividendsResponse,
HistoricalResponse,
LookupResponse,
QuotesResponse
} from '@ghostfolio/common/interfaces';
import { Injectable, Logger } from '@nestjs/common';
import { DataSource, SymbolProfile } from '@prisma/client';
import { format } from 'date-fns';
import got from 'got';
import { StatusCodes } from 'http-status-codes';
@Injectable()
export class GhostfolioService implements DataProviderInterface {
private readonly URL = environment.production
? 'https://ghostfol.io/api'
: `${this.configurationService.get('ROOT_URL')}/api`;
public constructor(
private readonly configurationService: ConfigurationService,
private readonly propertyService: PropertyService
) {}
public canHandle() {
return true;
}
public async getAssetProfile({
symbol
}: {
symbol: string;
}): Promise<Partial<SymbolProfile>> {
const { items } = await this.search({ query: symbol });
const searchResult = items?.[0];
return {
symbol,
assetClass: searchResult?.assetClass,
assetSubClass: searchResult?.assetSubClass,
currency: searchResult?.currency,
dataSource: this.getName(),
name: searchResult?.name
};
}
public getDataProviderInfo(): DataProviderInfo {
return {
isPremium: true,
name: 'Ghostfolio',
url: 'https://ghostfo.io'
};
}
public async getDividends({
from,
granularity = 'day',
requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'),
symbol,
to
}: GetDividendsParams): Promise<{
[date: string]: IDataProviderHistoricalResponse;
}> {
let response: {
[date: string]: IDataProviderHistoricalResponse;
} = {};
try {
const abortController = new AbortController();
setTimeout(() => {
abortController.abort();
}, requestTimeout);
const { dividends } = await got(
`${this.URL}/v1/data-providers/ghostfolio/dividends/${symbol}?from=${format(from, DATE_FORMAT)}&granularity=${granularity}&to=${format(
to,
DATE_FORMAT
)}`,
{
headers: await this.getRequestHeaders(),
// @ts-ignore
signal: abortController.signal
}
).json<DividendsResponse>();
response = dividends;
} catch (error) {
let message = error;
if (error.response?.statusCode === StatusCodes.TOO_MANY_REQUESTS) {
message = 'RequestError: The daily request limit has been exceeded';
} else if (error.response?.statusCode === StatusCodes.UNAUTHORIZED) {
message =
'RequestError: The provided API key is invalid. Please update it in the Settings section of the Admin Control panel.';
}
Logger.error(message, 'GhostfolioService');
}
return response;
}
public async getHistorical({
from,
granularity = 'day',
requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'),
symbol,
to
}: GetHistoricalParams): Promise<{
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
}> {
try {
const abortController = new AbortController();
setTimeout(() => {
abortController.abort();
}, requestTimeout);
const { historicalData } = await got(
`${this.URL}/v1/data-providers/ghostfolio/historical/${symbol}?from=${format(from, DATE_FORMAT)}&granularity=${granularity}&to=${format(
to,
DATE_FORMAT
)}`,
{
headers: await this.getRequestHeaders(),
// @ts-ignore
signal: abortController.signal
}
).json<HistoricalResponse>();
return {
[symbol]: historicalData
};
} catch (error) {
let message = error;
if (error.response?.statusCode === StatusCodes.TOO_MANY_REQUESTS) {
message = 'RequestError: The daily request limit has been exceeded';
} else if (error.response?.statusCode === StatusCodes.UNAUTHORIZED) {
message =
'RequestError: The provided API key is invalid. Please update it in the Settings section of the Admin Control panel.';
}
Logger.error(message, 'GhostfolioService');
throw new Error(
`Could not get historical market data for ${symbol} (${this.getName()}) from ${format(
from,
DATE_FORMAT
)} to ${format(to, DATE_FORMAT)}: [${error.name}] ${error.message}`
);
}
}
public getMaxNumberOfSymbolsPerRequest() {
return 20;
}
public getName(): DataSource {
return DataSource.GHOSTFOLIO;
}
public async getQuotes({
requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'),
symbols
}: GetQuotesParams): Promise<{
[symbol: string]: IDataProviderResponse;
}> {
let response: { [symbol: string]: IDataProviderResponse } = {};
if (symbols.length <= 0) {
return response;
}
try {
const abortController = new AbortController();
setTimeout(() => {
abortController.abort();
}, requestTimeout);
const { quotes } = await got(
`${this.URL}/v1/data-providers/ghostfolio/quotes?symbols=${symbols.join(',')}`,
{
headers: await this.getRequestHeaders(),
// @ts-ignore
signal: abortController.signal
}
).json<QuotesResponse>();
response = quotes;
} catch (error) {
let message = error;
if (error?.code === 'ABORT_ERR') {
message = `RequestError: The operation to get the quotes was aborted because the request to the data provider took more than ${(
this.configurationService.get('REQUEST_TIMEOUT') / 1000
).toFixed(3)} seconds`;
} else if (error.response?.statusCode === StatusCodes.TOO_MANY_REQUESTS) {
message = 'RequestError: The daily request limit has been exceeded';
} else if (error.response?.statusCode === StatusCodes.UNAUTHORIZED) {
message =
'RequestError: The provided API key is invalid. Please update it in the Settings section of the Admin Control panel.';
}
Logger.error(message, 'GhostfolioService');
}
return response;
}
public getTestSymbol() {
return 'AAPL.US';
}
public async search({ query }: GetSearchParams): Promise<LookupResponse> {
let searchResult: LookupResponse = { items: [] };
try {
const abortController = new AbortController();
setTimeout(() => {
abortController.abort();
}, this.configurationService.get('REQUEST_TIMEOUT'));
searchResult = await got(
`${this.URL}/v1/data-providers/ghostfolio/lookup?query=${query}`,
{
headers: await this.getRequestHeaders(),
// @ts-ignore
signal: abortController.signal
}
).json<LookupResponse>();
} catch (error) {
let message = error;
if (error?.code === 'ABORT_ERR') {
message = `RequestError: The operation to search for ${query} was aborted because the request to the data provider took more than ${(
this.configurationService.get('REQUEST_TIMEOUT') / 1000
).toFixed(3)} seconds`;
} else if (error.response?.statusCode === StatusCodes.TOO_MANY_REQUESTS) {
message = 'RequestError: The daily request limit has been exceeded';
} else if (error.response?.statusCode === StatusCodes.UNAUTHORIZED) {
message =
'RequestError: The provided API key is invalid. Please update it in the Settings section of the Admin Control panel.';
}
Logger.error(message, 'GhostfolioService');
}
return searchResult;
}
private async getRequestHeaders() {
const apiKey = (await this.propertyService.getByKey(
PROPERTY_API_KEY_GHOSTFOLIO
)) as string;
return {
[HEADER_KEY_TOKEN]: `Bearer ${apiKey}`
};
}
}

View File

@ -1,4 +1,3 @@
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { import {
DataProviderInterface, DataProviderInterface,
@ -14,7 +13,10 @@ import {
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper'; import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper';
import { DataProviderInfo } from '@ghostfolio/common/interfaces'; import {
DataProviderInfo,
LookupResponse
} from '@ghostfolio/common/interfaces';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { DataSource, SymbolProfile } from '@prisma/client'; import { DataSource, SymbolProfile } from '@prisma/client';
@ -157,9 +159,7 @@ export class GoogleSheetsService implements DataProviderInterface {
return 'INDEXSP:.INX'; return 'INDEXSP:.INX';
} }
public async search({ public async search({ query }: GetSearchParams): Promise<LookupResponse> {
query
}: GetSearchParams): Promise<{ items: LookupItem[] }> {
const items = await this.prismaService.symbolProfile.findMany({ const items = await this.prismaService.symbolProfile.findMany({
select: { select: {
assetClass: true, assetClass: true,

View File

@ -1,9 +1,11 @@
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { import {
IDataProviderHistoricalResponse, IDataProviderHistoricalResponse,
IDataProviderResponse IDataProviderResponse
} from '@ghostfolio/api/services/interfaces/interfaces'; } from '@ghostfolio/api/services/interfaces/interfaces';
import { DataProviderInfo } from '@ghostfolio/common/interfaces'; import {
DataProviderInfo,
LookupResponse
} from '@ghostfolio/common/interfaces';
import { Granularity } from '@ghostfolio/common/types'; import { Granularity } from '@ghostfolio/common/types';
import { DataSource, SymbolProfile } from '@prisma/client'; import { DataSource, SymbolProfile } from '@prisma/client';
@ -19,7 +21,13 @@ export interface DataProviderInterface {
getDataProviderInfo(): DataProviderInfo; getDataProviderInfo(): DataProviderInfo;
getDividends({ from, granularity, symbol, to }: GetDividendsParams): Promise<{ getDividends({
from,
granularity,
requestTimeout,
symbol,
to
}: GetDividendsParams): Promise<{
[date: string]: IDataProviderHistoricalResponse; [date: string]: IDataProviderHistoricalResponse;
}>; }>;
@ -44,10 +52,7 @@ export interface DataProviderInterface {
getTestSymbol(): string; getTestSymbol(): string;
search({ search({ includeIndices, query }: GetSearchParams): Promise<LookupResponse>;
includeIndices,
query
}: GetSearchParams): Promise<{ items: LookupItem[] }>;
} }
export interface GetDividendsParams { export interface GetDividendsParams {

View File

@ -1,4 +1,3 @@
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { import {
DataProviderInterface, DataProviderInterface,
@ -20,6 +19,7 @@ import {
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
import { import {
DataProviderInfo, DataProviderInfo,
LookupResponse,
ScraperConfiguration ScraperConfiguration
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
@ -219,9 +219,7 @@ export class ManualService implements DataProviderInterface {
return undefined; return undefined;
} }
public async search({ public async search({ query }: GetSearchParams): Promise<LookupResponse> {
query
}: GetSearchParams): Promise<{ items: LookupItem[] }> {
let items = await this.prismaService.symbolProfile.findMany({ let items = await this.prismaService.symbolProfile.findMany({
select: { select: {
assetClass: true, assetClass: true,

View File

@ -1,4 +1,3 @@
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { import {
DataProviderInterface, DataProviderInterface,
@ -13,7 +12,10 @@ import {
} from '@ghostfolio/api/services/interfaces/interfaces'; } from '@ghostfolio/api/services/interfaces/interfaces';
import { ghostfolioFearAndGreedIndexSymbol } from '@ghostfolio/common/config'; import { ghostfolioFearAndGreedIndexSymbol } from '@ghostfolio/common/config';
import { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper'; import { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper';
import { DataProviderInfo } from '@ghostfolio/common/interfaces'; import {
DataProviderInfo,
LookupResponse
} from '@ghostfolio/common/interfaces';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { DataSource, SymbolProfile } from '@prisma/client'; import { DataSource, SymbolProfile } from '@prisma/client';
@ -121,7 +123,7 @@ export class RapidApiService implements DataProviderInterface {
return undefined; return undefined;
} }
public async search({}: GetSearchParams): Promise<{ items: LookupItem[] }> { public async search({}: GetSearchParams): Promise<LookupResponse> {
return { items: [] }; return { items: [] };
} }
@ -157,9 +159,9 @@ export class RapidApiService implements DataProviderInterface {
let message = error; let message = error;
if (error?.code === 'ABORT_ERR') { if (error?.code === 'ABORT_ERR') {
message = `RequestError: The operation was aborted because the request to the data provider took more than ${this.configurationService.get( message = `RequestError: The operation was aborted because the request to the data provider took more than ${(
'REQUEST_TIMEOUT' this.configurationService.get('REQUEST_TIMEOUT') / 1000
)}ms`; ).toFixed(3)} seconds`;
} }
Logger.error(message, 'RapidApiService'); Logger.error(message, 'RapidApiService');

View File

@ -1,4 +1,3 @@
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service'; import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service';
import { YahooFinanceDataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service'; import { YahooFinanceDataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service';
import { import {
@ -14,7 +13,11 @@ import {
} from '@ghostfolio/api/services/interfaces/interfaces'; } from '@ghostfolio/api/services/interfaces/interfaces';
import { DEFAULT_CURRENCY } from '@ghostfolio/common/config'; import { DEFAULT_CURRENCY } from '@ghostfolio/common/config';
import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { DataProviderInfo } from '@ghostfolio/common/interfaces'; import {
DataProviderInfo,
LookupItem,
LookupResponse
} from '@ghostfolio/common/interfaces';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { DataSource, SymbolProfile } from '@prisma/client'; import { DataSource, SymbolProfile } from '@prisma/client';
@ -224,7 +227,7 @@ export class YahooFinanceService implements DataProviderInterface {
public async search({ public async search({
includeIndices = false, includeIndices = false,
query query
}: GetSearchParams): Promise<{ items: LookupItem[] }> { }: GetSearchParams): Promise<LookupResponse> {
const items: LookupItem[] = []; const items: LookupItem[] = [];
try { try {

View File

@ -15,6 +15,7 @@ export interface Environment extends CleanedEnvAccessors {
DATA_SOURCE_EXCHANGE_RATES: string; DATA_SOURCE_EXCHANGE_RATES: string;
DATA_SOURCE_IMPORT: string; DATA_SOURCE_IMPORT: string;
DATA_SOURCES: string[]; DATA_SOURCES: string[];
DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER: string[];
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: boolean; ENABLE_FEATURE_FEAR_AND_GREED_INDEX: boolean;
ENABLE_FEATURE_READ_ONLY_MODE: boolean; ENABLE_FEATURE_READ_ONLY_MODE: boolean;
ENABLE_FEATURE_SOCIAL_LOGIN: boolean; ENABLE_FEATURE_SOCIAL_LOGIN: boolean;

View File

@ -1,9 +1,10 @@
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
@Module({ @Module({
providers: [PrismaService], exports: [PrismaService],
exports: [PrismaService] providers: [ConfigService, PrismaService]
}) })
export class PrismaModule {} export class PrismaModule {}

View File

@ -1,16 +1,38 @@
import { import {
Injectable, Injectable,
Logger, Logger,
LogLevel,
OnModuleDestroy, OnModuleDestroy,
OnModuleInit OnModuleInit
} from '@nestjs/common'; } from '@nestjs/common';
import { PrismaClient } from '@prisma/client'; import { ConfigService } from '@nestjs/config';
import { Prisma, PrismaClient } from '@prisma/client';
@Injectable() @Injectable()
export class PrismaService export class PrismaService
extends PrismaClient extends PrismaClient
implements OnModuleInit, OnModuleDestroy implements OnModuleInit, OnModuleDestroy
{ {
public constructor(configService: ConfigService) {
let customLogLevels: LogLevel[];
try {
customLogLevels = JSON.parse(
configService.get<string>('LOG_LEVELS')
) as LogLevel[];
} catch {}
const log: Prisma.LogDefinition[] =
customLogLevels?.includes('debug') || customLogLevels?.includes('verbose')
? [{ emit: 'stdout', level: 'query' }]
: [];
super({
log,
errorFormat: 'colorless'
});
}
public async onModuleInit() { public async onModuleInit() {
try { try {
await this.$connect(); await this.$connect();

View File

@ -89,7 +89,7 @@ export class DataGatheringProcessor {
); );
const historicalData = await this.dataProviderService.getHistoricalRaw({ const historicalData = await this.dataProviderService.getHistoricalRaw({
dataGatheringItems: [{ dataSource, symbol }], assetProfileIdentifiers: [{ dataSource, symbol }],
from: currentDate, from: currentDate,
to: new Date() to: new Date()
}); });

View File

@ -122,7 +122,7 @@ export class DataGatheringService {
}) { }) {
try { try {
const historicalData = await this.dataProviderService.getHistoricalRaw({ const historicalData = await this.dataProviderService.getHistoricalRaw({
dataGatheringItems: [{ dataSource, symbol }], assetProfileIdentifiers: [{ dataSource, symbol }],
from: date, from: date,
to: date to: date
}); });

View File

@ -32,6 +32,15 @@ const routes: Routes = [
loadChildren: () => loadChildren: () =>
import('./pages/admin/admin-page.module').then((m) => m.AdminPageModule) import('./pages/admin/admin-page.module').then((m) => m.AdminPageModule)
}, },
{
canActivate: [AuthGuard],
loadComponent: () =>
import('./pages/api/api-page.component').then(
(c) => c.GfApiPageComponent
),
path: 'api',
title: 'Ghostfolio API'
},
{ {
path: 'auth', path: 'auth',
loadChildren: () => loadChildren: () =>

View File

@ -33,6 +33,7 @@
[deviceType]="deviceType" [deviceType]="deviceType"
[hasPermissionToChangeDateRange]="hasPermissionToChangeDateRange" [hasPermissionToChangeDateRange]="hasPermissionToChangeDateRange"
[hasPermissionToChangeFilters]="hasPermissionToChangeFilters" [hasPermissionToChangeFilters]="hasPermissionToChangeFilters"
[hasPromotion]="hasPromotion"
[hasTabs]="hasTabs" [hasTabs]="hasTabs"
[info]="info" [info]="info"
[pageTitle]="pageTitle" [pageTitle]="pageTitle"

View File

@ -57,6 +57,7 @@ export class AppComponent implements OnDestroy, OnInit {
public hasPermissionToAccessFearAndGreedIndex: boolean; public hasPermissionToAccessFearAndGreedIndex: boolean;
public hasPermissionToChangeDateRange: boolean; public hasPermissionToChangeDateRange: boolean;
public hasPermissionToChangeFilters: boolean; public hasPermissionToChangeFilters: boolean;
public hasPromotion = false;
public hasTabs = false; public hasTabs = false;
public info: InfoItem; public info: InfoItem;
public pageTitle: string; public pageTitle: string;
@ -136,6 +137,10 @@ export class AppComponent implements OnDestroy, OnInit {
permissions.enableFearAndGreedIndex permissions.enableFearAndGreedIndex
); );
this.hasPromotion =
!!this.info?.subscriptionOffers?.default?.coupon ||
!!this.info?.subscriptionOffers?.default?.durationExtension;
this.impersonationStorageService this.impersonationStorageService
.onChangeHasImpersonation() .onChangeHasImpersonation()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
@ -231,6 +236,14 @@ export class AppComponent implements OnDestroy, OnInit {
this.hasInfoMessage = this.hasInfoMessage =
this.canCreateAccount || !!this.user?.systemMessage; this.canCreateAccount || !!this.user?.systemMessage;
this.hasPromotion =
!!this.info?.subscriptionOffers?.[
this.user?.subscription?.offer ?? 'default'
]?.coupon ||
!!this.info?.subscriptionOffers?.[
this.user?.subscription?.offer ?? 'default'
]?.durationExtension;
this.initializeTheme(this.user?.settings.colorScheme); this.initializeTheme(this.user?.settings.colorScheme);
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();

View File

@ -1,15 +0,0 @@
import { GfLineChartComponent } from '@ghostfolio/ui/line-chart';
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { AdminMarketDataDetailComponent } from './admin-market-data-detail.component';
import { GfMarketDataDetailDialogModule } from './market-data-detail-dialog/market-data-detail-dialog.module';
@NgModule({
declarations: [AdminMarketDataDetailComponent],
exports: [AdminMarketDataDetailComponent],
imports: [CommonModule, GfLineChartComponent, GfMarketDataDetailDialogModule],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class GfAdminMarketDataDetailModule {}

View File

@ -1,26 +0,0 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatDatepickerModule } from '@angular/material/datepicker';
import { MatDialogModule } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MarketDataDetailDialog } from './market-data-detail-dialog.component';
@NgModule({
declarations: [MarketDataDetailDialog],
imports: [
CommonModule,
FormsModule,
MatButtonModule,
MatDatepickerModule,
MatDialogModule,
MatFormFieldModule,
MatInputModule,
ReactiveFormsModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class GfMarketDataDetailDialogModule {}

View File

@ -3,5 +3,9 @@
.mat-mdc-dialog-content { .mat-mdc-dialog-content {
max-height: unset; max-height: unset;
gf-line-chart {
aspect-ratio: 16/9;
}
} }
} }

View File

@ -1,15 +1,17 @@
import { UpdateAssetProfileDto } from '@ghostfolio/api/app/admin/update-asset-profile.dto'; import { UpdateAssetProfileDto } from '@ghostfolio/api/app/admin/update-asset-profile.dto';
import { UpdateMarketDataDto } from '@ghostfolio/api/app/admin/update-market-data.dto';
import { AdminMarketDataService } from '@ghostfolio/client/components/admin-market-data/admin-market-data.service'; import { AdminMarketDataService } from '@ghostfolio/client/components/admin-market-data/admin-market-data.service';
import { NotificationService } from '@ghostfolio/client/core/notification/notification.service'; import { NotificationService } from '@ghostfolio/client/core/notification/notification.service';
import { AdminService } from '@ghostfolio/client/services/admin.service'; import { AdminService } from '@ghostfolio/client/services/admin.service';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { validateObjectForForm } from '@ghostfolio/client/util/form.util'; import { validateObjectForForm } from '@ghostfolio/client/util/form.util';
import { ghostfolioScraperApiSymbolPrefix } from '@ghostfolio/common/config'; import { ghostfolioScraperApiSymbolPrefix } from '@ghostfolio/common/config';
import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { import {
AdminMarketDataDetails, AdminMarketDataDetails,
AssetProfileIdentifier AssetProfileIdentifier,
LineChartItem,
User
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { translate } from '@ghostfolio/ui/i18n'; import { translate } from '@ghostfolio/ui/i18n';
@ -23,7 +25,6 @@ import {
} from '@angular/core'; } from '@angular/core';
import { FormBuilder, FormControl, Validators } from '@angular/forms'; import { FormBuilder, FormControl, Validators } from '@angular/forms';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar';
import { import {
AssetClass, AssetClass,
AssetSubClass, AssetSubClass,
@ -31,7 +32,6 @@ import {
SymbolProfile SymbolProfile
} from '@prisma/client'; } from '@prisma/client';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { parse as csvToJson } from 'papaparse';
import { EMPTY, Subject } from 'rxjs'; import { EMPTY, Subject } from 'rxjs';
import { catchError, takeUntil } from 'rxjs/operators'; import { catchError, takeUntil } from 'rxjs/operators';
@ -75,11 +75,13 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
}; };
public currencies: string[] = []; public currencies: string[] = [];
public ghostfolioScraperApiSymbolPrefix = ghostfolioScraperApiSymbolPrefix; public ghostfolioScraperApiSymbolPrefix = ghostfolioScraperApiSymbolPrefix;
public historicalDataItems: LineChartItem[];
public isBenchmark = false; public isBenchmark = false;
public marketDataDetails: MarketData[] = []; public marketDataItems: MarketData[] = [];
public sectors: { public sectors: {
[name: string]: { name: string; value: number }; [name: string]: { name: string; value: number };
}; };
public user: User;
private static readonly HISTORICAL_DATA_TEMPLATE = `date;marketPrice\n${format( private static readonly HISTORICAL_DATA_TEMPLATE = `date;marketPrice\n${format(
new Date(), new Date(),
@ -96,7 +98,7 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
public dialogRef: MatDialogRef<AssetProfileDialog>, public dialogRef: MatDialogRef<AssetProfileDialog>,
private formBuilder: FormBuilder, private formBuilder: FormBuilder,
private notificationService: NotificationService, private notificationService: NotificationService,
private snackBar: MatSnackBar private userService: UserService
) {} ) {}
public ngOnInit() { public ngOnInit() {
@ -109,6 +111,16 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
} }
public initialize() { public initialize() {
this.historicalDataItems = undefined;
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((state) => {
if (state?.user) {
this.user = state.user;
}
});
this.adminService this.adminService
.fetchAdminMarketDataBySymbol({ .fetchAdminMarketDataBySymbol({
dataSource: this.data.dataSource, dataSource: this.data.dataSource,
@ -121,10 +133,19 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
this.assetProfileClass = translate(this.assetProfile?.assetClass); this.assetProfileClass = translate(this.assetProfile?.assetClass);
this.assetProfileSubClass = translate(this.assetProfile?.assetSubClass); this.assetProfileSubClass = translate(this.assetProfile?.assetSubClass);
this.countries = {}; this.countries = {};
this.isBenchmark = this.benchmarks.some(({ id }) => { this.isBenchmark = this.benchmarks.some(({ id }) => {
return id === this.assetProfile.id; return id === this.assetProfile.id;
}); });
this.marketDataDetails = marketData;
this.historicalDataItems = marketData.map(({ date, marketPrice }) => {
return {
date: format(date, DATE_FORMAT),
value: marketPrice
};
});
this.marketDataItems = marketData;
this.sectors = {}; this.sectors = {};
if (this.assetProfile?.countries?.length > 0) { if (this.assetProfile?.countries?.length > 0) {
@ -200,47 +221,6 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
.subscribe(); .subscribe();
} }
public onImportHistoricalData() {
try {
const marketData = csvToJson(
this.assetProfileForm.controls['historicalData'].controls['csvString']
.value,
{
dynamicTyping: true,
header: true,
skipEmptyLines: true
}
).data as UpdateMarketDataDto[];
this.adminService
.postMarketData({
dataSource: this.data.dataSource,
marketData: {
marketData
},
symbol: this.data.symbol
})
.pipe(
catchError(({ error, message }) => {
this.snackBar.open(`${error}: ${message[0]}`, undefined, {
duration: 3000
});
return EMPTY;
}),
takeUntil(this.unsubscribeSubject)
)
.subscribe(() => {
this.initialize();
});
} catch {
this.snackBar.open(
$localize`Oops! Could not parse historical data.`,
undefined,
{ duration: 3000 }
);
}
}
public onMarketDataChanged(withRefresh: boolean = false) { public onMarketDataChanged(withRefresh: boolean = false) {
if (withRefresh) { if (withRefresh) {
this.initialize(); this.initialize();

View File

@ -68,50 +68,28 @@
</div> </div>
<div class="flex-grow-1" mat-dialog-content> <div class="flex-grow-1" mat-dialog-content>
<gf-admin-market-data-detail <gf-line-chart
class="mb-4"
[colorScheme]="user?.settings?.colorScheme"
[historicalDataItems]="historicalDataItems"
[isAnimated]="true"
[locale]="data.locale"
[showXAxis]="true"
[showYAxis]="true"
[symbol]="data.symbol"
/>
<gf-historical-market-data-editor
class="mb-3" class="mb-3"
[currency]="assetProfile?.currency" [currency]="assetProfile?.currency"
[dataSource]="data.dataSource" [dataSource]="data.dataSource"
[dateOfFirstActivity]="assetProfile?.dateOfFirstActivity" [dateOfFirstActivity]="assetProfile?.dateOfFirstActivity"
[locale]="data.locale" [locale]="data.locale"
[marketData]="marketDataDetails" [marketData]="marketDataItems"
[symbol]="data.symbol" [symbol]="data.symbol"
[user]="user"
(marketDataChanged)="onMarketDataChanged($event)" (marketDataChanged)="onMarketDataChanged($event)"
/> />
<div class="mt-3" formGroupName="historicalData">
<mat-form-field appearance="outline" class="w-100 without-hint">
<mat-label>
<ng-container i18n>Historical Data</ng-container> (CSV)
</mat-label>
<textarea
cdkAutosizeMaxRows="5"
cdkTextareaAutosize
formControlName="csvString"
matInput
type="text"
(keyup.enter)="$event.stopPropagation()"
></textarea>
</mat-form-field>
</div>
<div class="d-flex justify-content-end mt-2">
<button
color="accent"
mat-flat-button
type="button"
[disabled]="
!assetProfileForm.controls['historicalData']?.controls['csvString']
.touched ||
assetProfileForm.controls['historicalData']?.controls['csvString']
?.value === ''
"
(click)="onImportHistoricalData()"
>
<ng-container i18n>Import</ng-container>
</button>
</div>
<div class="row"> <div class="row">
<div class="col-6 mb-3"> <div class="col-6 mb-3">
<gf-value i18n size="medium" [value]="assetProfile?.symbol" <gf-value i18n size="medium" [value]="assetProfile?.symbol"

View File

@ -1,7 +1,8 @@
import { GfAdminMarketDataDetailModule } from '@ghostfolio/client/components/admin-market-data-detail/admin-market-data-detail.module';
import { AdminMarketDataService } from '@ghostfolio/client/components/admin-market-data/admin-market-data.service'; import { AdminMarketDataService } from '@ghostfolio/client/components/admin-market-data/admin-market-data.service';
import { GfAssetProfileIconComponent } from '@ghostfolio/client/components/asset-profile-icon/asset-profile-icon.component'; import { GfAssetProfileIconComponent } from '@ghostfolio/client/components/asset-profile-icon/asset-profile-icon.component';
import { GfCurrencySelectorComponent } from '@ghostfolio/ui/currency-selector'; import { GfCurrencySelectorComponent } from '@ghostfolio/ui/currency-selector';
import { GfHistoricalMarketDataEditorComponent } from '@ghostfolio/ui/historical-market-data-editor';
import { GfLineChartComponent } from '@ghostfolio/ui/line-chart';
import { GfPortfolioProportionChartComponent } from '@ghostfolio/ui/portfolio-proportion-chart'; import { GfPortfolioProportionChartComponent } from '@ghostfolio/ui/portfolio-proportion-chart';
import { GfValueComponent } from '@ghostfolio/ui/value'; import { GfValueComponent } from '@ghostfolio/ui/value';
@ -24,9 +25,10 @@ import { AssetProfileDialog } from './asset-profile-dialog.component';
imports: [ imports: [
CommonModule, CommonModule,
FormsModule, FormsModule,
GfAdminMarketDataDetailModule,
GfAssetProfileIconComponent, GfAssetProfileIconComponent,
GfCurrencySelectorComponent, GfCurrencySelectorComponent,
GfHistoricalMarketDataEditorComponent,
GfLineChartComponent,
GfPortfolioProportionChartComponent, GfPortfolioProportionChartComponent,
GfValueComponent, GfValueComponent,
MatButtonModule, MatButtonModule,

View File

@ -11,23 +11,63 @@
target="_blank" target="_blank"
[href]="pricingUrl" [href]="pricingUrl"
> >
<span class="badge badge-warning mr-1" i18n>NEW</span> @if (isGhostfolioApiKeyValid === false) {
<span class="badge badge-warning mr-1" i18n>NEW</span>
}
Ghostfolio Premium Ghostfolio Premium
<gf-premium-indicator <gf-premium-indicator
class="d-inline-block ml-1" class="d-inline-block ml-1"
[enableLink]="false" [enableLink]="false"
/> />
</a> </a>
@if (isGhostfolioApiKeyValid === true) {
<div class="line-height-1">
<small class="text-muted">
<ng-container i18n>Valid until</ng-container>
{{
ghostfolioApiStatus?.subscription?.expiresAt
| date: defaultDateFormat
}}</small
>
</div>
}
</div> </div>
<div class="w-50"> <div class="w-50">
<button @if (isGhostfolioApiKeyValid === true) {
color="accent" <div class="align-items-center d-flex flex-wrap">
mat-flat-button <div class="flex-grow-1 mr-3">
(click)="onSetGhostfolioApiKey()" {{ ghostfolioApiStatus.dailyRequests }}
> <ng-container i18n>of</ng-container>
<ion-icon class="mr-1" name="key-outline" /> {{ ghostfolioApiStatus.dailyRequestsMax }}
<span i18n>Set API Key</span> <ng-container i18n>daily requests</ng-container>
</button> </div>
<button
class="mx-1 no-min-width px-2"
mat-button
[matMenuTriggerFor]="ghostfolioApiMenu"
(click)="$event.stopPropagation()"
>
<ion-icon name="ellipsis-horizontal" />
</button>
<mat-menu #ghostfolioApiMenu="matMenu" xPosition="before">
<button mat-menu-item (click)="onRemoveGhostfolioApiKey()">
<span class="align-items-center d-flex">
<ion-icon class="mr-2" name="trash-outline" />
<span i18n>Remove API key</span>
</span>
</button>
</mat-menu>
</div>
} @else if (isGhostfolioApiKeyValid === false) {
<button
color="accent"
mat-flat-button
(click)="onSetGhostfolioApiKey()"
>
<ion-icon class="mr-1" name="key-outline" />
<span i18n>Set API key</span>
</button>
}
</div> </div>
</div> </div>
</mat-card-content> </mat-card-content>

View File

@ -1,5 +1,17 @@
import { ConfirmationDialogType } from '@ghostfolio/client/core/notification/confirmation-dialog/confirmation-dialog.type';
import { NotificationService } from '@ghostfolio/client/core/notification/notification.service';
import { AdminService } from '@ghostfolio/client/services/admin.service';
import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { User } from '@ghostfolio/common/interfaces'; import {
DEFAULT_LANGUAGE_CODE,
PROPERTY_API_KEY_GHOSTFOLIO
} from '@ghostfolio/common/config';
import { getDateFormatString } from '@ghostfolio/common/helper';
import {
DataProviderGhostfolioStatusResponse,
User
} from '@ghostfolio/common/interfaces';
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
@ -10,7 +22,7 @@ import {
} from '@angular/core'; } from '@angular/core';
import { MatDialog } from '@angular/material/dialog'; import { MatDialog } from '@angular/material/dialog';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject, takeUntil } from 'rxjs'; import { catchError, filter, of, Subject, takeUntil } from 'rxjs';
import { GfGhostfolioPremiumApiDialogComponent } from './ghostfolio-premium-api-dialog/ghostfolio-premium-api-dialog.component'; import { GfGhostfolioPremiumApiDialogComponent } from './ghostfolio-premium-api-dialog/ghostfolio-premium-api-dialog.component';
@ -21,6 +33,9 @@ import { GfGhostfolioPremiumApiDialogComponent } from './ghostfolio-premium-api-
templateUrl: './admin-settings.component.html' templateUrl: './admin-settings.component.html'
}) })
export class AdminSettingsComponent implements OnDestroy, OnInit { export class AdminSettingsComponent implements OnDestroy, OnInit {
public defaultDateFormat: string;
public ghostfolioApiStatus: DataProviderGhostfolioStatusResponse;
public isGhostfolioApiKeyValid: boolean;
public pricingUrl: string; public pricingUrl: string;
private deviceType: string; private deviceType: string;
@ -28,9 +43,12 @@ export class AdminSettingsComponent implements OnDestroy, OnInit {
private user: User; private user: User;
public constructor( public constructor(
private adminService: AdminService,
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private deviceService: DeviceDetectorService, private deviceService: DeviceDetectorService,
private matDialog: MatDialog, private matDialog: MatDialog,
private notificationService: NotificationService,
private userService: UserService private userService: UserService
) {} ) {}
@ -43,29 +61,86 @@ export class AdminSettingsComponent implements OnDestroy, OnInit {
if (state?.user) { if (state?.user) {
this.user = state.user; this.user = state.user;
this.defaultDateFormat = getDateFormatString(
this.user?.settings?.locale
);
const languageCode =
this.user?.settings?.language ?? DEFAULT_LANGUAGE_CODE;
this.pricingUrl = this.pricingUrl =
`https://ghostfol.io/${this.user.settings.language}/` + `https://ghostfol.io/${languageCode}/` +
$localize`:snake-case:pricing`; $localize`:snake-case:pricing`;
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
} }
}); });
this.initialize();
}
public onRemoveGhostfolioApiKey() {
this.notificationService.confirm({
confirmFn: () => {
this.dataService
.putAdminSetting(PROPERTY_API_KEY_GHOSTFOLIO, { value: undefined })
.subscribe(() => {
this.initialize();
});
},
confirmType: ConfirmationDialogType.Warn,
title: $localize`Do you really want to delete the API key?`
});
} }
public onSetGhostfolioApiKey() { public onSetGhostfolioApiKey() {
this.matDialog.open(GfGhostfolioPremiumApiDialogComponent, { const dialogRef = this.matDialog.open(
autoFocus: false, GfGhostfolioPremiumApiDialogComponent,
data: { {
deviceType: this.deviceType, autoFocus: false,
pricingUrl: this.pricingUrl data: {
}, deviceType: this.deviceType,
height: this.deviceType === 'mobile' ? '98vh' : undefined, pricingUrl: this.pricingUrl
width: this.deviceType === 'mobile' ? '100vw' : '50rem' },
}); height: this.deviceType === 'mobile' ? '98vh' : undefined,
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
}
);
dialogRef
.afterClosed()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.initialize();
});
} }
public ngOnDestroy() { public ngOnDestroy() {
this.unsubscribeSubject.next(); this.unsubscribeSubject.next();
this.unsubscribeSubject.complete(); this.unsubscribeSubject.complete();
} }
private initialize() {
this.adminService
.fetchGhostfolioDataProviderStatus()
.pipe(
catchError(() => {
this.isGhostfolioApiKeyValid = false;
this.changeDetectorRef.markForCheck();
return of(null);
}),
filter((status) => {
return status !== null;
}),
takeUntil(this.unsubscribeSubject)
)
.subscribe((status) => {
this.ghostfolioApiStatus = status;
this.isGhostfolioApiKeyValid = true;
this.changeDetectorRef.markForCheck();
});
}
} }

View File

@ -6,6 +6,7 @@ import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card'; import { MatCardModule } from '@angular/material/card';
import { MatMenuModule } from '@angular/material/menu';
import { RouterModule } from '@angular/router'; import { RouterModule } from '@angular/router';
import { AdminSettingsComponent } from './admin-settings.component'; import { AdminSettingsComponent } from './admin-settings.component';
@ -19,6 +20,7 @@ import { AdminSettingsComponent } from './admin-settings.component';
GfPremiumIndicatorComponent, GfPremiumIndicatorComponent,
MatButtonModule, MatButtonModule,
MatCardModule, MatCardModule,
MatMenuModule,
RouterModule RouterModule
], ],
schemas: [CUSTOM_ELEMENTS_SCHEMA] schemas: [CUSTOM_ELEMENTS_SCHEMA]

View File

@ -1,3 +1,5 @@
import { DataService } from '@ghostfolio/client/services/data.service';
import { PROPERTY_API_KEY_GHOSTFOLIO } from '@ghostfolio/common/config';
import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator'; import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
@ -30,10 +32,28 @@ import { GhostfolioPremiumApiDialogParams } from './interfaces/interfaces';
export class GfGhostfolioPremiumApiDialogComponent { export class GfGhostfolioPremiumApiDialogComponent {
public constructor( public constructor(
@Inject(MAT_DIALOG_DATA) public data: GhostfolioPremiumApiDialogParams, @Inject(MAT_DIALOG_DATA) public data: GhostfolioPremiumApiDialogParams,
private dataService: DataService,
public dialogRef: MatDialogRef<GfGhostfolioPremiumApiDialogComponent> public dialogRef: MatDialogRef<GfGhostfolioPremiumApiDialogComponent>
) {} ) {}
public onCancel() { public onCancel() {
this.dialogRef.close(); this.dialogRef.close();
} }
public onSetGhostfolioApiKey() {
let ghostfolioApiKey = prompt(
$localize`Please enter your Ghostfolio API key:`
);
ghostfolioApiKey = ghostfolioApiKey?.trim();
if (ghostfolioApiKey) {
this.dataService
.putAdminSetting(PROPERTY_API_KEY_GHOSTFOLIO, {
value: ghostfolioApiKey
})
.subscribe(() => {
this.dialogRef.close();
});
}
}
} }

View File

@ -29,9 +29,19 @@
href="mailto:hi@ghostfol.io?Subject=Ghostfolio Premium Data Provider&body=Hello%0D%0DPlease notify me as soon as the Ghostfolio Premium Data Provider is available.%0D%0DKind regards" href="mailto:hi@ghostfol.io?Subject=Ghostfolio Premium Data Provider&body=Hello%0D%0DPlease notify me as soon as the Ghostfolio Premium Data Provider is available.%0D%0DKind regards"
i18n i18n
mat-flat-button mat-flat-button
>Notify me</a
> >
Notify me <div>
</a> <small class="text-muted" i18n>or</small>
</div>
<button
color="accent"
i18n
mat-stroked-button
(click)="onSetGhostfolioApiKey()"
>
I have an API key
</button>
</div> </div>
</div> </div>

View File

@ -4,11 +4,19 @@ import { AdminService } from '@ghostfolio/client/services/admin.service';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { DEFAULT_PAGE_SIZE } from '@ghostfolio/common/config';
import { getDateFormatString, getEmojiFlag } from '@ghostfolio/common/helper'; import { getDateFormatString, getEmojiFlag } from '@ghostfolio/common/helper';
import { AdminUsers, InfoItem, User } from '@ghostfolio/common/interfaces'; import { AdminUsers, InfoItem, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; import {
ChangeDetectorRef,
Component,
OnDestroy,
OnInit,
ViewChild
} from '@angular/core';
import { MatPaginator, PageEvent } from '@angular/material/paginator';
import { MatTableDataSource } from '@angular/material/table'; import { MatTableDataSource } from '@angular/material/table';
import { import {
differenceInSeconds, differenceInSeconds,
@ -24,6 +32,8 @@ import { takeUntil } from 'rxjs/operators';
templateUrl: './admin-users.html' templateUrl: './admin-users.html'
}) })
export class AdminUsersComponent implements OnDestroy, OnInit { export class AdminUsersComponent implements OnDestroy, OnInit {
@ViewChild(MatPaginator) paginator: MatPaginator;
public dataSource = new MatTableDataSource<AdminUsers['users'][0]>(); public dataSource = new MatTableDataSource<AdminUsers['users'][0]>();
public defaultDateFormat: string; public defaultDateFormat: string;
public displayedColumns: string[] = []; public displayedColumns: string[] = [];
@ -32,6 +42,8 @@ export class AdminUsersComponent implements OnDestroy, OnInit {
public hasPermissionToImpersonateAllUsers: boolean; public hasPermissionToImpersonateAllUsers: boolean;
public info: InfoItem; public info: InfoItem;
public isLoading = false; public isLoading = false;
public pageSize = DEFAULT_PAGE_SIZE;
public totalItems = 0;
public user: User; public user: User;
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
@ -60,6 +72,7 @@ export class AdminUsersComponent implements OnDestroy, OnInit {
'accounts', 'accounts',
'activities', 'activities',
'engagementPerDay', 'engagementPerDay',
'dailyApiRequests',
'lastRequest', 'lastRequest',
'actions' 'actions'
]; ];
@ -136,19 +149,33 @@ export class AdminUsersComponent implements OnDestroy, OnInit {
window.location.reload(); window.location.reload();
} }
public onChangePage(page: PageEvent) {
this.fetchUsers({
pageIndex: page.pageIndex
});
}
public ngOnDestroy() { public ngOnDestroy() {
this.unsubscribeSubject.next(); this.unsubscribeSubject.next();
this.unsubscribeSubject.complete(); this.unsubscribeSubject.complete();
} }
private fetchUsers() { private fetchUsers({ pageIndex }: { pageIndex: number } = { pageIndex: 0 }) {
this.isLoading = true; this.isLoading = true;
if (pageIndex === 0 && this.paginator) {
this.paginator.pageIndex = 0;
}
this.adminService this.adminService
.fetchUsers() .fetchUsers({
skip: pageIndex * this.pageSize,
take: this.pageSize
})
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ users }) => { .subscribe(({ count, users }) => {
this.dataSource = new MatTableDataSource(users); this.dataSource = new MatTableDataSource(users);
this.totalItems = count;
this.isLoading = false; this.isLoading = false;

View File

@ -169,6 +169,27 @@
/> />
</td> </td>
</ng-container> </ng-container>
<ng-container matColumnDef="dailyApiRequests">
<th
*matHeaderCellDef
class="mat-mdc-header-cell px-1 py-2 text-right"
mat-header-cell
>
<ng-container i18n>API Requests Today</ng-container>
</th>
<td
*matCellDef="let element"
class="mat-mdc-cell px-1 py-2 text-right"
mat-cell
>
<gf-value
class="d-inline-block justify-content-end"
[locale]="user?.settings?.locale"
[precision]="0"
[value]="element.dailyApiRequests"
/>
</td>
</ng-container>
} }
@if (hasPermissionForSubscription) { @if (hasPermissionForSubscription) {
@ -246,6 +267,17 @@
></tr> ></tr>
</table> </table>
</div> </div>
<mat-paginator
[length]="totalItems"
[ngClass]="{
'd-none': (isLoading && totalItems === 0) || totalItems <= pageSize
}"
[pageSize]="pageSize"
[showFirstLastButtons]="true"
(page)="onChangePage($event)"
/>
@if (isLoading) { @if (isLoading) {
<ngx-skeleton-loader <ngx-skeleton-loader
animation="pulse" animation="pulse"

View File

@ -5,6 +5,7 @@ import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatMenuModule } from '@angular/material/menu'; import { MatMenuModule } from '@angular/material/menu';
import { MatPaginatorModule } from '@angular/material/paginator';
import { MatTableModule } from '@angular/material/table'; import { MatTableModule } from '@angular/material/table';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
@ -19,6 +20,7 @@ import { AdminUsersComponent } from './admin-users.component';
GfValueComponent, GfValueComponent,
MatButtonModule, MatButtonModule,
MatMenuModule, MatMenuModule,
MatPaginatorModule,
MatTableModule, MatTableModule,
NgxSkeletonLoaderModule NgxSkeletonLoaderModule
], ],

View File

@ -88,15 +88,20 @@
<li class="list-inline-item"> <li class="list-inline-item">
<a <a
class="d-none d-sm-block" class="d-none d-sm-block"
i18n
mat-flat-button mat-flat-button
[ngClass]="{ [ngClass]="{
'font-weight-bold': currentRoute === routePricing, 'font-weight-bold': currentRoute === routePricing,
'text-decoration-underline': currentRoute === routePricing 'text-decoration-underline': currentRoute === routePricing
}" }"
[routerLink]="routerLinkPricing" [routerLink]="routerLinkPricing"
>Pricing</a
> >
<span class="align-items-center d-flex">
<span i18n>Pricing</span>
@if (currentRoute !== routePricing && hasPromotion) {
<span class="badge badge-warning ml-1">%</span>
}
</span>
</a>
</li> </li>
} }
<li class="list-inline-item"> <li class="list-inline-item">
@ -290,12 +295,17 @@
) { ) {
<a <a
class="d-flex d-sm-none" class="d-flex d-sm-none"
i18n
mat-menu-item mat-menu-item
[ngClass]="{ 'font-weight-bold': currentRoute === routePricing }" [ngClass]="{ 'font-weight-bold': currentRoute === routePricing }"
[routerLink]="routerLinkPricing" [routerLink]="routerLinkPricing"
>Pricing</a
> >
<span class="align-items-center d-flex">
<span i18n>Pricing</span>
@if (currentRoute !== routePricing && hasPromotion) {
<span class="badge badge-warning ml-1">%</span>
}
</span>
</a>
} }
<a <a
class="d-flex d-sm-none" class="d-flex d-sm-none"
@ -358,15 +368,20 @@
<li class="list-inline-item"> <li class="list-inline-item">
<a <a
class="d-sm-block" class="d-sm-block"
i18n
mat-flat-button mat-flat-button
[ngClass]="{ [ngClass]="{
'font-weight-bold': currentRoute === routePricing, 'font-weight-bold': currentRoute === routePricing,
'text-decoration-underline': currentRoute === routePricing 'text-decoration-underline': currentRoute === routePricing
}" }"
[routerLink]="routerLinkPricing" [routerLink]="routerLinkPricing"
>Pricing</a
> >
<span class="align-items-center d-flex">
<span i18n>Pricing</span>
@if (currentRoute !== routePricing && hasPromotion) {
<span class="badge badge-warning ml-1">%</span>
}
</span>
</a>
</li> </li>
} }
@if (hasPermissionToAccessFearAndGreedIndex) { @if (hasPermissionToAccessFearAndGreedIndex) {

View File

@ -58,6 +58,7 @@ export class HeaderComponent implements OnChanges {
@Input() deviceType: string; @Input() deviceType: string;
@Input() hasPermissionToChangeDateRange: boolean; @Input() hasPermissionToChangeDateRange: boolean;
@Input() hasPermissionToChangeFilters: boolean; @Input() hasPermissionToChangeFilters: boolean;
@Input() hasPromotion: boolean;
@Input() hasTabs: boolean; @Input() hasTabs: boolean;
@Input() info: InfoItem; @Input() info: InfoItem;
@Input() pageTitle: string; @Input() pageTitle: string;
@ -174,17 +175,17 @@ export class HeaderComponent implements OnChanges {
const userSetting: UpdateUserSettingDto = {}; const userSetting: UpdateUserSettingDto = {};
for (const filter of filters) { for (const filter of filters) {
let filtersType: string;
if (filter.type === 'ACCOUNT') { if (filter.type === 'ACCOUNT') {
filtersType = 'accounts'; userSetting['filters.accounts'] = filter.id ? [filter.id] : null;
} else if (filter.type === 'ASSET_CLASS') { } else if (filter.type === 'ASSET_CLASS') {
filtersType = 'assetClasses'; userSetting['filters.assetClasses'] = filter.id ? [filter.id] : null;
} else if (filter.type === 'DATA_SOURCE') {
userSetting['filters.dataSource'] = filter.id ? filter.id : null;
} else if (filter.type === 'SYMBOL') {
userSetting['filters.symbol'] = filter.id ? filter.id : null;
} else if (filter.type === 'TAG') { } else if (filter.type === 'TAG') {
filtersType = 'tags'; userSetting['filters.tags'] = filter.id ? [filter.id] : null;
} }
userSetting[`filters.${filtersType}`] = filter.id ? [filter.id] : null;
} }
this.dataService this.dataService

View File

@ -7,23 +7,21 @@
<div class="row"> <div class="row">
<div class="col-lg"> <div class="col-lg">
<div class="d-flex"> <div class="d-flex">
@if (user?.settings?.isExperimentalFeatures) { <div class="d-flex">
<div class="d-flex"> <div class="d-none d-lg-block">
<div class="d-none d-lg-block"> <mat-button-toggle-group
<mat-button-toggle-group [formControl]="viewModeFormControl"
[formControl]="viewModeFormControl" [hideSingleSelectionIndicator]="true"
[hideSingleSelectionIndicator]="true" >
> <mat-button-toggle i18n-title title="Table" value="TABLE">
<mat-button-toggle i18n-title title="Table" value="TABLE"> <ion-icon name="reorder-four-outline" />
<ion-icon name="reorder-four-outline" /> </mat-button-toggle>
</mat-button-toggle> <mat-button-toggle i18n-title title="Chart" value="CHART">
<mat-button-toggle i18n-title title="Chart" value="CHART"> <ion-icon name="grid-outline" />
<ion-icon name="grid-outline" /> </mat-button-toggle>
</mat-button-toggle> </mat-button-toggle-group>
</mat-button-toggle-group>
</div>
</div> </div>
} </div>
<div class="align-items-center d-flex flex-grow-1 justify-content-end"> <div class="align-items-center d-flex flex-grow-1 justify-content-end">
<gf-toggle <gf-toggle
class="d-none d-lg-block" class="d-none d-lg-block"

View File

@ -1,76 +1,128 @@
<div mat-dialog-title>{{ data.rule.name }}</div> <div mat-dialog-title>{{ data.rule.name }}</div>
<div class="py-3" mat-dialog-content> <div class="py-3" mat-dialog-content>
<div @if (
class="w-100" data.rule.configuration.thresholdMin && data.rule.configuration.thresholdMax
[ngClass]="{ 'd-none': !data.rule.configuration.thresholdMin }" ) {
> <div class="w-100">
<h6 class="mb-0"> <h6 class="mb-0">
<ng-container i18n>Threshold Min</ng-container>: <ng-container i18n>Threshold range</ng-container>:
@if (data.rule.configuration.threshold.unit === '%') { @if (data.rule.configuration.threshold.unit === '%') {
{{ data.settings.thresholdMin | percent: '1.2-2' }} {{ data.settings.thresholdMin | percent: '1.2-2' }}
} @else { } @else {
{{ data.settings.thresholdMin }} {{ data.settings.thresholdMin }}
} }
</h6> -
@if (data.rule.configuration.threshold.unit === '%') { @if (data.rule.configuration.threshold.unit === '%') {
<label>{{ {{ data.settings.thresholdMax | percent: '1.2-2' }}
data.rule.configuration.threshold.min | percent: '1.2-2' } @else {
}}</label> {{ data.settings.thresholdMax }}
} @else { }
<label>{{ data.rule.configuration.threshold.min }}</label> </h6>
} <div class="align-items-center d-flex w-100">
<mat-slider @if (data.rule.configuration.threshold.unit === '%') {
name="thresholdMin" <label>{{
[max]="data.rule.configuration.threshold.max" data.rule.configuration.threshold.min | percent: '1.2-2'
[min]="data.rule.configuration.threshold.min" }}</label>
[step]="data.rule.configuration.threshold.step" } @else {
<label>{{ data.rule.configuration.threshold.min }}</label>
}
<mat-slider
class="flex-grow-1"
[max]="data.rule.configuration.threshold.max"
[min]="data.rule.configuration.threshold.min"
[step]="data.rule.configuration.threshold.step"
>
<input matSliderStartThumb [(ngModel)]="data.settings.thresholdMin" />
<input matSliderEndThumb [(ngModel)]="data.settings.thresholdMax" />
</mat-slider>
@if (data.rule.configuration.threshold.unit === '%') {
<label>{{
data.rule.configuration.threshold.max | percent: '1.2-2'
}}</label>
} @else {
<label>{{ data.rule.configuration.threshold.max }}</label>
}
</div>
</div>
} @else {
<div
class="w-100"
[ngClass]="{ 'd-none': !data.rule.configuration.thresholdMin }"
> >
<input matSliderThumb [(ngModel)]="data.settings.thresholdMin" /> <h6 class="mb-0">
</mat-slider> <ng-container i18n>Threshold Min</ng-container>:
@if (data.rule.configuration.threshold.unit === '%') { @if (data.rule.configuration.threshold.unit === '%') {
<label>{{ {{ data.settings.thresholdMin | percent: '1.2-2' }}
data.rule.configuration.threshold.max | percent: '1.2-2' } @else {
}}</label> {{ data.settings.thresholdMin }}
} @else { }
<label>{{ data.rule.configuration.threshold.max }}</label> </h6>
} <div class="align-items-center d-flex w-100">
</div> @if (data.rule.configuration.threshold.unit === '%') {
<div <label>{{
class="w-100" data.rule.configuration.threshold.min | percent: '1.2-2'
[ngClass]="{ 'd-none': !data.rule.configuration.thresholdMax }" }}</label>
> } @else {
<h6 class="mb-0"> <label>{{ data.rule.configuration.threshold.min }}</label>
<ng-container i18n>Threshold Max</ng-container>: }
@if (data.rule.configuration.threshold.unit === '%') { <mat-slider
{{ data.settings.thresholdMax | percent: '1.2-2' }} class="flex-grow-1"
} @else { name="thresholdMin"
{{ data.settings.thresholdMax }} [max]="data.rule.configuration.threshold.max"
} [min]="data.rule.configuration.threshold.min"
</h6> [step]="data.rule.configuration.threshold.step"
@if (data.rule.configuration.threshold.unit === '%') { >
<label>{{ <input matSliderThumb [(ngModel)]="data.settings.thresholdMin" />
data.rule.configuration.threshold.min | percent: '1.2-2' </mat-slider>
}}</label> @if (data.rule.configuration.threshold.unit === '%') {
} @else { <label>{{
<label>{{ data.rule.configuration.threshold.min }}</label> data.rule.configuration.threshold.max | percent: '1.2-2'
} }}</label>
<mat-slider } @else {
name="thresholdMax" <label>{{ data.rule.configuration.threshold.max }}</label>
[max]="data.rule.configuration.threshold.max" }
[min]="data.rule.configuration.threshold.min" </div>
[step]="data.rule.configuration.threshold.step" </div>
<div
class="w-100"
[ngClass]="{ 'd-none': !data.rule.configuration.thresholdMax }"
> >
<input matSliderThumb [(ngModel)]="data.settings.thresholdMax" /> <h6 class="mb-0">
</mat-slider> <ng-container i18n>Threshold Max</ng-container>:
@if (data.rule.configuration.threshold.unit === '%') { @if (data.rule.configuration.threshold.unit === '%') {
<label>{{ {{ data.settings.thresholdMax | percent: '1.2-2' }}
data.rule.configuration.threshold.max | percent: '1.2-2' } @else {
}}</label> {{ data.settings.thresholdMax }}
} @else { }
<label>{{ data.rule.configuration.threshold.max }}</label> </h6>
} <div class="align-items-center d-flex w-100">
</div> @if (data.rule.configuration.threshold.unit === '%') {
<label>{{
data.rule.configuration.threshold.min | percent: '1.2-2'
}}</label>
} @else {
<label>{{ data.rule.configuration.threshold.min }}</label>
}
<mat-slider
class="flex-grow-1"
name="thresholdMax"
[max]="data.rule.configuration.threshold.max"
[min]="data.rule.configuration.threshold.min"
[step]="data.rule.configuration.threshold.step"
>
<input matSliderThumb [(ngModel)]="data.settings.thresholdMax" />
</mat-slider>
@if (data.rule.configuration.threshold.unit === '%') {
<label>{{
data.rule.configuration.threshold.max | percent: '1.2-2'
}}</label>
} @else {
<label>{{ data.rule.configuration.threshold.max }}</label>
}
</div>
</div>
}
</div> </div>
<div align="end" mat-dialog-actions> <div align="end" mat-dialog-actions>

View File

@ -1,2 +1,5 @@
:host { :host {
label {
margin-bottom: 0;
}
} }

View File

@ -16,6 +16,7 @@ import {
MatSnackBarRef, MatSnackBarRef,
TextOnlySnackBar TextOnlySnackBar
} from '@angular/material/snack-bar'; } from '@angular/material/snack-bar';
import { StringValue } from 'ms';
import { StripeService } from 'ngx-stripe'; import { StripeService } from 'ngx-stripe';
import { EMPTY, Subject } from 'rxjs'; import { EMPTY, Subject } from 'rxjs';
import { catchError, switchMap, takeUntil } from 'rxjs/operators'; import { catchError, switchMap, takeUntil } from 'rxjs/operators';
@ -31,6 +32,7 @@ export class UserAccountMembershipComponent implements OnDestroy {
public coupon: number; public coupon: number;
public couponId: string; public couponId: string;
public defaultDateFormat: string; public defaultDateFormat: string;
public durationExtension: StringValue;
public hasPermissionForSubscription: boolean; public hasPermissionForSubscription: boolean;
public hasPermissionToUpdateUserSettings: boolean; public hasPermissionToUpdateUserSettings: boolean;
public price: number; public price: number;
@ -51,7 +53,7 @@ export class UserAccountMembershipComponent implements OnDestroy {
private stripeService: StripeService, private stripeService: StripeService,
private userService: UserService private userService: UserService
) { ) {
const { baseCurrency, globalPermissions, subscriptions } = const { baseCurrency, globalPermissions, subscriptionOffers } =
this.dataService.fetchInfo(); this.dataService.fetchInfo();
this.baseCurrency = baseCurrency; this.baseCurrency = baseCurrency;
@ -76,11 +78,18 @@ export class UserAccountMembershipComponent implements OnDestroy {
permissions.updateUserSettings permissions.updateUserSettings
); );
this.coupon = subscriptions?.[this.user.subscription.offer]?.coupon; this.coupon =
subscriptionOffers?.[this.user.subscription.offer]?.coupon;
this.couponId = this.couponId =
subscriptions?.[this.user.subscription.offer]?.couponId; subscriptionOffers?.[this.user.subscription.offer]?.couponId;
this.price = subscriptions?.[this.user.subscription.offer]?.price; this.durationExtension =
this.priceId = subscriptions?.[this.user.subscription.offer]?.priceId; subscriptionOffers?.[
this.user.subscription.offer
]?.durationExtension;
this.price =
subscriptionOffers?.[this.user.subscription.offer]?.price;
this.priceId =
subscriptionOffers?.[this.user.subscription.offer]?.priceId;
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
} }

View File

@ -34,6 +34,16 @@
&nbsp;<span i18n>per year</span> &nbsp;<span i18n>per year</span>
</div> </div>
} }
@if (durationExtension) {
<div class="mt-1 text-center">
<div
class="badge badge-pill badge-warning font-weight-normal px-2 py-1"
>
<strong>Limited Offer!</strong> Get
{{ durationExtension }} extra
</div>
</div>
}
} }
<div class="align-items-center d-flex justify-content-center mt-4"> <div class="align-items-center d-flex justify-content-center mt-4">
@if (!user?.subscription?.expiresAt) { @if (!user?.subscription?.expiresAt) {

View File

@ -117,7 +117,7 @@ export class UserAccountSettingsComponent implements OnDestroy, OnInit {
} }
public isCommunityLanguage() { public isCommunityLanguage() {
return !(this.language === 'de' || this.language === 'en'); return !['de', 'en'].includes(this.language);
} }
public onChangeUserSetting(aKey: string, aValue: string) { public onChangeUserSetting(aKey: string, aValue: string) {

View File

@ -2,6 +2,7 @@ import { ImpersonationStorageService } from '@ghostfolio/client/services/imperso
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service'; import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
import { import {
HEADER_KEY_IMPERSONATION, HEADER_KEY_IMPERSONATION,
HEADER_KEY_SKIP_INTERCEPTOR,
HEADER_KEY_TIMEZONE, HEADER_KEY_TIMEZONE,
HEADER_KEY_TOKEN HEADER_KEY_TOKEN
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
@ -27,6 +28,16 @@ export class AuthInterceptor implements HttpInterceptor {
next: HttpHandler next: HttpHandler
): Observable<HttpEvent<any>> { ): Observable<HttpEvent<any>> {
let request = req; let request = req;
if (request.headers.has(HEADER_KEY_SKIP_INTERCEPTOR)) {
// Bypass the interceptor
request = request.clone({
headers: req.headers.delete(HEADER_KEY_SKIP_INTERCEPTOR)
});
return next.handle(request);
}
let headers = request.headers.set( let headers = request.headers.set(
HEADER_KEY_TIMEZONE, HEADER_KEY_TIMEZONE,
Intl?.DateTimeFormat().resolvedOptions().timeZone Intl?.DateTimeFormat().resolvedOptions().timeZone

View File

@ -103,7 +103,7 @@ export class HttpResponseInterceptor implements HttpInterceptor {
} else if (error.status === StatusCodes.UNAUTHORIZED) { } else if (error.status === StatusCodes.UNAUTHORIZED) {
if (this.webAuthnService.isEnabled()) { if (this.webAuthnService.isEnabled()) {
this.router.navigate(['/webauthn']); this.router.navigate(['/webauthn']);
} else { } else if (!error.url.includes('/data-providers/ghostfolio/status')) {
this.tokenStorageService.signOut(); this.tokenStorageService.signOut();
} }
} }

View File

@ -0,0 +1,131 @@
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import {
DataProviderGhostfolioStatusResponse,
DividendsResponse,
HistoricalResponse,
LookupResponse,
QuotesResponse
} from '@ghostfolio/common/interfaces';
import { CommonModule } from '@angular/common';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Component, OnInit } from '@angular/core';
import { format, startOfYear } from 'date-fns';
import { map, Observable, Subject, takeUntil } from 'rxjs';
@Component({
host: { class: 'page' },
imports: [CommonModule],
selector: 'gf-api-page',
standalone: true,
styleUrls: ['./api-page.scss'],
templateUrl: './api-page.html'
})
export class GfApiPageComponent implements OnInit {
public dividends$: Observable<DividendsResponse['dividends']>;
public historicalData$: Observable<HistoricalResponse['historicalData']>;
public quotes$: Observable<QuotesResponse['quotes']>;
public status$: Observable<DataProviderGhostfolioStatusResponse>;
public symbols$: Observable<LookupResponse['items']>;
private unsubscribeSubject = new Subject<void>();
public constructor(private http: HttpClient) {}
public ngOnInit() {
this.dividends$ = this.fetchDividends({ symbol: 'KO' });
this.historicalData$ = this.fetchHistoricalData({ symbol: 'AAPL.US' });
this.quotes$ = this.fetchQuotes({ symbols: ['AAPL.US', 'VOO.US'] });
this.status$ = this.fetchStatus();
this.symbols$ = this.fetchSymbols({ query: 'apple' });
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
private fetchDividends({ symbol }: { symbol: string }) {
const params = new HttpParams()
.set('from', format(startOfYear(new Date()), DATE_FORMAT))
.set('to', format(new Date(), DATE_FORMAT));
return this.http
.get<DividendsResponse>(
`/api/v1/data-providers/ghostfolio/dividends/${symbol}`,
{ params }
)
.pipe(
map(({ dividends }) => {
return dividends;
}),
takeUntil(this.unsubscribeSubject)
);
}
private fetchHistoricalData({ symbol }: { symbol: string }) {
const params = new HttpParams()
.set('from', format(startOfYear(new Date()), DATE_FORMAT))
.set('to', format(new Date(), DATE_FORMAT));
return this.http
.get<HistoricalResponse>(
`/api/v1/data-providers/ghostfolio/historical/${symbol}`,
{ params }
)
.pipe(
map(({ historicalData }) => {
return historicalData;
}),
takeUntil(this.unsubscribeSubject)
);
}
private fetchQuotes({ symbols }: { symbols: string[] }) {
const params = new HttpParams().set('symbols', symbols.join(','));
return this.http
.get<QuotesResponse>('/api/v1/data-providers/ghostfolio/quotes', {
params
})
.pipe(
map(({ quotes }) => {
return quotes;
}),
takeUntil(this.unsubscribeSubject)
);
}
private fetchStatus() {
return this.http
.get<DataProviderGhostfolioStatusResponse>(
'/api/v1/data-providers/ghostfolio/status'
)
.pipe(takeUntil(this.unsubscribeSubject));
}
private fetchSymbols({
includeIndices = false,
query
}: {
includeIndices?: boolean;
query: string;
}) {
let params = new HttpParams().set('query', query);
if (includeIndices) {
params = params.append('includeIndices', includeIndices);
}
return this.http
.get<LookupResponse>('/api/v1/data-providers/ghostfolio/lookup', {
params
})
.pipe(
map(({ items }) => {
return items;
}),
takeUntil(this.unsubscribeSubject)
);
}
}

View File

@ -0,0 +1,62 @@
<div class="container">
<div class="mb-3">
<h2 class="text-center">Status</h2>
<div>{{ status$ | async | json }}</div>
</div>
<div class="mb-3">
<h2 class="text-center">Lookup</h2>
@if (symbols$) {
@let symbols = symbols$ | async;
<ul>
@for (item of symbols; track item.symbol) {
<li>{{ item.name }} ({{ item.symbol }})</li>
}
</ul>
}
</div>
<div>
<h2 class="text-center">Quotes</h2>
@if (quotes$) {
@let quotes = quotes$ | async;
<ul>
@for (quote of quotes | keyvalue; track quote) {
<li>
{{ quote.key }}: {{ quote.value.marketPrice }}
{{ quote.value.currency }}
</li>
}
</ul>
}
</div>
<div>
<h2 class="text-center">Historical</h2>
@if (historicalData$) {
@let historicalData = historicalData$ | async;
<ul>
@for (
historicalDataItem of historicalData | keyvalue;
track historicalDataItem
) {
<li>
{{ historicalDataItem.key }}:
{{ historicalDataItem.value.marketPrice }}
</li>
}
</ul>
}
</div>
<div>
<h2 class="text-center">Dividends</h2>
@if (dividends$) {
@let dividends = dividends$ | async;
<ul>
@for (dividend of dividends | keyvalue; track dividend) {
<li>
{{ dividend.key }}:
{{ dividend.value.marketPrice }}
</li>
}
</ul>
}
</div>
</div>

View File

@ -0,0 +1,3 @@
:host {
display: block;
}

View File

@ -0,0 +1,17 @@
import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator';
import { Component } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
@Component({
host: { class: 'page' },
imports: [GfPremiumIndicatorComponent, MatButtonModule, RouterModule],
selector: 'gf-black-weeks-2024-page',
standalone: true,
templateUrl: './black-weeks-2024-page.html'
})
export class BlackWeeks2024PageComponent {
public routerLinkFeatures = ['/' + $localize`:snake-case:features`];
public routerLinkPricing = ['/' + $localize`:snake-case:pricing`];
}

View File

@ -0,0 +1,180 @@
<div class="blog container">
<div class="row">
<div class="col-md-8 offset-md-2">
<article>
<div class="mb-4 text-center">
<h1 class="mb-1">Black Weeks 2024</h1>
<div class="mb-3 text-muted"><small>2024-11-16</small></div>
<img
alt="Black Week 2024 Teaser"
class="rounded w-100"
src="../assets/images/blog/black-weeks-2024.jpg"
title="Black Weeks 2024"
/>
</div>
<section class="mb-4">
<p>
Take advantage of our exclusive <strong>Black Weeks</strong> offer
and save <strong>25%</strong> on your annual
<span class="align-items-center d-inline-flex"
>Ghostfolio Premium
<gf-premium-indicator
class="d-inline-block ml-1"
[enableLink]="false"
/>
</span>
subscription, plus get <strong>3 months extra</strong> for free!
</p>
</section>
<section class="mb-4">
<p>
<a
href="https://ghostfol.io"
title="Open Source Wealth Management Software"
>Ghostfolio</a
>
is a powerful personal finance dashboard, designed to simplify your
investment journey. With this Open Source Software (OSS) platform,
you can:
</p>
<ul class="list-unstyled">
<li>
<strong>Unify your assets</strong>: Track your financial
portfolio, including stocks, ETFs, cryptocurrencies, etc.
</li>
<li>
<strong>Gain deeper insights</strong>: Access real-time analytics
and data-driven insights.
</li>
<li>
<strong>Make informed decisions</strong>: Empower yourself with
actionable information.
</li>
</ul>
</section>
<section class="mb-4">
<p>
Dont miss this limited-time offer to optimize your financial
future.
</p>
<p class="text-center">
<a color="primary" mat-flat-button [routerLink]="routerLinkPricing"
>Get the Deal</a
>
</p>
<p class="mt-5">
For more information, visit our
<a [routerLink]="routerLinkPricing">pricing page</a>.
</p>
</section>
<section class="mb-4">
<ul class="list-inline">
<li class="list-inline-item">
<span class="badge badge-light">2024</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Black Friday</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Black Weeks</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Cryptocurrency</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Dashboard</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Deal</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">DeFi</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">ETF</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Finance</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Fintech</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Ghostfolio</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Ghostfolio Premium</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Hosting</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Investment</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Open Source</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">OSS</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Personal Finance</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Portfolio</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Portfolio Tracker</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Pricing</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Promotion</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">SaaS</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Sale</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Software</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Stock</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Subscription</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Wealth</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Wealth Management</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Web3</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Web 3.0</span>
</li>
</ul>
</section>
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item">
<a i18n [routerLink]="['/blog']">Blog</a>
</li>
<li
aria-current="page"
class="active breadcrumb-item text-truncate"
>
Black Weeks 2024
</li>
</ol>
</nav>
</article>
</div>
</div>
</div>

View File

@ -165,15 +165,6 @@ const routes: Routes = [
).then((c) => c.Hacktoberfest2023PageComponent), ).then((c) => c.Hacktoberfest2023PageComponent),
title: 'Hacktoberfest 2023' title: 'Hacktoberfest 2023'
}, },
{
canActivate: [AuthGuard],
path: '2023/11/hacktoberfest-2023-debriefing',
loadComponent: () =>
import(
'./2023/11/hacktoberfest-2023-debriefing/hacktoberfest-2023-debriefing-page.component'
).then((c) => c.Hacktoberfest2023DebriefingPageComponent),
title: 'Hacktoberfest 2023 Debriefing'
},
{ {
canActivate: [AuthGuard], canActivate: [AuthGuard],
path: '2023/11/black-week-2023', path: '2023/11/black-week-2023',
@ -183,6 +174,15 @@ const routes: Routes = [
), ),
title: 'Black Week 2023' title: 'Black Week 2023'
}, },
{
canActivate: [AuthGuard],
path: '2023/11/hacktoberfest-2023-debriefing',
loadComponent: () =>
import(
'./2023/11/hacktoberfest-2023-debriefing/hacktoberfest-2023-debriefing-page.component'
).then((c) => c.Hacktoberfest2023DebriefingPageComponent),
title: 'Hacktoberfest 2023 Debriefing'
},
{ {
canActivate: [AuthGuard], canActivate: [AuthGuard],
path: '2024/09/hacktoberfest-2024', path: '2024/09/hacktoberfest-2024',
@ -191,6 +191,15 @@ const routes: Routes = [
'./2024/09/hacktoberfest-2024/hacktoberfest-2024-page.component' './2024/09/hacktoberfest-2024/hacktoberfest-2024-page.component'
).then((c) => c.Hacktoberfest2024PageComponent), ).then((c) => c.Hacktoberfest2024PageComponent),
title: 'Hacktoberfest 2024' title: 'Hacktoberfest 2024'
},
{
canActivate: [AuthGuard],
path: '2024/11/black-weeks-2024',
loadComponent: () =>
import('./2024/11/black-weeks-2024/black-weeks-2024-page.component').then(
(c) => c.BlackWeeks2024PageComponent
),
title: 'Black Weeks 2024'
} }
]; ];

View File

@ -8,6 +8,32 @@
finance</small finance</small
> >
</h1> </h1>
@if (hasPermissionForSubscription) {
<mat-card appearance="outlined" class="mb-3">
<mat-card-content class="p-0">
<div class="container p-0">
<div class="flex-nowrap no-gutters row">
<a
class="d-flex overflow-hidden p-3 w-100"
href="../en/blog/2024/11/black-weeks-2024"
>
<div class="flex-grow-1 overflow-hidden">
<div class="h6 m-0 text-truncate">Black Weeks 2024</div>
<div class="d-flex text-muted">2024-11-16</div>
</div>
<div class="align-items-center d-flex">
<ion-icon
class="chevron text-muted"
name="chevron-forward-outline"
size="small"
/>
</div>
</a>
</div>
</div>
</mat-card-content>
</mat-card>
}
<mat-card appearance="outlined" class="mb-3"> <mat-card appearance="outlined" class="mb-3">
<mat-card-content class="p-0"> <mat-card-content class="p-0">
<div class="container p-0"> <div class="container p-0">

View File

@ -7,7 +7,7 @@ import { MAX_TOP_HOLDINGS, UNKNOWN_KEY } from '@ghostfolio/common/config';
import { prettifySymbol } from '@ghostfolio/common/helper'; import { prettifySymbol } from '@ghostfolio/common/helper';
import { import {
AssetProfileIdentifier, AssetProfileIdentifier,
Holding, HoldingWithParents,
PortfolioDetails, PortfolioDetails,
PortfolioPosition, PortfolioPosition,
User User
@ -86,7 +86,7 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
value: number; value: number;
}; };
}; };
public topHoldings: Holding[]; public topHoldings: HoldingWithParents[];
public topHoldingsMap: { public topHoldingsMap: {
[name: string]: { name: string; value: number }; [name: string]: { name: string; value: number };
}; };
@ -490,6 +490,36 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
name, name,
allocationInPercentage: allocationInPercentage:
this.totalValueInEtf > 0 ? value / this.totalValueInEtf : 0, this.totalValueInEtf > 0 ? value / this.totalValueInEtf : 0,
parents: Object.entries(this.portfolioDetails.holdings)
.map(([symbol, holding]) => {
if (holding.holdings.length > 0) {
const currentParentHolding = holding.holdings.find(
(parentHolding) => {
return parentHolding.name === name;
}
);
return currentParentHolding
? {
allocationInPercentage:
currentParentHolding.valueInBaseCurrency / value,
name: holding.name,
position: holding,
symbol: prettifySymbol(symbol),
valueInBaseCurrency:
currentParentHolding.valueInBaseCurrency
}
: null;
}
return null;
})
.filter((item) => {
return item !== null;
})
.sort((a, b) => {
return b.allocationInPercentage - a.allocationInPercentage;
}),
valueInBaseCurrency: value valueInBaseCurrency: value
}; };
}) })

View File

@ -347,6 +347,7 @@
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[pageSize]="10" [pageSize]="10"
[topHoldings]="topHoldings" [topHoldings]="topHoldings"
(holdingClicked)="onSymbolChartClicked($event)"
/> />
</mat-card-content> </mat-card-content>
</mat-card> </mat-card>

View File

@ -2,7 +2,7 @@ import { ToggleComponent } from '@ghostfolio/client/components/toggle/toggle.com
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { MatPaginator, PageEvent } from '@angular/material/paginator'; import { PageEvent } from '@angular/material/paginator';
import { import {
HistoricalDataItem, HistoricalDataItem,
InvestmentItem, InvestmentItem,

View File

@ -10,7 +10,7 @@ const routes: Routes = [
canActivate: [AuthGuard], canActivate: [AuthGuard],
component: FirePageComponent, component: FirePageComponent,
path: '', path: '',
title: $localize`FIRE` title: 'FIRE'
} }
]; ];

View File

@ -1,12 +1,7 @@
import { UpdateUserSettingDto } from '@ghostfolio/api/app/user/update-user-setting.dto';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { import { User } from '@ghostfolio/common/interfaces';
PortfolioReport,
PortfolioReportRule,
User
} from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
@ -21,18 +16,11 @@ import { takeUntil } from 'rxjs/operators';
templateUrl: './fire-page.html' templateUrl: './fire-page.html'
}) })
export class FirePageComponent implements OnDestroy, OnInit { export class FirePageComponent implements OnDestroy, OnInit {
public accountClusterRiskRules: PortfolioReportRule[];
public allocationClusterRiskRules: PortfolioReportRule[];
public currencyClusterRiskRules: PortfolioReportRule[];
public deviceType: string; public deviceType: string;
public emergencyFundRules: PortfolioReportRule[];
public feeRules: PortfolioReportRule[];
public fireWealth: Big; public fireWealth: Big;
public hasImpersonationId: boolean; public hasImpersonationId: boolean;
public hasPermissionToUpdateUserSettings: boolean; public hasPermissionToUpdateUserSettings: boolean;
public inactiveRules: PortfolioReportRule[];
public isLoading = false; public isLoading = false;
public isLoadingPortfolioReport = false;
public user: User; public user: User;
public withdrawalRatePerMonth: Big; public withdrawalRatePerMonth: Big;
public withdrawalRatePerYear: Big; public withdrawalRatePerYear: Big;
@ -95,8 +83,6 @@ export class FirePageComponent implements OnDestroy, OnInit {
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
} }
}); });
this.initializePortfolioReport();
} }
public onAnnualInterestRateChange(annualInterestRate: number) { public onAnnualInterestRateChange(annualInterestRate: number) {
@ -133,21 +119,6 @@ export class FirePageComponent implements OnDestroy, OnInit {
}); });
}); });
} }
public onRulesUpdated(event: UpdateUserSettingDto) {
this.dataService
.putUserSetting(event)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.userService
.get(true)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe();
this.initializePortfolioReport();
});
}
public onSavingsRateChange(savingsRate: number) { public onSavingsRateChange(savingsRate: number) {
this.dataService this.dataService
.putUserSetting({ savingsRate }) .putUserSetting({ savingsRate })
@ -187,66 +158,4 @@ export class FirePageComponent implements OnDestroy, OnInit {
this.unsubscribeSubject.next(); this.unsubscribeSubject.next();
this.unsubscribeSubject.complete(); this.unsubscribeSubject.complete();
} }
private initializePortfolioReport() {
this.isLoadingPortfolioReport = true;
this.dataService
.fetchPortfolioReport()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((portfolioReport) => {
this.inactiveRules = this.mergeInactiveRules(portfolioReport);
this.accountClusterRiskRules =
portfolioReport.rules['accountClusterRisk']?.filter(
({ isActive }) => {
return isActive;
}
) ?? null;
this.allocationClusterRiskRules =
portfolioReport.rules['allocationClusterRisk']?.filter(
({ isActive }) => {
return isActive;
}
) ?? null;
this.currencyClusterRiskRules =
portfolioReport.rules['currencyClusterRisk']?.filter(
({ isActive }) => {
return isActive;
}
) ?? null;
this.emergencyFundRules =
portfolioReport.rules['emergencyFund']?.filter(({ isActive }) => {
return isActive;
}) ?? null;
this.feeRules =
portfolioReport.rules['fees']?.filter(({ isActive }) => {
return isActive;
}) ?? null;
this.isLoadingPortfolioReport = false;
this.changeDetectorRef.markForCheck();
});
}
private mergeInactiveRules(report: PortfolioReport): PortfolioReportRule[] {
let inactiveRules: PortfolioReportRule[] = [];
for (const category in report.rules) {
const rulesArray = report.rules[category];
inactiveRules = inactiveRules.concat(
rulesArray.filter(({ isActive }) => {
return !isActive;
})
);
}
return inactiveRules;
}
} }

View File

@ -101,133 +101,3 @@
} }
</div> </div>
</div> </div>
<div class="container mt-5">
<div class="row">
<div class="col">
<h2 class="h3 mb-3 text-center">X-ray</h2>
<p class="mb-4">
<span i18n
>Ghostfolio X-ray uses static analysis to identify potential issues
and risks in your portfolio.</span
>
<span class="d-none"
>It will be highly configurable in the future: activate / deactivate
rules and customize the thresholds to match your personal investment
style.</span
>
</p>
<div class="mb-4">
<h4 class="align-items-center d-flex m-0">
<span i18n>Emergency Fund</span>
@if (user?.subscription?.type === 'Basic') {
<gf-premium-indicator class="ml-1" />
}
</h4>
<gf-rules
[hasPermissionToUpdateUserSettings]="
!hasImpersonationId &&
hasPermissionToUpdateUserSettings &&
user?.settings?.isExperimentalFeatures
"
[isLoading]="isLoadingPortfolioReport"
[rules]="emergencyFundRules"
[settings]="user?.settings?.xRayRules"
(rulesUpdated)="onRulesUpdated($event)"
/>
</div>
<div class="mb-4">
<h4 class="align-items-center d-flex m-0">
<span i18n>Currency Cluster Risks</span>
@if (user?.subscription?.type === 'Basic') {
<gf-premium-indicator class="ml-1" />
}
</h4>
<gf-rules
[hasPermissionToUpdateUserSettings]="
!hasImpersonationId &&
hasPermissionToUpdateUserSettings &&
user?.settings?.isExperimentalFeatures
"
[isLoading]="isLoadingPortfolioReport"
[rules]="currencyClusterRiskRules"
[settings]="user?.settings?.xRayRules"
(rulesUpdated)="onRulesUpdated($event)"
/>
</div>
<div class="mb-4">
<h4 class="align-items-center d-flex m-0">
<span i18n>Account Cluster Risks</span>
@if (user?.subscription?.type === 'Basic') {
<gf-premium-indicator class="ml-1" />
}
</h4>
<gf-rules
[hasPermissionToUpdateUserSettings]="
!hasImpersonationId &&
hasPermissionToUpdateUserSettings &&
user?.settings?.isExperimentalFeatures
"
[isLoading]="isLoadingPortfolioReport"
[rules]="accountClusterRiskRules"
[settings]="user?.settings?.xRayRules"
(rulesUpdated)="onRulesUpdated($event)"
/>
</div>
<div class="mb-4">
<h4 class="align-items-center d-flex m-0">
<span i18n>Allocation Cluster Risks</span>
@if (user?.subscription?.type === 'Basic') {
<gf-premium-indicator class="ml-1" />
}
</h4>
<gf-rules
[hasPermissionToUpdateUserSettings]="
!hasImpersonationId &&
hasPermissionToUpdateUserSettings &&
user?.settings?.isExperimentalFeatures
"
[isLoading]="isLoadingPortfolioReport"
[rules]="allocationClusterRiskRules"
[settings]="user?.settings?.xRayRules"
(rulesUpdated)="onRulesUpdated($event)"
/>
</div>
<div class="mb-4">
<h4 class="align-items-center d-flex m-0">
<span i18n>Fees</span>
@if (user?.subscription?.type === 'Basic') {
<gf-premium-indicator class="ml-1" />
}
</h4>
<gf-rules
[hasPermissionToUpdateUserSettings]="
!hasImpersonationId &&
hasPermissionToUpdateUserSettings &&
user?.settings?.isExperimentalFeatures
"
[isLoading]="isLoadingPortfolioReport"
[rules]="feeRules"
[settings]="user?.settings?.xRayRules"
(rulesUpdated)="onRulesUpdated($event)"
/>
</div>
@if (inactiveRules?.length > 0) {
<div>
<h4 class="m-0" i18n>Inactive</h4>
<gf-rules
[hasPermissionToUpdateUserSettings]="
!hasImpersonationId &&
hasPermissionToUpdateUserSettings &&
user?.settings?.isExperimentalFeatures
"
[isLoading]="isLoadingPortfolioReport"
[rules]="inactiveRules"
[settings]="user?.settings?.xRayRules"
(rulesUpdated)="onRulesUpdated($event)"
/>
</div>
}
</div>
</div>
</div>

View File

@ -1,4 +1,3 @@
import { GfRulesModule } from '@ghostfolio/client/components/rules/rules.module';
import { GfFireCalculatorComponent } from '@ghostfolio/ui/fire-calculator'; import { GfFireCalculatorComponent } from '@ghostfolio/ui/fire-calculator';
import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator'; import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator';
import { GfValueComponent } from '@ghostfolio/ui/value'; import { GfValueComponent } from '@ghostfolio/ui/value';
@ -17,7 +16,6 @@ import { FirePageComponent } from './fire-page.component';
FirePageRoutingModule, FirePageRoutingModule,
GfFireCalculatorComponent, GfFireCalculatorComponent,
GfPremiumIndicatorComponent, GfPremiumIndicatorComponent,
GfRulesModule,
GfValueComponent, GfValueComponent,
NgxSkeletonLoaderModule NgxSkeletonLoaderModule
], ],

View File

@ -34,6 +34,11 @@ const routes: Routes = [
path: 'fire', path: 'fire',
loadChildren: () => loadChildren: () =>
import('./fire/fire-page.module').then((m) => m.FirePageModule) import('./fire/fire-page.module').then((m) => m.FirePageModule)
},
{
path: 'x-ray',
loadChildren: () =>
import('./x-ray/x-ray-page.module').then((m) => m.XRayPageModule)
} }
], ],
component: PortfolioPageComponent, component: PortfolioPageComponent,

View File

@ -46,8 +46,13 @@ export class PortfolioPageComponent implements OnDestroy, OnInit {
}, },
{ {
iconName: 'calculator-outline', iconName: 'calculator-outline',
label: 'FIRE / X-ray', label: 'FIRE ',
path: ['/portfolio', 'fire'] path: ['/portfolio', 'fire']
},
{
iconName: 'scan-outline',
label: 'X-ray',
path: ['/portfolio', 'x-ray']
} }
]; ];
this.user = state.user; this.user = state.user;

View File

@ -0,0 +1,21 @@
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { XRayPageComponent } from './x-ray-page.component';
const routes: Routes = [
{
canActivate: [AuthGuard],
component: XRayPageComponent,
path: '',
title: 'X-ray'
}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class XRayPageRoutingModule {}

View File

@ -0,0 +1,123 @@
<div class="container">
<div class="row">
<div class="col">
<h2 class="d-none d-sm-block h3 mb-3 text-center">X-ray</h2>
<p class="mb-4" i18n>
Ghostfolio X-ray uses static analysis to uncover potential issues and
risks in your portfolio. Adjust the rules below and set custom
thresholds to align with your personal investment strategy.
</p>
<div class="mb-4">
<h4 class="align-items-center d-flex m-0">
<span i18n>Emergency Fund</span>
@if (user?.subscription?.type === 'Basic') {
<gf-premium-indicator class="ml-1" />
}
</h4>
<gf-rules
[hasPermissionToUpdateUserSettings]="
!hasImpersonationId &&
hasPermissionToUpdateUserSettings &&
user?.settings?.isExperimentalFeatures
"
[isLoading]="isLoadingPortfolioReport"
[rules]="emergencyFundRules"
[settings]="user?.settings?.xRayRules"
(rulesUpdated)="onRulesUpdated($event)"
/>
</div>
<div class="mb-4">
<h4 class="align-items-center d-flex m-0">
<span i18n>Currency Cluster Risks</span>
@if (user?.subscription?.type === 'Basic') {
<gf-premium-indicator class="ml-1" />
}
</h4>
<gf-rules
[hasPermissionToUpdateUserSettings]="
!hasImpersonationId &&
hasPermissionToUpdateUserSettings &&
user?.settings?.isExperimentalFeatures
"
[isLoading]="isLoadingPortfolioReport"
[rules]="currencyClusterRiskRules"
[settings]="user?.settings?.xRayRules"
(rulesUpdated)="onRulesUpdated($event)"
/>
</div>
<div class="mb-4">
<h4 class="align-items-center d-flex m-0">
<span i18n>Account Cluster Risks</span>
@if (user?.subscription?.type === 'Basic') {
<gf-premium-indicator class="ml-1" />
}
</h4>
<gf-rules
[hasPermissionToUpdateUserSettings]="
!hasImpersonationId &&
hasPermissionToUpdateUserSettings &&
user?.settings?.isExperimentalFeatures
"
[isLoading]="isLoadingPortfolioReport"
[rules]="accountClusterRiskRules"
[settings]="user?.settings?.xRayRules"
(rulesUpdated)="onRulesUpdated($event)"
/>
</div>
<div class="mb-4">
<h4 class="align-items-center d-flex m-0">
<span i18n>Economic Market Cluster Risks</span>
@if (user?.subscription?.type === 'Basic') {
<gf-premium-indicator class="ml-1" />
}
</h4>
<gf-rules
[hasPermissionToUpdateUserSettings]="
!hasImpersonationId &&
hasPermissionToUpdateUserSettings &&
user?.settings?.isExperimentalFeatures
"
[isLoading]="isLoadingPortfolioReport"
[rules]="economicMarketClusterRiskRules"
[settings]="user?.settings?.xRayRules"
(rulesUpdated)="onRulesUpdated($event)"
/>
</div>
<div class="mb-4">
<h4 class="align-items-center d-flex m-0">
<span i18n>Fees</span>
@if (user?.subscription?.type === 'Basic') {
<gf-premium-indicator class="ml-1" />
}
</h4>
<gf-rules
[hasPermissionToUpdateUserSettings]="
!hasImpersonationId &&
hasPermissionToUpdateUserSettings &&
user?.settings?.isExperimentalFeatures
"
[isLoading]="isLoadingPortfolioReport"
[rules]="feeRules"
[settings]="user?.settings?.xRayRules"
(rulesUpdated)="onRulesUpdated($event)"
/>
</div>
@if (inactiveRules?.length > 0) {
<div>
<h4 class="m-0" i18n>Inactive</h4>
<gf-rules
[hasPermissionToUpdateUserSettings]="
!hasImpersonationId &&
hasPermissionToUpdateUserSettings &&
user?.settings?.isExperimentalFeatures
"
[isLoading]="isLoadingPortfolioReport"
[rules]="inactiveRules"
[settings]="user?.settings?.xRayRules"
(rulesUpdated)="onRulesUpdated($event)"
/>
</div>
}
</div>
</div>
</div>

View File

@ -0,0 +1,3 @@
:host {
display: block;
}

View File

@ -0,0 +1,150 @@
import { UpdateUserSettingDto } from '@ghostfolio/api/app/user/update-user-setting.dto';
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 {
PortfolioReportRule,
PortfolioReport
} from '@ghostfolio/common/interfaces';
import { User } from '@ghostfolio/common/interfaces/user.interface';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { ChangeDetectorRef, Component } from '@angular/core';
import { Subject, takeUntil } from 'rxjs';
@Component({
selector: 'gf-x-ray-page',
styleUrl: './x-ray-page.component.scss',
templateUrl: './x-ray-page.component.html'
})
export class XRayPageComponent {
public accountClusterRiskRules: PortfolioReportRule[];
public currencyClusterRiskRules: PortfolioReportRule[];
public economicMarketClusterRiskRules: PortfolioReportRule[];
public emergencyFundRules: PortfolioReportRule[];
public feeRules: PortfolioReportRule[];
public hasImpersonationId: boolean;
public hasPermissionToUpdateUserSettings: boolean;
public inactiveRules: PortfolioReportRule[];
public isLoadingPortfolioReport = false;
public user: User;
private unsubscribeSubject = new Subject<void>();
public constructor(
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private impersonationStorageService: ImpersonationStorageService,
private userService: UserService
) {}
public ngOnInit() {
this.impersonationStorageService
.onChangeHasImpersonation()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((impersonationId) => {
this.hasImpersonationId = !!impersonationId;
});
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((state) => {
if (state?.user) {
this.user = state.user;
this.hasPermissionToUpdateUserSettings =
this.user.subscription?.type === 'Basic'
? false
: hasPermission(
this.user.permissions,
permissions.updateUserSettings
);
this.changeDetectorRef.markForCheck();
}
});
this.initializePortfolioReport();
}
public onRulesUpdated(event: UpdateUserSettingDto) {
this.dataService
.putUserSetting(event)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.userService
.get(true)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe();
this.initializePortfolioReport();
});
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
private initializePortfolioReport() {
this.isLoadingPortfolioReport = true;
this.dataService
.fetchPortfolioReport()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((portfolioReport) => {
this.inactiveRules = this.mergeInactiveRules(portfolioReport);
this.accountClusterRiskRules =
portfolioReport.rules['accountClusterRisk']?.filter(
({ isActive }) => {
return isActive;
}
) ?? null;
this.currencyClusterRiskRules =
portfolioReport.rules['currencyClusterRisk']?.filter(
({ isActive }) => {
return isActive;
}
) ?? null;
this.economicMarketClusterRiskRules =
portfolioReport.rules['economicMarketClusterRisk']?.filter(
({ isActive }) => {
return isActive;
}
) ?? null;
this.emergencyFundRules =
portfolioReport.rules['emergencyFund']?.filter(({ isActive }) => {
return isActive;
}) ?? null;
this.feeRules =
portfolioReport.rules['fees']?.filter(({ isActive }) => {
return isActive;
}) ?? null;
this.isLoadingPortfolioReport = false;
this.changeDetectorRef.markForCheck();
});
}
private mergeInactiveRules(report: PortfolioReport): PortfolioReportRule[] {
let inactiveRules: PortfolioReportRule[] = [];
for (const category in report.rules) {
const rulesArray = report.rules[category];
inactiveRules = inactiveRules.concat(
rulesArray.filter(({ isActive }) => {
return !isActive;
})
);
}
return inactiveRules;
}
}

View File

@ -0,0 +1,22 @@
import { GfRulesModule } from '@ghostfolio/client/components/rules/rules.module';
import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator';
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { XRayPageRoutingModule } from './x-ray-page-routing.module';
import { XRayPageComponent } from './x-ray-page.component';
@NgModule({
declarations: [XRayPageComponent],
imports: [
CommonModule,
GfPremiumIndicatorComponent,
GfRulesModule,
NgxSkeletonLoaderModule,
XRayPageRoutingModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class XRayPageModule {}

View File

@ -6,6 +6,7 @@ import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { translate } from '@ghostfolio/ui/i18n'; import { translate } from '@ghostfolio/ui/i18n';
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { StringValue } from 'ms';
import { StripeService } from 'ngx-stripe'; import { StripeService } from 'ngx-stripe';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { catchError, switchMap, takeUntil } from 'rxjs/operators'; import { catchError, switchMap, takeUntil } from 'rxjs/operators';
@ -20,6 +21,7 @@ export class PricingPageComponent implements OnDestroy, OnInit {
public baseCurrency: string; public baseCurrency: string;
public coupon: number; public coupon: number;
public couponId: string; public couponId: string;
public durationExtension: StringValue;
public hasPermissionToUpdateUserSettings: boolean; public hasPermissionToUpdateUserSettings: boolean;
public importAndExportTooltipBasic = translate( public importAndExportTooltipBasic = translate(
'DATA_IMPORT_AND_EXPORT_TOOLTIP_BASIC' 'DATA_IMPORT_AND_EXPORT_TOOLTIP_BASIC'
@ -51,11 +53,12 @@ export class PricingPageComponent implements OnDestroy, OnInit {
) {} ) {}
public ngOnInit() { public ngOnInit() {
const { baseCurrency, subscriptions } = this.dataService.fetchInfo(); const { baseCurrency, subscriptionOffers } = this.dataService.fetchInfo();
this.baseCurrency = baseCurrency; this.baseCurrency = baseCurrency;
this.coupon = subscriptions?.default?.coupon; this.coupon = subscriptionOffers?.default?.coupon;
this.price = subscriptions?.default?.price; this.durationExtension = subscriptionOffers?.default?.durationExtension;
this.price = subscriptionOffers?.default?.price;
this.userService.stateChanged this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
@ -68,11 +71,18 @@ export class PricingPageComponent implements OnDestroy, OnInit {
permissions.updateUserSettings permissions.updateUserSettings
); );
this.coupon = subscriptions?.[this.user?.subscription?.offer]?.coupon; this.coupon =
subscriptionOffers?.[this.user?.subscription?.offer]?.coupon;
this.couponId = this.couponId =
subscriptions?.[this.user.subscription.offer]?.couponId; subscriptionOffers?.[this.user.subscription.offer]?.couponId;
this.price = subscriptions?.[this.user?.subscription?.offer]?.price; this.durationExtension =
this.priceId = subscriptions?.[this.user.subscription.offer]?.priceId; subscriptionOffers?.[
this.user?.subscription?.offer
]?.durationExtension;
this.price =
subscriptionOffers?.[this.user?.subscription?.offer]?.price;
this.priceId =
subscriptionOffers?.[this.user.subscription.offer]?.priceId;
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
} }

View File

@ -101,6 +101,11 @@
</p> </p>
</div> </div>
} }
@if (durationExtension) {
<div class="d-none d-lg-block hidden mt-3">
<div class="badge p-3 w-100">&nbsp;</div>
</div>
}
</mat-card-content> </mat-card-content>
</mat-card> </mat-card>
</div> </div>
@ -159,6 +164,11 @@
</p> </p>
</div> </div>
} }
@if (durationExtension) {
<div class="d-none d-lg-block hidden mt-3">
<div class="badge p-3 w-100">&nbsp;</div>
</div>
}
</mat-card-content> </mat-card-content>
</mat-card> </mat-card>
</div> </div>
@ -289,6 +299,14 @@
</p> </p>
</div> </div>
} }
@if (durationExtension) {
<div class="mt-3">
<div class="badge badge-warning font-weight-normal p-3 w-100">
<strong>Limited Offer!</strong> Get
{{ durationExtension }} extra
</div>
</div>
}
</mat-card-content> </mat-card-content>
</mat-card> </mat-card>
</div> </div>

View File

@ -35,9 +35,9 @@ export class GfProductPageComponent implements OnInit {
) {} ) {}
public ngOnInit() { public ngOnInit() {
const { subscriptions } = this.dataService.fetchInfo(); const { subscriptionOffers } = this.dataService.fetchInfo();
this.price = subscriptions?.default?.price; this.price = subscriptionOffers?.default?.price;
this.product1 = { this.product1 = {
founded: 2021, founded: 2021,

View File

@ -5,6 +5,12 @@ import { UpdatePlatformDto } from '@ghostfolio/api/app/platform/update-platform.
import { CreateTagDto } from '@ghostfolio/api/app/tag/create-tag.dto'; import { CreateTagDto } from '@ghostfolio/api/app/tag/create-tag.dto';
import { UpdateTagDto } from '@ghostfolio/api/app/tag/update-tag.dto'; import { UpdateTagDto } from '@ghostfolio/api/app/tag/update-tag.dto';
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces'; import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
import {
HEADER_KEY_SKIP_INTERCEPTOR,
HEADER_KEY_TOKEN,
PROPERTY_API_KEY_GHOSTFOLIO
} from '@ghostfolio/common/config';
import { DEFAULT_PAGE_SIZE } from '@ghostfolio/common/config';
import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { import {
AssetProfileIdentifier, AssetProfileIdentifier,
@ -13,6 +19,7 @@ import {
AdminMarketData, AdminMarketData,
AdminMarketDataDetails, AdminMarketDataDetails,
AdminUsers, AdminUsers,
DataProviderGhostfolioStatusResponse,
EnhancedSymbolProfile, EnhancedSymbolProfile,
Filter Filter
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
@ -23,8 +30,9 @@ import { SortDirection } from '@angular/material/sort';
import { DataSource, MarketData, Platform, Tag } from '@prisma/client'; import { DataSource, MarketData, Platform, Tag } from '@prisma/client';
import { JobStatus } from 'bull'; import { JobStatus } from 'bull';
import { format, parseISO } from 'date-fns'; import { format, parseISO } from 'date-fns';
import { Observable, map } from 'rxjs'; import { Observable, map, switchMap } from 'rxjs';
import { environment } from '../../environments/environment';
import { DataService } from './data.service'; import { DataService } from './data.service';
@Injectable({ @Injectable({
@ -136,6 +144,22 @@ export class AdminService {
); );
} }
public fetchGhostfolioDataProviderStatus() {
return this.fetchAdminData().pipe(
switchMap(({ settings }) => {
return this.http.get<DataProviderGhostfolioStatusResponse>(
`${environment.production ? 'https://ghostfol.io' : ''}/api/v1/data-providers/ghostfolio/status`,
{
headers: {
[HEADER_KEY_SKIP_INTERCEPTOR]: 'true',
[HEADER_KEY_TOKEN]: `Bearer ${settings[PROPERTY_API_KEY_GHOSTFOLIO]}`
}
}
);
})
);
}
public fetchJobs({ status }: { status?: JobStatus[] }) { public fetchJobs({ status }: { status?: JobStatus[] }) {
let params = new HttpParams(); let params = new HttpParams();
@ -156,8 +180,19 @@ export class AdminService {
return this.http.get<Tag[]>('/api/v1/tag'); return this.http.get<Tag[]>('/api/v1/tag');
} }
public fetchUsers() { public fetchUsers({
return this.http.get<AdminUsers>('/api/v1/admin/user'); skip,
take = DEFAULT_PAGE_SIZE
}: {
skip?: number;
take?: number;
}) {
let params = new HttpParams();
params = params.append('skip', skip);
params = params.append('take', take);
return this.http.get<AdminUsers>('/api/v1/admin/user', { params });
} }
public gather7Days() { public gather7Days() {

Some files were not shown because too many files have changed in this diff Show More