Browse Source

Merge branch 'ghostfolio:main' into main

pull/985/head
Leon Stoldt 3 years ago
committed by GitHub
parent
commit
0454a93754
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 7
      .env
  2. 81
      CHANGELOG.md
  3. 33
      README.md
  4. 14
      apps/api/src/app/admin/admin.service.ts
  5. 5
      apps/api/src/app/app.module.ts
  6. 32
      apps/api/src/app/benchmark/benchmark.controller.ts
  7. 25
      apps/api/src/app/benchmark/benchmark.module.ts
  8. 84
      apps/api/src/app/benchmark/benchmark.service.ts
  9. 1
      apps/api/src/app/info/info.service.ts
  10. 2
      apps/api/src/app/order/order.controller.ts
  11. 7
      apps/api/src/app/portfolio/current-rate.service.spec.ts
  12. 6
      apps/api/src/app/portfolio/interfaces/portfolio-position-detail.interface.ts
  13. 11
      apps/api/src/app/portfolio/portfolio.controller.ts
  14. 27
      apps/api/src/app/portfolio/portfolio.service.ts
  15. 1
      apps/api/src/app/redis-cache/redis-cache.module.ts
  16. 6
      apps/api/src/app/symbol/interfaces/symbol-item.interface.ts
  17. 3
      apps/api/src/app/symbol/symbol.service.ts
  18. 16
      apps/api/src/app/user/user.service.ts
  19. 50
      apps/api/src/interceptors/redact-values-in-response.interceptor.ts
  20. 3
      apps/api/src/services/configuration.service.ts
  21. 11
      apps/api/src/services/data-gathering.service.ts
  22. 7
      apps/api/src/services/data-provider/data-provider.module.ts
  23. 138
      apps/api/src/services/data-provider/eod-historical-data/eod-historical-data.service.ts
  24. 12
      apps/api/src/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service.ts
  25. 5
      apps/api/src/services/data-provider/google-sheets/google-sheets.service.ts
  26. 2
      apps/api/src/services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service.ts
  27. 8
      apps/api/src/services/data-provider/yahoo-finance/yahoo-finance.service.spec.ts
  28. 31
      apps/api/src/services/data-provider/yahoo-finance/yahoo-finance.service.ts
  29. 10
      apps/api/src/services/exchange-rate-data.module.ts
  30. 14
      apps/api/src/services/exchange-rate-data.service.ts
  31. 3
      apps/api/src/services/interfaces/environment.interface.ts
  32. 14
      apps/api/src/services/market-data.service.ts
  33. 83
      apps/api/src/services/symbol-profile.service.ts
  34. 4
      apps/api/src/services/twitter-bot/twitter-bot.module.ts
  35. 57
      apps/api/src/services/twitter-bot/twitter-bot.service.ts
  36. 2
      apps/client/src/app/components/admin-market-data-detail/admin-market-data-detail.component.html
  37. 1
      apps/client/src/app/components/home-holdings/home-holdings.component.ts
  38. 11
      apps/client/src/app/components/home-market/home-market.component.ts
  39. 27
      apps/client/src/app/components/home-market/home-market.html
  40. 8
      apps/client/src/app/components/home-market/home-market.module.ts
  41. 1
      apps/client/src/app/components/home-overview/home-overview.html
  42. 52
      apps/client/src/app/components/investment-chart/investment-chart.component.ts
  43. 5
      apps/client/src/app/components/position/position-detail-dialog/position-detail-dialog.component.ts
  44. 2
      apps/client/src/app/components/position/position-detail-dialog/position-detail-dialog.html
  45. 2
      apps/client/src/app/pages/about/about-page.component.ts
  46. 7
      apps/client/src/app/pages/account/account-page.component.ts
  47. 4
      apps/client/src/app/pages/account/account-page.html
  48. 14
      apps/client/src/app/pages/features/features-page.html
  49. 12
      apps/client/src/app/pages/landing/landing-page.html
  50. 26
      apps/client/src/app/pages/portfolio/analysis/analysis-page.html
  51. 118
      apps/client/src/app/pages/portfolio/fire/fire-page.html
  52. 6
      apps/client/src/app/pages/pricing/pricing-page.component.ts
  53. 29
      apps/client/src/app/pages/pricing/pricing-page.html
  54. 4
      apps/client/src/app/pages/pricing/pricing-page.scss
  55. 14
      apps/client/src/app/services/data.service.ts
  56. BIN
      apps/client/src/assets/images/video-preview.jpg
  57. 22
      apps/client/src/assets/sitemap.xml
  58. 2
      apps/client/src/index.html
  59. 8
      apps/client/src/styles.scss
  60. 3
      docker/docker-compose.build.yml
  61. 3
      docker/docker-compose.yml
  62. 83
      libs/common/src/lib/chart-helper.ts
  63. 3
      libs/common/src/lib/config.ts
  64. 13
      libs/common/src/lib/helper.ts
  65. 11
      libs/common/src/lib/interfaces/benchmark.interface.ts
  66. 7
      libs/common/src/lib/interfaces/enhanced-symbol-profile.interface.ts
  67. 8
      libs/common/src/lib/interfaces/index.ts
  68. 1
      libs/common/src/lib/interfaces/info-item.interface.ts
  69. 1
      libs/common/src/lib/interfaces/position.interface.ts
  70. 5
      libs/common/src/lib/interfaces/responses/benchmark-response.interface.ts
  71. 0
      libs/common/src/lib/interfaces/scraper-configuration.interface.ts
  72. 49
      libs/ui/src/lib/benchmark/benchmark.component.html
  73. 3
      libs/ui/src/lib/benchmark/benchmark.component.scss
  74. 18
      libs/ui/src/lib/benchmark/benchmark.component.ts
  75. 14
      libs/ui/src/lib/benchmark/benchmark.module.ts
  76. 1
      libs/ui/src/lib/benchmark/index.ts
  77. 2
      libs/ui/src/lib/fire-calculator/fire-calculator.component.html
  78. 6
      libs/ui/src/lib/fire-calculator/fire-calculator.component.ts
  79. 54
      libs/ui/src/lib/line-chart/line-chart.component.ts
  80. 13
      libs/ui/src/lib/portfolio-proportion-chart/portfolio-proportion-chart.component.ts
  81. 2
      libs/ui/src/lib/value/value.component.html
  82. 13
      package.json
  83. 2
      prisma/migrations/20220529071429_added_eod_historical_data_to_data_source/migration.sql
  84. 1
      prisma/schema.prisma
  85. 161
      yarn.lock

7
.env

@ -3,14 +3,15 @@ COMPOSE_PROJECT_NAME=ghostfolio-development
# CACHE
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD=<INSERT_REDIS_PASSWORD>
# POSTGRES
POSTGRES_DB=ghostfolio-db
POSTGRES_USER=user
POSTGRES_PASSWORD=password
POSTGRES_PASSWORD=<INSERT_POSTGRES_PASSWORD>
ACCESS_TOKEN_SALT=GHOSTFOLIO
ACCESS_TOKEN_SALT=<INSERT_RANDOM_STRING>
ALPHA_VANTAGE_API_KEY=
DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer
JWT_SECRET_KEY=123456
JWT_SECRET_KEY=<INSERT_RANDOM_STRING>
PORT=3333

81
CHANGELOG.md

@ -5,6 +5,87 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## Unreleased
### Added
- Added the user id to the account page
### Changed
- Simplified the features page
- Restructured the _FIRE_ section
- Upgraded `@simplewebauthn/browser` and `@simplewebauthn/server` from version `4.1.0` to `5.2.1`
### Fixed
- Fixed the `docker-compose` files to resolve variables correctly
## 1.155.0 - 29.05.2022
### Added
- Added `EOD_HISTORICAL_DATA` as a new data source type
### Changed
- Exposed the environment variable `REDIS_PASSWORD`
### Fixed
- Fixed the empty state of the portfolio proportion chart component (with 2 levels)
### Todo
- Apply data migration (`yarn database:migrate`)
## 1.154.0 - 28.05.2022
### Added
- Added a vertical hover line to inspect data points in the line chart component
### Changed
- Improved the tooltips of the chart components (content and style)
- Simplified the pricing page
- Improved the rounding numbers in the twitter bot service
- Removed the dependency `round-to`
## 1.153.0 - 27.05.2022
### Added
- Extended the benchmarks of the markets overview by the current market condition (bear and bull market)
- Extended the twitter bot service by benchmarks
- Added value redaction for the impersonation mode in the API response as an interceptor
### Changed
- Changed the twitter bot service to rest on the weekend
- Upgraded `prisma` from version `3.12.0` to `3.14.0`
### Fixed
- Fixed a styling issue in the benchmark component on mobile
## 1.152.0 - 26.05.2022
### Added
- Added the _Ghostfolio_ trailer to the landing page
- Extended the markets overview by benchmarks (current change to the all time high)
## 1.151.0 - 24.05.2022
### Added
- Added support to set the base currency as an environment variable (`BASE_CURRENCY`)
### Fixed
- Fixed an issue with the missing conversion of countries in the symbol profile overrides
## 1.150.0 - 21.05.2022
### Changed

33
README.md

@ -9,7 +9,7 @@
<h1>Ghostfolio</h1>
<p>
<strong>Open Source Wealth Management Software made for Humans</strong>
<strong>Open Source Wealth Management Software</strong>
</p>
<p>
<a href="https://ghostfol.io"><strong>Live Demo</strong></a> | <a href="https://ghostfol.io/pricing"><strong>Ghostfolio Premium</strong></a> | <a href="https://ghostfol.io/blog"><strong>Blog</strong></a> | <a href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"><strong>Slack</strong></a> | <a href="https://twitter.com/ghostfolio_"><strong>Twitter</strong></a>
@ -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.
<div align="center">
<img src="./apps/client/src/assets/images/screenshot.png" width="300">
<div align="center" style="margin-top: 1rem; margin-bottom: 1rem;">
<a href="https://www.youtube.com/watch?v=yY6ObSQVJZk">
<img src="./apps/client/src/assets/images/video-preview.jpg" width="600"></a>
</div>
## Ghostfolio Premium
@ -47,7 +48,7 @@ Ghostfolio is for you if you are...
- 🧘 into minimalism
- 🧺 caring about diversifying your financial resources
- 🆓 interested in financial independence
- 🙅 saying no to spreadsheets in 2021
- 🙅 saying no to spreadsheets in 2022
- 😎 still reading this list
## Features
@ -62,6 +63,10 @@ Ghostfolio is for you if you are...
- ✅ Zen Mode
- ✅ Mobile-first design
<div align="center" style="margin-top: 1rem; margin-bottom: 1rem;">
<img src="./apps/client/src/assets/images/screenshot.png" width="300">
</div>
## Technology Stack
Ghostfolio is a modern web application written in [TypeScript](https://www.typescriptlang.org) and organized as an [Nx](https://nx.dev) workspace.
@ -86,7 +91,7 @@ The frontend is built with [Angular](https://angular.io) and uses [Angular Mater
Run the following command to start the Docker images from [Docker Hub](https://hub.docker.com/r/ghostfolio/ghostfolio):
```bash
docker-compose -f docker/docker-compose.yml up -d
docker-compose --env-file ./.env -f docker/docker-compose.yml up -d
```
#### Setup Database
@ -94,7 +99,7 @@ docker-compose -f docker/docker-compose.yml up -d
Run the following command to setup the database once Ghostfolio is running:
```bash
docker-compose -f docker/docker-compose.yml exec ghostfolio yarn database:setup
docker-compose --env-file ./.env -f docker/docker-compose.yml exec ghostfolio yarn database:setup
```
### b. Build and run environment
@ -102,8 +107,8 @@ docker-compose -f docker/docker-compose.yml exec ghostfolio yarn database:setup
Run the following commands to build and start the Docker images:
```bash
docker-compose -f docker/docker-compose.build.yml build
docker-compose -f docker/docker-compose.build.yml up -d
docker-compose --env-file ./.env -f docker/docker-compose.build.yml build
docker-compose --env-file ./.env -f docker/docker-compose.build.yml up -d
```
#### Setup Database
@ -111,7 +116,7 @@ docker-compose -f docker/docker-compose.build.yml up -d
Run the following command to setup the database once Ghostfolio is running:
```bash
docker-compose -f docker/docker-compose.build.yml exec ghostfolio yarn database:setup
docker-compose --env-file ./.env -f docker/docker-compose.build.yml exec ghostfolio yarn database:setup
```
### Fetch Historical Data
@ -125,8 +130,12 @@ Open http://localhost:3333 in your browser and accomplish these steps:
### Upgrade Version
1. Increase the version of the `ghostfolio/ghostfolio` Docker image in `docker/docker-compose.yml`
1. Run the following command to start the new Docker image: `docker-compose -f docker/docker-compose.yml up -d`
1. Then, run the following command to keep your database schema in sync: `docker-compose -f docker/docker-compose.yml exec ghostfolio yarn database:migrate`
1. Run the following command to start the new Docker image: `docker-compose --env-file ./.env -f docker/docker-compose.yml up -d`
1. Then, run the following command to keep your database schema in sync: `docker-compose --env-file ./.env -f docker/docker-compose.yml exec ghostfolio yarn database:migrate`
## Run with _Unraid_ (self-hosting)
Please follow the instructions of the Ghostfolio [Unraid Community App](https://unraid.net/community/apps?q=ghostfolio).
## Development
@ -140,7 +149,7 @@ Open http://localhost:3333 in your browser and accomplish these steps:
### Setup
1. Run `yarn install`
1. Run `docker-compose -f docker/docker-compose.dev.yml up -d` to start [PostgreSQL](https://www.postgresql.org) and [Redis](https://redis.io)
1. Run `docker-compose --env-file ./.env -f docker/docker-compose.dev.yml up -d` to start [PostgreSQL](https://www.postgresql.org) and [Redis](https://redis.io)
1. Run `yarn database:setup` to initialize the database schema and populate your database with (example) data
1. Start the server and the client (see [_Development_](#Development))
1. Create a new user via _Get Started_ (this first user will get the role `ADMIN`)

14
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
)
};

5
apps/api/src/app/app.module.ts

@ -20,6 +20,7 @@ import { AccountModule } from './account/account.module';
import { AdminModule } from './admin/admin.module';
import { AppController } from './app.controller';
import { AuthModule } from './auth/auth.module';
import { BenchmarkModule } from './benchmark/benchmark.module';
import { CacheModule } from './cache/cache.module';
import { ExportModule } from './export/export.module';
import { ImportModule } from './import/import.module';
@ -37,10 +38,12 @@ import { UserModule } from './user/user.module';
AccountModule,
AuthDeviceModule,
AuthModule,
BenchmarkModule,
BullModule.forRoot({
redis: {
host: process.env.REDIS_HOST,
port: parseInt(process.env.REDIS_PORT, 10)
port: parseInt(process.env.REDIS_PORT, 10),
password: process.env.REDIS_PASSWORD
}
}),
CacheModule,

32
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<BenchmarkResponse> {
const benchmarkAssets: UniqueAsset[] =
((await this.propertyService.getByKey(
PROPERTY_BENCHMARKS
)) as UniqueAsset[]) ?? [];
return {
benchmarks: await this.benchmarkService.getBenchmarks(benchmarkAssets)
};
}
}

25
apps/api/src/app/benchmark/benchmark.module.ts

@ -0,0 +1,25 @@
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { MarketDataModule } from '@ghostfolio/api/services/market-data.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
import { Module } from '@nestjs/common';
import { BenchmarkController } from './benchmark.controller';
import { BenchmarkService } from './benchmark.service';
@Module({
controllers: [BenchmarkController],
exports: [BenchmarkService],
imports: [
ConfigurationModule,
DataProviderModule,
MarketDataModule,
PropertyModule,
RedisCacheModule,
SymbolProfileModule
],
providers: [BenchmarkService]
})
export class BenchmarkModule {}

84
apps/api/src/app/benchmark/benchmark.service.ts

@ -0,0 +1,84 @@
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
import { BenchmarkResponse, UniqueAsset } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common';
import Big from 'big.js';
@Injectable()
export class BenchmarkService {
private readonly CACHE_KEY_BENCHMARKS = 'BENCHMARKS';
public constructor(
private readonly dataProviderService: DataProviderService,
private readonly marketDataService: MarketDataService,
private readonly redisCacheService: RedisCacheService,
private readonly symbolProfileService: SymbolProfileService
) {}
public async getBenchmarks(
benchmarkAssets: UniqueAsset[]
): Promise<BenchmarkResponse['benchmarks']> {
let benchmarks: BenchmarkResponse['benchmarks'];
try {
benchmarks = JSON.parse(
await this.redisCacheService.get(this.CACHE_KEY_BENCHMARKS)
);
if (benchmarks) {
return benchmarks;
}
} catch {}
const promises: Promise<number>[] = [];
const [quotes, assetProfiles] = await Promise.all([
this.dataProviderService.getQuotes(benchmarkAssets),
this.symbolProfileService.getSymbolProfiles(benchmarkAssets)
]);
for (const benchmarkAsset of benchmarkAssets) {
promises.push(this.marketDataService.getMax(benchmarkAsset));
}
const allTimeHighs = await Promise.all(promises);
benchmarks = allTimeHighs.map((allTimeHigh, index) => {
const { marketPrice } = quotes[benchmarkAssets[index].symbol];
const performancePercentFromAllTimeHigh = new Big(marketPrice)
.div(allTimeHigh)
.minus(1);
return {
marketCondition: this.getMarketCondition(
performancePercentFromAllTimeHigh
),
name: assetProfiles.find(({ dataSource, symbol }) => {
return (
dataSource === benchmarkAssets[index].dataSource &&
symbol === benchmarkAssets[index].symbol
);
})?.name,
performances: {
allTimeHigh: {
performancePercent: performancePercentFromAllTimeHigh.toNumber()
}
}
};
});
await this.redisCacheService.set(
this.CACHE_KEY_BENCHMARKS,
JSON.stringify(benchmarks)
);
return benchmarks;
}
private getMarketCondition(aPerformanceInPercent: Big) {
return aPerformanceInPercent.lte(-0.2) ? 'BEAR_MARKET' : 'NEUTRAL_MARKET';
}
}

1
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(),

2
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

7
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();

6
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';

11
apps/api/src/app/portfolio/portfolio.controller.ts

@ -4,11 +4,11 @@ import {
hasNotDefinedValuesInObject,
nullifyValuesInObject
} from '@ghostfolio/api/helper/object.helper';
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor';
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { baseCurrency } from '@ghostfolio/common/config';
import { parseDate } from '@ghostfolio/common/helper';
import {
Filter,
@ -43,6 +43,8 @@ import { PortfolioService } from './portfolio.service';
@Controller('portfolio')
export class PortfolioController {
private baseCurrency: string;
public constructor(
private readonly accessService: AccessService,
private readonly configurationService: ConfigurationService,
@ -50,7 +52,9 @@ export class PortfolioController {
private readonly portfolioService: PortfolioService,
@Inject(REQUEST) private readonly request: RequestWithUser,
private readonly userService: UserService
) {}
) {
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
}
@Get('chart')
@UseGuards(AuthGuard('jwt'))
@ -103,6 +107,7 @@ export class PortfolioController {
@Get('details')
@UseGuards(AuthGuard('jwt'))
@UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getDetails(
@Headers('impersonation-id') impersonationId: string,
@ -327,7 +332,7 @@ export class PortfolioController {
return this.exchangeRateDataService.toCurrency(
portfolioPosition.quantity * portfolioPosition.marketPrice,
portfolioPosition.currency,
this.request.user?.Settings?.currency ?? baseCurrency
this.request.user?.Settings?.currency ?? this.baseCurrency
);
})
.reduce((a, b) => a + b, 0);

27
apps/api/src/app/portfolio/portfolio.service.ts

@ -15,19 +15,19 @@ import { CurrencyClusterRiskBaseCurrencyInitialInvestment } from '@ghostfolio/ap
import { CurrencyClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/current-investment';
import { CurrencyClusterRiskInitialInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/initial-investment';
import { FeeRatioInitialInvestment } from '@ghostfolio/api/models/rules/fees/fee-ratio-initial-investment';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
import { EnhancedSymbolProfile } from '@ghostfolio/api/services/interfaces/symbol-profile.interface';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
import {
ASSET_SUB_CLASS_EMERGENCY_FUND,
UNKNOWN_KEY,
baseCurrency
UNKNOWN_KEY
} from '@ghostfolio/common/config';
import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper';
import {
Accounts,
EnhancedSymbolProfile,
Filter,
HistoricalDataItem,
PortfolioDetails,
@ -82,8 +82,11 @@ const emergingMarkets = require('../../assets/countries/emerging-markets.json');
@Injectable()
export class PortfolioService {
private baseCurrency: string;
public constructor(
private readonly accountService: AccountService,
private readonly configurationService: ConfigurationService,
private readonly currentRateService: CurrentRateService,
private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService,
@ -93,7 +96,9 @@ export class PortfolioService {
private readonly rulesService: RulesService,
private readonly symbolProfileService: SymbolProfileService,
private readonly userService: UserService
) {}
) {
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
}
public async getAccounts(aUserId: string): Promise<AccountWithValue[]> {
const [accounts, details] = await Promise.all([
@ -320,7 +325,7 @@ export class PortfolioService {
const userCurrency =
user.Settings?.currency ??
this.request.user?.Settings?.currency ??
baseCurrency;
this.baseCurrency;
const { orders, portfolioOrders, transactionPoints } =
await this.getTransactionPoints({
@ -370,7 +375,7 @@ export class PortfolioService {
const [dataProviderResponses, symbolProfiles] = await Promise.all([
this.dataProviderService.getQuotes(dataGatheringItems),
this.symbolProfileService.getSymbolProfiles(symbols)
this.symbolProfileService.getSymbolProfilesBySymbols(symbols)
]);
const symbolProfileMap: { [symbol: string]: EnhancedSymbolProfile } = {};
@ -513,9 +518,8 @@ export class PortfolioService {
}
const positionCurrency = orders[0].SymbolProfile.currency;
const [SymbolProfile] = await this.symbolProfileService.getSymbolProfiles([
aSymbol
]);
const [SymbolProfile] =
await this.symbolProfileService.getSymbolProfilesBySymbols([aSymbol]);
const portfolioOrders: PortfolioOrder[] = orders
.filter((order) => {
@ -763,7 +767,7 @@ export class PortfolioService {
const [dataProviderResponses, symbolProfiles] = await Promise.all([
this.dataProviderService.getQuotes(dataGatheringItem),
this.symbolProfileService.getSymbolProfiles(symbols)
this.symbolProfileService.getSymbolProfilesBySymbols(symbols)
]);
const symbolProfileMap: { [symbol: string]: EnhancedSymbolProfile } = {};
@ -1213,7 +1217,8 @@ export class PortfolioService {
orders: OrderWithAccount[];
portfolioOrders: PortfolioOrder[];
}> {
const userCurrency = this.request.user?.Settings?.currency ?? baseCurrency;
const userCurrency =
this.request.user?.Settings?.currency ?? this.baseCurrency;
const orders = await this.orderService.getOrders({
filters,

1
apps/api/src/app/redis-cache/redis-cache.module.ts

@ -14,6 +14,7 @@ import { RedisCacheService } from './redis-cache.service';
useFactory: async (configurationService: ConfigurationService) => ({
host: configurationService.get('REDIS_HOST'),
max: configurationService.get('MAX_ITEM_IN_CACHE'),
password: configurationService.get('REDIS_PASSWORD'),
port: configurationService.get('REDIS_PORT'),
store: redisStore,
ttl: configurationService.get('CACHE_TTL')

6
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;
}

3
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
};
}

16
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
}
}
}

50
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<T>
implements NestInterceptor<T, any>
{
public constructor() {}
public intercept(
context: ExecutionContext,
next: CallHandler<T>
): Observable<any> {
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;
})
);
}
}

3
apps/api/src/services/configuration.service.ts

@ -12,6 +12,7 @@ export class ConfigurationService {
this.environmentConfiguration = cleanEnv(process.env, {
ACCESS_TOKEN_SALT: str(),
ALPHA_VANTAGE_API_KEY: str({ default: '' }),
BASE_CURRENCY: str({ default: 'USD' }),
CACHE_TTL: num({ default: 1 }),
DATA_SOURCE_PRIMARY: str({ default: DataSource.YAHOO }),
DATA_SOURCES: json({ default: JSON.stringify([DataSource.YAHOO]) }),
@ -24,6 +25,7 @@ export class ConfigurationService {
ENABLE_FEATURE_STATISTICS: bool({ default: false }),
ENABLE_FEATURE_SUBSCRIPTION: bool({ default: false }),
ENABLE_FEATURE_SYSTEM_MESSAGE: bool({ default: false }),
EOD_HISTORICAL_DATA_API_KEY: str({ default: '' }),
GOOGLE_CLIENT_ID: str({ default: 'dummyClientId' }),
GOOGLE_SECRET: str({ default: 'dummySecret' }),
GOOGLE_SHEETS_ACCOUNT: str({ default: '' }),
@ -35,6 +37,7 @@ export class ConfigurationService {
PORT: port({ default: 3333 }),
RAKUTEN_RAPID_API_KEY: str({ default: '' }),
REDIS_HOST: str({ default: 'localhost' }),
REDIS_PASSWORD: str({ default: '' }),
REDIS_PORT: port({ default: 6379 }),
ROOT_URL: str({ default: 'http://localhost:4200' }),
STRIPE_PUBLIC_KEY: str({ default: '' }),

11
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) => {

7
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,

138
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<Partial<SymbolProfile>> {
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: [] };
}
}

12
apps/api/src/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service.ts

@ -10,7 +10,7 @@ import { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common';
import { DataSource, SymbolProfile } from '@prisma/client';
import * as bent from 'bent';
import bent from 'bent';
import * as cheerio from 'cheerio';
import { addDays, format, isBefore } from 'date-fns';
@ -46,9 +46,8 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
try {
const symbol = aSymbol;
const [symbolProfile] = await this.symbolProfileService.getSymbolProfiles(
[symbol]
);
const [symbolProfile] =
await this.symbolProfileService.getSymbolProfilesBySymbols([symbol]);
const { defaultMarketPrice, selector, url } =
symbolProfile.scraperConfiguration;
@ -108,9 +107,8 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
}
try {
const symbolProfiles = await this.symbolProfileService.getSymbolProfiles(
aSymbols
);
const symbolProfiles =
await this.symbolProfileService.getSymbolProfilesBySymbols(aSymbols);
const marketData = await this.prismaService.marketData.findMany({
distinct: ['symbol'],

5
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'),

2
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()

8
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 () => {

31
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;

10
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]
})

14
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}`
};
});
}

3
apps/api/src/services/interfaces/environment.interface.ts

@ -3,6 +3,7 @@ import { CleanedEnvAccessors } from 'envalid';
export interface Environment extends CleanedEnvAccessors {
ACCESS_TOKEN_SALT: string;
ALPHA_VANTAGE_API_KEY: string;
BASE_CURRENCY: string;
CACHE_TTL: number;
DATA_SOURCE_PRIMARY: string;
DATA_SOURCES: string | string[]; // string is not correct, error in envalid?
@ -15,6 +16,7 @@ export interface Environment extends CleanedEnvAccessors {
ENABLE_FEATURE_STATISTICS: boolean;
ENABLE_FEATURE_SUBSCRIPTION: boolean;
ENABLE_FEATURE_SYSTEM_MESSAGE: boolean;
EOD_HISTORICAL_DATA_API_KEY: string;
GOOGLE_CLIENT_ID: string;
GOOGLE_SECRET: string;
GOOGLE_SHEETS_ACCOUNT: string;
@ -26,6 +28,7 @@ export interface Environment extends CleanedEnvAccessors {
PORT: number;
RAKUTEN_RAPID_API_KEY: string;
REDIS_HOST: string;
REDIS_PASSWORD: string;
REDIS_PORT: number;
ROOT_URL: string;
STRIPE_PUBLIC_KEY: string;

14
apps/api/src/services/market-data.service.ts

@ -34,6 +34,20 @@ export class MarketDataService {
});
}
public async getMax({ dataSource, symbol }: UniqueAsset): Promise<number> {
const aggregations = await this.prismaService.marketData.aggregate({
_max: {
marketPrice: true
},
where: {
dataSource,
symbol
}
});
return aggregations._max.marketPrice;
}
public async getRange({
dateQuery,
symbols

83
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<EnhancedSymbolProfile[]> {
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<EnhancedSymbolProfile[]> {
return this.prismaService.symbolProfile
@ -59,7 +90,9 @@ export class SymbolProfileService {
return symbolProfiles.map((symbolProfile) => {
const item = {
...symbolProfile,
countries: this.getCountries(symbolProfile),
countries: this.getCountries(
symbolProfile?.countries as unknown as Prisma.JsonArray
),
scraperConfiguration: this.getScraperConfiguration(symbolProfile),
sectors: this.getSectors(symbolProfile),
symbolMapping: this.getSymbolMapping(symbolProfile)
@ -70,9 +103,17 @@ export class SymbolProfileService {
item.SymbolProfileOverrides.assetClass ?? item.assetClass;
item.assetSubClass =
item.SymbolProfileOverrides.assetSubClass ?? item.assetSubClass;
item.countries =
(item.SymbolProfileOverrides.countries as unknown as Country[]) ??
item.countries;
if (
(item.SymbolProfileOverrides.countries as unknown as Prisma.JsonArray)
?.length > 0
) {
item.countries = this.getCountries(
item.SymbolProfileOverrides
?.countries as unknown as Prisma.JsonArray
);
}
item.name = item.SymbolProfileOverrides?.name ?? item.name;
item.sectors =
(item.SymbolProfileOverrides.sectors as unknown as Sector[]) ??
@ -85,20 +126,22 @@ export class SymbolProfileService {
});
}
private getCountries(symbolProfile: SymbolProfile): Country[] {
return ((symbolProfile?.countries as Prisma.JsonArray) ?? []).map(
(country) => {
const { code, weight } = country as Prisma.JsonObject;
private getCountries(aCountries: Prisma.JsonArray = []): Country[] {
if (aCountries === null) {
return [];
}
return {
code: code as string,
continent:
continents[countries[code as string]?.continent] ?? UNKNOWN_KEY,
name: countries[code as string]?.name ?? UNKNOWN_KEY,
weight: weight as number
};
}
);
return aCountries.map((country: Pick<Country, 'code' | 'weight'>) => {
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(

4
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 {}

57
apps/api/src/services/twitter-bot/twitter-bot.service.ts

@ -1,12 +1,19 @@
import { BenchmarkService } from '@ghostfolio/api/app/benchmark/benchmark.service';
import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import {
PROPERTY_BENCHMARKS,
ghostfolioFearAndGreedIndexDataSource,
ghostfolioFearAndGreedIndexSymbol
} from '@ghostfolio/common/config';
import { resolveFearAndGreedIndex } from '@ghostfolio/common/helper';
import {
resolveFearAndGreedIndex,
resolveMarketCondition
} from '@ghostfolio/common/helper';
import { UniqueAsset } from '@ghostfolio/common/interfaces';
import { Injectable, Logger } from '@nestjs/common';
import { isSunday } from 'date-fns';
import { isWeekend } from 'date-fns';
import { TwitterApi, TwitterApiReadWrite } from 'twitter-api-v2';
@Injectable()
@ -14,7 +21,9 @@ export class TwitterBotService {
private twitterClient: TwitterApiReadWrite;
public constructor(
private readonly benchmarkService: BenchmarkService,
private readonly configurationService: ConfigurationService,
private readonly propertyService: PropertyService,
private readonly symbolService: SymbolService
) {
this.twitterClient = new TwitterApi({
@ -30,7 +39,7 @@ export class TwitterBotService {
public async tweetFearAndGreedIndex() {
if (
!this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX') ||
isSunday(new Date())
isWeekend(new Date())
) {
return;
}
@ -48,7 +57,16 @@ export class TwitterBotService {
symbolItem.marketPrice
);
const status = `Current Market Mood: ${emoji} ${text} (${symbolItem.marketPrice}/100)\n\n#FearAndGreed #Markets #ServiceTweet`;
let status = `Current Market Mood: ${emoji} ${text} (${symbolItem.marketPrice}/100)`;
const benchmarkListing = await this.getBenchmarkListing(3);
if (benchmarkListing?.length > 1) {
status += '\n\n';
status += '±% from ATH\n';
status += benchmarkListing;
}
const { data: createdTweet } = await this.twitterClient.v2.tweet(
status
);
@ -62,4 +80,35 @@ export class TwitterBotService {
Logger.error(error, 'TwitterBotService');
}
}
private async getBenchmarkListing(aMax: number) {
const benchmarkAssets: UniqueAsset[] =
((await this.propertyService.getByKey(
PROPERTY_BENCHMARKS
)) as UniqueAsset[]) ?? [];
const benchmarks = await this.benchmarkService.getBenchmarks(
benchmarkAssets
);
const benchmarkListing: string[] = [];
for (const [index, benchmark] of benchmarks.entries()) {
if (index > aMax - 1) {
break;
}
benchmarkListing.push(
`${benchmark.name} ${(
benchmark.performances.allTimeHigh.performancePercent * 100
).toFixed(1)}%${
benchmark.marketCondition !== 'NEUTRAL_MARKET'
? ' ' + resolveMarketCondition(benchmark.marketCondition).emoji
: ''
}`
);
}
return benchmarkListing.join('\n');
}
}

2
apps/client/src/app/components/admin-market-data-detail/admin-market-data-detail.component.html

@ -2,8 +2,10 @@
<gf-line-chart
class="mb-4"
[historicalDataItems]="historicalDataItems"
[locale]="locale"
[showXAxis]="true"
[showYAxis]="true"
[symbol]="symbol"
></gf-line-chart>
<div *ngFor="let itemByMonth of marketDataByMonth | keyvalue" class="d-flex">
<div class="date px-1 text-nowrap">{{ itemByMonth.key }}</div>

1
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({

11
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();
}
});

27
apps/client/src/app/components/home-market/home-market.html

@ -1,18 +1,19 @@
<div
class="align-items-center container d-flex flex-grow-1 h-100 justify-content-center w-100"
>
<div class="no-gutters row w-100">
<div class="container">
<h3 class="mb-3 text-center" i18n>Markets</h3>
<div class="mb-5 row">
<div class="col-xs-12 col-md-8 offset-md-2">
<div class="mb-2 text-center text-muted">
<small i18n>Last {{ numberOfDays }} Days</small>
</div>
<gf-line-chart
class="mb-5"
class="mb-3"
symbol="Fear & Greed Index"
yMax="100"
yMaxLabel="Greed"
yMin="0"
yMinLabel="Fear"
[historicalDataItems]="historicalData"
[locale]="user?.settings?.locale"
[showXAxis]="true"
[showYAxis]="true"
></gf-line-chart>
@ -23,4 +24,20 @@
></gf-fear-and-greed-index>
</div>
</div>
<div class="mb-3 row">
<div class="col-xs-12 col-md-8 offset-md-2">
<gf-benchmark
*ngFor="let benchmark of benchmarks"
class="py-2"
[benchmark]="benchmark"
[locale]="user?.settings?.locale"
></gf-benchmark>
<gf-benchmark
*ngIf="!benchmarks"
class="py-2"
[benchmark]="undefined"
></gf-benchmark>
</div>
</div>
</div>

8
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]
})

1
apps/client/src/app/components/home-overview/home-overview.html

@ -6,6 +6,7 @@
<gf-line-chart
symbol="Performance"
[historicalDataItems]="historicalDataItems"
[locale]="user?.settings?.locale"
[ngClass]="{ 'pr-3': deviceType === 'mobile' }"
[showGradient]="true"
[showLoader]="false"

52
apps/client/src/app/components/investment-chart/investment-chart.component.ts

@ -6,11 +6,18 @@ import {
Input,
OnChanges,
OnDestroy,
OnInit,
ViewChild
} from '@angular/core';
import {
getTooltipOptions,
getTooltipPositionerMapTop,
getVerticalHoverLinePlugin
} from '@ghostfolio/common/chart-helper';
import { primaryColorRgb } from '@ghostfolio/common/config';
import {
getBackgroundColor,
getDateFormatString,
getTextColor,
parseDate,
transformTickToAbbreviation
} from '@ghostfolio/common/helper';
@ -21,7 +28,8 @@ import {
LineElement,
LinearScale,
PointElement,
TimeScale
TimeScale,
Tooltip
} from 'chart.js';
import { addDays, isAfter, parseISO, subDays } from 'date-fns';
@ -32,9 +40,11 @@ import { addDays, isAfter, parseISO, subDays } from 'date-fns';
styleUrls: ['./investment-chart.component.scss']
})
export class InvestmentChartComponent implements OnChanges, OnDestroy {
@Input() currency: string;
@Input() daysInMarket: number;
@Input() investments: InvestmentItem[];
@Input() isInPercent = false;
@Input() locale: string;
@ViewChild('chartCanvas') chartCanvas;
@ -47,8 +57,12 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
LineController,
LineElement,
PointElement,
TimeScale
TimeScale,
Tooltip
);
Tooltip.positioners['top'] = (elements, position) =>
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 = <unknown>(
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: <unknown>{
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: <unknown>'top',
xAlign: 'center',
yAlign: 'bottom'
};
}
private isInFuture<T>(aContext: any, aValue: T) {
return isAfter(new Date(aContext?.p1?.parsed?.x), new Date())
? aValue

5
apps/client/src/app/components/position/position-detail-dialog/position-detail-dialog.component.ts

@ -9,9 +9,10 @@ import {
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { DataService } from '@ghostfolio/client/services/data.service';
import { DATE_FORMAT, downloadAsFile } from '@ghostfolio/common/helper';
import { EnhancedSymbolProfile } from '@ghostfolio/common/interfaces';
import { OrderWithAccount } from '@ghostfolio/common/types';
import { LineChartItem } from '@ghostfolio/ui/line-chart/interfaces/line-chart.interface';
import { SymbolProfile, Tag } from '@prisma/client';
import { Tag } from '@prisma/client';
import { format, isSameMonth, isToday, parseISO } from 'date-fns';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@ -48,7 +49,7 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
public sectors: {
[name: string]: { name: string; value: number };
};
public SymbolProfile: SymbolProfile;
public SymbolProfile: EnhancedSymbolProfile;
public tags: Tag[];
public transactionCount: number;
public value: number;

2
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"

2
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;

7
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;

4
apps/client/src/app/pages/account/account-page.html

@ -169,6 +169,10 @@
></mat-slide-toggle>
</div>
</div>
<div class="align-items-center d-flex mt-4 py-1">
<div class="pr-1 w-50" i18n>ID</div>
<div class="pl-1 w-50">{{ user?.id }}</div>
</div>
</mat-card-content>
</mat-card>
</div>

14
apps/client/src/app/pages/features/features-page.html

@ -4,14 +4,12 @@
<h3 class="d-flex justify-content-center mb-3 text-center" i18n>
Features
</h3>
<mat-card class="mb-4">
<mat-card-content>
<p>
Check out the numerous features of <strong>Ghostfolio</strong> to
manage your wealth.
</p>
</mat-card-content>
</mat-card>
<div class="mb-4">
<p>
Check out the numerous features of <strong>Ghostfolio</strong> to
manage your wealth.
</p>
</div>
<div class="row">
<div class="col-xs-12 col-md-4 mb-3">
<mat-card class="d-flex flex-column h-100">

12
apps/client/src/app/pages/landing/landing-page.html

@ -51,6 +51,18 @@
stocks, ETFs or cryptocurrencies and make solid, data-driven investment
decisions.
</p>
<p>
<a
href="https://www.youtube.com/watch?v=yY6ObSQVJZk"
title="Watch the Ghostfol.io Trailer on YouTube"
>
<img
alt="Ghostfol.io Trailer"
src="./assets/images/video-preview.jpg"
style="max-width: 100%; width: 40rem"
/>
</a>
</p>
</div>
</div>

26
apps/client/src/app/pages/portfolio/analysis/analysis-page.html

@ -2,21 +2,17 @@
<div class="investment-chart row">
<div class="col-lg">
<h3 class="d-flex justify-content-center mb-3" i18n>Analysis</h3>
<mat-card class="mb-3">
<mat-card-header>
<mat-card-title class="align-items-center d-flex" i18n
>Investment Timeline</mat-card-title
>
</mat-card-header>
<mat-card-content>
<gf-investment-chart
class="h-100"
[daysInMarket]="daysInMarket"
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
[investments]="investments"
></gf-investment-chart>
</mat-card-content>
</mat-card>
<div class="mb-3">
<div class="h5 mb-3" i18n>Investment Timeline</div>
<gf-investment-chart
class="h-100"
[currency]="user?.settings?.baseCurrency"
[daysInMarket]="daysInMarket"
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
[investments]="investments"
[locale]="user?.settings?.locale"
></gf-investment-chart>
</div>
</div>
</div>

118
apps/client/src/app/pages/portfolio/fire/fire-page.html

@ -1,68 +1,68 @@
<div class="container">
<div class="row">
<div class="row mb-5">
<div class="col-lg">
<h3 class="d-flex justify-content-center mb-3" i18n>FIRE</h3>
<div class="mb-5">
<h4 i18n>4% Rule</h4>
<div *ngIf="isLoading">
<ngx-skeleton-loader
animation="pulse"
class="my-1"
[theme]="{
height: '1rem',
width: '100%'
}"
></ngx-skeleton-loader>
<ngx-skeleton-loader
animation="pulse"
[theme]="{
height: '1rem',
width: '10rem'
}"
></ngx-skeleton-loader>
</div>
<div *ngIf="!isLoading">
If you retire today, you would be able to withdraw
<span class="font-weight-bold"
><gf-value
class="d-inline-block"
[currency]="user?.settings?.baseCurrency"
[locale]="user?.settings?.locale"
[value]="withdrawalRatePerYear?.toNumber()"
></gf-value>
per year</span
>
or
<span class="font-weight-bold"
><gf-value
class="d-inline-block"
[currency]="user?.settings?.baseCurrency"
[locale]="user?.settings?.locale"
[value]="withdrawalRatePerMonth?.toNumber()"
></gf-value>
per month</span
>, based on your total assets of
<gf-value
class="d-inline-block"
[currency]="user?.settings?.baseCurrency"
[locale]="user?.settings?.locale"
[value]="fireWealth?.toNumber()"
></gf-value>
and a withdrawal rate of 4%.
</div>
<div>
<h4 class="mb-3" i18n>Calculator</h4>
<gf-fire-calculator
[currency]="user?.settings?.baseCurrency"
[deviceType]="deviceType"
[fireWealth]="fireWealth?.toNumber()"
[hasPermissionToUpdateUserSettings]="hasPermissionToUpdateUserSettings"
[locale]="user?.settings?.locale"
[savingsRate]="user?.settings?.savingsRate"
(savingsRateChanged)="onSavingsRateChange($event)"
></gf-fire-calculator>
</div>
</div>
</div>
<div>
<h4 class="mb-3" i18n>Calculator</h4>
<gf-fire-calculator
[currency]="user?.settings?.baseCurrency"
[deviceType]="deviceType"
[fireWealth]="fireWealth?.toNumber()"
[hasPermissionToUpdateUserSettings]="hasPermissionToUpdateUserSettings"
[locale]="user?.settings?.locale"
[savingsRate]="user?.settings?.savingsRate"
(savingsRateChanged)="onSavingsRateChange($event)"
></gf-fire-calculator>
<h4 i18n>4% Rule</h4>
<div *ngIf="isLoading">
<ngx-skeleton-loader
animation="pulse"
class="my-1"
[theme]="{
height: '1rem',
width: '100%'
}"
></ngx-skeleton-loader>
<ngx-skeleton-loader
animation="pulse"
[theme]="{
height: '1rem',
width: '10rem'
}"
></ngx-skeleton-loader>
</div>
<div *ngIf="!isLoading">
If you retire today, you would be able to withdraw
<span class="font-weight-bold"
><gf-value
class="d-inline-block"
[currency]="user?.settings?.baseCurrency"
[locale]="user?.settings?.locale"
[value]="withdrawalRatePerYear?.toNumber()"
></gf-value>
per year</span
>
or
<span class="font-weight-bold"
><gf-value
class="d-inline-block"
[currency]="user?.settings?.baseCurrency"
[locale]="user?.settings?.locale"
[value]="withdrawalRatePerMonth?.toNumber()"
></gf-value>
per month</span
>, based on your total assets of
<gf-value
class="d-inline-block"
[currency]="user?.settings?.baseCurrency"
[locale]="user?.settings?.locale"
[value]="fireWealth?.toNumber()"
></gf-value>
and a withdrawal rate of 4%.
</div>
</div>
</div>

6
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;
}

29
apps/client/src/app/pages/pricing/pricing-page.html

@ -4,22 +4,19 @@
<h3 class="d-flex justify-content-center mb-3 text-center" i18n>
Pricing Plans
</h3>
<mat-card class="mb-4">
<mat-card-content>
<p>
Our official
<strong>Ghostfolio Premium</strong> 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.
</p>
<p>
If you prefer to run <strong>Ghostfolio</strong> on your own
infrastructure, please find the source code and further instructions
on <a href="https://github.com/ghostfolio/ghostfolio">GitHub</a>.
</p>
</mat-card-content>
</mat-card>
<div class="mb-4">
<p>
Our official
<strong>Ghostfolio Premium</strong> 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.
</p>
<p>
If you prefer to run <strong>Ghostfolio</strong> on your own
infrastructure, please find the source code and further instructions
on <a href="https://github.com/ghostfolio/ghostfolio">GitHub</a>.
</p>
</div>
<div class="row">
<div class="col-xs-12 col-md-4 mb-3">
<mat-card class="d-flex flex-column h-100">

4
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));
}
}

14
apps/client/src/app/services/data.service.ts

@ -6,6 +6,7 @@ import { UpdateAccountDto } from '@ghostfolio/api/app/account/update-account.dto
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { Activities } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { UpdateOrderDto } from '@ghostfolio/api/app/order/update-order.dto';
import { PortfolioPositionDetail } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position-detail.interface';
import { PortfolioPositions } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-positions.interface';
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { SymbolItem } from '@ghostfolio/api/app/symbol/interfaces/symbol-item.interface';
@ -18,6 +19,7 @@ import {
Accounts,
AdminData,
AdminMarketData,
BenchmarkResponse,
Export,
Filter,
InfoItem,
@ -89,6 +91,10 @@ export class DataService {
return this.http.get<Access[]>('/api/v1/access');
}
public fetchBenchmarks() {
return this.http.get<BenchmarkResponse>('/api/v1/benchmark');
}
public fetchChart({ range }: { range: DateRange }) {
return this.http.get<PortfolioChart>('/api/v1/portfolio/chart', {
params: { range }
@ -273,13 +279,15 @@ export class DataService {
symbol: string;
}) {
return this.http
.get<any>(`/api/v1/portfolio/position/${dataSource}/${symbol}`)
.get<PortfolioPositionDetail>(
`/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(<string>(<unknown>order.createdAt));
order.date = parseISO(<string>(<unknown>order.date));
}
}

BIN
apps/client/src/assets/images/video-preview.jpg

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

22
apps/client/src/assets/sitemap.xml

@ -6,46 +6,46 @@
http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd">
<url>
<loc>https://ghostfol.io</loc>
<lastmod>2022-02-13T00:00:00+00:00</lastmod>
<lastmod>2022-05-28T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/about</loc>
<lastmod>2022-02-13T00:00:00+00:00</lastmod>
<lastmod>2022-05-28T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/about/changelog</loc>
<lastmod>2022-02-13T00:00:00+00:00</lastmod>
<lastmod>2022-05-28T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/blog</loc>
<lastmod>2022-02-13T00:00:00+00:00</lastmod>
<lastmod>2022-05-28T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/blog/2021/07/hallo-ghostfolio</loc>
<lastmod>2022-02-13T00:00:00+00:00</lastmod>
<lastmod>2022-05-28T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/blog/2021/07/hello-ghostfolio</loc>
<lastmod>2022-02-13T00:00:00+00:00</lastmod>
<lastmod>2022-05-28T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/blog/2022/01/ghostfolio-first-months-in-open-source</loc>
<lastmod>2022-02-13T00:00:00+00:00</lastmod>
<lastmod>2022-05-28T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/features</loc>
<lastmod>2022-02-13T00:00:00+00:00</lastmod>
<lastmod>2022-05-28T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/pricing</loc>
<lastmod>2022-02-13T00:00:00+00:00</lastmod>
<lastmod>2022-05-28T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/register</loc>
<lastmod>2022-02-13T00:00:00+00:00</lastmod>
<lastmod>2022-05-28T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/resources</loc>
<lastmod>2022-02-13T00:00:00+00:00</lastmod>
<lastmod>2022-05-28T00:00:00+00:00</lastmod>
</url>
</urlset>

2
apps/client/src/index.html

@ -42,7 +42,7 @@
property="og:image"
content="https://www.ghostfol.io/assets/cover.png"
/>
<meta property="og:updated_time" content="2021-03-20T00:00:00+00:00" />
<meta property="og:updated_time" content="2022-05-28T00:00:00+00:00" />
<meta
property="og:site_name"
content="Ghostfolio – Open Source Wealth Management Software"

8
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;
}
}

3
docker/docker-compose.build.yml

@ -5,8 +5,9 @@ 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:
- 3333:3333

3
docker/docker-compose.yml

@ -5,8 +5,9 @@ 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:
- 3333:3333

83
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'
};
}

3
libs/common/src/lib/config.ts

@ -2,8 +2,6 @@ import { DataSource } from '@prisma/client';
import { ToggleOption } from './types';
export const baseCurrency = 'USD';
export const defaultDateRangeOptions: ToggleOption[] = [
{ label: 'Today', value: '1d' },
{ label: 'YTD', value: 'ytd' },
@ -50,6 +48,7 @@ export const DEFAULT_DATE_FORMAT_MONTH_YEAR = 'MMM yyyy';
export const GATHER_ASSET_PROFILE_PROCESS = 'GATHER_ASSET_PROFILE';
export const PROPERTY_BENCHMARKS = 'BENCHMARKS';
export const PROPERTY_COUPONS = 'COUPONS';
export const PROPERTY_CURRENCIES = 'CURRENCIES';
export const PROPERTY_IS_READ_ONLY_MODE = 'IS_READ_ONLY_MODE';

13
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) {

11
libs/common/src/lib/interfaces/benchmark.interface.ts

@ -0,0 +1,11 @@
import { EnhancedSymbolProfile } from './enhanced-symbol-profile.interface';
export interface Benchmark {
marketCondition: 'BEAR_MARKET' | 'BULL_MARKET' | 'NEUTRAL_MARKET';
name: EnhancedSymbolProfile['name'];
performances: {
allTimeHigh: {
performancePercent: number;
};
};
}

7
apps/api/src/services/interfaces/symbol-profile.interface.ts → 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;

8
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,

1
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;

1
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 {

5
libs/common/src/lib/interfaces/responses/benchmark-response.interface.ts

@ -0,0 +1,5 @@
import { Benchmark } from '../benchmark.interface';
export interface BenchmarkResponse {
benchmarks: Benchmark[];
}

0
apps/api/src/services/data-provider/ghostfolio-scraper-api/interfaces/scraper-configuration.interface.ts → libs/common/src/lib/interfaces/scraper-configuration.interface.ts

49
libs/ui/src/lib/benchmark/benchmark.component.html

@ -0,0 +1,49 @@
<div class="align-items-center d-flex">
<div *ngIf="benchmark?.name" class="flex-grow-1 text-truncate">
{{ benchmark.name }}
</div>
<div *ngIf="!benchmark?.name" class="flex-grow-1">
<ngx-skeleton-loader
animation="pulse"
[theme]="{
width: '67%'
}"
></ngx-skeleton-loader>
</div>
<gf-value
class="mx-2"
size="medium"
[isPercent]="true"
[locale]="locale"
[ngClass]="{
'text-danger':
benchmark?.performances?.allTimeHigh?.performancePercent < 0,
'text-success':
benchmark?.performances?.allTimeHigh?.performancePercent > 0
}"
[value]="
benchmark?.performances?.allTimeHigh?.performancePercent ?? undefined
"
></gf-value>
<div class="text-muted">
<small class="d-none d-sm-block text-nowrap" i18n>from All Time High</small
><small class="d-block d-sm-none text-nowrap" i18n>from ATH</small>
</div>
<div class="ml-2">
<div
*ngIf="benchmark?.marketCondition"
[title]="benchmark?.marketCondition"
>
{{ resolveMarketCondition(benchmark.marketCondition).emoji }}
</div>
<ngx-skeleton-loader
*ngIf="!benchmark?.marketCondition"
animation="pulse"
appearance="circle"
[theme]="{
height: '1rem',
width: '1rem'
}"
></ngx-skeleton-loader>
</div>
</div>

3
libs/ui/src/lib/benchmark/benchmark.component.scss

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

18
libs/ui/src/lib/benchmark/benchmark.component.ts

@ -0,0 +1,18 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
import { resolveMarketCondition } from '@ghostfolio/common/helper';
import { Benchmark } from '@ghostfolio/common/interfaces';
@Component({
selector: 'gf-benchmark',
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './benchmark.component.html',
styleUrls: ['./benchmark.component.scss']
})
export class BenchmarkComponent {
@Input() benchmark: Benchmark;
@Input() locale: string;
public resolveMarketCondition = resolveMarketCondition;
public constructor() {}
}

14
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 {}

1
libs/ui/src/lib/benchmark/index.ts

@ -0,0 +1 @@
export * from './benchmark.module';

2
libs/ui/src/lib/fire-calculator/fire-calculator.component.html

@ -1,7 +1,7 @@
<div class="container p-0">
<div class="row">
<div class="col-md-3">
<form class="" [formGroup]="calculatorForm">
<form class="mb-4" [formGroup]="calculatorForm">
<!--<mat-form-field appearance="outline">
<input formControlName="principalInvestmentAmount" matInput />
</mat-form-field>-->

6
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) => {

54
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 = <unknown>(
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: <unknown>{
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: <unknown>'top',
xAlign: 'center',
yAlign: 'bottom'
};
}
}

13
libs/ui/src/lib/portfolio-proportion-chart/portfolio-proportion-chart.component.ts

@ -10,6 +10,7 @@ import {
Output,
ViewChild
} from '@angular/core';
import { getTooltipOptions } from '@ghostfolio/common/chart-helper';
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
import { getTextColor } from '@ghostfolio/common/helper';
import { PortfolioPosition, UniqueAsset } from '@ghostfolio/common/interfaces';
@ -247,6 +248,12 @@ export class PortfolioProportionChartComponent
datasets[0].data[0] = Number.MAX_SAFE_INTEGER;
}
if (datasets[1]?.data?.length === 0 || datasets[1]?.data?.[1] === 0) {
labels = [''];
datasets[1].backgroundColor = [this.colorMap[UNKNOWN_KEY]];
datasets[1].data[1] = Number.MAX_SAFE_INTEGER;
}
const data: ChartConfiguration['data'] = {
datasets,
labels
@ -255,8 +262,9 @@ export class PortfolioProportionChartComponent
if (this.chartCanvas) {
if (this.chart) {
this.chart.data = data;
this.chart.options.plugins.tooltip =
this.getTooltipPluginConfiguration(data);
this.chart.options.plugins.tooltip = <unknown>(
this.getTooltipPluginConfiguration(data)
);
this.chart.update();
} else {
this.chart = new Chart(this.chartCanvas.nativeElement, {
@ -339,6 +347,7 @@ export class PortfolioProportionChartComponent
private getTooltipPluginConfiguration(data: ChartConfiguration['data']) {
return {
...getTooltipOptions(this.baseCurrency, this.locale),
callbacks: {
label: (context) => {
const labelIndex =

2
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'
}"
></ngx-skeleton-loader>

13
package.json

@ -1,6 +1,6 @@
{
"name": "ghostfolio",
"version": "1.150.0",
"version": "1.155.0",
"homepage": "https://ghostfol.io",
"license": "AGPL-3.0",
"scripts": {
@ -72,10 +72,9 @@
"@nestjs/schedule": "1.0.2",
"@nestjs/serve-static": "2.2.2",
"@nrwl/angular": "14.1.4",
"@prisma/client": "3.12.0",
"@simplewebauthn/browser": "4.1.0",
"@simplewebauthn/server": "4.1.0",
"@simplewebauthn/typescript-types": "4.0.0",
"@prisma/client": "3.14.0",
"@simplewebauthn/browser": "5.2.1",
"@simplewebauthn/server": "5.2.1",
"@stripe/stripe-js": "1.22.0",
"alphavantage": "2.2.0",
"angular-material-css-vars": "3.0.0",
@ -110,9 +109,8 @@
"passport": "0.4.1",
"passport-google-oauth20": "2.0.0",
"passport-jwt": "4.0.0",
"prisma": "3.12.0",
"prisma": "3.14.0",
"reflect-metadata": "0.1.13",
"round-to": "5.0.0",
"rxjs": "7.4.0",
"stripe": "8.199.0",
"svgmap": "2.6.0",
@ -141,6 +139,7 @@
"@nrwl/nx-cloud": "14.0.3",
"@nrwl/storybook": "14.1.4",
"@nrwl/workspace": "14.1.4",
"@simplewebauthn/typescript-types": "5.2.1",
"@storybook/addon-essentials": "6.4.22",
"@storybook/angular": "6.4.22",
"@storybook/builder-webpack5": "6.4.22",

2
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';

1
prisma/schema.prisma

@ -201,6 +201,7 @@ enum AssetSubClass {
enum DataSource {
ALPHA_VANTAGE
EOD_HISTORICAL_DATA
GHOSTFOLIO
GOOGLE_SHEETS
MANUAL

161
yarn.lock

@ -3554,35 +3554,34 @@
node-addon-api "^3.2.1"
node-gyp-build "^4.3.0"
"@peculiar/asn1-android@^2.0.38":
version "2.0.38"
resolved "https://registry.yarnpkg.com/@peculiar/asn1-android/-/asn1-android-2.0.38.tgz#193281f5a232e323d6f2c069c7a8e8e8f4a994bd"
integrity sha512-krWyggV6FgYf3fEPKVNjHVecLcQWlAu3/YhOyN+/L43dNKcsmqiEvuhqplh3aiXF62Ds0pqzqttWmdvoVqmSVQ==
"@peculiar/asn1-android@^2.1.7":
version "2.1.8"
resolved "https://registry.yarnpkg.com/@peculiar/asn1-android/-/asn1-android-2.1.8.tgz#64b6da2b5a03ddb86bcc9061d981be7ba811069d"
integrity sha512-SgtOvNES2Aex5rafRlQiaAbWd38hMLwwtQL13ndVhDN1/NYxPF3VgeJWv3KKRY4uFh9VXvF6NuRfEcrSX5UWiQ==
dependencies:
"@peculiar/asn1-schema" "^2.0.38"
asn1js "^2.1.1"
tslib "^2.3.0"
"@peculiar/asn1-schema" "^2.1.8"
asn1js "^3.0.4"
tslib "^2.4.0"
"@peculiar/asn1-schema@^2.0.38":
version "2.0.38"
resolved "https://registry.yarnpkg.com/@peculiar/asn1-schema/-/asn1-schema-2.0.38.tgz#98b6f12daad275ecd6774dfe31fb62f362900412"
integrity sha512-zZ64UpCTm9me15nuCpPgJghSdbEm8atcDQPCyK+bKXjZAQ1735NCZXCSCfbckbQ4MH36Rm9403n/qMq77LFDzQ==
"@peculiar/asn1-schema@^2.1.7", "@peculiar/asn1-schema@^2.1.8":
version "2.1.8"
resolved "https://registry.yarnpkg.com/@peculiar/asn1-schema/-/asn1-schema-2.1.8.tgz#552300a1ed7991b22c9abf789a3920a3cb94c26b"
integrity sha512-u34H/bpqCdDuqrCVZvH0vpwFBT/dNEdNY+eE8u4IuC26yYnhDkXF4+Hliqca88Avbb7hyN2EF/eokyDdyS7G/A==
dependencies:
"@types/asn1js" "^2.0.2"
asn1js "^2.1.1"
pvtsutils "^1.2.0"
tslib "^2.3.0"
asn1js "^3.0.4"
pvtsutils "^1.3.2"
tslib "^2.4.0"
"@peculiar/asn1-x509@^2.0.38":
version "2.0.38"
resolved "https://registry.yarnpkg.com/@peculiar/asn1-x509/-/asn1-x509-2.0.38.tgz#7ff3b5478d9c3784f0eb2fbe7693509da9de0a43"
integrity sha512-10aK9fSxlc1DK9nEcwh+WPFNhAheXSE9RbI5MyS7FdBhgq+Mz4Z9JqFfaBZm1Qp+5mPtUMOP6cXVo7aaYlgq7A==
"@peculiar/asn1-x509@^2.1.7":
version "2.1.8"
resolved "https://registry.yarnpkg.com/@peculiar/asn1-x509/-/asn1-x509-2.1.8.tgz#b67317ba1ee33c758ad7c6145dbaa1ddef4f1913"
integrity sha512-asAcoeZ+bjy/4/lf6gbMlfmywHpxLBa7LBE4pPCzSAKBM0IHXWa7bqsDyshtywzLW+VpA+G2m0Fs7Lt7Woh7RA==
dependencies:
"@peculiar/asn1-schema" "^2.0.38"
asn1js "^2.1.1"
"@peculiar/asn1-schema" "^2.1.8"
asn1js "^3.0.4"
ipaddr.js "^2.0.1"
pvtsutils "^1.2.0"
tslib "^2.3.0"
pvtsutils "^1.3.2"
tslib "^2.4.0"
"@phenomnomnominal/tsquery@4.1.1":
version "4.1.1"
@ -3596,22 +3595,22 @@
resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.10.1.tgz#728ecd95ab207aab8a9a4e421f0422db329232be"
integrity sha512-HnUhk1Sy9IuKrxEMdIRCxpIqPw6BFsbYSEUO9p/hNw5sMld/+3OLMWQP80F8/db9qsv3qUjs7ZR5bS/R+iinXw==
"@prisma/client@3.12.0":
version "3.12.0"
resolved "https://registry.yarnpkg.com/@prisma/client/-/client-3.12.0.tgz#a0eb49ffea5c128dd11dffb896d7139a60073d12"
integrity sha512-4NEQjUcWja/NVBvfuDFscWSk1/rXg3+wj+TSkqXCb1tKlx/bsUE00rxsvOvGg7VZ6lw1JFpGkwjwmsOIc4zvQw==
"@prisma/client@3.14.0":
version "3.14.0"
resolved "https://registry.yarnpkg.com/@prisma/client/-/client-3.14.0.tgz#bb90405c012fcca11f4647d91153ed4c58f3bd48"
integrity sha512-atb41UpgTR1MCst0VIbiHTMw8lmXnwUvE1KyUCAkq08+wJyjRE78Due+nSf+7uwqQn+fBFYVmoojtinhlLOSaA==
dependencies:
"@prisma/engines-version" "3.12.0-37.22b822189f46ef0dc5c5b503368d1bee01213980"
"@prisma/engines-version" "3.14.0-36.2b0c12756921c891fec4f68d9444e18c7d5d4a6a"
"@prisma/engines-version@3.12.0-37.22b822189f46ef0dc5c5b503368d1bee01213980":
version "3.12.0-37.22b822189f46ef0dc5c5b503368d1bee01213980"
resolved "https://registry.yarnpkg.com/@prisma/engines-version/-/engines-version-3.12.0-37.22b822189f46ef0dc5c5b503368d1bee01213980.tgz#829ca3d9d0d92555f44644606d4edfd45b2f5886"
integrity sha512-o+jo8d7ZEiVpcpNWUDh3fj2uPQpBxl79XE9ih9nkogJbhw6P33274SHnqheedZ7PyvPIK/mvU8MLNYgetgXPYw==
"@prisma/engines-version@3.14.0-36.2b0c12756921c891fec4f68d9444e18c7d5d4a6a":
version "3.14.0-36.2b0c12756921c891fec4f68d9444e18c7d5d4a6a"
resolved "https://registry.yarnpkg.com/@prisma/engines-version/-/engines-version-3.14.0-36.2b0c12756921c891fec4f68d9444e18c7d5d4a6a.tgz#4edae57cf6527f35e22cebe75e49214fc0e99ac9"
integrity sha512-D+yHzq4a2r2Rrd0ZOW/mTZbgDIkUkD8ofKgusEI1xPiZz60Daks+UM7Me2ty5FzH3p/TgyhBpRrfIHx+ha20RQ==
"@prisma/engines@3.12.0-37.22b822189f46ef0dc5c5b503368d1bee01213980":
version "3.12.0-37.22b822189f46ef0dc5c5b503368d1bee01213980"
resolved "https://registry.yarnpkg.com/@prisma/engines/-/engines-3.12.0-37.22b822189f46ef0dc5c5b503368d1bee01213980.tgz#e52e364084c4d05278f62768047b788665e64a45"
integrity sha512-zULjkN8yhzS7B3yeEz4aIym4E2w1ChrV12i14pht3ePFufvsAvBSoZ+tuXMvfSoNTgBS5E4bolRzLbMmbwkkMQ==
"@prisma/engines@3.14.0-36.2b0c12756921c891fec4f68d9444e18c7d5d4a6a":
version "3.14.0-36.2b0c12756921c891fec4f68d9444e18c7d5d4a6a"
resolved "https://registry.yarnpkg.com/@prisma/engines/-/engines-3.14.0-36.2b0c12756921c891fec4f68d9444e18c7d5d4a6a.tgz#7fa11bc26a51d450185c816cc0ab8cac673fb4bf"
integrity sha512-LwZvI3FY6f43xFjQNRuE10JM5R8vJzFTSmbV9X0Wuhv9kscLkjRlZt0BEoiHmO+2HA3B3xxbMfB5du7ZoSFXGg==
"@samverschueren/stream-to-observable@^0.3.0":
version "0.3.1"
@ -3629,32 +3628,33 @@
"@angular-devkit/schematics" "13.3.5"
jsonc-parser "3.0.0"
"@simplewebauthn/browser@4.1.0":
version "4.1.0"
resolved "https://registry.yarnpkg.com/@simplewebauthn/browser/-/browser-4.1.0.tgz#3e7fd66729405d6a2a2a187c93577b90a8e41786"
integrity sha512-tIsEfShC1rrqrsNb44tOFuSriAFCz4tkdDnCjHfn2rYxgz+t+yqEvuIRfJHQpFrWSnZPdsjrAHtasj6lzfGI6w==
"@simplewebauthn/browser@5.2.1":
version "5.2.1"
resolved "https://registry.yarnpkg.com/@simplewebauthn/browser/-/browser-5.2.1.tgz#569252a9f235a99aae90c4d1cc6c441f42637b8e"
integrity sha512-TxL3OPHJf57hmnfQoF3zRIQWEdsJLxrA9NcGdRK0sB/h3jd13kpGQonBtMnj4YBQnWTtRDZ804wlpI9IEMaJ9g==
"@simplewebauthn/server@4.1.0":
version "4.1.0"
resolved "https://registry.yarnpkg.com/@simplewebauthn/server/-/server-4.1.0.tgz#9ad2e32cffa83833ff8a633775b2ace5e6926fa0"
integrity sha512-52X5/U+5Fo0XYG1TuBBGgG0ap9c0ffpeq0GZfFio/DZDW4He0Arb7Q/XkHw96JK0X1sfRKNmnfC+NImplvIimA==
"@simplewebauthn/server@5.2.1":
version "5.2.1"
resolved "https://registry.yarnpkg.com/@simplewebauthn/server/-/server-5.2.1.tgz#49038d2951ad2ac065bdf8342fdb13f78ee4df1c"
integrity sha512-+CQ8oJf9Io8y4ReYLagX5JG9ShntIkdeCPkMoyHLBSRPlNY0N/Yv3Iun4YPQ8d4LJUU9f8S1eD5bibIEMjWDRg==
dependencies:
"@peculiar/asn1-android" "^2.0.38"
"@peculiar/asn1-schema" "^2.0.38"
"@peculiar/asn1-x509" "^2.0.38"
"@simplewebauthn/typescript-types" "^4.0.0"
"@peculiar/asn1-android" "^2.1.7"
"@peculiar/asn1-schema" "^2.1.7"
"@peculiar/asn1-x509" "^2.1.7"
"@simplewebauthn/typescript-types" "^5.2.1"
base64url "^3.0.1"
cbor "^5.1.0"
debug "^4.3.2"
elliptic "^6.5.3"
jsrsasign "^10.4.0"
jwk-to-pem "^2.0.4"
node-fetch "^2.6.0"
node-rsa "^1.1.1"
"@simplewebauthn/typescript-types@4.0.0", "@simplewebauthn/typescript-types@^4.0.0":
version "4.0.0"
resolved "https://registry.yarnpkg.com/@simplewebauthn/typescript-types/-/typescript-types-4.0.0.tgz#46ae4e69cb07305c57093a3ed99555437dfe0d49"
integrity sha512-jqQ0bCeBO96CytB397vSrQ8ipozQzAmI57izA7izyglyu35JBV90I7+75fSX+ZGNHmMwDNnA3EGYtBLOIpkJEg==
"@simplewebauthn/typescript-types@5.2.1", "@simplewebauthn/typescript-types@^5.2.1":
version "5.2.1"
resolved "https://registry.yarnpkg.com/@simplewebauthn/typescript-types/-/typescript-types-5.2.1.tgz#a8229ce4f71be7edafe3bfdce062b332ef494f0d"
integrity sha512-t/NzbjaD0zu4ivUmiof2cPA8X5LHhFX+DflBBl71/dzEhl15qepDI2rxWdjB+Hc0FfOT1fBQnb1uP19fPcDUiA==
"@sinonjs/commons@^1.7.0":
version "1.8.3"
@ -4804,11 +4804,6 @@
resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-2.0.0.tgz#f544a148d3ab35801c1f633a7441fd87c2e484bf"
integrity sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==
"@types/asn1js@^2.0.2":
version "2.0.2"
resolved "https://registry.yarnpkg.com/@types/asn1js/-/asn1js-2.0.2.tgz#bb1992291381b5f06e22a829f2ae009267cdf8c5"
integrity sha512-t4YHCgtD+ERvH0FyxvNlYwJ2ezhqw7t+Ygh4urQ7dJER8i185JPv6oIM3ey5YQmGN6Zp9EMbpohkjZi9t3UxwA==
"@types/babel__core@^7.0.0", "@types/babel__core@^7.1.14":
version "7.1.15"
resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.1.15.tgz#2ccfb1ad55a02c83f8e0ad327cbc332f55eb1024"
@ -6367,12 +6362,14 @@ asn1@^0.2.4, asn1@~0.2.3:
dependencies:
safer-buffer "~2.1.0"
asn1js@^2.1.1:
version "2.1.1"
resolved "https://registry.yarnpkg.com/asn1js/-/asn1js-2.1.1.tgz#bb3896191ebb5fb1caeda73436a6c6e20a2eedff"
integrity sha512-t9u0dU0rJN4ML+uxgN6VM2Z4H5jWIYm0w8LsZLzMJaQsgL3IJNbxHgmbWDvJAwspyHpDFuzUaUFh4c05UB4+6g==
asn1js@^3.0.4:
version "3.0.5"
resolved "https://registry.yarnpkg.com/asn1js/-/asn1js-3.0.5.tgz#5ea36820443dbefb51cc7f88a2ebb5b462114f38"
integrity sha512-FVnvrKJwpt9LP2lAMl8qZswRNm3T4q9CON+bxldk2iwk3FFpuwhx2FfinyitizWHsVYyaY+y5JzDR0rCMV5yTQ==
dependencies:
pvutils latest
pvtsutils "^1.3.2"
pvutils "^1.1.3"
tslib "^2.4.0"
assert-plus@1.0.0, assert-plus@^1.0.0:
version "1.0.0"
@ -15660,12 +15657,12 @@ pretty-hrtime@^1.0.3:
resolved "https://registry.yarnpkg.com/pretty-hrtime/-/pretty-hrtime-1.0.3.tgz#b7e3ea42435a4c9b2759d99e0f201eb195802ee1"
integrity sha1-t+PqQkNaTJsnWdmeDyAesZWALuE=
prisma@3.12.0:
version "3.12.0"
resolved "https://registry.yarnpkg.com/prisma/-/prisma-3.12.0.tgz#9675e0e72407122759d3eadcb6d27cdccd3497bd"
integrity sha512-ltCMZAx1i0i9xuPM692Srj8McC665h6E5RqJom999sjtVSccHSD8Z+HSdBN2183h9PJKvC5dapkn78dd0NWMBg==
prisma@3.14.0:
version "3.14.0"
resolved "https://registry.yarnpkg.com/prisma/-/prisma-3.14.0.tgz#dd67ece37d7b5373e9fd9588971de0024b49be81"
integrity sha512-l9MOgNCn/paDE+i1K2fp9NZ+Du4trzPTJsGkaQHVBufTGqzoYHuNk8JfzXuIn0Gte6/ZjyKj652Jq/Lc1tp2yw==
dependencies:
"@prisma/engines" "3.12.0-37.22b822189f46ef0dc5c5b503368d1bee01213980"
"@prisma/engines" "3.14.0-36.2b0c12756921c891fec4f68d9444e18c7d5d4a6a"
prismjs@^1.21.0, prismjs@~1.24.0:
version "1.24.1"
@ -15820,17 +15817,17 @@ punycode@^2.1.0, punycode@^2.1.1:
resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec"
integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==
pvtsutils@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/pvtsutils/-/pvtsutils-1.2.0.tgz#619e4767093d23cd600482600c16f4c36d3025bb"
integrity sha512-IDefMJEQl7HX0FP2hIKJFnAR11klP1js2ixCrOaMhe3kXFK6RQ2ABUCuwWaaD4ib0hSbh2fGTICvWJJhDfNecA==
pvtsutils@^1.3.2:
version "1.3.2"
resolved "https://registry.yarnpkg.com/pvtsutils/-/pvtsutils-1.3.2.tgz#9f8570d132cdd3c27ab7d51a2799239bf8d8d5de"
integrity sha512-+Ipe2iNUyrZz+8K/2IOo+kKikdtfhRKzNpQbruF2URmqPtoqAs8g3xS7TJvFF2GcPXjh7DkqMnpVveRFq4PgEQ==
dependencies:
tslib "^2.2.0"
tslib "^2.4.0"
pvutils@latest:
version "1.0.17"
resolved "https://registry.yarnpkg.com/pvutils/-/pvutils-1.0.17.tgz#ade3c74dfe7178944fe44806626bd2e249d996bf"
integrity sha512-wLHYUQxWaXVQvKnwIDWFVKDJku9XDCvyhhxoq8dc5MFdIlRenyPI9eSfEtcvgHgD7FlvCyGAlWgOzRnZD99GZQ==
pvutils@^1.1.3:
version "1.1.3"
resolved "https://registry.yarnpkg.com/pvutils/-/pvutils-1.1.3.tgz#f35fc1d27e7cd3dfbd39c0826d173e806a03f5a3"
integrity sha512-pMpnA0qRdFp32b1sJl1wOJNxZLQ2cbQx+k6tjNtZ8CpvVhNqEPRgivZ2WOUev2YMajecdH7ctUPDvEe87nariQ==
qs@6.7.0:
version "6.7.0"
@ -16563,11 +16560,6 @@ ripemd160@^2.0.0, ripemd160@^2.0.1:
hash-base "^3.0.0"
inherits "^2.0.1"
round-to@5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/round-to/-/round-to-5.0.0.tgz#a66292701a93b194f630a0d57f04c08821b6eeed"
integrity sha512-i4+Ntwmo5kY7UWWFSDEVN3RjT2PX1FqkZ9iCcAO3sKML3Ady9NgsjM/HLdYKUAnrxK4IlSvXzpBMDvMHZQALRQ==
rsvp@^4.8.4:
version "4.8.5"
resolved "https://registry.yarnpkg.com/rsvp/-/rsvp-4.8.5.tgz#c8f155311d167f68f21e168df71ec5b083113734"
@ -18111,7 +18103,7 @@ tslib@2.0.0:
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.0.0.tgz#18d13fc2dce04051e20f074cc8387fd8089ce4f3"
integrity sha512-lTqkx847PI7xEDYJntxZH89L2/aXInsyF2luSafe/+0fHOMjlBNXdH6th7f70qxLDhul7KZK0zC8V5ZIyHl0/g==
tslib@2.3.1, tslib@^2.0.0, tslib@^2.0.3, tslib@^2.1.0, tslib@^2.2.0, tslib@^2.3.0, tslib@^2.3.1:
tslib@2.3.1, tslib@^2.0.0, tslib@^2.0.3, tslib@^2.1.0, tslib@^2.3.0, tslib@^2.3.1:
version "2.3.1"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.3.1.tgz#e8a335add5ceae51aa261d32a490158ef042ef01"
integrity sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==
@ -18121,6 +18113,11 @@ tslib@^1.10.0, tslib@^1.8.1, tslib@^1.9.0:
resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00"
integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==
tslib@^2.4.0:
version "2.4.0"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.4.0.tgz#7cecaa7f073ce680a05847aa77be941098f36dc3"
integrity sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==
tslib@~2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.1.0.tgz#da60860f1c2ecaa5703ab7d39bc05b6bf988b97a"

Loading…
Cancel
Save