From ae8a2035269f454c87499aa08db660e547ccd887 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sun, 22 May 2022 21:14:22 +0200 Subject: [PATCH 01/31] Add type (#939) --- .../position-detail-dialog.component.ts | 5 +++-- apps/client/src/app/services/data.service.ts | 9 ++++++--- 2 files changed, 9 insertions(+), 5 deletions(-) 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..05caca115 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 @@ -7,11 +7,12 @@ import { OnInit } from '@angular/core'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { EnhancedSymbolProfile } from '@ghostfolio/api/services/interfaces/symbol-profile.interface'; import { DataService } from '@ghostfolio/client/services/data.service'; import { DATE_FORMAT, downloadAsFile } from '@ghostfolio/common/helper'; 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/services/data.service.ts b/apps/client/src/app/services/data.service.ts index 58a900a72..5c946d036 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'; @@ -273,13 +274,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)); } } From f48832c671c6b72aad596b910e15dc11193a1138 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Mon, 23 May 2022 18:04:09 +0200 Subject: [PATCH 02/31] Bugfix/add missing conversion of countries (#941) * Add missing conversion of countries for SymbolProfileOverrides * Update changelog --- CHANGELOG.md | 6 +++ .../src/services/symbol-profile.service.ts | 46 ++++++++++++------- 2 files changed, 35 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d0b90eda1..5d195d5ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ 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 + +### 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/apps/api/src/services/symbol-profile.service.ts b/apps/api/src/services/symbol-profile.service.ts index 5c8839ebf..d7a0c4cfd 100644 --- a/apps/api/src/services/symbol-profile.service.ts +++ b/apps/api/src/services/symbol-profile.service.ts @@ -59,7 +59,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 +72,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 +95,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( From 332203b9e210c6296b3a7e720bf1b79703a14b68 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Tue, 24 May 2022 20:55:55 +0200 Subject: [PATCH 03/31] Feature/add support to set the base currency via env variable (#948) * Set base currency via environment variable * Update changelog --- CHANGELOG.md | 4 +++ apps/api/src/app/admin/admin.service.ts | 14 ++++++--- apps/api/src/app/info/info.service.ts | 1 + .../portfolio/current-rate.service.spec.ts | 7 ++++- .../src/app/portfolio/portfolio.controller.ts | 9 ++++-- .../src/app/portfolio/portfolio.service.ts | 16 +++++++--- apps/api/src/app/user/user.service.ts | 16 +++++----- .../api/src/services/configuration.service.ts | 1 + .../yahoo-finance.service.spec.ts | 8 ++++- .../yahoo-finance/yahoo-finance.service.ts | 31 +++++++++++++------ .../src/services/exchange-rate-data.module.ts | 10 ++++-- .../services/exchange-rate-data.service.ts | 14 ++++++--- .../interfaces/environment.interface.ts | 1 + .../home-holdings/home-holdings.component.ts | 1 + .../app/pages/about/about-page.component.ts | 2 -- .../pages/account/account-page.component.ts | 7 +++-- .../pages/pricing/pricing-page.component.ts | 6 ++-- libs/common/src/lib/config.ts | 2 -- .../src/lib/interfaces/info-item.interface.ts | 1 + .../src/lib/interfaces/position.interface.ts | 1 + 20 files changed, 102 insertions(+), 50 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d195d5ca..33490cf0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### 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 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/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/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/portfolio.controller.ts b/apps/api/src/app/portfolio/portfolio.controller.ts index 5d3c683f1..f0e75e731 100644 --- a/apps/api/src/app/portfolio/portfolio.controller.ts +++ b/apps/api/src/app/portfolio/portfolio.controller.ts @@ -8,7 +8,6 @@ import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interce 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 +42,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 +51,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')) @@ -327,7 +330,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..1ac6bb349 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -15,6 +15,7 @@ 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'; @@ -22,8 +23,7 @@ import { EnhancedSymbolProfile } from '@ghostfolio/api/services/interfaces/symbo 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 { @@ -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({ @@ -1213,7 +1218,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/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/services/configuration.service.ts b/apps/api/src/services/configuration.service.ts index abbfa6641..e405884f5 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]) }), 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..c4cc8f754 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? 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/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/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/libs/common/src/lib/config.ts b/libs/common/src/lib/config.ts index cc709e96f..7fa9e9d00 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' }, 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 { From 9c4d8bdf4b3ae799d50aed62ea358b58c2bc6520 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Tue, 24 May 2022 20:58:24 +0200 Subject: [PATCH 04/31] Release 1.151.0 (#949) --- CHANGELOG.md | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 33490cf0d..9b9b88b89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ 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 +## 1.151.0 - 24.05.2022 ### Added diff --git a/package.json b/package.json index a4d762ffd..2bb4504a0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ghostfolio", - "version": "1.150.0", + "version": "1.151.0", "homepage": "https://ghostfol.io", "license": "AGPL-3.0", "scripts": { From 424748ae905469f66d6f5408e6d8ad789e33a74a Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Thu, 26 May 2022 10:30:13 +0200 Subject: [PATCH 05/31] Feature/add ghostfolio trailer to landing page (#952) * Add link to Ghostfolio trailer * Update changelog --- CHANGELOG.md | 6 ++++++ README.md | 11 ++++++++--- .../src/app/pages/landing/landing-page.html | 12 ++++++++++++ apps/client/src/assets/images/video-preview.jpg | Bin 0 -> 64525 bytes 4 files changed, 26 insertions(+), 3 deletions(-) create mode 100644 apps/client/src/assets/images/video-preview.jpg diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b9b88b89..dfd347337 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ 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 _Ghostfolio_ trailer to the landing page + ## 1.151.0 - 24.05.2022 ### Added diff --git a/README.md b/README.md index fd737530f..e54ed0170 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 @@ -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. 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/assets/images/video-preview.jpg b/apps/client/src/assets/images/video-preview.jpg new file mode 100644 index 0000000000000000000000000000000000000000..05ba2124766939d372b71c22752109c4b08276cc GIT binary patch literal 64525 zcmb@t1z1&0*D$;f4HAN&G$<|IDIf?4NOyxYNH+)`1VQOWy5rDDNU4;hgtUZ8hlsR@ zpx+$8+xvO`_r3n>yRL75y=Tv?Sv|AYtl9jY`u!QYtRO2d3&CJ8=q~tyelMUf%S%g} zsH&^T%HNX#9T0><3W0*g1wjsu?yl-`Qnb2y`n1TlKgG=4&G}FLf4Bo2hZ7LELeMDt z|Iq*c*Mx0h>1Gaqw!xpfDk-JhtERd%fXN1RX6t{|@Bgdr z?MdB_nmhd4lu2jYUbA%WjBkTisff`a@7 z1r7Y7W1*v?p`qhoVq#$7;^5+5!nt$_51;5V9zG%drAwEuUM9RkOhQ6}OK^?!DlsV$ zF$pme2@DmCK|{wzN5>|{yM#yle_ejJLWEeT!5EULup1BxAqyOR-*DlP3XIL6 zQ?G6#;dR5hdW~N|`o61s-1~WY21Y)O$2IMOcQnnJg5q;`v@BnAYy+&6XXKDX|Ij?c zijIMa1$u=EVMwB=Ff=T5pfDr|j1UmLj!wjV8{>+a*$o~SK(M-vm`?ruHlNfyiL~qE z+!s5)ry*QaFq9CL5R!m?Lo&j~9SN`SSD9TSRo8cuvzPs--rjY6!qD`v(yZ@LG(D$} zxFU;n%Jo$g0QujV@v~Tb$@+{#Jw|vFFf|f4tyz4jKB`5<!qN$ZqsxByI@Pnw_^MR@z@q)_XDL!i+!QwM zc1sYx{TBd7=bu)o*ncqSk(^g&mQOL+YNLuS1P}pSsPFRvdA|W+@RM15@zwW5Tk?b@ zJ%Ad{*oecY7(j~1eUj7aG8>V5{+qCVpg*W{eJWwJiz4OgHwq|zQM7WExc zR+=~VQD#g3ts>Poh$K@ZtcSJX`YM!lYRfg08Q{18uE-5Nm%%APSSnN{;T0ax&&xi_ zT%HNS+D8NK`nCAm*^DlgV{N?sPPNyb^ z?3V-n3CNkB>-Jxq(f_4|_D7!TKGXuBsPhT7XR@pnXva+$kC72LU0wgAO5^s6k2AY?}`#0B}mb zCY^zfO zv4)h@{lbTM!HE|QmnhF0o?P7F9)}XQtKZOpF+{UsXRsGtWL06GKWqv}NCS$fmHk68 z&fT$7QvzrnW4udFNZjZiKuAvkJh;a=_Zhz;CpA9S;%!y<=luTRALUOwD-gQyY`~9z zuH8ZZp!)zXkf;_Z(YeSKXG3L&|4VO3)o@bFnVU|*zZCmXASHrd2!wWVhqawcPSV!T zd&ZTs>$?w=)kuz=U3))ldU}#o70n@HA>C`90@tfylT)X7%jq+CQJ=mIwB z-hO6+|I!)&6fp1Ee86jZ7uf^65T!xbO305YGRX8FPx)b<00;+C1*o;0`IyHKD`3Su zGxCdy>4hpxb}ljn>R zz?=gG@TAvZuK54M(-S$91Yv977MKF>YnP`5p13$rC>!P3lDw_ME8=)xOBZf49c7ef zqjU9r9c43ctNI)ySm4`Q2Vu|1>~m-vN(0q1C`tp_(PW!xO#D$zWu-ULno>gbkw`3; z7p4W$!RQM1Vh6c1taeB$|H66(hR>d7+h6N|3TV21rI5TB1V~&BT&R_Q_lpXQ_GqPh~xJ(q17~`j$=D&!}5agYszx!7dAtY}?o~L+f!UAcv z2z(|&q>ORn{|G6KWDDybAkj#n5mB}1}r50mtt=Nt03EfmClG=XzGue^&33Xkg9rb9*)#+p~Eqn zi%{en>13Ok{hJMkXKEa|P%=^)B%eq_BT)u+)Zx96PP0u52xREYbwrv`?ZO9=D$bb( z*8VqDpw^HxYoALP32VXXJdgN@2&v}027W}+%ddo zjQs~M|HgtcZssjZQPU-|xH zWbnCSNks3pd7I;6O*N~h_rv*|SNr9M3UC@;fu0Ax)`3Rms zH6e4{Y!c)J4Lrh;9gs3<2Q%zXQ{Y*>Jj3P*(om8^+aaG7= z#k;InW*Bie;2Bv%OL$xrF-=5t99*USzLuSfqz;!Bfo_INOHUGPHHLu-)|pgOLEDDg zX0&D3>rmA3+It&rt6z*BD8|M3N+HeH>Nx8Bxc#<)TH5V<$guzc>7*nVs(KG8PCLVQ zTF7xsGYQo>vK@^ca~2(jz=*Mg&w2=AahBZHmBzxpAjcJGi>xKZXkTu}igCOuJ4SBy zd_#LUS@jMVNiY{Y#$FfOf)xfvFwm2~4==^)dO_4?pw@6ZA1T)z$U@59`6{PVf43E7OAE;i?-~Ci|48yKpCZ`tG6U^I?3vQ&h;Hjzjch-|dS^O} zQe75V2!GbrtOV1m9eAa{g)z!ygXKVhe01Q0v75G6$DWe~tZOd3gVFMbph^m2%wr%e zcRQayc50$(Krx$~hvvRBQ3CF>>}-e_Z0M0$7zjFms?MGuGTazuTzH&M5X0>F6!Tdh z@`6g^#-j=~V@_dCVGA&tP6g(_UWVwiL64~!vLBL!E4DNC+B)$T(J(ahI@JJRw>L z+Z5tE!-=8_;UFuRQxHAJG86})Nb+FgA^9T;hR|^#i1so)eeP|LK0`vC7pptXMCgw- z+8QN!coeswvJ^T9RmU76iNDLrjE7-~o6Yy``A0ptF1IHigw})CLl4vAdz@^__YMG7 zS@@A(peehd;172vqi;>PE3P317&ef(Y3B#9!kS1}$aKstD(t`z|z#$BV zv0iItl4CL&vJDC4z#&E{=Z!OD}JfjJb{>X<_=Q?>OhmKspM@(;(u1Hm{VfDf2%HLXNz3rYBTMWc{KrK#(`^ z8VCap@}I3Lsz}$hXLR6$6wd+R5G`;=z%4GEqcsw#NcwFE2+a&(OG3;Rkvx(_y*Zws z0a;YfZx>e}7zELy(t(2G{I z7j7E$u3tEwu0~u}^06LE5(!Jsid6{yG-qv{MzSJY%@9?Z!~b1(Y3RG&%t~97#Vy_H z(PfqIx*s@Yy?kOHc{`f4u(=!R@A6{$^KXPMJc)vf{?ss*ePGfQZk~((or9o7EhJsHp33Q@!Y6L0a0#Xax~lsuCSf>=CZ zo{-i1LnYE4v|U_(QS#=q4pe5#3jj0IUee|Za4;RofRh1h{weYh1V3L;u&gmG(URam ztD|5IvEQ;H1+D-GnO}nqwMenR6y|iE7jWDNc#?$$$XSD#LG(#I!gdK)1#xE)9W4gJ z`ZroDNyz4m1R8{<`d^lVki4NXThl^)5Y2s84JKT?XN3$wk^r6;jv>CXOnO{(sk)FU zNa`*?iqdYdafQFLtBORH7q|%k>YhqZ_<04+&-0W;9Bvz3)rYr_BFWFh^kf$8{>pZe z@|YQpyd~pXp%U)?t^47K$?Am6E9#4v-qYONGc0gAbQ7j3ms<{!i+jEC5&r@AGoR_> zz39uwB;JL#?uD@vDAz9(Y;xjQ>67xF5GcNum&oqb0ce+ z+~`5m+A}E?%;MCDVDky=9g11)iQr^(ynNDsFFN&BxH%lM(Ntm(qe#FA;sNV9 zSn^U>4(TU`ZBzKYxTe0NN+R1}q@z9ZOT%E=B8%zS_3T0x!v~>`L1Zz|pXK*#K_!a? z?HG!UnIUztwRm8B3t0mmhNEBu81xacr?p!2OQ{`y5BWa>v{1IxSQ{lN+R}pbtS~fD zHqS`{NM2e9ch=F>3Psnf5KhqwSuILJD0d+=2+DSXdOcwcj$8~ef-Z#)d<<~1hIf%L zvB#~jbh3t$VFS_yULcCt8d{8UK z3o9uftam)gX7Jmbjdzo0bF;BCWRyA7ZRsBx%=t4e%2=N=+Ht42*x8#E(I}WWYDD(@AHIEjbiubd3t9rgL(^rn!y%})C_bQH^Oq@KFY_kNK$701=s ze6EJ#fvWfr?**j3t2u0rgSHVY*wwX_b;VXR~@I;&#e+*OkbX^j9%rUpbhPIo;Pe zbA`V)Je%!hp6PvKek*P{axne)^^~x!ngz97i3 zo0aXJl}+hmT4AuyYrc=us>5L%s2;zIqTV~3hZpl*XzhKsw&$4#hb4@?pRgT`ofRsT zV+m|8)ZVNp!guc4CrqRZ-MsA@XS3*pk(iAVXh8xzm?>To47%n_YE=+oV=shCH#M9D z8VFP&1&E+Z^-8>sGap1G3DKUt!dv|x>wYFsC%^-|infHAX5e!y>HWJRpL`Fngp9;25!uDj&HvfQ3qX<{B z%qNVU@MIGMb-wCGkAqwKIy}yycj;E5K5uqvtnv=A%Qr$;qY+ToHjXEo6=F^!{%ia= z%Q9WJtgesTc-=&XQ1Mul`sN_z&dZ|OiEibc-tU53DSJ6(!|_JWZgLtgANf-ny*My8 zQ1&+(+g6)=vP|Z&BDW`O?xvtvaQ98;fMOhP*F%N2^d9jMlavxQ|2IzyoAVY>T_a3u zfrdYpum z7|ka&pP#f}_ zcfaaS1Y5kU2ra0ouW!n)FQ6Z~;Z@uGL*G!Q{?NLlm+?!exg48I|GM{Oc;rkY-K4NLCb5m%+CZ;7*mhww>9&)0P8Nm z+d%aEUd&*-hYa&!Np?PG?|o7%@y?26u+Ts-cq_zfiaJIu2|_p!1K>glUDw=P;i|~h z>pUDsJzH`9T*SLLK7+Wg6-JLb3<60_u=fOo7F8UAOlTn{ICO~zo8=IX4DIt&V9sA8 z7sFv2=M|z>fSjlmb(45dUf@?pc3oje)5up!jPyDI_-4C*S?$o1-Lc(R7x1j~8? z#W%Hzr#l`8BFS1A3UU39a>mB%$UYitrsq!&-8qR$uorM!B;NL@vsMpKd8zzu_M5?+ zWm%?gqU=zPgI;0O-DCx`Lryc%5|?0oMPcg!Pd6<}u_1(|nj-e0_#C@<(zphd{@|Sf z4$?tmYO5y+`6Kr4_9s|0UPkCxxM{p7s;hm`m-k@I@ctX#aN!1D14Aa<1?%xfX$Duu z=FVAYFn+-UF4)}>jW)y*9vRm#2HibWSIDS!zNJBvy-0#D`ntAb zxg676&DIWH*R)z;mdp5ry4nWgJCLY91jYZc%$l_&1c+Hc{F>vHOUv;K~+?m%?C3`mxxP zRXpwNbVUF2SKL91nuY5v@_eU5?`Omr-xRQ>a+LEw>pl>689YjsHn1h6ni2~{eC}m` z;o?8qD*W-@2}x|+Z8e{OLPW2p1Er-;Ok?Pca{lkjKawf>-hWj$Nolvbq)4_*T`|-> z`5Sthykh8K@ccKF?k(ai8<$f&HYld}>Xg?l>t)av!cYFAxP>wo zO6j$SDPtb=%ZxJt`DWyH+)tfXOOqbyOTr8v*k5<&>%@ro3`J{nOe&Yx1o^m#H^r_r zSSe|hv{d<0E^Szoa28B2&lOw^|Ke?Apq!p88%^TVu{GUS;A4$rM7R|zcy*^CcfI#v zWuwbrgSCMazi{XZ4!(0zZv+2I)Z+pMf-E7Ycc&s*`1(@HhP9vaO_o&ObJdn4WDAuWI0PWf4q*qN4HNh^?7V}zdlQq z5B1q6mnP!r)TwIDy3xkT%KQ(r-6ReOO(SXsw4a`$aDps$j4nId>Cqp*Cg?s@D2If$ zYq}dLEFAI|XqQ_oh7u=e`21q}1g6u&IdggaI_9=XgyX`maltvC!j>WSQs-OtcJ6O- zmzfMied6N`=w4D)PY&(f8r7qa+|$>=&=_T%(bP(B)bJ2!`0giA*|Tos%&wUF@=C_L zn{HzX@u3bQO0R!IO>wv4bosD@HE-lLGXxp5^QFekT*Xu}z(EMoZ|IWW3m7)z@ zyz{AK{xK0AwKndMl$83U>x-u!nOA;gn zz;p*;?<@u!x$O{yo9meh;&s?eqdL!;>q6>27HlMo#LruBBf_GQvXe8cO*AFXGiABeNd-+Cu&BqKBS{JU_bbo1R~ zVzND(EUAcaT*VF5GDJl*;(*1@HG{eP)++*ukmJ0U52m;D>p9FjKGJYvwsu5?fLpPc3-vSUnUu5U#Q_l8GXC z5>O~=>zk3y<{?gYoTt@%AS%lrYrbe~BSGO>B=S5=w_QB3AcIxJj(G9OiSh&P5WPO@ zP-0v2h(R^VmyB`A>Jeaqa!?tvKEiByGE&urs#1 zzO-d7NRT$!&OLaaGE9cjHX-Guk&HFsy4MXh$TkMWa`sOO?p)Qd!v4gj$>mdy$2F2U&m~TySN=1e zB1Y6_hB`64E)>4Jdq$DWXxk7)!^fqB^Aruhga%RP&_ck)t#l`3Mhhc264OAWoCJ8pc=y@*1@#a&kk`P7Pvn9ouytkBisg%HEfuLb9=hzQW0)@fkgCGX5J76a7 z6&9csi1<8W;>_B_&4x@M`64?(0p3saGY~MRgH& zbj+>#j4q9ZYTRf#6m<;pceZAtmYox|r1xTG{Lq18?K>?Lx2DbTu7>o+Z2Ap1h0Dv9 z-;JU&{0D_unZ59O4}=khwS(n1iP9b{7dhNI+QZpTWtS5PFYWze`zDdvL~?XPRzZm` zc7wK>II6yEX6l1EH)R>*d`lI=Fa=8ZS0O!#a%xXt~gb>nMqLhR)=!nc87$e z>Cv;LEwfH>Czo5(adkHmqWEX)W@pNljRM1~bBLReHf6mYX)z4Fjh7^SH>)Okw_Jr0 zWPq8GsWGPbw&Zxpn?T=ewSoQiDrqiQ$F;4Djhud z74`Y!mAZK&0X_u#!AgyGsgXtd>+ynGwsM={!UWsVun6^iIT2D|=zkS+k;F_Z&*ekC z_i6noCQ?eVfedf84z7EUP+M3iMY4F(TSI#COToJ~S2OWu6?|aYfh-B=a@F}vkK;i~ z2g4apkro7n7lj7`ZOFm}p#&ja|4+dM=^aQC8D@g7f>DKFaNd7SIq(hut;j-9MNmcb z;9dv6`xc5(yAfU=P%!;IaBUu|&*fl3!D39IDpQEuq8Tf7;+`Vphyl^jLgfG@_}Y}m zC=$;C_gNuF#y}7F+!TT^c!D+p=U4fQnJx`t|>5q|hfzv;Gb9PH}B)$i$#sn~ht3$z-QESDzT zeO&xnnwyRFGxD>NrK=S}=v!hpJlI7d$%Vtz#FmW{^^L->Jg_qtQc{fCO_LYzSr@L` z&2pO07^r{ve&^2VwW3{&eMj>(-_CNt}h#GuN4=kcFb1bX82yFatbyuML4{wh-MWrR$U zSatg0BG0ydxzp1zR+Ur6*l`}$IbjK%n5fN%G`#8G@AW0SF3vugZTQZ5$taTBQ;>`) zm&KFs?bImpEa6`TbND^;9_JPNo3~UlqKf9`H-sWK_&?du1OHA` z{_8mGUMUWxd5-d|Au$g>9nG;X8yrgS5!#yT*57q@B_55HFMAXjs)x>`Q|cIcNT4m2 zk|*h;1|*Yil}mjT&7*!JXc$}6KaYd|<>~&BIAS8o?LeYH0$DI%*%0@4+2=Nt9tHK7^4~=TRdVHYgv{t;dDg<5*cOX-h+028 zv9;ncAb9WcbCs|?ZVoK$?6^--P|-;d5vX^m0n+7lCF>zao;JWDL7WCbWDqPH8JEBs zMg>wqA`}E0$ZZSo;m17?b`Nx^2N1@Aj?n-tvLMr?qGS!$Q>Lu-%#-Q8oAGGpEglJ_0%z{PB`kLew`YT2->a=?i$D{tVLZnI_C0XcMc7MCN`#j`J+T z3KN4PF<|iR+JNvSO$ine0Ug64J;?Y5vcdU~AjU4nH3i{R+SPhJAKFcaJBs@(HBmke zM3k;yj8s?l6&vxxYO<%-yyDu%$GFyN2X!-TN5|T^pAD?ku}f)b&n{d1NEl7uJ*G?A zh+pxu=&mQuGA^t z7CxUuCNhhk`u>|l5r$1apFH=S<01N?zv16-D0Y~IkRmivJGlOc>`>&xA}^$S#tA4!aufmR=!V&qCZsfy9%>+$xYG-+K9wC0}if)ROWPdN= z8_oy`hIo>@`twUu-0IhQm%a$G4Z5!`Iz z{Oq^C>%B#oqoFYGz=PNDpdgvXYw}0d{iGW8mMaBl)8EM4P5rYUPWxtYGc}u^ekk*} zQUBs=L0GNj%kO#Pu!Gxsw)PW=*JkXuMeTMYavBwjoQ2yai~MCPJjbFJ2A+Jyi6$() z`qK+F{mN?=YgX#~hf3maM7;M=6S`h_nLkUdjmkqS9 zVsb$&(J4gVS9)p-&I56A(dy0FJi+`xP6h~hBFPM)wqia9Ar3G+8W)TQq*$v^uWZ<8 zj6s(Pav5G{v%1Brx3&UckiGE7?IFfBibN&fBNIf0vGK@8Slm=F3b_pWP&bt&v+r+G zY+yWgy|RH0gA=uKDVk|w_ez3s_KfgLxdgdPw0N_E6yS(|aOF@EjVvwF{~6BM?f`=q z2&HhrCo>=wj8+F`CF&-IVs%JG;XEvM6TJ7VILa7manFv-z!?S(L`qgWiwh-D?&m%A zS4L~f=#eA58>iqFB-&HoL`E#j5-H%pf5~SecT6;6kBjbR+P(cnF*{@Ntr5-44;)2( z4fzXu@pE&!?eiW+{wk3Mp&9xE)=Q$^t5*mJbnG*)bo48X&x(f&i6q)GTT-`wQ_;6f zsPE=UzL%-+ZP}s5MPOq&c%Gl*iE?C$LQTY8yv|UPVhAbC{lTB48~**Go6793L*uh| z=>3Ie%ihYY&T5D=g}y8*ZqI1RZ>oLa{nh;G#+;^0c)LpVzJueK`7;Sl-sO)*1{#e? zea^1_$7B)r8E1dkucpiL{P;v8l6dt8@3-~TnmpoMpK|%PV_G*BN#8WRlBwJ3>Fd+^HghGhK2D8W`!mTNjwSpNc|jWFE7TRE+OCS1+~J2X(D_;5CDNFz&uTKhXE zX(40qE`E1tT*1_GSi4ZNc^p;H&9>rqsm-y(U_}T2KlCmz`wvct_uVkGWy|*?S#8L2 zY(K2_+JnWlhYl!d${yUC(G3Ohr3kUtLDr#;87ErUoh!ld`$7x3KR-8gi9en9B}nT$ zW>9)0kFZv5%6U~*Uz3>Gt@7;1?sa*`;k0Gl-H`}G;@L?Ll9?kKU(KbRdR@Bt(%RpU z?dk^Rt%5RJ{gEOexuvjhW0_K4`P1OnPd9cQ<2q7L2Fb^^=)Sjp9k0&Q63eeyaS*-x zK4kn^$B2<~1p5}#6P9^@Wj%dOXT>y*%+GG3ipGt3SdCt5?iKsTQDp(tc~QQ6lXs#{ z%V@K+_gFZ#wKkO%Ji~0{zRfeHK23Zwk2?H zB3YZ&iMwG{sJEX=T+Juyh8KyAV#V^~Jbs4v*&?wu9bbqCEfuNdDZ2u@No$SqAbgRL z84Op-Ybwik@ynXxG&WUo%t(LomI@{fzSVh3rM)|F8TT_(ViX8wh>Toyp*_cPzAuO3`l6byKJW8P!RGITk~XYlJrlY7h#wDO4y8&iEN1C>w@Gb6>|b z>%L?K{Ov3V?GgU%TjU!=8(=01K)0l(ARKNu3V<{9MaxFMpAC4hnOPa8+Tmo>UE<5= zQ?$vq;V8Ng8V1Ma=rvDr^DQ+_uy+OJt`P_>Aqa~hx|#t(oINmr?-ZE9wh4m8ZQ3yR zd`HF?U;&7GtO|=m=R=x!ZQAal>u&BHXMSKKNdgEC%Cwb!bB@_@O%(X)-6}s&ihFwDUHf@*Jb_G)(ewXf%4GOXeOK8dry*Fa? zby91WVLZcMM>#UlR!;ctYQy%PpA}u>jwZFT?sz0x{s-_D&gs5Mc{KlhmzBe}sZ7&g zZW8lt#!QCx>EM9_qXLZsCt}Zzxv}eV^Z<2SK-s&Og!r?wb~6g<-$)WXSo|xBx73SX zuqeW$`0WHqqFo6&KBkboyLZYdo9d}L)K~qUcQ?&%-C$Bpo-dO?P5H1+OMyGyAZ{&% zr@M)=NhEVse8cbPaBbIiVsxHaZ$$m4?w9x%9t``sL+*FrC`e5q3ySzPl35XvfX?HA z&)cg7ixe4?Tm{P{@dH$4bfJMw{mtqIL~*^(OdBe?SqqudV|59Q@UVDw+%D16S4W7E zFg?NzU=# zIEwsiuMTUjwF`9-zKhBEDU56R4vW~*N|^n-vG7*(sT66K zp4TCP?K!oxl#yh?n7D`*F|X_DNh04xbxBbxd(^j_u5)yYUv8z+xour-7iJV?eMQq5 zlZJD4s+-#K^DsA^B;>(CGdTEuqxY~<%m zQh8IzMAKA))Ryionb};WXwz~Q@nAUH73yWQ0ys+X*$KL0BV6@T6>u!_UIaKogoC*6 z`U-gM6(B!y$%c?Ed7D;1>%5N142+;4B@7DgK@!?l274hzJfb^O9PHVDKT)fpQETXY+!0 z@jzh#9GO6a(Ne>~p#U=kKDD_zwHGgmgM@-0aHvks{fAqx}I*=L?*Gi)9{{{n$@_&7f};WKf2{F#O7{F zBS}Qx;=L_-ISLmQ=#+DQ^eeD`UMk>Bq46vXx|*hEtsV7DU&}e?N#?5EM|Q=Pxvg{> zjRH%n#>oDG%=StgEriU2B+HeQ(NKJyBF>trWTM&i)#&FwGktgii@Ezk=|4166-(qk zHIEwATHegE@1^!^VY<4#2k&H3t|`^e^?P_SH0RCnyZu@^pOBkh0c z>CU}1c>TK{#TA^e4fo~!_w)Tk3wM<7=H6T|^ou9fM1Dwwwuq3#RXhu3&rqR_c?XH$ zopie{=mw5<8^UekvhFjn(Wyq2>!c7-^qZCtHxt=y5$Qu{;Pj|764JX=%0@(-gd6R4 z+Z19opUpL&&1QDw_Tv7I3S0%5y&{qXdO#LXq2;bmd7TF%i%32DM2I$y$F+|X`@vag z6(r?@N(xzFaM_r}@W-G^>2kM_D{f(I@x{T(=k42jsw8d`P>)~B0L9OzKlhI~r z&^SjLMS@Qf(ZCW9l4FpO5_rJvflR^njEnSa-vkIBLq9R}8pd|ztMT~27ft9W8XKze zx1fD6m53p1Rhy}>#6Lae z8&R+9weP8x?7cTs6NyOD9UFN<Y?7`;iJ`t?ZG&FOUiDBRW1 zqMG)-mppnL^mmqwqH1Oh)%uz#UAA1`qN{K6!Xw)cP&NAaI;%ij{I^iq7md?ZPA!$~ z3Ans&V@O$Z(e{4U<7_CALLS%--3;^+wCO@;s715eLg(lzNH9~Zy3IxoU9aAqsFY8|W67A1AcrT|~+f)79cpe6&0nJU2<=EjRqS8-;wOqzl0NG-7PoP7 zOzju^@ZLDb!B4wulcHZYZk6*tCYYA?=HcX4@{oJBBmK;&!LDnhJW)njBO&2M&6<|q z!%Ee?r2zi6fE)JYLxj`Qh7%FRV@|GWhY}XyeXwz~ZTpjENeJe z5BvxyTHxI=H<>-0mElw>etLQ!(9yekL}Bt!CR?>JJ)yLqzEHvsGuwe7`8~0tFX@LL z$z(_6+a#;@1;u0O)C^h&@;|Asd#80$&iw`-ik_ysqaNd$p7_b^B<&{NZT}T)-(A1P z#(VI5gYQk=__FM}`iEEF)`Kz4F_yDpTm&O@@K@a3i@S&yb_SBQheQ?MQqvZx$KiplC49et=DEOE1j zD&H#8s{Go0Gbep=a|iP`r2b|O{Y|z3H%*MQ1dZMCM1A>IDa+_`pKg~({zLmA;)7!y ztsIJ{1!R4!%SJCqes-(4`O?52JyE9p4Q-hJTA-QNFab54pTz{9c7p;!7f%Qm@N3F; zsLIO+EN}8t(^o|sbyL$0&)eK2RxkJAY|M=PZtBNTm^Ji0fceYh71zj_{ItG^YvykX zyx){gE90z7t)za-jG%wxg4r{ld84Q^^#RBFNCw|W^0>W2@rLWd9V}v5Y2F2IkE8KE zeHeXFg6PU;U~wNx6?skR(uL?0S?&0!tx-_j!Vr}mP&h5kl9h6l)#aOox8XVVprlyJ zeKyrz+!_-)$}&)Wb975TUjuP!bkEFsnC^g=Myuh- zD%+orbtQKL$iF1}{)Pye)271;9L=L#qC(3ceAcuQZFg(!+HnyVJ#eTI`@@OosApXp zAuaznK}Xqr(rXibYP660E5Mj}hk##Yk@r{Bc+mKEcLWbYpO{~l8Nt{1e)l++;f=Nm zbJw7#eX5S8nSR2!f}37M?ry3C-D0N11fL^AQ*B*Yj#SzYZJf#}w_z_af>3c<@$30t zIg3dAs1b)Bqiu^P5-(i}i}6li=~LW@QS|inC68{qd6ZWvTf4Y(2}g-DdG^#^Vcd~J zNL+9<^Gm4Akk@OJSJ+vBM`$PN3sghbSRO3{uW&*64Xq^qET(_+=mbv!wSGs@1mU8L zzs%1<|4TFAtF_vAZJw|{LW=#=neZKKPbgU@Dm2e0VH-ZhGi`L zGqbGUYmXD-f1G+FDp!B)(0o^s=@94A(VAD(3bs^Gp`7}DoBenvEjqDcBPtqy?vwt2 zAUTKoN>fGmLi~=(Q{t>s2J=%*%zcbSmDQ=E(oXEv1&!ZOU$j}ZHk1Ijn$3$v`ihu{##Hm*P1i{G7x5f1h;UC+rOT96=s@TkJQPZ~wmm%fhb zhS7(EMB~2-S@6BZgnk$!`=tA^hsZ&B!upW7KmISmD}||oWEz#336=!Cbw6#@#xz~_ z1cy)QLe|Fe@@GoIiDuHN-NM`}9E)tWlYSJ$e=yL{WY&26v-{*hFf5mbktDfW==Qd*Hy%YQln$zU%e6F>6b~bqKCoq_8ftpjD}ZoiWi(nR(WpzWp=VmA+LIucD#rgYHaK6E z3TA_YjnwB|2q$VVLov+@>xacbXx99(vJQv$TC~*935E96-Y8-p>c6ETx9~CLl96x5TNYW(FCAO2 z2k0M57|$!Ii@XlBbj~zH6mXUpsgNu_Ix6Y-DgTHry3N5l#n!E8(1efrh-rPr)%(*g z)+&?r{vWU(zoF1ezow@AEl&4)qo>~TI_VG95bP{{4d?y?7zk(;h zzmsL4ee8fIPOwtRK6*^Ii>0rHuor#)wn;TEqG0XA+R5GV-%!C(;d++=;{C(oUsOXH zx!VUUM`o@CHCYCS{Ut;9OnIWw4>Gk5{6(A!?yS;OO7DMUYRD)eup>d7HmJp$7}5+G za+fIHnRIZB@9LdD73Dn^FJsxz<_YRVXot`=*$^xj35vwII@IqDjd)u>Inpp5DQ?$^ z&wIptvffV`yK_|85h*9#fKX3laZv8*Jgx=PT7OM1myG8ShQY z`mJ)bW39s7RB<#*kt@m^s&PKLYptj8K@Yi-0xSErR?0=g_$D`$MEkKIv9(Mi8w)4mm{k|`$57P1 zs6Xn{o72;Xn`&Y9ZvEmzA?Y;cZ`E6L!XCYl?>29n*fCoSxBm^5L@R!F>mLh<#W_fo z3z?RE_DErCPD{)7z@R80fV0bspQ9o5Sj%Geh?G}XL%cmagZEGH!nAi2yN((;87BEhji8q*N?1_epOn6YT zswWB;MaY|Ot&1rZuX&h@8k8iAJTiBi{tZFW$AWUBEH={)uL_70Sl-GNrF;~7gW~eQ zyHa7LmwB@Aa7=M>>;PsG1tt^!=x^vn@{XKb+oxS%4y(cMQ3T`_S*Hna!o(j1jr@jc zhkgpMq=U_sc2@!lzluoW53S>{4?KeeZpAnkSMPkmHi~Nuwc;Nx57wGnyta zHF2{}bowQ)ymH>`2-ay)+t9AcZ)h-fYtp>^Na)RG+OKK3W+Rk0OgrJvO4>E|OL$zu z4NCZa{-h=dKGJ?evQb@EAZ|!Br=It-`1myo%On3u4!w@A@$Zl5Za$njYH@Q;J?NHv zZQ>b#XN&)#o!|auLpQJZBkA2J*7=eX))M83vX|X0OwqN`>*l*O6dA%sQw34?)rTrX z(7ePb7KCS2%L^vc;zNfNIr|3!LZfy#i%3oU)uy&n!q8;LfMsdF!fiJ$kKH2?# zo7zdGrMzfvq%lVjr*^rKFW_5_iw8B^e0Wp%<)$K8g>nhG(6JMRNvWphkuRUr!ZOHt z)G}!_l_Se%ZC+kMNI8mro@f5SH-KNzeY)`I%bSIOpE-}Vf6;6X9nrv*ANEBVS2BG& zz6PSCoL}#LLxIm};Nq)aM6QLN2>RzwPgWYMm8)a-x~Xpj$TQ7usgH1Ov?*Ubu)Ox6 zVE9GVY22p{aWaqWwej=~ri4VzAurj~0)@*aRxC0BnDZYgY;}@mwQNPkZc3#N8YWAB zmtwzog0462 z8MSf2$+aHvW?sSeS+jqQP z*PvAVO)dHT>jJrbquGs9PJqkSOaNTj! zIrTb`Jk@w|!aGtIMOoWYvQctiyXBx*sPfLElxA&CePM!U7BOUJJw1B#Q*ZPE%e294 zgIW<)wY;ZXTe5`4D<`5-2GM-wJRzAsOu4CR$TyDAK!4xN(ul1`CHYy6%lH$7s@q;ZUUuqn?d+cP1yd{T zhOshaSMKHMxIT+^4ae=VLjM1YT4QhiBBEhUQQwBChM(IK<9VjiO@Nx{BPlUsa0P@~71Jg#jiiInj>k(xaUXd0|@DW$`uQf_1cK+C>gi53{^UyiM47w0tkf2ev7sHV2AZ95*0q9SlaKtak;L?ASkUZeN` z0+I-UAYBE-1nIrTLXm!?9%&Im2mwN9p%g!EF0F;X9@(#KzWbfQgVCc@VpK~O$Vt$oVBxO<--KCU^VV`Y zROgDiv_v!COUgR;JgjZ5o3Ku^e#f<^Imp~b=jaz@(oM#qqMLVHkna!P{m&H_bV?m& zwO<)dSt(KeoWNG?dP~6Dp;F9bshII%M*blH7+s@t=gl9fuM95oK6>%b$iHtNEtBjf zTI?o-qArM$2a@>f{(T!5p2U<7oD<&#oM&MU%d8@N1+ahwx6u}hQE!IdYYBAxywpQ+Vl;>*v56-LTH)(M=iN5AAi(Z32`5zzO5Qc^zsbz~lBL5TD1y;F zc$n3P_QR?ac=n^012=Ep!C+Q4PgRg2-5t#u0htcU1(I*e8j(jrq?khklQ~TxlK3O& zVS(iSmsD`~*`LBmZYcxwn&fo<6nRs z1;0lGneCjW-Cuwol6xB0+St{s$T;&sw1r&$Rz|}2Bgm)eg{=REr}$Z6c7@pMHpDtY z*MgRL%BaJawlls}{^6@rQ{x3zY2`h>5YDOr&dk|qp;5QuOi!)FOua6Q;p(e>4co&v zL!PJF$W9@JLl7b0*)IU{?C}Yw)07eV>=)qV-KkTUa@ly)dI7aEbe7+9ggjjK3lQ1e zmR2_CC0UoUHGct3?|3|W=+t~rFO9V_YItS}G@YnaFM5gm*2E<*2>G-}<4p_5n{V>g zHMR`NdSv^N>@DjubuC%Hd^y!;T=T^5aU_qzjZtfHyRzu-E7i8mnHJr)tbliIqP=}6IrNYaW4EteHV3bG|Ty8`Pz=`4bHQ!MS*c5P^srtA;3AMu0W(DUvOOJ|p@j+4 zNjzA*&B-@eFD11Hf)CLn-B95sl8^<0_^Y&3pV6Ia*Y5=b)nU%C3dKiDiIC#WLXGKb zZVp(kmA&yuIv<0m%Ng*%)js091iggEHHBZV*u#IFaMOI)8 zKFI%otm`6>2`YY|X4mDq0<*N(H?Tt1e@WO;5bhvId$zDoWJtpDdA>?}U|L2=iJowT z+XfnuEtnLt=Q)#xN(UW`w43yy5Fa6I`NDBWobRp9ECQdyJgufe56k;i)?Z7 zg1TtTbQF5%@a8^|Gv`cndnqEI=`Z*5mUT}Cz3dY#mm*F$iLrthYyn>+&(S7?gKak9@IR%Zta#x3xOAzR^s!`aE^vURY~cgcaB9Uu&wA%5+>vP zUIn@!gq#`!^3ehlFz8E!TMqX3M$|svzsNYdXbD!VqND|i-bp;U%E;cW(BK$7c~!v-6dJ4Tx4I+t> z2F@(I%e2x_zU1HGAY;X z=l|7uYbtbIR)ojw--R9ZA|bS!#F1-^fShrK1TE>s&##pxdX8-5lg+Thg!yoV=hlO? zIxy#^2t&W&h7~DU^&e_|{A7s(CPK>o}QcW+`l! z_aMZ&O z%u>2ifoSGrctxaiW|FAZAZCm)2D&BxCoLqHQ*@5?&;D>(YYU^qvVt?ckkviM6&t=S z%mJH)4YXmjwup=_UHY+CyBO_Syh3!Ad4*xF?q{p?^PqpZjy|8VFvDwx@Y?2~aOR|+ z23~71Cug}YDsfayU9m}ot|6;aT|WB+PFLKeTxWXE+*o(xxH8IVA;fGy9A+iW5RbPE zD9aQVuewnr*GJy^Xzk&w6DQ0-?yxlr1|Eq20x(cJEF+}tcwJ9DOPH~%umiI7pwjp9 z##pjnkxnl6P)N*)zR*>t(u6?qJ^!=vGu?Fy>w3KWs)gLK*NV=Ajsk;QFL$du%q;G{ z_x4Q6MSohNp!)tYbuCVMd0mw#Iw-3cvOWjlL=ovyPY-87u%kQH4>BF?RcgEIlszgHiHq9B4c%)CG@|&8~?3X$OW-GN172^lmkE(LCyqazNkEtTqf|sAFjZgA~`E+xF{3j%FGNA99_s z?t0nP=0exTKzWDZU3r+CDNCc4LRb=-d-`cbh-$sau<7$|2aZ_KD(ELGVW|G@piIRv zg3jZpnlvxAyVtE-FsKZAI!pfr_)pa{)MiymF#CfkSgkjaKT?8(y-^>K>r--0>hJ%p z;Q_8%5Y+;VaWHo*Sn@%}6qhY@Crf_2IV>Fq&wgYDvz4qvjl?qwH-iHOY6Hw^y)QXs zkz}7qHOQ39143v(|0pMksA1EsUFm!MzyvQ|oKEZ1l^qOdvfBmU+NO0;y0SjQC2J$+ z(`^?bW>U=v*8WzJoho(*C1iJ zQ}ph!O_TYyZKU@XL;cgS!PKMlH~HGK1mDB*uHjm|w-S)z$h>}zcl-VhP@atwu;V_& z2T#p=Ym6}xlTH@xSwn^&k~fWg7{L_#V`XMy>KlXmknK}JQHOQ|@(g+{>;58?y|^*g zhTjupZ!p6$0BJe9lUFXyMVDPO-`zMwnv@t$z=ut^HfTl(wpo8V#vCT~I}@xmez32G0(=?yq##MP=B|57{QFKj(wbqWH9l*>w}n zj&+ZX-BC$BpupMwjrXKY0SUx@Vp;x_N<|35X?3v)x_2sPRz}5}wuMn7YdJRu(-Ih< z$A@GFzhVQjW%zRqX`{drX5$ttTyFhkk+yg8UiyDdmvY2>rONyR!y9s1brR}u!2PAYq{tyCyHDfWw2kz zNjW6Dl&0o_#%?Me*|uxgt%tqixSsppu-To8zkI(}ul*`L@teTAe>?Rh^3v$q0QFx0 zes@6`p+P5V(omszH7PEjGT4wA5#&CH)fI1pGdxcFzbVARc4LWq35nj0m6-|@?Q?s6 zETJ-fXlU%ZJq0g;>&k81?Y9rv24Bt)xv_6>apg^bwZ=_@Dk7GL2P~oP-z3-gKcPGG zwS(!<{=p zK4g9YJS-o7vABAm-1nr*#Mx!FUB(!rZyXiX?J9VspTk)_aQBm6u$msW{Y;c5eD%xd zcqdN0-a*^h0Q6q-wi_ibwslNfQ04>LxKbdSPifd!C%~3!(VQ1^Hdy_AxH+jR)R-WCR8jl$=cSxw>5Xg7eM9vehq}0>FAw8J=6Zt<(M8W! z_zi&x_{J2eLgsw!AF1ZxQsi&GRaVTX=lT1)S}E!8BY zArC1CgfG}AWByWJ1EdCz$N=RdOb*RViOCl&+jKI?SfXx@ddwQA0jxrDRz^ZI*Hwux zYya}sQ!L9;+xsDG*DgUT9op+ky&jZrW%La9x{3k)LKc4@?$2Y@G2rWN8hIyF-?Qr)AbVdtbID zL-ppHbeH|dk;ew}3Kk>NI&Jc&mDrO$)TN2`gI4h0)a74*N$Gi*Wj#tKbxWzmDZ)8- z+w3Z3MdJ0nM7nd+;yZeVbitbm!}hV*UUr4{oqjLtpO}BUi&dpEGm&lUoLY6s1sNN( zj&%>NB)daY)>bdwMrey<5*e^pAb}{UCL! zYnP*yD4AV^zZ`X*USgW*zr65XHF)62SMHy9Z|I!VN4pVrYk-0`*S&HsdDh=zKCYWW z?cCgo8a?v2gcg_t1MVF~jb^1AEDvw|QFiJ7R_U38^Z#I<`D2&GBjknXjmKYuBhM>c z5hYVb0QWJ8PlKD&4X?B_&ei-G^ZwzFv~xu(mVUQ58*p6#6L3K%c>k<(7R$;i<&dHnLw`( zYfPa%)8+(-6bD>TT6TGG|6NhMU0_Vi*Kkw&!ZYQu6LsLY3%RkKe87iDtf@&1;~qo1^6r!3PlC-S z#+|V?EY zzW~|hki;b$LZ@MfY}26qsqKe)?((5owg!0Ncq45RY06311Pz=j#)?0~@O#bjd(D-g zc|x05da2W`?wJ1G18ccacEdNZhI{)RdmX68U=XP0$mZjWbZl9yp~Z%;N4qr;bjPeR zuxa2R!0h}R3n^}A`9cfl;3Fs$bv)F43aRFl)Ce|&;1l<(2~S4W&_}99*S2>-F*{LS z^XEUcX$NS(6ezLG{VXP3+Y?vc;QLZa1PBd#KIaxQoSUM50V4ac&`peU^&j{8A!&rt z!K|Sca#NR+4g$>yZv>TA8{^PZeN-D0C;T=t-jhD#b8nc5?Ws`@o_;d2X?HYcB)1(W zwjF2NX=U(Db?Firan_EAPi~XVO8*7usS6h3TZ&%??nz7xB?HOxunel`rJ}rs!6P$) zLq0f82eTMT$s9IUGdne5_{48C4bUrmw^RA+gQhdRLG}t>ue=rICWT3wA)9(9jR!Nj z)ia#jbfGJFejW$4UKz+@RYq;g6leF!$c}2(7jLw!X?X)$GB66l6*_F<4S}fNqqLVT z3bR*DxG3^Z?dm%U%%9-m$81Mxd7ECgrg^JPYR)Mv8~Xc3?;CTsy|%j}&Q$h4xW}aVeX01WZ)x!S(hRlC?)iVD zA2QYHPk#R<;lPMx8LP$NkcjUFQgjPbP16hwMuvIs;PjV{dQpb(97JIgG>Y*%^}TdXY!=A+Fagb>tL+!4EoS9NGN>bG8~BZTiNQI8WBiRrG=%1SpK;zn}B# zZaxM}k#7YRBP^`jfoikE8X;I&p2NV?o#io?tWB&=CfcyG!!zBe-PRVj_Dz`0V%EUf zE<7tTn;+_*)MA(FxldH69N@aGQ0XoJ;Rr8c{n8dqPF;Z6NWq zmD>^K>Xg2_xt=%+n`y8$+-g|fleXGig-yclCHR@!Am1|vSauDe?}}YcB+G}ENux;p zdqpda8zD}s!+Yve$!#Z=nB}X3ZxQMqOlI+PLEgM!-=syB9u1yeuuA!VbzqF;^H?!3NTdHX?!H+FdJjrb+2f_ zS&6h{%}KK-U*>|(UA||uOS?4nNT8Q*u z7$aG7bX-5CWI?!rlC>18oJF{_;!~L<+FOqkEPxQ+N`(gA8QN7~YC0srtW>(Bsjp{& z3C2^Q=8+O3<3y~DKRg}ZaLqIkCpE_-I=IyNXGnB)2EoLSLw+uu#vp23f08mrf}7xk z@Q&SdRbn-(MMc$NnkrsD)yUkfU+46XhW&DwNkt*Edr4^Ye_6184>uMC1e%i<@)_>^9P3fkVZAtX<^EywDJ8%wwM{w#NFQ8 z#pn*z!g_C@1w3g=jN3x1MBg(Q%K$zLJkF991At*>-$r9>EDn|VRAZ)*J zc2x*EMk{}YB_|IhHqe%(aMYkzBB^SM63g&>Ea2!%N{3&+*SjVPyb zCKq5ES($lOcTQ(GPI4&hiCJ_^g3|+C-&lGB^x*(AyHVL))7f5V?K!X?V2`A&x~>gx zsmsP#%Xc${bCbw#AMwtQ<7DtSYS9`J&AbE(&3+`jqfZa=^uW z1K|%+rc^)J)hR6ugcnYj;PqS(#t^ZonWM@MpqGkrye?H)V|*I&kmi51%sKRUh2I=KxpsU=BEi#fTgazI<${5sdXYHHJh z>wg~TKQpXa40+tXr(mcWMod0AyPf2a=Dj@`AV;4RIWcoOO=GDThTiKs|L5-~{T_g$ z-9=&l4m;#-LS53Ijm(K%@qJ+l^Cu4guV+Y4C*H6qvA4gw^pp7sAAe_N>^XCk9aEow zC-};Q6YcfDqiUD?MSudx-#_-{S!i)=b+xMAc+2#(&ls&3t0fhmDC^O_5Q%x@&DsOd z0B|^zJm%$3ZyR*P)3Jr>{x92Cw^5MYneC9j-5Wv_D3exQ zyz-W@4cC9xk01D{muQBEV5{$5TRj2Ld=yhRq7Z9J=Gqk?!W- z9S=D*_HRE2aC5ZPhrs%3#+e0X9sB2icg(mr)}B+T{e%^}-I%pv^&MLKAt?P{LK-J& zGElf@0@^%$B2v)TewH;_T#^ye`(!Ei^JxI;asPJJ`BC@E&yohoWfh)4p5XwGUx1Ih z8tlb1FUKm`LWZUi!HZoJ6T14Q4bkxnAe(eHbf&L?3_l6BeNX??1%{Fe6>dgJ5+G9s z6rn)NL6>TzNp(p>-pYhgV)S0fDn%|QnB)0;VJzgnYAN3` zr%0|Me7S)-c>d4Eq4T5IMGw>`zpEW!9*34;qNYyOn7$39PdLt_>DoTEKbbm|DX?@2 z`Eai>8%y4=-ncgxY&WY}dfND(I+R$Xw>Ns_y28A2d(o|b?;e*&M^}fdE1^wm{k%1M zm75ep&KGX8PZ8Wzhj}o@#{MnA*9T?-`l#g{s*QzOJ_kx3nG)*N^VvIIgOk*ew=z`&qtQMOy9P&gIYv+$=9Nl>NVH8jvRGC;^nc}2{<&SfRewJurb=^ z>}pJ_fJ37*7Prts`11W<0QM75T-YOU?PbClp^C+ z*5d!yXTZGanGSu%i>-Qydp|k1d`N@S)drC%(Rp;Yzjt%hlK||e>MACA?E2Qg?nf6L zC#g4Y_}BoMc00C<{}z>hmbxus^dFgp6sBRCIpVo^riy>S@x_IIsk)vI0WW?p;4CVi zvAcSTsReaWrK>+(dKjhNL9&qS{Y<4|b3Y9j_tEub|{jhI6QlMWUAla);{K!~Ho zXBR!06@RJBV7%zaHpj1cxI#HQxT;ZtpZLzDpmQO z-z~V+7iM^&-mBVzl;K-DgjWe%s6K-Tf8L_A^KZlof2#9Z&VPJ!!L-|J+}yQO28A0A zAXqxJ)_V6$*tzA9eNVRCkAee^1PfI6Q5G=;?X+|(pR;rQONO_)X&YPColK`sM!i5w z3E8j&7bA!)`(B9dP{n|#4I|kp%8cg~whu9@s}j`;yIs2Yjqc?wX&Korze-9^=rOU+ zG8zd(cqSz|@+nM;jch6G)iT?5^oZ(fHbVyk%T2o+@N^`89 zF;-l~VbQSixCqq4IZ!w;RGCLJO$)v5|3FrD`T4UYE7Mq@fBTANL}V?=xxj{8HVK3! z%e*wy?#*4`o~S)u6q^{%*I8T~nhFc>+E@p!xi052<(r5i4O=6>DkKB=A>+T@?)@DvQI%^c>6#*kr$M19dKF@wy&LVYt+8Fw{H z!!DUi_?@-0YQ6W2ztpXWtLq$nuyU^r88|d=UgBw5Jq2gdA+T%2`OV)w0*{z49-;}^k{t+?DN`%%o33md>v@7L>W zHQ6k76U{jn-u;35Eejp^hg{#wR&tqKd&79G$NC%E_`bJf38x$r9!mNj|MC5*#0M=K z*WDB}v+FOETsI^C;jd8iFGKUa4@=-}kFMazOHxVXR}(003JN%EJ48%XeTLW1&DpOu zMAN!1ySpcJIk5|gz_u!>i}i&glP1ihuDihe;Rk_w>+fS4&Z=`u4r++Ok4cW#W9o%v zo1>%XUfm36VzRj1>yUWS$IZv9;o06rOmmZOoMr!Fzhm~+X>pi69-mz)9AM)SGHb)x za;Y74)#*~_-6fgL$}%$7%?m7}c0aAn|dgq)Gya81HdL8muMTYIh-!^2ClM<>9=MUEDqOHKA?=KLLzF*8 zCYwYX=MTfSCuV0hc!Q)}Z4!$L3lb-?CSFL+`S}*>Qy|(6@xZyzte(Azcw%diY@WO5 z6H;}4wl~ke)lFXntR8ubVlEZl-^l6EqOL6njlgeiSfJ~2GqQNizAPc!E)Z~{kDpNsU_?lf z+J>>t zoX+|wo@GE4x~A|AH^KqM)ztv?V*%phvvfxwO^kDeE*~hNPfbrm^G>Pl=?=xEJ343; z#EKhZMR$+xHuO2Mxqd9me(bYh#VxlQ_ECZD20}4enx>d-^m5(LK}cq`B@jXKRW;lt zCN(+`H=<9$DUk#kLyVg=D7z||ZbeCsPC<_BmoxjDEp>1Htn1E$45D__Q@P#lDAX-ak6Y;v?JFP6 z)y*feGJdjpSmUejR-VoOIayny zPhwxUY3NSY7pJ(Ua@}~sY?x%M>a#*$)#0)Pn=@2=^9?w1dB>i)-ppf&o}5LZy00to$ZP=33dK~3cNQcx*UR^Q0R#_Yrx4UJ zZHdL$3jbs-|#c?5iet5@m zFHrAJLbK}lsq69c(@ob=j#NIIUjUfq^RwM!-6_fllP4!f7Y4Zg#$pypDX6Cf#of!u z-%6=c^>E90>(uoiy2TKh!tXerAvo`FmXz&WB!S@Vz69$7eh)l5m?y8TtnX6VJz555 zH*5RGu`c%pCbs#sr*ne|(|=d#iB#4JUkg7>G@L_Ro(I=q-LH5@`6UHs1fth7t#aJc zQp>Tp@Z$Sf8Lh1hCfCR6{TKPxcfX~O!Wn+O-<%@7%y1TuRg*J3L(6ES-FS+l!`(Uh%4jt5VKmj^fADr_E^2lsb15LVn{I-U*1 zt2-P+)fqc*0bP4S6=^e}`RG&D64B7Rmn(c5=JSdqCBbF$$ew;D`L}y>QUxu7Va(0Qx20j+Y8u)1lp_T5*4xXBbBrMeRIU zH2Cvj30sY&S&if-d*bF5cRt~@cUmvmmQ`AvIF_iU*Rp1^hCD=hOW zJJ>Pt;4sRdG>}yN5lb{{Ke!&gRT6i|@^iv%GP9=;tZ&s-k$hd9KE_0TUocK{ud2a0 z^BKinx|3ASli|BQvG;-#WE(mwt=NDEjytG+#_{Q^7`WUkT^ZO|9(!7`Oing8&V)Da z;nws5nCai&hq8<4CxH%&1^PFEKdZ<&wr*WOb?r=8UqJs=)x{(LISLQpPk8~nlT^nC z&%+EfH$OpGUtu-|0bCwQ548_WVo_F5O8jHv{5$4YUzyZwx%WwYi7a1#TxNE_IQLK1 zx%dCDu-stf2`qqBET|HBlx24QF_SsBw9t`uN33*8il9MY9VsJA(=Y2|8lF==?nbke z2)|4ee$mYy?|Bbl{piZ;;&ufxxw+dd{2aN7vbNX88Aae%n-Zz(k*u7$DvSG^R(M{# z9!rsXNFL`akHzo2bGY*J4hs`r_6A`8;N0hb{)FLg1}Fe~*F$eFh%KkW z%opk**I%rVa3o1fp={gl??mj{k|q#A9$iSj)IH%) z0rd*azp`O&~#yCt|x+K)(RceBD?B^ZTFMWB?Bt}!NPvBdh zz#zp#zxLape&YL>`{6Gqu6slp;2z!Dt|pzt4I@V+e*sp0oIL{-9{xY?HTp8?4?+yq z35;0b6GytQJpR?e%5egHZfE?&14I%19?71Pu#!p`m?B=T5nAJxkczptlvHx0i3hSVSY4}f`uAm?p4q+LHcSv5s;S>Z<0-BD6T|D#~onEJKs_UKcht}(`Ra|rZf*SHT7-!IgI<=aE*uM^Sk z=Tq0+uUVU%wGNrpx1d; zME_>&FaN*Ie}13t1-U1M3gC>~lN7C|WOWFy6|gp(ypvNBB){e3&A&0Xk$Y-BY>++K zt{g-ywjS-NVEX-rz2M(|KA}vsGPvvhd3O0hszaicgV5^vYW)-d6MytuW!^)Vj{;nN z_vgZU9r>PaK6~Q5Q?SMTY1nEhe<&h=3%Y+P4Q3sdnin%HK~s04q(v!MrUDZpgnN9(DO8b0$lA z^N`tU$MmDppG=GubJ}9|JK|tTx_* z8P=kzr6maSX9+c^HcKtCf{r_kXEWDpRrDM+97i3Z4L*)I z3?Kir)_8Z^BhII~yVic2lWS~ivO>}d*J-$J6yf97$XX~PI9E6+#H>%d7-Nt2PTA&i z4-;eGdX1croyRhthS&=gn$8_t3&kcIM0s=7`bFtE1S91ZXpMJQEF3z3Kuk~Sm{$Tk z1lzG4ay3>Ecy+m|3zVZ_#_2{yIphw6I%kWi!n=&kUBz<_kumaTBGE9)eN$hIx`Yr* zn{-In`YD^f*AjmUQRqN)@buDL;SIOc8>3dw&$<-ST-d72$P6Lrp-@MR{{1M=^lpQu zhw+eRb#o^I^rt1Mkk9zT#A3-8ivxj`+Vfyj^Dd&Rs#nPvBeS49Jh6qs%rnF|Ugod` zwp-{%ZjI}r@zgqs?CnC6$xzJj_T@cQv&b|U+MWNwqNP6M5>-!(kyU1TJG&`hJZ4FQ z^X8oJl`*q{RcjK4Uja{L_Eh0hKRD?0mSwlOb5szNdFHo#b%p49%`5Z4oQYjeZIaE+ zoJ8B!jW+_pm{qzdKQx(tlc-v4c*+#7Z*rKAzo~F>)ZuX&jysSjI zy3B(PukTKWxyZrg%iud?Qsb5yOHYk;v^v6#J`r2(B?>`R{ zj~0-FrUSVp)1jC6!_U4>FymM;W3Zk-=BLr7SFuV76=AJ2rXYILuSVZlO1^=EU+DIV zsaNN;USG0;bQD+n3dkX`!5wV@S5=sBexsEl-d$v7HgNI_(7Yi)r1JE(jV%F<^|=@J zp>&o#Ng;`{`oX5$1yDrg>Z!}Ma79aMhFsv;9jq$B?muIo9)&R#ASvsZ2Z{QspGkyW&Px*GdD`TlC%=8lySPGEv7!~hLLt>G&jT#ybzl5J5}D`yK;O&B^n3FZvBh*$;H zN-NI1!Mq7mFZ5kh%4N;ArAUSYv1fS*W3y@3l6)htDUvQPBuA=#U;Qk9g=)QG+Wh&a zpJSA~^>sb8!7QS$Fv>FllTkkBewP5ZwWuI_r6b5rj~1O$hM4NA$%m%~RHLSGB4 zO>zIW1BKqo+NwOJSk-nNB=88?%}!m z^UVHf*3a$$SiS+6)fbj`0LI?~S0)ht7q0*=-g|fMArq8&>ki=0(6EWgY^e+Bq4$3` z4?5GmGiT$R9iwqXSrrPG$h-)~n^(Vx2;L1{Wn)&of=!!>d)N=DxPyPNzfF zbq1@uuEt`#bvF5YlG98zw(5rm&WvS}%?EM(Nekj&S)h?0JX6p)E34{$YhyX)Shs$K z5)Pe?Xb#rw+&dK>dvO*#(BQGScMs(DBjq;&iG{Fhuz0f;sZ(HfZ-i^DAIgV*tRq^9vB;qu<@i zuFH0{unfDz?UzXB+;~@IQe8>S*)$#nbykGf1iMTHR=A@QkH+!v<6<-+25E8@)YzD5 zN-lerRI6nux;}xE|3WIs;vODYbCf!ooPaexJ4KDIL;Q#K9vqp?$_bYZ+tHR)1veVJ zf0Utt6s~TA&H$9C+`cS}e2vca0|Ija0~LeO%c$(Flw^(T5pRoVQYKi=FOEIOlBJ@e z@BV+=#APQlVNNV1C1AZ-Z7E}(4WBZQiyC`f8l8Hf(YpF+y;p7J>2vD{uj_VooM|b= z3&lTkJuL^5+uo2O%=KpcZr^>K4cn*4AjF~3NvH%3rqx9rr>#G~gvtrNIyvN34d1(6 zf`Qh3-ZP?hgPsIh+O*&wF6%OqZv-X*AYUy|T2W z{!4|wb39D^;2R=oFh zw3|Z`84zLnI>;ggiqZX|E>qq9BpdPtcHhR8Rrswz4L_s0_h|z?6B${FHIAz24!Yu~C|Tc|8LuX`*Nj)lQJ%5dJDmoCTRSGlcH~%r zHd@33P4p$H7rljp=Pb(}fZLW;oT_;y(`s)k23ELq9QR!A%oXnp-t4(cPH?T}{qI?^ zMAetFdlfkqdXBuOqE}=_1C|Hd)%wT2Rma=999dgE|Yf1|JdP4IK&b+=QKnA$IvB)S*dL zijbU8^*#t}kfV%o&@xSS)Zr*N-jD=+>6rqYthN>c)2!E1YFyHiRm2SmtxH=|V*#<0 z4Wf-~Fx;8RyXl-SmWsH#KDdVKN2z!|gvTq+=zWVbmf?=m(>;cs(gEXSY=YwPSkhe z`pU%j<{}Ye&U*W@ciUsvc?aeKga=Wa#y9h;^^L9NVuW0^$ucKuWEp3IG!5K`?s4QH zZJqR!ohw$IKs0eE<}BA3xY!pqp*W1bT)LM5W!be6okwIq;ioq-Xa3iNSwW@Hc<1p(Auxm<5fUa}7%9$n! z`ZqSYUb1!&ZD<<2m{WfAzO6`V#g{3LPhIwlzjzB6;JopQiePH5BNq z4@a!HrzC+N<;8i ze(VRYpwL~HjocH!vgoH#yk62!6S!7FFq!uO>w<+geL6xF)7#M7pf_ron2-cBE7!zt zMp8CchtO!|1f^oY-X(Q#!0RP5TS*i*jnAcm)trHSy^CQ)AcT7L$;TEyY=2=3uS?N= zQ!?1dK-tgH$!fMMe1@>)N7Frq? z%9=k?^pReFGuGn3{|qbqr0s9@>A&3HRwJK<@l$!k>}RPJB1xz`PT974-LI?0HPDARoR-ESh} zAK_01{!FU7^4|%Qbsjm%ktNT%kj^X+Q$yOPp*yx4AF}J74z7kQApSX-L!FUo%F~8B zPWFO2DysXe#VkY?egVdWBZc!1S?*NF8`-!p`}PEYWI#Gwzy+guVA6Pp;ut&_`t-op zC_n{tds#(C5TfNc&)7P9cmBnTBgZ|#T+B%7ggx-R3g4xL9c9Wdz{u(Ujdbc_7+XFK zXWwmq0l>ODksDr{xWpZZGm`w~7hoQg572O2V*cfd=!^Av*}Z5quDVZtuQa0!^~8U7($Dw1IuvIMtslbJ8ld^4rVOjKi;iTQ_Gk?d zin7{wX^{xBB#j+WRY+LBoh*JTvP5@e;skJ=c@EtXct$Hg{hi!}IxM97f-kW(%NA!RSbCNHg2dfk z5y;Xnz(zzukRevvHz9pp^U#v7l&y5s42tMg|h(Ai+xwV7R;Mg=C(^c&o;U_-C1}@h<`Hgyrg+jwd0X!qEwfvt){R*r(b(<@J z9?7k#qkg)X8q|-EKj#fSZ*lTe-c5_}g-7@VG1Q1oq^dYt;xik3bYPc~`GQHz_Hw^S ztC=Z~rXW$H@lUhC54XKMB0oYp_c%i%!?H%L<{Oh`%AOVKog|5{k9T+ za$B#XifO{?px8*CG8i1}+^(wN8d#@Mo8=iTQDlTdnfM<1(0pMMy$fyY-MgBuys%yi zdD2?hVA1U|EbqK>|KpmaZ>-KUzx&Jn@6j>HC#Z#yAEI~mx;jf1WK=Q35}x9g}Gt&7SZveDacl&bdF0l9-(8DkgXVSN=eYE-n- zr5hPJw?;@q{Zext5!3PZ@e$s6ljK6v0)1moPtz)?dBN+Y2RU)t%icC?%JQpE%q>WH zIf;5M2O*9Q^_{ZdY$-kg6$66Hdu!)c>(5ur%-GkxR=V^U>OwY6e|1BKibTOxt$o}M z%x&53IA8x_Ah06aCOzy)yIb(QXi{I1SM&{Nk_cSqUIM}>hRe|Z{ldEP4xWnE8B^pD z?HQNu)cl+}8r+bsq9Aj(zKl$xnBpWao6freJ>q-CVmh&F0Z*3q^=V4?t3sW$#c`O3 zuu(xhx4cYMdO=s*5hXFHN4eB`j%)-xNz+`YX;;z|oh)>VdksQihi3 zGL>*n5fIh4Ontg^V?F*p$3@c0P||H$?kv#Xqr0UY`M$&RyS${?V)G(V!oUk*#~EZJ zCp7gffL{UauCMSnIr|QVx1OZLhuhP!9E@$a?De(uhPJk>y@~pCKKJi`+_?ll!}p|W z|3uxJYbIItqg)r;-G<>W1v>r@Tkio6*Ve@ikBA6@G(AXys3CeUksD=1n_+Y!h#-0| zgCs>M@^OF&Ad z8p#Sb0VX6JZxib13p#qth>uqg`KDwI@EiWaVU@0=e!*?d;F1UGyzKP&L3=SQ7{qiC z3q05UW(F9I)xKV7=?4fUl&-=1fgazBXhQ(-n&lIq|A8pO3oXyYBwUzd`9|sPMc4o9 zu)aw~$}u@e0q-LRGLg3sH_Y>l66@9UES}8(E|s6`vLAMHSN-VrB9nY~(YNbQ8%^)j z7M3z4pa(HF@^1)VRT_AvGMYUF~dB$Goq}i7Du2JGEtxI2w8a^E&=v-;Q|6y_vi(rJzmepgYUPR?-;Jjni}j zwlO?{@@z*GlwSawnY$H9KE#>moLjEKt_SD?vP;r8DM4Yab6n}Pz&?VY;_iG9PsWJp z^RjPsN`HgSf4cq+!f+gk9mseOcOD5ruwBOJCH3xBG4r<0@CPNijWFMMRo8;;p&lz1Y-_NxhR?<72BE8p{ zH!{W*d)ZT5yn{!Kow}pkzBE>u1+t}`#qGmI`i38_uNVUs!R;)SNcta83>{;uA7|~I z;4N|Svf;E|i`=yuuTjj*SVdDqVA*~LNXq<^)!M5|cjPJjW4i*h#6}IMHycp7ygUHD ziD|(R!QsZ4rd8yEaHL-q-qUqpsM|o1vE}<&-=dbnFbvHzduYCJ`0FhX3U5`yyY07K|C_E ztz=xC4=hb-`Cb11T)drw?Y&L+V4J=9HuZzSLetve3Mpgc`QN#XOXX8$3=? zu}F6qC*4@L?Y6QbvHaWNj@4Bq(3;%y|NfR5#&GzhXkuyFw!*1)N|x@J{ld}%xcDS- zNvn#J~uRVcN7GnU^aCtgf_La{jaF;cAcs(31IM2JXttSH`PTkPHEshvT_hS3G4? zgF#kYu=bKHflFNghOReHFo+WUV~D{@SveRKj2=s2TOeb2ZqNAKe$DO!pa`#E`oG># z0zp~6PI66tg}ej8;6MO1jS&E@dtKBuOSQkJts%#e6xtbO1LW8N$A}WCMC4pnj$%2W z^8Unq^p#3dct7H-WMUNeA^`|kNP%oTz!^r835;f3{R9HIw?}|=11WVOBn7{K?Ujp; zYd@GfNF38N&946kju|8mQUj4(fS7Yl0{MUdXt}6MvK1(M`&x{ZGU(4aoL3yh$X*zV zffAUi01C6B#TYsd^31J7qHZe-RyNgV{i9^!(fl^~E<;qE;9m5dXq0mLY8pe0p_z^5 zuEVBJP|E2sTwxSCZTHu4JMCBb%pbkYLFvNmszPXXYalT?u=|(Lt(?MLnW3ZP_4S+%X@`$}&{$q*csbqvj zjjw8Q!@yeKwCcw*i_8-)0?M`G{Fd~wq0iAA04AphMAoob{q`aCAt^XhIts8HdW%0; z#Y_ePyQ;e7WkqmvF~{8AdOMFu-qy~>Z|sEBk(69}m}Et?zvG6TR%F};igW0kyS-n^ zJmzY@&d@q z{*s?M5L`0DZF;aV<1r)IjUz$cd$w!G^lx%phEpqsaF&i5ea;5l8$V$Ii9xyI$tup1 zc?W*TYYKm%85W}}3g@84feD-Ye%`8MyB+5$>>6`&eW&m~jh>ji)1K*iZI*}TTiT0g z&fbN@^{F`xhme2;!H!k=N=z4%Ds0sIp|rMl|3ZRD!w_j=r(a6dYK@4)LhjIk@K6D^ zu2XW2+`(;+$sE&EGwfg_&IAEW4-ZxQ!DIbt&Wbm?+c7PTHB{kNG8L)ROx$G*beks% zdBQ z09IvVf2FndLo3hjTDmsWJAb1bGbg7&J~kEkyXO53;a%5b=z-6v%?X+213X@>ccjqI zGqSI_&WJ#?*m|}?FtKqa1Gr1-YZ2|VC`E+Sah;P-J2b(4upD*7E^xMYobfKHt2q?y zDzI2@q%f_KwU+c{#+TV~FT?tbHtlOgDZ0roz(zZs>&m|PMjsY3c%vY%?`G<9MkYI$ zLD+2~9dkcjJIn%tcH|nOu9NVl^ERH^&TDr!dPa+?YS-oH92oy1vrorBmkmt; zkl0kWz+Z4D54hG-&eOgOSM3J(6LJKlsacTZUa*F?-A+`8{N8T5@heTm*%v&Q_g;`}g`NMIKb2;F7md z)ge;3yeuOn+anGD5q+|_=eE&nqtg1>UX|lr_t0h{Z5$p}Bk?6=B{KkdDXx5QrZ(p{ zvI5RxQe&Xwh87n~r%4a!Uar_;v}=*2_~j(*DIywK_C8(q*PIvPJuQJ7@n%QreIvIP zHY`jqEJB`v-oO{Fu8`qCp5|OKF%%xw=Pa^8K!a!ulmMLraKAW_UWR?1rY2Ky;-h*f zB%wvq6RKb{C52Cn7j2P#6utI^Exjw_!3e@N`omI;`)!vv^H5HY948Ci5U;uGxrma# z01}Cq7t(ZW?e~PYd8m7t0M?=l(f>VIH9k5e_peKac)Z+sa1%03A)(gPBvr`u0ZsJm z1^*U3zT+d7g=Ms->?g6gmtXx?;lg!ljR!2(abjcu0p>`}<+of+w;mG-oA$n(KMpP^ z+<^nIfPwAke{?_Sl7(v$yUP^};et-0?KtJrN$__ps1^h$b72B7n0n?`62LEir4aS% zW9G%#FRxI8t^m(Jm)?;Qu2_REEL9N6AD$GT!UY22Spb!Ujt+R9HLi(AK)2HAxN26Xvk3HmlLrdA)hx*o>Gb*R>v)C?al|M zoqs?68}w}l;fRpxAo4G-_MA~@=#^*a!iKd9ssr=7og%O zzQfoVmtQG1ixztFW=(d$yp7ML`^aG4W6roPaFTh?SN1xN$)gWSn*18hFzgQtSei9+qmLq zoU{$s)oS(JR)LDs*hAHbbO2%@?aT6>MN`h4t1BC#En_UEGJi*wSw`AnVIrb~2-j9c zFKbm4?5wbrDK4c1(9-*yxjN-d+JcI4tiEY^w z|9J}j+&V~9!FFLYrgqQG;9ZuMJ{#wkvLENJ`QV)Dwmm)-K1%#Vl7E5qdGMU3rk$L$ zznPrI%i%rS*}ywc15?YFdChKa&a|V~Rus@DJv=pfD6%`b1s+lkT@dZp7PD}@Bw5Cl z)+3TRAxC0QCB7kN3|f`BQu=1iv46)U0a_|ex49M5$FdncMUvlS~*0khE9fBbZopLK$>jaLF9}S1Hs7p`` z?0*HE^hl;rXeA&xYt}m0RqBxyD6$5A_q8p;&cUhFvcxO`^)qW~Xk`kDl+@R-ch1qS zcQ&V6S+D{H2Cno@7j?{|R+6Hp9;&#Q@#{sHYtHYNRbmwJT0*P0vMjE z$(u0wX5BP&2{rq1()_E;QH8H=n59Y3 zlzGf+Mr3CwJ|PYM!z>cs6Cvwz)VY8zoEKc;)D)iCbs+AoPBg`+DO0fvJwrxOK@Ry& zUT%TS&SK)i&7OI+hY8p(-I{V%&vRussv z?k(wFvmqfH>}tZ=Ohr1k{=Vw>fxWR)eYHuA>}e=~pXTP~>sYyAo36ewQ&;I1pX|Q8 zTIpKbknm-P5tn=P^-e4uO^$D`ueTaVZ0=vRLW|AR{pq%NPE^oc_MkLzT#)m&Wpmfr zaCF%qgpF!*sa8{cuR`S>bG6Ke-{+YR=K!mr7+!p0j`~-?pZBYITUR_X-0?1ev7>U+ z=pMs+OMnS;%#zhUM#MUd!2C+$fw#E8RrOaObxJkBD1J<&iv*}wK!iwMFG`kp5DVMF z1s%^Gh(!O*JEfN`HK`-l%z8e-K$jw_i?E-?y-98 z+q)Mv9?+%WE04bdN>v1OEiw~i4ca4>VtSkc+8|W|`hjtrrDl=hbt%D0Nt(Tvd}wLE z^DF)7xyWG&c|w)>Pi`|w@D*jXD}WQ=5(k`O$A z;WK448roX_ben(?$bs`wEp(p7HV2qsH*tNk+SMNMLGqF#w~kT!y*IXo)Q34JnbDFt zrVVKmQz{lQo`>hQ?}|zZLrZLy#|O{$F?RJIn$6+UL5jEbwGU2z=8DHlyY5Ok3a#%^ z-oQDS10d^ONM2r{o;&BNX*2t=!U?9@6uNc?%bepxAn1)B3pqy0VYm|K{}R87PfFFU zd{V}5@1C@D`DZTHnKHK7>^hTHxqU%vLQXh_%}B1C^F4zJsg%QT`?dO+8DL3J@yDu_{f$N-3^5z4#QeK745y!q!1E2zeSIH zVk+Q)zdP6E)VAI)eXOqWN?LG0Hzv8mxsCjfg`B^ie!r9OkT?YkJ~S|hw6|xR0*863^4IPqZz}4!scluJH2~Q1D~Ef5DQin* zWtE?&ojF#5D%rz&1B0&ao&@+F*HP!_6ILu_-1V{DZeTN(V|KWzebb$m+XM58YQEDV zGc~8$4l09miOhUj`s``Ds>rOFP)+8IL1NQFFJ3x_a4g_^#QvowR--yR8?K}(WwZfV zyY`an4A7w2TGNO}L9`48&?0KC;P1 z6zXf_xkP-|bMpfq=w?Y|Z^gGbFii^#_-j&??3!6sls4kpGIU53Iy78&JRqgQ*Wo(lR%o)haG z`r<`{w|lmQAzn@V1FUd^qLTKMPKSKyn^L3hNeZfFYl8B;~aJ>TXyX0$Ssx zn`mt0qofNH@1NtE^HmyrOKI=Tyt*EQ<9kEb(Ik#{pH@eMgmdp`=Ynul7ltK06M#-@ zy!JF0L(ycYG%7?64&^!#=xpt}A{5DdBt z=r59PuE}QOjz4jbhSmU|GM{FQWjUevpEZCUk%|Gq&q|kL_c<{?J}K2EfH!nt8rAQ+ ztAinHfD_gC%_1ofD#CMeQ#VbibrN;+Th>FND)EA-tenEHpd=8Z}N^iDn%^u2Z>Xs28O;sU&+T$91 ztCOdiit060AoH$ZiQCEp^S23G`@S+;CYWq|_ zF0!)^pQTNB@*>R?v#?vc85N2_!>7Tt&l#%FA z$Dg-3Wp0kLf&3iQd?~a%`eb5o(vEm$v~VPGg8J>}@i$13MnU*{pyD(_#^SoYF_%81 z>3nujeQ95xS({j^*mA&Z5Pka8Hq^U=aJSKq@@7z^UtrkcuW3ISzYG%=&x$Ek-!i~x z%a>|zIy{wAbSXJsj_?|+9khQkqDRD>#*}HapR>l9c1eB;3{MUEe(psM^OCu>e6YD+ zlxImN-EvmKj#vDW2LO~emspgcd?lAx(5HPd{$~ST{ayG0qTX&IxKn6={rwvj)~mbX z^7+J+RuxxvYt`S*gZLn}s_-NH%UrwuplULR##UVWvX3Uzf`G3nM4z3t0u-C;dD2|F zX34)iNXDC0wY7@%$z`r`CR53`=Ds*TZv5xhG*UgPRr2I@+jl+1YmG3*wpX&D0yIDy5aUlQpi^QrFX0O zhJ5~yQuk!`#O6;P$+$LPZ`bu2n)hO~+BpmC&|~wVHXofLmdRP_Qhn_1KpRpWt$)kf zM6MG((Y7gzy36E7QxY|?RCp$vmNor~FjEgH*6fTa($osn_qR=LH06a{yaCmxG_O{+ zlapu`>c5~6bGdiuM}ghawSr>=ofEfA)jQwU$3YZYaFf@g&RYCyIpkf4Lc6``>c?f{Tk^Y>m@Z-B zQvcSqwUu_-TfT`)${WTT;HR8!bqnp)9W{4cHryzbE@)l=!Zjea`-(!&=h|Q4Jt#XR z9c0e)VxIEC1&}U(wF=ie^~=FbL_Ih)TPm{4nb91R1A#239JS?pvwtip54ELmSGb=S z(X8{fbHD1AX5JcozN@D>&Lmm7En(?1z>>^p@dTH)?Bz3ptoMUMq^*d74|83kG0HMH zeEqP2Im1wVTJoM=ygJw3>thCmyF@+dNz!Yct}K?;fcQR6oTetul2{gVR)a_ z_8Dyp>r=bqg8Z(;HC*caNSue2Tw#ys8=rW%FWO<`0Q?h9^?IlZ@63W85E|Vj-TQ#& zN*FS4oj(mzo0WIh8u93nxNWarUS6&*XiXI8_KW9(LX> zK_pd`F`*Vig|)h!wi>@+>e}E*9Cu7CcN(sj-^+8?^V;oF_kBZrGc@#jY6{6Wj6D@s zZ;k~q@G5!fn<3v~^WSNf_1P#gm+%F6i!#+tNXfcH;{RbJ`BjR1HRl1LW+`3kuf z4FAZ_flF0SMpXD6pGh!h_P(gQt&Q@ZPrJJ4_BAIlViBv=xEzb)i;khW3Ln&iz}d zm;>3?&U#sxt3|E*we#GwEP=3FW$#x_Xqn_5_9?GSVCyv*8EjfaOg@;=;sMMx(M!T$zbhG$$8TQxdN#EuLF zweC;M(Nr7ys>Kt;F~2O=g0$Lv5t#MQXI~gB&-y$Ze-Z6d+n(3povE>bA_{PgX|fsF zLe2Cg5?5BX^D`mRyP=Qq4`bDg^6~h|V|=L0%Ho%Z@zPpFZ2wmDM&6)@ zrbt1FaXvJ!eo*5GX;4c$Twed;r%?&o+ksn#4rckFB*6>O_kH*GZ4Vmuin5`5e2G>n z2)nL=`v_>e1>uaQYdack3tpVIM+2xHk;$o)i=H17ZPUYAYv&%)v@Ol7+QW8Nxx*O5 z2Qthgd{7Tx*T#bK1I<)ZEmx!M;2lUfd%DdKWMD9xeB(=>5iqgv3sfLP<`gn7RXF zHRemn%n9?ZK6qpkY4q(|PhqWvXZ&h^1;)WyE0iY}nv-BfH^%9+uGkX6OTaqAMi$3C za%EPu7^modl8>9(Un)R};ZG$s*; zy7~fUJeGy3u8h=2Bb5j7CcHm@Qsd&~n^Z{>nQ z!4+6O7sH*Q=s>-a4S!bh@HMG7rd=bLZe)npem)<$? zL(8z>W1M_RWv>nW^rOE)r0osV(eGMh{*o{?GXcM|r+ctK>6xszHO7vuc(jp;F_2bYG4r62-f^!t zKhM;>wYF2a?M4=b$*A67Zru0mG-6;DRJjEAY8~xJ8|L=2MBw3AuU6<1=?cT>#x|~k zxNX;cD=splrk9aj(yWe9Ag6GcY^>+JG8lr9I_UMw5Ym}5d;C-S$9iII!b6@7Gd@@x z@jL>1a5&zsL)cBROA@;$WvXgT>^0OIEu`VABUARHPRdZJThANUgkj939JNP_Tokza z7*|bYjn5}^6Y*2_9`_*eC~~?QZ59g)_YM0x3j=Vz1K1;rw^rR02yauR`eI?wzx>n3 zTee4HMMDP(*Qy75vJUH&e3omq^ksEuxH2M6nLJ$h`_)L1E2SPZ(p2v*I)yWMw~n`9 zdNtKUaHF7shVDLEQ`)9e9C5<#GMA+#_Noa15%5UjQ;X&fx4$UV%$`wF%0+Bw601on!!#6sbS3R|){6)}~H8 zYc|k{(teN<$$Xo(iiC&6n?FY>F4=PE$0f+sjt>2|A|gyM6?GCcP$BzIxtY)g{|NMP z$E$U8&;z3`3h?B~aS!&tAD#lIPys!6FUo}s%0Crr>rXe6fU(B^ck_SGrnj=5x2EhX z{%hWv>az8pa{n6>6J@cAic%Rx2L-q@`&#IKgx|Y5F@K1eOa7dZ&ErYt?jCpwPvb-nbaH4D0hqD$M|sM!73Y&Lwfd)#htZzci5Bsb`{NjA!$sd zo9QJ)!=sE{Oj{a#_kwcSlU(tJlZVY#lxMt;hhi*R&I}?2#96hUV4cyXDu5>`nooU> zN?x0Kw(pV!(O_2``QRqjT?>dJJ~3lgiTS9}A}E3UQW~ACKWSwvKHz)2|CJT4XfT^k zBVN&;Z=Z2P;WeFEjEA0u!6;jpZTPwY^C4DM)6FTgMkjvX&`n+lAd;|9_GaEFKT1Wa zb!n_IHR!ku+S+nP!S z?sw`6z4}y9D9J476TAJ;w+Kd;wN-OH1fB`9D81nm(dN40QFnhdf2D?|DvwtW^%7~Q zdc+nJb*eGwc<59vcGBf-;5k!@0Klu3dXCv?-x9}MxXERs+z`L;>UHf_L4uD;$y;WS zHygvng`A7_KAMa~mT0|&8(_4b3?BO1w8+@*HYB<-cO_VIIhvRHLy7U){fBjrXBHmv zKTEG|KF-E1=kDm;o-~)8kvk8|{zdb!zW$e{@_}wHQTA1*N_lNJMM9us&Whi8b({X2 zgF8!<@<82a+R6ZaC~Nr?M!=tFu*fUyyCmX`T_VTZY;`@Im*Fv_uepruYj9enIB_!W z!^%_Lp)EFxp9~+4Ve#zgx3_8(V)BEs$VWE;S}5I_ddj?!S1QfV@HtBqot8b7@mcEgzC#kV=gGy<2~?JFM!ZL|vXMS}Vd+ zq;_}plS}|ryVsK3J&D%vo(GU!*Z&3`lr_oxyx!@AlXkO{JjmVz0?(&@vD2<> zA#rBT7M_AOuH4;`IU@#z&@<4PDtMMv(G=pv;IZL#pcCg9NZvnwk`cB-@BB_qA7g1F zeJZccBW0_|-%Jc#jZu<1o}br!#(23(;w+kFyrcq;l^o!7FQ_CO-O)DF9UAX5@(_I!$f5(s!e03J8g zF5V%*B#-AoAXihnVZ16hlqUzNR$XzTDz61ycR3TN?kTdi<<0eyI6NT!C3p6l*4)BD zW}yenV#m+z>=;v@IPY6y`Y=y{wzjTF`Ah9W(=?KdJzZ?bB-MQREC4?`N&Mardvpbf z8JgGv>_-_1SZ`cU={u98NA)f)n^Q6#e3PyH7`UUYolL^UoMw}*qt)wr3+R66Rq?RP zqbRd$7)wV+@Qq3|qxSmK$DHRm`iB)r)VSHJ07Xkju0BGc_hfIHZzR;${D;Jx8pZ*D zA!@B(7?$;uyn%5f)8ET5$1Pz`xTs0BDv;8HrCDH)osuRB$G(6&bW$4Ne7nuU-Wypx zOHxnE5oZ>;?1@ub^d&*Y=mI|THfDIej6Lkfs)79xIzu1>7G2-sSHFi&q*@+>RTk?P zqNN^G%FS&p`drc5pxcrUr0rklmT{@B#YzoKPLJC) zN}R?L%vn{&E5c+o#&pjkqX`Jn#^E!qC8lNO=3D_xjD;1zSr{8qYcJ!&@LFPYh9`Q;)E<(F(T6lUC^!$v_hq=w^rCE< zN0O4_Fy1i9*qVGWWa65@uFLO*ag27a{p75dPR|BU&?v--! zt0sDtwK-@73;Dsb)Z`pDm6X}1fE_L*)@y=sb|ycu16bgXG4sjQvui%uCXK^l87ovQ zRc&-;8mWm6&LP=#`es3As)gn~-IEGt!)s5Z`1tCA5dyvS^AN%?WC3`?!oQSse1o8BIGLy`c3xzFMgNF=a;zFxitch=2R@NKowp9A1G-6oav5kKF^C7cNQ_uMlF-G*De)RH zab8M{cT>8wEuutO7x@WvQE+Y6UvK>B`OgrtKSJGDK3*;X^uUSp zhJMxLpVKJ$6(7ebF2|mq9ep~4_;cQ`!lw%uQefMzg+i*EQ zLkEV>pF?tZX*fN?Ws-bJfyG6uy2)TK%hR37Ze5zsB}osx`YtT&P(vR(>$rgra_+9^ zQ3Y#a5W6wUfhhne0{OzZhzoij!!NJZl!k+hz0ip<2$|^I6sM7&H5V8{Wt-SRQLt=Z zPy1Ya;zVD`iO+cOX@tc9$Jao{;qqQ*XJO`2bH@XVH-p0^&4JY8i|6j_$zzshFoH4f_z@_%_yTOt%W7v8Ljpsk%%)U-$IO@K6BTP}Ws^O*X&2 zx?d517&_=;z`pEfab;Y&p1`YD`3{o3Q(f24USBn|UMPS>gC8NwqI*p&1m$Vjq2H$O zVBLETeOqni@Inc+{4@19C&9Uhjr&zObI#hw1^bi7LY*A@n(a{Sp>>b^S0=`~p_Ibw zi~<~`pP4U$95IP=EZGa-xQ)^=;++~cst<+I#|dW~{R>0UW~|ftp$ZPZ;$EDD-tH!d znbu#f?bWWNaRS2|%F7U6e!gWV^{!ne^ANgETJZNXFCv{uU8ClyCuL3;on~Hf69%C~ zNvhivlOnBoZ)Bq;1)TiC25zOJr0K=wGN2kGjs;kKj8PX7Y^QP4!cO7Go`UJEgg7%3 zS#b}O6;l(78jn?dl+eo$V7BM#|v7}KOJXJ;3CWx+&zAezSyd`uqRL^=S-2wmtpy$w@) zsJgTq*Y-K}Ik#;{)?;F#w28xd&cc=s@MLwCveZN7&n%g+YLKr&>#<_x*ZvwY5wEBi z3MhBUav}TtuF+b%!H~`3;^NWNWjLkZFrmDg#>|sHKfElR(^{CTJ^URRl`(9nTF^0~}&TTAr zVlWb|t`}mb(NkVgcc!HgDD;yr!S?l%S2l{$gEJeGn11$&Et!M+tEr_EQU;o3WTx3A z_o3Y*K3a5w7-g#K41?`iic{G#b>*kpwL7#4uYOY4hetN;s)E}M%L!OLth0fKU7a>> zPR0O&{*DHQNFP@n+QRWvb7+b?vYZSE-KTce#M(*|DnpL3{r=V|n;t)D@8#$hd9o@4 z94vZBKK)hBVJ>3qO5q-*gqM2_q6~av>hlp|GZ(|yFlO|X%{$bGFNQ|8YnZ=ldwbg? zAC{`2sI9d$Sv<6Cm((>qPKo54Xs?xJiA@Xce>vygPeYYrMQ(;jzB#3s)%1fUK03SP zW;FBm%G)L%*GtfEwNZc`oBv8?J$UPz`aBymu-%D$WA@hPQ9qC1;sg6-(lVa7vVQ8T z3CH_9;h7B2bwDJwQGNZN9@oB~1C^+!#gRPWxhc}Yb1O-t3GznTN&u1zRKI3w8Ft_P z)5v3U>ysYLjiUF85uFmEB#Bp#qMPYO=q0x{!>1Q7XdP|=n>QCdWEUR+(o!bNinZ zLy)LGgoTJMPO9LZse4!mFqF10!p9L@w*355YL(3N@z6+v>82g2jBD!VFod8Liyz*% zG-nwjF!P{nrJn|Ts@qW)D(N)L1V98vtIIXY`LQbug*#cfibE3ebQA1H7Ok@Hrj4D_ zFu9nGcAnVZ^jMJ23l|DV>lHaGbtm*YTT<>?GRk=J4&>yZhk53_wZGG%nh{S}jU(^r z>Vemj+?}Nro_hyTyCL-m_d3B=r%+GdCW8}{m zf}TJy09!kAa!GE!{x&)Vh*dTu{k?Lk>6~k3q5M#JdIWrDr5wd;B}6_P<>wY@j4+Pt zYTgL+HK0$J;+<%V6406zL`%x|73e|sn)+tl#_h^iR{)CfEM8d$Pc4Lr!_Bxp?bb}i z$K*@im3z!tWdRtlUaqoJ!w6rrwN>Axi>c4Wmo&D_&eXkZihI-b)|)9ef)c`13Msk{Dw zAxaf4WAB+jbo{-*hBT*{uWIwfN*QXQ@!VvP!ujHb=Auf~VrTEt_~ibOs! z+tORk>sHk}y!PYKSunhVMz~O-(U6x&%=g)J7U#~RH>)Bd0X3#itRS_Sh4*^Ycl)%E z$4FM$NJnRG25Bfp3#Y2}o;z92+tHU~x%8@j7H37LTtR`tva@-nVW;!~OZ~--r9DY` z#rk`()J9&O7`Nnnnxa%y-YN|7(`(y5`d-z%-LTAF@{@1e!^kM9nYrI?bzg4Ve|z6v z{e6ew=Bz7#c<>9d0n>e8om=-MUaG6}`p*qX>reDa)6XvMw?8-1|6Kh4+^kuXLv}lY zziQJ}wxjq(5Hm#@m~h@MTM1h0)xujNet$Rz}}(8Jum)?7CoM19m=Vk}`ic`uP4z z)1%&-MjO6J#gB;^U7I+{k}%nLiq}U_jj|wUWA5rre4QR|jqk$mfcDU&%}?xLdiu=* zVq26rlyMS8Ahy5v==eQhceT2;6`$VMxXGkOU&$yALFaOHhL zJ-(&_0Ab$uD?9y3sGWW#%;UD*gg(N?+%4YS{-Id>Da}ZAwua6S)<`#?Ms}`|minpZ z$`QwkUNuJRRsxU|B+$83QjDWan3Jk&)8H+7q(B!)=Q;BoNmw=KecFc8OI7zW)F_Ej zuCGM0MEI3+dD0&^z5gM0c2k}P-hnozL)F}bM7t)C$5ssDS>R4M`x_V-T1M+uqG|Sm zME3Z;q1vyi8I~G1eUVia@q3cYkQVfC7r^YZE8qUOpY|i#P_Lw{uBbb|4>xLKv=QI5 z+z9Ra(L^s7^RWa65$xqdN@^&U2RZO}rvK(Ac)X_Nz>ZJr%=oh^guK|maF%dR!bAlW z`;P?Ir?e7SUqcfaVEI*fE7gQvxq5lp0cP#~)#1|0!dap&Mw0yVB7Y{^edHnfO(qEgYg;^-Ls97FGwY%lF-+=~8j8{WY;ki>%c=?sxs_?^ z47w$1{a7_<=u65H_`s=Ha}jrU$C=KfxnlO02t*eB2|g(*z6t)!ne_us?(v(Egt7w>c#g`D<#uk3&Ehs$Z&c~=}V!2TayRy zbKc{94zyf2IeDOKx47?KI7M{;XXw+53K#IW6AK1DDgRsf(|pknPAFsfCVd(B>JYi1 z`KzbxQeHgZIgR^Q?EOC#BiYD~OBd~L{tO8>1DvLu7tY-Ki5Ih8_)N3@tz$|woW z14OGU0}k1%rQS@R=+x&2uPFVyn*T2A!eM(mA6Va?N?u>_Ys6cW`Q_2MVwigAAVE1`?rW>6o{S9tZ|EX{AqANLG;XzuHC^{EfRpXJ3b+UtMFFNN~ma#AjH1tE!dt63_!ys6Y&7 zRbUXw=2qPI4e4R_*XW0xfgjmjg%ij-2G^Vg#Ja;BADeb%FHvwOY#&Pm7}vnh(dN@d zyMD#BU(&FyOhV)GI4i0KlO6eJ-Kb-dSu?&pk5!++p?n==0vAL}Z`?hd{po5_uP0hJ z%iY#lh9W>dVLrVNFBvn6I_W$h{3iJl!Ci7SJP`&S}Q2h2f%||!YrNxg*j}GfA z5_x;Xq1IgdN%JVSzd_um+Cg18q3ppowYuOUBcAzjPL?CsZS(a1ssmDB-mdlwLvHNlxWL9`L7VV$>ZY-c5wA z?8XD#Q-+L<2rTsK7w_8SNON> zfZpjFI!99hwRkSO`RvY^7hig6h)=IWeDCwS#^jPQr&HdYZphT(zSjZpUwrzrsqh%M zz!EMlxwsLE*W4RqmqhgZI)!u^ENnFga!vCj#a~Q;HLE;VhOJ68LSGHy(Ny-)kt@AW zedfst_;!YSy$W7dgQv@Aytqc7tR~$b2+xyk1o1~Y$P8!X{|!2l@oP); z_r&T~g|ZGn42^y>w$AMZWv$%XlTH#LU;5c!nd%4x*EHcR#+#gGAN}Og{;kT^*FAYM z&MIl+2!~4=IR2F`eWxM;N<Y+i;h~;HIJf#z5+8CdpY~F)u)70Nf2)5*t>7g>+ z{8vv2Cz^t1Zmv@<3%FJzLS)_mmY3HFEng35-y}_bT$c~G>B)!D4Fx?^Q*|<^*|u%2 zu8vx|KGjt4dzeuQg5kH3|2VhUt-(6BUyc(o_5=?^W^hnI-}xRmo>cTwu`+Ko&AMTN z8m2wDAbJ>0+x%{$4pxmcjdt9`gbC5$_R2OA{Bb>r;njf!G(vlLLvYC$vx{ehq9M8q z;2UnPF)Al#+T{h@EZzCIm0q0G(A)Mg4aF@Pmwtf6l3DDPZjmRY{li|V>GK1Nz)WwC zDW{)Mw3i7>Y5?kdX}ZEw-W*_8QJBGdFHoJERV#mQ?*P{_U9$T?p zyOiQ`H&Zep;vQUbbci>#QGZ3gdD)V?+9cF3Iy?U@r}16!q&9 zozY9DMO4@hwRIEr|XI`>S^`GyIB;-?fBSqAne!sep5Y3j9ix!=*I!lCN+v?urd{Qy0)RDUkC{ zlKQKiIA<0)uO#DxO7SUyw5nr2E;>kE_HlMyr)YDs2HV;!yTS$zUw*s1or`q?s=+7p z+QK3b=Se>-G>cfC;)Nl(k$VT+k`q)NdtFg-R>^m)SnHz6P)2TObrY!G{Pt{;3{rag z*l?D2ryQ<1R9sVJUJt|8fhAeZB$D+LCs&4MO%`A{zM%4aE;2GmIswgV>AT-<@pYF< zwd_!OJdal=+2=v8Y#>W%WmVKGm4H^BZjm%!JF(g`wL;`Wuk*wn4c@j;hjwvhCuyDa z6x(IDl~iQ2U*h`g1dDgqLX(kEj8{Xow3C#pepQq|u+4iwnVsi;Q+u5=wfUGMHT`Yw z3Y|crohBjaXgI@HQlMC7aE=Xa~Y5aA+x+_vu22`&!z#D&Ho;qKqI|-p7k@He0%k zK+M2vSlGeqgZeuvVU~>x!8(|+aZv7-+e8Gri zLLOGdCE>*g7q1no|C{wUi}tP$;R^MXbXh8HQ^gg!Jpo_CCUL!IFTJoYec;XF!usXj}&6 zsHlT3+~gMy?}va}`@&~_(eS_J&j8)hQngE^BLBaIg0iFY?nU8EM_Yd)1NQg-Ef?PM z%NKLl0hQ}$pa*i{^VYYvj(1-pDHCDoAcabD2J>_Lf6Chnd0&7DDeQp?U#SLMi6pL6 z?P@_}+@*YAI1>|ydS}CnM=%O$Gr2xw7%s{YFHg~N`C4Q!DaTz6JL34XkedA&Drqt554Hx*jCN*A2PSF;3LOS<5XAkZuD4&(+Vuq*e?{8J$)3nL z%fiRUd(d@grqM`KbLKwN<2(6!xlWVda z_hI_|wzg10E>_n?OW%qAv{1Ua1uBK$TY9#huUXLITU|Q=nKAyaS}meU7gh+H*0N#m;Ig6q{COYId0O8Yp(U`GXsda7 z+iB^v_=lb++e+$|>5T-L#C?CZD-^zA`s&Y6@89zuRsXzVx8s%H99LAmNXdWtZ#U=H z1`)>3Xp_bQ7t+wPX?_9k>SasG*14?>kIP$zckQinMDn}05N74pjEmfDrT zVfM<2eV%u%u2!;dutpkA-|XmkTgJ&P`013VUx8texV>+2XN;X1SJs2TCW`qP4P1ry z_cM&8aXLGFZkpws=xq+#gnvv6c>t0zz4p45rneU`m zR;(Y6$PmmoOI-HNir+~o7wPs&Xq;V!@LA$cW~6+w2th{+h(8Z4MM*2Tn_30fdN{+L zCUo(@WA+b#rk8@D^Cgl+4qQSM@7(v+U~{j?LB|Nodfihg$`>4*+yT_Sz1|4SHt2-S4NLe_0NTX)qgS_w?d8gb`fK#<>hr!UQb8kskEas8qYBhZq>Y7|pifERX-U+g;Dc9mOwB z9XRw|bG_}>dH6t%uXr+PbYe$wvf;_WTbUDmK95qqU){gq;1Y$>)k1>w2N5@BA)1jl zn!esifB=C{e={yrIi7lYaj1{d|G-43H;)Y_$$P+WU(r00MR&kuGTj1$hU-z)WTh%w^y-$~3ulK99 z)!nxyz{}Qz9f7Imhd}^Y^a@oEGPQwJvsDZGpc2wSdDD&IQ)TPf`3F?^qNRq@O|Xm3U@j zH(m9VBCGc|EgOs~Zc$6+`i1D-n{g^GYUojd{Mp*V$oSbj89)CNIjy~pr{!ib+18s*y!!w6qL+MVJ)Vw=Mtv3Ue>-6cw7qpQ)F!u;>|*+*qsQ$=WbV8xF)6^z0{SFiN~QZKei2Q8ChfCKn@1NRi~gG$|+*2tOyrFy+-Jnk2V6{2B2(sPrEmb z)LK0_D;BlRpxpF$p^WTPar0AZR${ad_VkPf5f|kGJ*;e?W{ypJdWt#r2B3ee_pt+M ztIU-%rbVZBak;a7zqZnEBO1{~x;+X!s`#QRFaSo7Ri2~fT9P$nPtIUyOY9>rb(+S~ zU1U=i8P1pzJ{P(cT{{5#ph)1X8E#bpe|)t*arhgpEx)?d=A~XabzzRvok83$ls{^> zXdHtD3oQ2Q+dKsaHu#?D-_V~udt#x3Mw2_+Gcy*l2*55Yw7_GJpdmCYWV(M_dEf;r zfB9r@_|vEIKZEmc9`C*5GaALPEgw*Q(d7Bs-Gb8T*@rjxV{azsZntqt3%}HvXr!&` zjP@&42N-6Sz+huHQ8>lny{_P$8CFi>Il2>S*&RjE4@sKl_uaRdPo5}kXg1hZVo#aH z@ty8dne9>?Ien@58~gO!4ZS|{30!)Rl~WpQRU;MR)W@UUS2jBFxAj`_g?icw+PXmK z++|n%7$eD)pos4`A7I#MD?5?z&vuK10`fw+qJlXT%<40rfs4}q8wma&a1E{IBBCBVwx7}_?fFh$mxOl`s`H{frEa2#NI4~7+pIWj&1P^!BG1bn)6b& z?9Gbe;U6nv6jGaY`Lt~8wE`%S>L_AaGD0dC`dddwwUX=_6(i3*Xo_!__t4GN=E5n==t(-U>1td3``s=ed{O{y03nn4k`qD*eY zMUiH-m?|qyPzBqAo(|}g68vvvxMpWbKfW#3xxHv6p6+odQixN}=c*W9J#32%eGwn4 znV%Mx7O8P_O%-YE9{P?Z1wNe{ZlJk=pumd)!{(Epv~V6)H+;3nc^`Xsc=L#AA;_0= zj4zC^8Kn)+$a2p|zC}As9P<^w^d0(Iw|7fZR8LvpiM%-xEIkgJN4DuFMk8EBb7AnSn|Kc_;>e}9bQO|MZ`Q$Af|ppp`M0c0r@i#v*ig0#9JDJomB zgQ&Q5`vAerGlitWgjtc}Lqs(PmAL*?@H2t7n70zW6OW4Ykf{Rn0os&)(2SD(yx=EL zq*OFk<%3e-S!`{asPJRjpqhovIuQNSQThK51PXo>VB{Tx& zY~~2e;_v;n`eOA7urGk)S4(}Qq?8v+rYd08aEud}(;jqMC<`?mF&aVt&~vn%+&w zwo)F;3>*tMU}z6U8{$BXlU60Wy3`%TgX{y_X2efC5#H?4&m)ap>eZj83(2dk7?$+s5vlI%a%O=U7iSsg zxWt2Cv)#;eI^L4QtcnvxKSnsIx$x8Mi>n(dJ$p><8kRfe+D4H?KjcCtLPX6>fMDu| z?sh3LVcFrB-K47W-HKH#Wx z+#kZskNW2^?qeiAtixKR_|qc9R6hQ(CZSty7i6*No1 z5+|Zq8{$+LhBO{}or!`7K)lKwP*pP3JOOH2BCbE7%(dlqV{<ukITz7e4bnjL(Kcj0;xJ$P^^QKW}EFHf@);tmFo|R!Rr&zMp%d|nluvk>JPobqo z!30FrigKy>v%}L{_FoEBqWO8gv$!~e+QzOFl}!x*KRIEPSLtUu$|N>9`zGIlNz7`YI7lsU0%

u6uuz~OdU!Zf#Sspmco4r=goEHh^l()L1w!oM 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 Z*h5dD&F_jJm Date: Thu, 26 May 2022 17:52:14 +0200 Subject: [PATCH 06/31] Update README.md (#943) * Update README.md for Unraid users --- README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/README.md b/README.md index e54ed0170..a13c965fb 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,14 @@ Run the following command to setup the database once Ghostfolio is running: docker-compose -f docker/docker-compose.yml exec ghostfolio yarn database:setup ``` +## For self-hosted environment (e.g Unraid) + +Please follow the guide at the following link for Unraid. + +[https://forums.unraid.net/topic/123829-support-community-applications-ghostfolio/](https://forums.unraid.net/topic/123829-support-community-applications-ghostfolio/) + + + ### b. Build and run environment Run the following commands to build and start the Docker images: From 4711b0d1edcb07f0e222e3a0f21b14f21d3fb308 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Thu, 26 May 2022 17:59:09 +0200 Subject: [PATCH 07/31] Improve instructions for Unraid (#954) --- README.md | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index a13c965fb..063a5dd91 100644 --- a/README.md +++ b/README.md @@ -102,14 +102,6 @@ Run the following command to setup the database once Ghostfolio is running: docker-compose -f docker/docker-compose.yml exec ghostfolio yarn database:setup ``` -## For self-hosted environment (e.g Unraid) - -Please follow the guide at the following link for Unraid. - -[https://forums.unraid.net/topic/123829-support-community-applications-ghostfolio/](https://forums.unraid.net/topic/123829-support-community-applications-ghostfolio/) - - - ### b. Build and run environment Run the following commands to build and start the Docker images: @@ -141,6 +133,10 @@ Open http://localhost:3333 in your browser and accomplish these steps: 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` +## Run with _Unraid_ (self-hosting) + +Please follow the instructions of the Ghostfolio [Unraid Community App](https://unraid.net/community/apps?q=ghostfolio). + ## Development ### Prerequisites From 2c4c16ec992b8d94eccc1af4db9014e9685738b4 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Thu, 26 May 2022 18:59:29 +0200 Subject: [PATCH 08/31] Feature/extend markets overview by benchmarks (#953) * Add benchmarks to markets overview * Update changelog --- CHANGELOG.md | 1 + apps/api/src/app/app.module.ts | 2 + .../src/app/benchmark/benchmark.controller.ts | 32 ++++++++ .../api/src/app/benchmark/benchmark.module.ts | 24 ++++++ .../src/app/benchmark/benchmark.service.ts | 77 +++++++++++++++++++ .../portfolio-position-detail.interface.ts | 6 +- .../src/app/portfolio/portfolio.service.ts | 11 ++- .../interfaces/symbol-item.interface.ts | 6 +- apps/api/src/app/symbol/symbol.service.ts | 3 +- .../src/services/data-gathering.service.ts | 11 +-- .../ghostfolio-scraper-api.service.ts | 10 +-- .../google-sheets/google-sheets.service.ts | 5 +- apps/api/src/services/market-data.service.ts | 14 ++++ .../src/services/symbol-profile.service.ts | 37 ++++++++- .../home-market/home-market.component.ts | 11 +++ .../components/home-market/home-market.html | 25 ++++-- .../home-market/home-market.module.ts | 8 +- .../position-detail-dialog.component.ts | 2 +- apps/client/src/app/services/data.service.ts | 5 ++ apps/client/src/styles.scss | 8 +- libs/common/src/lib/config.ts | 1 + .../src/lib/interfaces/benchmark.interface.ts | 10 +++ .../enhanced-symbol-profile.interface.ts | 7 +- libs/common/src/lib/interfaces/index.ts | 8 ++ .../responses/benchmark-response.interface.ts | 5 ++ .../scraper-configuration.interface.ts | 0 .../lib/benchmark/benchmark.component.html | 32 ++++++++ .../lib/benchmark/benchmark.component.scss | 3 + .../src/lib/benchmark/benchmark.component.ts | 15 ++++ libs/ui/src/lib/benchmark/benchmark.module.ts | 14 ++++ libs/ui/src/lib/benchmark/index.ts | 1 + libs/ui/src/lib/value/value.component.html | 2 +- 32 files changed, 351 insertions(+), 45 deletions(-) create mode 100644 apps/api/src/app/benchmark/benchmark.controller.ts create mode 100644 apps/api/src/app/benchmark/benchmark.module.ts create mode 100644 apps/api/src/app/benchmark/benchmark.service.ts create mode 100644 libs/common/src/lib/interfaces/benchmark.interface.ts rename apps/api/src/services/interfaces/symbol-profile.interface.ts => libs/common/src/lib/interfaces/enhanced-symbol-profile.interface.ts (61%) create mode 100644 libs/common/src/lib/interfaces/responses/benchmark-response.interface.ts rename {apps/api/src/services/data-provider/ghostfolio-scraper-api => libs/common/src/lib}/interfaces/scraper-configuration.interface.ts (100%) create mode 100644 libs/ui/src/lib/benchmark/benchmark.component.html create mode 100644 libs/ui/src/lib/benchmark/benchmark.component.scss create mode 100644 libs/ui/src/lib/benchmark/benchmark.component.ts create mode 100644 libs/ui/src/lib/benchmark/benchmark.module.ts create mode 100644 libs/ui/src/lib/benchmark/index.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index dfd347337..9cc1bca12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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 diff --git a/apps/api/src/app/app.module.ts b/apps/api/src/app/app.module.ts index f3b85fc9b..f95c93fd2 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,6 +38,7 @@ import { UserModule } from './user/user.module'; AccountModule, AuthDeviceModule, AuthModule, + BenchmarkModule, BullModule.forRoot({ redis: { host: process.env.REDIS_HOST, 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..4d95c4bd7 --- /dev/null +++ b/apps/api/src/app/benchmark/benchmark.module.ts @@ -0,0 +1,24 @@ +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], + 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..5780c768a --- /dev/null +++ b/apps/api/src/app/benchmark/benchmark.service.ts @@ -0,0 +1,77 @@ +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 { + 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; + } +} 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.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index 1ac6bb349..da08f8e52 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -19,7 +19,6 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration.ser 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, @@ -28,6 +27,7 @@ import { import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper'; import { Accounts, + EnhancedSymbolProfile, Filter, HistoricalDataItem, PortfolioDetails, @@ -375,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 } = {}; @@ -518,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) => { @@ -768,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 } = {}; 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/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/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..26437bcdf 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 @@ -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/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 d7a0c4cfd..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 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..b55103bf0 100644 --- a/apps/client/src/app/components/home-market/home-market.html +++ b/apps/client/src/app/components/home-market/home-market.html @@ -1,13 +1,12 @@ -

-
+
+

Markets

+
Last {{ numberOfDays }} Days
+ +
+
+ + +
+
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/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 05caca115..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 @@ -7,9 +7,9 @@ import { OnInit } from '@angular/core'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; -import { EnhancedSymbolProfile } from '@ghostfolio/api/services/interfaces/symbol-profile.interface'; 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 { Tag } from '@prisma/client'; diff --git a/apps/client/src/app/services/data.service.ts b/apps/client/src/app/services/data.service.ts index 5c946d036..c0dd5ef89 100644 --- a/apps/client/src/app/services/data.service.ts +++ b/apps/client/src/app/services/data.service.ts @@ -19,6 +19,7 @@ import { Accounts, AdminData, AdminMarketData, + BenchmarkResponse, Export, Filter, InfoItem, @@ -90,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 } diff --git a/apps/client/src/styles.scss b/apps/client/src/styles.scss index ed670f1d4..8638c00ce 100644 --- a/apps/client/src/styles.scss +++ b/apps/client/src/styles.scss @@ -60,12 +60,8 @@ body { } ngx-skeleton-loader { - line-height: 0; - outline: 0; - .loader { background-color: #323232; - outline: 0; } } @@ -117,9 +113,13 @@ ion-icon { ngx-skeleton-loader { display: block; + line-height: 0; + outline: 0; .loader { + display: flex; margin: 0 !important; + outline: 0; } } diff --git a/libs/common/src/lib/config.ts b/libs/common/src/lib/config.ts index 7fa9e9d00..410d0498f 100644 --- a/libs/common/src/lib/config.ts +++ b/libs/common/src/lib/config.ts @@ -48,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/interfaces/benchmark.interface.ts b/libs/common/src/lib/interfaces/benchmark.interface.ts new file mode 100644 index 000000000..146fc4b07 --- /dev/null +++ b/libs/common/src/lib/interfaces/benchmark.interface.ts @@ -0,0 +1,10 @@ +import { EnhancedSymbolProfile } from './enhanced-symbol-profile.interface'; + +export interface Benchmark { + 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/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..5bba27f51 --- /dev/null +++ b/libs/ui/src/lib/benchmark/benchmark.component.html @@ -0,0 +1,32 @@ +
+
+ {{ benchmark.name }} +
+
+ +
+ +
+ from All Time Highfrom ATH +
+
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..a5f439364 --- /dev/null +++ b/libs/ui/src/lib/benchmark/benchmark.component.ts @@ -0,0 +1,15 @@ +import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; +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 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/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' }" > From 0c04f10e196cfdf4ffa50c7a09b1f7b2590d171c Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Thu, 26 May 2022 19:01:08 +0200 Subject: [PATCH 09/31] Release 1.152.0 (#955) --- CHANGELOG.md | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9cc1bca12..eb006c51b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ 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 +## 1.152.0 - 26.05.2022 ### Added diff --git a/package.json b/package.json index 2bb4504a0..300bfc773 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ghostfolio", - "version": "1.151.0", + "version": "1.152.0", "homepage": "https://ghostfol.io", "license": "AGPL-3.0", "scripts": { From c62a5af9eb95c4d0dfc49ed6a96eb7e3f8fa4e88 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Fri, 27 May 2022 09:49:37 +0200 Subject: [PATCH 10/31] Bugfix/fix width of skeleton loader in benchmark component (#956) * Fix width * Update changelog --- CHANGELOG.md | 6 ++++++ libs/ui/src/lib/benchmark/benchmark.component.html | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eb006c51b..c2f9bcc7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ 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 + +### Fixed + +- Fixed a styling issue in the benchmark component on mobile + ## 1.152.0 - 26.05.2022 ### Added diff --git a/libs/ui/src/lib/benchmark/benchmark.component.html b/libs/ui/src/lib/benchmark/benchmark.component.html index 5bba27f51..e41621fef 100644 --- a/libs/ui/src/lib/benchmark/benchmark.component.html +++ b/libs/ui/src/lib/benchmark/benchmark.component.html @@ -6,7 +6,7 @@
From c07c300fefc596a333b1aeee8a04c5768cc1488c Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Fri, 27 May 2022 09:49:57 +0200 Subject: [PATCH 11/31] Move @simplewebauthn/typescript-types to devDependencies (#957) --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 300bfc773..3b379888f 100644 --- a/package.json +++ b/package.json @@ -75,7 +75,6 @@ "@prisma/client": "3.12.0", "@simplewebauthn/browser": "4.1.0", "@simplewebauthn/server": "4.1.0", - "@simplewebauthn/typescript-types": "4.0.0", "@stripe/stripe-js": "1.22.0", "alphavantage": "2.2.0", "angular-material-css-vars": "3.0.0", @@ -141,6 +140,7 @@ "@nrwl/nx-cloud": "14.0.3", "@nrwl/storybook": "14.1.4", "@nrwl/workspace": "14.1.4", + "@simplewebauthn/typescript-types": "4.0.0", "@storybook/addon-essentials": "6.4.22", "@storybook/angular": "6.4.22", "@storybook/builder-webpack5": "6.4.22", From 3498ed8549e8c78d68045e2c3c77403da6b0217d Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Fri, 27 May 2022 09:50:38 +0200 Subject: [PATCH 12/31] Feature/upgrade prisma to version 3.14.0 (#958) * Upgrade prisma dependencies to version 3.14.0 * Update changelog --- CHANGELOG.md | 4 ++++ package.json | 4 ++-- yarn.lock | 36 ++++++++++++++++++------------------ 3 files changed, 24 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c2f9bcc7a..79c83fbb1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Changed + +- Upgraded `prisma` from version `3.12.0` to `3.14.0` + ### Fixed - Fixed a styling issue in the benchmark component on mobile diff --git a/package.json b/package.json index 3b379888f..ce28380a5 100644 --- a/package.json +++ b/package.json @@ -72,7 +72,7 @@ "@nestjs/schedule": "1.0.2", "@nestjs/serve-static": "2.2.2", "@nrwl/angular": "14.1.4", - "@prisma/client": "3.12.0", + "@prisma/client": "3.14.0", "@simplewebauthn/browser": "4.1.0", "@simplewebauthn/server": "4.1.0", "@stripe/stripe-js": "1.22.0", @@ -109,7 +109,7 @@ "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", diff --git a/yarn.lock b/yarn.lock index 64d095983..e4cf8ed39 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3596,22 +3596,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" @@ -15660,12 +15660,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" From c3768a882de0bbd75f88c40dcde2661d3f861eec Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Fri, 27 May 2022 10:03:37 +0200 Subject: [PATCH 13/31] Feature/add benchmarks to twitter bot service (#959) * Extend benchmarks with market condition and adapt twitter bot service * Update changelog --- CHANGELOG.md | 5 ++ .../api/src/app/benchmark/benchmark.module.ts | 1 + .../src/app/benchmark/benchmark.service.ts | 7 +++ .../twitter-bot/twitter-bot.module.ts | 4 +- .../twitter-bot/twitter-bot.service.ts | 55 ++++++++++++++++++- libs/common/src/lib/helper.ts | 13 +++++ .../src/lib/interfaces/benchmark.interface.ts | 1 + .../lib/benchmark/benchmark.component.html | 17 ++++++ .../src/lib/benchmark/benchmark.component.ts | 3 + 9 files changed, 103 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 79c83fbb1..8fd5e5381 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Added + +- Extended the benchmarks of the markets overview by the current market condition (bear and bull market) +- Extended the twitter bot service by benchmarks + ### Changed - Upgraded `prisma` from version `3.12.0` to `3.14.0` diff --git a/apps/api/src/app/benchmark/benchmark.module.ts b/apps/api/src/app/benchmark/benchmark.module.ts index 4d95c4bd7..fa26a3afd 100644 --- a/apps/api/src/app/benchmark/benchmark.module.ts +++ b/apps/api/src/app/benchmark/benchmark.module.ts @@ -11,6 +11,7 @@ import { BenchmarkService } from './benchmark.service'; @Module({ controllers: [BenchmarkController], + exports: [BenchmarkService], imports: [ ConfigurationModule, DataProviderModule, diff --git a/apps/api/src/app/benchmark/benchmark.service.ts b/apps/api/src/app/benchmark/benchmark.service.ts index 5780c768a..f7a10d8e5 100644 --- a/apps/api/src/app/benchmark/benchmark.service.ts +++ b/apps/api/src/app/benchmark/benchmark.service.ts @@ -53,6 +53,9 @@ export class BenchmarkService { .minus(1); return { + marketCondition: this.getMarketCondition( + performancePercentFromAllTimeHigh + ), name: assetProfiles.find(({ dataSource, symbol }) => { return ( dataSource === benchmarkAssets[index].dataSource && @@ -74,4 +77,8 @@ export class BenchmarkService { return benchmarks; } + + private getMarketCondition(aPerformanceInPercent: Big) { + return aPerformanceInPercent.lte(-0.2) ? 'BEAR_MARKET' : 'NEUTRAL_MARKET'; + } } 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..2829896dd 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,20 @@ +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 * as roundTo from 'round-to'; import { TwitterApi, TwitterApiReadWrite } from 'twitter-api-v2'; @Injectable() @@ -14,7 +22,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({ @@ -48,7 +58,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 +81,36 @@ 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} ${roundTo( + benchmark.performances.allTimeHigh.performancePercent * 100, + 1 + )}%${ + benchmark.marketCondition !== 'NEUTRAL_MARKET' + ? ' ' + resolveMarketCondition(benchmark.marketCondition).emoji + : '' + }` + ); + } + + return benchmarkListing.join('\n'); + } } 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 index 146fc4b07..906e30759 100644 --- a/libs/common/src/lib/interfaces/benchmark.interface.ts +++ b/libs/common/src/lib/interfaces/benchmark.interface.ts @@ -1,6 +1,7 @@ import { EnhancedSymbolProfile } from './enhanced-symbol-profile.interface'; export interface Benchmark { + marketCondition: 'BEAR_MARKET' | 'BULL_MARKET' | 'NEUTRAL_MARKET'; name: EnhancedSymbolProfile['name']; performances: { allTimeHigh: { diff --git a/libs/ui/src/lib/benchmark/benchmark.component.html b/libs/ui/src/lib/benchmark/benchmark.component.html index e41621fef..59113927f 100644 --- a/libs/ui/src/lib/benchmark/benchmark.component.html +++ b/libs/ui/src/lib/benchmark/benchmark.component.html @@ -29,4 +29,21 @@ from All Time Highfrom ATH
+
+
+ {{ resolveMarketCondition(benchmark.marketCondition).emoji }} +
+ +
diff --git a/libs/ui/src/lib/benchmark/benchmark.component.ts b/libs/ui/src/lib/benchmark/benchmark.component.ts index a5f439364..939e3a35c 100644 --- a/libs/ui/src/lib/benchmark/benchmark.component.ts +++ b/libs/ui/src/lib/benchmark/benchmark.component.ts @@ -1,4 +1,5 @@ import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; +import { resolveMarketCondition } from '@ghostfolio/common/helper'; import { Benchmark } from '@ghostfolio/common/interfaces'; @Component({ @@ -11,5 +12,7 @@ export class BenchmarkComponent { @Input() benchmark: Benchmark; @Input() locale: string; + public resolveMarketCondition = resolveMarketCondition; + public constructor() {} } From 69088b93a61afe93153e23b178360b95d613c76e Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Fri, 27 May 2022 11:21:47 +0200 Subject: [PATCH 14/31] Feature/add value redaction as interceptor (#960) * Add value redaction as interceptor * Update changelog --- CHANGELOG.md | 1 + apps/api/src/app/order/order.controller.ts | 2 + .../src/app/portfolio/portfolio.controller.ts | 2 + .../redact-values-in-response.interceptor.ts | 50 +++++++++++++++++++ 4 files changed, 55 insertions(+) create mode 100644 apps/api/src/interceptors/redact-values-in-response.interceptor.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8fd5e5381..1094ddec0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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 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/portfolio.controller.ts b/apps/api/src/app/portfolio/portfolio.controller.ts index f0e75e731..606ce658b 100644 --- a/apps/api/src/app/portfolio/portfolio.controller.ts +++ b/apps/api/src/app/portfolio/portfolio.controller.ts @@ -4,6 +4,7 @@ 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'; @@ -106,6 +107,7 @@ export class PortfolioController { @Get('details') @UseGuards(AuthGuard('jwt')) + @UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor) public async getDetails( @Headers('impersonation-id') impersonationId: string, 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; + }) + ); + } +} From e79be9f2d69f2df16c2c8c4d844777ea3210254a Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Fri, 27 May 2022 11:37:48 +0200 Subject: [PATCH 15/31] Feature/do not tweet on weekend (#961) * Do not tweet on the weekend * Update changelog --- CHANGELOG.md | 1 + apps/api/src/services/twitter-bot/twitter-bot.service.ts | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1094ddec0..291735dd3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Changed the twitter bot service to rest on the weekend - Upgraded `prisma` from version `3.12.0` to `3.14.0` ### Fixed 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 2829896dd..d820d6c11 100644 --- a/apps/api/src/services/twitter-bot/twitter-bot.service.ts +++ b/apps/api/src/services/twitter-bot/twitter-bot.service.ts @@ -13,7 +13,7 @@ import { } 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 * as roundTo from 'round-to'; import { TwitterApi, TwitterApiReadWrite } from 'twitter-api-v2'; @@ -40,7 +40,7 @@ export class TwitterBotService { public async tweetFearAndGreedIndex() { if ( !this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX') || - isSunday(new Date()) + isWeekend(new Date()) ) { return; } From 0fdafcb7e4223fdcc1929917be6b2bce8f9884d0 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Fri, 27 May 2022 11:53:18 +0200 Subject: [PATCH 16/31] Release 1.153.0 (#962) --- CHANGELOG.md | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 291735dd3..e5aceedd9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ 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 +## 1.153.0 - 27.05.2022 ### Added diff --git a/package.json b/package.json index ce28380a5..cd05f856d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ghostfolio", - "version": "1.152.0", + "version": "1.153.0", "homepage": "https://ghostfol.io", "license": "AGPL-3.0", "scripts": { From f7060230b7ea2187359c79802336749e1f11e427 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sat, 28 May 2022 18:46:22 +0200 Subject: [PATCH 17/31] Update dates (#966) --- README.md | 2 +- apps/client/src/assets/sitemap.xml | 22 +++++++++++----------- apps/client/src/index.html | 2 +- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 063a5dd91..f29693e7e 100644 --- a/README.md +++ b/README.md @@ -48,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 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" /> - + Date: Sat, 28 May 2022 18:52:30 +0200 Subject: [PATCH 18/31] Feature/modernize pricing page (#967) * Simplify pricing page * Update changelog --- CHANGELOG.md | 6 ++++ .../src/app/pages/pricing/pricing-page.html | 29 +++++++++---------- 2 files changed, 19 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e5aceedd9..98a0320ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ 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 + +### Changed + +- Simplified the pricing page + ## 1.153.0 - 27.05.2022 ### Added 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. +

+
From 15dda886a095e2183afeaf0fc24ccf7251da2c47 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sat, 28 May 2022 20:53:54 +0200 Subject: [PATCH 19/31] Feature/add vertical hover line to line chart component (#963) * Add vertical hover line * Improve tooltips of charts * Update changelog --- CHANGELOG.md | 5 ++ .../admin-market-data-detail.component.html | 2 + .../components/home-market/home-market.html | 2 + .../home-overview/home-overview.html | 1 + .../investment-chart.component.ts | 52 +++++++++++- .../position-detail-dialog.html | 2 + .../portfolio/analysis/analysis-page.html | 26 +++--- libs/common/src/lib/chart-helper.ts | 83 +++++++++++++++++++ .../fire-calculator.component.ts | 6 +- .../lib/line-chart/line-chart.component.ts | 54 +++++++++++- .../portfolio-proportion-chart.component.ts | 7 +- 11 files changed, 211 insertions(+), 29 deletions(-) create mode 100644 libs/common/src/lib/chart-helper.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 98a0320ef..ddffe90a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### 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 ## 1.153.0 - 27.05.2022 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-market/home-market.html b/apps/client/src/app/components/home-market/home-market.html index b55103bf0..d509a641d 100644 --- a/apps/client/src/app/components/home-market/home-market.html +++ b/apps/client/src/app/components/home-market/home-market.html @@ -7,11 +7,13 @@
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.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/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/libs/common/src/lib/chart-helper.ts b/libs/common/src/lib/chart-helper.ts new file mode 100644 index 000000000..d2c68af26 --- /dev/null +++ b/libs/common/src/lib/chart-helper.ts @@ -0,0 +1,83 @@ +import { Chart, TooltipPosition } from 'chart.js'; + +import { getBackgroundColor, getTextColor } from './helper'; + +export function getTooltipOptions(currency = '', locale = '') { + return { + backgroundColor: getBackgroundColor(), + bodyColor: `rgb(${getTextColor()})`, + borderWidth: 1, + borderColor: `rgba(${getTextColor()}, 0.1)`, + callbacks: { + label: (context) => { + 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/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..389e09676 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'; @@ -255,8 +256,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 +341,7 @@ export class PortfolioProportionChartComponent private getTooltipPluginConfiguration(data: ChartConfiguration['data']) { return { + ...getTooltipOptions(this.baseCurrency, this.locale), callbacks: { label: (context) => { const labelIndex = From bbe30218bd7a6430f2a39a2ee723fedff222c112 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sat, 28 May 2022 21:10:45 +0200 Subject: [PATCH 20/31] Feature/remove dependency round to (#972) * Remove round-to dependency * Update changelog --- CHANGELOG.md | 2 ++ apps/api/src/services/twitter-bot/twitter-bot.service.ts | 8 +++----- package.json | 1 - yarn.lock | 5 ----- 4 files changed, 5 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ddffe90a2..40ddf370e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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 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 d820d6c11..402159add 100644 --- a/apps/api/src/services/twitter-bot/twitter-bot.service.ts +++ b/apps/api/src/services/twitter-bot/twitter-bot.service.ts @@ -14,7 +14,6 @@ import { import { UniqueAsset } from '@ghostfolio/common/interfaces'; import { Injectable, Logger } from '@nestjs/common'; import { isWeekend } from 'date-fns'; -import * as roundTo from 'round-to'; import { TwitterApi, TwitterApiReadWrite } from 'twitter-api-v2'; @Injectable() @@ -100,10 +99,9 @@ export class TwitterBotService { } benchmarkListing.push( - `${benchmark.name} ${roundTo( - benchmark.performances.allTimeHigh.performancePercent * 100, - 1 - )}%${ + `${benchmark.name} ${( + benchmark.performances.allTimeHigh.performancePercent * 100 + ).toFixed(1)}%${ benchmark.marketCondition !== 'NEUTRAL_MARKET' ? ' ' + resolveMarketCondition(benchmark.marketCondition).emoji : '' diff --git a/package.json b/package.json index cd05f856d..46d86ef40 100644 --- a/package.json +++ b/package.json @@ -111,7 +111,6 @@ "passport-jwt": "4.0.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", diff --git a/yarn.lock b/yarn.lock index e4cf8ed39..5beca0f91 100644 --- a/yarn.lock +++ b/yarn.lock @@ -16563,11 +16563,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" From de53cf18843ea02a9320ff42bf8896983bc0ed97 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sat, 28 May 2022 21:12:42 +0200 Subject: [PATCH 21/31] Release 1.154.0 (#973) --- CHANGELOG.md | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 40ddf370e..e72ff0763 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ 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 +## 1.154.0 - 28.05.2022 ### Added diff --git a/package.json b/package.json index 46d86ef40..e0e98088b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ghostfolio", - "version": "1.153.0", + "version": "1.154.0", "homepage": "https://ghostfol.io", "license": "AGPL-3.0", "scripts": { From b678998801d1a184ee2ef484572bc4bbc82d1b45 Mon Sep 17 00:00:00 2001 From: willyp713 Date: Sun, 29 May 2022 07:18:57 -0500 Subject: [PATCH 22/31] Feature/add-redis-password (#947) * Expose REDIS_PASSWORD --- .env | 1 + apps/api/src/app/app.module.ts | 3 ++- apps/api/src/app/redis-cache/redis-cache.module.ts | 1 + apps/api/src/services/configuration.service.ts | 1 + apps/api/src/services/interfaces/environment.interface.ts | 1 + docker/docker-compose.build.yml | 1 + docker/docker-compose.yml | 1 + 7 files changed, 8 insertions(+), 1 deletion(-) diff --git a/.env b/.env index e96c8b6b2..3b1b28877 100644 --- a/.env +++ b/.env @@ -3,6 +3,7 @@ COMPOSE_PROJECT_NAME=ghostfolio-development # CACHE REDIS_HOST=localhost REDIS_PORT=6379 +REDIS_PASSWORD=password # POSTGRES POSTGRES_DB=ghostfolio-db diff --git a/apps/api/src/app/app.module.ts b/apps/api/src/app/app.module.ts index f95c93fd2..f1fc27976 100644 --- a/apps/api/src/app/app.module.ts +++ b/apps/api/src/app/app.module.ts @@ -42,7 +42,8 @@ import { UserModule } from './user/user.module'; 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/redis-cache/redis-cache.module.ts b/apps/api/src/app/redis-cache/redis-cache.module.ts index dcda94041..05fa7bf88 100644 --- a/apps/api/src/app/redis-cache/redis-cache.module.ts +++ b/apps/api/src/app/redis-cache/redis-cache.module.ts @@ -15,6 +15,7 @@ import { RedisCacheService } from './redis-cache.service'; host: configurationService.get('REDIS_HOST'), max: configurationService.get('MAX_ITEM_IN_CACHE'), port: configurationService.get('REDIS_PORT'), + password: configurationService.get('REDIS_PASSWORD'), store: redisStore, ttl: configurationService.get('CACHE_TTL') }) diff --git a/apps/api/src/services/configuration.service.ts b/apps/api/src/services/configuration.service.ts index e405884f5..5b471686f 100644 --- a/apps/api/src/services/configuration.service.ts +++ b/apps/api/src/services/configuration.service.ts @@ -37,6 +37,7 @@ export class ConfigurationService { RAKUTEN_RAPID_API_KEY: str({ default: '' }), REDIS_HOST: str({ default: 'localhost' }), REDIS_PORT: port({ default: 6379 }), + REDIS_PASSWORD: str({ default: '' }), ROOT_URL: str({ default: 'http://localhost:4200' }), STRIPE_PUBLIC_KEY: str({ default: '' }), STRIPE_SECRET_KEY: str({ default: '' }), diff --git a/apps/api/src/services/interfaces/environment.interface.ts b/apps/api/src/services/interfaces/environment.interface.ts index c4cc8f754..ec363c0e1 100644 --- a/apps/api/src/services/interfaces/environment.interface.ts +++ b/apps/api/src/services/interfaces/environment.interface.ts @@ -28,6 +28,7 @@ export interface Environment extends CleanedEnvAccessors { RAKUTEN_RAPID_API_KEY: string; REDIS_HOST: string; REDIS_PORT: number; + REDIS_PASSWORD: string; ROOT_URL: string; STRIPE_PUBLIC_KEY: string; STRIPE_SECRET_KEY: string; diff --git a/docker/docker-compose.build.yml b/docker/docker-compose.build.yml index 1d2496f9b..37f63d8c8 100644 --- a/docker/docker-compose.build.yml +++ b/docker/docker-compose.build.yml @@ -7,6 +7,7 @@ services: environment: DATABASE_URL: postgresql://user:password@postgres:5432/ghostfolio-db?sslmode=prefer REDIS_HOST: 'redis' + REDIS_PASSWORD: ${REDIS_PASSWORD} ports: - 3333:3333 diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 7afd19a19..786f62f55 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -7,6 +7,7 @@ services: environment: DATABASE_URL: postgresql://user:password@postgres:5432/ghostfolio-db?sslmode=prefer REDIS_HOST: 'redis' + REDIS_PASSWORD: ${REDIS_PASSWORD} ports: - 3333:3333 From 697e92f81837c0c3c43358bec90218f709b9baf1 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sun, 29 May 2022 14:54:53 +0200 Subject: [PATCH 23/31] Feature/finalize exposing redis password env variable (#975) * Add hints * Update changelog --- .env | 8 ++++---- CHANGELOG.md | 6 ++++++ apps/api/src/app/redis-cache/redis-cache.module.ts | 2 +- apps/api/src/services/configuration.service.ts | 2 +- apps/api/src/services/interfaces/environment.interface.ts | 2 +- 5 files changed, 13 insertions(+), 7 deletions(-) diff --git a/.env b/.env index 3b1b28877..44c1ec3a5 100644 --- a/.env +++ b/.env @@ -3,15 +3,15 @@ COMPOSE_PROJECT_NAME=ghostfolio-development # CACHE REDIS_HOST=localhost REDIS_PORT=6379 -REDIS_PASSWORD=password +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 e72ff0763..dca00fbc2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ 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 + +### Changed + +- Exposed the environment variable `REDIS_PASSWORD` + ## 1.154.0 - 28.05.2022 ### Added 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 05fa7bf88..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,8 +14,8 @@ import { RedisCacheService } from './redis-cache.service'; useFactory: async (configurationService: ConfigurationService) => ({ host: configurationService.get('REDIS_HOST'), max: configurationService.get('MAX_ITEM_IN_CACHE'), - port: configurationService.get('REDIS_PORT'), password: configurationService.get('REDIS_PASSWORD'), + port: configurationService.get('REDIS_PORT'), store: redisStore, ttl: configurationService.get('CACHE_TTL') }) diff --git a/apps/api/src/services/configuration.service.ts b/apps/api/src/services/configuration.service.ts index 5b471686f..9666e7ab7 100644 --- a/apps/api/src/services/configuration.service.ts +++ b/apps/api/src/services/configuration.service.ts @@ -36,8 +36,8 @@ export class ConfigurationService { PORT: port({ default: 3333 }), RAKUTEN_RAPID_API_KEY: str({ default: '' }), REDIS_HOST: str({ default: 'localhost' }), - REDIS_PORT: port({ default: 6379 }), REDIS_PASSWORD: str({ default: '' }), + REDIS_PORT: port({ default: 6379 }), ROOT_URL: str({ default: 'http://localhost:4200' }), STRIPE_PUBLIC_KEY: str({ default: '' }), STRIPE_SECRET_KEY: str({ default: '' }), diff --git a/apps/api/src/services/interfaces/environment.interface.ts b/apps/api/src/services/interfaces/environment.interface.ts index ec363c0e1..79db93f54 100644 --- a/apps/api/src/services/interfaces/environment.interface.ts +++ b/apps/api/src/services/interfaces/environment.interface.ts @@ -27,8 +27,8 @@ export interface Environment extends CleanedEnvAccessors { PORT: number; RAKUTEN_RAPID_API_KEY: string; REDIS_HOST: string; - REDIS_PORT: number; REDIS_PASSWORD: string; + REDIS_PORT: number; ROOT_URL: string; STRIPE_PUBLIC_KEY: string; STRIPE_SECRET_KEY: string; From f1e06347d348074a6307cf7d73dcadebfc270557 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sun, 29 May 2022 15:37:40 +0200 Subject: [PATCH 24/31] Feature/add data source eod historical data (#974) * Add EOD Historical Data as a data source * Update changelog --- CHANGELOG.md | 8 + .../api/src/services/configuration.service.ts | 1 + .../data-provider/data-provider.module.ts | 7 +- .../eod-historical-data.service.ts | 138 ++++++++++++++++++ .../ghostfolio-scraper-api.service.ts | 2 +- .../rakuten-rapid-api.service.ts | 2 +- .../interfaces/environment.interface.ts | 1 + .../migration.sql | 2 + prisma/schema.prisma | 1 + 9 files changed, 159 insertions(+), 3 deletions(-) create mode 100644 apps/api/src/services/data-provider/eod-historical-data/eod-historical-data.service.ts create mode 100644 prisma/migrations/20220529071429_added_eod_historical_data_to_data_source/migration.sql diff --git a/CHANGELOG.md b/CHANGELOG.md index dca00fbc2..040b771a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,10 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Added + +- Added `EOD_HISTORICAL_DATA` as a new data source type + ### Changed - Exposed the environment variable `REDIS_PASSWORD` +### Todo + +- Apply data migration (`yarn database:migrate`) + ## 1.154.0 - 28.05.2022 ### Added diff --git a/apps/api/src/services/configuration.service.ts b/apps/api/src/services/configuration.service.ts index 9666e7ab7..525951a70 100644 --- a/apps/api/src/services/configuration.service.ts +++ b/apps/api/src/services/configuration.service.ts @@ -25,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: '' }), 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 26437bcdf..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'; 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/interfaces/environment.interface.ts b/apps/api/src/services/interfaces/environment.interface.ts index 79db93f54..36e9c7261 100644 --- a/apps/api/src/services/interfaces/environment.interface.ts +++ b/apps/api/src/services/interfaces/environment.interface.ts @@ -16,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; 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 From b9c94438991322dbf646b6197dfb326f6c197e26 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sun, 29 May 2022 15:38:13 +0200 Subject: [PATCH 25/31] Bugfix/fix empty state of proportion chart (#976) * Fix empty state (chart with two levels) * Update changelog --- CHANGELOG.md | 4 ++++ .../portfolio-proportion-chart.component.ts | 6 ++++++ 2 files changed, 10 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 040b771a6..bdcaedd59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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`) 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 389e09676..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 @@ -248,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 From a96e89a86eca1f9a0cd87c926504353db04b0850 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sun, 29 May 2022 15:39:57 +0200 Subject: [PATCH 26/31] Release 1.155.0 (#977) --- CHANGELOG.md | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bdcaedd59..d9196a6fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ 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 +## 1.155.0 - 29.05.2022 ### Added diff --git a/package.json b/package.json index e0e98088b..a1fe3ba8a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ghostfolio", - "version": "1.154.0", + "version": "1.155.0", "homepage": "https://ghostfol.io", "license": "AGPL-3.0", "scripts": { From 023a7147e251abd00d81f3971e592b4e430fdf44 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Tue, 31 May 2022 18:01:29 +0200 Subject: [PATCH 27/31] Feature/simplify feature page (#978) * Simplify page * Update changelog --- CHANGELOG.md | 6 ++++++ .../src/app/pages/features/features-page.html | 14 ++++++-------- .../client/src/app/pages/pricing/pricing-page.scss | 4 ---- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d9196a6fd..b64e0670f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ 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 + +### Changed + +- Simplified the features page + ## 1.155.0 - 29.05.2022 ### Added 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/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)); - } } From 2cc7c6fa1c31ea6bb88d14db4413c4a77116624d Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Fri, 3 Jun 2022 06:51:08 +0200 Subject: [PATCH 28/31] Feature/add user id to account page (#980) * Add user id * Update changelog --- CHANGELOG.md | 4 ++++ apps/client/src/app/pages/account/account-page.html | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b64e0670f..f73b3f16a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Added + +- Added the user id to the account page + ### Changed - Simplified the features page 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 }}
+
From 565947e7524d63426ca19df8c079cb84e4820935 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Fri, 3 Jun 2022 18:44:53 +0200 Subject: [PATCH 29/31] Feature/upgrade simplewebauthn browser and server to version 5.2.1 (#981) * Upgrade @simplewebauthn/browser and @simplewebauthn/server * Update changelog --- CHANGELOG.md | 1 + package.json | 6 +-- yarn.lock | 120 ++++++++++++++++++++++++++------------------------- 3 files changed, 65 insertions(+), 62 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f73b3f16a..6de0c43fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Simplified the features page +- Upgraded `@simplewebauthn/browser` and `@simplewebauthn/server` from version `4.1.0` to `5.2.1` ## 1.155.0 - 29.05.2022 diff --git a/package.json b/package.json index a1fe3ba8a..8063fe1a6 100644 --- a/package.json +++ b/package.json @@ -73,8 +73,8 @@ "@nestjs/serve-static": "2.2.2", "@nrwl/angular": "14.1.4", "@prisma/client": "3.14.0", - "@simplewebauthn/browser": "4.1.0", - "@simplewebauthn/server": "4.1.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", @@ -139,7 +139,7 @@ "@nrwl/nx-cloud": "14.0.3", "@nrwl/storybook": "14.1.4", "@nrwl/workspace": "14.1.4", - "@simplewebauthn/typescript-types": "4.0.0", + "@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/yarn.lock b/yarn.lock index 5beca0f91..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" @@ -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" @@ -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" @@ -18106,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== @@ -18116,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" From 6774c48dff2b18bf90119891329ee0a6716d2c3b Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sat, 4 Jun 2022 09:06:53 +0200 Subject: [PATCH 30/31] Feature/restructure fire page (#982) * Restructure fire page * Update changelog --- CHANGELOG.md | 1 + .../app/pages/portfolio/fire/fire-page.html | 118 +++++++++--------- .../fire-calculator.component.html | 2 +- 3 files changed, 61 insertions(+), 60 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6de0c43fd..39b91d833 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Simplified the features page +- Restructured the _FIRE_ section - Upgraded `@simplewebauthn/browser` and `@simplewebauthn/server` from version `4.1.0` to `5.2.1` ## 1.155.0 - 29.05.2022 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/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 @@
-
+ From 14a0eeab291959a0c855d4a69fe52313e4f429c2 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sat, 4 Jun 2022 10:02:38 +0200 Subject: [PATCH 31/31] Bugfix/fix docker compose files to resolve variables correctly (#983) * Fix variable resolving * Update changelog --- CHANGELOG.md | 4 ++++ README.md | 16 ++++++++-------- docker/docker-compose.build.yml | 2 +- docker/docker-compose.yml | 2 +- 4 files changed, 14 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 39b91d833..6a986f88c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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 diff --git a/README.md b/README.md index f29693e7e..545d9d6b3 100644 --- a/README.md +++ b/README.md @@ -91,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 @@ -99,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 @@ -107,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 @@ -116,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 @@ -130,8 +130,8 @@ 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) @@ -149,7 +149,7 @@ Please follow the instructions of the Ghostfolio [Unraid Community App](https:// ### 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/docker/docker-compose.build.yml b/docker/docker-compose.build.yml index 37f63d8c8..82fccad8b 100644 --- a/docker/docker-compose.build.yml +++ b/docker/docker-compose.build.yml @@ -5,7 +5,7 @@ services: env_file: - ../.env environment: - DATABASE_URL: postgresql://user:password@postgres:5432/ghostfolio-db?sslmode=prefer + DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}?sslmode=prefer REDIS_HOST: 'redis' REDIS_PASSWORD: ${REDIS_PASSWORD} ports: diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 786f62f55..c94954b3e 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -5,7 +5,7 @@ services: env_file: - ../.env environment: - DATABASE_URL: postgresql://user:password@postgres:5432/ghostfolio-db?sslmode=prefer + DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}?sslmode=prefer REDIS_HOST: 'redis' REDIS_PASSWORD: ${REDIS_PASSWORD} ports: