update
This commit is contained in:
commit
f97075d82b
@ -39,6 +39,7 @@
|
||||
"plugin:@typescript-eslint/stylistic-type-checked"
|
||||
],
|
||||
"rules": {
|
||||
"@typescript-eslint/consistent-indexed-object-style": "off",
|
||||
"@typescript-eslint/dot-notation": "off",
|
||||
"@typescript-eslint/explicit-member-accessibility": [
|
||||
"off",
|
||||
@ -142,8 +143,7 @@
|
||||
|
||||
// The following rules are part of @typescript-eslint/stylistic-type-checked
|
||||
// and can be remove once solved
|
||||
"@typescript-eslint/prefer-nullish-coalescing": "warn", // TODO: Requires strictNullChecks: true
|
||||
"@typescript-eslint/consistent-indexed-object-style": "warn"
|
||||
"@typescript-eslint/prefer-nullish-coalescing": "warn" // TODO: Requires strictNullChecks: true
|
||||
}
|
||||
}
|
||||
],
|
||||
|
98
CHANGELOG.md
98
CHANGELOG.md
@ -7,13 +7,103 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## 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
|
||||
|
||||
- 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`)
|
||||
- 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 `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
|
||||
|
||||
@ -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 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 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_ (Developed Markets)
|
||||
- Fixed an issue in the calculation of the static portfolio analysis rule: _Allocation Cluster Risk_ (Emerging Markets)
|
||||
|
||||
## 2.118.0 - 2024-10-23
|
||||
|
||||
### Added
|
||||
|
||||
- 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_ (Developed Markets)
|
||||
- Added a new static portfolio analysis rule: _Allocation Cluster Risk_ (Emerging Markets)
|
||||
- Added support for mutual funds in the _EOD Historical Data_ service
|
||||
|
||||
### Changed
|
||||
|
@ -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 ./docker/entrypoint.sh /ghostfolio/entrypoint.sh
|
||||
RUN chmod 0700 /ghostfolio/entrypoint.sh
|
||||
WORKDIR /ghostfolio/apps/api
|
||||
EXPOSE ${PORT:-3333}
|
||||
USER node
|
||||
|
@ -352,7 +352,13 @@ export class AdminController {
|
||||
@Get('user')
|
||||
@HasPermission(permissions.accessAdminControl)
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async getUsers(): Promise<AdminUsers> {
|
||||
return this.adminService.getUsers();
|
||||
public async 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
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -140,7 +140,7 @@ export class AdminService {
|
||||
const [settings, transactionCount, userCount] = await Promise.all([
|
||||
this.propertyService.get(),
|
||||
this.prismaService.order.count(),
|
||||
this.prismaService.user.count()
|
||||
this.countUsersWithAnalytics()
|
||||
]);
|
||||
|
||||
return {
|
||||
@ -429,8 +429,19 @@ export class AdminService {
|
||||
};
|
||||
}
|
||||
|
||||
public async getUsers(): Promise<AdminUsers> {
|
||||
return { users: await this.getUsersWithAnalytics() };
|
||||
public async getUsers({
|
||||
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({
|
||||
@ -508,6 +519,22 @@ export class AdminService {
|
||||
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() {
|
||||
Logger.debug('Connect extended prisma client', 'AdminService');
|
||||
|
||||
@ -640,8 +667,14 @@ export class AdminService {
|
||||
return { marketData, count: marketData.length };
|
||||
}
|
||||
|
||||
private async getUsersWithAnalytics(): Promise<AdminUsers['users']> {
|
||||
let orderBy: any = {
|
||||
private async getUsersWithAnalytics({
|
||||
skip,
|
||||
take
|
||||
}: {
|
||||
skip?: number;
|
||||
take?: number;
|
||||
}): Promise<AdminUsers['users']> {
|
||||
let orderBy: Prisma.UserOrderByWithRelationInput = {
|
||||
createdAt: 'desc'
|
||||
};
|
||||
let where: Prisma.UserWhereInput;
|
||||
@ -649,7 +682,7 @@ export class AdminService {
|
||||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||
orderBy = {
|
||||
Analytics: {
|
||||
updatedAt: 'desc'
|
||||
lastRequestAt: 'desc'
|
||||
}
|
||||
};
|
||||
where = {
|
||||
@ -661,6 +694,8 @@ export class AdminService {
|
||||
|
||||
const usersWithAnalytics = await this.prismaService.user.findMany({
|
||||
orderBy,
|
||||
skip,
|
||||
take,
|
||||
where,
|
||||
select: {
|
||||
_count: {
|
||||
@ -670,6 +705,7 @@ export class AdminService {
|
||||
select: {
|
||||
activityCount: true,
|
||||
country: true,
|
||||
dataProviderGhostfolioDailyRequests: true,
|
||||
updatedAt: true
|
||||
}
|
||||
},
|
||||
@ -677,8 +713,7 @@ export class AdminService {
|
||||
id: true,
|
||||
role: true,
|
||||
Subscription: true
|
||||
},
|
||||
take: 30
|
||||
}
|
||||
});
|
||||
|
||||
return usersWithAnalytics.map(
|
||||
@ -706,6 +741,7 @@ export class AdminService {
|
||||
subscription,
|
||||
accountCount: _count.Account || 0,
|
||||
country: Analytics?.country,
|
||||
dailyApiRequests: Analytics?.dataProviderGhostfolioDailyRequests || 0,
|
||||
lastActivity: Analytics?.updatedAt,
|
||||
transactionCount: _count.Order || 0
|
||||
};
|
||||
|
@ -31,6 +31,7 @@ import { AuthDeviceModule } from './auth-device/auth-device.module';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { BenchmarkModule } from './benchmark/benchmark.module';
|
||||
import { CacheModule } from './cache/cache.module';
|
||||
import { GhostfolioModule } from './endpoints/data-providers/ghostfolio/ghostfolio.module';
|
||||
import { PublicModule } from './endpoints/public/public.module';
|
||||
import { ExchangeRateModule } from './exchange-rate/exchange-rate.module';
|
||||
import { ExportModule } from './export/export.module';
|
||||
@ -76,6 +77,7 @@ import { UserModule } from './user/user.module';
|
||||
ExchangeRateModule,
|
||||
ExchangeRateDataModule,
|
||||
ExportModule,
|
||||
GhostfolioModule,
|
||||
HealthModule,
|
||||
ImportModule,
|
||||
InfoModule,
|
||||
|
@ -46,7 +46,7 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
update: {
|
||||
country,
|
||||
activityCount: { increment: 1 },
|
||||
updatedAt: new Date()
|
||||
lastRequestAt: new Date()
|
||||
},
|
||||
where: { userId: user.id }
|
||||
});
|
||||
@ -60,7 +60,7 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error?.getStatus() === StatusCodes.TOO_MANY_REQUESTS) {
|
||||
if (error?.getStatus?.() === StatusCodes.TOO_MANY_REQUESTS) {
|
||||
throw error;
|
||||
} else {
|
||||
throw new HttpException(
|
||||
|
@ -437,7 +437,7 @@ export class BenchmarkService {
|
||||
};
|
||||
});
|
||||
|
||||
if (storeInCache) {
|
||||
if (!enableSharing && storeInCache) {
|
||||
const expiration = addHours(new Date(), 2);
|
||||
|
||||
await this.redisCacheService.set(
|
||||
|
@ -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;
|
||||
}
|
@ -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;
|
||||
}
|
@ -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[];
|
||||
}
|
@ -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 });
|
||||
}
|
||||
}
|
@ -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 {}
|
@ -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]);
|
||||
});
|
||||
}
|
||||
}
|
@ -582,12 +582,13 @@ export class ImportService {
|
||||
const assetProfiles: {
|
||||
[assetProfileIdentifier: string]: Partial<SymbolProfile>;
|
||||
} = {};
|
||||
const dataSources = await this.dataProviderService.getDataSources();
|
||||
|
||||
for (const [
|
||||
index,
|
||||
{ currency, dataSource, symbol, type }
|
||||
] of activitiesDto.entries()) {
|
||||
if (!this.configurationService.get('DATA_SOURCES').includes(dataSource)) {
|
||||
if (!dataSources.includes(dataSource)) {
|
||||
throw new Error(
|
||||
`activities.${index}.dataSource ("${dataSource}") is not valid`
|
||||
);
|
||||
|
@ -7,6 +7,7 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
|
||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||
import {
|
||||
DEFAULT_CURRENCY,
|
||||
HEADER_KEY_TOKEN,
|
||||
PROPERTY_BETTER_UPTIME_MONITOR_ID,
|
||||
PROPERTY_COUNTRIES_OF_SUBSCRIBERS,
|
||||
PROPERTY_DEMO_USER_ID,
|
||||
@ -23,10 +24,10 @@ import {
|
||||
import {
|
||||
InfoItem,
|
||||
Statistics,
|
||||
Subscription
|
||||
SubscriptionOffer
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
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 { JwtService } from '@nestjs/jwt';
|
||||
@ -101,7 +102,7 @@ export class InfoService {
|
||||
isUserSignupEnabled,
|
||||
platforms,
|
||||
statistics,
|
||||
subscriptions
|
||||
subscriptionOffers
|
||||
] = await Promise.all([
|
||||
this.benchmarkService.getBenchmarkAssetProfiles(),
|
||||
this.getDemoAuthToken(),
|
||||
@ -110,7 +111,7 @@ export class InfoService {
|
||||
orderBy: { name: 'asc' }
|
||||
}),
|
||||
this.getStatistics(),
|
||||
this.getSubscriptions()
|
||||
this.getSubscriptionOffers()
|
||||
]);
|
||||
|
||||
if (isUserSignupEnabled) {
|
||||
@ -125,7 +126,7 @@ export class InfoService {
|
||||
isReadOnlyMode,
|
||||
platforms,
|
||||
statistics,
|
||||
subscriptions,
|
||||
subscriptionOffers,
|
||||
baseCurrency: DEFAULT_CURRENCY,
|
||||
currencies: this.exchangeRateDataService.getCurrencies()
|
||||
};
|
||||
@ -142,7 +143,7 @@ export class InfoService {
|
||||
},
|
||||
{
|
||||
Analytics: {
|
||||
updatedAt: {
|
||||
lastRequestAt: {
|
||||
gt: subDays(new Date(), aDays)
|
||||
}
|
||||
}
|
||||
@ -314,8 +315,8 @@ export class InfoService {
|
||||
return statistics;
|
||||
}
|
||||
|
||||
private async getSubscriptions(): Promise<{
|
||||
[offer in SubscriptionOffer]: Subscription;
|
||||
private async getSubscriptionOffers(): Promise<{
|
||||
[offer in SubscriptionOfferKey]: SubscriptionOffer;
|
||||
}> {
|
||||
if (!this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||
return undefined;
|
||||
@ -347,7 +348,7 @@ export class InfoService {
|
||||
)}&to${format(new Date(), DATE_FORMAT)}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.configurationService.get(
|
||||
[HEADER_KEY_TOKEN]: `Bearer ${this.configurationService.get(
|
||||
'API_KEY_BETTER_UPTIME'
|
||||
)}`
|
||||
},
|
||||
|
@ -52,27 +52,24 @@ export class CurrentRateService {
|
||||
.then((dataResultProvider) => {
|
||||
const result: GetValueObject[] = [];
|
||||
|
||||
for (const dataGatheringItem of dataGatheringItems) {
|
||||
if (
|
||||
dataResultProvider?.[dataGatheringItem.symbol]?.dataProviderInfo
|
||||
) {
|
||||
for (const { dataSource, symbol } of dataGatheringItems) {
|
||||
if (dataResultProvider?.[symbol]?.dataProviderInfo) {
|
||||
dataProviderInfos.push(
|
||||
dataResultProvider[dataGatheringItem.symbol].dataProviderInfo
|
||||
dataResultProvider[symbol].dataProviderInfo
|
||||
);
|
||||
}
|
||||
|
||||
if (dataResultProvider?.[dataGatheringItem.symbol]?.marketPrice) {
|
||||
if (dataResultProvider?.[symbol]?.marketPrice) {
|
||||
result.push({
|
||||
dataSource: dataGatheringItem.dataSource,
|
||||
dataSource,
|
||||
symbol,
|
||||
date: today,
|
||||
marketPrice:
|
||||
dataResultProvider?.[dataGatheringItem.symbol]?.marketPrice,
|
||||
symbol: dataGatheringItem.symbol
|
||||
marketPrice: dataResultProvider?.[symbol]?.marketPrice
|
||||
});
|
||||
} else {
|
||||
quoteErrors.push({
|
||||
dataSource: dataGatheringItem.dataSource,
|
||||
symbol: dataGatheringItem.symbol
|
||||
dataSource,
|
||||
symbol
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -74,12 +74,15 @@ export class PortfolioController {
|
||||
@Get('details')
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||
public async getDetails(
|
||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
|
||||
@Query('accounts') filterByAccounts?: string,
|
||||
@Query('assetClasses') filterByAssetClasses?: string,
|
||||
@Query('dataSource') filterByDataSource?: string,
|
||||
@Query('range') dateRange: DateRange = 'max',
|
||||
@Query('symbol') filterBySymbol?: string,
|
||||
@Query('tags') filterByTags?: string,
|
||||
@Query('withMarkets') withMarketsParam = 'false'
|
||||
): Promise<PortfolioDetails & { hasError: boolean }> {
|
||||
@ -95,6 +98,8 @@ export class PortfolioController {
|
||||
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||
filterByAccounts,
|
||||
filterByAssetClasses,
|
||||
filterByDataSource,
|
||||
filterBySymbol,
|
||||
filterByTags
|
||||
});
|
||||
|
||||
@ -289,17 +294,22 @@ export class PortfolioController {
|
||||
|
||||
@Get('dividends')
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||
public async getDividends(
|
||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
|
||||
@Query('accounts') filterByAccounts?: string,
|
||||
@Query('assetClasses') filterByAssetClasses?: string,
|
||||
@Query('dataSource') filterByDataSource?: string,
|
||||
@Query('groupBy') groupBy?: GroupBy,
|
||||
@Query('range') dateRange: DateRange = 'max',
|
||||
@Query('symbol') filterBySymbol?: string,
|
||||
@Query('tags') filterByTags?: string
|
||||
): Promise<PortfolioDividends> {
|
||||
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||
filterByAccounts,
|
||||
filterByAssetClasses,
|
||||
filterByDataSource,
|
||||
filterBySymbol,
|
||||
filterByTags
|
||||
});
|
||||
|
||||
@ -356,21 +366,26 @@ export class PortfolioController {
|
||||
@Get('holdings')
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||
public async getHoldings(
|
||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
|
||||
@Query('accounts') filterByAccounts?: string,
|
||||
@Query('assetClasses') filterByAssetClasses?: string,
|
||||
@Query('dataSource') filterByDataSource?: string,
|
||||
@Query('holdingType') filterByHoldingType?: string,
|
||||
@Query('query') filterBySearchQuery?: string,
|
||||
@Query('range') dateRange: DateRange = 'max',
|
||||
@Query('symbol') filterBySymbol?: string,
|
||||
@Query('tags') filterByTags?: string
|
||||
): Promise<PortfolioHoldingsResponse> {
|
||||
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||
filterByAccounts,
|
||||
filterByAssetClasses,
|
||||
filterByDataSource,
|
||||
filterByHoldingType,
|
||||
filterBySearchQuery,
|
||||
filterBySymbol,
|
||||
filterByTags
|
||||
});
|
||||
|
||||
@ -386,17 +401,22 @@ export class PortfolioController {
|
||||
|
||||
@Get('investments')
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||
public async getInvestments(
|
||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
|
||||
@Query('accounts') filterByAccounts?: string,
|
||||
@Query('assetClasses') filterByAssetClasses?: string,
|
||||
@Query('dataSource') filterByDataSource?: string,
|
||||
@Query('groupBy') groupBy?: GroupBy,
|
||||
@Query('range') dateRange: DateRange = 'max',
|
||||
@Query('symbol') filterBySymbol?: string,
|
||||
@Query('tags') filterByTags?: string
|
||||
): Promise<PortfolioInvestments> {
|
||||
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||
filterByAccounts,
|
||||
filterByAssetClasses,
|
||||
filterByDataSource,
|
||||
filterBySymbol,
|
||||
filterByTags
|
||||
});
|
||||
|
||||
@ -451,13 +471,16 @@ export class PortfolioController {
|
||||
@Get('performance')
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
@UseInterceptors(PerformanceLoggingInterceptor)
|
||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||
@Version('2')
|
||||
public async getPerformanceV2(
|
||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
|
||||
@Query('accounts') filterByAccounts?: string,
|
||||
@Query('assetClasses') filterByAssetClasses?: string,
|
||||
@Query('dataSource') filterByDataSource?: string,
|
||||
@Query('range') dateRange: DateRange = 'max',
|
||||
@Query('symbol') filterBySymbol?: string,
|
||||
@Query('tags') filterByTags?: string,
|
||||
@Query('withExcludedAccounts') withExcludedAccountsParam = 'false'
|
||||
): Promise<PortfolioPerformanceResponse> {
|
||||
@ -466,6 +489,8 @@ export class PortfolioController {
|
||||
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||
filterByAccounts,
|
||||
filterByAssetClasses,
|
||||
filterByDataSource,
|
||||
filterBySymbol,
|
||||
filterByTags
|
||||
});
|
||||
|
||||
|
@ -7,10 +7,10 @@ import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||
import { getFactor } from '@ghostfolio/api/helper/portfolio.helper';
|
||||
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 { 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 { 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 { FeeRatioInitialInvestment } from '@ghostfolio/api/models/rules/fees/fee-ratio-initial-investment';
|
||||
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 {
|
||||
dataSource,
|
||||
symbol
|
||||
};
|
||||
});
|
||||
|
||||
const symbolProfiles =
|
||||
await this.symbolProfileService.getSymbolProfiles(dataGatheringItems);
|
||||
const symbolProfiles = await this.symbolProfileService.getSymbolProfiles(
|
||||
assetProfileIdentifiers
|
||||
);
|
||||
|
||||
const symbolProfileMap: { [symbol: string]: EnhancedSymbolProfile } = {};
|
||||
for (const symbolProfile of symbolProfiles) {
|
||||
@ -848,7 +849,7 @@ export class PortfolioService {
|
||||
if (isEmpty(historicalData)) {
|
||||
try {
|
||||
historicalData = await this.dataProviderService.getHistoricalRaw({
|
||||
dataGatheringItems: [
|
||||
assetProfileIdentifiers: [
|
||||
{ dataSource: DataSource.YAHOO, symbol: aSymbol }
|
||||
],
|
||||
from: portfolioStart,
|
||||
@ -953,7 +954,7 @@ export class PortfolioService {
|
||||
return !quantity.eq(0);
|
||||
});
|
||||
|
||||
const dataGatheringItems = positions.map(({ dataSource, symbol }) => {
|
||||
const assetProfileIdentifiers = positions.map(({ dataSource, symbol }) => {
|
||||
return {
|
||||
dataSource,
|
||||
symbol
|
||||
@ -961,7 +962,10 @@ export class PortfolioService {
|
||||
});
|
||||
|
||||
const [dataProviderResponses, symbolProfiles] = await Promise.all([
|
||||
this.dataProviderService.getQuotes({ user, items: dataGatheringItems }),
|
||||
this.dataProviderService.getQuotes({
|
||||
user,
|
||||
items: assetProfileIdentifiers
|
||||
}),
|
||||
this.symbolProfileService.getSymbolProfiles(
|
||||
positions.map(({ dataSource, symbol }) => {
|
||||
return { dataSource, symbol };
|
||||
@ -1193,16 +1197,16 @@ export class PortfolioService {
|
||||
userSettings
|
||||
)
|
||||
: undefined,
|
||||
allocationClusterRisk:
|
||||
economicMarketClusterRisk:
|
||||
summary.ordersCount > 0
|
||||
? await this.rulesService.evaluate(
|
||||
[
|
||||
new AllocationClusterRiskDevelopedMarkets(
|
||||
new EconomicMarketClusterRiskDevelopedMarkets(
|
||||
this.exchangeRateDataService,
|
||||
marketsTotalInBaseCurrency,
|
||||
markets.developedMarkets.valueInBaseCurrency
|
||||
),
|
||||
new AllocationClusterRiskEmergingMarkets(
|
||||
new EconomicMarketClusterRiskEmergingMarkets(
|
||||
this.exchangeRateDataService,
|
||||
marketsTotalInBaseCurrency,
|
||||
markets.emergingMarkets.valueInBaseCurrency
|
||||
|
@ -1,8 +1,16 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.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 { 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 { Injectable, Logger } from '@nestjs/common';
|
||||
@ -17,14 +25,17 @@ export class SubscriptionService {
|
||||
|
||||
public constructor(
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly prismaService: PrismaService
|
||||
private readonly prismaService: PrismaService,
|
||||
private readonly propertyService: PropertyService
|
||||
) {
|
||||
this.stripe = new Stripe(
|
||||
this.configurationService.get('STRIPE_SECRET_KEY'),
|
||||
{
|
||||
apiVersion: '2024-04-10'
|
||||
}
|
||||
);
|
||||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||
this.stripe = new Stripe(
|
||||
this.configurationService.get('STRIPE_SECRET_KEY'),
|
||||
{
|
||||
apiVersion: '2024-09-30.acacia'
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async createCheckoutSession({
|
||||
@ -36,6 +47,18 @@ export class SubscriptionService {
|
||||
priceId: string;
|
||||
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 = {
|
||||
cancel_url: `${this.configurationService.get('ROOT_URL')}/${
|
||||
user.Settings?.settings?.language ?? DEFAULT_LANGUAGE_CODE
|
||||
@ -47,6 +70,13 @@ export class SubscriptionService {
|
||||
quantity: 1
|
||||
}
|
||||
],
|
||||
locale:
|
||||
(user.Settings?.settings
|
||||
?.language as Stripe.Checkout.SessionCreateParams.Locale) ??
|
||||
DEFAULT_LANGUAGE_CODE,
|
||||
metadata: subscriptionOffer
|
||||
? { subscriptionOffer: JSON.stringify(subscriptionOffer) }
|
||||
: {},
|
||||
mode: 'payment',
|
||||
payment_method_types: ['card'],
|
||||
success_url: `${this.configurationService.get(
|
||||
@ -73,17 +103,25 @@ export class SubscriptionService {
|
||||
|
||||
public async createSubscription({
|
||||
duration = '1 year',
|
||||
durationExtension,
|
||||
price,
|
||||
userId
|
||||
}: {
|
||||
duration?: StringValue;
|
||||
durationExtension?: StringValue;
|
||||
price: number;
|
||||
userId: string;
|
||||
}) {
|
||||
let expiresAt = addMilliseconds(new Date(), ms(duration));
|
||||
|
||||
if (durationExtension) {
|
||||
expiresAt = addMilliseconds(expiresAt, ms(durationExtension));
|
||||
}
|
||||
|
||||
await this.prismaService.subscription.create({
|
||||
data: {
|
||||
expiresAt,
|
||||
price,
|
||||
expiresAt: addMilliseconds(new Date(), ms(duration)),
|
||||
User: {
|
||||
connect: {
|
||||
id: userId
|
||||
@ -95,10 +133,21 @@ export class SubscriptionService {
|
||||
|
||||
public async createSubscriptionViaStripe(aCheckoutSessionId: string) {
|
||||
try {
|
||||
let durationExtension: StringValue;
|
||||
|
||||
const session =
|
||||
await this.stripe.checkout.sessions.retrieve(aCheckoutSessionId);
|
||||
|
||||
const subscriptionOffer: SubscriptionOffer = JSON.parse(
|
||||
session.metadata.subscriptionOffer ?? '{}'
|
||||
);
|
||||
|
||||
if (subscriptionOffer) {
|
||||
durationExtension = subscriptionOffer.durationExtension;
|
||||
}
|
||||
|
||||
await this.createSubscription({
|
||||
durationExtension,
|
||||
price: session.amount_total / 100,
|
||||
userId: session.client_reference_id
|
||||
});
|
||||
@ -121,7 +170,7 @@ export class SubscriptionService {
|
||||
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'))) {
|
||||
offer = 'renewal-early-bird-2023';
|
||||
|
@ -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 { 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 { LookupResponse } from '@ghostfolio/common/interfaces';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
|
||||
import {
|
||||
@ -21,7 +22,6 @@ import { parseISO } from 'date-fns';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
import { isDate, isEmpty } from 'lodash';
|
||||
|
||||
import { LookupItem } from './interfaces/lookup-item.interface';
|
||||
import { SymbolItem } from './interfaces/symbol-item.interface';
|
||||
import { SymbolService } from './symbol.service';
|
||||
|
||||
@ -41,7 +41,7 @@ export class SymbolController {
|
||||
public async lookupSymbol(
|
||||
@Query('includeIndices') includeIndicesParam = 'false',
|
||||
@Query('query') query = ''
|
||||
): Promise<{ items: LookupItem[] }> {
|
||||
): Promise<LookupResponse> {
|
||||
const includeIndices = includeIndicesParam === 'true';
|
||||
|
||||
try {
|
||||
|
@ -5,13 +5,15 @@ import {
|
||||
} from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
|
||||
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 { Injectable, Logger } from '@nestjs/common';
|
||||
import { format, subDays } from 'date-fns';
|
||||
|
||||
import { LookupItem } from './interfaces/lookup-item.interface';
|
||||
import { SymbolItem } from './interfaces/symbol-item.interface';
|
||||
|
||||
@Injectable()
|
||||
@ -84,7 +86,7 @@ export class SymbolService {
|
||||
|
||||
try {
|
||||
historicalData = await this.dataProviderService.getHistoricalRaw({
|
||||
dataGatheringItems: [{ dataSource, symbol }],
|
||||
assetProfileIdentifiers: [{ dataSource, symbol }],
|
||||
from: date,
|
||||
to: date
|
||||
});
|
||||
@ -104,8 +106,8 @@ export class SymbolService {
|
||||
includeIndices?: boolean;
|
||||
query: string;
|
||||
user: UserWithSettings;
|
||||
}): Promise<{ items: LookupItem[] }> {
|
||||
const results: { items: LookupItem[] } = { items: [] };
|
||||
}): Promise<LookupResponse> {
|
||||
const results: LookupResponse = { items: [] };
|
||||
|
||||
if (!query) {
|
||||
return results;
|
||||
|
@ -64,6 +64,14 @@ export class UpdateUserSettingDto {
|
||||
@IsOptional()
|
||||
'filters.assetClasses'?: string[];
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
'filters.dataSource'?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
'filters.symbol'?: string;
|
||||
|
||||
@IsArray()
|
||||
@IsOptional()
|
||||
'filters.tags'?: string[];
|
||||
|
@ -4,10 +4,10 @@ import { environment } from '@ghostfolio/api/environments/environment';
|
||||
import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event';
|
||||
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 { 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 { 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 { FeeRatioInitialInvestment } from '@ghostfolio/api/models/rules/fees/fee-ratio-initial-investment';
|
||||
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 { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { Prisma, Role, User } from '@prisma/client';
|
||||
import { differenceInDays } from 'date-fns';
|
||||
import { differenceInDays, subDays } from 'date-fns';
|
||||
import { sortBy, without } from 'lodash';
|
||||
|
||||
const crypto = require('crypto');
|
||||
@ -60,6 +60,13 @@ export class UserService {
|
||||
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(
|
||||
{ Account, id, permissions, Settings, subscription }: UserWithSettings,
|
||||
aLocale = locale
|
||||
@ -176,7 +183,9 @@ export class UserService {
|
||||
Settings: Settings as UserWithSettings['Settings'],
|
||||
thirdPartyId,
|
||||
updatedAt,
|
||||
activityCount: Analytics?.activityCount
|
||||
activityCount: Analytics?.activityCount,
|
||||
dataProviderGhostfolioDailyRequests:
|
||||
Analytics?.dataProviderGhostfolioDailyRequests
|
||||
};
|
||||
|
||||
if (user?.Settings) {
|
||||
@ -217,14 +226,14 @@ export class UserService {
|
||||
undefined,
|
||||
{}
|
||||
).getSettings(user.Settings.settings),
|
||||
AllocationClusterRiskDevelopedMarkets:
|
||||
new AllocationClusterRiskDevelopedMarkets(
|
||||
EconomicMarketClusterRiskDevelopedMarkets:
|
||||
new EconomicMarketClusterRiskDevelopedMarkets(
|
||||
undefined,
|
||||
undefined,
|
||||
undefined
|
||||
).getSettings(user.Settings.settings),
|
||||
AllocationClusterRiskEmergingMarkets:
|
||||
new AllocationClusterRiskEmergingMarkets(
|
||||
EconomicMarketClusterRiskEmergingMarkets:
|
||||
new EconomicMarketClusterRiskEmergingMarkets(
|
||||
undefined,
|
||||
undefined,
|
||||
undefined
|
||||
@ -300,6 +309,7 @@ export class UserService {
|
||||
// Reset holdings view mode
|
||||
user.Settings.settings.holdingsViewMode = undefined;
|
||||
} else if (user.subscription?.type === 'Premium') {
|
||||
currentPermissions.push(permissions.enableDataProviderGhostfolio);
|
||||
currentPermissions.push(permissions.reportDataGlitch);
|
||||
|
||||
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({
|
||||
data
|
||||
}: {
|
||||
@ -426,17 +429,6 @@ export class UserService {
|
||||
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> {
|
||||
try {
|
||||
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({
|
||||
emitPortfolioChangedEvent,
|
||||
userId,
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -56,10 +56,22 @@
|
||||
<loc>https://ghostfol.io/de/ressourcen</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</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>
|
||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/de/ressourcen/ratgeber</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/de/ueber-uns</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
@ -176,6 +188,10 @@
|
||||
<loc>https://ghostfol.io/en/blog/2024/09/hacktoberfest-2024</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/blog/2024/11/black-weeks-2024</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/faq</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
|
@ -87,6 +87,10 @@ const locales = {
|
||||
'/en/blog/2024/09/hacktoberfest-2024': {
|
||||
featureGraphicPath: 'assets/images/blog/hacktoberfest-2024.png',
|
||||
title: `Hacktoberfest 2024 - ${title}`
|
||||
},
|
||||
'/en/blog/2024/11/black-weeks-2024': {
|
||||
featureGraphicPath: 'assets/images/blog/black-weeks-2024.jpg',
|
||||
title: `Black Weeks 2024 - ${title}`
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -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 { UserSettings } from '@ghostfolio/common/interfaces';
|
||||
|
||||
export class AllocationClusterRiskDevelopedMarkets extends Rule<Settings> {
|
||||
export class EconomicMarketClusterRiskDevelopedMarkets extends Rule<Settings> {
|
||||
private currentValueInBaseCurrency: number;
|
||||
private developedMarketsValueInBaseCurrency: number;
|
||||
|
||||
@ -13,7 +13,7 @@ export class AllocationClusterRiskDevelopedMarkets extends Rule<Settings> {
|
||||
developedMarketsValueInBaseCurrency: number
|
||||
) {
|
||||
super(exchangeRateDataService, {
|
||||
key: AllocationClusterRiskDevelopedMarkets.name,
|
||||
key: EconomicMarketClusterRiskDevelopedMarkets.name,
|
||||
name: 'Developed Markets'
|
||||
});
|
||||
|
@ -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 { UserSettings } from '@ghostfolio/common/interfaces';
|
||||
|
||||
export class AllocationClusterRiskEmergingMarkets extends Rule<Settings> {
|
||||
export class EconomicMarketClusterRiskEmergingMarkets extends Rule<Settings> {
|
||||
private currentValueInBaseCurrency: number;
|
||||
private emergingMarketsValueInBaseCurrency: number;
|
||||
|
||||
@ -13,7 +13,7 @@ export class AllocationClusterRiskEmergingMarkets extends Rule<Settings> {
|
||||
emergingMarketsValueInBaseCurrency: number
|
||||
) {
|
||||
super(exchangeRateDataService, {
|
||||
key: AllocationClusterRiskEmergingMarkets.name,
|
||||
key: EconomicMarketClusterRiskEmergingMarkets.name,
|
||||
name: 'Emerging Markets'
|
||||
});
|
||||
|
@ -35,6 +35,9 @@ export class ConfigurationService {
|
||||
DATA_SOURCES: json({
|
||||
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_READ_ONLY_MODE: bool({ default: false }),
|
||||
ENABLE_FEATURE_SOCIAL_LOGIN: bool({ default: false }),
|
||||
@ -67,7 +70,7 @@ export class ConfigurationService {
|
||||
REDIS_HOST: str({ default: 'localhost' }),
|
||||
REDIS_PASSWORD: str({ default: '' }),
|
||||
REDIS_PORT: port({ default: 6379 }),
|
||||
REQUEST_TIMEOUT: num({ default: 2000 }),
|
||||
REQUEST_TIMEOUT: num({ default: ms('3 seconds') }),
|
||||
ROOT_URL: url({ default: DEFAULT_ROOT_URL }),
|
||||
STRIPE_PUBLIC_KEY: str({ default: '' }),
|
||||
STRIPE_SECRET_KEY: str({ default: '' }),
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||
import {
|
||||
DATA_GATHERING_QUEUE_PRIORITY_LOW,
|
||||
GATHER_ASSET_PROFILE_PROCESS,
|
||||
@ -9,6 +10,7 @@ import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
|
||||
import { ConfigurationService } from './configuration/configuration.service';
|
||||
import { ExchangeRateDataService } from './exchange-rate-data/exchange-rate-data.service';
|
||||
import { PropertyService } from './property/property.service';
|
||||
import { DataGatheringService } from './queues/data-gathering/data-gathering.service';
|
||||
@ -19,10 +21,12 @@ export class CronService {
|
||||
private static readonly EVERY_SUNDAY_AT_LUNCH_TIME = '0 12 * * 0';
|
||||
|
||||
public constructor(
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly dataGatheringService: DataGatheringService,
|
||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||
private readonly propertyService: PropertyService,
|
||||
private readonly twitterBotService: TwitterBotService
|
||||
private readonly twitterBotService: TwitterBotService,
|
||||
private readonly userService: UserService
|
||||
) {}
|
||||
|
||||
@Cron(CronExpression.EVERY_HOUR)
|
||||
@ -42,6 +46,13 @@ export class CronService {
|
||||
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)
|
||||
public async runEverySundayAtTwelvePm() {
|
||||
if (await this.isDataGatheringEnabled()) {
|
||||
|
@ -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 {
|
||||
DataProviderInterface,
|
||||
@ -12,7 +11,10 @@ import {
|
||||
IDataProviderResponse
|
||||
} from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
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 { DataSource, SymbolProfile } from '@prisma/client';
|
||||
@ -119,9 +121,7 @@ export class AlphaVantageService implements DataProviderInterface {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
public async search({
|
||||
query
|
||||
}: GetSearchParams): Promise<{ items: LookupItem[] }> {
|
||||
public async search({ query }: GetSearchParams): Promise<LookupResponse> {
|
||||
const result = await this.alphaVantage.data.search(query);
|
||||
|
||||
return {
|
||||
|
@ -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 {
|
||||
DataProviderInterface,
|
||||
@ -13,7 +12,11 @@ import {
|
||||
} from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
import { DEFAULT_CURRENCY } from '@ghostfolio/common/config';
|
||||
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 {
|
||||
@ -83,9 +86,9 @@ export class CoinGeckoService implements DataProviderInterface {
|
||||
let message = error;
|
||||
|
||||
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(
|
||||
'REQUEST_TIMEOUT'
|
||||
)}ms`;
|
||||
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('REQUEST_TIMEOUT') / 1000
|
||||
).toFixed(3)} seconds`;
|
||||
}
|
||||
|
||||
Logger.error(message, 'CoinGeckoService');
|
||||
@ -221,9 +224,7 @@ export class CoinGeckoService implements DataProviderInterface {
|
||||
return 'bitcoin';
|
||||
}
|
||||
|
||||
public async search({
|
||||
query
|
||||
}: GetSearchParams): Promise<{ items: LookupItem[] }> {
|
||||
public async search({ query }: GetSearchParams): Promise<LookupResponse> {
|
||||
let items: LookupItem[] = [];
|
||||
|
||||
try {
|
||||
@ -254,9 +255,9 @@ export class CoinGeckoService implements DataProviderInterface {
|
||||
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'
|
||||
)}ms`;
|
||||
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`;
|
||||
}
|
||||
|
||||
Logger.error(message, 'CoinGeckoService');
|
||||
|
@ -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 { 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 { GhostfolioService } from '@ghostfolio/api/services/data-provider/ghostfolio/ghostfolio.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';
|
||||
@ -37,6 +38,7 @@ import { DataProviderService } from './data-provider.service';
|
||||
DataProviderService,
|
||||
EodHistoricalDataService,
|
||||
FinancialModelingPrepService,
|
||||
GhostfolioService,
|
||||
GoogleSheetsService,
|
||||
ManualService,
|
||||
RapidApiService,
|
||||
@ -47,6 +49,7 @@ import { DataProviderService } from './data-provider.service';
|
||||
CoinGeckoService,
|
||||
EodHistoricalDataService,
|
||||
FinancialModelingPrepService,
|
||||
GhostfolioService,
|
||||
GoogleSheetsService,
|
||||
ManualService,
|
||||
RapidApiService,
|
||||
@ -58,6 +61,7 @@ import { DataProviderService } from './data-provider.service';
|
||||
coinGeckoService,
|
||||
eodHistoricalDataService,
|
||||
financialModelingPrepService,
|
||||
ghostfolioService,
|
||||
googleSheetsService,
|
||||
manualService,
|
||||
rapidApiService,
|
||||
@ -67,6 +71,7 @@ import { DataProviderService } from './data-provider.service';
|
||||
coinGeckoService,
|
||||
eodHistoricalDataService,
|
||||
financialModelingPrepService,
|
||||
ghostfolioService,
|
||||
googleSheetsService,
|
||||
manualService,
|
||||
rapidApiService,
|
||||
|
@ -1,5 +1,4 @@
|
||||
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 { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
|
||||
import {
|
||||
@ -12,6 +11,7 @@ import { PropertyService } from '@ghostfolio/api/services/property/property.serv
|
||||
import {
|
||||
DEFAULT_CURRENCY,
|
||||
DERIVED_CURRENCIES,
|
||||
PROPERTY_API_KEY_GHOSTFOLIO,
|
||||
PROPERTY_DATA_SOURCE_MAPPING
|
||||
} from '@ghostfolio/common/config';
|
||||
import {
|
||||
@ -20,7 +20,11 @@ import {
|
||||
getStartOfUtcDate,
|
||||
isDerivedCurrency
|
||||
} 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 { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
@ -88,11 +92,11 @@ export class DataProviderService {
|
||||
|
||||
const promises = [];
|
||||
|
||||
for (const [dataSource, dataGatheringItems] of Object.entries(
|
||||
for (const [dataSource, assetProfileIdentifiers] of Object.entries(
|
||||
itemsGroupedByDataSource
|
||||
)) {
|
||||
const symbols = dataGatheringItems.map((dataGatheringItem) => {
|
||||
return dataGatheringItem.symbol;
|
||||
const symbols = assetProfileIdentifiers.map(({ symbol }) => {
|
||||
return symbol;
|
||||
});
|
||||
|
||||
for (const symbol of symbols) {
|
||||
@ -150,6 +154,24 @@ export class DataProviderService {
|
||||
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({
|
||||
dataSource,
|
||||
from,
|
||||
@ -239,11 +261,11 @@ export class DataProviderService {
|
||||
}
|
||||
|
||||
public async getHistoricalRaw({
|
||||
dataGatheringItems,
|
||||
assetProfileIdentifiers,
|
||||
from,
|
||||
to
|
||||
}: {
|
||||
dataGatheringItems: AssetProfileIdentifier[];
|
||||
assetProfileIdentifiers: AssetProfileIdentifier[];
|
||||
from: Date;
|
||||
to: Date;
|
||||
}): Promise<{
|
||||
@ -252,25 +274,32 @@ export class DataProviderService {
|
||||
for (const { currency, rootCurrency } of DERIVED_CURRENCIES) {
|
||||
if (
|
||||
this.hasCurrency({
|
||||
dataGatheringItems,
|
||||
assetProfileIdentifiers,
|
||||
currency: `${DEFAULT_CURRENCY}${currency}`
|
||||
})
|
||||
) {
|
||||
// Skip derived currency
|
||||
dataGatheringItems = dataGatheringItems.filter(({ symbol }) => {
|
||||
return symbol !== `${DEFAULT_CURRENCY}${currency}`;
|
||||
});
|
||||
assetProfileIdentifiers = assetProfileIdentifiers.filter(
|
||||
({ symbol }) => {
|
||||
return symbol !== `${DEFAULT_CURRENCY}${currency}`;
|
||||
}
|
||||
);
|
||||
// Add root currency
|
||||
dataGatheringItems.push({
|
||||
assetProfileIdentifiers.push({
|
||||
dataSource: this.getDataSourceForExchangeRates(),
|
||||
symbol: `${DEFAULT_CURRENCY}${rootCurrency}`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
dataGatheringItems = uniqWith(dataGatheringItems, (obj1, obj2) => {
|
||||
return obj1.dataSource === obj2.dataSource && obj1.symbol === obj2.symbol;
|
||||
});
|
||||
assetProfileIdentifiers = uniqWith(
|
||||
assetProfileIdentifiers,
|
||||
(obj1, obj2) => {
|
||||
return (
|
||||
obj1.dataSource === obj2.dataSource && obj1.symbol === obj2.symbol
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
const result: {
|
||||
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
|
||||
@ -280,7 +309,7 @@ export class DataProviderService {
|
||||
data: { [date: string]: IDataProviderHistoricalResponse };
|
||||
symbol: string;
|
||||
}>[] = [];
|
||||
for (const { dataSource, symbol } of dataGatheringItems) {
|
||||
for (const { dataSource, symbol } of assetProfileIdentifiers) {
|
||||
const dataProvider = this.getDataProvider(dataSource);
|
||||
if (dataProvider.canHandle(symbol)) {
|
||||
if (symbol === `${DEFAULT_CURRENCY}USX`) {
|
||||
@ -415,7 +444,7 @@ export class DataProviderService {
|
||||
|
||||
const promises: Promise<any>[] = [];
|
||||
|
||||
for (const [dataSource, dataGatheringItems] of Object.entries(
|
||||
for (const [dataSource, assetProfileIdentifiers] of Object.entries(
|
||||
itemsGroupedByDataSource
|
||||
)) {
|
||||
const dataProvider = this.getDataProvider(DataSource[dataSource]);
|
||||
@ -428,7 +457,7 @@ export class DataProviderService {
|
||||
continue;
|
||||
}
|
||||
|
||||
const symbols = dataGatheringItems
|
||||
const symbols = assetProfileIdentifiers
|
||||
.filter(({ symbol }) => {
|
||||
return !isDerivedCurrency(getCurrencyFromSymbol(symbol));
|
||||
})
|
||||
@ -571,19 +600,19 @@ export class DataProviderService {
|
||||
includeIndices?: boolean;
|
||||
query: string;
|
||||
user: UserWithSettings;
|
||||
}): Promise<{ items: LookupItem[] }> {
|
||||
const promises: Promise<{ items: LookupItem[] }>[] = [];
|
||||
}): Promise<LookupResponse> {
|
||||
let lookupItems: LookupItem[] = [];
|
||||
const promises: Promise<LookupResponse>[] = [];
|
||||
|
||||
if (query?.length < 2) {
|
||||
return { items: lookupItems };
|
||||
}
|
||||
|
||||
const dataProviderServices = this.configurationService
|
||||
.get('DATA_SOURCES')
|
||||
.map((dataSource) => {
|
||||
return this.getDataProvider(DataSource[dataSource]);
|
||||
});
|
||||
const dataSources = await this.getDataSources();
|
||||
|
||||
const dataProviderServices = dataSources.map((dataSource) => {
|
||||
return this.getDataProvider(DataSource[dataSource]);
|
||||
});
|
||||
|
||||
for (const dataProviderService of dataProviderServices) {
|
||||
promises.push(
|
||||
@ -596,16 +625,16 @@ export class DataProviderService {
|
||||
|
||||
const searchResults = await Promise.all(promises);
|
||||
|
||||
searchResults.forEach(({ items }) => {
|
||||
for (const { items } of searchResults) {
|
||||
if (items?.length > 0) {
|
||||
lookupItems = lookupItems.concat(items);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const filteredItems = lookupItems
|
||||
.filter((lookupItem) => {
|
||||
.filter(({ currency }) => {
|
||||
// Only allow symbols with supported currency
|
||||
return lookupItem.currency ? true : false;
|
||||
return currency ? true : false;
|
||||
})
|
||||
.sort(({ name: name1 }, { name: name2 }) => {
|
||||
return name1?.toLowerCase().localeCompare(name2?.toLowerCase());
|
||||
@ -631,13 +660,13 @@ export class DataProviderService {
|
||||
}
|
||||
|
||||
private hasCurrency({
|
||||
currency,
|
||||
dataGatheringItems
|
||||
assetProfileIdentifiers,
|
||||
currency
|
||||
}: {
|
||||
assetProfileIdentifiers: AssetProfileIdentifier[];
|
||||
currency: string;
|
||||
dataGatheringItems: AssetProfileIdentifier[];
|
||||
}) {
|
||||
return dataGatheringItems.some(({ dataSource, symbol }) => {
|
||||
return assetProfileIdentifiers.some(({ dataSource, symbol }) => {
|
||||
return (
|
||||
dataSource === this.getDataSourceForExchangeRates() &&
|
||||
symbol === currency
|
||||
|
@ -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 {
|
||||
DataProviderInterface,
|
||||
@ -17,7 +16,11 @@ import {
|
||||
REPLACE_NAME_PARTS
|
||||
} from '@ghostfolio/common/config';
|
||||
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 { Injectable, Logger } from '@nestjs/common';
|
||||
@ -317,9 +320,7 @@ export class EodHistoricalDataService implements DataProviderInterface {
|
||||
return 'AAPL.US';
|
||||
}
|
||||
|
||||
public async search({
|
||||
query
|
||||
}: GetSearchParams): Promise<{ items: LookupItem[] }> {
|
||||
public async search({ query }: GetSearchParams): Promise<LookupResponse> {
|
||||
const searchResult = await this.getSearchResult(query);
|
||||
|
||||
return {
|
||||
@ -409,14 +410,12 @@ export class EodHistoricalDataService implements DataProviderInterface {
|
||||
return name;
|
||||
}
|
||||
|
||||
private async getSearchResult(aQuery: string): Promise<
|
||||
(LookupItem & {
|
||||
private async getSearchResult(aQuery: string) {
|
||||
let searchResult: (LookupItem & {
|
||||
assetClass: AssetClass;
|
||||
assetSubClass: AssetSubClass;
|
||||
isin: string;
|
||||
})[]
|
||||
> {
|
||||
let searchResult = [];
|
||||
})[] = [];
|
||||
|
||||
try {
|
||||
const abortController = new AbortController();
|
||||
@ -455,9 +454,9 @@ export class EodHistoricalDataService implements DataProviderInterface {
|
||||
let message = error;
|
||||
|
||||
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(
|
||||
'REQUEST_TIMEOUT'
|
||||
)}ms`;
|
||||
message = `RequestError: The operation to search for ${aQuery} was aborted because the request to the data provider took more than ${(
|
||||
this.configurationService.get('REQUEST_TIMEOUT') / 1000
|
||||
).toFixed(3)} seconds`;
|
||||
}
|
||||
|
||||
Logger.error(message, 'EodHistoricalDataService');
|
||||
|
@ -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 {
|
||||
DataProviderInterface,
|
||||
@ -13,7 +12,11 @@ import {
|
||||
} from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
import { DEFAULT_CURRENCY } from '@ghostfolio/common/config';
|
||||
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 { DataSource, SymbolProfile } from '@prisma/client';
|
||||
@ -169,9 +172,7 @@ export class FinancialModelingPrepService implements DataProviderInterface {
|
||||
return 'AAPL';
|
||||
}
|
||||
|
||||
public async search({
|
||||
query
|
||||
}: GetSearchParams): Promise<{ items: LookupItem[] }> {
|
||||
public async search({ query }: GetSearchParams): Promise<LookupResponse> {
|
||||
let items: LookupItem[] = [];
|
||||
|
||||
try {
|
||||
@ -203,9 +204,9 @@ export class FinancialModelingPrepService implements DataProviderInterface {
|
||||
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'
|
||||
)}ms`;
|
||||
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`;
|
||||
}
|
||||
|
||||
Logger.error(message, 'FinancialModelingPrepService');
|
||||
|
@ -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}`
|
||||
};
|
||||
}
|
||||
}
|
@ -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 {
|
||||
DataProviderInterface,
|
||||
@ -14,7 +13,10 @@ import {
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
|
||||
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 { DataSource, SymbolProfile } from '@prisma/client';
|
||||
@ -157,9 +159,7 @@ export class GoogleSheetsService implements DataProviderInterface {
|
||||
return 'INDEXSP:.INX';
|
||||
}
|
||||
|
||||
public async search({
|
||||
query
|
||||
}: GetSearchParams): Promise<{ items: LookupItem[] }> {
|
||||
public async search({ query }: GetSearchParams): Promise<LookupResponse> {
|
||||
const items = await this.prismaService.symbolProfile.findMany({
|
||||
select: {
|
||||
assetClass: true,
|
||||
|
@ -1,9 +1,11 @@
|
||||
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
|
||||
import {
|
||||
IDataProviderHistoricalResponse,
|
||||
IDataProviderResponse
|
||||
} 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 { DataSource, SymbolProfile } from '@prisma/client';
|
||||
@ -19,7 +21,13 @@ export interface DataProviderInterface {
|
||||
|
||||
getDataProviderInfo(): DataProviderInfo;
|
||||
|
||||
getDividends({ from, granularity, symbol, to }: GetDividendsParams): Promise<{
|
||||
getDividends({
|
||||
from,
|
||||
granularity,
|
||||
requestTimeout,
|
||||
symbol,
|
||||
to
|
||||
}: GetDividendsParams): Promise<{
|
||||
[date: string]: IDataProviderHistoricalResponse;
|
||||
}>;
|
||||
|
||||
@ -44,10 +52,7 @@ export interface DataProviderInterface {
|
||||
|
||||
getTestSymbol(): string;
|
||||
|
||||
search({
|
||||
includeIndices,
|
||||
query
|
||||
}: GetSearchParams): Promise<{ items: LookupItem[] }>;
|
||||
search({ includeIndices, query }: GetSearchParams): Promise<LookupResponse>;
|
||||
}
|
||||
|
||||
export interface GetDividendsParams {
|
||||
|
@ -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 {
|
||||
DataProviderInterface,
|
||||
@ -20,6 +19,7 @@ import {
|
||||
} from '@ghostfolio/common/helper';
|
||||
import {
|
||||
DataProviderInfo,
|
||||
LookupResponse,
|
||||
ScraperConfiguration
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
|
||||
@ -219,9 +219,7 @@ export class ManualService implements DataProviderInterface {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
public async search({
|
||||
query
|
||||
}: GetSearchParams): Promise<{ items: LookupItem[] }> {
|
||||
public async search({ query }: GetSearchParams): Promise<LookupResponse> {
|
||||
let items = await this.prismaService.symbolProfile.findMany({
|
||||
select: {
|
||||
assetClass: true,
|
||||
|
@ -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 {
|
||||
DataProviderInterface,
|
||||
@ -13,7 +12,10 @@ import {
|
||||
} from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
import { ghostfolioFearAndGreedIndexSymbol } from '@ghostfolio/common/config';
|
||||
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 { DataSource, SymbolProfile } from '@prisma/client';
|
||||
@ -121,7 +123,7 @@ export class RapidApiService implements DataProviderInterface {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
public async search({}: GetSearchParams): Promise<{ items: LookupItem[] }> {
|
||||
public async search({}: GetSearchParams): Promise<LookupResponse> {
|
||||
return { items: [] };
|
||||
}
|
||||
|
||||
@ -157,9 +159,9 @@ export class RapidApiService implements DataProviderInterface {
|
||||
let message = error;
|
||||
|
||||
if (error?.code === 'ABORT_ERR') {
|
||||
message = `RequestError: The operation was aborted because the request to the data provider took more than ${this.configurationService.get(
|
||||
'REQUEST_TIMEOUT'
|
||||
)}ms`;
|
||||
message = `RequestError: The operation was aborted because the request to the data provider took more than ${(
|
||||
this.configurationService.get('REQUEST_TIMEOUT') / 1000
|
||||
).toFixed(3)} seconds`;
|
||||
}
|
||||
|
||||
Logger.error(message, 'RapidApiService');
|
||||
|
@ -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 { YahooFinanceDataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service';
|
||||
import {
|
||||
@ -14,7 +13,11 @@ import {
|
||||
} from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
import { DEFAULT_CURRENCY } from '@ghostfolio/common/config';
|
||||
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 { DataSource, SymbolProfile } from '@prisma/client';
|
||||
@ -224,7 +227,7 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
public async search({
|
||||
includeIndices = false,
|
||||
query
|
||||
}: GetSearchParams): Promise<{ items: LookupItem[] }> {
|
||||
}: GetSearchParams): Promise<LookupResponse> {
|
||||
const items: LookupItem[] = [];
|
||||
|
||||
try {
|
||||
|
@ -15,6 +15,7 @@ export interface Environment extends CleanedEnvAccessors {
|
||||
DATA_SOURCE_EXCHANGE_RATES: string;
|
||||
DATA_SOURCE_IMPORT: string;
|
||||
DATA_SOURCES: string[];
|
||||
DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER: string[];
|
||||
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: boolean;
|
||||
ENABLE_FEATURE_READ_ONLY_MODE: boolean;
|
||||
ENABLE_FEATURE_SOCIAL_LOGIN: boolean;
|
||||
|
@ -1,9 +1,10 @@
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
@Module({
|
||||
providers: [PrismaService],
|
||||
exports: [PrismaService]
|
||||
exports: [PrismaService],
|
||||
providers: [ConfigService, PrismaService]
|
||||
})
|
||||
export class PrismaModule {}
|
||||
|
@ -1,16 +1,38 @@
|
||||
import {
|
||||
Injectable,
|
||||
Logger,
|
||||
LogLevel,
|
||||
OnModuleDestroy,
|
||||
OnModuleInit
|
||||
} from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Prisma, PrismaClient } from '@prisma/client';
|
||||
|
||||
@Injectable()
|
||||
export class PrismaService
|
||||
extends PrismaClient
|
||||
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() {
|
||||
try {
|
||||
await this.$connect();
|
||||
|
@ -89,7 +89,7 @@ export class DataGatheringProcessor {
|
||||
);
|
||||
|
||||
const historicalData = await this.dataProviderService.getHistoricalRaw({
|
||||
dataGatheringItems: [{ dataSource, symbol }],
|
||||
assetProfileIdentifiers: [{ dataSource, symbol }],
|
||||
from: currentDate,
|
||||
to: new Date()
|
||||
});
|
||||
|
@ -122,7 +122,7 @@ export class DataGatheringService {
|
||||
}) {
|
||||
try {
|
||||
const historicalData = await this.dataProviderService.getHistoricalRaw({
|
||||
dataGatheringItems: [{ dataSource, symbol }],
|
||||
assetProfileIdentifiers: [{ dataSource, symbol }],
|
||||
from: date,
|
||||
to: date
|
||||
});
|
||||
|
@ -32,6 +32,15 @@ const routes: Routes = [
|
||||
loadChildren: () =>
|
||||
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',
|
||||
loadChildren: () =>
|
||||
|
@ -33,6 +33,7 @@
|
||||
[deviceType]="deviceType"
|
||||
[hasPermissionToChangeDateRange]="hasPermissionToChangeDateRange"
|
||||
[hasPermissionToChangeFilters]="hasPermissionToChangeFilters"
|
||||
[hasPromotion]="hasPromotion"
|
||||
[hasTabs]="hasTabs"
|
||||
[info]="info"
|
||||
[pageTitle]="pageTitle"
|
||||
|
@ -57,6 +57,7 @@ export class AppComponent implements OnDestroy, OnInit {
|
||||
public hasPermissionToAccessFearAndGreedIndex: boolean;
|
||||
public hasPermissionToChangeDateRange: boolean;
|
||||
public hasPermissionToChangeFilters: boolean;
|
||||
public hasPromotion = false;
|
||||
public hasTabs = false;
|
||||
public info: InfoItem;
|
||||
public pageTitle: string;
|
||||
@ -136,6 +137,10 @@ export class AppComponent implements OnDestroy, OnInit {
|
||||
permissions.enableFearAndGreedIndex
|
||||
);
|
||||
|
||||
this.hasPromotion =
|
||||
!!this.info?.subscriptionOffers?.default?.coupon ||
|
||||
!!this.info?.subscriptionOffers?.default?.durationExtension;
|
||||
|
||||
this.impersonationStorageService
|
||||
.onChangeHasImpersonation()
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
@ -231,6 +236,14 @@ export class AppComponent implements OnDestroy, OnInit {
|
||||
this.hasInfoMessage =
|
||||
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.changeDetectorRef.markForCheck();
|
||||
|
@ -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 {}
|
@ -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 {}
|
@ -3,5 +3,9 @@
|
||||
|
||||
.mat-mdc-dialog-content {
|
||||
max-height: unset;
|
||||
|
||||
gf-line-chart {
|
||||
aspect-ratio: 16/9;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,15 +1,17 @@
|
||||
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 { 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 { validateObjectForForm } from '@ghostfolio/client/util/form.util';
|
||||
import { ghostfolioScraperApiSymbolPrefix } from '@ghostfolio/common/config';
|
||||
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||
import {
|
||||
AdminMarketDataDetails,
|
||||
AssetProfileIdentifier
|
||||
AssetProfileIdentifier,
|
||||
LineChartItem,
|
||||
User
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { translate } from '@ghostfolio/ui/i18n';
|
||||
|
||||
@ -23,7 +25,6 @@ import {
|
||||
} from '@angular/core';
|
||||
import { FormBuilder, FormControl, Validators } from '@angular/forms';
|
||||
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import {
|
||||
AssetClass,
|
||||
AssetSubClass,
|
||||
@ -31,7 +32,6 @@ import {
|
||||
SymbolProfile
|
||||
} from '@prisma/client';
|
||||
import { format } from 'date-fns';
|
||||
import { parse as csvToJson } from 'papaparse';
|
||||
import { EMPTY, Subject } from 'rxjs';
|
||||
import { catchError, takeUntil } from 'rxjs/operators';
|
||||
|
||||
@ -75,11 +75,13 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
|
||||
};
|
||||
public currencies: string[] = [];
|
||||
public ghostfolioScraperApiSymbolPrefix = ghostfolioScraperApiSymbolPrefix;
|
||||
public historicalDataItems: LineChartItem[];
|
||||
public isBenchmark = false;
|
||||
public marketDataDetails: MarketData[] = [];
|
||||
public marketDataItems: MarketData[] = [];
|
||||
public sectors: {
|
||||
[name: string]: { name: string; value: number };
|
||||
};
|
||||
public user: User;
|
||||
|
||||
private static readonly HISTORICAL_DATA_TEMPLATE = `date;marketPrice\n${format(
|
||||
new Date(),
|
||||
@ -96,7 +98,7 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
|
||||
public dialogRef: MatDialogRef<AssetProfileDialog>,
|
||||
private formBuilder: FormBuilder,
|
||||
private notificationService: NotificationService,
|
||||
private snackBar: MatSnackBar
|
||||
private userService: UserService
|
||||
) {}
|
||||
|
||||
public ngOnInit() {
|
||||
@ -109,6 +111,16 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
|
||||
}
|
||||
|
||||
public initialize() {
|
||||
this.historicalDataItems = undefined;
|
||||
|
||||
this.userService.stateChanged
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe((state) => {
|
||||
if (state?.user) {
|
||||
this.user = state.user;
|
||||
}
|
||||
});
|
||||
|
||||
this.adminService
|
||||
.fetchAdminMarketDataBySymbol({
|
||||
dataSource: this.data.dataSource,
|
||||
@ -121,10 +133,19 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
|
||||
this.assetProfileClass = translate(this.assetProfile?.assetClass);
|
||||
this.assetProfileSubClass = translate(this.assetProfile?.assetSubClass);
|
||||
this.countries = {};
|
||||
|
||||
this.isBenchmark = this.benchmarks.some(({ 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 = {};
|
||||
|
||||
if (this.assetProfile?.countries?.length > 0) {
|
||||
@ -200,47 +221,6 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
|
||||
.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) {
|
||||
if (withRefresh) {
|
||||
this.initialize();
|
||||
|
@ -68,50 +68,28 @@
|
||||
</div>
|
||||
|
||||
<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"
|
||||
[currency]="assetProfile?.currency"
|
||||
[dataSource]="data.dataSource"
|
||||
[dateOfFirstActivity]="assetProfile?.dateOfFirstActivity"
|
||||
[locale]="data.locale"
|
||||
[marketData]="marketDataDetails"
|
||||
[marketData]="marketDataItems"
|
||||
[symbol]="data.symbol"
|
||||
[user]="user"
|
||||
(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="col-6 mb-3">
|
||||
<gf-value i18n size="medium" [value]="assetProfile?.symbol"
|
||||
|
@ -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 { GfAssetProfileIconComponent } from '@ghostfolio/client/components/asset-profile-icon/asset-profile-icon.component';
|
||||
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 { GfValueComponent } from '@ghostfolio/ui/value';
|
||||
|
||||
@ -24,9 +25,10 @@ import { AssetProfileDialog } from './asset-profile-dialog.component';
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
GfAdminMarketDataDetailModule,
|
||||
GfAssetProfileIconComponent,
|
||||
GfCurrencySelectorComponent,
|
||||
GfHistoricalMarketDataEditorComponent,
|
||||
GfLineChartComponent,
|
||||
GfPortfolioProportionChartComponent,
|
||||
GfValueComponent,
|
||||
MatButtonModule,
|
||||
|
@ -11,23 +11,63 @@
|
||||
target="_blank"
|
||||
[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
|
||||
<gf-premium-indicator
|
||||
class="d-inline-block ml-1"
|
||||
[enableLink]="false"
|
||||
/>
|
||||
</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 class="w-50">
|
||||
<button
|
||||
color="accent"
|
||||
mat-flat-button
|
||||
(click)="onSetGhostfolioApiKey()"
|
||||
>
|
||||
<ion-icon class="mr-1" name="key-outline" />
|
||||
<span i18n>Set API Key</span>
|
||||
</button>
|
||||
@if (isGhostfolioApiKeyValid === true) {
|
||||
<div class="align-items-center d-flex flex-wrap">
|
||||
<div class="flex-grow-1 mr-3">
|
||||
{{ ghostfolioApiStatus.dailyRequests }}
|
||||
<ng-container i18n>of</ng-container>
|
||||
{{ ghostfolioApiStatus.dailyRequestsMax }}
|
||||
<ng-container i18n>daily requests</ng-container>
|
||||
</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>
|
||||
</mat-card-content>
|
||||
|
@ -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 { 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 {
|
||||
ChangeDetectionStrategy,
|
||||
@ -10,7 +22,7 @@ import {
|
||||
} from '@angular/core';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
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';
|
||||
|
||||
@ -21,6 +33,9 @@ import { GfGhostfolioPremiumApiDialogComponent } from './ghostfolio-premium-api-
|
||||
templateUrl: './admin-settings.component.html'
|
||||
})
|
||||
export class AdminSettingsComponent implements OnDestroy, OnInit {
|
||||
public defaultDateFormat: string;
|
||||
public ghostfolioApiStatus: DataProviderGhostfolioStatusResponse;
|
||||
public isGhostfolioApiKeyValid: boolean;
|
||||
public pricingUrl: string;
|
||||
|
||||
private deviceType: string;
|
||||
@ -28,9 +43,12 @@ export class AdminSettingsComponent implements OnDestroy, OnInit {
|
||||
private user: User;
|
||||
|
||||
public constructor(
|
||||
private adminService: AdminService,
|
||||
private changeDetectorRef: ChangeDetectorRef,
|
||||
private dataService: DataService,
|
||||
private deviceService: DeviceDetectorService,
|
||||
private matDialog: MatDialog,
|
||||
private notificationService: NotificationService,
|
||||
private userService: UserService
|
||||
) {}
|
||||
|
||||
@ -43,29 +61,86 @@ export class AdminSettingsComponent implements OnDestroy, OnInit {
|
||||
if (state?.user) {
|
||||
this.user = state.user;
|
||||
|
||||
this.defaultDateFormat = getDateFormatString(
|
||||
this.user?.settings?.locale
|
||||
);
|
||||
|
||||
const languageCode =
|
||||
this.user?.settings?.language ?? DEFAULT_LANGUAGE_CODE;
|
||||
|
||||
this.pricingUrl =
|
||||
`https://ghostfol.io/${this.user.settings.language}/` +
|
||||
`https://ghostfol.io/${languageCode}/` +
|
||||
$localize`:snake-case:pricing`;
|
||||
|
||||
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() {
|
||||
this.matDialog.open(GfGhostfolioPremiumApiDialogComponent, {
|
||||
autoFocus: false,
|
||||
data: {
|
||||
deviceType: this.deviceType,
|
||||
pricingUrl: this.pricingUrl
|
||||
},
|
||||
height: this.deviceType === 'mobile' ? '98vh' : undefined,
|
||||
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
|
||||
});
|
||||
const dialogRef = this.matDialog.open(
|
||||
GfGhostfolioPremiumApiDialogComponent,
|
||||
{
|
||||
autoFocus: false,
|
||||
data: {
|
||||
deviceType: this.deviceType,
|
||||
pricingUrl: this.pricingUrl
|
||||
},
|
||||
height: this.deviceType === 'mobile' ? '98vh' : undefined,
|
||||
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
|
||||
}
|
||||
);
|
||||
|
||||
dialogRef
|
||||
.afterClosed()
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(() => {
|
||||
this.initialize();
|
||||
});
|
||||
}
|
||||
|
||||
public ngOnDestroy() {
|
||||
this.unsubscribeSubject.next();
|
||||
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();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -6,6 +6,7 @@ import { CommonModule } from '@angular/common';
|
||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatMenuModule } from '@angular/material/menu';
|
||||
import { RouterModule } from '@angular/router';
|
||||
|
||||
import { AdminSettingsComponent } from './admin-settings.component';
|
||||
@ -19,6 +20,7 @@ import { AdminSettingsComponent } from './admin-settings.component';
|
||||
GfPremiumIndicatorComponent,
|
||||
MatButtonModule,
|
||||
MatCardModule,
|
||||
MatMenuModule,
|
||||
RouterModule
|
||||
],
|
||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||
|
@ -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 { CommonModule } from '@angular/common';
|
||||
@ -30,10 +32,28 @@ import { GhostfolioPremiumApiDialogParams } from './interfaces/interfaces';
|
||||
export class GfGhostfolioPremiumApiDialogComponent {
|
||||
public constructor(
|
||||
@Inject(MAT_DIALOG_DATA) public data: GhostfolioPremiumApiDialogParams,
|
||||
private dataService: DataService,
|
||||
public dialogRef: MatDialogRef<GfGhostfolioPremiumApiDialogComponent>
|
||||
) {}
|
||||
|
||||
public onCancel() {
|
||||
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();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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"
|
||||
i18n
|
||||
mat-flat-button
|
||||
>Notify me</a
|
||||
>
|
||||
Notify me
|
||||
</a>
|
||||
<div>
|
||||
<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>
|
||||
|
||||
|
@ -4,11 +4,19 @@ import { AdminService } from '@ghostfolio/client/services/admin.service';
|
||||
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 { DEFAULT_PAGE_SIZE } from '@ghostfolio/common/config';
|
||||
import { getDateFormatString, getEmojiFlag } from '@ghostfolio/common/helper';
|
||||
import { AdminUsers, InfoItem, User } from '@ghostfolio/common/interfaces';
|
||||
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 {
|
||||
differenceInSeconds,
|
||||
@ -24,6 +32,8 @@ import { takeUntil } from 'rxjs/operators';
|
||||
templateUrl: './admin-users.html'
|
||||
})
|
||||
export class AdminUsersComponent implements OnDestroy, OnInit {
|
||||
@ViewChild(MatPaginator) paginator: MatPaginator;
|
||||
|
||||
public dataSource = new MatTableDataSource<AdminUsers['users'][0]>();
|
||||
public defaultDateFormat: string;
|
||||
public displayedColumns: string[] = [];
|
||||
@ -32,6 +42,8 @@ export class AdminUsersComponent implements OnDestroy, OnInit {
|
||||
public hasPermissionToImpersonateAllUsers: boolean;
|
||||
public info: InfoItem;
|
||||
public isLoading = false;
|
||||
public pageSize = DEFAULT_PAGE_SIZE;
|
||||
public totalItems = 0;
|
||||
public user: User;
|
||||
|
||||
private unsubscribeSubject = new Subject<void>();
|
||||
@ -60,6 +72,7 @@ export class AdminUsersComponent implements OnDestroy, OnInit {
|
||||
'accounts',
|
||||
'activities',
|
||||
'engagementPerDay',
|
||||
'dailyApiRequests',
|
||||
'lastRequest',
|
||||
'actions'
|
||||
];
|
||||
@ -136,19 +149,33 @@ export class AdminUsersComponent implements OnDestroy, OnInit {
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
public onChangePage(page: PageEvent) {
|
||||
this.fetchUsers({
|
||||
pageIndex: page.pageIndex
|
||||
});
|
||||
}
|
||||
|
||||
public ngOnDestroy() {
|
||||
this.unsubscribeSubject.next();
|
||||
this.unsubscribeSubject.complete();
|
||||
}
|
||||
|
||||
private fetchUsers() {
|
||||
private fetchUsers({ pageIndex }: { pageIndex: number } = { pageIndex: 0 }) {
|
||||
this.isLoading = true;
|
||||
|
||||
if (pageIndex === 0 && this.paginator) {
|
||||
this.paginator.pageIndex = 0;
|
||||
}
|
||||
|
||||
this.adminService
|
||||
.fetchUsers()
|
||||
.fetchUsers({
|
||||
skip: pageIndex * this.pageSize,
|
||||
take: this.pageSize
|
||||
})
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(({ users }) => {
|
||||
.subscribe(({ count, users }) => {
|
||||
this.dataSource = new MatTableDataSource(users);
|
||||
this.totalItems = count;
|
||||
|
||||
this.isLoading = false;
|
||||
|
||||
|
@ -169,6 +169,27 @@
|
||||
/>
|
||||
</td>
|
||||
</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) {
|
||||
@ -246,6 +267,17 @@
|
||||
></tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<mat-paginator
|
||||
[length]="totalItems"
|
||||
[ngClass]="{
|
||||
'd-none': (isLoading && totalItems === 0) || totalItems <= pageSize
|
||||
}"
|
||||
[pageSize]="pageSize"
|
||||
[showFirstLastButtons]="true"
|
||||
(page)="onChangePage($event)"
|
||||
/>
|
||||
|
||||
@if (isLoading) {
|
||||
<ngx-skeleton-loader
|
||||
animation="pulse"
|
||||
|
@ -5,6 +5,7 @@ import { CommonModule } from '@angular/common';
|
||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatMenuModule } from '@angular/material/menu';
|
||||
import { MatPaginatorModule } from '@angular/material/paginator';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
|
||||
|
||||
@ -19,6 +20,7 @@ import { AdminUsersComponent } from './admin-users.component';
|
||||
GfValueComponent,
|
||||
MatButtonModule,
|
||||
MatMenuModule,
|
||||
MatPaginatorModule,
|
||||
MatTableModule,
|
||||
NgxSkeletonLoaderModule
|
||||
],
|
||||
|
@ -88,15 +88,20 @@
|
||||
<li class="list-inline-item">
|
||||
<a
|
||||
class="d-none d-sm-block"
|
||||
i18n
|
||||
mat-flat-button
|
||||
[ngClass]="{
|
||||
'font-weight-bold': currentRoute === routePricing,
|
||||
'text-decoration-underline': currentRoute === routePricing
|
||||
}"
|
||||
[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 class="list-inline-item">
|
||||
@ -290,12 +295,17 @@
|
||||
) {
|
||||
<a
|
||||
class="d-flex d-sm-none"
|
||||
i18n
|
||||
mat-menu-item
|
||||
[ngClass]="{ 'font-weight-bold': currentRoute === routePricing }"
|
||||
[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
|
||||
class="d-flex d-sm-none"
|
||||
@ -358,15 +368,20 @@
|
||||
<li class="list-inline-item">
|
||||
<a
|
||||
class="d-sm-block"
|
||||
i18n
|
||||
mat-flat-button
|
||||
[ngClass]="{
|
||||
'font-weight-bold': currentRoute === routePricing,
|
||||
'text-decoration-underline': currentRoute === routePricing
|
||||
}"
|
||||
[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>
|
||||
}
|
||||
@if (hasPermissionToAccessFearAndGreedIndex) {
|
||||
|
@ -58,6 +58,7 @@ export class HeaderComponent implements OnChanges {
|
||||
@Input() deviceType: string;
|
||||
@Input() hasPermissionToChangeDateRange: boolean;
|
||||
@Input() hasPermissionToChangeFilters: boolean;
|
||||
@Input() hasPromotion: boolean;
|
||||
@Input() hasTabs: boolean;
|
||||
@Input() info: InfoItem;
|
||||
@Input() pageTitle: string;
|
||||
@ -174,17 +175,17 @@ export class HeaderComponent implements OnChanges {
|
||||
const userSetting: UpdateUserSettingDto = {};
|
||||
|
||||
for (const filter of filters) {
|
||||
let filtersType: string;
|
||||
|
||||
if (filter.type === 'ACCOUNT') {
|
||||
filtersType = 'accounts';
|
||||
userSetting['filters.accounts'] = filter.id ? [filter.id] : null;
|
||||
} 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') {
|
||||
filtersType = 'tags';
|
||||
userSetting['filters.tags'] = filter.id ? [filter.id] : null;
|
||||
}
|
||||
|
||||
userSetting[`filters.${filtersType}`] = filter.id ? [filter.id] : null;
|
||||
}
|
||||
|
||||
this.dataService
|
||||
|
@ -7,23 +7,21 @@
|
||||
<div class="row">
|
||||
<div class="col-lg">
|
||||
<div class="d-flex">
|
||||
@if (user?.settings?.isExperimentalFeatures) {
|
||||
<div class="d-flex">
|
||||
<div class="d-none d-lg-block">
|
||||
<mat-button-toggle-group
|
||||
[formControl]="viewModeFormControl"
|
||||
[hideSingleSelectionIndicator]="true"
|
||||
>
|
||||
<mat-button-toggle i18n-title title="Table" value="TABLE">
|
||||
<ion-icon name="reorder-four-outline" />
|
||||
</mat-button-toggle>
|
||||
<mat-button-toggle i18n-title title="Chart" value="CHART">
|
||||
<ion-icon name="grid-outline" />
|
||||
</mat-button-toggle>
|
||||
</mat-button-toggle-group>
|
||||
</div>
|
||||
<div class="d-flex">
|
||||
<div class="d-none d-lg-block">
|
||||
<mat-button-toggle-group
|
||||
[formControl]="viewModeFormControl"
|
||||
[hideSingleSelectionIndicator]="true"
|
||||
>
|
||||
<mat-button-toggle i18n-title title="Table" value="TABLE">
|
||||
<ion-icon name="reorder-four-outline" />
|
||||
</mat-button-toggle>
|
||||
<mat-button-toggle i18n-title title="Chart" value="CHART">
|
||||
<ion-icon name="grid-outline" />
|
||||
</mat-button-toggle>
|
||||
</mat-button-toggle-group>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="align-items-center d-flex flex-grow-1 justify-content-end">
|
||||
<gf-toggle
|
||||
class="d-none d-lg-block"
|
||||
|
@ -1,76 +1,128 @@
|
||||
<div mat-dialog-title>{{ data.rule.name }}</div>
|
||||
|
||||
<div class="py-3" mat-dialog-content>
|
||||
<div
|
||||
class="w-100"
|
||||
[ngClass]="{ 'd-none': !data.rule.configuration.thresholdMin }"
|
||||
>
|
||||
<h6 class="mb-0">
|
||||
<ng-container i18n>Threshold Min</ng-container>:
|
||||
@if (data.rule.configuration.threshold.unit === '%') {
|
||||
{{ data.settings.thresholdMin | percent: '1.2-2' }}
|
||||
} @else {
|
||||
{{ data.settings.thresholdMin }}
|
||||
}
|
||||
</h6>
|
||||
@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
|
||||
name="thresholdMin"
|
||||
[max]="data.rule.configuration.threshold.max"
|
||||
[min]="data.rule.configuration.threshold.min"
|
||||
[step]="data.rule.configuration.threshold.step"
|
||||
@if (
|
||||
data.rule.configuration.thresholdMin && data.rule.configuration.thresholdMax
|
||||
) {
|
||||
<div class="w-100">
|
||||
<h6 class="mb-0">
|
||||
<ng-container i18n>Threshold range</ng-container>:
|
||||
@if (data.rule.configuration.threshold.unit === '%') {
|
||||
{{ data.settings.thresholdMin | percent: '1.2-2' }}
|
||||
} @else {
|
||||
{{ data.settings.thresholdMin }}
|
||||
}
|
||||
-
|
||||
@if (data.rule.configuration.threshold.unit === '%') {
|
||||
{{ data.settings.thresholdMax | percent: '1.2-2' }}
|
||||
} @else {
|
||||
{{ data.settings.thresholdMax }}
|
||||
}
|
||||
</h6>
|
||||
<div class="align-items-center d-flex w-100">
|
||||
@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"
|
||||
[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" />
|
||||
</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
|
||||
class="w-100"
|
||||
[ngClass]="{ 'd-none': !data.rule.configuration.thresholdMax }"
|
||||
>
|
||||
<h6 class="mb-0">
|
||||
<ng-container i18n>Threshold Max</ng-container>:
|
||||
@if (data.rule.configuration.threshold.unit === '%') {
|
||||
{{ data.settings.thresholdMax | percent: '1.2-2' }}
|
||||
} @else {
|
||||
{{ data.settings.thresholdMax }}
|
||||
}
|
||||
</h6>
|
||||
@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
|
||||
name="thresholdMax"
|
||||
[max]="data.rule.configuration.threshold.max"
|
||||
[min]="data.rule.configuration.threshold.min"
|
||||
[step]="data.rule.configuration.threshold.step"
|
||||
<h6 class="mb-0">
|
||||
<ng-container i18n>Threshold Min</ng-container>:
|
||||
@if (data.rule.configuration.threshold.unit === '%') {
|
||||
{{ data.settings.thresholdMin | percent: '1.2-2' }}
|
||||
} @else {
|
||||
{{ data.settings.thresholdMin }}
|
||||
}
|
||||
</h6>
|
||||
<div class="align-items-center d-flex w-100">
|
||||
@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="thresholdMin"
|
||||
[max]="data.rule.configuration.threshold.max"
|
||||
[min]="data.rule.configuration.threshold.min"
|
||||
[step]="data.rule.configuration.threshold.step"
|
||||
>
|
||||
<input matSliderThumb [(ngModel)]="data.settings.thresholdMin" />
|
||||
</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
|
||||
class="w-100"
|
||||
[ngClass]="{ 'd-none': !data.rule.configuration.thresholdMax }"
|
||||
>
|
||||
<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>
|
||||
<h6 class="mb-0">
|
||||
<ng-container i18n>Threshold Max</ng-container>:
|
||||
@if (data.rule.configuration.threshold.unit === '%') {
|
||||
{{ data.settings.thresholdMax | percent: '1.2-2' }}
|
||||
} @else {
|
||||
{{ data.settings.thresholdMax }}
|
||||
}
|
||||
</h6>
|
||||
<div class="align-items-center d-flex w-100">
|
||||
@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 align="end" mat-dialog-actions>
|
||||
|
@ -1,2 +1,5 @@
|
||||
:host {
|
||||
label {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
@ -16,6 +16,7 @@ import {
|
||||
MatSnackBarRef,
|
||||
TextOnlySnackBar
|
||||
} from '@angular/material/snack-bar';
|
||||
import { StringValue } from 'ms';
|
||||
import { StripeService } from 'ngx-stripe';
|
||||
import { EMPTY, Subject } from 'rxjs';
|
||||
import { catchError, switchMap, takeUntil } from 'rxjs/operators';
|
||||
@ -31,6 +32,7 @@ export class UserAccountMembershipComponent implements OnDestroy {
|
||||
public coupon: number;
|
||||
public couponId: string;
|
||||
public defaultDateFormat: string;
|
||||
public durationExtension: StringValue;
|
||||
public hasPermissionForSubscription: boolean;
|
||||
public hasPermissionToUpdateUserSettings: boolean;
|
||||
public price: number;
|
||||
@ -51,7 +53,7 @@ export class UserAccountMembershipComponent implements OnDestroy {
|
||||
private stripeService: StripeService,
|
||||
private userService: UserService
|
||||
) {
|
||||
const { baseCurrency, globalPermissions, subscriptions } =
|
||||
const { baseCurrency, globalPermissions, subscriptionOffers } =
|
||||
this.dataService.fetchInfo();
|
||||
|
||||
this.baseCurrency = baseCurrency;
|
||||
@ -76,11 +78,18 @@ export class UserAccountMembershipComponent implements OnDestroy {
|
||||
permissions.updateUserSettings
|
||||
);
|
||||
|
||||
this.coupon = subscriptions?.[this.user.subscription.offer]?.coupon;
|
||||
this.coupon =
|
||||
subscriptionOffers?.[this.user.subscription.offer]?.coupon;
|
||||
this.couponId =
|
||||
subscriptions?.[this.user.subscription.offer]?.couponId;
|
||||
this.price = subscriptions?.[this.user.subscription.offer]?.price;
|
||||
this.priceId = subscriptions?.[this.user.subscription.offer]?.priceId;
|
||||
subscriptionOffers?.[this.user.subscription.offer]?.couponId;
|
||||
this.durationExtension =
|
||||
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();
|
||||
}
|
||||
|
@ -34,6 +34,16 @@
|
||||
<span i18n>per year</span>
|
||||
</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">
|
||||
@if (!user?.subscription?.expiresAt) {
|
||||
|
@ -117,7 +117,7 @@ export class UserAccountSettingsComponent implements OnDestroy, OnInit {
|
||||
}
|
||||
|
||||
public isCommunityLanguage() {
|
||||
return !(this.language === 'de' || this.language === 'en');
|
||||
return !['de', 'en'].includes(this.language);
|
||||
}
|
||||
|
||||
public onChangeUserSetting(aKey: string, aValue: string) {
|
||||
|
@ -2,6 +2,7 @@ import { ImpersonationStorageService } from '@ghostfolio/client/services/imperso
|
||||
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
|
||||
import {
|
||||
HEADER_KEY_IMPERSONATION,
|
||||
HEADER_KEY_SKIP_INTERCEPTOR,
|
||||
HEADER_KEY_TIMEZONE,
|
||||
HEADER_KEY_TOKEN
|
||||
} from '@ghostfolio/common/config';
|
||||
@ -27,6 +28,16 @@ export class AuthInterceptor implements HttpInterceptor {
|
||||
next: HttpHandler
|
||||
): Observable<HttpEvent<any>> {
|
||||
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(
|
||||
HEADER_KEY_TIMEZONE,
|
||||
Intl?.DateTimeFormat().resolvedOptions().timeZone
|
||||
|
@ -103,7 +103,7 @@ export class HttpResponseInterceptor implements HttpInterceptor {
|
||||
} else if (error.status === StatusCodes.UNAUTHORIZED) {
|
||||
if (this.webAuthnService.isEnabled()) {
|
||||
this.router.navigate(['/webauthn']);
|
||||
} else {
|
||||
} else if (!error.url.includes('/data-providers/ghostfolio/status')) {
|
||||
this.tokenStorageService.signOut();
|
||||
}
|
||||
}
|
||||
|
131
apps/client/src/app/pages/api/api-page.component.ts
Normal file
131
apps/client/src/app/pages/api/api-page.component.ts
Normal 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)
|
||||
);
|
||||
}
|
||||
}
|
62
apps/client/src/app/pages/api/api-page.html
Normal file
62
apps/client/src/app/pages/api/api-page.html
Normal 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>
|
3
apps/client/src/app/pages/api/api-page.scss
Normal file
3
apps/client/src/app/pages/api/api-page.scss
Normal file
@ -0,0 +1,3 @@
|
||||
:host {
|
||||
display: block;
|
||||
}
|
@ -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`];
|
||||
}
|
@ -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>
|
||||
Don’t 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>
|
@ -165,15 +165,6 @@ const routes: Routes = [
|
||||
).then((c) => c.Hacktoberfest2023PageComponent),
|
||||
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],
|
||||
path: '2023/11/black-week-2023',
|
||||
@ -183,6 +174,15 @@ const routes: Routes = [
|
||||
),
|
||||
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],
|
||||
path: '2024/09/hacktoberfest-2024',
|
||||
@ -191,6 +191,15 @@ const routes: Routes = [
|
||||
'./2024/09/hacktoberfest-2024/hacktoberfest-2024-page.component'
|
||||
).then((c) => c.Hacktoberfest2024PageComponent),
|
||||
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'
|
||||
}
|
||||
];
|
||||
|
||||
|
@ -8,6 +8,32 @@
|
||||
finance</small
|
||||
>
|
||||
</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-content class="p-0">
|
||||
<div class="container p-0">
|
||||
|
@ -7,7 +7,7 @@ import { MAX_TOP_HOLDINGS, UNKNOWN_KEY } from '@ghostfolio/common/config';
|
||||
import { prettifySymbol } from '@ghostfolio/common/helper';
|
||||
import {
|
||||
AssetProfileIdentifier,
|
||||
Holding,
|
||||
HoldingWithParents,
|
||||
PortfolioDetails,
|
||||
PortfolioPosition,
|
||||
User
|
||||
@ -86,7 +86,7 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
|
||||
value: number;
|
||||
};
|
||||
};
|
||||
public topHoldings: Holding[];
|
||||
public topHoldings: HoldingWithParents[];
|
||||
public topHoldingsMap: {
|
||||
[name: string]: { name: string; value: number };
|
||||
};
|
||||
@ -490,6 +490,36 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
|
||||
name,
|
||||
allocationInPercentage:
|
||||
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
|
||||
};
|
||||
})
|
||||
|
@ -347,6 +347,7 @@
|
||||
[locale]="user?.settings?.locale"
|
||||
[pageSize]="10"
|
||||
[topHoldings]="topHoldings"
|
||||
(holdingClicked)="onSymbolChartClicked($event)"
|
||||
/>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
|
@ -2,7 +2,7 @@ import { ToggleComponent } from '@ghostfolio/client/components/toggle/toggle.com
|
||||
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 { MatPaginator, PageEvent } from '@angular/material/paginator';
|
||||
import { PageEvent } from '@angular/material/paginator';
|
||||
import {
|
||||
HistoricalDataItem,
|
||||
InvestmentItem,
|
||||
|
@ -10,7 +10,7 @@ const routes: Routes = [
|
||||
canActivate: [AuthGuard],
|
||||
component: FirePageComponent,
|
||||
path: '',
|
||||
title: $localize`FIRE`
|
||||
title: 'FIRE'
|
||||
}
|
||||
];
|
||||
|
||||
|
@ -1,12 +1,7 @@
|
||||
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 {
|
||||
PortfolioReport,
|
||||
PortfolioReportRule,
|
||||
User
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { User } from '@ghostfolio/common/interfaces';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
|
||||
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
||||
@ -21,18 +16,11 @@ import { takeUntil } from 'rxjs/operators';
|
||||
templateUrl: './fire-page.html'
|
||||
})
|
||||
export class FirePageComponent implements OnDestroy, OnInit {
|
||||
public accountClusterRiskRules: PortfolioReportRule[];
|
||||
public allocationClusterRiskRules: PortfolioReportRule[];
|
||||
public currencyClusterRiskRules: PortfolioReportRule[];
|
||||
public deviceType: string;
|
||||
public emergencyFundRules: PortfolioReportRule[];
|
||||
public feeRules: PortfolioReportRule[];
|
||||
public fireWealth: Big;
|
||||
public hasImpersonationId: boolean;
|
||||
public hasPermissionToUpdateUserSettings: boolean;
|
||||
public inactiveRules: PortfolioReportRule[];
|
||||
public isLoading = false;
|
||||
public isLoadingPortfolioReport = false;
|
||||
public user: User;
|
||||
public withdrawalRatePerMonth: Big;
|
||||
public withdrawalRatePerYear: Big;
|
||||
@ -95,8 +83,6 @@ export class FirePageComponent implements OnDestroy, OnInit {
|
||||
this.changeDetectorRef.markForCheck();
|
||||
}
|
||||
});
|
||||
|
||||
this.initializePortfolioReport();
|
||||
}
|
||||
|
||||
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) {
|
||||
this.dataService
|
||||
.putUserSetting({ savingsRate })
|
||||
@ -187,66 +158,4 @@ export class FirePageComponent implements OnDestroy, OnInit {
|
||||
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.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;
|
||||
}
|
||||
}
|
||||
|
@ -101,133 +101,3 @@
|
||||
}
|
||||
</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>
|
||||
|
@ -1,4 +1,3 @@
|
||||
import { GfRulesModule } from '@ghostfolio/client/components/rules/rules.module';
|
||||
import { GfFireCalculatorComponent } from '@ghostfolio/ui/fire-calculator';
|
||||
import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator';
|
||||
import { GfValueComponent } from '@ghostfolio/ui/value';
|
||||
@ -17,7 +16,6 @@ import { FirePageComponent } from './fire-page.component';
|
||||
FirePageRoutingModule,
|
||||
GfFireCalculatorComponent,
|
||||
GfPremiumIndicatorComponent,
|
||||
GfRulesModule,
|
||||
GfValueComponent,
|
||||
NgxSkeletonLoaderModule
|
||||
],
|
||||
|
@ -34,6 +34,11 @@ const routes: Routes = [
|
||||
path: 'fire',
|
||||
loadChildren: () =>
|
||||
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,
|
||||
|
@ -46,8 +46,13 @@ export class PortfolioPageComponent implements OnDestroy, OnInit {
|
||||
},
|
||||
{
|
||||
iconName: 'calculator-outline',
|
||||
label: 'FIRE / X-ray',
|
||||
label: 'FIRE ',
|
||||
path: ['/portfolio', 'fire']
|
||||
},
|
||||
{
|
||||
iconName: 'scan-outline',
|
||||
label: 'X-ray',
|
||||
path: ['/portfolio', 'x-ray']
|
||||
}
|
||||
];
|
||||
this.user = state.user;
|
||||
|
@ -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 {}
|
@ -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>
|
@ -0,0 +1,3 @@
|
||||
:host {
|
||||
display: block;
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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 {}
|
@ -6,6 +6,7 @@ import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import { translate } from '@ghostfolio/ui/i18n';
|
||||
|
||||
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
||||
import { StringValue } from 'ms';
|
||||
import { StripeService } from 'ngx-stripe';
|
||||
import { Subject } from 'rxjs';
|
||||
import { catchError, switchMap, takeUntil } from 'rxjs/operators';
|
||||
@ -20,6 +21,7 @@ export class PricingPageComponent implements OnDestroy, OnInit {
|
||||
public baseCurrency: string;
|
||||
public coupon: number;
|
||||
public couponId: string;
|
||||
public durationExtension: StringValue;
|
||||
public hasPermissionToUpdateUserSettings: boolean;
|
||||
public importAndExportTooltipBasic = translate(
|
||||
'DATA_IMPORT_AND_EXPORT_TOOLTIP_BASIC'
|
||||
@ -51,11 +53,12 @@ export class PricingPageComponent implements OnDestroy, OnInit {
|
||||
) {}
|
||||
|
||||
public ngOnInit() {
|
||||
const { baseCurrency, subscriptions } = this.dataService.fetchInfo();
|
||||
const { baseCurrency, subscriptionOffers } = this.dataService.fetchInfo();
|
||||
this.baseCurrency = baseCurrency;
|
||||
|
||||
this.coupon = subscriptions?.default?.coupon;
|
||||
this.price = subscriptions?.default?.price;
|
||||
this.coupon = subscriptionOffers?.default?.coupon;
|
||||
this.durationExtension = subscriptionOffers?.default?.durationExtension;
|
||||
this.price = subscriptionOffers?.default?.price;
|
||||
|
||||
this.userService.stateChanged
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
@ -68,11 +71,18 @@ export class PricingPageComponent implements OnDestroy, OnInit {
|
||||
permissions.updateUserSettings
|
||||
);
|
||||
|
||||
this.coupon = subscriptions?.[this.user?.subscription?.offer]?.coupon;
|
||||
this.coupon =
|
||||
subscriptionOffers?.[this.user?.subscription?.offer]?.coupon;
|
||||
this.couponId =
|
||||
subscriptions?.[this.user.subscription.offer]?.couponId;
|
||||
this.price = subscriptions?.[this.user?.subscription?.offer]?.price;
|
||||
this.priceId = subscriptions?.[this.user.subscription.offer]?.priceId;
|
||||
subscriptionOffers?.[this.user.subscription.offer]?.couponId;
|
||||
this.durationExtension =
|
||||
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();
|
||||
}
|
||||
|
@ -101,6 +101,11 @@
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
@if (durationExtension) {
|
||||
<div class="d-none d-lg-block hidden mt-3">
|
||||
<div class="badge p-3 w-100"> </div>
|
||||
</div>
|
||||
}
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
</div>
|
||||
@ -159,6 +164,11 @@
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
@if (durationExtension) {
|
||||
<div class="d-none d-lg-block hidden mt-3">
|
||||
<div class="badge p-3 w-100"> </div>
|
||||
</div>
|
||||
}
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
</div>
|
||||
@ -289,6 +299,14 @@
|
||||
</p>
|
||||
</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>
|
||||
</div>
|
||||
|
@ -35,9 +35,9 @@ export class GfProductPageComponent implements OnInit {
|
||||
) {}
|
||||
|
||||
public ngOnInit() {
|
||||
const { subscriptions } = this.dataService.fetchInfo();
|
||||
const { subscriptionOffers } = this.dataService.fetchInfo();
|
||||
|
||||
this.price = subscriptions?.default?.price;
|
||||
this.price = subscriptionOffers?.default?.price;
|
||||
|
||||
this.product1 = {
|
||||
founded: 2021,
|
||||
|
@ -5,6 +5,12 @@ import { UpdatePlatformDto } from '@ghostfolio/api/app/platform/update-platform.
|
||||
import { CreateTagDto } from '@ghostfolio/api/app/tag/create-tag.dto';
|
||||
import { UpdateTagDto } from '@ghostfolio/api/app/tag/update-tag.dto';
|
||||
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 {
|
||||
AssetProfileIdentifier,
|
||||
@ -13,6 +19,7 @@ import {
|
||||
AdminMarketData,
|
||||
AdminMarketDataDetails,
|
||||
AdminUsers,
|
||||
DataProviderGhostfolioStatusResponse,
|
||||
EnhancedSymbolProfile,
|
||||
Filter
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
@ -23,8 +30,9 @@ import { SortDirection } from '@angular/material/sort';
|
||||
import { DataSource, MarketData, Platform, Tag } from '@prisma/client';
|
||||
import { JobStatus } from 'bull';
|
||||
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';
|
||||
|
||||
@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[] }) {
|
||||
let params = new HttpParams();
|
||||
|
||||
@ -156,8 +180,19 @@ export class AdminService {
|
||||
return this.http.get<Tag[]>('/api/v1/tag');
|
||||
}
|
||||
|
||||
public fetchUsers() {
|
||||
return this.http.get<AdminUsers>('/api/v1/admin/user');
|
||||
public fetchUsers({
|
||||
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() {
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user