Compare commits

..

60 Commits

Author SHA1 Message Date
a96e89a86e Release 1.155.0 (#977) 2022-05-29 15:39:57 +02:00
b9c9443899 Bugfix/fix empty state of proportion chart (#976)
* Fix empty state (chart with two levels)

* Update changelog
2022-05-29 15:38:13 +02:00
f1e06347d3 Feature/add data source eod historical data (#974)
* Add EOD Historical Data as a data source

* Update changelog
2022-05-29 15:37:40 +02:00
697e92f818 Feature/finalize exposing redis password env variable (#975)
* Add hints

* Update changelog
2022-05-29 14:54:53 +02:00
b678998801 Feature/add-redis-password (#947)
* Expose REDIS_PASSWORD
2022-05-29 14:18:57 +02:00
de53cf1884 Release 1.154.0 (#973) 2022-05-28 21:12:42 +02:00
bbe30218bd Feature/remove dependency round to (#972)
* Remove round-to dependency

* Update changelog
2022-05-28 21:10:45 +02:00
15dda886a0 Feature/add vertical hover line to line chart component (#963)
* Add vertical hover line

* Improve tooltips of charts

* Update changelog
2022-05-28 20:53:54 +02:00
34d4212f55 Feature/modernize pricing page (#967)
* Simplify pricing page

* Update changelog
2022-05-28 18:52:30 +02:00
f7060230b7 Update dates (#966) 2022-05-28 18:46:22 +02:00
0fdafcb7e4 Release 1.153.0 (#962) 2022-05-27 11:53:18 +02:00
e79be9f2d6 Feature/do not tweet on weekend (#961)
* Do not tweet on the weekend

* Update changelog
2022-05-27 11:37:48 +02:00
69088b93a6 Feature/add value redaction as interceptor (#960)
* Add value redaction as interceptor

* Update changelog
2022-05-27 11:21:47 +02:00
c3768a882d Feature/add benchmarks to twitter bot service (#959)
* Extend benchmarks with market condition and adapt twitter bot service

* Update changelog
2022-05-27 10:03:37 +02:00
3498ed8549 Feature/upgrade prisma to version 3.14.0 (#958)
* Upgrade prisma dependencies to version 3.14.0

* Update changelog
2022-05-27 09:50:38 +02:00
c07c300fef Move @simplewebauthn/typescript-types to devDependencies (#957) 2022-05-27 09:49:57 +02:00
c62a5af9eb Bugfix/fix width of skeleton loader in benchmark component (#956)
* Fix width

* Update changelog
2022-05-27 09:49:37 +02:00
0c04f10e19 Release 1.152.0 (#955) 2022-05-26 19:01:08 +02:00
2c4c16ec99 Feature/extend markets overview by benchmarks (#953)
* Add benchmarks to markets overview

* Update changelog
2022-05-26 18:59:29 +02:00
4711b0d1ed Improve instructions for Unraid (#954) 2022-05-26 17:59:09 +02:00
a8521e0ecf Update README.md (#943)
* Update README.md for Unraid users
2022-05-26 17:52:14 +02:00
424748ae90 Feature/add ghostfolio trailer to landing page (#952)
* Add link to Ghostfolio trailer

* Update changelog
2022-05-26 10:30:13 +02:00
9c4d8bdf4b Release 1.151.0 (#949) 2022-05-24 20:58:24 +02:00
332203b9e2 Feature/add support to set the base currency via env variable (#948)
* Set base currency via environment variable

* Update changelog
2022-05-24 20:55:55 +02:00
f48832c671 Bugfix/add missing conversion of countries (#941)
* Add missing conversion of countries for SymbolProfileOverrides

* Update changelog
2022-05-23 18:04:09 +02:00
ae8a203526 Add type (#939) 2022-05-22 21:14:22 +02:00
d0c1506ded Release 1.150.0 (#940) 2022-05-21 20:00:34 +02:00
af0863d193 Bugfix/fix currency conversion in accounts (#937)
* Fix currency conversion in accounts

* Update changelog
2022-05-21 19:58:47 +02:00
f5819cc399 Bugfix/fix countries in symbol profile overrides (#936)
* Fix countries

* Update changelog
2022-05-20 20:16:23 +02:00
977c5a9544 Feature/skip data enhancement if data is inaccurate (#935)
* Skip data enhancer if data is inaccurate

* Update changelog
2022-05-20 20:15:19 +02:00
b9cd42cd53 Move dependencies to devDependencies (#934) 2022-05-20 20:14:33 +02:00
379977008d Simplify intro text (#933) 2022-05-19 21:05:14 +02:00
38f9d54705 Release 1.149.0 (#927) 2022-05-16 21:50:43 +02:00
5cb6e5dec6 Feature/support filtering by asset class on the allocations page (#926)
* Support filtering by asset class

* Update changelog
2022-05-16 21:49:22 +02:00
4a123c38f2 Refactor placeholder (#925) 2022-05-16 21:17:58 +02:00
160335302a Feature/group filters by type (#922)
* Add groups to activities filter component

* Update changelog
2022-05-15 21:51:31 +02:00
f1483569a2 Release 1.148.0 (#921) 2022-05-14 13:55:25 +02:00
5391b88c42 Feature/add report data glitch button (#920)
* Add report data glitch button

* Update changelog
2022-05-14 13:53:43 +02:00
2b63f7e707 Feature/support enter to submit create or update transaction dialog form (#913)
* Support enter key press to submit form

* Update changelog
2022-05-14 10:56:07 +02:00
d5c96d1cb7 Bugfix/fix date picker date format (#912)
* Fix date picker date format

* Update changelog
2022-05-14 10:55:09 +02:00
1a4dc51825 Bugfix/fix state of delete account button (#911)
* Fix disable state

* Update changelog
2022-05-13 06:48:40 +02:00
d094bae7de Bugfix/fix issue in activities filter component with typing (#910)
* Handle filter (selecting) or search term (typing)

* Update changelog
2022-05-12 07:48:12 +02:00
57bf10e7e7 Release 1.147.0 (#904) 2022-05-10 21:25:25 +02:00
c1d460cead Improve filtering (#901) 2022-05-10 21:24:36 +02:00
dfa67b275c Feature/improve filtering on allocations page (#900)
* Include cash positions on allocations page (with no filtering)

* Update changelog
2022-05-10 19:22:57 +02:00
80862e5c2a Release 1.146.3 (#899) 2022-05-08 22:54:07 +02:00
904d4db219 Refactor build:all and build:dev scripts (#898) 2022-05-08 22:52:46 +02:00
10f13eec48 Release 1.146.2 (#897) 2022-05-08 22:18:00 +02:00
ea3a9d3b79 Feature/eliminate circular dependencies in common library (#896)
* Eliminate circular dependencies

* Update changelog
2022-05-08 22:16:47 +02:00
e55b05fe3d Release 1.146.1 (#895) 2022-05-08 16:57:23 +02:00
32dd76be5f Fix path to jest files (#894) 2022-05-08 16:55:14 +02:00
ff9b6bb4df Release 1.146.0 (#893) 2022-05-08 16:04:43 +02:00
5be95b7b63 Feature/simplify about page (#892)
* Simplify about page

* Update changelog
2022-05-08 16:02:44 +02:00
b3e07c8446 Feature/support permissions in fire calculator (#891)
* Support hasPermissionToUpdateUserSettings

* Update changelog
2022-05-08 15:59:19 +02:00
eb9cece4e4 Update browserslist database (#890) 2022-05-08 15:56:39 +02:00
b331f5f04d Feature/setup nx cloud (#889)
* Upgrade angular, Nx and storybook

* Setup Nx Cloud

* Update changelog
2022-05-08 15:52:21 +02:00
34cbdd7c2a Upgrade angular, Nx and storybook (#888)
* Upgrade angular, Nx and storybook

* Update changelog
2022-05-08 09:26:33 +02:00
57314d62ee Feature/improve allocations page with no filter (#887)
* Improve accounts for no filters

* Update changelog
2022-05-07 22:33:57 +02:00
40380346e6 Feature/setup bull queue system (#886)
* Setup @nestjs/bull and asset profile data gathering job

* Update changelog
2022-05-07 20:00:51 +02:00
5622c4cf7e Feature/harmonize no data available label (#885)
* Harmonize label for UNKNOWN_KEY

* Update changelog
2022-05-07 14:11:42 +02:00
143 changed files with 3945 additions and 2230 deletions

7
.env
View File

@ -3,14 +3,15 @@ COMPOSE_PROJECT_NAME=ghostfolio-development
# CACHE # CACHE
REDIS_HOST=localhost REDIS_HOST=localhost
REDIS_PORT=6379 REDIS_PORT=6379
REDIS_PASSWORD=<INSERT_REDIS_PASSWORD>
# POSTGRES # POSTGRES
POSTGRES_DB=ghostfolio-db POSTGRES_DB=ghostfolio-db
POSTGRES_USER=user 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= ALPHA_VANTAGE_API_KEY=
DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer 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 PORT=3333

View File

@ -5,6 +5,130 @@ 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/), 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). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## 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
- Skipped data enhancer (_Trackinsight_) if data is inaccurate
### Fixed
- Fixed an issue with the currency conversion in the account calculations
- Fixed an issue with countries in the symbol profile overrides
## 1.149.0 - 16.05.2022
### Added
- Added groups to the activities filter component
- Added support for filtering by asset class on the allocations page
## 1.148.0 - 14.05.2022
### Added
- Supported enter key press to submit the form of the create or edit transaction dialog
- Added a _Report Data Glitch_ button to the position detail dialog
### Fixed
- Fixed the date format of the date picker and support manual changes
- Fixed the state of the account delete button (disable if account contains activities)
- Fixed an issue in the activities filter component (typing a search term)
## 1.147.0 - 10.05.2022
### Changed
- Improved the allocations page with no filtering (include cash positions)
## 1.146.3 - 08.05.2022
### Added
- Set up a queue for the data gathering jobs
- Set up _Nx Cloud_
### Changed
- Migrated the asset profile data gathering to the queue design pattern
- Improved the allocations page with no filtering
- Harmonized the _No data available_ label in the portfolio proportion chart component
- Improved the _FIRE_ calculator for the _Live Demo_
- Simplified the about page
- Upgraded `angular` from version `13.2.2` to `13.3.6`
- Upgraded `Nx` from version `13.8.5` to `14.1.4`
- Upgraded `storybook` from version `6.4.18` to `6.4.22`
### Fixed
- Eliminated the circular dependencies in the `@ghostfolio/common` library
## 1.145.0 - 07.05.2022 ## 1.145.0 - 07.05.2022
### Added ### Added
@ -415,7 +539,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
- Upgraded `angular` from version `13.1.2` to `13.2.3` - Upgraded `angular` from version `13.1.2` to `13.2.2`
- Upgraded `Nx` from version `13.4.1` to `13.8.1` - Upgraded `Nx` from version `13.4.1` to `13.8.1`
- Upgraded `storybook` from version `6.4.9` to `6.4.18` - Upgraded `storybook` from version `6.4.9` to `6.4.18`

View File

@ -22,8 +22,8 @@ RUN node decorate-angular-cli.js
COPY ./angular.json angular.json COPY ./angular.json angular.json
COPY ./nx.json nx.json COPY ./nx.json nx.json
COPY ./replace.build.js replace.build.js COPY ./replace.build.js replace.build.js
COPY ./jest.preset.js jest.preset.js COPY ./jest.preset.ts jest.preset.ts
COPY ./jest.config.js jest.config.js COPY ./jest.config.ts jest.config.ts
COPY ./tsconfig.base.json tsconfig.base.json COPY ./tsconfig.base.json tsconfig.base.json
COPY ./libs libs COPY ./libs libs
COPY ./apps apps COPY ./apps apps

View File

@ -9,7 +9,7 @@
<h1>Ghostfolio</h1> <h1>Ghostfolio</h1>
<p> <p>
<strong>Open Source Wealth Management Software made for Humans</strong> <strong>Open Source Wealth Management Software</strong>
</p> </p>
<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> <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>
@ -24,10 +24,11 @@
</p> </p>
</div> </div>
**Ghostfolio** is an open source wealth management software built with web technology. The application empowers busy people to keep track of their wealth like stocks, ETFs or cryptocurrencies and make solid, data-driven investment decisions. **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"> <div align="center" style="margin-top: 1rem; margin-bottom: 1rem;">
<img src="./apps/client/src/assets/images/screenshot.png" width="300"> <a href="https://www.youtube.com/watch?v=yY6ObSQVJZk">
<img src="./apps/client/src/assets/images/video-preview.jpg" width="600"></a>
</div> </div>
## Ghostfolio Premium ## Ghostfolio Premium
@ -47,7 +48,7 @@ Ghostfolio is for you if you are...
- 🧘 into minimalism - 🧘 into minimalism
- 🧺 caring about diversifying your financial resources - 🧺 caring about diversifying your financial resources
- 🆓 interested in financial independence - 🆓 interested in financial independence
- 🙅 saying no to spreadsheets in 2021 - 🙅 saying no to spreadsheets in 2022
- 😎 still reading this list - 😎 still reading this list
## Features ## Features
@ -62,6 +63,10 @@ Ghostfolio is for you if you are...
- ✅ Zen Mode - ✅ Zen Mode
- ✅ Mobile-first design - ✅ 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 ## Technology Stack
Ghostfolio is a modern web application written in [TypeScript](https://www.typescriptlang.org) and organized as an [Nx](https://nx.dev) workspace. Ghostfolio is a modern web application written in [TypeScript](https://www.typescriptlang.org) and organized as an [Nx](https://nx.dev) workspace.
@ -128,6 +133,10 @@ Open http://localhost:3333 in your browser and accomplish these steps:
1. Run the following command to start the new Docker image: `docker-compose -f docker/docker-compose.yml up -d` 1. 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. Then, run the following command to keep your database schema in sync: `docker-compose -f docker/docker-compose.yml exec ghostfolio yarn database:migrate`
## Run with _Unraid_ (self-hosting)
Please follow the instructions of the Ghostfolio [Unraid Community App](https://unraid.net/community/apps?q=ghostfolio).
## Development ## Development
### Prerequisites ### Prerequisites

View File

@ -47,7 +47,7 @@
"test": { "test": {
"builder": "@nrwl/jest:jest", "builder": "@nrwl/jest:jest",
"options": { "options": {
"jestConfig": "apps/api/jest.config.js", "jestConfig": "apps/api/jest.config.ts",
"passWithNoTests": true "passWithNoTests": true
}, },
"outputs": ["coverage/apps/api"] "outputs": ["coverage/apps/api"]
@ -180,7 +180,7 @@
"test": { "test": {
"builder": "@nrwl/jest:jest", "builder": "@nrwl/jest:jest",
"options": { "options": {
"jestConfig": "apps/client/jest.config.js", "jestConfig": "apps/client/jest.config.ts",
"passWithNoTests": true "passWithNoTests": true
}, },
"outputs": ["coverage/apps/client"] "outputs": ["coverage/apps/client"]
@ -225,7 +225,7 @@
"builder": "@nrwl/jest:jest", "builder": "@nrwl/jest:jest",
"outputs": ["coverage/libs/common"], "outputs": ["coverage/libs/common"],
"options": { "options": {
"jestConfig": "libs/common/jest.config.js", "jestConfig": "libs/common/jest.config.ts",
"passWithNoTests": true "passWithNoTests": true
} }
} }
@ -247,7 +247,7 @@
"builder": "@nrwl/jest:jest", "builder": "@nrwl/jest:jest",
"outputs": ["coverage/libs/ui"], "outputs": ["coverage/libs/ui"],
"options": { "options": {
"jestConfig": "libs/ui/jest.config.js", "jestConfig": "libs/ui/jest.config.ts",
"passWithNoTests": true "passWithNoTests": true
} }
}, },

View File

@ -1,6 +1,6 @@
module.exports = { module.exports = {
displayName: 'api', displayName: 'api',
preset: '../../jest.preset.js',
globals: { globals: {
'ts-jest': { 'ts-jest': {
tsconfig: '<rootDir>/tsconfig.spec.json' tsconfig: '<rootDir>/tsconfig.spec.json'
@ -12,5 +12,6 @@ module.exports = {
moduleFileExtensions: ['ts', 'js', 'html'], moduleFileExtensions: ['ts', 'js', 'html'],
coverageDirectory: '../../coverage/apps/api', coverageDirectory: '../../coverage/apps/api',
testTimeout: 10000, testTimeout: 10000,
testEnvironment: 'node' testEnvironment: 'node',
preset: '../../jest.preset.ts'
}; };

View File

@ -1,8 +1,10 @@
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Filter } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Account, Order, Platform, Prisma } from '@prisma/client'; import { Account, Order, Platform, Prisma } from '@prisma/client';
import Big from 'big.js'; import Big from 'big.js';
import { groupBy } from 'lodash';
import { CashDetails } from './interfaces/cash-details.interface'; import { CashDetails } from './interfaces/cash-details.interface';
@ -102,22 +104,43 @@ export class AccountService {
}); });
} }
public async getCashDetails( public async getCashDetails({
aUserId: string, currency,
aCurrency: string filters = [],
): Promise<CashDetails> { userId
}: {
currency: string;
filters?: Filter[];
userId: string;
}): Promise<CashDetails> {
let totalCashBalanceInBaseCurrency = new Big(0); let totalCashBalanceInBaseCurrency = new Big(0);
const accounts = await this.accounts({ const where: Prisma.AccountWhereInput = { userId };
where: { userId: aUserId }
const {
ACCOUNT: filtersByAccount,
ASSET_CLASS: filtersByAssetClass,
TAG: filtersByTag
} = groupBy(filters, (filter) => {
return filter.type;
}); });
if (filtersByAccount?.length > 0) {
where.id = {
in: filtersByAccount.map(({ id }) => {
return id;
})
};
}
const accounts = await this.accounts({ where });
for (const account of accounts) { for (const account of accounts) {
totalCashBalanceInBaseCurrency = totalCashBalanceInBaseCurrency.plus( totalCashBalanceInBaseCurrency = totalCashBalanceInBaseCurrency.plus(
this.exchangeRateDataService.toCurrency( this.exchangeRateDataService.toCurrency(
account.balance, account.balance,
account.currency, account.currency,
aCurrency currency
) )
); );
} }

View File

@ -1,6 +1,10 @@
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service'; import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data.service'; import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
import { PropertyDto } from '@ghostfolio/api/services/property/property.dto'; import { PropertyDto } from '@ghostfolio/api/services/property/property.dto';
import {
DATA_GATHERING_QUEUE,
GATHER_ASSET_PROFILE_PROCESS
} from '@ghostfolio/common/config';
import { import {
AdminData, AdminData,
AdminMarketData, AdminMarketData,
@ -8,6 +12,7 @@ import {
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { InjectQueue } from '@nestjs/bull';
import { import {
Body, Body,
Controller, Controller,
@ -23,6 +28,7 @@ import {
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { DataSource, MarketData } from '@prisma/client'; import { DataSource, MarketData } from '@prisma/client';
import { Queue } from 'bull';
import { isDate } from 'date-fns'; import { isDate } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
@ -33,6 +39,8 @@ import { UpdateMarketDataDto } from './update-market-data.dto';
export class AdminController { export class AdminController {
public constructor( public constructor(
private readonly adminService: AdminService, private readonly adminService: AdminService,
@InjectQueue(DATA_GATHERING_QUEUE)
private readonly dataGatheringQueue: Queue,
private readonly dataGatheringService: DataGatheringService, private readonly dataGatheringService: DataGatheringService,
private readonly marketDataService: MarketDataService, private readonly marketDataService: MarketDataService,
@Inject(REQUEST) private readonly request: RequestWithUser @Inject(REQUEST) private readonly request: RequestWithUser
@ -71,10 +79,16 @@ export class AdminController {
); );
} }
await this.dataGatheringService.gatherProfileData(); const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
this.dataGatheringService.gatherMax();
return; for (const { dataSource, symbol } of uniqueAssets) {
await this.dataGatheringQueue.add(GATHER_ASSET_PROFILE_PROCESS, {
dataSource,
symbol
});
}
this.dataGatheringService.gatherMax();
} }
@Post('gather/profile-data') @Post('gather/profile-data')
@ -92,9 +106,14 @@ export class AdminController {
); );
} }
this.dataGatheringService.gatherProfileData(); const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
return; for (const { dataSource, symbol } of uniqueAssets) {
await this.dataGatheringQueue.add(GATHER_ASSET_PROFILE_PROCESS, {
dataSource,
symbol
});
}
} }
@Post('gather/profile-data/:dataSource/:symbol') @Post('gather/profile-data/:dataSource/:symbol')
@ -115,9 +134,10 @@ export class AdminController {
); );
} }
this.dataGatheringService.gatherProfileData([{ dataSource, symbol }]); await this.dataGatheringQueue.add(GATHER_ASSET_PROFILE_PROCESS, {
dataSource,
return; symbol
});
} }
@Post('gather/:dataSource/:symbol') @Post('gather/:dataSource/:symbol')

View File

@ -6,7 +6,7 @@ import { MarketDataService } from '@ghostfolio/api/services/market-data.service'
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.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 { import {
AdminData, AdminData,
AdminMarketData, AdminMarketData,
@ -15,11 +15,13 @@ import {
UniqueAsset UniqueAsset
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { DataSource, Property } from '@prisma/client'; import { Property } from '@prisma/client';
import { differenceInDays } from 'date-fns'; import { differenceInDays } from 'date-fns';
@Injectable() @Injectable()
export class AdminService { export class AdminService {
private baseCurrency: string;
public constructor( public constructor(
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly dataGatheringService: DataGatheringService, private readonly dataGatheringService: DataGatheringService,
@ -29,7 +31,9 @@ export class AdminService {
private readonly propertyService: PropertyService, private readonly propertyService: PropertyService,
private readonly subscriptionService: SubscriptionService, private readonly subscriptionService: SubscriptionService,
private readonly symbolProfileService: SymbolProfileService private readonly symbolProfileService: SymbolProfileService
) {} ) {
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
}
public async deleteProfileData({ dataSource, symbol }: UniqueAsset) { public async deleteProfileData({ dataSource, symbol }: UniqueAsset) {
await this.marketDataService.deleteMany({ dataSource, symbol }); await this.marketDataService.deleteMany({ dataSource, symbol });
@ -43,15 +47,15 @@ export class AdminService {
exchangeRates: this.exchangeRateDataService exchangeRates: this.exchangeRateDataService
.getCurrencies() .getCurrencies()
.filter((currency) => { .filter((currency) => {
return currency !== baseCurrency; return currency !== this.baseCurrency;
}) })
.map((currency) => { .map((currency) => {
return { return {
label1: baseCurrency, label1: this.baseCurrency,
label2: currency, label2: currency,
value: this.exchangeRateDataService.toCurrency( value: this.exchangeRateDataService.toCurrency(
1, 1,
baseCurrency, this.baseCurrency,
currency currency
) )
}; };

View File

@ -9,6 +9,7 @@ import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { TwitterBotModule } from '@ghostfolio/api/services/twitter-bot/twitter-bot.module'; import { TwitterBotModule } from '@ghostfolio/api/services/twitter-bot/twitter-bot.module';
import { BullModule } from '@nestjs/bull';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config'; import { ConfigModule } from '@nestjs/config';
import { ScheduleModule } from '@nestjs/schedule'; import { ScheduleModule } from '@nestjs/schedule';
@ -19,6 +20,7 @@ import { AccountModule } from './account/account.module';
import { AdminModule } from './admin/admin.module'; import { AdminModule } from './admin/admin.module';
import { AppController } from './app.controller'; import { AppController } from './app.controller';
import { AuthModule } from './auth/auth.module'; import { AuthModule } from './auth/auth.module';
import { BenchmarkModule } from './benchmark/benchmark.module';
import { CacheModule } from './cache/cache.module'; import { CacheModule } from './cache/cache.module';
import { ExportModule } from './export/export.module'; import { ExportModule } from './export/export.module';
import { ImportModule } from './import/import.module'; import { ImportModule } from './import/import.module';
@ -36,6 +38,14 @@ import { UserModule } from './user/user.module';
AccountModule, AccountModule,
AuthDeviceModule, AuthDeviceModule,
AuthModule, AuthModule,
BenchmarkModule,
BullModule.forRoot({
redis: {
host: process.env.REDIS_HOST,
port: parseInt(process.env.REDIS_PORT, 10),
password: process.env.REDIS_PASSWORD
}
}),
CacheModule, CacheModule,
ConfigModule.forRoot(), ConfigModule.forRoot(),
ConfigurationModule, ConfigurationModule,

View File

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

View File

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

View File

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

View File

@ -103,6 +103,7 @@ export class InfoService {
isReadOnlyMode, isReadOnlyMode,
platforms, platforms,
systemMessage, systemMessage,
baseCurrency: this.configurationService.get('BASE_CURRENCY'),
currencies: this.exchangeRateDataService.getCurrencies(), currencies: this.exchangeRateDataService.getCurrencies(),
demoAuthToken: this.getDemoAuthToken(), demoAuthToken: this.getDemoAuthToken(),
lastDataGathering: await this.getLastDataGathering(), lastDataGathering: await this.getLastDataGathering(),

View File

@ -1,5 +1,6 @@
import { UserService } from '@ghostfolio/api/app/user/user.service'; import { UserService } from '@ghostfolio/api/app/user/user.service';
import { nullifyValuesInObjects } from '@ghostfolio/api/helper/object.helper'; 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 { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor'; import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service'; import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
@ -62,6 +63,7 @@ export class OrderController {
@Get() @Get()
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
@UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getAllOrders( public async getAllOrders(
@Headers('impersonation-id') impersonationId @Headers('impersonation-id') impersonationId

View File

@ -4,8 +4,13 @@ import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.se
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
import {
DATA_GATHERING_QUEUE,
GATHER_ASSET_PROFILE_PROCESS
} from '@ghostfolio/common/config';
import { Filter } from '@ghostfolio/common/interfaces'; import { Filter } from '@ghostfolio/common/interfaces';
import { OrderWithAccount } from '@ghostfolio/common/types'; import { OrderWithAccount } from '@ghostfolio/common/types';
import { InjectQueue } from '@nestjs/bull';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { import {
AssetClass, AssetClass,
@ -16,6 +21,7 @@ import {
Type as TypeOfOrder Type as TypeOfOrder
} from '@prisma/client'; } from '@prisma/client';
import Big from 'big.js'; import Big from 'big.js';
import { Queue } from 'bull';
import { endOfToday, isAfter } from 'date-fns'; import { endOfToday, isAfter } from 'date-fns';
import { groupBy } from 'lodash'; import { groupBy } from 'lodash';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
@ -27,6 +33,8 @@ export class OrderService {
public constructor( public constructor(
private readonly accountService: AccountService, private readonly accountService: AccountService,
private readonly cacheService: CacheService, private readonly cacheService: CacheService,
@InjectQueue(DATA_GATHERING_QUEUE)
private readonly dataGatheringQueue: Queue,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly dataGatheringService: DataGatheringService, private readonly dataGatheringService: DataGatheringService,
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
@ -112,12 +120,10 @@ export class OrderService {
data.SymbolProfile.connectOrCreate.create.symbol.toUpperCase(); data.SymbolProfile.connectOrCreate.create.symbol.toUpperCase();
} }
await this.dataGatheringService.gatherProfileData([ await this.dataGatheringQueue.add(GATHER_ASSET_PROFILE_PROCESS, {
{ dataSource: data.SymbolProfile.connectOrCreate.create.dataSource,
dataSource: data.SymbolProfile.connectOrCreate.create.dataSource, symbol: data.SymbolProfile.connectOrCreate.create.symbol
symbol: data.SymbolProfile.connectOrCreate.create.symbol });
}
]);
const isDraft = isAfter(data.date as Date, endOfToday()); const isDraft = isAfter(data.date as Date, endOfToday());
@ -182,12 +188,13 @@ export class OrderService {
}): Promise<Activity[]> { }): Promise<Activity[]> {
const where: Prisma.OrderWhereInput = { userId }; const where: Prisma.OrderWhereInput = { userId };
const { account: filtersByAccount, tag: filtersByTag } = groupBy( const {
filters, ACCOUNT: filtersByAccount,
(filter) => { ASSET_CLASS: filtersByAssetClass,
return filter.type; TAG: filtersByTag
} } = groupBy(filters, (filter) => {
); return filter.type;
});
if (filtersByAccount?.length > 0) { if (filtersByAccount?.length > 0) {
where.accountId = { where.accountId = {
@ -201,6 +208,34 @@ export class OrderService {
where.isDraft = false; where.isDraft = false;
} }
if (filtersByAssetClass?.length > 0) {
where.SymbolProfile = {
OR: [
{
AND: [
{
OR: filtersByAssetClass.map(({ id }) => {
return { assetClass: AssetClass[id] };
})
},
{
SymbolProfileOverrides: {
is: null
}
}
]
},
{
SymbolProfileOverrides: {
OR: filtersByAssetClass.map(({ id }) => {
return { assetClass: AssetClass[id] };
})
}
}
]
};
}
if (filtersByTag?.length > 0) { if (filtersByTag?.length > 0) {
where.tags = { where.tags = {
some: { some: {

View File

@ -1,6 +1,7 @@
import { parseDate, resetHours } from '@ghostfolio/common/helper'; import { parseDate, resetHours } from '@ghostfolio/common/helper';
import { addDays, endOfDay, isBefore, isSameDay } from 'date-fns'; import { addDays, endOfDay, isBefore, isSameDay } from 'date-fns';
import { GetValueObject } from './interfaces/get-value-object.interface';
import { GetValuesParams } from './interfaces/get-values-params.interface'; import { GetValuesParams } from './interfaces/get-values-params.interface';
function mockGetValue(symbol: string, date: Date) { function mockGetValue(symbol: string, date: Date) {
@ -33,8 +34,11 @@ function mockGetValue(symbol: string, date: Date) {
} }
export const CurrentRateServiceMock = { export const CurrentRateServiceMock = {
getValues: ({ dataGatheringItems, dateQuery }: GetValuesParams) => { getValues: ({
const result = []; dataGatheringItems,
dateQuery
}: GetValuesParams): Promise<GetValueObject[]> => {
const result: GetValueObject[] = [];
if (dateQuery.lt) { if (dateQuery.lt) {
for ( for (
let date = resetHours(dateQuery.gte); let date = resetHours(dateQuery.gte);
@ -44,8 +48,10 @@ export const CurrentRateServiceMock = {
for (const dataGatheringItem of dataGatheringItems) { for (const dataGatheringItem of dataGatheringItems) {
result.push({ result.push({
date, date,
marketPrice: mockGetValue(dataGatheringItem.symbol, date) marketPriceInBaseCurrency: mockGetValue(
.marketPrice, dataGatheringItem.symbol,
date
).marketPrice,
symbol: dataGatheringItem.symbol symbol: dataGatheringItem.symbol
}); });
} }
@ -55,8 +61,10 @@ export const CurrentRateServiceMock = {
for (const dataGatheringItem of dataGatheringItems) { for (const dataGatheringItem of dataGatheringItems) {
result.push({ result.push({
date, date,
marketPrice: mockGetValue(dataGatheringItem.symbol, date) marketPriceInBaseCurrency: mockGetValue(
.marketPrice, dataGatheringItem.symbol,
date
).marketPrice,
symbol: dataGatheringItem.symbol symbol: dataGatheringItem.symbol
}); });
} }

View File

@ -4,6 +4,7 @@ import { MarketDataService } from '@ghostfolio/api/services/market-data.service'
import { DataSource, MarketData } from '@prisma/client'; import { DataSource, MarketData } from '@prisma/client';
import { CurrentRateService } from './current-rate.service'; import { CurrentRateService } from './current-rate.service';
import { GetValueObject } from './interfaces/get-value-object.interface';
jest.mock('@ghostfolio/api/services/market-data.service', () => { jest.mock('@ghostfolio/api/services/market-data.service', () => {
return { return {
@ -73,7 +74,12 @@ describe('CurrentRateService', () => {
beforeAll(async () => { beforeAll(async () => {
dataProviderService = new DataProviderService(null, [], null); dataProviderService = new DataProviderService(null, [], null);
exchangeRateDataService = new ExchangeRateDataService(null, null, null); exchangeRateDataService = new ExchangeRateDataService(
null,
null,
null,
null
);
marketDataService = new MarketDataService(null); marketDataService = new MarketDataService(null);
await exchangeRateDataService.initialize(); await exchangeRateDataService.initialize();
@ -96,15 +102,15 @@ describe('CurrentRateService', () => {
}, },
userCurrency: 'CHF' userCurrency: 'CHF'
}) })
).toMatchObject([ ).toMatchObject<GetValueObject[]>([
{ {
date: undefined, date: undefined,
marketPrice: 1841.823902, marketPriceInBaseCurrency: 1841.823902,
symbol: 'AMZN' symbol: 'AMZN'
}, },
{ {
date: undefined, date: undefined,
marketPrice: 1847.839966, marketPriceInBaseCurrency: 1847.839966,
symbol: 'AMZN' symbol: 'AMZN'
} }
]); ]);

View File

@ -28,13 +28,7 @@ export class CurrentRateService {
(!dateQuery.gte || isBefore(dateQuery.gte, new Date())) && (!dateQuery.gte || isBefore(dateQuery.gte, new Date())) &&
(!dateQuery.in || this.containsToday(dateQuery.in)); (!dateQuery.in || this.containsToday(dateQuery.in));
const promises: Promise< const promises: Promise<GetValueObject[]>[] = [];
{
date: Date;
marketPrice: number;
symbol: string;
}[]
>[] = [];
if (includeToday) { if (includeToday) {
const today = resetHours(new Date()); const today = resetHours(new Date());
@ -42,16 +36,17 @@ export class CurrentRateService {
this.dataProviderService this.dataProviderService
.getQuotes(dataGatheringItems) .getQuotes(dataGatheringItems)
.then((dataResultProvider) => { .then((dataResultProvider) => {
const result = []; const result: GetValueObject[] = [];
for (const dataGatheringItem of dataGatheringItems) { for (const dataGatheringItem of dataGatheringItems) {
result.push({ result.push({
date: today, date: today,
marketPrice: this.exchangeRateDataService.toCurrency( marketPriceInBaseCurrency:
dataResultProvider?.[dataGatheringItem.symbol]?.marketPrice ?? this.exchangeRateDataService.toCurrency(
0, dataResultProvider?.[dataGatheringItem.symbol]
dataResultProvider?.[dataGatheringItem.symbol]?.currency, ?.marketPrice ?? 0,
userCurrency dataResultProvider?.[dataGatheringItem.symbol]?.currency,
), userCurrency
),
symbol: dataGatheringItem.symbol symbol: dataGatheringItem.symbol
}); });
} }
@ -74,11 +69,12 @@ export class CurrentRateService {
return data.map((marketDataItem) => { return data.map((marketDataItem) => {
return { return {
date: marketDataItem.date, date: marketDataItem.date,
marketPrice: this.exchangeRateDataService.toCurrency( marketPriceInBaseCurrency:
marketDataItem.marketPrice, this.exchangeRateDataService.toCurrency(
currencies[marketDataItem.symbol], marketDataItem.marketPrice,
userCurrency currencies[marketDataItem.symbol],
), userCurrency
),
symbol: marketDataItem.symbol symbol: marketDataItem.symbol
}; };
}); });

View File

@ -1,5 +1,5 @@
export interface GetValueObject { export interface GetValueObject {
date: Date; date: Date;
marketPrice: number; marketPriceInBaseCurrency: number;
symbol: string; symbol: string;
} }

View File

@ -1,4 +1,7 @@
import { EnhancedSymbolProfile } from '@ghostfolio/api/services/interfaces/symbol-profile.interface'; import {
EnhancedSymbolProfile,
HistoricalDataItem
} from '@ghostfolio/common/interfaces';
import { OrderWithAccount } from '@ghostfolio/common/types'; import { OrderWithAccount } from '@ghostfolio/common/types';
import { Tag } from '@prisma/client'; import { Tag } from '@prisma/client';
@ -27,10 +30,3 @@ export interface HistoricalDataContainer {
isAllTimeLow: boolean; isAllTimeLow: boolean;
items: HistoricalDataItem[]; items: HistoricalDataItem[];
} }
export interface HistoricalDataItem {
averagePrice?: number;
date: string;
grossPerformancePercent?: number;
value: number;
}

View File

@ -231,9 +231,9 @@ export class PortfolioCalculator {
if (!marketSymbolMap[date]) { if (!marketSymbolMap[date]) {
marketSymbolMap[date] = {}; marketSymbolMap[date] = {};
} }
if (marketSymbol.marketPrice) { if (marketSymbol.marketPriceInBaseCurrency) {
marketSymbolMap[date][marketSymbol.symbol] = new Big( marketSymbolMap[date][marketSymbol.symbol] = new Big(
marketSymbol.marketPrice marketSymbol.marketPriceInBaseCurrency
); );
} }
} }
@ -548,9 +548,9 @@ export class PortfolioCalculator {
if (!marketSymbolMap[date]) { if (!marketSymbolMap[date]) {
marketSymbolMap[date] = {}; marketSymbolMap[date] = {};
} }
if (marketSymbol.marketPrice) { if (marketSymbol.marketPriceInBaseCurrency) {
marketSymbolMap[date][marketSymbol.symbol] = new Big( marketSymbolMap[date][marketSymbol.symbol] = new Big(
marketSymbol.marketPrice marketSymbol.marketPriceInBaseCurrency
); );
} }
} }

View File

@ -4,11 +4,11 @@ import {
hasNotDefinedValuesInObject, hasNotDefinedValuesInObject,
nullifyValuesInObject nullifyValuesInObject
} from '@ghostfolio/api/helper/object.helper'; } 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 { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor'; import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { baseCurrency } from '@ghostfolio/common/config';
import { parseDate } from '@ghostfolio/common/helper'; import { parseDate } from '@ghostfolio/common/helper';
import { import {
Filter, Filter,
@ -43,6 +43,8 @@ import { PortfolioService } from './portfolio.service';
@Controller('portfolio') @Controller('portfolio')
export class PortfolioController { export class PortfolioController {
private baseCurrency: string;
public constructor( public constructor(
private readonly accessService: AccessService, private readonly accessService: AccessService,
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
@ -50,7 +52,9 @@ export class PortfolioController {
private readonly portfolioService: PortfolioService, private readonly portfolioService: PortfolioService,
@Inject(REQUEST) private readonly request: RequestWithUser, @Inject(REQUEST) private readonly request: RequestWithUser,
private readonly userService: UserService private readonly userService: UserService
) {} ) {
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
}
@Get('chart') @Get('chart')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
@ -103,29 +107,38 @@ export class PortfolioController {
@Get('details') @Get('details')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
@UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getDetails( public async getDetails(
@Headers('impersonation-id') impersonationId: string, @Headers('impersonation-id') impersonationId: string,
@Query('accounts') filterByAccounts?: string, @Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string,
@Query('range') range?: DateRange, @Query('range') range?: DateRange,
@Query('tags') filterByTags?: string @Query('tags') filterByTags?: string
): Promise<PortfolioDetails & { hasError: boolean }> { ): Promise<PortfolioDetails & { hasError: boolean }> {
let hasError = false; let hasError = false;
const accountIds = filterByAccounts?.split(',') ?? []; const accountIds = filterByAccounts?.split(',') ?? [];
const assetClasses = filterByAssetClasses?.split(',') ?? [];
const tagIds = filterByTags?.split(',') ?? []; const tagIds = filterByTags?.split(',') ?? [];
const filters: Filter[] = [ const filters: Filter[] = [
...accountIds.map((accountId) => { ...accountIds.map((accountId) => {
return <Filter>{ return <Filter>{
id: accountId, id: accountId,
type: 'account' type: 'ACCOUNT'
};
}),
...assetClasses.map((assetClass) => {
return <Filter>{
id: assetClass,
type: 'ASSET_CLASS'
}; };
}), }),
...tagIds.map((tagId) => { ...tagIds.map((tagId) => {
return <Filter>{ return <Filter>{
id: tagId, id: tagId,
type: 'tag' type: 'TAG'
}; };
}) })
]; ];
@ -182,8 +195,8 @@ export class PortfolioController {
this.request.user.subscription.type === 'Basic'; this.request.user.subscription.type === 'Basic';
return { return {
accounts,
hasError, hasError,
accounts: filters ? {} : accounts,
holdings: isBasicUser ? {} : holdings holdings: isBasicUser ? {} : holdings
}; };
} }
@ -319,7 +332,7 @@ export class PortfolioController {
return this.exchangeRateDataService.toCurrency( return this.exchangeRateDataService.toCurrency(
portfolioPosition.quantity * portfolioPosition.marketPrice, portfolioPosition.quantity * portfolioPosition.marketPrice,
portfolioPosition.currency, portfolioPosition.currency,
this.request.user?.Settings?.currency ?? baseCurrency this.request.user?.Settings?.currency ?? this.baseCurrency
); );
}) })
.reduce((a, b) => a + b, 0); .reduce((a, b) => a + b, 0);

View File

@ -15,21 +15,21 @@ import { CurrencyClusterRiskBaseCurrencyInitialInvestment } from '@ghostfolio/ap
import { CurrencyClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/current-investment'; import { CurrencyClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/current-investment';
import { CurrencyClusterRiskInitialInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/initial-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 { 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 { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service'; import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
import { MarketState } from '@ghostfolio/api/services/interfaces/interfaces';
import { EnhancedSymbolProfile } from '@ghostfolio/api/services/interfaces/symbol-profile.interface';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
import { import {
ASSET_SUB_CLASS_EMERGENCY_FUND, ASSET_SUB_CLASS_EMERGENCY_FUND,
UNKNOWN_KEY, UNKNOWN_KEY
baseCurrency
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper'; import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper';
import { import {
Accounts, Accounts,
EnhancedSymbolProfile,
Filter, Filter,
HistoricalDataItem,
PortfolioDetails, PortfolioDetails,
PortfolioPerformanceResponse, PortfolioPerformanceResponse,
PortfolioReport, PortfolioReport,
@ -68,11 +68,10 @@ import {
subDays, subDays,
subYears subYears
} from 'date-fns'; } from 'date-fns';
import { isEmpty, sortBy, uniqBy } from 'lodash'; import { isEmpty, sortBy, uniq, uniqBy } from 'lodash';
import { import {
HistoricalDataContainer, HistoricalDataContainer,
HistoricalDataItem,
PortfolioPositionDetail PortfolioPositionDetail
} from './interfaces/portfolio-position-detail.interface'; } from './interfaces/portfolio-position-detail.interface';
import { PortfolioCalculator } from './portfolio-calculator'; import { PortfolioCalculator } from './portfolio-calculator';
@ -83,8 +82,11 @@ const emergingMarkets = require('../../assets/countries/emerging-markets.json');
@Injectable() @Injectable()
export class PortfolioService { export class PortfolioService {
private baseCurrency: string;
public constructor( public constructor(
private readonly accountService: AccountService, private readonly accountService: AccountService,
private readonly configurationService: ConfigurationService,
private readonly currentRateService: CurrentRateService, private readonly currentRateService: CurrentRateService,
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
@ -94,7 +96,9 @@ export class PortfolioService {
private readonly rulesService: RulesService, private readonly rulesService: RulesService,
private readonly symbolProfileService: SymbolProfileService, private readonly symbolProfileService: SymbolProfileService,
private readonly userService: UserService private readonly userService: UserService
) {} ) {
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
}
public async getAccounts(aUserId: string): Promise<AccountWithValue[]> { public async getAccounts(aUserId: string): Promise<AccountWithValue[]> {
const [accounts, details] = await Promise.all([ const [accounts, details] = await Promise.all([
@ -319,9 +323,9 @@ export class PortfolioService {
(user.Settings?.settings as UserSettings)?.emergencyFund ?? 0 (user.Settings?.settings as UserSettings)?.emergencyFund ?? 0
); );
const userCurrency = const userCurrency =
this.request.user?.Settings?.currency ??
user.Settings?.currency ?? user.Settings?.currency ??
baseCurrency; this.request.user?.Settings?.currency ??
this.baseCurrency;
const { orders, portfolioOrders, transactionPoints } = const { orders, portfolioOrders, transactionPoints } =
await this.getTransactionPoints({ await this.getTransactionPoints({
@ -345,10 +349,11 @@ export class PortfolioService {
startDate startDate
); );
const cashDetails = await this.accountService.getCashDetails( const cashDetails = await this.accountService.getCashDetails({
userId, userId,
userCurrency currency: userCurrency,
); filters: aFilters
});
const holdings: PortfolioDetails['holdings'] = {}; const holdings: PortfolioDetails['holdings'] = {};
const totalInvestment = currentPositions.totalInvestment.plus( const totalInvestment = currentPositions.totalInvestment.plus(
@ -370,7 +375,7 @@ export class PortfolioService {
const [dataProviderResponses, symbolProfiles] = await Promise.all([ const [dataProviderResponses, symbolProfiles] = await Promise.all([
this.dataProviderService.getQuotes(dataGatheringItems), this.dataProviderService.getQuotes(dataGatheringItems),
this.symbolProfileService.getSymbolProfiles(symbols) this.symbolProfileService.getSymbolProfilesBySymbols(symbols)
]); ]);
const symbolProfileMap: { [symbol: string]: EnhancedSymbolProfile } = {}; const symbolProfileMap: { [symbol: string]: EnhancedSymbolProfile } = {};
@ -441,26 +446,32 @@ export class PortfolioService {
}; };
} }
const cashPositions = await this.getCashPositions({ if (
cashDetails, aFilters?.length === 0 ||
emergencyFund, (aFilters?.length === 1 &&
userCurrency, aFilters[0].type === 'ASSET_CLASS' &&
investment: totalInvestment, aFilters[0].id === 'CASH')
value: totalValue ) {
}); const cashPositions = await this.getCashPositions({
cashDetails,
emergencyFund,
userCurrency,
investment: totalInvestment,
value: totalValue
});
if (aFilters === undefined) {
for (const symbol of Object.keys(cashPositions)) { for (const symbol of Object.keys(cashPositions)) {
holdings[symbol] = cashPositions[symbol]; holdings[symbol] = cashPositions[symbol];
} }
} }
const accounts = await this.getValueOfAccounts( const accounts = await this.getValueOfAccounts({
orders, orders,
portfolioItemsNow, portfolioItemsNow,
userCurrency, userCurrency,
userId userId,
); filters: aFilters
});
return { accounts, holdings, hasErrors: currentPositions.hasErrors }; return { accounts, holdings, hasErrors: currentPositions.hasErrors };
} }
@ -507,9 +518,8 @@ export class PortfolioService {
} }
const positionCurrency = orders[0].SymbolProfile.currency; const positionCurrency = orders[0].SymbolProfile.currency;
const [SymbolProfile] = await this.symbolProfileService.getSymbolProfiles([ const [SymbolProfile] =
aSymbol await this.symbolProfileService.getSymbolProfilesBySymbols([aSymbol]);
]);
const portfolioOrders: PortfolioOrder[] = orders const portfolioOrders: PortfolioOrder[] = orders
.filter((order) => { .filter((order) => {
@ -757,7 +767,7 @@ export class PortfolioService {
const [dataProviderResponses, symbolProfiles] = await Promise.all([ const [dataProviderResponses, symbolProfiles] = await Promise.all([
this.dataProviderService.getQuotes(dataGatheringItem), this.dataProviderService.getQuotes(dataGatheringItem),
this.symbolProfileService.getSymbolProfiles(symbols) this.symbolProfileService.getSymbolProfilesBySymbols(symbols)
]); ]);
const symbolProfileMap: { [symbol: string]: EnhancedSymbolProfile } = {}; const symbolProfileMap: { [symbol: string]: EnhancedSymbolProfile } = {};
@ -777,8 +787,7 @@ export class PortfolioService {
position.grossPerformancePercentage?.toNumber() ?? null, position.grossPerformancePercentage?.toNumber() ?? null,
investment: new Big(position.investment).toNumber(), investment: new Big(position.investment).toNumber(),
marketState: marketState:
dataProviderResponses[position.symbol]?.marketState ?? dataProviderResponses[position.symbol]?.marketState ?? 'delayed',
MarketState.delayed,
name: symbolProfileMap[position.symbol].name, name: symbolProfileMap[position.symbol].name,
netPerformance: position.netPerformance?.toNumber() ?? null, netPerformance: position.netPerformance?.toNumber() ?? null,
netPerformancePercentage: netPerformancePercentage:
@ -892,12 +901,12 @@ export class PortfolioService {
for (const position of currentPositions.positions) { for (const position of currentPositions.positions) {
portfolioItemsNow[position.symbol] = position; portfolioItemsNow[position.symbol] = position;
} }
const accounts = await this.getValueOfAccounts( const accounts = await this.getValueOfAccounts({
orders, orders,
portfolioItemsNow, portfolioItemsNow,
currency, userId,
userId userCurrency: currency
); });
return { return {
rules: { rules: {
accountClusterRisk: await this.rulesService.evaluate( accountClusterRisk: await this.rulesService.evaluate(
@ -959,10 +968,10 @@ export class PortfolioService {
const performanceInformation = await this.getPerformance(aImpersonationId); const performanceInformation = await this.getPerformance(aImpersonationId);
const { balanceInBaseCurrency } = await this.accountService.getCashDetails( const { balanceInBaseCurrency } = await this.accountService.getCashDetails({
userId, userId,
userCurrency currency: userCurrency
); });
const orders = await this.orderService.getOrders({ const orders = await this.orderService.getOrders({
userCurrency, userCurrency,
userId userId
@ -1062,7 +1071,7 @@ export class PortfolioService {
grossPerformancePercent: 0, grossPerformancePercent: 0,
investment: convertedBalance, investment: convertedBalance,
marketPrice: 0, marketPrice: 0,
marketState: MarketState.open, marketState: 'open',
name: account.currency, name: account.currency,
netPerformance: 0, netPerformance: 0,
netPerformancePercent: 0, netPerformancePercent: 0,
@ -1208,7 +1217,8 @@ export class PortfolioService {
orders: OrderWithAccount[]; orders: OrderWithAccount[];
portfolioOrders: PortfolioOrder[]; 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({ const orders = await this.orderService.getOrders({
filters, filters,
@ -1255,21 +1265,42 @@ export class PortfolioService {
portfolioCalculator.computeTransactionPoints(); portfolioCalculator.computeTransactionPoints();
return { return {
transactionPoints: portfolioCalculator.getTransactionPoints(),
orders, orders,
portfolioOrders portfolioOrders,
transactionPoints: portfolioCalculator.getTransactionPoints()
}; };
} }
private async getValueOfAccounts( private async getValueOfAccounts({
orders: OrderWithAccount[], filters = [],
portfolioItemsNow: { [p: string]: TimelinePosition }, orders,
userCurrency: string, portfolioItemsNow,
userId: string userCurrency,
) { userId
}: {
filters?: Filter[];
orders: OrderWithAccount[];
portfolioItemsNow: { [p: string]: TimelinePosition };
userCurrency: string;
userId: string;
}) {
const accounts: PortfolioDetails['accounts'] = {}; const accounts: PortfolioDetails['accounts'] = {};
const currentAccounts = await this.accountService.getAccounts(userId); let currentAccounts = [];
if (filters.length === 0) {
currentAccounts = await this.accountService.getAccounts(userId);
} else {
const accountIds = uniq(
orders.map(({ accountId }) => {
return accountId;
})
);
currentAccounts = await this.accountService.accounts({
where: { id: { in: accountIds } }
});
}
for (const account of currentAccounts) { for (const account of currentAccounts) {
const ordersByAccount = orders.filter(({ accountId }) => { const ordersByAccount = orders.filter(({ accountId }) => {
@ -1279,34 +1310,47 @@ export class PortfolioService {
accounts[account.id] = { accounts[account.id] = {
balance: account.balance, balance: account.balance,
currency: account.currency, currency: account.currency,
current: account.balance, current: this.exchangeRateDataService.toCurrency(
account.balance,
account.currency,
userCurrency
),
name: account.name, name: account.name,
original: account.balance original: this.exchangeRateDataService.toCurrency(
account.balance,
account.currency,
userCurrency
)
}; };
for (const order of ordersByAccount) { for (const order of ordersByAccount) {
let currentValueOfSymbol = let currentValueOfSymbolInBaseCurrency =
order.quantity * order.quantity *
portfolioItemsNow[order.SymbolProfile.symbol].marketPrice; portfolioItemsNow[order.SymbolProfile.symbol].marketPrice;
let originalValueOfSymbol = order.quantity * order.unitPrice; let originalValueOfSymbolInBaseCurrency =
this.exchangeRateDataService.toCurrency(
order.quantity * order.unitPrice,
order.SymbolProfile.currency,
userCurrency
);
if (order.type === 'SELL') { if (order.type === 'SELL') {
currentValueOfSymbol *= -1; currentValueOfSymbolInBaseCurrency *= -1;
originalValueOfSymbol *= -1; originalValueOfSymbolInBaseCurrency *= -1;
} }
if (accounts[order.Account?.id || UNKNOWN_KEY]?.current) { if (accounts[order.Account?.id || UNKNOWN_KEY]?.current) {
accounts[order.Account?.id || UNKNOWN_KEY].current += accounts[order.Account?.id || UNKNOWN_KEY].current +=
currentValueOfSymbol; currentValueOfSymbolInBaseCurrency;
accounts[order.Account?.id || UNKNOWN_KEY].original += accounts[order.Account?.id || UNKNOWN_KEY].original +=
originalValueOfSymbol; originalValueOfSymbolInBaseCurrency;
} else { } else {
accounts[order.Account?.id || UNKNOWN_KEY] = { accounts[order.Account?.id || UNKNOWN_KEY] = {
balance: 0, balance: 0,
currency: order.Account?.currency, currency: order.Account?.currency,
current: currentValueOfSymbol, current: currentValueOfSymbolInBaseCurrency,
name: account.name, name: account.name,
original: originalValueOfSymbol original: originalValueOfSymbolInBaseCurrency
}; };
} }
} }

View File

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

View File

@ -1,9 +1,7 @@
import { HistoricalDataItem } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position-detail.interface'; import { HistoricalDataItem, UniqueAsset } from '@ghostfolio/common/interfaces';
import { DataSource } from '@prisma/client';
export interface SymbolItem { export interface SymbolItem extends UniqueAsset {
currency: string; currency: string;
dataSource: DataSource;
historicalData: HistoricalDataItem[]; historicalData: HistoricalDataItem[];
marketPrice: number; marketPrice: number;
} }

View File

@ -1,4 +1,3 @@
import { HistoricalDataItem } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position-detail.interface';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { import {
IDataGatheringItem, IDataGatheringItem,
@ -6,6 +5,7 @@ import {
} from '@ghostfolio/api/services/interfaces/interfaces'; } from '@ghostfolio/api/services/interfaces/interfaces';
import { MarketDataService } from '@ghostfolio/api/services/market-data.service'; import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { HistoricalDataItem } from '@ghostfolio/common/interfaces';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
import { format, subDays } from 'date-fns'; import { format, subDays } from 'date-fns';
@ -55,7 +55,8 @@ export class SymbolService {
currency, currency,
historicalData, historicalData,
marketPrice, marketPrice,
dataSource: dataGatheringItem.dataSource dataSource: dataGatheringItem.dataSource,
symbol: dataGatheringItem.symbol
}; };
} }

View File

@ -1,4 +0,0 @@
export interface Access {
alias?: string;
id: string;
}

View File

@ -3,11 +3,7 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration.ser
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { TagService } from '@ghostfolio/api/services/tag/tag.service'; import { TagService } from '@ghostfolio/api/services/tag/tag.service';
import { import { PROPERTY_IS_READ_ONLY_MODE, locale } from '@ghostfolio/common/config';
PROPERTY_IS_READ_ONLY_MODE,
baseCurrency,
locale
} from '@ghostfolio/common/config';
import { User as IUser, UserWithSettings } from '@ghostfolio/common/interfaces'; import { User as IUser, UserWithSettings } from '@ghostfolio/common/interfaces';
import { import {
getPermissions, getPermissions,
@ -26,13 +22,17 @@ const crypto = require('crypto');
export class UserService { export class UserService {
public static DEFAULT_CURRENCY = 'USD'; public static DEFAULT_CURRENCY = 'USD';
private baseCurrency: string;
public constructor( public constructor(
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService, private readonly propertyService: PropertyService,
private readonly subscriptionService: SubscriptionService, private readonly subscriptionService: SubscriptionService,
private readonly tagService: TagService private readonly tagService: TagService
) {} ) {
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
}
public async getUser( public async getUser(
{ {
@ -102,19 +102,69 @@ export class UserService {
public async user( public async user(
userWhereUniqueInput: Prisma.UserWhereUniqueInput userWhereUniqueInput: Prisma.UserWhereUniqueInput
): Promise<UserWithSettings | null> { ): Promise<UserWithSettings | null> {
const userFromDatabase = await this.prismaService.user.findUnique({ const {
accessToken,
Account,
alias,
authChallenge,
createdAt,
id,
provider,
role,
Settings,
Subscription,
thirdPartyId,
updatedAt
} = await this.prismaService.user.findUnique({
include: { Account: true, Settings: true, Subscription: true }, include: { Account: true, Settings: true, Subscription: true },
where: userWhereUniqueInput where: userWhereUniqueInput
}); });
const user: UserWithSettings = userFromDatabase; const user: UserWithSettings = {
accessToken,
Account,
alias,
authChallenge,
createdAt,
id,
provider,
role,
Settings,
thirdPartyId,
updatedAt
};
let currentPermissions = getPermissions(userFromDatabase.role); if (user?.Settings) {
if (!user.Settings.currency) {
// Set default currency if needed
user.Settings.currency = UserService.DEFAULT_CURRENCY;
}
} else if (user) {
// Set default settings if needed
user.Settings = {
currency: UserService.DEFAULT_CURRENCY,
settings: null,
updatedAt: new Date(),
userId: user?.id,
viewMode: ViewMode.DEFAULT
};
}
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
user.subscription =
this.subscriptionService.getSubscription(Subscription);
}
let currentPermissions = getPermissions(user.role);
if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) { if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) {
currentPermissions.push(permissions.accessFearAndGreedIndex); currentPermissions.push(permissions.accessFearAndGreedIndex);
} }
if (user.subscription?.type === 'Premium') {
currentPermissions.push(permissions.reportDataGlitch);
}
if (this.configurationService.get('ENABLE_FEATURE_READ_ONLY_MODE')) { if (this.configurationService.get('ENABLE_FEATURE_READ_ONLY_MODE')) {
if (hasRole(user, Role.ADMIN)) { if (hasRole(user, Role.ADMIN)) {
currentPermissions.push(permissions.toggleReadOnlyMode); currentPermissions.push(permissions.toggleReadOnlyMode);
@ -135,29 +185,7 @@ export class UserService {
} }
} }
user.permissions = currentPermissions; user.permissions = currentPermissions.sort();
if (userFromDatabase?.Settings) {
if (!userFromDatabase.Settings.currency) {
// Set default currency if needed
userFromDatabase.Settings.currency = UserService.DEFAULT_CURRENCY;
}
} else if (userFromDatabase) {
// Set default settings if needed
userFromDatabase.Settings = {
currency: UserService.DEFAULT_CURRENCY,
settings: null,
updatedAt: new Date(),
userId: userFromDatabase?.id,
viewMode: ViewMode.DEFAULT
};
}
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
user.subscription = this.subscriptionService.getSubscription(
userFromDatabase?.Subscription
);
}
return user; return user;
} }
@ -196,14 +224,14 @@ export class UserService {
...data, ...data,
Account: { Account: {
create: { create: {
currency: baseCurrency, currency: this.baseCurrency,
isDefault: true, isDefault: true,
name: 'Default Account' name: 'Default Account'
} }
}, },
Settings: { Settings: {
create: { create: {
currency: baseCurrency currency: this.baseCurrency
} }
} }
} }

View File

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

View File

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

View File

@ -1,5 +1,11 @@
import {
DATA_GATHERING_QUEUE,
GATHER_ASSET_PROFILE_PROCESS
} from '@ghostfolio/common/config';
import { InjectQueue } from '@nestjs/bull';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule'; import { Cron, CronExpression } from '@nestjs/schedule';
import { Queue } from 'bull';
import { DataGatheringService } from './data-gathering.service'; import { DataGatheringService } from './data-gathering.service';
import { ExchangeRateDataService } from './exchange-rate-data.service'; import { ExchangeRateDataService } from './exchange-rate-data.service';
@ -8,6 +14,8 @@ import { TwitterBotService } from './twitter-bot/twitter-bot.service';
@Injectable() @Injectable()
export class CronService { export class CronService {
public constructor( public constructor(
@InjectQueue(DATA_GATHERING_QUEUE)
private readonly dataGatheringQueue: Queue,
private readonly dataGatheringService: DataGatheringService, private readonly dataGatheringService: DataGatheringService,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly twitterBotService: TwitterBotService private readonly twitterBotService: TwitterBotService
@ -30,6 +38,13 @@ export class CronService {
@Cron(CronExpression.EVERY_WEEKEND) @Cron(CronExpression.EVERY_WEEKEND)
public async runEveryWeekend() { public async runEveryWeekend() {
await this.dataGatheringService.gatherProfileData(); const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
for (const { dataSource, symbol } of uniqueAssets) {
await this.dataGatheringQueue.add(GATHER_ASSET_PROFILE_PROCESS, {
dataSource,
symbol
});
}
} }
} }

View File

@ -3,13 +3,19 @@ import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.se
import { DataEnhancerModule } from '@ghostfolio/api/services/data-provider/data-enhancer/data-enhancer.module'; import { DataEnhancerModule } from '@ghostfolio/api/services/data-provider/data-enhancer/data-enhancer.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { DATA_GATHERING_QUEUE } from '@ghostfolio/common/config';
import { BullModule } from '@nestjs/bull';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { DataGatheringProcessor } from './data-gathering.processor';
import { ExchangeRateDataModule } from './exchange-rate-data.module'; import { ExchangeRateDataModule } from './exchange-rate-data.module';
import { SymbolProfileModule } from './symbol-profile.module'; import { SymbolProfileModule } from './symbol-profile.module';
@Module({ @Module({
imports: [ imports: [
BullModule.registerQueue({
name: DATA_GATHERING_QUEUE
}),
ConfigurationModule, ConfigurationModule,
DataEnhancerModule, DataEnhancerModule,
DataProviderModule, DataProviderModule,
@ -17,7 +23,7 @@ import { SymbolProfileModule } from './symbol-profile.module';
PrismaModule, PrismaModule,
SymbolProfileModule SymbolProfileModule
], ],
providers: [DataGatheringService], providers: [DataGatheringProcessor, DataGatheringService],
exports: [DataEnhancerModule, DataGatheringService] exports: [BullModule, DataEnhancerModule, DataGatheringService]
}) })
export class DataGatheringModule {} export class DataGatheringModule {}

View File

@ -0,0 +1,27 @@
import {
DATA_GATHERING_QUEUE,
GATHER_ASSET_PROFILE_PROCESS
} from '@ghostfolio/common/config';
import { UniqueAsset } from '@ghostfolio/common/interfaces';
import { Process, Processor } from '@nestjs/bull';
import { Injectable, Logger } from '@nestjs/common';
import { Job } from 'bull';
import { DataGatheringService } from './data-gathering.service';
@Injectable()
@Processor(DATA_GATHERING_QUEUE)
export class DataGatheringProcessor {
public constructor(
private readonly dataGatheringService: DataGatheringService
) {}
@Process(GATHER_ASSET_PROFILE_PROCESS)
public async gatherAssetProfile(job: Job<UniqueAsset>) {
try {
await this.dataGatheringService.gatherAssetProfiles([job.data]);
} catch (error) {
Logger.error(error, 'DataGatheringProcessor');
}
}
}

View File

@ -226,31 +226,33 @@ export class DataGatheringService {
} }
} }
public async gatherProfileData(aDataGatheringItems?: IDataGatheringItem[]) { public async gatherAssetProfiles(aUniqueAssets?: UniqueAsset[]) {
Logger.log( let uniqueAssets = aUniqueAssets?.filter((dataGatheringItem) => {
'Profile data gathering has been started.', return dataGatheringItem.dataSource !== 'MANUAL';
'DataGatheringService' });
);
console.time('data-gathering-profile');
let dataGatheringItems = aDataGatheringItems?.filter( if (!uniqueAssets) {
(dataGatheringItem) => { uniqueAssets = await this.getUniqueAssets();
return dataGatheringItem.dataSource !== 'MANUAL';
}
);
if (!dataGatheringItems) {
dataGatheringItems = await this.getSymbolsProfileData();
} }
Logger.log(
`Asset profile data gathering has been started for ${uniqueAssets
.map(({ dataSource, symbol }) => {
return `${symbol} (${dataSource})`;
})
.join(',')}.`,
'DataGatheringService'
);
const assetProfiles = await this.dataProviderService.getAssetProfiles( const assetProfiles = await this.dataProviderService.getAssetProfiles(
dataGatheringItems uniqueAssets
);
const symbolProfiles = await this.symbolProfileService.getSymbolProfiles(
dataGatheringItems.map(({ symbol }) => {
return symbol;
})
); );
const symbolProfiles =
await this.symbolProfileService.getSymbolProfilesBySymbols(
uniqueAssets.map(({ symbol }) => {
return symbol;
})
);
for (const [symbol, assetProfile] of Object.entries(assetProfiles)) { for (const [symbol, assetProfile] of Object.entries(assetProfiles)) {
const symbolMapping = symbolProfiles.find((symbolProfile) => { const symbolMapping = symbolProfiles.find((symbolProfile) => {
@ -322,10 +324,13 @@ export class DataGatheringService {
} }
Logger.log( Logger.log(
'Profile data gathering has been completed.', `Asset profile data gathering has been completed for ${uniqueAssets
.map(({ dataSource, symbol }) => {
return `${symbol} (${dataSource})`;
})
.join(',')}.`,
'DataGatheringService' 'DataGatheringService'
); );
console.timeEnd('data-gathering-profile');
} }
public async gatherSymbols(aSymbolsWithStartDate: IDataGatheringItem[]) { public async gatherSymbols(aSymbolsWithStartDate: IDataGatheringItem[]) {
@ -508,6 +513,27 @@ export class DataGatheringService {
return [...currencyPairsToGather, ...symbolProfilesToGather]; return [...currencyPairsToGather, ...symbolProfilesToGather];
} }
public async getUniqueAssets(): Promise<UniqueAsset[]> {
const symbolProfiles = await this.prismaService.symbolProfile.findMany({
orderBy: [{ symbol: 'asc' }]
});
return symbolProfiles
.filter(({ dataSource }) => {
return (
dataSource !== DataSource.GHOSTFOLIO &&
dataSource !== DataSource.MANUAL &&
dataSource !== DataSource.RAKUTEN
);
})
.map(({ dataSource, symbol }) => {
return {
dataSource,
symbol
};
});
}
public async reset() { public async reset() {
Logger.log('Data gathering has been reset.', 'DataGatheringService'); Logger.log('Data gathering has been reset.', 'DataGatheringService');
@ -584,27 +610,6 @@ export class DataGatheringService {
return [...currencyPairsToGather, ...symbolProfilesToGather]; return [...currencyPairsToGather, ...symbolProfilesToGather];
} }
private async getSymbolsProfileData(): Promise<IDataGatheringItem[]> {
const symbolProfiles = await this.prismaService.symbolProfile.findMany({
orderBy: [{ symbol: 'asc' }]
});
return symbolProfiles
.filter((symbolProfile) => {
return (
symbolProfile.dataSource !== DataSource.GHOSTFOLIO &&
symbolProfile.dataSource !== DataSource.MANUAL &&
symbolProfile.dataSource !== DataSource.RAKUTEN
);
})
.map((symbolProfile) => {
return {
dataSource: symbolProfile.dataSource,
symbol: symbolProfile.symbol
};
});
}
private async isDataGatheringNeeded() { private async isDataGatheringNeeded() {
const lastDataGathering = await this.getLastDataGathering(); const lastDataGathering = await this.getLastDataGathering();

View File

@ -32,7 +32,7 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
return response; return response;
} }
const holdings = await getJSON( const result = await getJSON(
`${TrackinsightDataEnhancerService.baseUrl}/${symbol}.json` `${TrackinsightDataEnhancerService.baseUrl}/${symbol}.json`
).catch(() => { ).catch(() => {
return getJSON( return getJSON(
@ -42,12 +42,17 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
); );
}); });
if (result.weight < 0.95) {
// Skip if data is inaccurate
return response;
}
if ( if (
!response.countries || !response.countries ||
(response.countries as unknown as Country[]).length === 0 (response.countries as unknown as Country[]).length === 0
) { ) {
response.countries = []; response.countries = [];
for (const [name, value] of Object.entries<any>(holdings.countries)) { for (const [name, value] of Object.entries<any>(result.countries)) {
let countryCode: string; let countryCode: string;
for (const [key, country] of Object.entries<any>( for (const [key, country] of Object.entries<any>(
@ -75,7 +80,7 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
(response.sectors as unknown as Sector[]).length === 0 (response.sectors as unknown as Sector[]).length === 0
) { ) {
response.sectors = []; response.sectors = [];
for (const [name, value] of Object.entries<any>(holdings.sectors)) { for (const [name, value] of Object.entries<any>(result.sectors)) {
response.sectors.push({ response.sectors.push({
name: TrackinsightDataEnhancerService.sectorsMapping[name] ?? name, name: TrackinsightDataEnhancerService.sectorsMapping[name] ?? name,
weight: value.weight weight: value.weight

View File

@ -1,5 +1,7 @@
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { CryptocurrencyModule } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.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 { 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 { GoogleSheetsService } from '@ghostfolio/api/services/data-provider/google-sheets/google-sheets.service';
import { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.service'; import { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.service';
@ -9,7 +11,6 @@ import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module'; import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { AlphaVantageService } from './alpha-vantage/alpha-vantage.service';
import { DataProviderService } from './data-provider.service'; import { DataProviderService } from './data-provider.service';
@Module({ @Module({
@ -22,6 +23,7 @@ import { DataProviderService } from './data-provider.service';
providers: [ providers: [
AlphaVantageService, AlphaVantageService,
DataProviderService, DataProviderService,
EodHistoricalDataService,
GhostfolioScraperApiService, GhostfolioScraperApiService,
GoogleSheetsService, GoogleSheetsService,
ManualService, ManualService,
@ -30,6 +32,7 @@ import { DataProviderService } from './data-provider.service';
{ {
inject: [ inject: [
AlphaVantageService, AlphaVantageService,
EodHistoricalDataService,
GhostfolioScraperApiService, GhostfolioScraperApiService,
GoogleSheetsService, GoogleSheetsService,
ManualService, ManualService,
@ -39,6 +42,7 @@ import { DataProviderService } from './data-provider.service';
provide: 'DataProviderInterfaces', provide: 'DataProviderInterfaces',
useFactory: ( useFactory: (
alphaVantageService, alphaVantageService,
eodHistoricalDataService,
ghostfolioScraperApiService, ghostfolioScraperApiService,
googleSheetsService, googleSheetsService,
manualService, manualService,
@ -46,6 +50,7 @@ import { DataProviderService } from './data-provider.service';
yahooFinanceService yahooFinanceService
) => [ ) => [
alphaVantageService, alphaVantageService,
eodHistoricalDataService,
ghostfolioScraperApiService, ghostfolioScraperApiService,
googleSheetsService, googleSheetsService,
manualService, manualService,

View File

@ -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: [] };
}
}

View File

@ -2,8 +2,7 @@ import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.in
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface'; import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
import { import {
IDataProviderHistoricalResponse, IDataProviderHistoricalResponse,
IDataProviderResponse, IDataProviderResponse
MarketState
} from '@ghostfolio/api/services/interfaces/interfaces'; } from '@ghostfolio/api/services/interfaces/interfaces';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
@ -11,7 +10,7 @@ import { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types'; import { Granularity } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { DataSource, SymbolProfile } from '@prisma/client'; import { DataSource, SymbolProfile } from '@prisma/client';
import * as bent from 'bent'; import bent from 'bent';
import * as cheerio from 'cheerio'; import * as cheerio from 'cheerio';
import { addDays, format, isBefore } from 'date-fns'; import { addDays, format, isBefore } from 'date-fns';
@ -47,9 +46,8 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
try { try {
const symbol = aSymbol; const symbol = aSymbol;
const [symbolProfile] = await this.symbolProfileService.getSymbolProfiles( const [symbolProfile] =
[symbol] await this.symbolProfileService.getSymbolProfilesBySymbols([symbol]);
);
const { defaultMarketPrice, selector, url } = const { defaultMarketPrice, selector, url } =
symbolProfile.scraperConfiguration; symbolProfile.scraperConfiguration;
@ -109,9 +107,8 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
} }
try { try {
const symbolProfiles = await this.symbolProfileService.getSymbolProfiles( const symbolProfiles =
aSymbols await this.symbolProfileService.getSymbolProfilesBySymbols(aSymbols);
);
const marketData = await this.prismaService.marketData.findMany({ const marketData = await this.prismaService.marketData.findMany({
distinct: ['symbol'], distinct: ['symbol'],
@ -133,7 +130,7 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
marketPrice: marketData.find((marketDataItem) => { marketPrice: marketData.find((marketDataItem) => {
return marketDataItem.symbol === symbolProfile.symbol; return marketDataItem.symbol === symbolProfile.symbol;
}).marketPrice, }).marketPrice,
marketState: MarketState.delayed marketState: 'delayed'
}; };
} }

View File

@ -3,8 +3,7 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration.ser
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface'; import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
import { import {
IDataProviderHistoricalResponse, IDataProviderHistoricalResponse,
IDataProviderResponse, IDataProviderResponse
MarketState
} from '@ghostfolio/api/services/interfaces/interfaces'; } from '@ghostfolio/api/services/interfaces/interfaces';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
@ -92,9 +91,8 @@ export class GoogleSheetsService implements DataProviderInterface {
try { try {
const response: { [symbol: string]: IDataProviderResponse } = {}; const response: { [symbol: string]: IDataProviderResponse } = {};
const symbolProfiles = await this.symbolProfileService.getSymbolProfiles( const symbolProfiles =
aSymbols await this.symbolProfileService.getSymbolProfilesBySymbols(aSymbols);
);
const sheet = await this.getSheet({ const sheet = await this.getSheet({
sheetId: this.configurationService.get('GOOGLE_SHEETS_ID'), sheetId: this.configurationService.get('GOOGLE_SHEETS_ID'),
@ -114,7 +112,7 @@ export class GoogleSheetsService implements DataProviderInterface {
return symbolProfile.symbol === symbol; return symbolProfile.symbol === symbol;
})?.currency, })?.currency,
dataSource: this.getName(), dataSource: this.getName(),
marketState: MarketState.delayed marketState: 'delayed'
}; };
} }
} }

View File

@ -3,8 +3,7 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration.ser
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface'; import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
import { import {
IDataProviderHistoricalResponse, IDataProviderHistoricalResponse,
IDataProviderResponse, IDataProviderResponse
MarketState
} from '@ghostfolio/api/services/interfaces/interfaces'; } from '@ghostfolio/api/services/interfaces/interfaces';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { ghostfolioFearAndGreedIndexSymbol } from '@ghostfolio/common/config'; import { ghostfolioFearAndGreedIndexSymbol } from '@ghostfolio/common/config';
@ -12,7 +11,7 @@ import { DATE_FORMAT, getToday, getYesterday } from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types'; import { Granularity } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { DataSource, SymbolProfile } from '@prisma/client'; import { DataSource, SymbolProfile } from '@prisma/client';
import * as bent from 'bent'; import bent from 'bent';
import { format, subMonths, subWeeks, subYears } from 'date-fns'; import { format, subMonths, subWeeks, subYears } from 'date-fns';
@Injectable() @Injectable()
@ -118,7 +117,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
currency: undefined, currency: undefined,
dataSource: this.getName(), dataSource: this.getName(),
marketPrice: fgi.now.value, marketPrice: fgi.now.value,
marketState: MarketState.open marketState: 'open'
} }
}; };
} }

View File

@ -1,3 +1,4 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service'; import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service';
import { YahooFinanceService } from './yahoo-finance.service'; import { YahooFinanceService } from './yahoo-finance.service';
@ -25,13 +26,18 @@ jest.mock(
); );
describe('YahooFinanceService', () => { describe('YahooFinanceService', () => {
let configurationService: ConfigurationService;
let cryptocurrencyService: CryptocurrencyService; let cryptocurrencyService: CryptocurrencyService;
let yahooFinanceService: YahooFinanceService; let yahooFinanceService: YahooFinanceService;
beforeAll(async () => { beforeAll(async () => {
configurationService = new ConfigurationService();
cryptocurrencyService = new CryptocurrencyService(); cryptocurrencyService = new CryptocurrencyService();
yahooFinanceService = new YahooFinanceService(cryptocurrencyService); yahooFinanceService = new YahooFinanceService(
configurationService,
cryptocurrencyService
);
}); });
it('convertFromYahooFinanceSymbol', async () => { it('convertFromYahooFinanceSymbol', async () => {

View File

@ -1,12 +1,11 @@
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface'; 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 { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service';
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface'; import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
import { import {
IDataProviderHistoricalResponse, IDataProviderHistoricalResponse,
IDataProviderResponse, IDataProviderResponse
MarketState
} from '@ghostfolio/api/services/interfaces/interfaces'; } from '@ghostfolio/api/services/interfaces/interfaces';
import { baseCurrency } from '@ghostfolio/common/config';
import { DATE_FORMAT, isCurrency } from '@ghostfolio/common/helper'; import { DATE_FORMAT, isCurrency } from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types'; import { Granularity } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
@ -24,9 +23,14 @@ import type { Price } from 'yahoo-finance2/dist/esm/src/modules/quoteSummary-ifa
@Injectable() @Injectable()
export class YahooFinanceService implements DataProviderInterface { export class YahooFinanceService implements DataProviderInterface {
private baseCurrency: string;
public constructor( public constructor(
private readonly configurationService: ConfigurationService,
private readonly cryptocurrencyService: CryptocurrencyService private readonly cryptocurrencyService: CryptocurrencyService
) {} ) {
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
}
public canHandle(symbol: string) { public canHandle(symbol: string) {
return true; return true;
@ -34,8 +38,8 @@ export class YahooFinanceService implements DataProviderInterface {
public convertFromYahooFinanceSymbol(aYahooFinanceSymbol: string) { public convertFromYahooFinanceSymbol(aYahooFinanceSymbol: string) {
const symbol = aYahooFinanceSymbol.replace( const symbol = aYahooFinanceSymbol.replace(
new RegExp(`-${baseCurrency}$`), new RegExp(`-${this.baseCurrency}$`),
baseCurrency this.baseCurrency
); );
return symbol.replace('=X', ''); return symbol.replace('=X', '');
} }
@ -48,12 +52,15 @@ export class YahooFinanceService implements DataProviderInterface {
* DOGEUSD -> DOGE-USD * DOGEUSD -> DOGE-USD
*/ */
public convertToYahooFinanceSymbol(aSymbol: string) { 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))) { if (isCurrency(aSymbol.substring(0, aSymbol.length - 3))) {
return `${aSymbol}=X`; return `${aSymbol}=X`;
} else if ( } else if (
this.cryptocurrencyService.isCryptocurrency( 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 // Add a dash before the last three characters
@ -61,8 +68,8 @@ export class YahooFinanceService implements DataProviderInterface {
// DOGEUSD -> DOGE-USD // DOGEUSD -> DOGE-USD
// SOL1USD -> SOL1-USD // SOL1USD -> SOL1-USD
return aSymbol.replace( return aSymbol.replace(
new RegExp(`-?${baseCurrency}$`), new RegExp(`-?${this.baseCurrency}$`),
`-${baseCurrency}` `-${this.baseCurrency}`
); );
} }
} }
@ -216,8 +223,8 @@ export class YahooFinanceService implements DataProviderInterface {
marketState: marketState:
quote.marketState === 'REGULAR' || quote.marketState === 'REGULAR' ||
this.cryptocurrencyService.isCryptocurrency(symbol) this.cryptocurrencyService.isCryptocurrency(symbol)
? MarketState.open ? 'open'
: MarketState.closed, : 'closed',
marketPrice: quote.regularMarketPrice || 0 marketPrice: quote.regularMarketPrice || 0
}; };
@ -256,7 +263,10 @@ export class YahooFinanceService implements DataProviderInterface {
return ( return (
(quoteType === 'CRYPTOCURRENCY' && (quoteType === 'CRYPTOCURRENCY' &&
this.cryptocurrencyService.isCryptocurrency( this.cryptocurrencyService.isCryptocurrency(
symbol.replace(new RegExp(`-${baseCurrency}$`), baseCurrency) symbol.replace(
new RegExp(`-${this.baseCurrency}$`),
this.baseCurrency
)
)) || )) ||
['EQUITY', 'ETF', 'FUTURE', 'MUTUALFUND'].includes(quoteType) ['EQUITY', 'ETF', 'FUTURE', 'MUTUALFUND'].includes(quoteType)
); );
@ -265,7 +275,7 @@ export class YahooFinanceService implements DataProviderInterface {
if (quoteType === 'CRYPTOCURRENCY') { if (quoteType === 'CRYPTOCURRENCY') {
// Only allow cryptocurrencies in base currency to avoid having redundancy in the database. // Only allow cryptocurrencies in base currency to avoid having redundancy in the database.
// Transactions need to be converted manually to the base currency before // Transactions need to be converted manually to the base currency before
return symbol.includes(baseCurrency); return symbol.includes(this.baseCurrency);
} else if (quoteType === 'FUTURE') { } else if (quoteType === 'FUTURE') {
// Allow GC=F, but not MGC=F // Allow GC=F, but not MGC=F
return symbol.length === 4; return symbol.length === 4;

View File

@ -1,12 +1,18 @@
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service'; 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 { Module } from '@nestjs/common';
import { PrismaModule } from './prisma.module'; import { PrismaModule } from './prisma.module';
import { PropertyModule } from './property/property.module';
@Module({ @Module({
imports: [DataProviderModule, PrismaModule, PropertyModule], imports: [
ConfigurationModule,
DataProviderModule,
PrismaModule,
PropertyModule
],
providers: [ExchangeRateDataService], providers: [ExchangeRateDataService],
exports: [ExchangeRateDataService] exports: [ExchangeRateDataService]
}) })

View File

@ -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 { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { isNumber, uniq } from 'lodash'; import { isNumber, uniq } from 'lodash';
import { ConfigurationService } from './configuration.service';
import { DataProviderService } from './data-provider/data-provider.service'; import { DataProviderService } from './data-provider/data-provider.service';
import { IDataGatheringItem } from './interfaces/interfaces'; import { IDataGatheringItem } from './interfaces/interfaces';
import { PrismaService } from './prisma.service'; import { PrismaService } from './prisma.service';
@ -11,11 +12,13 @@ import { PropertyService } from './property/property.service';
@Injectable() @Injectable()
export class ExchangeRateDataService { export class ExchangeRateDataService {
private baseCurrency: string;
private currencies: string[] = []; private currencies: string[] = [];
private currencyPairs: IDataGatheringItem[] = []; private currencyPairs: IDataGatheringItem[] = [];
private exchangeRates: { [currencyPair: string]: number } = {}; private exchangeRates: { [currencyPair: string]: number } = {};
public constructor( public constructor(
private readonly configurationService: ConfigurationService,
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService private readonly propertyService: PropertyService
@ -24,7 +27,7 @@ export class ExchangeRateDataService {
} }
public getCurrencies() { public getCurrencies() {
return this.currencies?.length > 0 ? this.currencies : [baseCurrency]; return this.currencies?.length > 0 ? this.currencies : [this.baseCurrency];
} }
public getCurrencyPairs() { public getCurrencyPairs() {
@ -32,6 +35,7 @@ export class ExchangeRateDataService {
} }
public async initialize() { public async initialize() {
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
this.currencies = await this.prepareCurrencies(); this.currencies = await this.prepareCurrencies();
this.currencyPairs = []; this.currencyPairs = [];
this.exchangeRates = {}; this.exchangeRates = {};
@ -212,14 +216,14 @@ export class ExchangeRateDataService {
private prepareCurrencyPairs(aCurrencies: string[]) { private prepareCurrencyPairs(aCurrencies: string[]) {
return aCurrencies return aCurrencies
.filter((currency) => { .filter((currency) => {
return currency !== baseCurrency; return currency !== this.baseCurrency;
}) })
.map((currency) => { .map((currency) => {
return { return {
currency1: baseCurrency, currency1: this.baseCurrency,
currency2: currency, currency2: currency,
dataSource: this.dataProviderService.getPrimaryDataSource(), dataSource: this.dataProviderService.getPrimaryDataSource(),
symbol: `${baseCurrency}${currency}` symbol: `${this.baseCurrency}${currency}`
}; };
}); });
} }

View File

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

View File

@ -1,18 +1,11 @@
import { MarketState } from '@ghostfolio/common/types';
import { import {
Account, Account,
AssetClass,
AssetSubClass,
DataSource, DataSource,
SymbolProfile, SymbolProfile,
Type as TypeOfOrder Type as TypeOfOrder
} from '@prisma/client'; } from '@prisma/client';
export const MarketState = {
closed: 'closed',
delayed: 'delayed',
open: 'open'
};
export interface IOrder { export interface IOrder {
account: Account; account: Account;
currency: string; currency: string;
@ -44,5 +37,3 @@ export interface IDataGatheringItem {
date?: Date; date?: Date;
symbol: string; symbol: string;
} }
export type MarketState = typeof MarketState[keyof typeof MarketState];

View File

@ -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({ public async getRange({
dateQuery, dateQuery,
symbols symbols

View File

@ -1,6 +1,10 @@
import { EnhancedSymbolProfile } from '@ghostfolio/api/services/interfaces/symbol-profile.interface';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { UNKNOWN_KEY } from '@ghostfolio/common/config'; import { UNKNOWN_KEY } from '@ghostfolio/common/config';
import {
EnhancedSymbolProfile,
ScraperConfiguration,
UniqueAsset
} from '@ghostfolio/common/interfaces';
import { Country } from '@ghostfolio/common/interfaces/country.interface'; import { Country } from '@ghostfolio/common/interfaces/country.interface';
import { Sector } from '@ghostfolio/common/interfaces/sector.interface'; import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
@ -12,8 +16,6 @@ import {
} from '@prisma/client'; } from '@prisma/client';
import { continents, countries } from 'countries-list'; import { continents, countries } from 'countries-list';
import { ScraperConfiguration } from './data-provider/ghostfolio-scraper-api/interfaces/scraper-configuration.interface';
@Injectable() @Injectable()
export class SymbolProfileService { export class SymbolProfileService {
public constructor(private readonly prismaService: PrismaService) {} public constructor(private readonly prismaService: PrismaService) {}
@ -37,6 +39,35 @@ export class SymbolProfileService {
} }
public async getSymbolProfiles( 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[] symbols: string[]
): Promise<EnhancedSymbolProfile[]> { ): Promise<EnhancedSymbolProfile[]> {
return this.prismaService.symbolProfile return this.prismaService.symbolProfile
@ -59,7 +90,9 @@ export class SymbolProfileService {
return symbolProfiles.map((symbolProfile) => { return symbolProfiles.map((symbolProfile) => {
const item = { const item = {
...symbolProfile, ...symbolProfile,
countries: this.getCountries(symbolProfile), countries: this.getCountries(
symbolProfile?.countries as unknown as Prisma.JsonArray
),
scraperConfiguration: this.getScraperConfiguration(symbolProfile), scraperConfiguration: this.getScraperConfiguration(symbolProfile),
sectors: this.getSectors(symbolProfile), sectors: this.getSectors(symbolProfile),
symbolMapping: this.getSymbolMapping(symbolProfile) symbolMapping: this.getSymbolMapping(symbolProfile)
@ -70,9 +103,17 @@ export class SymbolProfileService {
item.SymbolProfileOverrides.assetClass ?? item.assetClass; item.SymbolProfileOverrides.assetClass ?? item.assetClass;
item.assetSubClass = item.assetSubClass =
item.SymbolProfileOverrides.assetSubClass ?? item.assetSubClass; item.SymbolProfileOverrides.assetSubClass ?? item.assetSubClass;
item.countries =
(item.SymbolProfileOverrides.sectors as unknown as Country[]) ?? if (
item.countries; (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.name = item.SymbolProfileOverrides?.name ?? item.name;
item.sectors = item.sectors =
(item.SymbolProfileOverrides.sectors as unknown as Sector[]) ?? (item.SymbolProfileOverrides.sectors as unknown as Sector[]) ??
@ -85,20 +126,22 @@ export class SymbolProfileService {
}); });
} }
private getCountries(symbolProfile: SymbolProfile): Country[] { private getCountries(aCountries: Prisma.JsonArray = []): Country[] {
return ((symbolProfile?.countries as Prisma.JsonArray) ?? []).map( if (aCountries === null) {
(country) => { return [];
const { code, weight } = country as Prisma.JsonObject; }
return { return aCountries.map((country: Pick<Country, 'code' | 'weight'>) => {
code: code as string, const { code, weight } = country;
continent:
continents[countries[code as string]?.continent] ?? UNKNOWN_KEY, return {
name: countries[code as string]?.name ?? UNKNOWN_KEY, code,
weight: weight as number weight,
}; continent:
} continents[countries[code as string]?.continent] ?? UNKNOWN_KEY,
); name: countries[code as string]?.name ?? UNKNOWN_KEY
};
});
} }
private getScraperConfiguration( private getScraperConfiguration(

View File

@ -1,11 +1,13 @@
import { BenchmarkModule } from '@ghostfolio/api/app/benchmark/benchmark.module';
import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module'; import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.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 { TwitterBotService } from '@ghostfolio/api/services/twitter-bot/twitter-bot.service';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
@Module({ @Module({
exports: [TwitterBotService], exports: [TwitterBotService],
imports: [ConfigurationModule, SymbolModule], imports: [BenchmarkModule, ConfigurationModule, PropertyModule, SymbolModule],
providers: [TwitterBotService] providers: [TwitterBotService]
}) })
export class TwitterBotModule {} export class TwitterBotModule {}

View File

@ -1,12 +1,19 @@
import { BenchmarkService } from '@ghostfolio/api/app/benchmark/benchmark.service';
import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service'; import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { import {
PROPERTY_BENCHMARKS,
ghostfolioFearAndGreedIndexDataSource, ghostfolioFearAndGreedIndexDataSource,
ghostfolioFearAndGreedIndexSymbol ghostfolioFearAndGreedIndexSymbol
} from '@ghostfolio/common/config'; } 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 { Injectable, Logger } from '@nestjs/common';
import { isSunday } from 'date-fns'; import { isWeekend } from 'date-fns';
import { TwitterApi, TwitterApiReadWrite } from 'twitter-api-v2'; import { TwitterApi, TwitterApiReadWrite } from 'twitter-api-v2';
@Injectable() @Injectable()
@ -14,7 +21,9 @@ export class TwitterBotService {
private twitterClient: TwitterApiReadWrite; private twitterClient: TwitterApiReadWrite;
public constructor( public constructor(
private readonly benchmarkService: BenchmarkService,
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly propertyService: PropertyService,
private readonly symbolService: SymbolService private readonly symbolService: SymbolService
) { ) {
this.twitterClient = new TwitterApi({ this.twitterClient = new TwitterApi({
@ -30,7 +39,7 @@ export class TwitterBotService {
public async tweetFearAndGreedIndex() { public async tweetFearAndGreedIndex() {
if ( if (
!this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX') || !this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX') ||
isSunday(new Date()) isWeekend(new Date())
) { ) {
return; return;
} }
@ -48,7 +57,16 @@ export class TwitterBotService {
symbolItem.marketPrice 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( const { data: createdTweet } = await this.twitterClient.v2.tweet(
status status
); );
@ -62,4 +80,35 @@ export class TwitterBotService {
Logger.error(error, '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');
}
} }

View File

@ -6,6 +6,6 @@
"emitDecoratorMetadata": true, "emitDecoratorMetadata": true,
"target": "es2015" "target": "es2015"
}, },
"exclude": ["**/*.spec.ts", "**/*.test.ts"], "exclude": ["**/*.spec.ts", "**/*.test.ts", "jest.config.ts"],
"include": ["**/*.ts"] "include": ["**/*.ts"]
} }

View File

@ -5,5 +5,5 @@
"module": "commonjs", "module": "commonjs",
"types": ["jest", "node"] "types": ["jest", "node"]
}, },
"include": ["**/*.spec.ts", "**/*.test.ts", "**/*.d.ts"] "include": ["**/*.spec.ts", "**/*.test.ts", "**/*.d.ts", "jest.config.ts"]
} }

View File

@ -1,6 +1,6 @@
module.exports = { module.exports = {
displayName: 'client', displayName: 'client',
preset: '../../jest.preset.js',
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'], setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
globals: { globals: {
'ts-jest': { 'ts-jest': {
@ -17,5 +17,6 @@ module.exports = {
transform: { transform: {
'^.+.(ts|mjs|js|html)$': 'jest-preset-angular' '^.+.(ts|mjs|js|html)$': 'jest-preset-angular'
}, },
transformIgnorePatterns: ['node_modules/(?!.*.mjs$)'] transformIgnorePatterns: ['node_modules/(?!.*.mjs$)'],
preset: '../../jest.preset.ts'
}; };

View File

@ -1,20 +1,28 @@
import { Platform } from '@angular/cdk/platform'; import { Platform } from '@angular/cdk/platform';
import { Inject, forwardRef } from '@angular/core'; import { Inject, forwardRef } from '@angular/core';
import { MAT_DATE_LOCALE, NativeDateAdapter } from '@angular/material/core'; import { MAT_DATE_LOCALE, NativeDateAdapter } from '@angular/material/core';
import { format, isValid } from 'date-fns'; import { getDateFormatString } from '@ghostfolio/common/helper';
import * as deDateFnsLocale from 'date-fns/locale/de/index'; import { format, parse } from 'date-fns';
export class CustomDateAdapter extends NativeDateAdapter { export class CustomDateAdapter extends NativeDateAdapter {
/** /**
* @constructor * @constructor
*/ */
public constructor( public constructor(
@Inject(MAT_DATE_LOCALE) public locale: string,
@Inject(forwardRef(() => MAT_DATE_LOCALE)) matDateLocale: string, @Inject(forwardRef(() => MAT_DATE_LOCALE)) matDateLocale: string,
platform: Platform platform: Platform
) { ) {
super(matDateLocale, platform); super(matDateLocale, platform);
} }
/**
* Formats a date as a string
*/
public format(aDate: Date, aParseFormat: string): string {
return format(aDate, getDateFormatString(this.locale));
}
/** /**
* Sets the first day of the week to Monday * Sets the first day of the week to Monday
*/ */
@ -22,44 +30,10 @@ export class CustomDateAdapter extends NativeDateAdapter {
return 1; return 1;
} }
/**
* Formats a date as a string according to the given format
*/
public format(aDate: Date, aParseFormat: string): string {
return format(aDate, aParseFormat, {
locale: <any>deDateFnsLocale
});
}
/** /**
* Parses a date from a provided value * Parses a date from a provided value
*/ */
public parse(aValue: any): Date { public parse(aValue: string): Date {
let date: Date; return parse(aValue, getDateFormatString(this.locale), new Date());
try {
// TODO
// Native date parser from the following formats:
// - 'd.M.yyyy'
// - 'dd.MM.yyyy'
// https://github.com/you-dont-need/You-Dont-Need-Momentjs#string--date-format
const datePattern = /^(\d{1,2}).(\d{1,2}).(\d{4})$/;
const [, day, month, year] = datePattern.exec(aValue);
date = new Date(
parseInt(year, 10),
parseInt(month, 10) - 1, // monthIndex
parseInt(day, 10)
);
} catch (error) {
} finally {
const isDateValid = date && isValid(date);
if (isDateValid) {
return date;
}
return null;
}
} }
} }

View File

@ -200,7 +200,7 @@
</button> </button>
<button <button
mat-menu-item mat-menu-item
[disabled]="element.isDefault || element.Order?.length > 0" [disabled]="element.isDefault || element.transactionCount > 0"
(click)="onDeleteAccount(element.id)" (click)="onDeleteAccount(element.id)"
> >
<ion-icon class="mr-2" name="trash-outline"></ion-icon> <ion-icon class="mr-2" name="trash-outline"></ion-icon>

View File

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

View File

@ -8,11 +8,13 @@ import {
Output Output
} from '@angular/core'; } from '@angular/core';
import { MatDialog } from '@angular/material/dialog'; import { MatDialog } from '@angular/material/dialog';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { import {
DATE_FORMAT, DATE_FORMAT,
getDateFormatString, getDateFormatString,
getLocale getLocale
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
import { User } from '@ghostfolio/common/interfaces';
import { LineChartItem } from '@ghostfolio/ui/line-chart/interfaces/line-chart.interface'; import { LineChartItem } from '@ghostfolio/ui/line-chart/interfaces/line-chart.interface';
import { DataSource, MarketData } from '@prisma/client'; import { DataSource, MarketData } from '@prisma/client';
import { import {
@ -53,14 +55,24 @@ export class AdminMarketDataDetailComponent implements OnChanges, OnInit {
[day: string]: Pick<MarketData, 'date' | 'marketPrice'> & { day: number }; [day: string]: Pick<MarketData, 'date' | 'marketPrice'> & { day: number };
}; };
} = {}; } = {};
public user: User;
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
public constructor( public constructor(
private deviceService: DeviceDetectorService, private deviceService: DeviceDetectorService,
private dialog: MatDialog private dialog: MatDialog,
private userService: UserService
) { ) {
this.deviceType = this.deviceService.getDeviceInfo().deviceType; this.deviceType = this.deviceService.getDeviceInfo().deviceType;
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((state) => {
if (state?.user) {
this.user = state.user;
}
});
} }
public ngOnInit() {} public ngOnInit() {}
@ -145,7 +157,8 @@ export class AdminMarketDataDetailComponent implements OnChanges, OnInit {
date, date,
marketPrice, marketPrice,
dataSource: this.dataSource, dataSource: this.dataSource,
symbol: this.symbol symbol: this.symbol,
user: this.user
}, },
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh', height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
width: this.deviceType === 'mobile' ? '100vw' : '50rem' width: this.deviceType === 'mobile' ? '100vw' : '50rem'

View File

@ -1,3 +1,4 @@
import { User } from '@ghostfolio/common/interfaces';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
export interface MarketDataDetailDialogParams { export interface MarketDataDetailDialogParams {
@ -5,4 +6,5 @@ export interface MarketDataDetailDialogParams {
date: Date; date: Date;
marketPrice: number; marketPrice: number;
symbol: string; symbol: string;
user: User;
} }

View File

@ -5,6 +5,7 @@ import {
Inject, Inject,
OnDestroy OnDestroy
} from '@angular/core'; } from '@angular/core';
import { DateAdapter, MAT_DATE_LOCALE } from '@angular/material/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { AdminService } from '@ghostfolio/client/services/admin.service'; import { AdminService } from '@ghostfolio/client/services/admin.service';
import { Subject, takeUntil } from 'rxjs'; import { Subject, takeUntil } from 'rxjs';
@ -24,11 +25,16 @@ export class MarketDataDetailDialog implements OnDestroy {
public constructor( public constructor(
private adminService: AdminService, private adminService: AdminService,
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
@Inject(MAT_DIALOG_DATA) public data: MarketDataDetailDialogParams,
private dateAdapter: DateAdapter<any>,
public dialogRef: MatDialogRef<MarketDataDetailDialog>, public dialogRef: MatDialogRef<MarketDataDetailDialog>,
@Inject(MAT_DIALOG_DATA) public data: MarketDataDetailDialogParams @Inject(MAT_DATE_LOCALE) private locale: string
) {} ) {}
public ngOnInit() {} public ngOnInit() {
this.locale = this.data.user?.settings?.locale;
this.dateAdapter.setLocale(this.locale);
}
public onCancel(): void { public onCancel(): void {
this.dialogRef.close({ withRefresh: false }); this.dialogRef.close({ withRefresh: false });

View File

@ -18,6 +18,8 @@ import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators'; import { takeUntil } from 'rxjs/operators';
import { PositionDetailDialogParams } from '../position/position-detail-dialog/interfaces/interfaces';
@Component({ @Component({
selector: 'gf-home-holdings', selector: 'gf-home-holdings',
styleUrls: ['./home-holdings.scss'], styleUrls: ['./home-holdings.scss'],
@ -126,12 +128,16 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
const dialogRef = this.dialog.open(PositionDetailDialog, { const dialogRef = this.dialog.open(PositionDetailDialog, {
autoFocus: false, autoFocus: false,
data: { data: <PositionDetailDialogParams>{
dataSource, dataSource,
symbol, symbol,
baseCurrency: this.user?.settings?.baseCurrency, baseCurrency: this.user?.settings?.baseCurrency,
deviceType: this.deviceType, deviceType: this.deviceType,
hasImpersonationId: this.hasImpersonationId, hasImpersonationId: this.hasImpersonationId,
hasPermissionToReportDataGlitch: hasPermission(
this.user?.permissions,
permissions.reportDataGlitch
),
locale: this.user?.settings?.locale locale: this.user?.settings?.locale
}, },
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh', height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',

View File

@ -1,10 +1,14 @@
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { HistoricalDataItem } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position-detail.interface';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { ghostfolioFearAndGreedIndexSymbol } from '@ghostfolio/common/config'; import { ghostfolioFearAndGreedIndexSymbol } from '@ghostfolio/common/config';
import { resetHours } from '@ghostfolio/common/helper'; import { resetHours } from '@ghostfolio/common/helper';
import { InfoItem, User } from '@ghostfolio/common/interfaces'; import {
Benchmark,
HistoricalDataItem,
InfoItem,
User
} from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators'; import { takeUntil } from 'rxjs/operators';
@ -15,6 +19,7 @@ import { takeUntil } from 'rxjs/operators';
templateUrl: './home-market.html' templateUrl: './home-market.html'
}) })
export class HomeMarketComponent implements OnDestroy, OnInit { export class HomeMarketComponent implements OnDestroy, OnInit {
public benchmarks: Benchmark[];
public fearAndGreedIndex: number; public fearAndGreedIndex: number;
public hasPermissionToAccessFearAndGreedIndex: boolean; public hasPermissionToAccessFearAndGreedIndex: boolean;
public historicalData: HistoricalDataItem[]; public historicalData: HistoricalDataItem[];
@ -70,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(); this.changeDetectorRef.markForCheck();
} }
}); });

View File

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

View File

@ -1,6 +1,7 @@
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { GfFearAndGreedIndexModule } from '@ghostfolio/client/components/fear-and-greed-index/fear-and-greed-index.module'; 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 { GfLineChartModule } from '@ghostfolio/ui/line-chart/line-chart.module';
import { HomeMarketComponent } from './home-market.component'; import { HomeMarketComponent } from './home-market.component';
@ -8,7 +9,12 @@ import { HomeMarketComponent } from './home-market.component';
@NgModule({ @NgModule({
declarations: [HomeMarketComponent], declarations: [HomeMarketComponent],
exports: [], exports: [],
imports: [CommonModule, GfFearAndGreedIndexModule, GfLineChartModule], imports: [
CommonModule,
GfBenchmarkModule,
GfFearAndGreedIndexModule,
GfLineChartModule
],
providers: [], providers: [],
schemas: [CUSTOM_ELEMENTS_SCHEMA] schemas: [CUSTOM_ELEMENTS_SCHEMA]
}) })

View File

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

View File

@ -6,11 +6,18 @@ import {
Input, Input,
OnChanges, OnChanges,
OnDestroy, OnDestroy,
OnInit,
ViewChild ViewChild
} from '@angular/core'; } from '@angular/core';
import {
getTooltipOptions,
getTooltipPositionerMapTop,
getVerticalHoverLinePlugin
} from '@ghostfolio/common/chart-helper';
import { primaryColorRgb } from '@ghostfolio/common/config'; import { primaryColorRgb } from '@ghostfolio/common/config';
import { import {
getBackgroundColor,
getDateFormatString,
getTextColor,
parseDate, parseDate,
transformTickToAbbreviation transformTickToAbbreviation
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
@ -21,7 +28,8 @@ import {
LineElement, LineElement,
LinearScale, LinearScale,
PointElement, PointElement,
TimeScale TimeScale,
Tooltip
} from 'chart.js'; } from 'chart.js';
import { addDays, isAfter, parseISO, subDays } from 'date-fns'; 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'] styleUrls: ['./investment-chart.component.scss']
}) })
export class InvestmentChartComponent implements OnChanges, OnDestroy { export class InvestmentChartComponent implements OnChanges, OnDestroy {
@Input() currency: string;
@Input() daysInMarket: number; @Input() daysInMarket: number;
@Input() investments: InvestmentItem[]; @Input() investments: InvestmentItem[];
@Input() isInPercent = false; @Input() isInPercent = false;
@Input() locale: string;
@ViewChild('chartCanvas') chartCanvas; @ViewChild('chartCanvas') chartCanvas;
@ -47,8 +57,12 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
LineController, LineController,
LineElement, LineElement,
PointElement, PointElement,
TimeScale TimeScale,
Tooltip
); );
Tooltip.positioners['top'] = (elements, position) =>
getTooltipPositionerMapTop(this.chart, position);
} }
public ngOnChanges() { public ngOnChanges() {
@ -98,6 +112,7 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
data: this.investments.map((position) => { data: this.investments.map((position) => {
return position.investment; return position.investment;
}), }),
label: 'Investment',
segment: { segment: {
borderColor: (context: unknown) => borderColor: (context: unknown) =>
this.isInFuture( this.isInFuture(
@ -114,6 +129,9 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
if (this.chartCanvas) { if (this.chartCanvas) {
if (this.chart) { if (this.chart) {
this.chart.data = data; this.chart.data = data;
this.chart.options.plugins.tooltip = <unknown>(
this.getTooltipPluginConfiguration()
);
this.chart.update(); this.chart.update();
} else { } else {
this.chart = new Chart(this.chartCanvas.nativeElement, { this.chart = new Chart(this.chartCanvas.nativeElement, {
@ -124,13 +142,20 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
tension: 0 tension: 0
}, },
point: { point: {
hoverBackgroundColor: getBackgroundColor(),
hoverRadius: 2,
radius: 0 radius: 0
} }
}, },
interaction: { intersect: false, mode: 'index' },
maintainAspectRatio: true, maintainAspectRatio: true,
plugins: { plugins: <unknown>{
legend: { legend: {
display: false display: false
},
tooltip: this.getTooltipPluginConfiguration(),
verticalHoverLine: {
color: `rgba(${getTextColor()}, 0.1)`
} }
}, },
responsive: true, responsive: true,
@ -138,16 +163,21 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
x: { x: {
display: true, display: true,
grid: { grid: {
borderColor: `rgba(${getTextColor()}, 0.1)`,
color: `rgba(${getTextColor()}, 0.8)`,
display: false display: false
}, },
type: 'time', type: 'time',
time: { time: {
tooltipFormat: getDateFormatString(this.locale),
unit: 'year' unit: 'year'
} }
}, },
y: { y: {
display: !this.isInPercent, display: !this.isInPercent,
grid: { grid: {
borderColor: `rgba(${getTextColor()}, 0.1)`,
color: `rgba(${getTextColor()}, 0.8)`,
display: false display: false
}, },
ticks: { ticks: {
@ -161,6 +191,7 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
} }
} }
}, },
plugins: [getVerticalHoverLinePlugin(this.chartCanvas)],
type: 'line' 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) { private isInFuture<T>(aContext: any, aValue: T) {
return isAfter(new Date(aContext?.p1?.parsed?.x), new Date()) return isAfter(new Date(aContext?.p1?.parsed?.x), new Date())
? aValue ? aValue

View File

@ -5,6 +5,7 @@ export interface PositionDetailDialogParams {
dataSource: DataSource; dataSource: DataSource;
deviceType: string; deviceType: string;
hasImpersonationId: boolean; hasImpersonationId: boolean;
hasPermissionToReportDataGlitch: boolean;
locale: string; locale: string;
symbol: string; symbol: string;
} }

View File

@ -9,9 +9,10 @@ import {
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { DATE_FORMAT, downloadAsFile } from '@ghostfolio/common/helper'; import { DATE_FORMAT, downloadAsFile } from '@ghostfolio/common/helper';
import { EnhancedSymbolProfile } from '@ghostfolio/common/interfaces';
import { OrderWithAccount } from '@ghostfolio/common/types'; import { OrderWithAccount } from '@ghostfolio/common/types';
import { LineChartItem } from '@ghostfolio/ui/line-chart/interfaces/line-chart.interface'; 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 { format, isSameMonth, isToday, parseISO } from 'date-fns';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators'; import { takeUntil } from 'rxjs/operators';
@ -44,10 +45,11 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
public orders: OrderWithAccount[]; public orders: OrderWithAccount[];
public quantity: number; public quantity: number;
public quantityPrecision = 2; public quantityPrecision = 2;
public reportDataGlitchMail: string;
public sectors: { public sectors: {
[name: string]: { name: string; value: number }; [name: string]: { name: string; value: number };
}; };
public SymbolProfile: SymbolProfile; public SymbolProfile: EnhancedSymbolProfile;
public tags: Tag[]; public tags: Tag[];
public transactionCount: number; public transactionCount: number;
public value: number; public value: number;
@ -91,6 +93,7 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
this.averagePrice = averagePrice; this.averagePrice = averagePrice;
this.benchmarkDataItems = []; this.benchmarkDataItems = [];
this.countries = {}; this.countries = {};
this.reportDataGlitchMail = `mailto:hi@ghostfol.io?Subject=Ghostfolio Data Glitch Report&body=Hello%0D%0DI would like to report a data glitch for%0D%0DSymbol: ${SymbolProfile?.symbol}%0DData Source: ${SymbolProfile?.dataSource}%0D%0DAdditional notes:%0D%0DCan you please take a look?%0D%0DKind regards`;
this.firstBuyDate = firstBuyDate; this.firstBuyDate = firstBuyDate;
this.grossPerformance = grossPerformance; this.grossPerformance = grossPerformance;
this.grossPerformancePercent = grossPerformancePercent; this.grossPerformancePercent = grossPerformancePercent;

View File

@ -23,7 +23,9 @@
class="mb-4" class="mb-4"
benchmarkLabel="Average Unit Price" benchmarkLabel="Average Unit Price"
[benchmarkDataItems]="benchmarkDataItems" [benchmarkDataItems]="benchmarkDataItems"
[currency]="SymbolProfile?.currency"
[historicalDataItems]="historicalDataItems" [historicalDataItems]="historicalDataItems"
[locale]="data.locale"
[showGradient]="true" [showGradient]="true"
[showXAxis]="true" [showXAxis]="true"
[showYAxis]="true" [showYAxis]="true"
@ -214,13 +216,26 @@
</div> </div>
<div *ngIf="tags?.length > 0" class="row"> <div *ngIf="tags?.length > 0" class="row">
<div class="col"> <div class="col mb-3">
<div class="h5" i18n>Tags</div> <div class="h5" i18n>Tags</div>
<mat-chip-list> <mat-chip-list>
<mat-chip *ngFor="let tag of tags">{{ tag.name }}</mat-chip> <mat-chip *ngFor="let tag of tags">{{ tag.name }}</mat-chip>
</mat-chip-list> </mat-chip-list>
</div> </div>
</div> </div>
<div
*ngIf="data.hasPermissionToReportDataGlitch === true && orders?.length > 0"
class="row"
>
<div class="col mb-3">
<hr />
<a color="warn" mat-stroked-button [href]="reportDataGlitchMail"
><ion-icon class="mr-1" name="flag-outline"></ion-icon
><span i18n>Report Data Glitch</span></a
>
</div>
</div>
</div> </div>
</div> </div>

View File

@ -73,11 +73,6 @@ export class PositionsTableComponent implements OnChanges, OnDestroy, OnInit {
} }
} }
/*public applyFilter(event: Event) {
const filterValue = (event.target as HTMLInputElement).value;
this.dataSource.filter = filterValue.trim().toLowerCase();
}*/
public onOpenPositionDialog({ dataSource, symbol }: UniqueAsset): void { public onOpenPositionDialog({ dataSource, symbol }: UniqueAsset): void {
this.router.navigate([], { this.router.navigate([], {
queryParams: { dataSource, symbol, positionDetailDialog: true } queryParams: { dataSource, symbol, positionDetailDialog: true }

View File

@ -5,7 +5,6 @@ import {
OnChanges, OnChanges,
OnInit OnInit
} from '@angular/core'; } from '@angular/core';
import { MarketState } from '@ghostfolio/api/services/interfaces/interfaces';
import { Position } from '@ghostfolio/common/interfaces'; import { Position } from '@ghostfolio/common/interfaces';
@Component({ @Component({
@ -42,10 +41,7 @@ export class PositionsComponent implements OnChanges, OnInit {
this.positionsWithPriority = []; this.positionsWithPriority = [];
for (const portfolioPosition of this.positions) { for (const portfolioPosition of this.positions) {
if ( if (portfolioPosition.marketState === 'open' || this.range !== '1d') {
portfolioPosition.marketState === MarketState.open ||
this.range !== '1d'
) {
// Only show positions where the market is open in today's view // Only show positions where the market is open in today's view
this.positionsWithPriority.push(portfolioPosition); this.positionsWithPriority.push(portfolioPosition);
} else { } else {

View File

@ -1,7 +1,6 @@
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { baseCurrency } from '@ghostfolio/common/config';
import { User } from '@ghostfolio/common/interfaces'; import { User } from '@ghostfolio/common/interfaces';
import { Statistics } from '@ghostfolio/common/interfaces/statistics.interface'; import { Statistics } from '@ghostfolio/common/interfaces/statistics.interface';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
@ -17,12 +16,10 @@ import { environment } from '../../../environments/environment';
templateUrl: './about-page.html' templateUrl: './about-page.html'
}) })
export class AboutPageComponent implements OnDestroy, OnInit { export class AboutPageComponent implements OnDestroy, OnInit {
public baseCurrency = baseCurrency;
public hasPermissionForBlog: boolean; public hasPermissionForBlog: boolean;
public hasPermissionForStatistics: boolean; public hasPermissionForStatistics: boolean;
public hasPermissionForSubscription: boolean; public hasPermissionForSubscription: boolean;
public isLoggedIn: boolean; public isLoggedIn: boolean;
public lastPublish = environment.lastPublish;
public statistics: Statistics; public statistics: Statistics;
public user: User; public user: User;
public version = environment.version; public version = environment.version;

View File

@ -2,103 +2,101 @@
<div class="mb-5 row"> <div class="mb-5 row">
<div class="col"> <div class="col">
<h3 class="d-flex justify-content-center mb-3" i18n>About Ghostfolio</h3> <h3 class="d-flex justify-content-center mb-3" i18n>About Ghostfolio</h3>
<mat-card class="about-container"> <div class="about-container">
<mat-card-content> <p>
<p> <strong>Ghostfolio</strong> is a lightweight wealth management
<strong>Ghostfolio</strong> is a lightweight wealth management application for individuals to keep track of stocks, ETFs or
application for individuals to keep track of their wealth like cryptocurrencies and make solid, data-driven investment decisions. The
stocks, ETFs or cryptocurrencies and make solid, data-driven source code is fully available as open source software (OSS). The
investment decisions. The source code is fully available as open project has been initiated by
source software (OSS). The project has been initiated by <a href="https://dotsilver.ch" title="Website of Thomas Kaul"
<a href="https://dotsilver.ch" title="Website of Thomas Kaul" >Thomas Kaul</a
>Thomas Kaul</a
>
and is driven by the efforts of its
<a
href="https://github.com/ghostfolio/ghostfolio/graphs/contributors"
title="Contributors to Ghostfolio"
>contributors</a
>.
<ng-container *ngIf="lastPublish">
This instance is running Ghostfolio {{ version }} and has been
last published on {{ lastPublish }}.
</ng-container>
<ng-container *ngIf="hasPermissionForStatistics" i18n
>Check the system status at
<a href="https://status.ghostfol.io" title="Ghostfolio status"
>status.ghostfol.io</a
>.</ng-container
>
</p>
<p>
If you encounter a bug or would like to suggest an improvement or a
new <a [routerLink]="['/features']">feature</a>, please join the
Ghostfolio
<a
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
title="Join the Ghostfolio Slack community"
>Slack community</a
>, tweet to
<a
href="https://twitter.com/ghostfolio_"
title="Tweet to Ghostfolio on Twitter"
>@ghostfolio_</a
>, send an e-mail to
<a href="mailto:hi@ghostfol.io" title="Send an e-mail"
>hi@ghostfol.io</a
>
or open an issue at
<a
href="https://github.com/ghostfolio/ghostfolio"
title="Find Ghostfolio on GitHub"
>GitHub</a
>.
</p>
<p class="text-center">
<a
class="mx-2"
href="https://twitter.com/ghostfolio_"
mat-icon-button
title="Follow Ghostfolio on Twitter"
>
<ion-icon name="logo-twitter" size="large"></ion-icon>
</a>
<a
class="mx-2"
href="mailto:hi@ghostfol.io"
mat-icon-button
title="Send an e-mail"
>
<ion-icon name="mail" size="large"></ion-icon>
</a>
<a
class="mx-2"
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
mat-icon-button
title="Join the Ghostfolio Slack channel"
>
<ion-icon name="logo-slack" size="large"></ion-icon>
</a>
<a
class="mx-2"
href="https://github.com/ghostfolio/ghostfolio"
mat-icon-button
title="Find Ghostfolio on GitHub"
>
<ion-icon name="logo-github" size="large"></ion-icon>
</a>
</p>
<div
*ngIf="hasPermissionForSubscription"
class="d-flex justify-content-center"
> >
<div and is driven by the efforts of its
class="independent-and-bootstrapped-logo mb-2" <a
title="Ghostfolio is an independent & bootstrapped business" href="https://github.com/ghostfolio/ghostfolio/graphs/contributors"
></div> title="Contributors to Ghostfolio"
</div> >contributors</a
</mat-card-content> >.
</mat-card> <ng-container *ngIf="version">
This instance is running Ghostfolio {{ version }}.
</ng-container>
<ng-container *ngIf="hasPermissionForStatistics" i18n
>Check the system status at
<a href="https://status.ghostfol.io" title="Ghostfolio status"
>status.ghostfol.io</a
>.</ng-container
>
</p>
<p>
If you encounter a bug or would like to suggest an improvement or a
new
<a [routerLink]="['/features']">feature</a>, please join the
Ghostfolio
<a
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
title="Join the Ghostfolio Slack community"
>Slack community</a
>, tweet to
<a
href="https://twitter.com/ghostfolio_"
title="Tweet to Ghostfolio on Twitter"
>@ghostfolio_</a
>, send an e-mail to
<a href="mailto:hi@ghostfol.io" title="Send an e-mail"
>hi@ghostfol.io</a
>
or open an issue at
<a
href="https://github.com/ghostfolio/ghostfolio"
title="Find Ghostfolio on GitHub"
>GitHub</a
>.
</p>
<p class="text-center">
<a
class="mx-2"
href="https://twitter.com/ghostfolio_"
mat-icon-button
title="Follow Ghostfolio on Twitter"
>
<ion-icon name="logo-twitter" size="large"></ion-icon>
</a>
<a
class="mx-2"
href="mailto:hi@ghostfol.io"
mat-icon-button
title="Send an e-mail"
>
<ion-icon name="mail" size="large"></ion-icon>
</a>
<a
class="mx-2"
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
mat-icon-button
title="Join the Ghostfolio Slack channel"
>
<ion-icon name="logo-slack" size="large"></ion-icon>
</a>
<a
class="mx-2"
href="https://github.com/ghostfolio/ghostfolio"
mat-icon-button
title="Find Ghostfolio on GitHub"
>
<ion-icon name="logo-github" size="large"></ion-icon>
</a>
</p>
<div
*ngIf="hasPermissionForSubscription"
class="d-flex justify-content-center"
>
<div
class="independent-and-bootstrapped-logo mb-2"
title="Ghostfolio is an independent & bootstrapped business"
></div>
</div>
</div>
</div> </div>
</div> </div>

View File

@ -2,15 +2,13 @@
color: rgb(var(--dark-primary-text)); color: rgb(var(--dark-primary-text));
display: block; display: block;
.mat-card { .about-container {
&.about-container { a {
a { color: rgba(var(--palette-primary-500), 1);
color: rgba(var(--palette-primary-500), 1); font-weight: 500;
font-weight: 500;
&:hover { &:hover {
color: rgba(var(--palette-primary-300), 1); color: rgba(var(--palette-primary-300), 1);
}
} }
} }
@ -29,7 +27,7 @@
:host-context(.is-dark-theme) { :host-context(.is-dark-theme) {
color: rgb(var(--light-primary-text)); color: rgb(var(--light-primary-text));
.mat-card { .about-container {
.independent-and-bootstrapped-logo { .independent-and-bootstrapped-logo {
background-image: url('/assets/bootstrapped-light.svg'); background-image: url('/assets/bootstrapped-light.svg');
opacity: 1; opacity: 1;

View File

@ -20,7 +20,6 @@ import { CreateAccessDto } from '@ghostfolio/api/app/access/create-access.dto';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { WebAuthnService } from '@ghostfolio/client/services/web-authn.service'; import { WebAuthnService } from '@ghostfolio/client/services/web-authn.service';
import { baseCurrency } from '@ghostfolio/common/config';
import { getDateFormatString } from '@ghostfolio/common/helper'; import { getDateFormatString } from '@ghostfolio/common/helper';
import { Access, User } from '@ghostfolio/common/interfaces'; import { Access, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
@ -43,7 +42,7 @@ export class AccountPageComponent implements OnDestroy, OnInit {
signInWithFingerprintElement: MatSlideToggle; signInWithFingerprintElement: MatSlideToggle;
public accesses: Access[]; public accesses: Access[];
public baseCurrency = baseCurrency; public baseCurrency: string;
public coupon: number; public coupon: number;
public couponId: string; public couponId: string;
public currencies: string[] = []; public currencies: string[] = [];
@ -79,8 +78,10 @@ export class AccountPageComponent implements OnDestroy, OnInit {
private userService: UserService, private userService: UserService,
public webAuthnService: WebAuthnService public webAuthnService: WebAuthnService
) { ) {
const { currencies, globalPermissions, subscriptions } = const { baseCurrency, currencies, globalPermissions, subscriptions } =
this.dataService.fetchInfo(); this.dataService.fetchInfo();
this.baseCurrency = baseCurrency;
this.coupon = subscriptions?.[0]?.coupon; this.coupon = subscriptions?.[0]?.coupon;
this.couponId = subscriptions?.[0]?.couponId; this.couponId = subscriptions?.[0]?.couponId;
this.currencies = currencies; this.currencies = currencies;

View File

@ -47,9 +47,21 @@
<strong>personal investment strategy</strong>. <strong>personal investment strategy</strong>.
</h2> </h2>
<p class="lead"> <p class="lead">
<strong>Ghostfolio</strong> empowers busy people to keep track of their <strong>Ghostfolio</strong> empowers busy people to keep track of
wealth like stocks, ETFs or cryptocurrencies and make solid, data-driven stocks, ETFs or cryptocurrencies and make solid, data-driven investment
investment decisions. 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> </p>
</div> </div>
</div> </div>

View File

@ -1,6 +1,7 @@
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { MatDialog } from '@angular/material/dialog'; import { MatDialog } from '@angular/material/dialog';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { PositionDetailDialogParams } from '@ghostfolio/client/components/position/position-detail-dialog/interfaces/interfaces';
import { PositionDetailDialog } from '@ghostfolio/client/components/position/position-detail-dialog/position-detail-dialog.component'; import { PositionDetailDialog } from '@ghostfolio/client/components/position/position-detail-dialog/position-detail-dialog.component';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
@ -14,6 +15,7 @@ import {
UniqueAsset, UniqueAsset,
User User
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { Market, ToggleOption } from '@ghostfolio/common/types'; import { Market, ToggleOption } from '@ghostfolio/common/types';
import { Account, AssetClass, DataSource } from '@prisma/client'; import { Account, AssetClass, DataSource } from '@prisma/client';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
@ -33,6 +35,7 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
value: number; value: number;
}; };
}; };
public activeFilters: Filter[] = [];
public allFilters: Filter[]; public allFilters: Filter[];
public continents: { public continents: {
[code: string]: { name: string; value: number }; [code: string]: { name: string; value: number };
@ -81,6 +84,7 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
public user: User; public user: User;
private readonly SEARCH_PLACEHOLDER = 'Filter by account or tag...';
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
/** /**
@ -130,8 +134,13 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
distinctUntilChanged(), distinctUntilChanged(),
switchMap((filters) => { switchMap((filters) => {
this.isLoading = true; this.isLoading = true;
this.activeFilters = filters;
this.placeholder =
this.activeFilters.length <= 0 ? this.SEARCH_PLACEHOLDER : '';
return this.dataService.fetchPortfolioDetails({ filters }); return this.dataService.fetchPortfolioDetails({
filters: this.activeFilters
});
}), }),
takeUntil(this.unsubscribeSubject) takeUntil(this.unsubscribeSubject)
) )
@ -151,25 +160,40 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
if (state?.user) { if (state?.user) {
this.user = state.user; this.user = state.user;
const accountFilters: Filter[] = this.user.accounts.map( const accountFilters: Filter[] = this.user.accounts
({ id, name }) => { .filter(({ accountType }) => {
return accountType === 'SECURITIES';
})
.map(({ id, name }) => {
return { return {
id: id, id,
label: name, label: name,
type: 'account' type: 'ACCOUNT'
}; };
} });
);
const assetClassFilters: Filter[] = [];
for (const assetClass of Object.keys(AssetClass)) {
assetClassFilters.push({
id: assetClass,
label: assetClass,
type: 'ASSET_CLASS'
});
}
const tagFilters: Filter[] = this.user.tags.map(({ id, name }) => { const tagFilters: Filter[] = this.user.tags.map(({ id, name }) => {
return { return {
id, id,
label: name, label: name,
type: 'tag' type: 'TAG'
}; };
}); });
this.allFilters = [...accountFilters, ...tagFilters]; this.allFilters = [
...accountFilters,
...assetClassFilters,
...tagFilters
];
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
} }
@ -343,14 +367,12 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
} }
} }
if (position.dataSource) { this.symbols[prettifySymbol(symbol)] = {
this.symbols[prettifySymbol(symbol)] = { dataSource: position.dataSource,
dataSource: position.dataSource, name: position.name,
name: position.name, symbol: prettifySymbol(symbol),
symbol: prettifySymbol(symbol), value: aPeriod === 'original' ? position.investment : position.value
value: aPeriod === 'original' ? position.investment : position.value };
};
}
} }
const marketsTotal = const marketsTotal =
@ -400,12 +422,16 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
const dialogRef = this.dialog.open(PositionDetailDialog, { const dialogRef = this.dialog.open(PositionDetailDialog, {
autoFocus: false, autoFocus: false,
data: { data: <PositionDetailDialogParams>{
dataSource, dataSource,
symbol, symbol,
baseCurrency: this.user?.settings?.baseCurrency, baseCurrency: this.user?.settings?.baseCurrency,
deviceType: this.deviceType, deviceType: this.deviceType,
hasImpersonationId: this.hasImpersonationId, hasImpersonationId: this.hasImpersonationId,
hasPermissionToReportDataGlitch: hasPermission(
this.user?.permissions,
permissions.reportDataGlitch
),
locale: this.user?.settings?.locale locale: this.user?.settings?.locale
}, },
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh', height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',

View File

@ -95,7 +95,7 @@
<mat-card class="mb-3"> <mat-card class="mb-3">
<mat-card-header class="overflow-hidden w-100"> <mat-card-header class="overflow-hidden w-100">
<mat-card-title class="align-items-center d-flex text-truncate" <mat-card-title class="align-items-center d-flex text-truncate"
><span i18n>By Symbol</span ><span i18n>By Position</span
><ion-icon ><ion-icon
*ngIf="user?.subscription?.type === 'Basic'" *ngIf="user?.subscription?.type === 'Basic'"
class="ml-1 text-muted" class="ml-1 text-muted"

View File

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

View File

@ -2,6 +2,7 @@ import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { User } from '@ghostfolio/common/interfaces'; import { User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import Big from 'big.js'; import Big from 'big.js';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
@ -16,6 +17,7 @@ import { takeUntil } from 'rxjs/operators';
export class FirePageComponent implements OnDestroy, OnInit { export class FirePageComponent implements OnDestroy, OnInit {
public deviceType: string; public deviceType: string;
public fireWealth: Big; public fireWealth: Big;
public hasPermissionToUpdateUserSettings: boolean;
public isLoading = false; public isLoading = false;
public user: User; public user: User;
public withdrawalRatePerMonth: Big; public withdrawalRatePerMonth: Big;
@ -63,6 +65,11 @@ export class FirePageComponent implements OnDestroy, OnInit {
if (state?.user) { if (state?.user) {
this.user = state.user; this.user = state.user;
this.hasPermissionToUpdateUserSettings = hasPermission(
this.user.permissions,
permissions.updateUserSettings
);
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
} }
}); });

View File

@ -59,6 +59,7 @@
[currency]="user?.settings?.baseCurrency" [currency]="user?.settings?.baseCurrency"
[deviceType]="deviceType" [deviceType]="deviceType"
[fireWealth]="fireWealth?.toNumber()" [fireWealth]="fireWealth?.toNumber()"
[hasPermissionToUpdateUserSettings]="hasPermissionToUpdateUserSettings"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[savingsRate]="user?.settings?.savingsRate" [savingsRate]="user?.settings?.savingsRate"
(savingsRateChanged)="onSavingsRateChange($event)" (savingsRateChanged)="onSavingsRateChange($event)"

View File

@ -8,6 +8,7 @@ import {
} from '@angular/core'; } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms'; import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { MatAutocompleteSelectedEvent } from '@angular/material/autocomplete'; import { MatAutocompleteSelectedEvent } from '@angular/material/autocomplete';
import { DateAdapter, MAT_DATE_LOCALE } from '@angular/material/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto'; import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { UpdateOrderDto } from '@ghostfolio/api/app/order/update-order.dto'; import { UpdateOrderDto } from '@ghostfolio/api/app/order/update-order.dto';
@ -54,13 +55,18 @@ export class CreateOrUpdateTransactionDialog implements OnDestroy {
public constructor( public constructor(
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
@Inject(MAT_DIALOG_DATA) public data: CreateOrUpdateTransactionDialogParams,
private dataService: DataService, private dataService: DataService,
private dateAdapter: DateAdapter<any>,
public dialogRef: MatDialogRef<CreateOrUpdateTransactionDialog>, public dialogRef: MatDialogRef<CreateOrUpdateTransactionDialog>,
private formBuilder: FormBuilder, private formBuilder: FormBuilder,
@Inject(MAT_DIALOG_DATA) public data: CreateOrUpdateTransactionDialogParams @Inject(MAT_DATE_LOCALE) private locale: string
) {} ) {}
public ngOnInit() { public ngOnInit() {
this.locale = this.data.user?.settings?.locale;
this.dateAdapter.setLocale(this.locale);
const { currencies, platforms } = this.dataService.fetchInfo(); const { currencies, platforms } = this.dataService.fetchInfo();
this.currencies = currencies; this.currencies = currencies;

View File

@ -1,6 +1,7 @@
<form <form
class="d-flex flex-column h-100" class="d-flex flex-column h-100"
[formGroup]="activityForm" [formGroup]="activityForm"
(keyup.enter)="activityForm.valid && onSubmit()"
(ngSubmit)="onSubmit()" (ngSubmit)="onSubmit()"
> >
<h1 *ngIf="data.activity.id" mat-dialog-title i18n>Update activity</h1> <h1 *ngIf="data.activity.id" mat-dialog-title i18n>Update activity</h1>

View File

@ -5,6 +5,7 @@ import { ActivatedRoute, Router } from '@angular/router';
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto'; import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { UpdateOrderDto } from '@ghostfolio/api/app/order/update-order.dto'; import { UpdateOrderDto } from '@ghostfolio/api/app/order/update-order.dto';
import { PositionDetailDialogParams } from '@ghostfolio/client/components/position/position-detail-dialog/interfaces/interfaces';
import { PositionDetailDialog } from '@ghostfolio/client/components/position/position-detail-dialog/position-detail-dialog.component'; import { PositionDetailDialog } from '@ghostfolio/client/components/position/position-detail-dialog/position-detail-dialog.component';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { IcsService } from '@ghostfolio/client/services/ics/ics.service'; import { IcsService } from '@ghostfolio/client/services/ics/ics.service';
@ -406,12 +407,16 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
const dialogRef = this.dialog.open(PositionDetailDialog, { const dialogRef = this.dialog.open(PositionDetailDialog, {
autoFocus: false, autoFocus: false,
data: { data: <PositionDetailDialogParams>{
dataSource, dataSource,
symbol, symbol,
baseCurrency: this.user?.settings?.baseCurrency, baseCurrency: this.user?.settings?.baseCurrency,
deviceType: this.deviceType, deviceType: this.deviceType,
hasImpersonationId: this.hasImpersonationId, hasImpersonationId: this.hasImpersonationId,
hasPermissionToReportDataGlitch: hasPermission(
this.user?.permissions,
permissions.reportDataGlitch
),
locale: this.user?.settings?.locale locale: this.user?.settings?.locale
}, },
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh', height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',

View File

@ -1,7 +1,6 @@
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { baseCurrency } from '@ghostfolio/common/config';
import { User } from '@ghostfolio/common/interfaces'; import { User } from '@ghostfolio/common/interfaces';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators'; import { takeUntil } from 'rxjs/operators';
@ -13,7 +12,7 @@ import { takeUntil } from 'rxjs/operators';
templateUrl: './pricing-page.html' templateUrl: './pricing-page.html'
}) })
export class PricingPageComponent implements OnDestroy, OnInit { export class PricingPageComponent implements OnDestroy, OnInit {
public baseCurrency = baseCurrency; public baseCurrency: string;
public coupon: number; public coupon: number;
public isLoggedIn: boolean; public isLoggedIn: boolean;
public price: number; public price: number;
@ -29,8 +28,9 @@ export class PricingPageComponent implements OnDestroy, OnInit {
private dataService: DataService, private dataService: DataService,
private userService: UserService 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.coupon = this.price = subscriptions?.[0]?.coupon;
this.price = subscriptions?.[0]?.price; this.price = subscriptions?.[0]?.price;
} }

View File

@ -4,22 +4,19 @@
<h3 class="d-flex justify-content-center mb-3 text-center" i18n> <h3 class="d-flex justify-content-center mb-3 text-center" i18n>
Pricing Plans Pricing Plans
</h3> </h3>
<mat-card class="mb-4"> <div class="mb-4">
<mat-card-content> <p>
<p> Our official
Our official <strong>Ghostfolio Premium</strong> cloud offering is the easiest way
<strong>Ghostfolio Premium</strong> cloud offering is the easiest to get started. Due to the time it saves, this will be the best option
way to get started. Due to the time it saves, this will be the best for most people. The revenue is used for covering the hosting costs.
option for most people. The revenue is used for covering the hosting </p>
costs. <p>
</p> If you prefer to run <strong>Ghostfolio</strong> on your own
<p> infrastructure, please find the source code and further instructions
If you prefer to run <strong>Ghostfolio</strong> on your own on <a href="https://github.com/ghostfolio/ghostfolio">GitHub</a>.
infrastructure, please find the source code and further instructions </p>
on <a href="https://github.com/ghostfolio/ghostfolio">GitHub</a>. </div>
</p>
</mat-card-content>
</mat-card>
<div class="row"> <div class="row">
<div class="col-xs-12 col-md-4 mb-3"> <div class="col-xs-12 col-md-4 mb-3">
<mat-card class="d-flex flex-column h-100"> <mat-card class="d-flex flex-column h-100">

View File

@ -10,7 +10,7 @@
<div class="col-md-12 allocations-by-symbol"> <div class="col-md-12 allocations-by-symbol">
<mat-card class="mb-3"> <mat-card class="mb-3">
<mat-card-header class="overflow-hidden w-100"> <mat-card-header class="overflow-hidden w-100">
<mat-card-title class="text-truncate" i18n>Symbols</mat-card-title> <mat-card-title class="text-truncate" i18n>Positions</mat-card-title>
</mat-card-header> </mat-card-header>
<mat-card-content> <mat-card-content>
<gf-portfolio-proportion-chart <gf-portfolio-proportion-chart

View File

@ -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 { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { Activities } from '@ghostfolio/api/app/order/interfaces/activities.interface'; import { Activities } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { UpdateOrderDto } from '@ghostfolio/api/app/order/update-order.dto'; 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 { PortfolioPositions } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-positions.interface';
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface'; import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { SymbolItem } from '@ghostfolio/api/app/symbol/interfaces/symbol-item.interface'; import { SymbolItem } from '@ghostfolio/api/app/symbol/interfaces/symbol-item.interface';
@ -18,6 +19,7 @@ import {
Accounts, Accounts,
AdminData, AdminData,
AdminMarketData, AdminMarketData,
BenchmarkResponse,
Export, Export,
Filter, Filter,
InfoItem, InfoItem,
@ -89,6 +91,10 @@ export class DataService {
return this.http.get<Access[]>('/api/v1/access'); return this.http.get<Access[]>('/api/v1/access');
} }
public fetchBenchmarks() {
return this.http.get<BenchmarkResponse>('/api/v1/benchmark');
}
public fetchChart({ range }: { range: DateRange }) { public fetchChart({ range }: { range: DateRange }) {
return this.http.get<PortfolioChart>('/api/v1/portfolio/chart', { return this.http.get<PortfolioChart>('/api/v1/portfolio/chart', {
params: { range } params: { range }
@ -187,12 +193,13 @@ export class DataService {
let params = new HttpParams(); let params = new HttpParams();
if (filters?.length > 0) { if (filters?.length > 0) {
const { account: filtersByAccount, tag: filtersByTag } = groupBy( const {
filters, ACCOUNT: filtersByAccount,
(filter) => { ASSET_CLASS: filtersByAssetClass,
return filter.type; TAG: filtersByTag
} } = groupBy(filters, (filter) => {
); return filter.type;
});
if (filtersByAccount) { if (filtersByAccount) {
params = params.append( params = params.append(
@ -205,6 +212,17 @@ export class DataService {
); );
} }
if (filtersByAssetClass) {
params = params.append(
'assetClasses',
filtersByAssetClass
.map(({ id }) => {
return id;
})
.join(',')
);
}
if (filtersByTag) { if (filtersByTag) {
params = params.append( params = params.append(
'tags', 'tags',
@ -261,13 +279,15 @@ export class DataService {
symbol: string; symbol: string;
}) { }) {
return this.http return this.http
.get<any>(`/api/v1/portfolio/position/${dataSource}/${symbol}`) .get<PortfolioPositionDetail>(
`/api/v1/portfolio/position/${dataSource}/${symbol}`
)
.pipe( .pipe(
map((data) => { map((data) => {
if (data.orders) { if (data.orders) {
for (const order of data.orders) { for (const order of data.orders) {
order.createdAt = parseISO(order.createdAt); order.createdAt = parseISO(<string>(<unknown>order.createdAt));
order.date = parseISO(order.date); order.date = parseISO(<string>(<unknown>order.date));
} }
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

View File

@ -6,46 +6,46 @@
http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd"> http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd">
<url> <url>
<loc>https://ghostfol.io</loc> <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>
<url> <url>
<loc>https://ghostfol.io/about</loc> <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>
<url> <url>
<loc>https://ghostfol.io/about/changelog</loc> <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>
<url> <url>
<loc>https://ghostfol.io/blog</loc> <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>
<url> <url>
<loc>https://ghostfol.io/de/blog/2021/07/hallo-ghostfolio</loc> <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>
<url> <url>
<loc>https://ghostfol.io/en/blog/2021/07/hello-ghostfolio</loc> <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>
<url> <url>
<loc>https://ghostfol.io/en/blog/2022/01/ghostfolio-first-months-in-open-source</loc> <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>
<url> <url>
<loc>https://ghostfol.io/features</loc> <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>
<url> <url>
<loc>https://ghostfol.io/pricing</loc> <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>
<url> <url>
<loc>https://ghostfol.io/register</loc> <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>
<url> <url>
<loc>https://ghostfol.io/resources</loc> <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> </url>
</urlset> </urlset>

View File

@ -17,7 +17,7 @@
<meta name="twitter:card" content="summary_large_image" /> <meta name="twitter:card" content="summary_large_image" />
<meta <meta
name="twitter:description" name="twitter:description"
content="Ghostfolio is a lightweight wealth management application for individuals to keep track of their wealth like stocks, ETFs or cryptocurrencies" content="Ghostfolio is a lightweight wealth management application for individuals to keep track of stocks, ETFs or cryptocurrencies"
/> />
<meta <meta
name="twitter:image" name="twitter:image"
@ -42,7 +42,7 @@
property="og:image" property="og:image"
content="https://www.ghostfol.io/assets/cover.png" 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 <meta
property="og:site_name" property="og:site_name"
content="Ghostfolio Open Source Wealth Management Software" content="Ghostfolio Open Source Wealth Management Software"

View File

@ -60,12 +60,8 @@ body {
} }
ngx-skeleton-loader { ngx-skeleton-loader {
line-height: 0;
outline: 0;
.loader { .loader {
background-color: #323232; background-color: #323232;
outline: 0;
} }
} }
@ -117,9 +113,13 @@ ion-icon {
ngx-skeleton-loader { ngx-skeleton-loader {
display: block; display: block;
line-height: 0;
outline: 0;
.loader { .loader {
display: flex;
margin: 0 !important; margin: 0 !important;
outline: 0;
} }
} }

View File

@ -5,5 +5,6 @@
"types": ["node"], "types": ["node"],
"typeRoots": ["../node_modules/@types"] "typeRoots": ["../node_modules/@types"]
}, },
"files": ["src/main.ts", "src/polyfills.ts"] "files": ["src/main.ts", "src/polyfills.ts"],
"exclude": ["jest.config.ts"]
} }

View File

@ -3,5 +3,6 @@
"include": ["**/*.ts"], "include": ["**/*.ts"],
"compilerOptions": { "compilerOptions": {
"types": ["jest", "node"] "types": ["jest", "node"]
} },
"exclude": ["jest.config.ts"]
} }

View File

@ -6,5 +6,5 @@
"types": ["jest", "node"] "types": ["jest", "node"]
}, },
"files": ["src/test-setup.ts"], "files": ["src/test-setup.ts"],
"include": ["**/*.spec.ts", "**/*.test.ts", "**/*.d.ts"] "include": ["**/*.spec.ts", "**/*.test.ts", "**/*.d.ts", "jest.config.ts"]
} }

View File

@ -7,6 +7,7 @@ services:
environment: environment:
DATABASE_URL: postgresql://user:password@postgres:5432/ghostfolio-db?sslmode=prefer DATABASE_URL: postgresql://user:password@postgres:5432/ghostfolio-db?sslmode=prefer
REDIS_HOST: 'redis' REDIS_HOST: 'redis'
REDIS_PASSWORD: ${REDIS_PASSWORD}
ports: ports:
- 3333:3333 - 3333:3333

View File

@ -7,6 +7,7 @@ services:
environment: environment:
DATABASE_URL: postgresql://user:password@postgres:5432/ghostfolio-db?sslmode=prefer DATABASE_URL: postgresql://user:password@postgres:5432/ghostfolio-db?sslmode=prefer
REDIS_HOST: 'redis' REDIS_HOST: 'redis'
REDIS_PASSWORD: ${REDIS_PASSWORD}
ports: ports:
- 3333:3333 - 3333:3333

View File

@ -1,6 +1,6 @@
module.exports = { module.exports = {
displayName: 'common', displayName: 'common',
preset: '../../jest.preset.js',
globals: { globals: {
'ts-jest': { tsconfig: '<rootDir>/tsconfig.spec.json' } 'ts-jest': { tsconfig: '<rootDir>/tsconfig.spec.json' }
}, },
@ -8,5 +8,6 @@ module.exports = {
'^.+\\.[tj]sx?$': 'ts-jest' '^.+\\.[tj]sx?$': 'ts-jest'
}, },
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'], moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'],
coverageDirectory: '../../coverage/libs/common' coverageDirectory: '../../coverage/libs/common',
preset: '../../jest.preset.ts'
}; };

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