diff --git a/.env b/.env index e96c8b6b2..44c1ec3a5 100644 --- a/.env +++ b/.env @@ -3,14 +3,15 @@ COMPOSE_PROJECT_NAME=ghostfolio-development # CACHE REDIS_HOST=localhost REDIS_PORT=6379 +REDIS_PASSWORD= # POSTGRES POSTGRES_DB=ghostfolio-db POSTGRES_USER=user -POSTGRES_PASSWORD=password +POSTGRES_PASSWORD= -ACCESS_TOKEN_SALT=GHOSTFOLIO +ACCESS_TOKEN_SALT= ALPHA_VANTAGE_API_KEY= DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer -JWT_SECRET_KEY=123456 +JWT_SECRET_KEY= PORT=3333 diff --git a/CHANGELOG.md b/CHANGELOG.md index d0b90eda1..6a986f88c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,87 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased + +### Added + +- Added the user id to the account page + +### Changed + +- Simplified the features page +- Restructured the _FIRE_ section +- Upgraded `@simplewebauthn/browser` and `@simplewebauthn/server` from version `4.1.0` to `5.2.1` + +### Fixed + +- Fixed the `docker-compose` files to resolve variables correctly + +## 1.155.0 - 29.05.2022 + +### Added + +- Added `EOD_HISTORICAL_DATA` as a new data source type + +### Changed + +- Exposed the environment variable `REDIS_PASSWORD` + +### Fixed + +- Fixed the empty state of the portfolio proportion chart component (with 2 levels) + +### Todo + +- Apply data migration (`yarn database:migrate`) + +## 1.154.0 - 28.05.2022 + +### Added + +- Added a vertical hover line to inspect data points in the line chart component + +### Changed + +- Improved the tooltips of the chart components (content and style) +- Simplified the pricing page +- Improved the rounding numbers in the twitter bot service +- Removed the dependency `round-to` + +## 1.153.0 - 27.05.2022 + +### Added + +- Extended the benchmarks of the markets overview by the current market condition (bear and bull market) +- Extended the twitter bot service by benchmarks +- Added value redaction for the impersonation mode in the API response as an interceptor + +### Changed + +- Changed the twitter bot service to rest on the weekend +- Upgraded `prisma` from version `3.12.0` to `3.14.0` + +### Fixed + +- Fixed a styling issue in the benchmark component on mobile + +## 1.152.0 - 26.05.2022 + +### Added + +- Added the _Ghostfolio_ trailer to the landing page +- Extended the markets overview by benchmarks (current change to the all time high) + +## 1.151.0 - 24.05.2022 + +### Added + +- Added support to set the base currency as an environment variable (`BASE_CURRENCY`) + +### Fixed + +- Fixed an issue with the missing conversion of countries in the symbol profile overrides + ## 1.150.0 - 21.05.2022 ### Changed diff --git a/README.md b/README.md index fd737530f..545d9d6b3 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@

Ghostfolio

- Open Source Wealth Management Software made for Humans + Open Source Wealth Management Software

Live Demo | Ghostfolio Premium | Blog | Slack | Twitter @@ -26,8 +26,9 @@ **Ghostfolio** is an open source wealth management software built with web technology. The application empowers busy people to keep track of stocks, ETFs or cryptocurrencies and make solid, data-driven investment decisions. -

- +
+ +
## Ghostfolio Premium @@ -47,7 +48,7 @@ Ghostfolio is for you if you are... - ๐Ÿง˜ into minimalism - ๐Ÿงบ caring about diversifying your financial resources - ๐Ÿ†“ interested in financial independence -- ๐Ÿ™… saying no to spreadsheets in 2021 +- ๐Ÿ™… saying no to spreadsheets in 2022 - ๐Ÿ˜Ž still reading this list ## Features @@ -62,6 +63,10 @@ Ghostfolio is for you if you are... - โœ… Zen Mode - โœ… Mobile-first design +
+ +
+ ## Technology Stack Ghostfolio is a modern web application written in [TypeScript](https://www.typescriptlang.org) and organized as an [Nx](https://nx.dev) workspace. @@ -86,7 +91,7 @@ The frontend is built with [Angular](https://angular.io) and uses [Angular Mater Run the following command to start the Docker images from [Docker Hub](https://hub.docker.com/r/ghostfolio/ghostfolio): ```bash -docker-compose -f docker/docker-compose.yml up -d +docker-compose --env-file ./.env -f docker/docker-compose.yml up -d ``` #### Setup Database @@ -94,7 +99,7 @@ docker-compose -f docker/docker-compose.yml up -d Run the following command to setup the database once Ghostfolio is running: ```bash -docker-compose -f docker/docker-compose.yml exec ghostfolio yarn database:setup +docker-compose --env-file ./.env -f docker/docker-compose.yml exec ghostfolio yarn database:setup ``` ### b. Build and run environment @@ -102,8 +107,8 @@ docker-compose -f docker/docker-compose.yml exec ghostfolio yarn database:setup Run the following commands to build and start the Docker images: ```bash -docker-compose -f docker/docker-compose.build.yml build -docker-compose -f docker/docker-compose.build.yml up -d +docker-compose --env-file ./.env -f docker/docker-compose.build.yml build +docker-compose --env-file ./.env -f docker/docker-compose.build.yml up -d ``` #### Setup Database @@ -111,7 +116,7 @@ docker-compose -f docker/docker-compose.build.yml up -d Run the following command to setup the database once Ghostfolio is running: ```bash -docker-compose -f docker/docker-compose.build.yml exec ghostfolio yarn database:setup +docker-compose --env-file ./.env -f docker/docker-compose.build.yml exec ghostfolio yarn database:setup ``` ### Fetch Historical Data @@ -125,8 +130,12 @@ Open http://localhost:3333 in your browser and accomplish these steps: ### Upgrade Version 1. Increase the version of the `ghostfolio/ghostfolio` Docker image in `docker/docker-compose.yml` -1. Run the following command to start the new Docker image: `docker-compose -f docker/docker-compose.yml up -d` -1. Then, run the following command to keep your database schema in sync: `docker-compose -f docker/docker-compose.yml exec ghostfolio yarn database:migrate` +1. Run the following command to start the new Docker image: `docker-compose --env-file ./.env -f docker/docker-compose.yml up -d` +1. Then, run the following command to keep your database schema in sync: `docker-compose --env-file ./.env -f docker/docker-compose.yml exec ghostfolio yarn database:migrate` + +## Run with _Unraid_ (self-hosting) + +Please follow the instructions of the Ghostfolio [Unraid Community App](https://unraid.net/community/apps?q=ghostfolio). ## Development @@ -140,7 +149,7 @@ Open http://localhost:3333 in your browser and accomplish these steps: ### Setup 1. Run `yarn install` -1. Run `docker-compose -f docker/docker-compose.dev.yml up -d` to start [PostgreSQL](https://www.postgresql.org) and [Redis](https://redis.io) +1. Run `docker-compose --env-file ./.env -f docker/docker-compose.dev.yml up -d` to start [PostgreSQL](https://www.postgresql.org) and [Redis](https://redis.io) 1. Run `yarn database:setup` to initialize the database schema and populate your database with (example) data 1. Start the server and the client (see [_Development_](#Development)) 1. Create a new user via _Get Started_ (this first user will get the role `ADMIN`) diff --git a/apps/api/src/app/admin/admin.service.ts b/apps/api/src/app/admin/admin.service.ts index 40a15afc2..3b2392bfc 100644 --- a/apps/api/src/app/admin/admin.service.ts +++ b/apps/api/src/app/admin/admin.service.ts @@ -6,7 +6,7 @@ import { MarketDataService } from '@ghostfolio/api/services/market-data.service' import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service'; -import { PROPERTY_CURRENCIES, baseCurrency } from '@ghostfolio/common/config'; +import { PROPERTY_CURRENCIES } from '@ghostfolio/common/config'; import { AdminData, AdminMarketData, @@ -20,6 +20,8 @@ import { differenceInDays } from 'date-fns'; @Injectable() export class AdminService { + private baseCurrency: string; + public constructor( private readonly configurationService: ConfigurationService, private readonly dataGatheringService: DataGatheringService, @@ -29,7 +31,9 @@ export class AdminService { private readonly propertyService: PropertyService, private readonly subscriptionService: SubscriptionService, private readonly symbolProfileService: SymbolProfileService - ) {} + ) { + this.baseCurrency = this.configurationService.get('BASE_CURRENCY'); + } public async deleteProfileData({ dataSource, symbol }: UniqueAsset) { await this.marketDataService.deleteMany({ dataSource, symbol }); @@ -43,15 +47,15 @@ export class AdminService { exchangeRates: this.exchangeRateDataService .getCurrencies() .filter((currency) => { - return currency !== baseCurrency; + return currency !== this.baseCurrency; }) .map((currency) => { return { - label1: baseCurrency, + label1: this.baseCurrency, label2: currency, value: this.exchangeRateDataService.toCurrency( 1, - baseCurrency, + this.baseCurrency, currency ) }; diff --git a/apps/api/src/app/app.module.ts b/apps/api/src/app/app.module.ts index f3b85fc9b..f1fc27976 100644 --- a/apps/api/src/app/app.module.ts +++ b/apps/api/src/app/app.module.ts @@ -20,6 +20,7 @@ import { AccountModule } from './account/account.module'; import { AdminModule } from './admin/admin.module'; import { AppController } from './app.controller'; import { AuthModule } from './auth/auth.module'; +import { BenchmarkModule } from './benchmark/benchmark.module'; import { CacheModule } from './cache/cache.module'; import { ExportModule } from './export/export.module'; import { ImportModule } from './import/import.module'; @@ -37,10 +38,12 @@ import { UserModule } from './user/user.module'; AccountModule, AuthDeviceModule, AuthModule, + BenchmarkModule, BullModule.forRoot({ redis: { host: process.env.REDIS_HOST, - port: parseInt(process.env.REDIS_PORT, 10) + port: parseInt(process.env.REDIS_PORT, 10), + password: process.env.REDIS_PASSWORD } }), CacheModule, diff --git a/apps/api/src/app/benchmark/benchmark.controller.ts b/apps/api/src/app/benchmark/benchmark.controller.ts new file mode 100644 index 000000000..86bbf9d36 --- /dev/null +++ b/apps/api/src/app/benchmark/benchmark.controller.ts @@ -0,0 +1,32 @@ +import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor'; +import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor'; +import { PropertyService } from '@ghostfolio/api/services/property/property.service'; +import { PROPERTY_BENCHMARKS } from '@ghostfolio/common/config'; +import { BenchmarkResponse, UniqueAsset } from '@ghostfolio/common/interfaces'; +import { Controller, Get, UseGuards, UseInterceptors } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; + +import { BenchmarkService } from './benchmark.service'; + +@Controller('benchmark') +export class BenchmarkController { + public constructor( + private readonly benchmarkService: BenchmarkService, + private readonly propertyService: PropertyService + ) {} + + @Get() + @UseGuards(AuthGuard('jwt')) + @UseInterceptors(TransformDataSourceInRequestInterceptor) + @UseInterceptors(TransformDataSourceInResponseInterceptor) + public async getBenchmark(): Promise { + const benchmarkAssets: UniqueAsset[] = + ((await this.propertyService.getByKey( + PROPERTY_BENCHMARKS + )) as UniqueAsset[]) ?? []; + + return { + benchmarks: await this.benchmarkService.getBenchmarks(benchmarkAssets) + }; + } +} diff --git a/apps/api/src/app/benchmark/benchmark.module.ts b/apps/api/src/app/benchmark/benchmark.module.ts new file mode 100644 index 000000000..fa26a3afd --- /dev/null +++ b/apps/api/src/app/benchmark/benchmark.module.ts @@ -0,0 +1,25 @@ +import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; +import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module'; +import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; +import { MarketDataModule } from '@ghostfolio/api/services/market-data.module'; +import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; +import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module'; +import { Module } from '@nestjs/common'; + +import { BenchmarkController } from './benchmark.controller'; +import { BenchmarkService } from './benchmark.service'; + +@Module({ + controllers: [BenchmarkController], + exports: [BenchmarkService], + imports: [ + ConfigurationModule, + DataProviderModule, + MarketDataModule, + PropertyModule, + RedisCacheModule, + SymbolProfileModule + ], + providers: [BenchmarkService] +}) +export class BenchmarkModule {} diff --git a/apps/api/src/app/benchmark/benchmark.service.ts b/apps/api/src/app/benchmark/benchmark.service.ts new file mode 100644 index 000000000..f7a10d8e5 --- /dev/null +++ b/apps/api/src/app/benchmark/benchmark.service.ts @@ -0,0 +1,84 @@ +import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; +import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; +import { MarketDataService } from '@ghostfolio/api/services/market-data.service'; +import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service'; +import { BenchmarkResponse, UniqueAsset } from '@ghostfolio/common/interfaces'; +import { Injectable } from '@nestjs/common'; +import Big from 'big.js'; + +@Injectable() +export class BenchmarkService { + private readonly CACHE_KEY_BENCHMARKS = 'BENCHMARKS'; + + public constructor( + private readonly dataProviderService: DataProviderService, + private readonly marketDataService: MarketDataService, + private readonly redisCacheService: RedisCacheService, + private readonly symbolProfileService: SymbolProfileService + ) {} + + public async getBenchmarks( + benchmarkAssets: UniqueAsset[] + ): Promise { + let benchmarks: BenchmarkResponse['benchmarks']; + + try { + benchmarks = JSON.parse( + await this.redisCacheService.get(this.CACHE_KEY_BENCHMARKS) + ); + + if (benchmarks) { + return benchmarks; + } + } catch {} + + const promises: Promise[] = []; + + const [quotes, assetProfiles] = await Promise.all([ + this.dataProviderService.getQuotes(benchmarkAssets), + this.symbolProfileService.getSymbolProfiles(benchmarkAssets) + ]); + + for (const benchmarkAsset of benchmarkAssets) { + promises.push(this.marketDataService.getMax(benchmarkAsset)); + } + + const allTimeHighs = await Promise.all(promises); + + benchmarks = allTimeHighs.map((allTimeHigh, index) => { + const { marketPrice } = quotes[benchmarkAssets[index].symbol]; + + const performancePercentFromAllTimeHigh = new Big(marketPrice) + .div(allTimeHigh) + .minus(1); + + return { + marketCondition: this.getMarketCondition( + performancePercentFromAllTimeHigh + ), + name: assetProfiles.find(({ dataSource, symbol }) => { + return ( + dataSource === benchmarkAssets[index].dataSource && + symbol === benchmarkAssets[index].symbol + ); + })?.name, + performances: { + allTimeHigh: { + performancePercent: performancePercentFromAllTimeHigh.toNumber() + } + } + }; + }); + + await this.redisCacheService.set( + this.CACHE_KEY_BENCHMARKS, + JSON.stringify(benchmarks) + ); + + return benchmarks; + } + + private getMarketCondition(aPerformanceInPercent: Big) { + return aPerformanceInPercent.lte(-0.2) ? 'BEAR_MARKET' : 'NEUTRAL_MARKET'; + } +} diff --git a/apps/api/src/app/info/info.service.ts b/apps/api/src/app/info/info.service.ts index 032b05f27..440f90aa1 100644 --- a/apps/api/src/app/info/info.service.ts +++ b/apps/api/src/app/info/info.service.ts @@ -103,6 +103,7 @@ export class InfoService { isReadOnlyMode, platforms, systemMessage, + baseCurrency: this.configurationService.get('BASE_CURRENCY'), currencies: this.exchangeRateDataService.getCurrencies(), demoAuthToken: this.getDemoAuthToken(), lastDataGathering: await this.getLastDataGathering(), diff --git a/apps/api/src/app/order/order.controller.ts b/apps/api/src/app/order/order.controller.ts index 73c546d83..e61c57ef7 100644 --- a/apps/api/src/app/order/order.controller.ts +++ b/apps/api/src/app/order/order.controller.ts @@ -1,5 +1,6 @@ import { UserService } from '@ghostfolio/api/app/user/user.service'; import { nullifyValuesInObjects } from '@ghostfolio/api/helper/object.helper'; +import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor'; import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor'; import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor'; import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service'; @@ -62,6 +63,7 @@ export class OrderController { @Get() @UseGuards(AuthGuard('jwt')) + @UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor) public async getAllOrders( @Headers('impersonation-id') impersonationId diff --git a/apps/api/src/app/portfolio/current-rate.service.spec.ts b/apps/api/src/app/portfolio/current-rate.service.spec.ts index 653cdc7dc..2ef8ad5fa 100644 --- a/apps/api/src/app/portfolio/current-rate.service.spec.ts +++ b/apps/api/src/app/portfolio/current-rate.service.spec.ts @@ -74,7 +74,12 @@ describe('CurrentRateService', () => { beforeAll(async () => { dataProviderService = new DataProviderService(null, [], null); - exchangeRateDataService = new ExchangeRateDataService(null, null, null); + exchangeRateDataService = new ExchangeRateDataService( + null, + null, + null, + null + ); marketDataService = new MarketDataService(null); await exchangeRateDataService.initialize(); diff --git a/apps/api/src/app/portfolio/interfaces/portfolio-position-detail.interface.ts b/apps/api/src/app/portfolio/interfaces/portfolio-position-detail.interface.ts index f400923e8..4d13a1ae3 100644 --- a/apps/api/src/app/portfolio/interfaces/portfolio-position-detail.interface.ts +++ b/apps/api/src/app/portfolio/interfaces/portfolio-position-detail.interface.ts @@ -1,5 +1,7 @@ -import { EnhancedSymbolProfile } from '@ghostfolio/api/services/interfaces/symbol-profile.interface'; -import { HistoricalDataItem } from '@ghostfolio/common/interfaces'; +import { + EnhancedSymbolProfile, + HistoricalDataItem +} from '@ghostfolio/common/interfaces'; import { OrderWithAccount } from '@ghostfolio/common/types'; import { Tag } from '@prisma/client'; diff --git a/apps/api/src/app/portfolio/portfolio.controller.ts b/apps/api/src/app/portfolio/portfolio.controller.ts index 5d3c683f1..606ce658b 100644 --- a/apps/api/src/app/portfolio/portfolio.controller.ts +++ b/apps/api/src/app/portfolio/portfolio.controller.ts @@ -4,11 +4,11 @@ import { hasNotDefinedValuesInObject, nullifyValuesInObject } from '@ghostfolio/api/helper/object.helper'; +import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor'; import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor'; import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service'; -import { baseCurrency } from '@ghostfolio/common/config'; import { parseDate } from '@ghostfolio/common/helper'; import { Filter, @@ -43,6 +43,8 @@ import { PortfolioService } from './portfolio.service'; @Controller('portfolio') export class PortfolioController { + private baseCurrency: string; + public constructor( private readonly accessService: AccessService, private readonly configurationService: ConfigurationService, @@ -50,7 +52,9 @@ export class PortfolioController { private readonly portfolioService: PortfolioService, @Inject(REQUEST) private readonly request: RequestWithUser, private readonly userService: UserService - ) {} + ) { + this.baseCurrency = this.configurationService.get('BASE_CURRENCY'); + } @Get('chart') @UseGuards(AuthGuard('jwt')) @@ -103,6 +107,7 @@ export class PortfolioController { @Get('details') @UseGuards(AuthGuard('jwt')) + @UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor) public async getDetails( @Headers('impersonation-id') impersonationId: string, @@ -327,7 +332,7 @@ export class PortfolioController { return this.exchangeRateDataService.toCurrency( portfolioPosition.quantity * portfolioPosition.marketPrice, portfolioPosition.currency, - this.request.user?.Settings?.currency ?? baseCurrency + this.request.user?.Settings?.currency ?? this.baseCurrency ); }) .reduce((a, b) => a + b, 0); diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index 840c17f42..da08f8e52 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -15,19 +15,19 @@ import { CurrencyClusterRiskBaseCurrencyInitialInvestment } from '@ghostfolio/ap import { CurrencyClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/current-investment'; import { CurrencyClusterRiskInitialInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/initial-investment'; import { FeeRatioInitialInvestment } from '@ghostfolio/api/models/rules/fees/fee-ratio-initial-investment'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service'; import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service'; -import { EnhancedSymbolProfile } from '@ghostfolio/api/services/interfaces/symbol-profile.interface'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service'; import { ASSET_SUB_CLASS_EMERGENCY_FUND, - UNKNOWN_KEY, - baseCurrency + UNKNOWN_KEY } from '@ghostfolio/common/config'; import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper'; import { Accounts, + EnhancedSymbolProfile, Filter, HistoricalDataItem, PortfolioDetails, @@ -82,8 +82,11 @@ const emergingMarkets = require('../../assets/countries/emerging-markets.json'); @Injectable() export class PortfolioService { + private baseCurrency: string; + public constructor( private readonly accountService: AccountService, + private readonly configurationService: ConfigurationService, private readonly currentRateService: CurrentRateService, private readonly dataProviderService: DataProviderService, private readonly exchangeRateDataService: ExchangeRateDataService, @@ -93,7 +96,9 @@ export class PortfolioService { private readonly rulesService: RulesService, private readonly symbolProfileService: SymbolProfileService, private readonly userService: UserService - ) {} + ) { + this.baseCurrency = this.configurationService.get('BASE_CURRENCY'); + } public async getAccounts(aUserId: string): Promise { const [accounts, details] = await Promise.all([ @@ -320,7 +325,7 @@ export class PortfolioService { const userCurrency = user.Settings?.currency ?? this.request.user?.Settings?.currency ?? - baseCurrency; + this.baseCurrency; const { orders, portfolioOrders, transactionPoints } = await this.getTransactionPoints({ @@ -370,7 +375,7 @@ export class PortfolioService { const [dataProviderResponses, symbolProfiles] = await Promise.all([ this.dataProviderService.getQuotes(dataGatheringItems), - this.symbolProfileService.getSymbolProfiles(symbols) + this.symbolProfileService.getSymbolProfilesBySymbols(symbols) ]); const symbolProfileMap: { [symbol: string]: EnhancedSymbolProfile } = {}; @@ -513,9 +518,8 @@ export class PortfolioService { } const positionCurrency = orders[0].SymbolProfile.currency; - const [SymbolProfile] = await this.symbolProfileService.getSymbolProfiles([ - aSymbol - ]); + const [SymbolProfile] = + await this.symbolProfileService.getSymbolProfilesBySymbols([aSymbol]); const portfolioOrders: PortfolioOrder[] = orders .filter((order) => { @@ -763,7 +767,7 @@ export class PortfolioService { const [dataProviderResponses, symbolProfiles] = await Promise.all([ this.dataProviderService.getQuotes(dataGatheringItem), - this.symbolProfileService.getSymbolProfiles(symbols) + this.symbolProfileService.getSymbolProfilesBySymbols(symbols) ]); const symbolProfileMap: { [symbol: string]: EnhancedSymbolProfile } = {}; @@ -1213,7 +1217,8 @@ export class PortfolioService { orders: OrderWithAccount[]; portfolioOrders: PortfolioOrder[]; }> { - const userCurrency = this.request.user?.Settings?.currency ?? baseCurrency; + const userCurrency = + this.request.user?.Settings?.currency ?? this.baseCurrency; const orders = await this.orderService.getOrders({ filters, diff --git a/apps/api/src/app/redis-cache/redis-cache.module.ts b/apps/api/src/app/redis-cache/redis-cache.module.ts index dcda94041..96cf50325 100644 --- a/apps/api/src/app/redis-cache/redis-cache.module.ts +++ b/apps/api/src/app/redis-cache/redis-cache.module.ts @@ -14,6 +14,7 @@ import { RedisCacheService } from './redis-cache.service'; useFactory: async (configurationService: ConfigurationService) => ({ host: configurationService.get('REDIS_HOST'), max: configurationService.get('MAX_ITEM_IN_CACHE'), + password: configurationService.get('REDIS_PASSWORD'), port: configurationService.get('REDIS_PORT'), store: redisStore, ttl: configurationService.get('CACHE_TTL') diff --git a/apps/api/src/app/symbol/interfaces/symbol-item.interface.ts b/apps/api/src/app/symbol/interfaces/symbol-item.interface.ts index 51ed38d4d..358658672 100644 --- a/apps/api/src/app/symbol/interfaces/symbol-item.interface.ts +++ b/apps/api/src/app/symbol/interfaces/symbol-item.interface.ts @@ -1,9 +1,7 @@ -import { HistoricalDataItem } from '@ghostfolio/common/interfaces'; -import { DataSource } from '@prisma/client'; +import { HistoricalDataItem, UniqueAsset } from '@ghostfolio/common/interfaces'; -export interface SymbolItem { +export interface SymbolItem extends UniqueAsset { currency: string; - dataSource: DataSource; historicalData: HistoricalDataItem[]; marketPrice: number; } diff --git a/apps/api/src/app/symbol/symbol.service.ts b/apps/api/src/app/symbol/symbol.service.ts index 6cfcbc209..e24aa71b2 100644 --- a/apps/api/src/app/symbol/symbol.service.ts +++ b/apps/api/src/app/symbol/symbol.service.ts @@ -55,7 +55,8 @@ export class SymbolService { currency, historicalData, marketPrice, - dataSource: dataGatheringItem.dataSource + dataSource: dataGatheringItem.dataSource, + symbol: dataGatheringItem.symbol }; } diff --git a/apps/api/src/app/user/user.service.ts b/apps/api/src/app/user/user.service.ts index 3fd6f8e1d..d0c30386e 100644 --- a/apps/api/src/app/user/user.service.ts +++ b/apps/api/src/app/user/user.service.ts @@ -3,11 +3,7 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration.ser import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { TagService } from '@ghostfolio/api/services/tag/tag.service'; -import { - PROPERTY_IS_READ_ONLY_MODE, - baseCurrency, - locale -} from '@ghostfolio/common/config'; +import { PROPERTY_IS_READ_ONLY_MODE, locale } from '@ghostfolio/common/config'; import { User as IUser, UserWithSettings } from '@ghostfolio/common/interfaces'; import { getPermissions, @@ -26,13 +22,17 @@ const crypto = require('crypto'); export class UserService { public static DEFAULT_CURRENCY = 'USD'; + private baseCurrency: string; + public constructor( private readonly configurationService: ConfigurationService, private readonly prismaService: PrismaService, private readonly propertyService: PropertyService, private readonly subscriptionService: SubscriptionService, private readonly tagService: TagService - ) {} + ) { + this.baseCurrency = this.configurationService.get('BASE_CURRENCY'); + } public async getUser( { @@ -224,14 +224,14 @@ export class UserService { ...data, Account: { create: { - currency: baseCurrency, + currency: this.baseCurrency, isDefault: true, name: 'Default Account' } }, Settings: { create: { - currency: baseCurrency + currency: this.baseCurrency } } } diff --git a/apps/api/src/interceptors/redact-values-in-response.interceptor.ts b/apps/api/src/interceptors/redact-values-in-response.interceptor.ts new file mode 100644 index 000000000..b5889328d --- /dev/null +++ b/apps/api/src/interceptors/redact-values-in-response.interceptor.ts @@ -0,0 +1,50 @@ +import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; +import { + CallHandler, + ExecutionContext, + Injectable, + NestInterceptor +} from '@nestjs/common'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + +@Injectable() +export class RedactValuesInResponseInterceptor + implements NestInterceptor +{ + public constructor() {} + + public intercept( + context: ExecutionContext, + next: CallHandler + ): Observable { + return next.handle().pipe( + map((data: any) => { + const request = context.switchToHttp().getRequest(); + const hasImpersonationId = !!request.headers?.['impersonation-id']; + + if (hasImpersonationId) { + if (data.accounts) { + for (const accountId of Object.keys(data.accounts)) { + if (data.accounts[accountId]?.balance !== undefined) { + data.accounts[accountId].balance = null; + } + } + } + + if (data.activities) { + data.activities = data.activities.map((activity: Activity) => { + if (activity.Account?.balance !== undefined) { + activity.Account.balance = null; + } + + return activity; + }); + } + } + + return data; + }) + ); + } +} diff --git a/apps/api/src/services/configuration.service.ts b/apps/api/src/services/configuration.service.ts index abbfa6641..525951a70 100644 --- a/apps/api/src/services/configuration.service.ts +++ b/apps/api/src/services/configuration.service.ts @@ -12,6 +12,7 @@ export class ConfigurationService { this.environmentConfiguration = cleanEnv(process.env, { ACCESS_TOKEN_SALT: str(), ALPHA_VANTAGE_API_KEY: str({ default: '' }), + BASE_CURRENCY: str({ default: 'USD' }), CACHE_TTL: num({ default: 1 }), DATA_SOURCE_PRIMARY: str({ default: DataSource.YAHOO }), DATA_SOURCES: json({ default: JSON.stringify([DataSource.YAHOO]) }), @@ -24,6 +25,7 @@ export class ConfigurationService { ENABLE_FEATURE_STATISTICS: bool({ default: false }), ENABLE_FEATURE_SUBSCRIPTION: bool({ default: false }), ENABLE_FEATURE_SYSTEM_MESSAGE: bool({ default: false }), + EOD_HISTORICAL_DATA_API_KEY: str({ default: '' }), GOOGLE_CLIENT_ID: str({ default: 'dummyClientId' }), GOOGLE_SECRET: str({ default: 'dummySecret' }), GOOGLE_SHEETS_ACCOUNT: str({ default: '' }), @@ -35,6 +37,7 @@ export class ConfigurationService { PORT: port({ default: 3333 }), RAKUTEN_RAPID_API_KEY: str({ default: '' }), REDIS_HOST: str({ default: 'localhost' }), + REDIS_PASSWORD: str({ default: '' }), REDIS_PORT: port({ default: 6379 }), ROOT_URL: str({ default: 'http://localhost:4200' }), STRIPE_PUBLIC_KEY: str({ default: '' }), diff --git a/apps/api/src/services/data-gathering.service.ts b/apps/api/src/services/data-gathering.service.ts index 61adaa19e..507e1e146 100644 --- a/apps/api/src/services/data-gathering.service.ts +++ b/apps/api/src/services/data-gathering.service.ts @@ -247,11 +247,12 @@ export class DataGatheringService { const assetProfiles = await this.dataProviderService.getAssetProfiles( uniqueAssets ); - const symbolProfiles = await this.symbolProfileService.getSymbolProfiles( - uniqueAssets.map(({ symbol }) => { - return symbol; - }) - ); + const symbolProfiles = + await this.symbolProfileService.getSymbolProfilesBySymbols( + uniqueAssets.map(({ symbol }) => { + return symbol; + }) + ); for (const [symbol, assetProfile] of Object.entries(assetProfiles)) { const symbolMapping = symbolProfiles.find((symbolProfile) => { diff --git a/apps/api/src/services/data-provider/data-provider.module.ts b/apps/api/src/services/data-provider/data-provider.module.ts index e2a77af4a..dcdb7acbd 100644 --- a/apps/api/src/services/data-provider/data-provider.module.ts +++ b/apps/api/src/services/data-provider/data-provider.module.ts @@ -1,5 +1,7 @@ import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module'; import { CryptocurrencyModule } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.module'; +import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service'; +import { EodHistoricalDataService } from '@ghostfolio/api/services/data-provider/eod-historical-data/eod-historical-data.service'; import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.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'; @@ -9,7 +11,6 @@ import { PrismaModule } from '@ghostfolio/api/services/prisma.module'; import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module'; import { Module } from '@nestjs/common'; -import { AlphaVantageService } from './alpha-vantage/alpha-vantage.service'; import { DataProviderService } from './data-provider.service'; @Module({ @@ -22,6 +23,7 @@ import { DataProviderService } from './data-provider.service'; providers: [ AlphaVantageService, DataProviderService, + EodHistoricalDataService, GhostfolioScraperApiService, GoogleSheetsService, ManualService, @@ -30,6 +32,7 @@ import { DataProviderService } from './data-provider.service'; { inject: [ AlphaVantageService, + EodHistoricalDataService, GhostfolioScraperApiService, GoogleSheetsService, ManualService, @@ -39,6 +42,7 @@ import { DataProviderService } from './data-provider.service'; provide: 'DataProviderInterfaces', useFactory: ( alphaVantageService, + eodHistoricalDataService, ghostfolioScraperApiService, googleSheetsService, manualService, @@ -46,6 +50,7 @@ import { DataProviderService } from './data-provider.service'; yahooFinanceService ) => [ alphaVantageService, + eodHistoricalDataService, ghostfolioScraperApiService, googleSheetsService, manualService, diff --git a/apps/api/src/services/data-provider/eod-historical-data/eod-historical-data.service.ts b/apps/api/src/services/data-provider/eod-historical-data/eod-historical-data.service.ts new file mode 100644 index 000000000..bb0401f00 --- /dev/null +++ b/apps/api/src/services/data-provider/eod-historical-data/eod-historical-data.service.ts @@ -0,0 +1,138 @@ +import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; +import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface'; +import { + IDataProviderHistoricalResponse, + IDataProviderResponse +} from '@ghostfolio/api/services/interfaces/interfaces'; +import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service'; +import { DATE_FORMAT } from '@ghostfolio/common/helper'; +import { Granularity } from '@ghostfolio/common/types'; +import { Injectable, Logger } from '@nestjs/common'; +import { DataSource, SymbolProfile } from '@prisma/client'; +import bent from 'bent'; +import { format } from 'date-fns'; + +@Injectable() +export class EodHistoricalDataService implements DataProviderInterface { + private apiKey: string; + private readonly URL = 'https://eodhistoricaldata.com/api'; + + public constructor( + private readonly configurationService: ConfigurationService, + private readonly symbolProfileService: SymbolProfileService + ) { + this.apiKey = this.configurationService.get('EOD_HISTORICAL_DATA_API_KEY'); + } + + public canHandle(symbol: string) { + return true; + } + + public async getAssetProfile( + aSymbol: string + ): Promise> { + return { + dataSource: this.getName() + }; + } + + public async getHistorical( + aSymbol: string, + aGranularity: Granularity = 'day', + from: Date, + to: Date + ): Promise<{ + [symbol: string]: { [date: string]: IDataProviderHistoricalResponse }; + }> { + try { + const get = bent( + `${this.URL}/eod/${aSymbol}?api_token=${ + this.apiKey + }&fmt=json&from=${format(from, DATE_FORMAT)}&to=${format( + to, + DATE_FORMAT + )}&period={aGranularity}`, + 'GET', + 'json', + 200 + ); + + const response = await get(); + + return response.reduce( + (result, historicalItem, index, array) => { + result[aSymbol][historicalItem.date] = { + marketPrice: historicalItem.close, + performance: historicalItem.open - historicalItem.close + }; + + return result; + }, + { [aSymbol]: {} } + ); + } catch (error) { + Logger.error(error, 'EodHistoricalDataService'); + } + + return {}; + } + + public getName(): DataSource { + return DataSource.EOD_HISTORICAL_DATA; + } + + public async getQuotes( + aSymbols: string[] + ): Promise<{ [symbol: string]: IDataProviderResponse }> { + if (aSymbols.length <= 0) { + return {}; + } + + try { + const get = bent( + `${this.URL}/real-time/${aSymbols[0]}?api_token=${ + this.apiKey + }&fmt=json&s=${aSymbols.join(',')}`, + 'GET', + 'json', + 200 + ); + + const [response, symbolProfiles] = await Promise.all([ + get(), + this.symbolProfileService.getSymbolProfiles( + aSymbols.map((symbol) => { + return { + symbol, + dataSource: DataSource.EOD_HISTORICAL_DATA + }; + }) + ) + ]); + + const quotes = aSymbols.length === 1 ? [response] : response; + + return quotes.reduce((result, item, index, array) => { + result[item.code] = { + currency: symbolProfiles.find((symbolProfile) => { + return symbolProfile.symbol === item.code; + })?.currency, + dataSource: DataSource.EOD_HISTORICAL_DATA, + marketPrice: item.close, + marketState: 'delayed' + }; + + return result; + }, {}); + } catch (error) { + Logger.error(error, 'EodHistoricalDataService'); + } + + return {}; + } + + public async search(aQuery: string): Promise<{ items: LookupItem[] }> { + return { items: [] }; + } +} diff --git a/apps/api/src/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service.ts b/apps/api/src/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service.ts index f0ee84237..7186ea7ec 100644 --- a/apps/api/src/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service.ts +++ b/apps/api/src/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service.ts @@ -10,7 +10,7 @@ import { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper'; import { Granularity } from '@ghostfolio/common/types'; import { Injectable, Logger } from '@nestjs/common'; import { DataSource, SymbolProfile } from '@prisma/client'; -import * as bent from 'bent'; +import bent from 'bent'; import * as cheerio from 'cheerio'; import { addDays, format, isBefore } from 'date-fns'; @@ -46,9 +46,8 @@ export class GhostfolioScraperApiService implements DataProviderInterface { try { const symbol = aSymbol; - const [symbolProfile] = await this.symbolProfileService.getSymbolProfiles( - [symbol] - ); + const [symbolProfile] = + await this.symbolProfileService.getSymbolProfilesBySymbols([symbol]); const { defaultMarketPrice, selector, url } = symbolProfile.scraperConfiguration; @@ -108,9 +107,8 @@ export class GhostfolioScraperApiService implements DataProviderInterface { } try { - const symbolProfiles = await this.symbolProfileService.getSymbolProfiles( - aSymbols - ); + const symbolProfiles = + await this.symbolProfileService.getSymbolProfilesBySymbols(aSymbols); const marketData = await this.prismaService.marketData.findMany({ distinct: ['symbol'], diff --git a/apps/api/src/services/data-provider/google-sheets/google-sheets.service.ts b/apps/api/src/services/data-provider/google-sheets/google-sheets.service.ts index 97022706f..b196df532 100644 --- a/apps/api/src/services/data-provider/google-sheets/google-sheets.service.ts +++ b/apps/api/src/services/data-provider/google-sheets/google-sheets.service.ts @@ -91,9 +91,8 @@ export class GoogleSheetsService implements DataProviderInterface { try { const response: { [symbol: string]: IDataProviderResponse } = {}; - const symbolProfiles = await this.symbolProfileService.getSymbolProfiles( - aSymbols - ); + const symbolProfiles = + await this.symbolProfileService.getSymbolProfilesBySymbols(aSymbols); const sheet = await this.getSheet({ sheetId: this.configurationService.get('GOOGLE_SHEETS_ID'), diff --git a/apps/api/src/services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service.ts b/apps/api/src/services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service.ts index baa6591f4..2a516c5ef 100644 --- a/apps/api/src/services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service.ts +++ b/apps/api/src/services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service.ts @@ -11,7 +11,7 @@ import { DATE_FORMAT, getToday, getYesterday } from '@ghostfolio/common/helper'; import { Granularity } from '@ghostfolio/common/types'; import { Injectable, Logger } from '@nestjs/common'; import { DataSource, SymbolProfile } from '@prisma/client'; -import * as bent from 'bent'; +import bent from 'bent'; import { format, subMonths, subWeeks, subYears } from 'date-fns'; @Injectable() diff --git a/apps/api/src/services/data-provider/yahoo-finance/yahoo-finance.service.spec.ts b/apps/api/src/services/data-provider/yahoo-finance/yahoo-finance.service.spec.ts index 648eb6037..e18b6b583 100644 --- a/apps/api/src/services/data-provider/yahoo-finance/yahoo-finance.service.spec.ts +++ b/apps/api/src/services/data-provider/yahoo-finance/yahoo-finance.service.spec.ts @@ -1,3 +1,4 @@ +import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service'; import { YahooFinanceService } from './yahoo-finance.service'; @@ -25,13 +26,18 @@ jest.mock( ); describe('YahooFinanceService', () => { + let configurationService: ConfigurationService; let cryptocurrencyService: CryptocurrencyService; let yahooFinanceService: YahooFinanceService; beforeAll(async () => { + configurationService = new ConfigurationService(); cryptocurrencyService = new CryptocurrencyService(); - yahooFinanceService = new YahooFinanceService(cryptocurrencyService); + yahooFinanceService = new YahooFinanceService( + configurationService, + cryptocurrencyService + ); }); it('convertFromYahooFinanceSymbol', async () => { diff --git a/apps/api/src/services/data-provider/yahoo-finance/yahoo-finance.service.ts b/apps/api/src/services/data-provider/yahoo-finance/yahoo-finance.service.ts index 28c9e8549..f277d9ac9 100644 --- a/apps/api/src/services/data-provider/yahoo-finance/yahoo-finance.service.ts +++ b/apps/api/src/services/data-provider/yahoo-finance/yahoo-finance.service.ts @@ -1,11 +1,11 @@ import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service'; import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface'; import { IDataProviderHistoricalResponse, IDataProviderResponse } from '@ghostfolio/api/services/interfaces/interfaces'; -import { baseCurrency } from '@ghostfolio/common/config'; import { DATE_FORMAT, isCurrency } from '@ghostfolio/common/helper'; import { Granularity } from '@ghostfolio/common/types'; import { Injectable, Logger } from '@nestjs/common'; @@ -23,9 +23,14 @@ import type { Price } from 'yahoo-finance2/dist/esm/src/modules/quoteSummary-ifa @Injectable() export class YahooFinanceService implements DataProviderInterface { + private baseCurrency: string; + public constructor( + private readonly configurationService: ConfigurationService, private readonly cryptocurrencyService: CryptocurrencyService - ) {} + ) { + this.baseCurrency = this.configurationService.get('BASE_CURRENCY'); + } public canHandle(symbol: string) { return true; @@ -33,8 +38,8 @@ export class YahooFinanceService implements DataProviderInterface { public convertFromYahooFinanceSymbol(aYahooFinanceSymbol: string) { const symbol = aYahooFinanceSymbol.replace( - new RegExp(`-${baseCurrency}$`), - baseCurrency + new RegExp(`-${this.baseCurrency}$`), + this.baseCurrency ); return symbol.replace('=X', ''); } @@ -47,12 +52,15 @@ export class YahooFinanceService implements DataProviderInterface { * DOGEUSD -> DOGE-USD */ public convertToYahooFinanceSymbol(aSymbol: string) { - if (aSymbol.includes(baseCurrency) && aSymbol.length >= 6) { + if (aSymbol.includes(this.baseCurrency) && aSymbol.length >= 6) { if (isCurrency(aSymbol.substring(0, aSymbol.length - 3))) { return `${aSymbol}=X`; } else if ( this.cryptocurrencyService.isCryptocurrency( - aSymbol.replace(new RegExp(`-${baseCurrency}$`), baseCurrency) + aSymbol.replace( + new RegExp(`-${this.baseCurrency}$`), + this.baseCurrency + ) ) ) { // Add a dash before the last three characters @@ -60,8 +68,8 @@ export class YahooFinanceService implements DataProviderInterface { // DOGEUSD -> DOGE-USD // SOL1USD -> SOL1-USD return aSymbol.replace( - new RegExp(`-?${baseCurrency}$`), - `-${baseCurrency}` + new RegExp(`-?${this.baseCurrency}$`), + `-${this.baseCurrency}` ); } } @@ -255,7 +263,10 @@ export class YahooFinanceService implements DataProviderInterface { return ( (quoteType === 'CRYPTOCURRENCY' && this.cryptocurrencyService.isCryptocurrency( - symbol.replace(new RegExp(`-${baseCurrency}$`), baseCurrency) + symbol.replace( + new RegExp(`-${this.baseCurrency}$`), + this.baseCurrency + ) )) || ['EQUITY', 'ETF', 'FUTURE', 'MUTUALFUND'].includes(quoteType) ); @@ -264,7 +275,7 @@ export class YahooFinanceService implements DataProviderInterface { if (quoteType === 'CRYPTOCURRENCY') { // Only allow cryptocurrencies in base currency to avoid having redundancy in the database. // Transactions need to be converted manually to the base currency before - return symbol.includes(baseCurrency); + return symbol.includes(this.baseCurrency); } else if (quoteType === 'FUTURE') { // Allow GC=F, but not MGC=F return symbol.length === 4; diff --git a/apps/api/src/services/exchange-rate-data.module.ts b/apps/api/src/services/exchange-rate-data.module.ts index 9c886b06a..8b8eeee28 100644 --- a/apps/api/src/services/exchange-rate-data.module.ts +++ b/apps/api/src/services/exchange-rate-data.module.ts @@ -1,12 +1,18 @@ +import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service'; +import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; import { Module } from '@nestjs/common'; import { PrismaModule } from './prisma.module'; -import { PropertyModule } from './property/property.module'; @Module({ - imports: [DataProviderModule, PrismaModule, PropertyModule], + imports: [ + ConfigurationModule, + DataProviderModule, + PrismaModule, + PropertyModule + ], providers: [ExchangeRateDataService], exports: [ExchangeRateDataService] }) diff --git a/apps/api/src/services/exchange-rate-data.service.ts b/apps/api/src/services/exchange-rate-data.service.ts index 8092f1804..eb4c84599 100644 --- a/apps/api/src/services/exchange-rate-data.service.ts +++ b/apps/api/src/services/exchange-rate-data.service.ts @@ -1,9 +1,10 @@ -import { PROPERTY_CURRENCIES, baseCurrency } from '@ghostfolio/common/config'; +import { PROPERTY_CURRENCIES } from '@ghostfolio/common/config'; import { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper'; import { Injectable, Logger } from '@nestjs/common'; import { format } from 'date-fns'; import { isNumber, uniq } from 'lodash'; +import { ConfigurationService } from './configuration.service'; import { DataProviderService } from './data-provider/data-provider.service'; import { IDataGatheringItem } from './interfaces/interfaces'; import { PrismaService } from './prisma.service'; @@ -11,11 +12,13 @@ import { PropertyService } from './property/property.service'; @Injectable() export class ExchangeRateDataService { + private baseCurrency: string; private currencies: string[] = []; private currencyPairs: IDataGatheringItem[] = []; private exchangeRates: { [currencyPair: string]: number } = {}; public constructor( + private readonly configurationService: ConfigurationService, private readonly dataProviderService: DataProviderService, private readonly prismaService: PrismaService, private readonly propertyService: PropertyService @@ -24,7 +27,7 @@ export class ExchangeRateDataService { } public getCurrencies() { - return this.currencies?.length > 0 ? this.currencies : [baseCurrency]; + return this.currencies?.length > 0 ? this.currencies : [this.baseCurrency]; } public getCurrencyPairs() { @@ -32,6 +35,7 @@ export class ExchangeRateDataService { } public async initialize() { + this.baseCurrency = this.configurationService.get('BASE_CURRENCY'); this.currencies = await this.prepareCurrencies(); this.currencyPairs = []; this.exchangeRates = {}; @@ -212,14 +216,14 @@ export class ExchangeRateDataService { private prepareCurrencyPairs(aCurrencies: string[]) { return aCurrencies .filter((currency) => { - return currency !== baseCurrency; + return currency !== this.baseCurrency; }) .map((currency) => { return { - currency1: baseCurrency, + currency1: this.baseCurrency, currency2: currency, dataSource: this.dataProviderService.getPrimaryDataSource(), - symbol: `${baseCurrency}${currency}` + symbol: `${this.baseCurrency}${currency}` }; }); } diff --git a/apps/api/src/services/interfaces/environment.interface.ts b/apps/api/src/services/interfaces/environment.interface.ts index 82cf08cbe..36e9c7261 100644 --- a/apps/api/src/services/interfaces/environment.interface.ts +++ b/apps/api/src/services/interfaces/environment.interface.ts @@ -3,6 +3,7 @@ import { CleanedEnvAccessors } from 'envalid'; export interface Environment extends CleanedEnvAccessors { ACCESS_TOKEN_SALT: string; ALPHA_VANTAGE_API_KEY: string; + BASE_CURRENCY: string; CACHE_TTL: number; DATA_SOURCE_PRIMARY: string; DATA_SOURCES: string | string[]; // string is not correct, error in envalid? @@ -15,6 +16,7 @@ export interface Environment extends CleanedEnvAccessors { ENABLE_FEATURE_STATISTICS: boolean; ENABLE_FEATURE_SUBSCRIPTION: boolean; ENABLE_FEATURE_SYSTEM_MESSAGE: boolean; + EOD_HISTORICAL_DATA_API_KEY: string; GOOGLE_CLIENT_ID: string; GOOGLE_SECRET: string; GOOGLE_SHEETS_ACCOUNT: string; @@ -26,6 +28,7 @@ export interface Environment extends CleanedEnvAccessors { PORT: number; RAKUTEN_RAPID_API_KEY: string; REDIS_HOST: string; + REDIS_PASSWORD: string; REDIS_PORT: number; ROOT_URL: string; STRIPE_PUBLIC_KEY: string; diff --git a/apps/api/src/services/market-data.service.ts b/apps/api/src/services/market-data.service.ts index 0afb5a811..9dd3e4773 100644 --- a/apps/api/src/services/market-data.service.ts +++ b/apps/api/src/services/market-data.service.ts @@ -34,6 +34,20 @@ export class MarketDataService { }); } + public async getMax({ dataSource, symbol }: UniqueAsset): Promise { + const aggregations = await this.prismaService.marketData.aggregate({ + _max: { + marketPrice: true + }, + where: { + dataSource, + symbol + } + }); + + return aggregations._max.marketPrice; + } + public async getRange({ dateQuery, symbols diff --git a/apps/api/src/services/symbol-profile.service.ts b/apps/api/src/services/symbol-profile.service.ts index 5c8839ebf..c91da6d61 100644 --- a/apps/api/src/services/symbol-profile.service.ts +++ b/apps/api/src/services/symbol-profile.service.ts @@ -1,6 +1,10 @@ -import { EnhancedSymbolProfile } from '@ghostfolio/api/services/interfaces/symbol-profile.interface'; import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { UNKNOWN_KEY } from '@ghostfolio/common/config'; +import { + EnhancedSymbolProfile, + ScraperConfiguration, + UniqueAsset +} from '@ghostfolio/common/interfaces'; import { Country } from '@ghostfolio/common/interfaces/country.interface'; import { Sector } from '@ghostfolio/common/interfaces/sector.interface'; import { Injectable } from '@nestjs/common'; @@ -12,8 +16,6 @@ import { } from '@prisma/client'; import { continents, countries } from 'countries-list'; -import { ScraperConfiguration } from './data-provider/ghostfolio-scraper-api/interfaces/scraper-configuration.interface'; - @Injectable() export class SymbolProfileService { public constructor(private readonly prismaService: PrismaService) {} @@ -37,6 +39,35 @@ export class SymbolProfileService { } public async getSymbolProfiles( + aUniqueAssets: UniqueAsset[] + ): Promise { + return this.prismaService.symbolProfile + .findMany({ + include: { SymbolProfileOverrides: true }, + where: { + AND: [ + { + dataSource: { + in: aUniqueAssets.map(({ dataSource }) => { + return dataSource; + }) + }, + symbol: { + in: aUniqueAssets.map(({ symbol }) => { + return symbol; + }) + } + } + ] + } + }) + .then((symbolProfiles) => this.getSymbols(symbolProfiles)); + } + + /** + * @deprecated + */ + public async getSymbolProfilesBySymbols( symbols: string[] ): Promise { return this.prismaService.symbolProfile @@ -59,7 +90,9 @@ export class SymbolProfileService { return symbolProfiles.map((symbolProfile) => { const item = { ...symbolProfile, - countries: this.getCountries(symbolProfile), + countries: this.getCountries( + symbolProfile?.countries as unknown as Prisma.JsonArray + ), scraperConfiguration: this.getScraperConfiguration(symbolProfile), sectors: this.getSectors(symbolProfile), symbolMapping: this.getSymbolMapping(symbolProfile) @@ -70,9 +103,17 @@ export class SymbolProfileService { item.SymbolProfileOverrides.assetClass ?? item.assetClass; item.assetSubClass = item.SymbolProfileOverrides.assetSubClass ?? item.assetSubClass; - item.countries = - (item.SymbolProfileOverrides.countries as unknown as Country[]) ?? - item.countries; + + if ( + (item.SymbolProfileOverrides.countries as unknown as Prisma.JsonArray) + ?.length > 0 + ) { + item.countries = this.getCountries( + item.SymbolProfileOverrides + ?.countries as unknown as Prisma.JsonArray + ); + } + item.name = item.SymbolProfileOverrides?.name ?? item.name; item.sectors = (item.SymbolProfileOverrides.sectors as unknown as Sector[]) ?? @@ -85,20 +126,22 @@ export class SymbolProfileService { }); } - private getCountries(symbolProfile: SymbolProfile): Country[] { - return ((symbolProfile?.countries as Prisma.JsonArray) ?? []).map( - (country) => { - const { code, weight } = country as Prisma.JsonObject; + private getCountries(aCountries: Prisma.JsonArray = []): Country[] { + if (aCountries === null) { + return []; + } - return { - code: code as string, - continent: - continents[countries[code as string]?.continent] ?? UNKNOWN_KEY, - name: countries[code as string]?.name ?? UNKNOWN_KEY, - weight: weight as number - }; - } - ); + return aCountries.map((country: Pick) => { + const { code, weight } = country; + + return { + code, + weight, + continent: + continents[countries[code as string]?.continent] ?? UNKNOWN_KEY, + name: countries[code as string]?.name ?? UNKNOWN_KEY + }; + }); } private getScraperConfiguration( diff --git a/apps/api/src/services/twitter-bot/twitter-bot.module.ts b/apps/api/src/services/twitter-bot/twitter-bot.module.ts index d74d6f10f..02213ef62 100644 --- a/apps/api/src/services/twitter-bot/twitter-bot.module.ts +++ b/apps/api/src/services/twitter-bot/twitter-bot.module.ts @@ -1,11 +1,13 @@ +import { BenchmarkModule } from '@ghostfolio/api/app/benchmark/benchmark.module'; import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module'; +import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; import { TwitterBotService } from '@ghostfolio/api/services/twitter-bot/twitter-bot.service'; import { Module } from '@nestjs/common'; @Module({ exports: [TwitterBotService], - imports: [ConfigurationModule, SymbolModule], + imports: [BenchmarkModule, ConfigurationModule, PropertyModule, SymbolModule], providers: [TwitterBotService] }) export class TwitterBotModule {} diff --git a/apps/api/src/services/twitter-bot/twitter-bot.service.ts b/apps/api/src/services/twitter-bot/twitter-bot.service.ts index 58052872b..402159add 100644 --- a/apps/api/src/services/twitter-bot/twitter-bot.service.ts +++ b/apps/api/src/services/twitter-bot/twitter-bot.service.ts @@ -1,12 +1,19 @@ +import { BenchmarkService } from '@ghostfolio/api/app/benchmark/benchmark.service'; import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; +import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { + PROPERTY_BENCHMARKS, ghostfolioFearAndGreedIndexDataSource, ghostfolioFearAndGreedIndexSymbol } from '@ghostfolio/common/config'; -import { resolveFearAndGreedIndex } from '@ghostfolio/common/helper'; +import { + resolveFearAndGreedIndex, + resolveMarketCondition +} from '@ghostfolio/common/helper'; +import { UniqueAsset } from '@ghostfolio/common/interfaces'; import { Injectable, Logger } from '@nestjs/common'; -import { isSunday } from 'date-fns'; +import { isWeekend } from 'date-fns'; import { TwitterApi, TwitterApiReadWrite } from 'twitter-api-v2'; @Injectable() @@ -14,7 +21,9 @@ export class TwitterBotService { private twitterClient: TwitterApiReadWrite; public constructor( + private readonly benchmarkService: BenchmarkService, private readonly configurationService: ConfigurationService, + private readonly propertyService: PropertyService, private readonly symbolService: SymbolService ) { this.twitterClient = new TwitterApi({ @@ -30,7 +39,7 @@ export class TwitterBotService { public async tweetFearAndGreedIndex() { if ( !this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX') || - isSunday(new Date()) + isWeekend(new Date()) ) { return; } @@ -48,7 +57,16 @@ export class TwitterBotService { symbolItem.marketPrice ); - const status = `Current Market Mood: ${emoji} ${text} (${symbolItem.marketPrice}/100)\n\n#FearAndGreed #Markets #ServiceTweet`; + let status = `Current Market Mood: ${emoji} ${text} (${symbolItem.marketPrice}/100)`; + + const benchmarkListing = await this.getBenchmarkListing(3); + + if (benchmarkListing?.length > 1) { + status += '\n\n'; + status += 'ยฑ% from ATH\n'; + status += benchmarkListing; + } + const { data: createdTweet } = await this.twitterClient.v2.tweet( status ); @@ -62,4 +80,35 @@ export class TwitterBotService { Logger.error(error, 'TwitterBotService'); } } + + private async getBenchmarkListing(aMax: number) { + const benchmarkAssets: UniqueAsset[] = + ((await this.propertyService.getByKey( + PROPERTY_BENCHMARKS + )) as UniqueAsset[]) ?? []; + + const benchmarks = await this.benchmarkService.getBenchmarks( + benchmarkAssets + ); + + const benchmarkListing: string[] = []; + + for (const [index, benchmark] of benchmarks.entries()) { + if (index > aMax - 1) { + break; + } + + benchmarkListing.push( + `${benchmark.name} ${( + benchmark.performances.allTimeHigh.performancePercent * 100 + ).toFixed(1)}%${ + benchmark.marketCondition !== 'NEUTRAL_MARKET' + ? ' ' + resolveMarketCondition(benchmark.marketCondition).emoji + : '' + }` + ); + } + + return benchmarkListing.join('\n'); + } } diff --git a/apps/client/src/app/components/admin-market-data-detail/admin-market-data-detail.component.html b/apps/client/src/app/components/admin-market-data-detail/admin-market-data-detail.component.html index 7264be84d..c3d905be0 100644 --- a/apps/client/src/app/components/admin-market-data-detail/admin-market-data-detail.component.html +++ b/apps/client/src/app/components/admin-market-data-detail/admin-market-data-detail.component.html @@ -2,8 +2,10 @@
{{ itemByMonth.key }}
diff --git a/apps/client/src/app/components/home-holdings/home-holdings.component.ts b/apps/client/src/app/components/home-holdings/home-holdings.component.ts index a1eafcd76..16f029dd6 100644 --- a/apps/client/src/app/components/home-holdings/home-holdings.component.ts +++ b/apps/client/src/app/components/home-holdings/home-holdings.component.ts @@ -17,6 +17,7 @@ import { DataSource } from '@prisma/client'; import { DeviceDetectorService } from 'ngx-device-detector'; import { Subject } from 'rxjs'; import { takeUntil } from 'rxjs/operators'; + import { PositionDetailDialogParams } from '../position/position-detail-dialog/interfaces/interfaces'; @Component({ diff --git a/apps/client/src/app/components/home-market/home-market.component.ts b/apps/client/src/app/components/home-market/home-market.component.ts index 0ea2f5944..9500c6e2d 100644 --- a/apps/client/src/app/components/home-market/home-market.component.ts +++ b/apps/client/src/app/components/home-market/home-market.component.ts @@ -4,6 +4,7 @@ import { UserService } from '@ghostfolio/client/services/user/user.service'; import { ghostfolioFearAndGreedIndexSymbol } from '@ghostfolio/common/config'; import { resetHours } from '@ghostfolio/common/helper'; import { + Benchmark, HistoricalDataItem, InfoItem, User @@ -18,6 +19,7 @@ import { takeUntil } from 'rxjs/operators'; templateUrl: './home-market.html' }) export class HomeMarketComponent implements OnDestroy, OnInit { + public benchmarks: Benchmark[]; public fearAndGreedIndex: number; public hasPermissionToAccessFearAndGreedIndex: boolean; public historicalData: HistoricalDataItem[]; @@ -73,6 +75,15 @@ export class HomeMarketComponent implements OnDestroy, OnInit { }); } + this.dataService + .fetchBenchmarks() + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe(({ benchmarks }) => { + this.benchmarks = benchmarks; + + this.changeDetectorRef.markForCheck(); + }); + this.changeDetectorRef.markForCheck(); } }); diff --git a/apps/client/src/app/components/home-market/home-market.html b/apps/client/src/app/components/home-market/home-market.html index f3d8315dd..d509a641d 100644 --- a/apps/client/src/app/components/home-market/home-market.html +++ b/apps/client/src/app/components/home-market/home-market.html @@ -1,18 +1,19 @@ -
-
+
+

Markets

+
Last {{ numberOfDays }} Days
@@ -23,4 +24,20 @@ >
+ +
+
+ + +
+
diff --git a/apps/client/src/app/components/home-market/home-market.module.ts b/apps/client/src/app/components/home-market/home-market.module.ts index 01267b426..2c831a221 100644 --- a/apps/client/src/app/components/home-market/home-market.module.ts +++ b/apps/client/src/app/components/home-market/home-market.module.ts @@ -1,6 +1,7 @@ import { CommonModule } from '@angular/common'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; import { GfFearAndGreedIndexModule } from '@ghostfolio/client/components/fear-and-greed-index/fear-and-greed-index.module'; +import { GfBenchmarkModule } from '@ghostfolio/ui/benchmark/benchmark.module'; import { GfLineChartModule } from '@ghostfolio/ui/line-chart/line-chart.module'; import { HomeMarketComponent } from './home-market.component'; @@ -8,7 +9,12 @@ import { HomeMarketComponent } from './home-market.component'; @NgModule({ declarations: [HomeMarketComponent], exports: [], - imports: [CommonModule, GfFearAndGreedIndexModule, GfLineChartModule], + imports: [ + CommonModule, + GfBenchmarkModule, + GfFearAndGreedIndexModule, + GfLineChartModule + ], providers: [], schemas: [CUSTOM_ELEMENTS_SCHEMA] }) diff --git a/apps/client/src/app/components/home-overview/home-overview.html b/apps/client/src/app/components/home-overview/home-overview.html index 7f804d990..e82ef7f5f 100644 --- a/apps/client/src/app/components/home-overview/home-overview.html +++ b/apps/client/src/app/components/home-overview/home-overview.html @@ -6,6 +6,7 @@ + getTooltipPositionerMapTop(this.chart, position); } public ngOnChanges() { @@ -98,6 +112,7 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy { data: this.investments.map((position) => { return position.investment; }), + label: 'Investment', segment: { borderColor: (context: unknown) => this.isInFuture( @@ -114,6 +129,9 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy { if (this.chartCanvas) { if (this.chart) { this.chart.data = data; + this.chart.options.plugins.tooltip = ( + this.getTooltipPluginConfiguration() + ); this.chart.update(); } else { this.chart = new Chart(this.chartCanvas.nativeElement, { @@ -124,13 +142,20 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy { tension: 0 }, point: { + hoverBackgroundColor: getBackgroundColor(), + hoverRadius: 2, radius: 0 } }, + interaction: { intersect: false, mode: 'index' }, maintainAspectRatio: true, - plugins: { + plugins: { legend: { display: false + }, + tooltip: this.getTooltipPluginConfiguration(), + verticalHoverLine: { + color: `rgba(${getTextColor()}, 0.1)` } }, responsive: true, @@ -138,16 +163,21 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy { x: { display: true, grid: { + borderColor: `rgba(${getTextColor()}, 0.1)`, + color: `rgba(${getTextColor()}, 0.8)`, display: false }, type: 'time', time: { + tooltipFormat: getDateFormatString(this.locale), unit: 'year' } }, y: { display: !this.isInPercent, grid: { + borderColor: `rgba(${getTextColor()}, 0.1)`, + color: `rgba(${getTextColor()}, 0.8)`, display: false }, ticks: { @@ -161,6 +191,7 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy { } } }, + plugins: [getVerticalHoverLinePlugin(this.chartCanvas)], type: 'line' }); @@ -169,6 +200,19 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy { } } + private getTooltipPluginConfiguration() { + return { + ...getTooltipOptions( + this.isInPercent ? undefined : this.currency, + this.isInPercent ? undefined : this.locale + ), + mode: 'index', + position: 'top', + xAlign: 'center', + yAlign: 'bottom' + }; + } + private isInFuture(aContext: any, aValue: T) { return isAfter(new Date(aContext?.p1?.parsed?.x), new Date()) ? aValue diff --git a/apps/client/src/app/components/position/position-detail-dialog/position-detail-dialog.component.ts b/apps/client/src/app/components/position/position-detail-dialog/position-detail-dialog.component.ts index 341b4abc0..3e9006111 100644 --- a/apps/client/src/app/components/position/position-detail-dialog/position-detail-dialog.component.ts +++ b/apps/client/src/app/components/position/position-detail-dialog/position-detail-dialog.component.ts @@ -9,9 +9,10 @@ import { import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { DataService } from '@ghostfolio/client/services/data.service'; import { DATE_FORMAT, downloadAsFile } from '@ghostfolio/common/helper'; +import { EnhancedSymbolProfile } from '@ghostfolio/common/interfaces'; import { OrderWithAccount } from '@ghostfolio/common/types'; import { LineChartItem } from '@ghostfolio/ui/line-chart/interfaces/line-chart.interface'; -import { SymbolProfile, Tag } from '@prisma/client'; +import { Tag } from '@prisma/client'; import { format, isSameMonth, isToday, parseISO } from 'date-fns'; import { Subject } from 'rxjs'; import { takeUntil } from 'rxjs/operators'; @@ -48,7 +49,7 @@ export class PositionDetailDialog implements OnDestroy, OnInit { public sectors: { [name: string]: { name: string; value: number }; }; - public SymbolProfile: SymbolProfile; + public SymbolProfile: EnhancedSymbolProfile; public tags: Tag[]; public transactionCount: number; public value: number; diff --git a/apps/client/src/app/components/position/position-detail-dialog/position-detail-dialog.html b/apps/client/src/app/components/position/position-detail-dialog/position-detail-dialog.html index 64bb56f3f..59a8e4e16 100644 --- a/apps/client/src/app/components/position/position-detail-dialog/position-detail-dialog.html +++ b/apps/client/src/app/components/position/position-detail-dialog/position-detail-dialog.html @@ -23,7 +23,9 @@ class="mb-4" benchmarkLabel="Average Unit Price" [benchmarkDataItems]="benchmarkDataItems" + [currency]="SymbolProfile?.currency" [historicalDataItems]="historicalDataItems" + [locale]="data.locale" [showGradient]="true" [showXAxis]="true" [showYAxis]="true" diff --git a/apps/client/src/app/pages/about/about-page.component.ts b/apps/client/src/app/pages/about/about-page.component.ts index 765e9ba92..b67533c30 100644 --- a/apps/client/src/app/pages/about/about-page.component.ts +++ b/apps/client/src/app/pages/about/about-page.component.ts @@ -1,7 +1,6 @@ import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; import { DataService } from '@ghostfolio/client/services/data.service'; import { UserService } from '@ghostfolio/client/services/user/user.service'; -import { baseCurrency } from '@ghostfolio/common/config'; import { User } from '@ghostfolio/common/interfaces'; import { Statistics } from '@ghostfolio/common/interfaces/statistics.interface'; import { hasPermission, permissions } from '@ghostfolio/common/permissions'; @@ -17,7 +16,6 @@ import { environment } from '../../../environments/environment'; templateUrl: './about-page.html' }) export class AboutPageComponent implements OnDestroy, OnInit { - public baseCurrency = baseCurrency; public hasPermissionForBlog: boolean; public hasPermissionForStatistics: boolean; public hasPermissionForSubscription: boolean; diff --git a/apps/client/src/app/pages/account/account-page.component.ts b/apps/client/src/app/pages/account/account-page.component.ts index 743d9c568..2ea177c08 100644 --- a/apps/client/src/app/pages/account/account-page.component.ts +++ b/apps/client/src/app/pages/account/account-page.component.ts @@ -20,7 +20,6 @@ import { CreateAccessDto } from '@ghostfolio/api/app/access/create-access.dto'; import { DataService } from '@ghostfolio/client/services/data.service'; import { UserService } from '@ghostfolio/client/services/user/user.service'; import { WebAuthnService } from '@ghostfolio/client/services/web-authn.service'; -import { baseCurrency } from '@ghostfolio/common/config'; import { getDateFormatString } from '@ghostfolio/common/helper'; import { Access, User } from '@ghostfolio/common/interfaces'; import { hasPermission, permissions } from '@ghostfolio/common/permissions'; @@ -43,7 +42,7 @@ export class AccountPageComponent implements OnDestroy, OnInit { signInWithFingerprintElement: MatSlideToggle; public accesses: Access[]; - public baseCurrency = baseCurrency; + public baseCurrency: string; public coupon: number; public couponId: string; public currencies: string[] = []; @@ -79,8 +78,10 @@ export class AccountPageComponent implements OnDestroy, OnInit { private userService: UserService, public webAuthnService: WebAuthnService ) { - const { currencies, globalPermissions, subscriptions } = + const { baseCurrency, currencies, globalPermissions, subscriptions } = this.dataService.fetchInfo(); + + this.baseCurrency = baseCurrency; this.coupon = subscriptions?.[0]?.coupon; this.couponId = subscriptions?.[0]?.couponId; this.currencies = currencies; diff --git a/apps/client/src/app/pages/account/account-page.html b/apps/client/src/app/pages/account/account-page.html index 97af7d213..3c8b28dc3 100644 --- a/apps/client/src/app/pages/account/account-page.html +++ b/apps/client/src/app/pages/account/account-page.html @@ -169,6 +169,10 @@ >
+
+
ID
+
{{ user?.id }}
+
diff --git a/apps/client/src/app/pages/features/features-page.html b/apps/client/src/app/pages/features/features-page.html index 3e1a4a9e0..32258a3cf 100644 --- a/apps/client/src/app/pages/features/features-page.html +++ b/apps/client/src/app/pages/features/features-page.html @@ -4,14 +4,12 @@

Features

- - -

- Check out the numerous features of Ghostfolio to - manage your wealth. -

-
-
+
+

+ Check out the numerous features of Ghostfolio to + manage your wealth. +

+
diff --git a/apps/client/src/app/pages/landing/landing-page.html b/apps/client/src/app/pages/landing/landing-page.html index 5e6ac8ec1..bc0b38a08 100644 --- a/apps/client/src/app/pages/landing/landing-page.html +++ b/apps/client/src/app/pages/landing/landing-page.html @@ -51,6 +51,18 @@ stocks, ETFs or cryptocurrencies and make solid, data-driven investment decisions.

+

+ + Ghostfol.io Trailer + +

diff --git a/apps/client/src/app/pages/portfolio/analysis/analysis-page.html b/apps/client/src/app/pages/portfolio/analysis/analysis-page.html index 361110d76..4364a8e83 100644 --- a/apps/client/src/app/pages/portfolio/analysis/analysis-page.html +++ b/apps/client/src/app/pages/portfolio/analysis/analysis-page.html @@ -2,21 +2,17 @@

Analysis

- - - Investment Timeline - - - - - +
+
Investment Timeline
+ +
diff --git a/apps/client/src/app/pages/portfolio/fire/fire-page.html b/apps/client/src/app/pages/portfolio/fire/fire-page.html index d62eeab30..89cc79a76 100644 --- a/apps/client/src/app/pages/portfolio/fire/fire-page.html +++ b/apps/client/src/app/pages/portfolio/fire/fire-page.html @@ -1,68 +1,68 @@
-
+

FIRE

-
-

4% Rule

-
- - -
-
- If you retire today, you would be able to withdraw - - per year - or - - per month, based on your total assets of - - and a withdrawal rate of 4%. -
+
+

Calculator

+
-

Calculator

- +

4% Rule

+
+ + +
+
+ If you retire today, you would be able to withdraw + + per year + or + + per month, based on your total assets of + + and a withdrawal rate of 4%. +
diff --git a/apps/client/src/app/pages/pricing/pricing-page.component.ts b/apps/client/src/app/pages/pricing/pricing-page.component.ts index eee7be440..7bfb9e720 100644 --- a/apps/client/src/app/pages/pricing/pricing-page.component.ts +++ b/apps/client/src/app/pages/pricing/pricing-page.component.ts @@ -1,7 +1,6 @@ import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; import { DataService } from '@ghostfolio/client/services/data.service'; import { UserService } from '@ghostfolio/client/services/user/user.service'; -import { baseCurrency } from '@ghostfolio/common/config'; import { User } from '@ghostfolio/common/interfaces'; import { Subject } from 'rxjs'; import { takeUntil } from 'rxjs/operators'; @@ -13,7 +12,7 @@ import { takeUntil } from 'rxjs/operators'; templateUrl: './pricing-page.html' }) export class PricingPageComponent implements OnDestroy, OnInit { - public baseCurrency = baseCurrency; + public baseCurrency: string; public coupon: number; public isLoggedIn: boolean; public price: number; @@ -29,8 +28,9 @@ export class PricingPageComponent implements OnDestroy, OnInit { private dataService: DataService, private userService: UserService ) { - const { subscriptions } = this.dataService.fetchInfo(); + const { baseCurrency, subscriptions } = this.dataService.fetchInfo(); + this.baseCurrency = baseCurrency; this.coupon = this.price = subscriptions?.[0]?.coupon; this.price = subscriptions?.[0]?.price; } diff --git a/apps/client/src/app/pages/pricing/pricing-page.html b/apps/client/src/app/pages/pricing/pricing-page.html index 4e6c5c705..1b7f7f87c 100644 --- a/apps/client/src/app/pages/pricing/pricing-page.html +++ b/apps/client/src/app/pages/pricing/pricing-page.html @@ -4,22 +4,19 @@

Pricing Plans

- - -

- Our official - Ghostfolio Premium cloud offering is the easiest - way to get started. Due to the time it saves, this will be the best - option for most people. The revenue is used for covering the hosting - costs. -

-

- If you prefer to run Ghostfolio on your own - infrastructure, please find the source code and further instructions - on GitHub. -

-
-
+
+

+ Our official + Ghostfolio Premium cloud offering is the easiest way + to get started. Due to the time it saves, this will be the best option + for most people. The revenue is used for covering the hosting costs. +

+

+ If you prefer to run Ghostfolio on your own + infrastructure, please find the source code and further instructions + on GitHub. +

+
diff --git a/apps/client/src/app/pages/pricing/pricing-page.scss b/apps/client/src/app/pages/pricing/pricing-page.scss index 829c1abef..cadb10fac 100644 --- a/apps/client/src/app/pages/pricing/pricing-page.scss +++ b/apps/client/src/app/pages/pricing/pricing-page.scss @@ -21,8 +21,4 @@ :host-context(.is-dark-theme) { color: rgb(var(--light-primary-text)); - - a { - color: rgb(var(--light-primary-text)); - } } diff --git a/apps/client/src/app/services/data.service.ts b/apps/client/src/app/services/data.service.ts index 58a900a72..c0dd5ef89 100644 --- a/apps/client/src/app/services/data.service.ts +++ b/apps/client/src/app/services/data.service.ts @@ -6,6 +6,7 @@ import { UpdateAccountDto } from '@ghostfolio/api/app/account/update-account.dto import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto'; import { Activities } from '@ghostfolio/api/app/order/interfaces/activities.interface'; import { UpdateOrderDto } from '@ghostfolio/api/app/order/update-order.dto'; +import { PortfolioPositionDetail } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position-detail.interface'; import { PortfolioPositions } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-positions.interface'; import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface'; import { SymbolItem } from '@ghostfolio/api/app/symbol/interfaces/symbol-item.interface'; @@ -18,6 +19,7 @@ import { Accounts, AdminData, AdminMarketData, + BenchmarkResponse, Export, Filter, InfoItem, @@ -89,6 +91,10 @@ export class DataService { return this.http.get('/api/v1/access'); } + public fetchBenchmarks() { + return this.http.get('/api/v1/benchmark'); + } + public fetchChart({ range }: { range: DateRange }) { return this.http.get('/api/v1/portfolio/chart', { params: { range } @@ -273,13 +279,15 @@ export class DataService { symbol: string; }) { return this.http - .get(`/api/v1/portfolio/position/${dataSource}/${symbol}`) + .get( + `/api/v1/portfolio/position/${dataSource}/${symbol}` + ) .pipe( map((data) => { if (data.orders) { for (const order of data.orders) { - order.createdAt = parseISO(order.createdAt); - order.date = parseISO(order.date); + order.createdAt = parseISO((order.createdAt)); + order.date = parseISO((order.date)); } } diff --git a/apps/client/src/assets/images/video-preview.jpg b/apps/client/src/assets/images/video-preview.jpg new file mode 100644 index 000000000..05ba21247 Binary files /dev/null and b/apps/client/src/assets/images/video-preview.jpg differ diff --git a/apps/client/src/assets/sitemap.xml b/apps/client/src/assets/sitemap.xml index 2801d9167..9e2d3f403 100644 --- a/apps/client/src/assets/sitemap.xml +++ b/apps/client/src/assets/sitemap.xml @@ -6,46 +6,46 @@ http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd"> https://ghostfol.io - 2022-02-13T00:00:00+00:00 + 2022-05-28T00:00:00+00:00 https://ghostfol.io/about - 2022-02-13T00:00:00+00:00 + 2022-05-28T00:00:00+00:00 https://ghostfol.io/about/changelog - 2022-02-13T00:00:00+00:00 + 2022-05-28T00:00:00+00:00 https://ghostfol.io/blog - 2022-02-13T00:00:00+00:00 + 2022-05-28T00:00:00+00:00 https://ghostfol.io/de/blog/2021/07/hallo-ghostfolio - 2022-02-13T00:00:00+00:00 + 2022-05-28T00:00:00+00:00 https://ghostfol.io/en/blog/2021/07/hello-ghostfolio - 2022-02-13T00:00:00+00:00 + 2022-05-28T00:00:00+00:00 https://ghostfol.io/en/blog/2022/01/ghostfolio-first-months-in-open-source - 2022-02-13T00:00:00+00:00 + 2022-05-28T00:00:00+00:00 https://ghostfol.io/features - 2022-02-13T00:00:00+00:00 + 2022-05-28T00:00:00+00:00 https://ghostfol.io/pricing - 2022-02-13T00:00:00+00:00 + 2022-05-28T00:00:00+00:00 https://ghostfol.io/register - 2022-02-13T00:00:00+00:00 + 2022-05-28T00:00:00+00:00 https://ghostfol.io/resources - 2022-02-13T00:00:00+00:00 + 2022-05-28T00:00:00+00:00 diff --git a/apps/client/src/index.html b/apps/client/src/index.html index f0b80253d..2e20f9d25 100644 --- a/apps/client/src/index.html +++ b/apps/client/src/index.html @@ -42,7 +42,7 @@ property="og:image" content="https://www.ghostfol.io/assets/cover.png" /> - + { + let label = context.dataset.label || ''; + if (label) { + label += ': '; + } + if (context.parsed.y !== null) { + if (currency) { + label += `${context.parsed.y.toLocaleString(locale, { + maximumFractionDigits: 2, + minimumFractionDigits: 2 + })} ${currency}`; + } else { + label += context.parsed.y.toFixed(2); + } + } + return label; + } + }, + caretSize: 0, + cornerRadius: 2, + footerColor: `rgb(${getTextColor()})`, + itemSort: (a, b) => { + // Reverse order + return b.datasetIndex - a.datasetIndex; + }, + titleColor: `rgb(${getTextColor()})`, + usePointStyle: true + }; +} + +export function getTooltipPositionerMapTop( + chart: Chart, + position: TooltipPosition +) { + if (!position) { + return false; + } + return { + x: position.x, + y: chart.chartArea.top + }; +} + +export function getVerticalHoverLinePlugin(chartCanvas) { + return { + afterDatasetsDraw: (chart, x, options) => { + const active = chart.getActiveElements(); + + if (!active || active.length === 0) { + return; + } + + const color = options.color || `rgb(${getTextColor()})`; + const width = options.width || 1; + + const { + chartArea: { bottom, top } + } = chart; + const xValue = active[0].element.x; + + const context = chartCanvas.nativeElement.getContext('2d'); + context.lineWidth = width; + context.strokeStyle = color; + + context.beginPath(); + context.moveTo(xValue, top); + context.lineTo(xValue, bottom); + context.stroke(); + }, + id: 'verticalHoverLine' + }; +} diff --git a/libs/common/src/lib/config.ts b/libs/common/src/lib/config.ts index cc709e96f..410d0498f 100644 --- a/libs/common/src/lib/config.ts +++ b/libs/common/src/lib/config.ts @@ -2,8 +2,6 @@ import { DataSource } from '@prisma/client'; import { ToggleOption } from './types'; -export const baseCurrency = 'USD'; - export const defaultDateRangeOptions: ToggleOption[] = [ { label: 'Today', value: '1d' }, { label: 'YTD', value: 'ytd' }, @@ -50,6 +48,7 @@ export const DEFAULT_DATE_FORMAT_MONTH_YEAR = 'MMM yyyy'; export const GATHER_ASSET_PROFILE_PROCESS = 'GATHER_ASSET_PROFILE'; +export const PROPERTY_BENCHMARKS = 'BENCHMARKS'; export const PROPERTY_COUPONS = 'COUPONS'; export const PROPERTY_CURRENCIES = 'CURRENCIES'; export const PROPERTY_IS_READ_ONLY_MODE = 'IS_READ_ONLY_MODE'; diff --git a/libs/common/src/lib/helper.ts b/libs/common/src/lib/helper.ts index ad47abfdd..69cfa5928 100644 --- a/libs/common/src/lib/helper.ts +++ b/libs/common/src/lib/helper.ts @@ -3,6 +3,7 @@ import { DataSource } from '@prisma/client'; import { getDate, getMonth, getYear, parse, subDays } from 'date-fns'; import { ghostfolioScraperApiSymbolPrefix, locale } from './config'; +import { Benchmark } from './interfaces'; export function capitalize(aString: string) { return aString.charAt(0).toUpperCase() + aString.slice(1).toLowerCase(); @@ -178,6 +179,18 @@ export function resolveFearAndGreedIndex(aValue: number) { } } +export function resolveMarketCondition( + aMarketCondition: Benchmark['marketCondition'] +) { + if (aMarketCondition === 'BEAR_MARKET') { + return { emoji: '๐Ÿป' }; + } else if (aMarketCondition === 'BULL_MARKET') { + return { emoji: '๐Ÿฎ' }; + } else { + return { emoji: 'โšช' }; + } +} + export const DATE_FORMAT = 'yyyy-MM-dd'; export function parseDate(date: string) { diff --git a/libs/common/src/lib/interfaces/benchmark.interface.ts b/libs/common/src/lib/interfaces/benchmark.interface.ts new file mode 100644 index 000000000..906e30759 --- /dev/null +++ b/libs/common/src/lib/interfaces/benchmark.interface.ts @@ -0,0 +1,11 @@ +import { EnhancedSymbolProfile } from './enhanced-symbol-profile.interface'; + +export interface Benchmark { + marketCondition: 'BEAR_MARKET' | 'BULL_MARKET' | 'NEUTRAL_MARKET'; + name: EnhancedSymbolProfile['name']; + performances: { + allTimeHigh: { + performancePercent: number; + }; + }; +} diff --git a/apps/api/src/services/interfaces/symbol-profile.interface.ts b/libs/common/src/lib/interfaces/enhanced-symbol-profile.interface.ts similarity index 61% rename from apps/api/src/services/interfaces/symbol-profile.interface.ts rename to libs/common/src/lib/interfaces/enhanced-symbol-profile.interface.ts index e8c00ecd6..657c9acd6 100644 --- a/apps/api/src/services/interfaces/symbol-profile.interface.ts +++ b/libs/common/src/lib/interfaces/enhanced-symbol-profile.interface.ts @@ -1,8 +1,9 @@ -import { ScraperConfiguration } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/interfaces/scraper-configuration.interface'; -import { Country } from '@ghostfolio/common/interfaces/country.interface'; -import { Sector } from '@ghostfolio/common/interfaces/sector.interface'; import { AssetClass, AssetSubClass, DataSource } from '@prisma/client'; +import { Country } from './country.interface'; +import { ScraperConfiguration } from './scraper-configuration.interface'; +import { Sector } from './sector.interface'; + export interface EnhancedSymbolProfile { assetClass: AssetClass; assetSubClass: AssetSubClass; diff --git a/libs/common/src/lib/interfaces/index.ts b/libs/common/src/lib/interfaces/index.ts index 96275e4d3..cb31e246e 100644 --- a/libs/common/src/lib/interfaces/index.ts +++ b/libs/common/src/lib/interfaces/index.ts @@ -6,7 +6,9 @@ import { AdminMarketData, AdminMarketDataItem } from './admin-market-data.interface'; +import { Benchmark } from './benchmark.interface'; import { Coupon } from './coupon.interface'; +import { EnhancedSymbolProfile } from './enhanced-symbol-profile.interface'; import { Export } from './export.interface'; import { FilterGroup } from './filter-group.interface'; import { Filter } from './filter.interface'; @@ -24,8 +26,10 @@ import { PortfolioReportRule } from './portfolio-report-rule.interface'; import { PortfolioReport } from './portfolio-report.interface'; import { PortfolioSummary } from './portfolio-summary.interface'; import { Position } from './position.interface'; +import { BenchmarkResponse } from './responses/benchmark-response.interface'; import { ResponseError } from './responses/errors.interface'; import { PortfolioPerformanceResponse } from './responses/portfolio-performance-response.interface'; +import { ScraperConfiguration } from './scraper-configuration.interface'; import { TimelinePosition } from './timeline-position.interface'; import { UniqueAsset } from './unique-asset.interface'; import { UserSettings } from './user-settings.interface'; @@ -39,7 +43,10 @@ export { AdminMarketData, AdminMarketDataDetails, AdminMarketDataItem, + Benchmark, + BenchmarkResponse, Coupon, + EnhancedSymbolProfile, Export, Filter, FilterGroup, @@ -59,6 +66,7 @@ export { PortfolioSummary, Position, ResponseError, + ScraperConfiguration, TimelinePosition, UniqueAsset, User, diff --git a/libs/common/src/lib/interfaces/info-item.interface.ts b/libs/common/src/lib/interfaces/info-item.interface.ts index b8119e34d..6bf6acfcf 100644 --- a/libs/common/src/lib/interfaces/info-item.interface.ts +++ b/libs/common/src/lib/interfaces/info-item.interface.ts @@ -4,6 +4,7 @@ import { Statistics } from './statistics.interface'; import { Subscription } from './subscription.interface'; export interface InfoItem { + baseCurrency: string; currencies: string[]; demoAuthToken: string; fearAndGreedDataSource?: string; diff --git a/libs/common/src/lib/interfaces/position.interface.ts b/libs/common/src/lib/interfaces/position.interface.ts index 72b99c37b..6d94e3443 100644 --- a/libs/common/src/lib/interfaces/position.interface.ts +++ b/libs/common/src/lib/interfaces/position.interface.ts @@ -1,4 +1,5 @@ import { AssetClass, DataSource } from '@prisma/client'; + import { MarketState } from '../types'; export interface Position { diff --git a/libs/common/src/lib/interfaces/responses/benchmark-response.interface.ts b/libs/common/src/lib/interfaces/responses/benchmark-response.interface.ts new file mode 100644 index 000000000..262d55fba --- /dev/null +++ b/libs/common/src/lib/interfaces/responses/benchmark-response.interface.ts @@ -0,0 +1,5 @@ +import { Benchmark } from '../benchmark.interface'; + +export interface BenchmarkResponse { + benchmarks: Benchmark[]; +} diff --git a/apps/api/src/services/data-provider/ghostfolio-scraper-api/interfaces/scraper-configuration.interface.ts b/libs/common/src/lib/interfaces/scraper-configuration.interface.ts similarity index 100% rename from apps/api/src/services/data-provider/ghostfolio-scraper-api/interfaces/scraper-configuration.interface.ts rename to libs/common/src/lib/interfaces/scraper-configuration.interface.ts diff --git a/libs/ui/src/lib/benchmark/benchmark.component.html b/libs/ui/src/lib/benchmark/benchmark.component.html new file mode 100644 index 000000000..59113927f --- /dev/null +++ b/libs/ui/src/lib/benchmark/benchmark.component.html @@ -0,0 +1,49 @@ +
+
+ {{ benchmark.name }} +
+
+ +
+ +
+ from All Time Highfrom ATH +
+
+
+ {{ resolveMarketCondition(benchmark.marketCondition).emoji }} +
+ +
+
diff --git a/libs/ui/src/lib/benchmark/benchmark.component.scss b/libs/ui/src/lib/benchmark/benchmark.component.scss new file mode 100644 index 000000000..5d4e87f30 --- /dev/null +++ b/libs/ui/src/lib/benchmark/benchmark.component.scss @@ -0,0 +1,3 @@ +:host { + display: block; +} diff --git a/libs/ui/src/lib/benchmark/benchmark.component.ts b/libs/ui/src/lib/benchmark/benchmark.component.ts new file mode 100644 index 000000000..939e3a35c --- /dev/null +++ b/libs/ui/src/lib/benchmark/benchmark.component.ts @@ -0,0 +1,18 @@ +import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; +import { resolveMarketCondition } from '@ghostfolio/common/helper'; +import { Benchmark } from '@ghostfolio/common/interfaces'; + +@Component({ + selector: 'gf-benchmark', + changeDetection: ChangeDetectionStrategy.OnPush, + templateUrl: './benchmark.component.html', + styleUrls: ['./benchmark.component.scss'] +}) +export class BenchmarkComponent { + @Input() benchmark: Benchmark; + @Input() locale: string; + + public resolveMarketCondition = resolveMarketCondition; + + public constructor() {} +} diff --git a/libs/ui/src/lib/benchmark/benchmark.module.ts b/libs/ui/src/lib/benchmark/benchmark.module.ts new file mode 100644 index 000000000..3a0eeb5dd --- /dev/null +++ b/libs/ui/src/lib/benchmark/benchmark.module.ts @@ -0,0 +1,14 @@ +import { CommonModule } from '@angular/common'; +import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; +import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; + +import { GfValueModule } from '../value'; +import { BenchmarkComponent } from './benchmark.component'; + +@NgModule({ + declarations: [BenchmarkComponent], + exports: [BenchmarkComponent], + imports: [CommonModule, GfValueModule, NgxSkeletonLoaderModule], + schemas: [CUSTOM_ELEMENTS_SCHEMA] +}) +export class GfBenchmarkModule {} diff --git a/libs/ui/src/lib/benchmark/index.ts b/libs/ui/src/lib/benchmark/index.ts new file mode 100644 index 000000000..b8cd0c1a8 --- /dev/null +++ b/libs/ui/src/lib/benchmark/index.ts @@ -0,0 +1 @@ +export * from './benchmark.module'; diff --git a/libs/ui/src/lib/fire-calculator/fire-calculator.component.html b/libs/ui/src/lib/fire-calculator/fire-calculator.component.html index 99273da11..bd45a1411 100644 --- a/libs/ui/src/lib/fire-calculator/fire-calculator.component.html +++ b/libs/ui/src/lib/fire-calculator/fire-calculator.component.html @@ -1,7 +1,7 @@
-
+ diff --git a/libs/ui/src/lib/fire-calculator/fire-calculator.component.ts b/libs/ui/src/lib/fire-calculator/fire-calculator.component.ts index 933d1899a..00c834822 100644 --- a/libs/ui/src/lib/fire-calculator/fire-calculator.component.ts +++ b/libs/ui/src/lib/fire-calculator/fire-calculator.component.ts @@ -13,6 +13,7 @@ import { ViewChild } from '@angular/core'; import { FormBuilder, FormControl } from '@angular/forms'; +import { getTooltipOptions } from '@ghostfolio/common/chart-helper'; import { primaryColorRgb } from '@ghostfolio/common/config'; import { transformTickToAbbreviation } from '@ghostfolio/common/helper'; import { @@ -182,10 +183,7 @@ export class FireCalculatorComponent options: { plugins: { tooltip: { - itemSort: (a, b) => { - // Reverse order - return b.datasetIndex - a.datasetIndex; - }, + ...getTooltipOptions(), mode: 'index', callbacks: { footer: (items) => { diff --git a/libs/ui/src/lib/line-chart/line-chart.component.ts b/libs/ui/src/lib/line-chart/line-chart.component.ts index d2c185b15..bcf004ed0 100644 --- a/libs/ui/src/lib/line-chart/line-chart.component.ts +++ b/libs/ui/src/lib/line-chart/line-chart.component.ts @@ -10,8 +10,17 @@ import { OnDestroy, ViewChild } from '@angular/core'; +import { + getTooltipOptions, + getTooltipPositionerMapTop, + getVerticalHoverLinePlugin +} from '@ghostfolio/common/chart-helper'; import { primaryColorRgb, secondaryColorRgb } from '@ghostfolio/common/config'; -import { getBackgroundColor } from '@ghostfolio/common/helper'; +import { + getBackgroundColor, + getDateFormatString, + getTextColor +} from '@ghostfolio/common/helper'; import { Chart, Filler, @@ -19,7 +28,8 @@ import { LineElement, LinearScale, PointElement, - TimeScale + TimeScale, + Tooltip } from 'chart.js'; import { LineChartItem } from './interfaces/line-chart.interface'; @@ -33,7 +43,9 @@ import { LineChartItem } from './interfaces/line-chart.interface'; export class LineChartComponent implements AfterViewInit, OnChanges, OnDestroy { @Input() benchmarkDataItems: LineChartItem[] = []; @Input() benchmarkLabel = ''; + @Input() currency: string; @Input() historicalDataItems: LineChartItem[]; + @Input() locale: string; @Input() showGradient = false; @Input() showLegend = false; @Input() showLoader = true; @@ -57,8 +69,12 @@ export class LineChartComponent implements AfterViewInit, OnChanges, OnDestroy { LineElement, PointElement, LinearScale, - TimeScale + TimeScale, + Tooltip ); + + Tooltip.positioners['top'] = (elements, position) => + getTooltipPositionerMapTop(this.chart, position); } public ngAfterViewInit() { @@ -142,26 +158,43 @@ export class LineChartComponent implements AfterViewInit, OnChanges, OnDestroy { if (this.chartCanvas) { if (this.chart) { this.chart.data = data; + this.chart.options.plugins.tooltip = ( + this.getTooltipPluginConfiguration() + ); this.chart.update(); } else { this.chart = new Chart(this.chartCanvas.nativeElement, { data, options: { animation: false, - plugins: { + elements: { + point: { + hoverBackgroundColor: getBackgroundColor(), + hoverRadius: 2 + } + }, + interaction: { intersect: false, mode: 'index' }, + plugins: { legend: { align: 'start', display: this.showLegend, position: 'bottom' + }, + tooltip: this.getTooltipPluginConfiguration(), + verticalHoverLine: { + color: `rgba(${getTextColor()}, 0.1)` } }, scales: { x: { display: this.showXAxis, grid: { + borderColor: `rgba(${getTextColor()}, 0.1)`, + color: `rgba(${getTextColor()}, 0.8)`, display: false }, time: { + tooltipFormat: getDateFormatString(this.locale), unit: 'year' }, type: 'time' @@ -169,6 +202,8 @@ export class LineChartComponent implements AfterViewInit, OnChanges, OnDestroy { y: { display: this.showYAxis, grid: { + borderColor: `rgba(${getTextColor()}, 0.1)`, + color: `rgba(${getTextColor()}, 0.8)`, display: false }, max: this.yMax, @@ -204,6 +239,7 @@ export class LineChartComponent implements AfterViewInit, OnChanges, OnDestroy { }, spanGaps: true }, + plugins: [getVerticalHoverLinePlugin(this.chartCanvas)], type: 'line' }); } @@ -211,4 +247,14 @@ export class LineChartComponent implements AfterViewInit, OnChanges, OnDestroy { this.isLoading = false; } + + private getTooltipPluginConfiguration() { + return { + ...getTooltipOptions(this.currency, this.locale), + mode: 'index', + position: 'top', + xAlign: 'center', + yAlign: 'bottom' + }; + } } diff --git a/libs/ui/src/lib/portfolio-proportion-chart/portfolio-proportion-chart.component.ts b/libs/ui/src/lib/portfolio-proportion-chart/portfolio-proportion-chart.component.ts index 78e56a8cc..11795788e 100644 --- a/libs/ui/src/lib/portfolio-proportion-chart/portfolio-proportion-chart.component.ts +++ b/libs/ui/src/lib/portfolio-proportion-chart/portfolio-proportion-chart.component.ts @@ -10,6 +10,7 @@ import { Output, ViewChild } from '@angular/core'; +import { getTooltipOptions } from '@ghostfolio/common/chart-helper'; import { UNKNOWN_KEY } from '@ghostfolio/common/config'; import { getTextColor } from '@ghostfolio/common/helper'; import { PortfolioPosition, UniqueAsset } from '@ghostfolio/common/interfaces'; @@ -247,6 +248,12 @@ export class PortfolioProportionChartComponent datasets[0].data[0] = Number.MAX_SAFE_INTEGER; } + if (datasets[1]?.data?.length === 0 || datasets[1]?.data?.[1] === 0) { + labels = ['']; + datasets[1].backgroundColor = [this.colorMap[UNKNOWN_KEY]]; + datasets[1].data[1] = Number.MAX_SAFE_INTEGER; + } + const data: ChartConfiguration['data'] = { datasets, labels @@ -255,8 +262,9 @@ export class PortfolioProportionChartComponent if (this.chartCanvas) { if (this.chart) { this.chart.data = data; - this.chart.options.plugins.tooltip = - this.getTooltipPluginConfiguration(data); + this.chart.options.plugins.tooltip = ( + this.getTooltipPluginConfiguration(data) + ); this.chart.update(); } else { this.chart = new Chart(this.chartCanvas.nativeElement, { @@ -339,6 +347,7 @@ export class PortfolioProportionChartComponent private getTooltipPluginConfiguration(data: ChartConfiguration['data']) { return { + ...getTooltipOptions(this.baseCurrency, this.locale), callbacks: { label: (context) => { const labelIndex = diff --git a/libs/ui/src/lib/value/value.component.html b/libs/ui/src/lib/value/value.component.html index 8ed9c9bdf..b55b16d44 100644 --- a/libs/ui/src/lib/value/value.component.html +++ b/libs/ui/src/lib/value/value.component.html @@ -58,7 +58,7 @@ *ngIf="value === undefined" animation="pulse" [theme]="{ - height: size === 'large' ? '2.5rem' : '1.5rem', + height: size === 'large' ? '2.5rem' : size === 'medium' ? '2rem' : '1.5rem', width: '5rem' }" > diff --git a/package.json b/package.json index a4d762ffd..8063fe1a6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ghostfolio", - "version": "1.150.0", + "version": "1.155.0", "homepage": "https://ghostfol.io", "license": "AGPL-3.0", "scripts": { @@ -72,10 +72,9 @@ "@nestjs/schedule": "1.0.2", "@nestjs/serve-static": "2.2.2", "@nrwl/angular": "14.1.4", - "@prisma/client": "3.12.0", - "@simplewebauthn/browser": "4.1.0", - "@simplewebauthn/server": "4.1.0", - "@simplewebauthn/typescript-types": "4.0.0", + "@prisma/client": "3.14.0", + "@simplewebauthn/browser": "5.2.1", + "@simplewebauthn/server": "5.2.1", "@stripe/stripe-js": "1.22.0", "alphavantage": "2.2.0", "angular-material-css-vars": "3.0.0", @@ -110,9 +109,8 @@ "passport": "0.4.1", "passport-google-oauth20": "2.0.0", "passport-jwt": "4.0.0", - "prisma": "3.12.0", + "prisma": "3.14.0", "reflect-metadata": "0.1.13", - "round-to": "5.0.0", "rxjs": "7.4.0", "stripe": "8.199.0", "svgmap": "2.6.0", @@ -141,6 +139,7 @@ "@nrwl/nx-cloud": "14.0.3", "@nrwl/storybook": "14.1.4", "@nrwl/workspace": "14.1.4", + "@simplewebauthn/typescript-types": "5.2.1", "@storybook/addon-essentials": "6.4.22", "@storybook/angular": "6.4.22", "@storybook/builder-webpack5": "6.4.22", diff --git a/prisma/migrations/20220529071429_added_eod_historical_data_to_data_source/migration.sql b/prisma/migrations/20220529071429_added_eod_historical_data_to_data_source/migration.sql new file mode 100644 index 000000000..4add28021 --- /dev/null +++ b/prisma/migrations/20220529071429_added_eod_historical_data_to_data_source/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "DataSource" ADD VALUE 'EOD_HISTORICAL_DATA'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index c5a03dc93..16249d51f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -201,6 +201,7 @@ enum AssetSubClass { enum DataSource { ALPHA_VANTAGE + EOD_HISTORICAL_DATA GHOSTFOLIO GOOGLE_SHEETS MANUAL diff --git a/yarn.lock b/yarn.lock index 64d095983..967f8871a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3554,35 +3554,34 @@ node-addon-api "^3.2.1" node-gyp-build "^4.3.0" -"@peculiar/asn1-android@^2.0.38": - version "2.0.38" - resolved "https://registry.yarnpkg.com/@peculiar/asn1-android/-/asn1-android-2.0.38.tgz#193281f5a232e323d6f2c069c7a8e8e8f4a994bd" - integrity sha512-krWyggV6FgYf3fEPKVNjHVecLcQWlAu3/YhOyN+/L43dNKcsmqiEvuhqplh3aiXF62Ds0pqzqttWmdvoVqmSVQ== +"@peculiar/asn1-android@^2.1.7": + version "2.1.8" + resolved "https://registry.yarnpkg.com/@peculiar/asn1-android/-/asn1-android-2.1.8.tgz#64b6da2b5a03ddb86bcc9061d981be7ba811069d" + integrity sha512-SgtOvNES2Aex5rafRlQiaAbWd38hMLwwtQL13ndVhDN1/NYxPF3VgeJWv3KKRY4uFh9VXvF6NuRfEcrSX5UWiQ== dependencies: - "@peculiar/asn1-schema" "^2.0.38" - asn1js "^2.1.1" - tslib "^2.3.0" + "@peculiar/asn1-schema" "^2.1.8" + asn1js "^3.0.4" + tslib "^2.4.0" -"@peculiar/asn1-schema@^2.0.38": - version "2.0.38" - resolved "https://registry.yarnpkg.com/@peculiar/asn1-schema/-/asn1-schema-2.0.38.tgz#98b6f12daad275ecd6774dfe31fb62f362900412" - integrity sha512-zZ64UpCTm9me15nuCpPgJghSdbEm8atcDQPCyK+bKXjZAQ1735NCZXCSCfbckbQ4MH36Rm9403n/qMq77LFDzQ== +"@peculiar/asn1-schema@^2.1.7", "@peculiar/asn1-schema@^2.1.8": + version "2.1.8" + resolved "https://registry.yarnpkg.com/@peculiar/asn1-schema/-/asn1-schema-2.1.8.tgz#552300a1ed7991b22c9abf789a3920a3cb94c26b" + integrity sha512-u34H/bpqCdDuqrCVZvH0vpwFBT/dNEdNY+eE8u4IuC26yYnhDkXF4+Hliqca88Avbb7hyN2EF/eokyDdyS7G/A== dependencies: - "@types/asn1js" "^2.0.2" - asn1js "^2.1.1" - pvtsutils "^1.2.0" - tslib "^2.3.0" + asn1js "^3.0.4" + pvtsutils "^1.3.2" + tslib "^2.4.0" -"@peculiar/asn1-x509@^2.0.38": - version "2.0.38" - resolved "https://registry.yarnpkg.com/@peculiar/asn1-x509/-/asn1-x509-2.0.38.tgz#7ff3b5478d9c3784f0eb2fbe7693509da9de0a43" - integrity sha512-10aK9fSxlc1DK9nEcwh+WPFNhAheXSE9RbI5MyS7FdBhgq+Mz4Z9JqFfaBZm1Qp+5mPtUMOP6cXVo7aaYlgq7A== +"@peculiar/asn1-x509@^2.1.7": + version "2.1.8" + resolved "https://registry.yarnpkg.com/@peculiar/asn1-x509/-/asn1-x509-2.1.8.tgz#b67317ba1ee33c758ad7c6145dbaa1ddef4f1913" + integrity sha512-asAcoeZ+bjy/4/lf6gbMlfmywHpxLBa7LBE4pPCzSAKBM0IHXWa7bqsDyshtywzLW+VpA+G2m0Fs7Lt7Woh7RA== dependencies: - "@peculiar/asn1-schema" "^2.0.38" - asn1js "^2.1.1" + "@peculiar/asn1-schema" "^2.1.8" + asn1js "^3.0.4" ipaddr.js "^2.0.1" - pvtsutils "^1.2.0" - tslib "^2.3.0" + pvtsutils "^1.3.2" + tslib "^2.4.0" "@phenomnomnominal/tsquery@4.1.1": version "4.1.1" @@ -3596,22 +3595,22 @@ resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.10.1.tgz#728ecd95ab207aab8a9a4e421f0422db329232be" integrity sha512-HnUhk1Sy9IuKrxEMdIRCxpIqPw6BFsbYSEUO9p/hNw5sMld/+3OLMWQP80F8/db9qsv3qUjs7ZR5bS/R+iinXw== -"@prisma/client@3.12.0": - version "3.12.0" - resolved "https://registry.yarnpkg.com/@prisma/client/-/client-3.12.0.tgz#a0eb49ffea5c128dd11dffb896d7139a60073d12" - integrity sha512-4NEQjUcWja/NVBvfuDFscWSk1/rXg3+wj+TSkqXCb1tKlx/bsUE00rxsvOvGg7VZ6lw1JFpGkwjwmsOIc4zvQw== +"@prisma/client@3.14.0": + version "3.14.0" + resolved "https://registry.yarnpkg.com/@prisma/client/-/client-3.14.0.tgz#bb90405c012fcca11f4647d91153ed4c58f3bd48" + integrity sha512-atb41UpgTR1MCst0VIbiHTMw8lmXnwUvE1KyUCAkq08+wJyjRE78Due+nSf+7uwqQn+fBFYVmoojtinhlLOSaA== dependencies: - "@prisma/engines-version" "3.12.0-37.22b822189f46ef0dc5c5b503368d1bee01213980" + "@prisma/engines-version" "3.14.0-36.2b0c12756921c891fec4f68d9444e18c7d5d4a6a" -"@prisma/engines-version@3.12.0-37.22b822189f46ef0dc5c5b503368d1bee01213980": - version "3.12.0-37.22b822189f46ef0dc5c5b503368d1bee01213980" - resolved "https://registry.yarnpkg.com/@prisma/engines-version/-/engines-version-3.12.0-37.22b822189f46ef0dc5c5b503368d1bee01213980.tgz#829ca3d9d0d92555f44644606d4edfd45b2f5886" - integrity sha512-o+jo8d7ZEiVpcpNWUDh3fj2uPQpBxl79XE9ih9nkogJbhw6P33274SHnqheedZ7PyvPIK/mvU8MLNYgetgXPYw== +"@prisma/engines-version@3.14.0-36.2b0c12756921c891fec4f68d9444e18c7d5d4a6a": + version "3.14.0-36.2b0c12756921c891fec4f68d9444e18c7d5d4a6a" + resolved "https://registry.yarnpkg.com/@prisma/engines-version/-/engines-version-3.14.0-36.2b0c12756921c891fec4f68d9444e18c7d5d4a6a.tgz#4edae57cf6527f35e22cebe75e49214fc0e99ac9" + integrity sha512-D+yHzq4a2r2Rrd0ZOW/mTZbgDIkUkD8ofKgusEI1xPiZz60Daks+UM7Me2ty5FzH3p/TgyhBpRrfIHx+ha20RQ== -"@prisma/engines@3.12.0-37.22b822189f46ef0dc5c5b503368d1bee01213980": - version "3.12.0-37.22b822189f46ef0dc5c5b503368d1bee01213980" - resolved "https://registry.yarnpkg.com/@prisma/engines/-/engines-3.12.0-37.22b822189f46ef0dc5c5b503368d1bee01213980.tgz#e52e364084c4d05278f62768047b788665e64a45" - integrity sha512-zULjkN8yhzS7B3yeEz4aIym4E2w1ChrV12i14pht3ePFufvsAvBSoZ+tuXMvfSoNTgBS5E4bolRzLbMmbwkkMQ== +"@prisma/engines@3.14.0-36.2b0c12756921c891fec4f68d9444e18c7d5d4a6a": + version "3.14.0-36.2b0c12756921c891fec4f68d9444e18c7d5d4a6a" + resolved "https://registry.yarnpkg.com/@prisma/engines/-/engines-3.14.0-36.2b0c12756921c891fec4f68d9444e18c7d5d4a6a.tgz#7fa11bc26a51d450185c816cc0ab8cac673fb4bf" + integrity sha512-LwZvI3FY6f43xFjQNRuE10JM5R8vJzFTSmbV9X0Wuhv9kscLkjRlZt0BEoiHmO+2HA3B3xxbMfB5du7ZoSFXGg== "@samverschueren/stream-to-observable@^0.3.0": version "0.3.1" @@ -3629,32 +3628,33 @@ "@angular-devkit/schematics" "13.3.5" jsonc-parser "3.0.0" -"@simplewebauthn/browser@4.1.0": - version "4.1.0" - resolved "https://registry.yarnpkg.com/@simplewebauthn/browser/-/browser-4.1.0.tgz#3e7fd66729405d6a2a2a187c93577b90a8e41786" - integrity sha512-tIsEfShC1rrqrsNb44tOFuSriAFCz4tkdDnCjHfn2rYxgz+t+yqEvuIRfJHQpFrWSnZPdsjrAHtasj6lzfGI6w== +"@simplewebauthn/browser@5.2.1": + version "5.2.1" + resolved "https://registry.yarnpkg.com/@simplewebauthn/browser/-/browser-5.2.1.tgz#569252a9f235a99aae90c4d1cc6c441f42637b8e" + integrity sha512-TxL3OPHJf57hmnfQoF3zRIQWEdsJLxrA9NcGdRK0sB/h3jd13kpGQonBtMnj4YBQnWTtRDZ804wlpI9IEMaJ9g== -"@simplewebauthn/server@4.1.0": - version "4.1.0" - resolved "https://registry.yarnpkg.com/@simplewebauthn/server/-/server-4.1.0.tgz#9ad2e32cffa83833ff8a633775b2ace5e6926fa0" - integrity sha512-52X5/U+5Fo0XYG1TuBBGgG0ap9c0ffpeq0GZfFio/DZDW4He0Arb7Q/XkHw96JK0X1sfRKNmnfC+NImplvIimA== +"@simplewebauthn/server@5.2.1": + version "5.2.1" + resolved "https://registry.yarnpkg.com/@simplewebauthn/server/-/server-5.2.1.tgz#49038d2951ad2ac065bdf8342fdb13f78ee4df1c" + integrity sha512-+CQ8oJf9Io8y4ReYLagX5JG9ShntIkdeCPkMoyHLBSRPlNY0N/Yv3Iun4YPQ8d4LJUU9f8S1eD5bibIEMjWDRg== dependencies: - "@peculiar/asn1-android" "^2.0.38" - "@peculiar/asn1-schema" "^2.0.38" - "@peculiar/asn1-x509" "^2.0.38" - "@simplewebauthn/typescript-types" "^4.0.0" + "@peculiar/asn1-android" "^2.1.7" + "@peculiar/asn1-schema" "^2.1.7" + "@peculiar/asn1-x509" "^2.1.7" + "@simplewebauthn/typescript-types" "^5.2.1" base64url "^3.0.1" cbor "^5.1.0" + debug "^4.3.2" elliptic "^6.5.3" jsrsasign "^10.4.0" jwk-to-pem "^2.0.4" node-fetch "^2.6.0" node-rsa "^1.1.1" -"@simplewebauthn/typescript-types@4.0.0", "@simplewebauthn/typescript-types@^4.0.0": - version "4.0.0" - resolved "https://registry.yarnpkg.com/@simplewebauthn/typescript-types/-/typescript-types-4.0.0.tgz#46ae4e69cb07305c57093a3ed99555437dfe0d49" - integrity sha512-jqQ0bCeBO96CytB397vSrQ8ipozQzAmI57izA7izyglyu35JBV90I7+75fSX+ZGNHmMwDNnA3EGYtBLOIpkJEg== +"@simplewebauthn/typescript-types@5.2.1", "@simplewebauthn/typescript-types@^5.2.1": + version "5.2.1" + resolved "https://registry.yarnpkg.com/@simplewebauthn/typescript-types/-/typescript-types-5.2.1.tgz#a8229ce4f71be7edafe3bfdce062b332ef494f0d" + integrity sha512-t/NzbjaD0zu4ivUmiof2cPA8X5LHhFX+DflBBl71/dzEhl15qepDI2rxWdjB+Hc0FfOT1fBQnb1uP19fPcDUiA== "@sinonjs/commons@^1.7.0": version "1.8.3" @@ -4804,11 +4804,6 @@ resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-2.0.0.tgz#f544a148d3ab35801c1f633a7441fd87c2e484bf" integrity sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A== -"@types/asn1js@^2.0.2": - version "2.0.2" - resolved "https://registry.yarnpkg.com/@types/asn1js/-/asn1js-2.0.2.tgz#bb1992291381b5f06e22a829f2ae009267cdf8c5" - integrity sha512-t4YHCgtD+ERvH0FyxvNlYwJ2ezhqw7t+Ygh4urQ7dJER8i185JPv6oIM3ey5YQmGN6Zp9EMbpohkjZi9t3UxwA== - "@types/babel__core@^7.0.0", "@types/babel__core@^7.1.14": version "7.1.15" resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.1.15.tgz#2ccfb1ad55a02c83f8e0ad327cbc332f55eb1024" @@ -6367,12 +6362,14 @@ asn1@^0.2.4, asn1@~0.2.3: dependencies: safer-buffer "~2.1.0" -asn1js@^2.1.1: - version "2.1.1" - resolved "https://registry.yarnpkg.com/asn1js/-/asn1js-2.1.1.tgz#bb3896191ebb5fb1caeda73436a6c6e20a2eedff" - integrity sha512-t9u0dU0rJN4ML+uxgN6VM2Z4H5jWIYm0w8LsZLzMJaQsgL3IJNbxHgmbWDvJAwspyHpDFuzUaUFh4c05UB4+6g== +asn1js@^3.0.4: + version "3.0.5" + resolved "https://registry.yarnpkg.com/asn1js/-/asn1js-3.0.5.tgz#5ea36820443dbefb51cc7f88a2ebb5b462114f38" + integrity sha512-FVnvrKJwpt9LP2lAMl8qZswRNm3T4q9CON+bxldk2iwk3FFpuwhx2FfinyitizWHsVYyaY+y5JzDR0rCMV5yTQ== dependencies: - pvutils latest + pvtsutils "^1.3.2" + pvutils "^1.1.3" + tslib "^2.4.0" assert-plus@1.0.0, assert-plus@^1.0.0: version "1.0.0" @@ -15660,12 +15657,12 @@ pretty-hrtime@^1.0.3: resolved "https://registry.yarnpkg.com/pretty-hrtime/-/pretty-hrtime-1.0.3.tgz#b7e3ea42435a4c9b2759d99e0f201eb195802ee1" integrity sha1-t+PqQkNaTJsnWdmeDyAesZWALuE= -prisma@3.12.0: - version "3.12.0" - resolved "https://registry.yarnpkg.com/prisma/-/prisma-3.12.0.tgz#9675e0e72407122759d3eadcb6d27cdccd3497bd" - integrity sha512-ltCMZAx1i0i9xuPM692Srj8McC665h6E5RqJom999sjtVSccHSD8Z+HSdBN2183h9PJKvC5dapkn78dd0NWMBg== +prisma@3.14.0: + version "3.14.0" + resolved "https://registry.yarnpkg.com/prisma/-/prisma-3.14.0.tgz#dd67ece37d7b5373e9fd9588971de0024b49be81" + integrity sha512-l9MOgNCn/paDE+i1K2fp9NZ+Du4trzPTJsGkaQHVBufTGqzoYHuNk8JfzXuIn0Gte6/ZjyKj652Jq/Lc1tp2yw== dependencies: - "@prisma/engines" "3.12.0-37.22b822189f46ef0dc5c5b503368d1bee01213980" + "@prisma/engines" "3.14.0-36.2b0c12756921c891fec4f68d9444e18c7d5d4a6a" prismjs@^1.21.0, prismjs@~1.24.0: version "1.24.1" @@ -15820,17 +15817,17 @@ punycode@^2.1.0, punycode@^2.1.1: resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec" integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A== -pvtsutils@^1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/pvtsutils/-/pvtsutils-1.2.0.tgz#619e4767093d23cd600482600c16f4c36d3025bb" - integrity sha512-IDefMJEQl7HX0FP2hIKJFnAR11klP1js2ixCrOaMhe3kXFK6RQ2ABUCuwWaaD4ib0hSbh2fGTICvWJJhDfNecA== +pvtsutils@^1.3.2: + version "1.3.2" + resolved "https://registry.yarnpkg.com/pvtsutils/-/pvtsutils-1.3.2.tgz#9f8570d132cdd3c27ab7d51a2799239bf8d8d5de" + integrity sha512-+Ipe2iNUyrZz+8K/2IOo+kKikdtfhRKzNpQbruF2URmqPtoqAs8g3xS7TJvFF2GcPXjh7DkqMnpVveRFq4PgEQ== dependencies: - tslib "^2.2.0" + tslib "^2.4.0" -pvutils@latest: - version "1.0.17" - resolved "https://registry.yarnpkg.com/pvutils/-/pvutils-1.0.17.tgz#ade3c74dfe7178944fe44806626bd2e249d996bf" - integrity sha512-wLHYUQxWaXVQvKnwIDWFVKDJku9XDCvyhhxoq8dc5MFdIlRenyPI9eSfEtcvgHgD7FlvCyGAlWgOzRnZD99GZQ== +pvutils@^1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/pvutils/-/pvutils-1.1.3.tgz#f35fc1d27e7cd3dfbd39c0826d173e806a03f5a3" + integrity sha512-pMpnA0qRdFp32b1sJl1wOJNxZLQ2cbQx+k6tjNtZ8CpvVhNqEPRgivZ2WOUev2YMajecdH7ctUPDvEe87nariQ== qs@6.7.0: version "6.7.0" @@ -16563,11 +16560,6 @@ ripemd160@^2.0.0, ripemd160@^2.0.1: hash-base "^3.0.0" inherits "^2.0.1" -round-to@5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/round-to/-/round-to-5.0.0.tgz#a66292701a93b194f630a0d57f04c08821b6eeed" - integrity sha512-i4+Ntwmo5kY7UWWFSDEVN3RjT2PX1FqkZ9iCcAO3sKML3Ady9NgsjM/HLdYKUAnrxK4IlSvXzpBMDvMHZQALRQ== - rsvp@^4.8.4: version "4.8.5" resolved "https://registry.yarnpkg.com/rsvp/-/rsvp-4.8.5.tgz#c8f155311d167f68f21e168df71ec5b083113734" @@ -18111,7 +18103,7 @@ tslib@2.0.0: resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.0.0.tgz#18d13fc2dce04051e20f074cc8387fd8089ce4f3" integrity sha512-lTqkx847PI7xEDYJntxZH89L2/aXInsyF2luSafe/+0fHOMjlBNXdH6th7f70qxLDhul7KZK0zC8V5ZIyHl0/g== -tslib@2.3.1, tslib@^2.0.0, tslib@^2.0.3, tslib@^2.1.0, tslib@^2.2.0, tslib@^2.3.0, tslib@^2.3.1: +tslib@2.3.1, tslib@^2.0.0, tslib@^2.0.3, tslib@^2.1.0, tslib@^2.3.0, tslib@^2.3.1: version "2.3.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.3.1.tgz#e8a335add5ceae51aa261d32a490158ef042ef01" integrity sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw== @@ -18121,6 +18113,11 @@ tslib@^1.10.0, tslib@^1.8.1, tslib@^1.9.0: resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== +tslib@^2.4.0: + version "2.4.0" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.4.0.tgz#7cecaa7f073ce680a05847aa77be941098f36dc3" + integrity sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ== + tslib@~2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.1.0.tgz#da60860f1c2ecaa5703ab7d39bc05b6bf988b97a"