Compare commits

..

82 Commits

Author SHA1 Message Date
646dcb91c5 Release 1.8.0 (#122) 2021-05-24 16:32:11 +02:00
ad961f3039 Bugfix/fix missing header of public pages (#121)
* Fix missing header of public pages

* Update changelog
2021-05-24 16:28:42 +02:00
c16f743b07 Feature/add tools section (#120)
* Add tools section

* Update changelog
2021-05-24 16:25:59 +02:00
8e13f6ef9b Bugfix/fix performance chart (#119)
* Fix value of performance chart

* Update changelog
2021-05-24 16:24:54 +02:00
95bcdea69b Refactor cd to changeDetectorRef (#118) 2021-05-24 10:12:53 +02:00
0d6fe4a232 Feature/refactor user service as observable store (#117)
* Implement user service as observable store

* Clean up tokenStorageService usage

* Update changelog
2021-05-24 09:38:44 +02:00
ced4519412 Reorder (#116) 2021-05-22 16:14:16 +02:00
ef8b7718b1 Release 1.7.0 (#115) 2021-05-22 13:49:25 +02:00
b4762dc463 Bugfix/fix internal navigation with query param (#114)
* Fix internal navigation with query parameter

* Add guard

* Update changelog
2021-05-22 13:48:06 +02:00
9851cce382 Feature/hide footer on mobile (#113)
* Hide footer on mobile

* Improve about text

* Update changelog
2021-05-22 13:45:50 +02:00
a1460a98fd Release 1.6.0 (#112) 2021-05-22 10:22:46 +02:00
1a553a296f Feature/improve user table of admin control panel (#109)
* Improve user table

* Add index
* Increase limit
* Improve alignment of cell content

* Update changelog
2021-05-22 10:17:12 +02:00
f5bd6b0d58 Release 1.5.0 (#111) 2021-05-22 10:11:01 +02:00
78a4946e8b Feature/zen mode (#110)
* Start with implementation
* Refactor AuthGuard, persist displayMode in user settings
* Refactor DisplayMode to ViewMode
* Update changelog
2021-05-22 10:04:56 +02:00
702ee956a2 Release 1.4.0 (#108) 2021-05-20 20:43:48 +02:00
200a7d2d65 Feature/refactor search functionality (#105)
* Refactor search functionality

* Update changelog

* Improvements after code review
2021-05-20 20:36:08 +02:00
79edc09710 Store utm_source in local storage (#106) 2021-05-19 20:36:44 +02:00
77255df4be Feature/disable base currency selector for demo user (#104)
* Disable base currency selector based on permission

* Update changelog
2021-05-18 19:36:24 +02:00
277133fa1a Clean up services (#103)
* LanguageService
* TokenStorageService
2021-05-17 19:55:10 +02:00
abd0e08566 Introduce @ghostfolio/common lib (#102) 2021-05-16 22:11:14 +02:00
561d8dbc70 Feature/rename account link (#101)
* Rename account link

* Update changelog
2021-05-16 21:22:03 +02:00
c973ffd3ba Feature/reorganize helper lib (#100)
Reorganize helper lib (Move interfaces and types)
* InfoItem
* PortfolioItem
* PortfolioOverview
* PortfolioPerformance
* Position
* PortfolioPosition
* PortfolioReport
* PortfolioReportRule
* User
* UserSettings
* DateRange
* AdminData
* AccessWithGranteeUser
* OrderWithAccount
* Granularity
* UserWithSettings
* RequestWithUser
2021-05-16 21:20:59 +02:00
368de7dedc Extend unit tests (#99) 2021-05-16 21:19:14 +02:00
e56514629f Refactor postgres variables (#98)
Co-Authored-By: Valentin Zickner <valentin@coderworks.de>
2021-05-16 09:31:49 +02:00
7a8a25c4c0 Feature/filter by year in transaction table (#97)
* Filter by year

Co-Authored-By: Valentin Zickner <valentin@coderworks.de>
2021-05-16 09:31:28 +02:00
5d36d3a6bb remove database dependencies for test execution (#96)
Co-Authored-By: Valentin Zickner <valentin@coderworks.de>
2021-05-15 18:57:27 +02:00
0ef35fd31f Feature/hide unknown exchange (#95)
* Hide unknown exchange

* Update changelog
2021-05-15 17:50:28 +02:00
c1c22c195d Release 1.3.0 (#94) 2021-05-15 10:15:29 +02:00
111d8d8e3c Feature/rename share to allocation in columns of positions table (#93)
* Rename columns

* Initial Share -> Initial Allocation
* Current Share -> Current Allocation

* Update changelog
2021-05-15 10:12:12 +02:00
b0a24e4fc0 Desaturate background color (#92) 2021-05-15 10:09:07 +02:00
694b9b8991 Feature/refactor active menu item state (#91)
* Refactor active menu item state

* Update changelog
2021-05-15 10:05:03 +02:00
fada347aa5 Fix pricing page link (#90) 2021-05-15 08:36:13 +02:00
f37ea9f0e7 Release 1.2.1 (#89) 2021-05-14 21:38:20 +02:00
59911925c2 Feature/update sitemap.xml (#88)
* Update sitemap

* Update changelog

* Fix permissions in header component
2021-05-14 21:35:19 +02:00
58fd59beb1 Release 1.2.0 (#87) 2021-05-14 21:22:38 +02:00
eb09d77251 Feature/move pricing section to page (#86)
* Add a dedicated pricing page

* Update changelog
2021-05-14 21:15:20 +02:00
42b9178d96 Feature/improve filter search in transactions table (#85)
* Improve filter search style

* Update changelog
2021-05-14 21:09:30 +02:00
45516311f5 Improve proportion charts (#83)
* Improve proportion charts

* Clean up code
2021-05-13 21:58:39 +02:00
04cfa7366f Sort imports (#84) 2021-05-12 22:03:07 +02:00
4234ab84a9 Add build badge (#81) 2021-05-12 20:35:28 +02:00
b8c05d1014 Feature/harmonize table styles (#80)
* Harmonize table styles

* Update changelog
2021-05-12 20:32:34 +02:00
91ec9aa0a4 Release 1.1.0 (#79) 2021-05-11 18:14:49 +02:00
565e920f1b Add link to retrieve manually the current market price (#74)
* feat: add link to retrieve manually the current market price from data source

* Add icon

* Update changelog

Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2021-05-11 18:01:46 +02:00
5d24adfa75 Feature/improve transaction filtering (#76)
* add multi-filter support for transaction filtering with auto completion

* update changelog

* fix table for transaction for accounts without platform

* simplify readme file since docker compose build is not required (#75)

* simplify readme file since docker compose build is not required

* add anchor navigation in README.md

* Improve UI

* Refactoring

* Refactoring

* Feature/travis (#77)

* integrate travis

* fix prettier transactions-page.component.ts

* change base branch to main

* fetch all branches in .travis.yml

* Bugfix/keep current menu item active (#78)

* Keep current menu item active

* Update changelog

* Feature/travis (#77)

* integrate travis

* fix prettier transactions-page.component.ts

* change base branch to main

* fetch all branches in .travis.yml

* Keep current menu item active

* Update changelog

Co-authored-by: Valentin Zickner <3200232+vzickner@users.noreply.github.com>

* add multi-filter support for transaction filtering with auto completion

* update changelog

* fix table for transaction for accounts without platform

* Improve UI

* Refactoring

* Refactoring

* Update changelog

Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2021-05-11 17:55:55 +02:00
1dc94c0027 Bugfix/keep current menu item active (#78)
* Keep current menu item active

* Update changelog

* Feature/travis (#77)

* integrate travis

* fix prettier transactions-page.component.ts

* change base branch to main

* fetch all branches in .travis.yml

* Keep current menu item active

* Update changelog

Co-authored-by: Valentin Zickner <3200232+vzickner@users.noreply.github.com>
2021-05-11 17:49:35 +02:00
ebae2f4ec9 Feature/travis (#77)
* integrate travis

* fix prettier transactions-page.component.ts

* change base branch to main

* fetch all branches in .travis.yml
2021-05-11 17:43:35 +02:00
7099edc591 simplify readme file since docker compose build is not required (#75)
* simplify readme file since docker compose build is not required

* add anchor navigation in README.md
2021-05-09 20:11:10 +02:00
de973d6bda Add filterPredicate on transactions table to filter by account name (#73)
* fix: add filterPredicate on transactions table to filter by account name

* Minor refactoring

* Update changelog

Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2021-05-06 22:57:15 +02:00
993a491d24 Release 1.0.0 (#70) 2021-05-05 12:05:43 +02:00
631efff7ae Add duplicate action on transactions table (#68)
* feat: add duplicate action on transactions table

* fix: review changes

* fix: add type and dataSource
2021-05-05 12:01:56 +02:00
a3d1ac2ce4 Feature/update icon and add google play badge (#69)
* Add maskable icons

* Add Google Play badge

* Update changelog
2021-05-04 21:48:51 +02:00
4484c21757 Clean up platform id (#67) 2021-05-04 17:49:47 +02:00
87cd3ef33f Release 0.99.0 (#65) 2021-05-03 21:25:39 +02:00
163f4a3d3f Feature/allow to delete users (#64)
* Allow to delete users

* Update changelog
2021-05-03 21:23:00 +02:00
a84256dc03 Feature/eliminate platform from order (#63)
* Eliminate platform from order

* Update changelog
2021-05-03 21:19:56 +02:00
cf82066976 Fix test (#62) 2021-05-02 22:38:42 +02:00
e248c9cedd Release 0.98.0 (#61) 2021-05-02 21:22:30 +02:00
90a2fea7d6 Feature/create and update accounts (#60)
* Allow to create and update accounts

* Activate account selector in transaction dialog

* Refactor analytics and report from platforms to accounts
2021-05-02 21:18:52 +02:00
d17b02092e Release 0.97.0 (#59) 2021-05-01 12:33:35 +02:00
c70eb7793e Feature/migration to accounts (#58)
* Migrate transaction table
* Add accounts page
* Add account page logic
2021-05-01 12:30:52 +02:00
e3a1d2b9cf Fix tests (#57) 2021-05-01 12:28:48 +02:00
e68ebdf76d Release 0.96.0 2021-04-30 21:18:42 +02:00
949cf58eef Release 0.96.0 (#56) 2021-04-30 21:14:28 +02:00
0816defb95 Feature/update slogan (#55)
* Harmonize slogan
2021-04-30 21:11:48 +02:00
a076a1c933 Feature/extend position detail dialog (#54)
* Extend position detail dialog

* Absolute change
* Number of transactions
2021-04-30 21:08:43 +02:00
40c95a541d Add data source to transaction model (#53) 2021-04-28 21:30:49 +02:00
2d04d7b8d5 Release 0.94.0 (#52) 2021-04-27 21:04:51 +02:00
94e0feac68 Add scraper symbols to symbol lookup results (#51) 2021-04-27 21:03:22 +02:00
cd9e974c40 Release 0.93.0 (#50) 2021-04-26 22:09:20 +02:00
11700f75d9 Improve colors in dark mode, improve user table (#49) 2021-04-26 21:54:19 +02:00
0439a7beaa Release 0.92.0 (#48) 2021-04-25 21:27:13 +02:00
608b195ba9 Extend user table (#47)
* Column: accounts
* Horizontal scrollbar
2021-04-25 21:25:23 +02:00
8cb5fd64dd Prepare for multi accounts support: store account for new transactions (#46) 2021-04-25 21:22:35 +02:00
ef317a86ed Setup angular compiler options (#45) 2021-04-25 17:41:53 +02:00
19ada83d0b Fix header and about page (#44) 2021-04-25 17:29:04 +02:00
6ecf66ea2a Fix types 2021-04-25 12:34:34 +02:00
65ba16c07c Fix tests 2021-04-25 12:30:28 +02:00
aee7c12c44 Release 0.91.0 (#43) 2021-04-25 12:15:40 +02:00
125956eb3e Refactor number extraction from string (#41) 2021-04-25 12:11:52 +02:00
954224401d Prepare for multi accounts support (#42) 2021-04-25 12:07:32 +02:00
d268de3e12 Feature/improve styling of x ray rules (#40) 2021-04-24 22:22:41 +02:00
39cfb4603b Extend support for feature flags (#39) 2021-04-24 22:01:38 +02:00
220 changed files with 4677 additions and 2047 deletions

4
.env
View File

@ -5,12 +5,12 @@ REDIS_HOST=localhost
REDIS_PORT=6379 REDIS_PORT=6379
# POSTGRES # POSTGRES
POSTGRES_DB=ghostfolio-db
POSTGRES_USER=user POSTGRES_USER=user
POSTGRES_PASSWORD=password POSTGRES_PASSWORD=password
POSTGRES_DB=ghostfolio-db
ACCESS_TOKEN_SALT=GHOSTFOLIO ACCESS_TOKEN_SALT=GHOSTFOLIO
ALPHA_VANTAGE_API_KEY= ALPHA_VANTAGE_API_KEY=
DATABASE_URL=postgresql://user:password@localhost:5432/ghostfolio-db?sslmode=prefer DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer
JWT_SECRET_KEY=123456 JWT_SECRET_KEY=123456
PORT=3333 PORT=3333

10
.travis.yml Normal file
View File

@ -0,0 +1,10 @@
language: node_js
git:
depth: false
node_js:
- 14
before_script:
- yarn
script:
- yarn format:check
- yarn test

View File

@ -5,14 +5,208 @@ 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.8.0 - 24.05.2021
### Added
- Added a section for _Analysis_, _X-ray_ and upcoming tools
### Changed
- Introduced a user service implemented as an observable store (single source of truth for state)
### Fixed
- Fixed the performance chart by considering the investment
- Fixed missing header of public pages (_About_, _Pricing_, _Resources_)
## 1.7.0 - 22.05.2021
### Changed
- Hid footer on mobile (except on landing page)
### Fixed
- Fixed the internal navigation of the _Zen Mode_ in combination with a query parameter
## 1.6.0 - 22.05.2021
### Added
- Added an index in the user table of the admin control panel
### Changed
- Improved the alignment in the user table of the admin control panel
## 1.5.0 - 22.05.2021
### Added
- Added _Zen Mode_: the distraction-free view
## 1.4.0 - 20.05.2021
### Added
- Added filtering by year in the transaction filtering component
### Changed
- Renamed _Ghostfolio Account_ to _My Ghostfolio_
- Hid unknown exchange in the position overview
- Disable the base currency selector for the demo user
- Refactored the portfolio unit tests to work without database
- Refactored the search functionality of the data management (aligned with data source)
- Renamed shared helper to `@ghostfolio/common/helper`
- Moved shared interfaces to `@ghostfolio/common/interfaces`
- Moved shared types to `@ghostfolio/common/types`
## 1.3.0 - 15.05.2021
### Changed
- Refactored the active menu item state by parsing the current url
- Used a desaturated background color for unknown types in pie charts
- Renamed the columns _Initial Share_ and _Current Share_ to _Initial Allocation_ and _Current Allocation_ in the positions table
### Fixed
- Fixed the link to the pricing page
## 1.2.1 - 14.05.2021
### Changed
- Updated the sitemap
## 1.2.0 - 14.05.2021
### Changed
- Harmonized the style of various tables
- Keep the color per type when switching between _Initial_ and _Current_ in pie charts
- Upgraded `chart.js` from version `3.0.2` to `3.2.1`
- Moved the pricing section to a dedicated page
- Improved the style of the transaction filtering component
### Fixed
- Fixed the tooltips when switching between _Initial_ and _Current_ in pie charts
## 1.1.0 - 11.05.2021
### Added
- Added a button to fetch the current market price in the create or edit transaction dialog
### Changed
- Improved the transaction filtering with multi filter support
### Fixed
- Fixed the filtering by account name in the transactions table
- Fixed the active menu item state when a modal has opened
## 1.0.0 - 05.05.2021
### Added
- Added the functionality to clone a transaction
- Added a _Google Play_ badge on the landing page
### Changed
- Changed to maskable icons
## 0.99.0 - 03.05.2021
### Added
- Added support for deleting users in the admin control panel
### Changed
- Eliminated the platform attribute from the transaction model
## 0.98.0 - 02.05.2021
### Added
- Added the logic to create and update accounts
## 0.97.0 - 01.05.2021
### Added
- Added an account page as a preparation for the multi accounts support
## 0.96.0 - 30.04.2021
### Added
- Added the absolute change to the position detail dialog
- Added the number of transactions to the position detail dialog
### Changed
- Harmonized the slogan to "Open Source Portfolio Tracker"
## 0.95.0 - 28.04.2021
### Added
- Added a data source attribute to the transactions model
## 0.94.0 - 27.04.2021
### Added
- Added the generic scraper symbols to the symbol lookup results
## 0.93.0 - 26.04.2021
### Changed
- Improved the user table styling of the admin control panel
- Improved the background colors in the dark mode
## 0.92.0 - 25.04.2021
### Added
- Prepared further for multi accounts support: store account for new transactions
- Added a horizontal scrollbar to the user table of the admin control panel
### Fixed
- Fixed an issue in the header with outdated data
- Fixed an issue on the about page with outdated data
## 0.91.0 - 25.04.2021
### Added
- Extended the support for feature flags to simplify the initial project setup
- Prepared for multi accounts support
### Changed
- Improved the styling of the rules in the _X-ray_ section
## 0.90.0 - 22.04.2021 ## 0.90.0 - 22.04.2021
### Added ### Added
- Improved the user table of the admin control panel
- Added the symbol logo to the position detail dialog - Added the symbol logo to the position detail dialog
- Introduced a third option for the market state: `delayed` (besides `open` and `closed`) - Introduced a third option for the market state: `delayed` (besides `open` and `closed`)
### Changed
- Improved the user table of the admin control panel
## 0.89.0 - 21.04.2021 ## 0.89.0 - 21.04.2021
### Added ### Added
@ -92,7 +286,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed ### Fixed
- Fixed an issue in the portfolio update on deleting a transaction - Fixed an issue in the portfolio update on deleting a transaction
- Fixed an issue in the _X-Ray_ section (missing redirection on logout) - Fixed an issue in the _X-ray_ section (missing redirection on logout)
## 0.82.0 - 10.04.2021 ## 0.82.0 - 10.04.2021
@ -165,7 +359,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
- Grouped the _X-Ray_ section visually in _Currency Cluster Risk_ and _Platform Cluster Risk_ - Grouped the _X-ray_ section visually in _Currency Cluster Risk_ and _Platform Cluster Risk_
## 0.76.0 - 02.04.2021 ## 0.76.0 - 02.04.2021
@ -177,7 +371,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed ### Fixed
- Fixed an issue in the _X-Ray_ section (empty portfolio) - Fixed an issue in the _X-ray_ section (empty portfolio)
## 0.75.0 - 01.04.2021 ## 0.75.0 - 01.04.2021
@ -190,7 +384,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- Added a _Create Account_ message in the _Live Demo_ - Added a _Create Account_ message in the _Live Demo_
- Added skeleton loaders to the _X-Ray_ section - Added skeleton loaders to the _X-ray_ section
### Changed ### Changed
@ -206,7 +400,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
- Improved the intro text in the _X-Ray_ section - Improved the intro text in the _X-ray_ section
### Fixed ### Fixed
@ -222,7 +416,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- Added an intro text to the _X-Ray_ section - Added an intro text to the _X-ray_ section
### Changed ### Changed
@ -241,7 +435,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
- Improved the styling in the _X-Ray_ section - Improved the styling in the _X-ray_ section
## 0.70.0 - 27.03.2021 ## 0.70.0 - 27.03.2021
@ -249,7 +443,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Added the current _Fear & Greed Index_ as text - Added the current _Fear & Greed Index_ as text
- Extended the landing page text: _Ghostfolio_ empowers busy folks... - Extended the landing page text: _Ghostfolio_ empowers busy folks...
- Added the first static portfolio analysis rule in the brand new _X-Ray_ section - Added the first static portfolio analysis rule in the brand new _X-ray_ section
### Changed ### Changed

View File

@ -7,8 +7,11 @@
<a href="https://ghostfol.io"><strong>Live Demo</strong></a> <a href="https://ghostfol.io"><strong>Live Demo</strong></a>
</p> </p>
<p> <p>
<a href="https://travis-ci.org/github/ghostfolio/ghostfolio" rel="nofollow">
<img src="https://travis-ci.org/ghostfolio/ghostfolio.svg?branch=main" alt="Build Status"/>
</a>
<a href="https://www.gnu.org/licenses/agpl-3.0" rel="nofollow"> <a href="https://www.gnu.org/licenses/agpl-3.0" rel="nofollow">
<img src="https://img.shields.io/badge/License-AGPL%20v3-blue.svg" alt="License: AGPL v3"> <img src="https://img.shields.io/badge/License-AGPL%20v3-blue.svg" alt="License: AGPL v3"/>
</a> </a>
</p> </p>
</div> </div>
@ -68,19 +71,18 @@ The frontend is built with [Angular](https://angular.io).
### Setup ### Setup
1. Run `yarn install` 1. Run `yarn install`
2. Run `cd docker` 1. Run `cd docker`
3. Run `docker compose build` 1. Run `docker compose up -d` to start [PostgreSQL](https://www.postgresql.org) and [Redis](https://redis.io)
4. Run `docker compose up -d` to start [PostgreSQL](https://www.postgresql.org) and [Redis](https://redis.io) 1. Run `cd -` to go back to the project root directory
5. Run `cd -` to go back to the project root directory 1. Run `yarn setup:database` to initialize the database schema and populate your database with (example) data
6. Run `yarn setup:database` to initialize the database schema and populate your database with (example) data 1. Start server and client (see [_Development_](#Development))
7. Start server and client (see _Development_) 1. Login as _Admin_ with the following _Security Token_: `ae76872ae8f3419c6d6f64bf51888ecbcc703927a342d815fafe486acdb938da07d0cf44fca211a0be74a423238f535362d390a41e81e633a9ce668a6e31cdf9`
8. Login as _Admin_ with the following _Security Token_: `ae76872ae8f3419c6d6f64bf51888ecbcc703927a342d815fafe486acdb938da07d0cf44fca211a0be74a423238f535362d390a41e81e633a9ce668a6e31cdf9` 1. Go to the _Admin Control Panel_ and press _Gather All Data_ to fetch historical data
9. Go to the _Admin Control Panel_ and press _Gather All Data_ to fetch historical data 1. Press _Sign out_ and check out the _Live Demo_
10. Press _Sign out_ and check out the _Live Demo_
## Development ## Development
Please make sure you have completed the instructions from _Setup_ Please make sure you have completed the instructions from [_Setup_](#Setup)
### Start server ### Start server

View File

@ -208,22 +208,22 @@
} }
} }
}, },
"helper": { "common": {
"root": "libs/helper", "root": "libs/common",
"sourceRoot": "libs/helper/src", "sourceRoot": "libs/common/src",
"projectType": "library", "projectType": "library",
"architect": { "architect": {
"lint": { "lint": {
"builder": "@nrwl/linter:eslint", "builder": "@nrwl/linter:eslint",
"options": { "options": {
"lintFilePatterns": ["libs/helper/**/*.ts"] "lintFilePatterns": ["libs/common/**/*.ts"]
} }
}, },
"test": { "test": {
"builder": "@nrwl/jest:jest", "builder": "@nrwl/jest:jest",
"outputs": ["coverage/libs/helper"], "outputs": ["coverage/libs/common"],
"options": { "options": {
"jestConfig": "libs/helper/jest.config.js", "jestConfig": "libs/common/jest.config.js",
"passWithNoTests": true "passWithNoTests": true
} }
} }

View File

@ -1,10 +1,10 @@
import { RequestWithUser } from '@ghostfolio/api/app/interfaces/request-with-user.type'; import { Access } from '@ghostfolio/common/interfaces';
import { RequestWithUser } from '@ghostfolio/common/types';
import { Controller, Get, Inject, UseGuards } from '@nestjs/common'; import { Controller, Get, Inject, UseGuards } from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { AccessService } from './access.service'; import { AccessService } from './access.service';
import { Access } from './interfaces/access.interface';
@Controller('access') @Controller('access')
export class AccessController { export class AccessController {

View File

@ -1,9 +1,8 @@
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { AccessWithGranteeUser } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { AccessWithGranteeUser } from './interfaces/access-with-grantee-user.type';
@Injectable() @Injectable()
export class AccessService { export class AccessService {
public constructor(private prisma: PrismaService) {} public constructor(private prisma: PrismaService) {}

View File

@ -0,0 +1,241 @@
import { nullifyValuesInObjects } from '@ghostfolio/api/helper/object.helper';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
import {
getPermissions,
hasPermission,
permissions
} from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types';
import {
Body,
Controller,
Delete,
Get,
Headers,
HttpException,
Inject,
Param,
Post,
Put,
UseGuards
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { Account as AccountModel } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { AccountService } from './account.service';
import { CreateAccountDto } from './create-account.dto';
import { UpdateAccountDto } from './update-account.dto';
@Controller('account')
export class AccountController {
public constructor(
private readonly accountService: AccountService,
private readonly impersonationService: ImpersonationService,
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
@Delete(':id')
@UseGuards(AuthGuard('jwt'))
public async deleteAccount(@Param('id') id: string): Promise<AccountModel> {
if (
!hasPermission(
getPermissions(this.request.user.role),
permissions.deleteAccount
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const account = await this.accountService.accountWithOrders(
{
id_userId: {
id,
userId: this.request.user.id
}
},
{ Order: true }
);
if (account?.isDefault || account?.Order.length > 0) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return this.accountService.deleteAccount(
{
id_userId: {
id,
userId: this.request.user.id
}
},
this.request.user.id
);
}
@Get()
@UseGuards(AuthGuard('jwt'))
public async getAllAccounts(
@Headers('impersonation-id') impersonationId
): Promise<AccountModel[]> {
const impersonationUserId = await this.impersonationService.validateImpersonationId(
impersonationId,
this.request.user.id
);
let accounts = await this.accountService.accounts({
include: { Order: true, Platform: true },
orderBy: { name: 'asc' },
where: { userId: impersonationUserId || this.request.user.id }
});
if (
impersonationUserId &&
!hasPermission(
getPermissions(this.request.user.role),
permissions.readForeignPortfolio
)
) {
accounts = nullifyValuesInObjects(accounts, [
'fee',
'quantity',
'unitPrice'
]);
}
return accounts;
}
@Get(':id')
@UseGuards(AuthGuard('jwt'))
public async getAccountById(@Param('id') id: string): Promise<AccountModel> {
return this.accountService.account({
id_userId: {
id,
userId: this.request.user.id
}
});
}
@Post()
@UseGuards(AuthGuard('jwt'))
public async createAccount(
@Body() data: CreateAccountDto
): Promise<AccountModel> {
if (
!hasPermission(
getPermissions(this.request.user.role),
permissions.createAccount
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
if (data.platformId) {
const platformId = data.platformId;
delete data.platformId;
return this.accountService.createAccount(
{
...data,
Platform: { connect: { id: platformId } },
User: { connect: { id: this.request.user.id } }
},
this.request.user.id
);
} else {
delete data.platformId;
return this.accountService.createAccount(
{
...data,
User: { connect: { id: this.request.user.id } }
},
this.request.user.id
);
}
}
@Put(':id')
@UseGuards(AuthGuard('jwt'))
public async update(@Param('id') id: string, @Body() data: UpdateAccountDto) {
if (
!hasPermission(
getPermissions(this.request.user.role),
permissions.updateAccount
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const originalAccount = await this.accountService.account({
id_userId: {
id,
userId: this.request.user.id
}
});
if (!originalAccount) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
if (data.platformId) {
const platformId = data.platformId;
delete data.platformId;
return this.accountService.updateAccount(
{
data: {
...data,
Platform: { connect: { id: platformId } },
User: { connect: { id: this.request.user.id } }
},
where: {
id_userId: {
id,
userId: this.request.user.id
}
}
},
this.request.user.id
);
} else {
// platformId is null, remove it
delete data.platformId;
return this.accountService.updateAccount(
{
data: {
...data,
Platform: originalAccount.platformId
? { disconnect: true }
: undefined,
User: { connect: { id: this.request.user.id } }
},
where: {
id_userId: {
id,
userId: this.request.user.id
}
}
},
this.request.user.id
);
}
}
}

View File

@ -0,0 +1,30 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service';
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service';
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
import { RakutenRapidApiService } from '@ghostfolio/api/services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Module } from '@nestjs/common';
import { RedisCacheModule } from '../redis-cache/redis-cache.module';
import { AccountController } from './account.controller';
import { AccountService } from './account.service';
@Module({
imports: [RedisCacheModule],
controllers: [AccountController],
providers: [
AccountService,
AlphaVantageService,
ConfigurationService,
DataProviderService,
GhostfolioScraperApiService,
ImpersonationService,
PrismaService,
RakutenRapidApiService,
YahooFinanceService
]
})
export class AccountModule {}

View File

@ -0,0 +1,89 @@
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Injectable } from '@nestjs/common';
import { Account, Order, Prisma } from '@prisma/client';
import { RedisCacheService } from '../redis-cache/redis-cache.service';
@Injectable()
export class AccountService {
public constructor(
private readonly redisCacheService: RedisCacheService,
private prisma: PrismaService
) {}
public async account(
accountWhereUniqueInput: Prisma.AccountWhereUniqueInput
): Promise<Account | null> {
return this.prisma.account.findUnique({
where: accountWhereUniqueInput
});
}
public async accountWithOrders(
accountWhereUniqueInput: Prisma.AccountWhereUniqueInput,
accountInclude: Prisma.AccountInclude
): Promise<
Account & {
Order?: Order[];
}
> {
return this.prisma.account.findUnique({
include: accountInclude,
where: accountWhereUniqueInput
});
}
public async accounts(params: {
include?: Prisma.AccountInclude;
skip?: number;
take?: number;
cursor?: Prisma.AccountWhereUniqueInput;
where?: Prisma.AccountWhereInput;
orderBy?: Prisma.AccountOrderByInput;
}): Promise<Account[]> {
const { include, skip, take, cursor, where, orderBy } = params;
return this.prisma.account.findMany({
cursor,
include,
orderBy,
skip,
take,
where
});
}
public async createAccount(
data: Prisma.AccountCreateInput,
aUserId: string
): Promise<Account> {
return this.prisma.account.create({
data
});
}
public async deleteAccount(
where: Prisma.AccountWhereUniqueInput,
aUserId: string
): Promise<Account> {
this.redisCacheService.remove(`${aUserId}.portfolio`);
return this.prisma.account.delete({
where
});
}
public async updateAccount(
params: {
where: Prisma.AccountWhereUniqueInput;
data: Prisma.AccountUpdateInput;
},
aUserId: string
): Promise<Account> {
const { data, where } = params;
return this.prisma.account.update({
data,
where
});
}
}

View File

@ -0,0 +1,14 @@
import { AccountType } from '@prisma/client';
import { IsString, ValidateIf } from 'class-validator';
export class CreateAccountDto {
@IsString()
accountType: AccountType;
@IsString()
name: string;
@IsString()
@ValidateIf((object, value) => value !== null)
platformId: string | null;
}

View File

@ -0,0 +1,17 @@
import { AccountType } from '@prisma/client';
import { IsString, ValidateIf } from 'class-validator';
export class UpdateAccountDto {
@IsString()
accountType: AccountType;
@IsString()
id: string;
@IsString()
name: string;
@IsString()
@ValidateIf((object, value) => value !== null)
platformId: string | null;
}

View File

@ -1,6 +1,11 @@
import { RequestWithUser } from '@ghostfolio/api/app/interfaces/request-with-user.type';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service'; import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
import { getPermissions, hasPermission, permissions } from '@ghostfolio/helper'; import { AdminData } from '@ghostfolio/common/interfaces';
import {
getPermissions,
hasPermission,
permissions
} from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Controller, Controller,
Get, Get,
@ -14,7 +19,6 @@ import { AuthGuard } from '@nestjs/passport';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { AdminService } from './admin.service'; import { AdminService } from './admin.service';
import { AdminData } from './interfaces/admin-data.interface';
@Controller('admin') @Controller('admin')
export class AdminController { export class AdminController {

View File

@ -1,10 +1,9 @@
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 { AdminData } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Currency } from '@prisma/client'; import { Currency } from '@prisma/client';
import { AdminData } from './interfaces/admin-data.interface';
@Injectable() @Injectable()
export class AdminService { export class AdminService {
public constructor( public constructor(
@ -97,7 +96,7 @@ export class AdminService {
}, },
select: { select: {
_count: { _count: {
select: { Order: true } select: { Account: true, Order: true }
}, },
alias: true, alias: true,
Analytics: { Analytics: {
@ -109,7 +108,7 @@ export class AdminService {
createdAt: true, createdAt: true,
id: true id: true
}, },
take: 20, take: 30,
where: { where: {
NOT: { NOT: {
Analytics: null Analytics: null

View File

@ -16,6 +16,7 @@ import { YahooFinanceService } from '../services/data-provider/yahoo-finance/yah
import { ExchangeRateDataService } from '../services/exchange-rate-data.service'; import { ExchangeRateDataService } from '../services/exchange-rate-data.service';
import { PrismaService } from '../services/prisma.service'; import { PrismaService } from '../services/prisma.service';
import { AccessModule } from './access/access.module'; import { AccessModule } from './access/access.module';
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';
@ -32,6 +33,7 @@ import { UserModule } from './user/user.module';
imports: [ imports: [
AdminModule, AdminModule,
AccessModule, AccessModule,
AccountModule,
AuthModule, AuthModule,
CacheModule, CacheModule,
ConfigModule.forRoot(), ConfigModule.forRoot(),

View File

@ -1,5 +1,5 @@
import { RequestWithUser } from '@ghostfolio/api/app/interfaces/request-with-user.type'; import { RequestWithUser } from '@ghostfolio/common/types';
import { Controller, Inject, Param, Post, UseGuards } from '@nestjs/common'; import { Controller, Inject, Post, UseGuards } from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';

View File

@ -1,9 +1,6 @@
import { RequestWithUser } from '@ghostfolio/api/app/interfaces/request-with-user.type'; import { baseCurrency, benchmarks } from '@ghostfolio/common/config';
import { import { isApiTokenAuthorized } from '@ghostfolio/common/permissions';
baseCurrency, import { RequestWithUser } from '@ghostfolio/common/types';
benchmarks,
isApiTokenAuthorized
} from '@ghostfolio/helper';
import { import {
Body, Body,
Controller, Controller,

View File

@ -3,11 +3,11 @@ import { DataProviderService } from '@ghostfolio/api/services/data-provider.serv
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 { RulesService } from '@ghostfolio/api/services/rules.service'; import { RulesService } from '@ghostfolio/api/services/rules.service';
import { OrderWithAccount } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Currency, Type } from '@prisma/client'; import { Currency, Type } from '@prisma/client';
import { parseISO } from 'date-fns'; import { parseISO } from 'date-fns';
import { OrderWithPlatform } from '../order/interfaces/order-with-platform.type';
import { CreateOrderDto } from './create-order.dto'; import { CreateOrderDto } from './create-order.dto';
import { Data } from './interfaces/data.interface'; import { Data } from './interfaces/data.interface';
@ -33,10 +33,13 @@ export class ExperimentalService {
aDate: Date, aDate: Date,
aBaseCurrency: Currency aBaseCurrency: Currency
): Promise<Data> { ): Promise<Data> {
const ordersWithPlatform: OrderWithPlatform[] = aOrders.map((order) => { const ordersWithPlatform: OrderWithAccount[] = aOrders.map((order) => {
return { return {
...order, ...order,
accountId: undefined,
accountUserId: undefined,
createdAt: new Date(), createdAt: new Date(),
dataSource: undefined,
date: parseISO(order.date), date: parseISO(order.date),
fee: 0, fee: 0,
id: undefined, id: undefined,

View File

@ -1,7 +1,7 @@
import { InfoItem } from '@ghostfolio/common/interfaces';
import { Controller, Get } from '@nestjs/common'; import { Controller, Get } from '@nestjs/common';
import { InfoService } from './info.service'; import { InfoService } from './info.service';
import { InfoItem } from './interfaces/info-item.interface';
@Controller('info') @Controller('info')
export class InfoController { export class InfoController {

View File

@ -1,12 +1,11 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { permissions } from '@ghostfolio/helper'; import { InfoItem } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import { Currency } from '@prisma/client'; import { Currency } from '@prisma/client';
import { InfoItem } from './interfaces/info-item.interface';
@Injectable() @Injectable()
export class InfoService { export class InfoService {
private static DEMO_USER_ID = '9b112b4d-3b7d-4bad-9bdd-3b0f7b4dac2f'; private static DEMO_USER_ID = '9b112b4d-3b7d-4bad-9bdd-3b0f7b4dac2f';
@ -26,7 +25,11 @@ export class InfoService {
const globalPermissions: string[] = []; const globalPermissions: string[] = [];
if (this.configurationService.get('ENABLE_FEATURE_SOCIAL_LOGIN')) { if (this.configurationService.get('ENABLE_FEATURE_SOCIAL_LOGIN')) {
globalPermissions.push(permissions.useSocialLogin); globalPermissions.push(permissions.enableSocialLogin);
}
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
globalPermissions.push(permissions.enableSubscription);
} }
return { return {

View File

@ -1,3 +0,0 @@
import { Settings, User } from '@prisma/client';
export type UserWithSettings = User & { Settings: Settings };

View File

@ -1,20 +1,22 @@
import { Currency, Type } from '@prisma/client'; import { Currency, DataSource, Type } from '@prisma/client';
import { IsISO8601, IsNumber, IsString, ValidateIf } from 'class-validator'; import { IsISO8601, IsNumber, IsString, ValidateIf } from 'class-validator';
export class CreateOrderDto { export class CreateOrderDto {
@IsString()
accountId: string;
@IsString() @IsString()
currency: Currency; currency: Currency;
@IsString()
dataSource: DataSource;
@IsISO8601() @IsISO8601()
date: string; date: string;
@IsNumber() @IsNumber()
fee: number; fee: number;
@IsString()
@ValidateIf((object, value) => value !== null)
platformId: string | null;
@IsNumber() @IsNumber()
quantity: number; quantity: number;

View File

@ -1,3 +0,0 @@
import { Order, Platform } from '@prisma/client';
export type OrderWithPlatform = Order & { Platform?: Platform };

View File

@ -1,7 +1,11 @@
import { RequestWithUser } from '@ghostfolio/api/app/interfaces/request-with-user.type';
import { nullifyValuesInObjects } from '@ghostfolio/api/helper/object.helper'; import { nullifyValuesInObjects } from '@ghostfolio/api/helper/object.helper';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service'; import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
import { getPermissions, hasPermission, permissions } from '@ghostfolio/helper'; import {
getPermissions,
hasPermission,
permissions
} from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Body, Body,
Controller, Controller,
@ -71,7 +75,11 @@ export class OrderController {
let orders = await this.orderService.orders({ let orders = await this.orderService.orders({
include: { include: {
Platform: true Account: {
include: {
Platform: true
}
}
}, },
orderBy: { date: 'desc' }, orderBy: { date: 'desc' },
where: { userId: impersonationUserId || this.request.user.id } where: { userId: impersonationUserId || this.request.user.id }
@ -118,31 +126,22 @@ export class OrderController {
const date = parseISO(data.date); const date = parseISO(data.date);
if (data.platformId) { const accountId = data.accountId;
const platformId = data.platformId; delete data.accountId;
delete data.platformId;
return this.orderService.createOrder( return this.orderService.createOrder(
{ {
...data, ...data,
date, date,
Platform: { connect: { id: platformId } }, Account: {
User: { connect: { id: this.request.user.id } } connect: {
id_userId: { id: accountId, userId: this.request.user.id }
}
}, },
this.request.user.id User: { connect: { id: this.request.user.id } }
); },
} else { this.request.user.id
delete data.platformId; );
return this.orderService.createOrder(
{
...data,
date,
User: { connect: { id: this.request.user.id } }
},
this.request.user.id
);
}
} }
@Put(':id') @Put(':id')
@ -167,52 +166,38 @@ export class OrderController {
} }
}); });
const date = parseISO(data.date); if (!originalOrder) {
throw new HttpException(
if (data.platformId) { getReasonPhrase(StatusCodes.FORBIDDEN),
const platformId = data.platformId; StatusCodes.FORBIDDEN
delete data.platformId;
return this.orderService.updateOrder(
{
data: {
...data,
date,
Platform: { connect: { id: platformId } },
User: { connect: { id: this.request.user.id } }
},
where: {
id_userId: {
id,
userId: this.request.user.id
}
}
},
this.request.user.id
);
} else {
// platformId is null, remove it
delete data.platformId;
return this.orderService.updateOrder(
{
data: {
...data,
date,
Platform: originalOrder.platformId
? { disconnect: true }
: undefined,
User: { connect: { id: this.request.user.id } }
},
where: {
id_userId: {
id,
userId: this.request.user.id
}
}
},
this.request.user.id
); );
} }
const date = parseISO(data.date);
const accountId = data.accountId;
delete data.accountId;
return this.orderService.updateOrder(
{
data: {
...data,
date,
Account: {
connect: {
id_userId: { id: accountId, userId: this.request.user.id }
}
},
User: { connect: { id: this.request.user.id } }
},
where: {
id_userId: {
id,
userId: this.request.user.id
}
}
},
this.request.user.id
);
} }
} }

View File

@ -1,11 +1,11 @@
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service'; import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { OrderWithAccount } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Order, Prisma } from '@prisma/client'; import { Order, Prisma } from '@prisma/client';
import { CacheService } from '../cache/cache.service'; import { CacheService } from '../cache/cache.service';
import { RedisCacheService } from '../redis-cache/redis-cache.service'; import { RedisCacheService } from '../redis-cache/redis-cache.service';
import { OrderWithPlatform } from './interfaces/order-with-platform.type';
@Injectable() @Injectable()
export class OrderService { export class OrderService {
@ -31,7 +31,7 @@ export class OrderService {
cursor?: Prisma.OrderWhereUniqueInput; cursor?: Prisma.OrderWhereUniqueInput;
where?: Prisma.OrderWhereInput; where?: Prisma.OrderWhereInput;
orderBy?: Prisma.OrderOrderByInput; orderBy?: Prisma.OrderOrderByInput;
}): Promise<OrderWithPlatform[]> { }): Promise<OrderWithAccount[]> {
const { include, skip, take, cursor, where, orderBy } = params; const { include, skip, take, cursor, where, orderBy } = params;
return this.prisma.order.findMany({ return this.prisma.order.findMany({

View File

@ -1,20 +1,22 @@
import { Currency, Type } from '@prisma/client'; import { Currency, DataSource, Type } from '@prisma/client';
import { IsISO8601, IsNumber, IsString, ValidateIf } from 'class-validator'; import { IsISO8601, IsNumber, IsString, ValidateIf } from 'class-validator';
export class UpdateOrderDto { export class UpdateOrderDto {
@IsString()
accountId: string;
@IsString() @IsString()
currency: Currency; currency: Currency;
@IsString()
dataSource: DataSource;
@IsISO8601() @IsISO8601()
date: string; date: string;
@IsNumber() @IsNumber()
fee: number; fee: number;
@IsString()
@ValidateIf((object, value) => value !== null)
platformId: string | null;
@IsString() @IsString()
id: string; id: string;

View File

@ -1,6 +1,8 @@
import { Currency } from '@prisma/client';
export interface PortfolioPositionDetail { export interface PortfolioPositionDetail {
averagePrice: number; averagePrice: number;
currency: string; currency: Currency;
firstBuyDate: string; firstBuyDate: string;
grossPerformance: number; grossPerformance: number;
grossPerformancePercent: number; grossPerformancePercent: number;
@ -11,6 +13,7 @@ export interface PortfolioPositionDetail {
minPrice: number; minPrice: number;
quantity: number; quantity: number;
symbol: string; symbol: string;
transactionCount: number;
} }
export interface HistoricalDataItem { export interface HistoricalDataItem {

View File

@ -4,7 +4,19 @@ import {
} from '@ghostfolio/api/helper/object.helper'; } from '@ghostfolio/api/helper/object.helper';
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 { getPermissions, hasPermission, permissions } from '@ghostfolio/helper'; import {
PortfolioItem,
PortfolioOverview,
PortfolioPerformance,
PortfolioPosition,
PortfolioReport
} from '@ghostfolio/common/interfaces';
import {
getPermissions,
hasPermission,
permissions
} from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Controller, Controller,
Get, Get,
@ -21,16 +33,10 @@ import { AuthGuard } from '@nestjs/passport';
import { Response } from 'express'; import { Response } from 'express';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { RequestWithUser } from '../interfaces/request-with-user.type';
import { PortfolioItem } from './interfaces/portfolio-item.interface';
import { PortfolioOverview } from './interfaces/portfolio-overview.interface';
import { PortfolioPerformance } from './interfaces/portfolio-performance.interface';
import { import {
HistoricalDataItem, HistoricalDataItem,
PortfolioPositionDetail PortfolioPositionDetail
} from './interfaces/portfolio-position-detail.interface'; } from './interfaces/portfolio-position-detail.interface';
import { PortfolioPosition } from './interfaces/portfolio-position.interface';
import { PortfolioReport } from './interfaces/portfolio-report.interface';
import { PortfolioService } from './portfolio.service'; import { PortfolioService } from './portfolio.service';
@Controller('portfolio') @Controller('portfolio')
@ -185,11 +191,11 @@ export class PortfolioController {
portfolioPosition.investment = portfolioPosition.investment =
portfolioPosition.investment / totalInvestment; portfolioPosition.investment / totalInvestment;
for (const [platform, { current, original }] of Object.entries( for (const [account, { current, original }] of Object.entries(
portfolioPosition.platforms portfolioPosition.accounts
)) { )) {
portfolioPosition.platforms[platform].current = current / totalValue; portfolioPosition.accounts[account].current = current / totalValue;
portfolioPosition.platforms[platform].original = portfolioPosition.accounts[account].original =
original / totalInvestment; original / totalInvestment;
} }

View File

@ -1,10 +1,14 @@
import { RequestWithUser } from '@ghostfolio/api/app/interfaces/request-with-user.type';
import { Portfolio } from '@ghostfolio/api/models/portfolio'; import { Portfolio } from '@ghostfolio/api/models/portfolio';
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service'; import { DataProviderService } from '@ghostfolio/api/services/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 { IOrder } from '@ghostfolio/api/services/interfaces/interfaces'; import { IOrder } from '@ghostfolio/api/services/interfaces/interfaces';
import { RulesService } from '@ghostfolio/api/services/rules.service'; import { RulesService } from '@ghostfolio/api/services/rules.service';
import {
PortfolioItem,
PortfolioOverview
} from '@ghostfolio/common/interfaces';
import { DateRange, RequestWithUser } from '@ghostfolio/common/types';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { import {
@ -26,9 +30,6 @@ import * as roundTo from 'round-to';
import { OrderService } from '../order/order.service'; import { OrderService } from '../order/order.service';
import { RedisCacheService } from '../redis-cache/redis-cache.service'; import { RedisCacheService } from '../redis-cache/redis-cache.service';
import { UserService } from '../user/user.service'; import { UserService } from '../user/user.service';
import { DateRange } from './interfaces/date-range.type';
import { PortfolioItem } from './interfaces/portfolio-item.interface';
import { PortfolioOverview } from './interfaces/portfolio-overview.interface';
import { import {
HistoricalDataItem, HistoricalDataItem,
PortfolioPositionDetail PortfolioPositionDetail
@ -73,7 +74,7 @@ export class PortfolioService {
// Get portfolio from database // Get portfolio from database
const orders = await this.orderService.orders({ const orders = await this.orderService.orders({
include: { include: {
Platform: true Account: true
}, },
orderBy: { date: 'asc' }, orderBy: { date: 'asc' },
where: { userId: aUserId } where: { userId: aUserId }
@ -157,8 +158,8 @@ export class PortfolioService {
return { return {
date: format(parseISO(portfolioItem.date), 'yyyy-MM-dd'), date: format(parseISO(portfolioItem.date), 'yyyy-MM-dd'),
grossPerformancePercent: portfolioItem.grossPerformancePercent, grossPerformancePercent: portfolioItem.grossPerformancePercent,
marketPrice: portfolioItem.value || null, marketPrice: portfolioItem.value ?? null,
value: portfolioItem.value || null value: portfolioItem.value - portfolioItem.investment ?? null
}; };
}); });
} }
@ -209,7 +210,8 @@ export class PortfolioService {
firstBuyDate, firstBuyDate,
investment, investment,
marketPrice, marketPrice,
quantity quantity,
transactionCount
} = portfolio.getPositions(new Date())[aSymbol]; } = portfolio.getPositions(new Date())[aSymbol];
const historicalData = await this.dataProviderService.getHistorical( const historicalData = await this.dataProviderService.getHistorical(
@ -262,6 +264,7 @@ export class PortfolioService {
maxPrice, maxPrice,
minPrice, minPrice,
quantity, quantity,
transactionCount,
grossPerformance: this.exchangeRateDataService.toCurrency( grossPerformance: this.exchangeRateDataService.toCurrency(
marketPrice - averagePrice, marketPrice - averagePrice,
currency, currency,
@ -315,7 +318,8 @@ export class PortfolioService {
maxPrice: undefined, maxPrice: undefined,
minPrice: undefined, minPrice: undefined,
quantity: undefined, quantity: undefined,
symbol: aSymbol symbol: aSymbol,
transactionCount: undefined
}; };
} }
@ -331,7 +335,8 @@ export class PortfolioService {
maxPrice: undefined, maxPrice: undefined,
minPrice: undefined, minPrice: undefined,
quantity: undefined, quantity: undefined,
symbol: aSymbol symbol: aSymbol,
transactionCount: undefined
}; };
} }

View File

@ -1,4 +1,7 @@
import { DataSource } from '@prisma/client';
export interface LookupItem { export interface LookupItem {
dataSource: DataSource;
name: string; name: string;
symbol: string; symbol: string;
} }

View File

@ -1,6 +1,7 @@
import { Currency } from '@prisma/client'; import { Currency, DataSource } from '@prisma/client';
export interface SymbolItem { export interface SymbolItem {
currency: Currency; currency: Currency;
dataSource: DataSource;
marketPrice: number; marketPrice: number;
} }

View File

@ -1,4 +1,4 @@
import { RequestWithUser } from '@ghostfolio/api/app/interfaces/request-with-user.type'; import { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Controller, Controller,
Get, Get,
@ -28,9 +28,12 @@ export class SymbolController {
*/ */
@Get('lookup') @Get('lookup')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async lookupSymbol(@Query() { query }): Promise<LookupItem[]> { public async lookupSymbol(
@Query() { query = '' }
): Promise<{ items: LookupItem[] }> {
try { try {
return this.symbolService.lookup(query); const encodedQuery = encodeURIComponent(query.toLowerCase());
return this.symbolService.lookup(encodedQuery);
} catch { } catch {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR), getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),

View File

@ -1,8 +1,8 @@
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider.service';
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
import { convertFromYahooSymbol } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service'; import { convertFromYahooSymbol } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Currency } from '@prisma/client'; import { Currency, DataSource } from '@prisma/client';
import * as bent from 'bent';
import { LookupItem } from './interfaces/lookup-item.interface'; import { LookupItem } from './interfaces/lookup-item.interface';
import { SymbolItem } from './interfaces/symbol-item.interface'; import { SymbolItem } from './interfaces/symbol-item.interface';
@ -10,55 +10,45 @@ import { SymbolItem } from './interfaces/symbol-item.interface';
@Injectable() @Injectable()
export class SymbolService { export class SymbolService {
public constructor( public constructor(
private readonly dataProviderService: DataProviderService private readonly dataProviderService: DataProviderService,
private readonly ghostfolioScraperApiService: GhostfolioScraperApiService
) {} ) {}
public async get(aSymbol: string): Promise<SymbolItem> { public async get(aSymbol: string): Promise<SymbolItem> {
const response = await this.dataProviderService.get([aSymbol]); const response = await this.dataProviderService.get([aSymbol]);
const { currency, marketPrice } = response[aSymbol]; const { currency, dataSource, marketPrice } = response[aSymbol];
return { return {
dataSource,
marketPrice, marketPrice,
currency: <Currency>(<unknown>currency) currency: <Currency>(<unknown>currency)
}; };
} }
public async lookup(aQuery: string): Promise<LookupItem[]> { public async lookup(aQuery: string): Promise<{ items: LookupItem[] }> {
const get = bent( const results: { items: LookupItem[] } = { items: [] };
`https://query1.finance.yahoo.com/v1/finance/search?q=${aQuery}&lang=en-US&region=US&quotesCount=8&newsCount=0&enableFuzzyQuery=false&quotesQueryId=tss_match_phrase_query&multiQuoteQueryId=multi_quote_single_token_query&newsQueryId=news_cie_vespa&enableCb=true&enableNavLinks=false&enableEnhancedTrivialQuery=true`,
'GET', if (!aQuery) {
'json', return results;
200 }
);
try { try {
const { quotes } = await get(); const { items } = await this.dataProviderService.search(aQuery);
results.items = items;
return quotes // Add custom symbols
.filter(({ isYahooFinance }) => { const scraperConfigurations = await this.ghostfolioScraperApiService.getScraperConfigurations();
return isYahooFinance; scraperConfigurations.forEach((scraperConfiguration) => {
}) if (scraperConfiguration.name.toLowerCase().startsWith(aQuery)) {
.filter(({ quoteType }) => { results.items.push({
return ( dataSource: DataSource.GHOSTFOLIO,
quoteType === 'CRYPTOCURRENCY' || name: scraperConfiguration.name,
quoteType === 'EQUITY' || symbol: scraperConfiguration.symbol
quoteType === 'ETF' });
); }
}) });
.filter(({ quoteType, symbol }) => {
if (quoteType === 'CRYPTOCURRENCY') {
// Only allow cryptocurrencies in USD
return symbol.includes('USD');
}
return true; return results;
})
.map(({ longname, shortname, symbol }) => {
return {
name: longname || shortname,
symbol: convertFromYahooSymbol(symbol)
};
});
} catch (error) { } catch (error) {
console.error(error); console.error(error);

View File

@ -1,20 +0,0 @@
import { Currency } from '@prisma/client';
import { Access } from './access.interface';
export interface User {
access: Access[];
alias?: string;
id: string;
permissions: string[];
settings: UserSettings;
subscription: {
expiresAt: Date;
type: 'Trial';
};
}
export interface UserSettings {
baseCurrency: Currency;
locale: string;
}

View File

@ -1,7 +1,10 @@
import { Currency } from '@prisma/client'; import { Currency, ViewMode } from '@prisma/client';
import { IsString } from 'class-validator'; import { IsString } from 'class-validator';
export class UpdateUserSettingsDto { export class UpdateUserSettingsDto {
@IsString() @IsString()
currency: Currency; baseCurrency: Currency;
@IsString()
viewMode: ViewMode;
} }

View File

@ -1,8 +1,14 @@
import { RequestWithUser } from '@ghostfolio/api/app/interfaces/request-with-user.type'; import { User } from '@ghostfolio/common/interfaces';
import { getPermissions, hasPermission, permissions } from '@ghostfolio/helper'; import {
getPermissions,
hasPermission,
permissions
} from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Body, Body,
Controller, Controller,
Delete,
Get, Get,
HttpException, HttpException,
Inject, Inject,
@ -15,10 +21,10 @@ import { REQUEST } from '@nestjs/core';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { Provider } from '@prisma/client'; import { Provider } from '@prisma/client';
import { User as UserModel } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { UserItem } from './interfaces/user-item.interface'; import { UserItem } from './interfaces/user-item.interface';
import { User } from './interfaces/user.interface';
import { UpdateUserSettingsDto } from './update-user-settings.dto'; import { UpdateUserSettingsDto } from './update-user-settings.dto';
import { UserService } from './user.service'; import { UserService } from './user.service';
@ -30,6 +36,27 @@ export class UserController {
private readonly userService: UserService private readonly userService: UserService
) {} ) {}
@Delete(':id')
@UseGuards(AuthGuard('jwt'))
public async deleteUser(@Param('id') id: string): Promise<UserModel> {
if (
!hasPermission(
getPermissions(this.request.user.role),
permissions.deleteUser
) ||
id === this.request.user.id
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return this.userService.deleteUser({
id
});
}
@Get() @Get()
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async getUser(@Param('id') id: string): Promise<User> { public async getUser(@Param('id') id: string): Promise<User> {
@ -66,8 +93,9 @@ export class UserController {
} }
return await this.userService.updateUserSettings({ return await this.userService.updateUserSettings({
currency: data.currency, currency: data.baseCurrency,
userId: this.request.user.id userId: this.request.user.id,
viewMode: data.viewMode
}); });
} }
} }

View File

@ -1,18 +1,13 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { import { locale } from '@ghostfolio/common/config';
getPermissions, import { resetHours } from '@ghostfolio/common/helper';
locale, import { User as IUser, UserWithSettings } from '@ghostfolio/common/interfaces';
permissions, import { getPermissions, permissions } from '@ghostfolio/common/permissions';
resetHours
} from '@ghostfolio/helper';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Currency, Prisma, Provider, User } from '@prisma/client'; import { Currency, Prisma, Provider, User, ViewMode } from '@prisma/client';
import { add } from 'date-fns'; import { add } from 'date-fns';
import { UserWithSettings } from '../interfaces/user-with-settings';
import { User as IUser } from './interfaces/user.interface';
const crypto = require('crypto'); const crypto = require('crypto');
@Injectable() @Injectable()
@ -25,6 +20,7 @@ export class UserService {
) {} ) {}
public async getUser({ public async getUser({
Account,
alias, alias,
id, id,
role, role,
@ -53,10 +49,12 @@ export class UserService {
id: accessItem.id id: accessItem.id
}; };
}), }),
accounts: Account,
permissions: currentPermissions, permissions: currentPermissions,
settings: { settings: {
baseCurrency: Settings?.currency || UserService.DEFAULT_CURRENCY, locale,
locale baseCurrency: Settings?.currency ?? UserService.DEFAULT_CURRENCY,
viewMode: Settings.viewMode ?? ViewMode.DEFAULT
}, },
subscription: { subscription: {
expiresAt: resetHours(add(new Date(), { days: 7 })), expiresAt: resetHours(add(new Date(), { days: 7 })),
@ -69,7 +67,7 @@ export class UserService {
userWhereUniqueInput: Prisma.UserWhereUniqueInput userWhereUniqueInput: Prisma.UserWhereUniqueInput
): Promise<UserWithSettings | null> { ): Promise<UserWithSettings | null> {
const user = await this.prisma.user.findUnique({ const user = await this.prisma.user.findUnique({
include: { Settings: true }, include: { Account: true, Settings: true },
where: userWhereUniqueInput where: userWhereUniqueInput
}); });
@ -83,7 +81,8 @@ export class UserService {
user.Settings = { user.Settings = {
currency: UserService.DEFAULT_CURRENCY, currency: UserService.DEFAULT_CURRENCY,
updatedAt: new Date(), updatedAt: new Date(),
userId: user?.id userId: user?.id,
viewMode: ViewMode.DEFAULT
}; };
} }
@ -116,7 +115,15 @@ export class UserService {
public async createUser(data?: Prisma.UserCreateInput): Promise<User> { public async createUser(data?: Prisma.UserCreateInput): Promise<User> {
let user = await this.prisma.user.create({ let user = await this.prisma.user.create({
data data: {
...data,
Account: {
create: {
isDefault: true,
name: 'Default Account'
}
}
}
}); });
if (data.provider === Provider.ANONYMOUS) { if (data.provider === Provider.ANONYMOUS) {
@ -153,6 +160,28 @@ export class UserService {
} }
public async deleteUser(where: Prisma.UserWhereUniqueInput): Promise<User> { public async deleteUser(where: Prisma.UserWhereUniqueInput): Promise<User> {
await this.prisma.access.deleteMany({
where: { OR: [{ granteeUserId: where.id }, { userId: where.id }] }
});
await this.prisma.account.deleteMany({
where: { userId: where.id }
});
await this.prisma.analytics.delete({
where: { userId: where.id }
});
await this.prisma.order.deleteMany({
where: { userId: where.id }
});
try {
await this.prisma.settings.delete({
where: { userId: where.id }
});
} catch {}
return this.prisma.user.delete({ return this.prisma.user.delete({
where where
}); });
@ -160,10 +189,12 @@ export class UserService {
public async updateUserSettings({ public async updateUserSettings({
currency, currency,
userId userId,
viewMode
}: { }: {
currency: Currency; currency?: Currency;
userId: string; userId: string;
viewMode?: ViewMode;
}) { }) {
await this.prisma.settings.upsert({ await this.prisma.settings.upsert({
create: { create: {
@ -172,10 +203,12 @@ export class UserService {
connect: { connect: {
id: userId id: userId
} }
} },
viewMode
}, },
update: { update: {
currency currency,
viewMode
}, },
where: { where: {
userId: userId userId: userId

View File

@ -1,7 +1,4 @@
import { import { PortfolioItem, Position } from '@ghostfolio/common/interfaces';
PortfolioItem,
Position
} from '@ghostfolio/api/app/portfolio/interfaces/portfolio-item.interface';
import { Order } from '../order'; import { Order } from '../order';

View File

@ -1,4 +1,4 @@
import { PortfolioPosition } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position.interface'; import { PortfolioPosition } from '@ghostfolio/common/interfaces';
import { EvaluationResult } from './evaluation-result.interface'; import { EvaluationResult } from './evaluation-result.interface';

View File

@ -1,27 +1,27 @@
import { Currency, Platform } from '@prisma/client'; import { Account, Currency, Platform } from '@prisma/client';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { IOrder } from '../services/interfaces/interfaces'; import { IOrder } from '../services/interfaces/interfaces';
import { OrderType } from './order-type'; import { OrderType } from './order-type';
export class Order { export class Order {
private account: Account;
private currency: Currency; private currency: Currency;
private fee: number; private fee: number;
private date: string; private date: string;
private id: string; private id: string;
private quantity: number; private quantity: number;
private platform: Platform;
private symbol: string; private symbol: string;
private total: number; private total: number;
private type: OrderType; private type: OrderType;
private unitPrice: number; private unitPrice: number;
public constructor(data: IOrder) { public constructor(data: IOrder) {
this.account = data.account;
this.currency = data.currency; this.currency = data.currency;
this.fee = data.fee; this.fee = data.fee;
this.date = data.date; this.date = data.date;
this.id = data.id || uuidv4(); this.id = data.id || uuidv4();
this.platform = data.platform;
this.quantity = data.quantity; this.quantity = data.quantity;
this.symbol = data.symbol; this.symbol = data.symbol;
this.type = data.type; this.type = data.type;
@ -30,6 +30,10 @@ export class Order {
this.total = this.quantity * data.unitPrice; this.total = this.quantity * data.unitPrice;
} }
public getAccount() {
return this.account;
}
public getCurrency() { public getCurrency() {
return this.currency; return this.currency;
} }
@ -46,10 +50,6 @@ export class Order {
return this.id; return this.id;
} }
public getPlatform() {
return this.platform;
}
public getQuantity() { public getQuantity() {
return this.quantity; return this.quantity;
} }

View File

@ -1,62 +1,102 @@
import { baseCurrency, getUtc, getYesterday } from '@ghostfolio/helper'; import { UNKNOWN_KEY, baseCurrency } from '@ghostfolio/common/config';
import { Test } from '@nestjs/testing'; import { getUtc, getYesterday } from '@ghostfolio/common/helper';
import { Currency, Role, Type } from '@prisma/client'; import {
AccountType,
Currency,
DataSource,
Role,
Type,
ViewMode
} from '@prisma/client';
import { format } from 'date-fns';
import { ConfigurationService } from '../services/configuration.service';
import { DataProviderService } from '../services/data-provider.service'; import { DataProviderService } from '../services/data-provider.service';
import { AlphaVantageService } from '../services/data-provider/alpha-vantage/alpha-vantage.service';
import { GhostfolioScraperApiService } from '../services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
import { RakutenRapidApiService } from '../services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
import { YahooFinanceService } from '../services/data-provider/yahoo-finance/yahoo-finance.service';
import { ExchangeRateDataService } from '../services/exchange-rate-data.service'; import { ExchangeRateDataService } from '../services/exchange-rate-data.service';
import { MarketState } from '../services/interfaces/interfaces'; import { MarketState } from '../services/interfaces/interfaces';
import { PrismaService } from '../services/prisma.service';
import { RulesService } from '../services/rules.service'; import { RulesService } from '../services/rules.service';
import { Portfolio } from './portfolio'; import { Portfolio } from './portfolio';
jest.mock('../services/data-provider.service', () => {
return {
DataProviderService: jest.fn().mockImplementation(() => {
const today = format(new Date(), 'yyyy-MM-dd');
const yesterday = format(getYesterday(), 'yyyy-MM-dd');
return {
get: () => {
return Promise.resolve({
BTCUSD: {
currency: Currency.USD,
dataSource: DataSource.YAHOO,
exchange: UNKNOWN_KEY,
marketPrice: 57973.008,
marketState: MarketState.open,
name: 'Bitcoin USD',
type: 'Cryptocurrency'
},
ETHUSD: {
currency: Currency.USD,
dataSource: DataSource.YAHOO,
exchange: UNKNOWN_KEY,
marketPrice: 3915.337,
marketState: MarketState.open,
name: 'Ethereum USD',
type: 'Cryptocurrency'
}
});
},
getHistorical: () => {
return Promise.resolve({
BTCUSD: {
[yesterday]: 56710.122,
[today]: 57973.008
},
ETHUSD: {
[yesterday]: 3641.984,
[today]: 3915.337
}
});
}
};
})
};
});
jest.mock('../services/exchange-rate-data.service', () => {
return {
ExchangeRateDataService: jest.fn().mockImplementation(() => {
return {
initialize: () => Promise.resolve(),
toCurrency: (value: number) => value
};
})
};
});
jest.mock('../services/data-provider.service');
jest.mock('../services/exchange-rate-data.service');
jest.mock('../services/rules.service');
const DEFAULT_ACCOUNT_ID = '693a834b-eb89-42c9-ae47-35196c25d269';
const USER_ID = 'ca6ce867-5d31-495a-bce9-5942bbca9237';
describe('Portfolio', () => { describe('Portfolio', () => {
let alphaVantageService: AlphaVantageService;
let configurationService: ConfigurationService;
let dataProviderService: DataProviderService; let dataProviderService: DataProviderService;
let exchangeRateDataService: ExchangeRateDataService; let exchangeRateDataService: ExchangeRateDataService;
let ghostfolioScraperApiService: GhostfolioScraperApiService;
let portfolio: Portfolio; let portfolio: Portfolio;
let prismaService: PrismaService;
let rakutenRapidApiService: RakutenRapidApiService;
let rulesService: RulesService; let rulesService: RulesService;
let yahooFinanceService: YahooFinanceService;
beforeAll(async () => { beforeAll(async () => {
const app = await Test.createTestingModule({ dataProviderService = new DataProviderService(
imports: [], null,
providers: [ null,
AlphaVantageService, null,
ConfigurationService, null,
DataProviderService, null,
ExchangeRateDataService, null
GhostfolioScraperApiService,
PrismaService,
RakutenRapidApiService,
RulesService,
YahooFinanceService
]
}).compile();
alphaVantageService = app.get<AlphaVantageService>(AlphaVantageService);
configurationService = app.get<ConfigurationService>(ConfigurationService);
dataProviderService = app.get<DataProviderService>(DataProviderService);
exchangeRateDataService = app.get<ExchangeRateDataService>(
ExchangeRateDataService
); );
ghostfolioScraperApiService = app.get<GhostfolioScraperApiService>( exchangeRateDataService = new ExchangeRateDataService(null);
GhostfolioScraperApiService rulesService = new RulesService();
);
prismaService = app.get<PrismaService>(PrismaService);
rakutenRapidApiService = app.get<RakutenRapidApiService>(
RakutenRapidApiService
);
rulesService = app.get<RulesService>(RulesService);
yahooFinanceService = app.get<YahooFinanceService>(YahooFinanceService);
await exchangeRateDataService.initialize(); await exchangeRateDataService.initialize();
@ -67,15 +107,28 @@ describe('Portfolio', () => {
); );
portfolio.setUser({ portfolio.setUser({
accessToken: null, accessToken: null,
Account: [
{
accountType: AccountType.SECURITIES,
createdAt: new Date(),
id: DEFAULT_ACCOUNT_ID,
isDefault: true,
name: 'Default Account',
platformId: null,
updatedAt: new Date(),
userId: USER_ID
}
],
alias: 'Test', alias: 'Test',
createdAt: new Date(), createdAt: new Date(),
id: '', id: USER_ID,
provider: null, provider: null,
role: Role.USER, role: Role.USER,
Settings: { Settings: {
currency: Currency.CHF, currency: Currency.CHF,
updatedAt: new Date(), updatedAt: new Date(),
userId: '' userId: USER_ID,
viewMode: ViewMode.DEFAULT
}, },
thirdPartyId: null, thirdPartyId: null,
updatedAt: new Date() updatedAt: new Date()
@ -126,18 +179,20 @@ describe('Portfolio', () => {
it('should return ["BTC"]', async () => { it('should return ["BTC"]', async () => {
await portfolio.setOrders([ await portfolio.setOrders([
{ {
accountId: DEFAULT_ACCOUNT_ID,
accountUserId: USER_ID,
createdAt: null, createdAt: null,
currency: Currency.USD, currency: Currency.USD,
dataSource: DataSource.YAHOO,
fee: 0, fee: 0,
date: new Date(), date: new Date(),
id: '8d999347-dee2-46ee-88e1-26b344e71fcc', id: '8d999347-dee2-46ee-88e1-26b344e71fcc',
platformId: null,
quantity: 1, quantity: 1,
symbol: 'BTCUSD', symbol: 'BTCUSD',
type: Type.BUY, type: Type.BUY,
unitPrice: 49631.24, unitPrice: 49631.24,
updatedAt: null, updatedAt: null,
userId: null userId: USER_ID
} }
]); ]);
@ -152,20 +207,8 @@ describe('Portfolio', () => {
const details = await portfolio.getDetails('1d'); const details = await portfolio.getDetails('1d');
expect(details).toMatchObject({ expect(details).toMatchObject({
BTCUSD: { BTCUSD: {
currency: Currency.USD, accounts: {
exchange: 'Other', [UNKNOWN_KEY]: {
grossPerformance: 0,
grossPerformancePercent: 0,
investment: exchangeRateDataService.toCurrency(
1 * 49631.24,
Currency.USD,
baseCurrency
),
// marketPrice: 57973.008,
marketState: MarketState.open,
name: 'Bitcoin USD',
platforms: {
Other: {
/*current: exchangeRateDataService.toCurrency( /*current: exchangeRateDataService.toCurrency(
1 * 49631.24, 1 * 49631.24,
Currency.USD, Currency.USD,
@ -178,10 +221,23 @@ describe('Portfolio', () => {
) )
} }
}, },
allocationCurrent: 1,
allocationInvestment: 1,
currency: Currency.USD,
exchange: UNKNOWN_KEY,
grossPerformance: 0,
grossPerformancePercent: 0,
investment: exchangeRateDataService.toCurrency(
1 * 49631.24,
Currency.USD,
baseCurrency
),
marketPrice: 57973.008,
marketState: MarketState.open,
name: 'Bitcoin USD',
quantity: 1, quantity: 1,
// shareCurrent: 0.9999999559148652,
shareInvestment: 1,
symbol: 'BTCUSD', symbol: 'BTCUSD',
transactionCount: 1,
type: 'Cryptocurrency' type: 'Cryptocurrency'
} }
}); });
@ -224,18 +280,20 @@ describe('Portfolio', () => {
it('should return ["ETHUSD"]', async () => { it('should return ["ETHUSD"]', async () => {
await portfolio.setOrders([ await portfolio.setOrders([
{ {
accountId: DEFAULT_ACCOUNT_ID,
accountUserId: USER_ID,
createdAt: null, createdAt: null,
currency: Currency.USD, currency: Currency.USD,
dataSource: DataSource.YAHOO,
fee: 0, fee: 0,
date: new Date(getUtc('2018-01-05')), date: new Date(getUtc('2018-01-05')),
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fb', id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fb',
platformId: null,
quantity: 0.2, quantity: 0.2,
symbol: 'ETHUSD', symbol: 'ETHUSD',
type: Type.BUY, type: Type.BUY,
unitPrice: 991.49, unitPrice: 991.49,
updatedAt: null, updatedAt: null,
userId: null userId: USER_ID
} }
]); ]);
@ -250,19 +308,8 @@ describe('Portfolio', () => {
const details = await portfolio.getDetails('1d'); const details = await portfolio.getDetails('1d');
expect(details).toMatchObject({ expect(details).toMatchObject({
ETHUSD: { ETHUSD: {
currency: Currency.USD, accounts: {
exchange: 'Other', [UNKNOWN_KEY]: {
// grossPerformance: 0,
// grossPerformancePercent: 0,
investment: exchangeRateDataService.toCurrency(
0.2 * 991.49,
Currency.USD,
baseCurrency
),
// marketPrice: 57973.008,
name: 'Ethereum USD',
platforms: {
Other: {
/*current: exchangeRateDataService.toCurrency( /*current: exchangeRateDataService.toCurrency(
0.2 * 991.49, 0.2 * 991.49,
Currency.USD, Currency.USD,
@ -275,9 +322,21 @@ describe('Portfolio', () => {
) )
} }
}, },
// allocationCurrent: 1,
allocationInvestment: 1,
currency: Currency.USD,
exchange: UNKNOWN_KEY,
// grossPerformance: 0,
// grossPerformancePercent: 0,
investment: exchangeRateDataService.toCurrency(
0.2 * 991.49,
Currency.USD,
baseCurrency
),
marketPrice: 3915.337,
name: 'Ethereum USD',
quantity: 0.2, quantity: 0.2,
shareCurrent: 1, transactionCount: 1,
shareInvestment: 1,
symbol: 'ETHUSD', symbol: 'ETHUSD',
type: 'Cryptocurrency' type: 'Cryptocurrency'
} }
@ -305,7 +364,7 @@ describe('Portfolio', () => {
baseCurrency baseCurrency
), ),
investmentInOriginalCurrency: 0.2 * 991.49, investmentInOriginalCurrency: 0.2 * 991.49,
// marketPrice: 0, // marketPrice: 3915.337,
quantity: 0.2 quantity: 0.2
} }
}); });
@ -316,32 +375,36 @@ describe('Portfolio', () => {
it('should return ["ETHUSD"]', async () => { it('should return ["ETHUSD"]', async () => {
await portfolio.setOrders([ await portfolio.setOrders([
{ {
accountId: DEFAULT_ACCOUNT_ID,
accountUserId: USER_ID,
createdAt: null, createdAt: null,
currency: Currency.USD, currency: Currency.USD,
dataSource: DataSource.YAHOO,
fee: 0, fee: 0,
date: new Date(getUtc('2018-01-05')), date: new Date(getUtc('2018-01-05')),
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fb', id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fb',
platformId: null,
quantity: 0.2, quantity: 0.2,
symbol: 'ETHUSD', symbol: 'ETHUSD',
type: Type.BUY, type: Type.BUY,
unitPrice: 991.49, unitPrice: 991.49,
updatedAt: null, updatedAt: null,
userId: null userId: USER_ID
}, },
{ {
accountId: DEFAULT_ACCOUNT_ID,
accountUserId: USER_ID,
createdAt: null, createdAt: null,
currency: Currency.USD, currency: Currency.USD,
dataSource: DataSource.YAHOO,
fee: 0, fee: 0,
date: new Date(getUtc('2018-01-28')), date: new Date(getUtc('2018-01-28')),
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fc', id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fc',
platformId: null,
quantity: 0.3, quantity: 0.3,
symbol: 'ETHUSD', symbol: 'ETHUSD',
type: Type.BUY, type: Type.BUY,
unitPrice: 1050, unitPrice: 1050,
updatedAt: null, updatedAt: null,
userId: null userId: USER_ID
} }
]); ]);
@ -377,7 +440,7 @@ describe('Portfolio', () => {
baseCurrency baseCurrency
), ),
investmentInOriginalCurrency: 0.2 * 991.49 + 0.3 * 1050, investmentInOriginalCurrency: 0.2 * 991.49 + 0.3 * 1050,
// marketPrice: 0, // marketPrice: 3641.984,
quantity: 0.5 quantity: 0.5
} }
}); });
@ -388,32 +451,36 @@ describe('Portfolio', () => {
it('should return ["BTCUSD", "ETHUSD"]', async () => { it('should return ["BTCUSD", "ETHUSD"]', async () => {
await portfolio.setOrders([ await portfolio.setOrders([
{ {
accountId: DEFAULT_ACCOUNT_ID,
accountUserId: USER_ID,
createdAt: null, createdAt: null,
currency: Currency.EUR, currency: Currency.EUR,
dataSource: DataSource.YAHOO,
date: new Date(getUtc('2017-08-16')), date: new Date(getUtc('2017-08-16')),
fee: 2.99, fee: 2.99,
id: 'd96795b2-6ae6-420e-aa21-fabe5e45d475', id: 'd96795b2-6ae6-420e-aa21-fabe5e45d475',
platformId: null,
quantity: 0.05614682, quantity: 0.05614682,
symbol: 'BTCUSD', symbol: 'BTCUSD',
type: Type.BUY, type: Type.BUY,
unitPrice: 3562.089535970158, unitPrice: 3562.089535970158,
updatedAt: null, updatedAt: null,
userId: null userId: USER_ID
}, },
{ {
accountId: DEFAULT_ACCOUNT_ID,
accountUserId: USER_ID,
createdAt: null, createdAt: null,
currency: Currency.USD, currency: Currency.USD,
dataSource: DataSource.YAHOO,
fee: 2.99, fee: 2.99,
date: new Date(getUtc('2018-01-05')), date: new Date(getUtc('2018-01-05')),
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fb', id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fb',
platformId: null,
quantity: 0.2, quantity: 0.2,
symbol: 'ETHUSD', symbol: 'ETHUSD',
type: Type.BUY, type: Type.BUY,
unitPrice: 991.49, unitPrice: 991.49,
updatedAt: null, updatedAt: null,
userId: null userId: USER_ID
} }
]); ]);
@ -473,51 +540,56 @@ describe('Portfolio', () => {
it('should work with buy and sell', async () => { it('should work with buy and sell', async () => {
await portfolio.setOrders([ await portfolio.setOrders([
{ {
accountId: DEFAULT_ACCOUNT_ID,
accountUserId: USER_ID,
createdAt: null, createdAt: null,
currency: Currency.USD, currency: Currency.USD,
dataSource: DataSource.YAHOO,
fee: 1.0, fee: 1.0,
date: new Date(getUtc('2018-01-05')), date: new Date(getUtc('2018-01-05')),
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fb', id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fb',
platformId: null,
quantity: 0.2, quantity: 0.2,
symbol: 'ETHUSD', symbol: 'ETHUSD',
type: Type.BUY, type: Type.BUY,
unitPrice: 991.49, unitPrice: 991.49,
updatedAt: null, updatedAt: null,
userId: null userId: USER_ID
}, },
{ {
accountId: DEFAULT_ACCOUNT_ID,
accountUserId: USER_ID,
createdAt: null, createdAt: null,
currency: Currency.USD, currency: Currency.USD,
dataSource: DataSource.YAHOO,
fee: 1.0, fee: 1.0,
date: new Date(getUtc('2018-01-28')), date: new Date(getUtc('2018-01-28')),
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fc', id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fc',
platformId: null,
quantity: 0.1, quantity: 0.1,
symbol: 'ETHUSD', symbol: 'ETHUSD',
type: Type.SELL, type: Type.SELL,
unitPrice: 1050, unitPrice: 1050,
updatedAt: null, updatedAt: null,
userId: null userId: USER_ID
}, },
{ {
accountId: DEFAULT_ACCOUNT_ID,
accountUserId: USER_ID,
createdAt: null, createdAt: null,
currency: Currency.USD, currency: Currency.USD,
dataSource: DataSource.YAHOO,
fee: 1.0, fee: 1.0,
date: new Date(getUtc('2018-01-31')), date: new Date(getUtc('2018-01-31')),
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fc', id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fc',
platformId: null,
quantity: 0.2, quantity: 0.2,
symbol: 'ETHUSD', symbol: 'ETHUSD',
type: Type.BUY, type: Type.BUY,
unitPrice: 1050, unitPrice: 1050,
updatedAt: null, updatedAt: null,
userId: null userId: USER_ID
} }
]); ]);
// TODO: Fix expect(portfolio.getCommittedFunds()).toEqual(
/*expect(portfolio.getCommittedFunds()).toEqual(
exchangeRateDataService.toCurrency( exchangeRateDataService.toCurrency(
0.2 * 991.49, 0.2 * 991.49,
Currency.USD, Currency.USD,
@ -533,7 +605,7 @@ describe('Portfolio', () => {
Currency.USD, Currency.USD,
baseCurrency baseCurrency
) )
);*/ );
expect(portfolio.getFees()).toEqual( expect(portfolio.getFees()).toEqual(
exchangeRateDataService.toCurrency(3, Currency.USD, baseCurrency) exchangeRateDataService.toCurrency(3, Currency.USD, baseCurrency)
@ -545,12 +617,11 @@ describe('Portfolio', () => {
(0.2 * 991.49 - 0.1 * 1050 + 0.2 * 1050) / (0.2 - 0.1 + 0.2), (0.2 * 991.49 - 0.1 * 1050 + 0.2 * 1050) / (0.2 - 0.1 + 0.2),
currency: Currency.USD, currency: Currency.USD,
firstBuyDate: '2018-01-05T00:00:00.000Z', firstBuyDate: '2018-01-05T00:00:00.000Z',
// TODO: Fix investment: exchangeRateDataService.toCurrency(
/*investment: exchangeRateDataService.toCurrency(
0.2 * 991.49 - 0.1 * 1050 + 0.2 * 1050, 0.2 * 991.49 - 0.1 * 1050 + 0.2 * 1050,
Currency.USD, Currency.USD,
baseCurrency baseCurrency
),*/ ),
investmentInOriginalCurrency: 0.2 * 991.49 - 0.1 * 1050 + 0.2 * 1050, investmentInOriginalCurrency: 0.2 * 991.49 - 0.1 * 1050 + 0.2 * 1050,
// marketPrice: 0, // marketPrice: 0,
quantity: 0.2 - 0.1 + 0.2 quantity: 0.2 - 0.1 + 0.2
@ -560,8 +631,4 @@ describe('Portfolio', () => {
expect(portfolio.getSymbols(getYesterday())).toEqual(['ETHUSD']); expect(portfolio.getSymbols(getYesterday())).toEqual(['ETHUSD']);
}); });
}); });
afterAll(async () => {
prismaService.$disconnect();
});
}); });

View File

@ -1,8 +1,14 @@
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
import { getToday, getYesterday, resetHours } from '@ghostfolio/common/helper';
import { import {
PortfolioItem, PortfolioItem,
Position PortfolioPerformance,
} from '@ghostfolio/api/app/portfolio/interfaces/portfolio-item.interface'; PortfolioPosition,
import { getToday, getYesterday, resetHours } from '@ghostfolio/helper'; PortfolioReport,
Position,
UserWithSettings
} from '@ghostfolio/common/interfaces';
import { DateRange, OrderWithAccount } from '@ghostfolio/common/types';
import { import {
add, add,
format, format,
@ -22,26 +28,21 @@ import {
import { cloneDeep, isEmpty } from 'lodash'; import { cloneDeep, isEmpty } from 'lodash';
import * as roundTo from 'round-to'; import * as roundTo from 'round-to';
import { UserWithSettings } from '../app/interfaces/user-with-settings';
import { OrderWithPlatform } from '../app/order/interfaces/order-with-platform.type';
import { DateRange } from '../app/portfolio/interfaces/date-range.type';
import { PortfolioPerformance } from '../app/portfolio/interfaces/portfolio-performance.interface';
import { PortfolioPosition } from '../app/portfolio/interfaces/portfolio-position.interface';
import { PortfolioReport } from '../app/portfolio/interfaces/portfolio-report.interface';
import { DataProviderService } from '../services/data-provider.service'; import { DataProviderService } from '../services/data-provider.service';
import { ExchangeRateDataService } from '../services/exchange-rate-data.service'; import { ExchangeRateDataService } from '../services/exchange-rate-data.service';
import { IOrder } from '../services/interfaces/interfaces'; import { IOrder } from '../services/interfaces/interfaces';
import { RulesService } from '../services/rules.service'; import { RulesService } from '../services/rules.service';
import { PortfolioInterface } from './interfaces/portfolio.interface'; import { PortfolioInterface } from './interfaces/portfolio.interface';
import { Order } from './order'; import { Order } from './order';
import { OrderType } from './order-type';
import { AccountClusterRiskCurrentInvestment } from './rules/account-cluster-risk/current-investment';
import { AccountClusterRiskInitialInvestment } from './rules/account-cluster-risk/initial-investment';
import { AccountClusterRiskSingleAccount } from './rules/account-cluster-risk/single-account';
import { CurrencyClusterRiskBaseCurrencyCurrentInvestment } from './rules/currency-cluster-risk/base-currency-current-investment'; import { CurrencyClusterRiskBaseCurrencyCurrentInvestment } from './rules/currency-cluster-risk/base-currency-current-investment';
import { CurrencyClusterRiskBaseCurrencyInitialInvestment } from './rules/currency-cluster-risk/base-currency-initial-investment'; import { CurrencyClusterRiskBaseCurrencyInitialInvestment } from './rules/currency-cluster-risk/base-currency-initial-investment';
import { CurrencyClusterRiskCurrentInvestment } from './rules/currency-cluster-risk/current-investment'; import { CurrencyClusterRiskCurrentInvestment } from './rules/currency-cluster-risk/current-investment';
import { CurrencyClusterRiskInitialInvestment } from './rules/currency-cluster-risk/initial-investment'; import { CurrencyClusterRiskInitialInvestment } from './rules/currency-cluster-risk/initial-investment';
import { FeeRatioInitialInvestment } from './rules/fees/fee-ratio-initial-investment'; import { FeeRatioInitialInvestment } from './rules/fees/fee-ratio-initial-investment';
import { PlatformClusterRiskCurrentInvestment } from './rules/platform-cluster-risk/current-investment';
import { PlatformClusterRiskInitialInvestment } from './rules/platform-cluster-risk/initial-investment';
import { PlatformClusterRiskSinglePlatform } from './rules/platform-cluster-risk/single-platform';
export class Portfolio implements PortfolioInterface { export class Portfolio implements PortfolioInterface {
private orders: Order[] = []; private orders: Order[] = [];
@ -57,7 +58,7 @@ export class Portfolio implements PortfolioInterface {
public async addCurrentPortfolioItems() { public async addCurrentPortfolioItems() {
const currentData = await this.dataProviderService.get(this.getSymbols()); const currentData = await this.dataProviderService.get(this.getSymbols());
let currentDate = new Date(); const currentDate = new Date();
const year = getYear(currentDate); const year = getYear(currentDate);
const month = getMonth(currentDate); const month = getMonth(currentDate);
@ -82,7 +83,9 @@ export class Portfolio implements PortfolioInterface {
marketPrice: marketPrice:
currentData[symbol]?.marketPrice ?? currentData[symbol]?.marketPrice ??
portfolioItemsYesterday.positions[symbol]?.marketPrice, portfolioItemsYesterday.positions[symbol]?.marketPrice,
quantity: portfolioItemsYesterday?.positions[symbol]?.quantity quantity: portfolioItemsYesterday?.positions[symbol]?.quantity,
transactionCount:
portfolioItemsYesterday?.positions[symbol]?.transactionCount
}; };
}); });
@ -117,11 +120,11 @@ export class Portfolio implements PortfolioInterface {
}): Portfolio { }): Portfolio {
orders.forEach( orders.forEach(
({ ({
account,
currency, currency,
fee, fee,
date, date,
id, id,
platform,
quantity, quantity,
symbol, symbol,
type, type,
@ -129,11 +132,11 @@ export class Portfolio implements PortfolioInterface {
}) => { }) => {
this.orders.push( this.orders.push(
new Order({ new Order({
account,
currency, currency,
fee, fee,
date, date,
id, id,
platform,
quantity, quantity,
symbol, symbol,
type, type,
@ -200,7 +203,7 @@ export class Portfolio implements PortfolioInterface {
const data = await this.dataProviderService.get(symbols); const data = await this.dataProviderService.get(symbols);
symbols.forEach((symbol) => { symbols.forEach((symbol) => {
const platforms: PortfolioPosition['platforms'] = {}; const accounts: PortfolioPosition['accounts'] = {};
const [portfolioItem] = portfolioItems; const [portfolioItem] = portfolioItems;
const ordersBySymbol = this.getOrders().filter((order) => { const ordersBySymbol = this.getOrders().filter((order) => {
@ -225,15 +228,17 @@ export class Portfolio implements PortfolioInterface {
originalValueOfSymbol *= -1; originalValueOfSymbol *= -1;
} }
if (platforms[orderOfSymbol.getPlatform()?.name || 'Other']?.current) { if (
platforms[ accounts[orderOfSymbol.getAccount()?.name || UNKNOWN_KEY]?.current
orderOfSymbol.getPlatform()?.name || 'Other' ) {
accounts[
orderOfSymbol.getAccount()?.name || UNKNOWN_KEY
].current += currentValueOfSymbol; ].current += currentValueOfSymbol;
platforms[ accounts[
orderOfSymbol.getPlatform()?.name || 'Other' orderOfSymbol.getAccount()?.name || UNKNOWN_KEY
].original += originalValueOfSymbol; ].original += originalValueOfSymbol;
} else { } else {
platforms[orderOfSymbol.getPlatform()?.name || 'Other'] = { accounts[orderOfSymbol.getAccount()?.name || UNKNOWN_KEY] = {
current: currentValueOfSymbol, current: currentValueOfSymbol,
original: originalValueOfSymbol original: originalValueOfSymbol
}; };
@ -274,8 +279,16 @@ export class Portfolio implements PortfolioInterface {
details[symbol] = { details[symbol] = {
...data[symbol], ...data[symbol],
platforms, accounts,
symbol, symbol,
allocationCurrent:
this.exchangeRateDataService.toCurrency(
portfolioItem.positions[symbol].quantity * now,
data[symbol]?.currency,
this.user.Settings.currency
) / value,
allocationInvestment:
portfolioItem.positions[symbol].investment / investment,
grossPerformance: roundTo( grossPerformance: roundTo(
portfolioItemsNow.positions[symbol].quantity * (now - before), portfolioItemsNow.positions[symbol].quantity * (now - before),
2 2
@ -283,13 +296,7 @@ export class Portfolio implements PortfolioInterface {
grossPerformancePercent: roundTo((now - before) / before, 4), grossPerformancePercent: roundTo((now - before) / before, 4),
investment: portfolioItem.positions[symbol].investment, investment: portfolioItem.positions[symbol].investment,
quantity: portfolioItem.positions[symbol].quantity, quantity: portfolioItem.positions[symbol].quantity,
shareCurrent: transactionCount: portfolioItem.positions[symbol].transactionCount
this.exchangeRateDataService.toCurrency(
portfolioItem.positions[symbol].quantity * now,
data[symbol]?.currency,
this.user.Settings.currency
) / value,
shareInvestment: portfolioItem.positions[symbol].investment / investment
}; };
}); });
@ -392,6 +399,19 @@ export class Portfolio implements PortfolioInterface {
return { return {
rules: { rules: {
accountClusterRisk: await this.rulesService.evaluate(
this,
[
new AccountClusterRiskCurrentInvestment(
this.exchangeRateDataService
),
new AccountClusterRiskInitialInvestment(
this.exchangeRateDataService
),
new AccountClusterRiskSingleAccount(this.exchangeRateDataService)
],
{ baseCurrency: this.user.Settings.currency }
),
currencyClusterRisk: await this.rulesService.evaluate( currencyClusterRisk: await this.rulesService.evaluate(
this, this,
[ [
@ -410,19 +430,6 @@ export class Portfolio implements PortfolioInterface {
], ],
{ baseCurrency: this.user.Settings.currency } { baseCurrency: this.user.Settings.currency }
), ),
platformClusterRisk: await this.rulesService.evaluate(
this,
[
new PlatformClusterRiskSinglePlatform(this.exchangeRateDataService),
new PlatformClusterRiskInitialInvestment(
this.exchangeRateDataService
),
new PlatformClusterRiskCurrentInvestment(
this.exchangeRateDataService
)
],
{ baseCurrency: this.user.Settings.currency }
),
fees: await this.rulesService.evaluate( fees: await this.rulesService.evaluate(
this, this,
[new FeeRatioInitialInvestment(this.exchangeRateDataService)], [new FeeRatioInitialInvestment(this.exchangeRateDataService)],
@ -518,20 +525,20 @@ export class Portfolio implements PortfolioInterface {
return isFinite(value) ? value : null; return isFinite(value) ? value : null;
} }
public async setOrders(aOrders: OrderWithPlatform[]) { public async setOrders(aOrders: OrderWithAccount[]) {
this.orders = []; this.orders = [];
// Map data // Map data
aOrders.forEach((order) => { aOrders.forEach((order) => {
this.orders.push( this.orders.push(
new Order({ new Order({
currency: <any>order.currency, account: order.Account,
currency: order.currency,
date: order.date.toISOString(), date: order.date.toISOString(),
fee: order.fee, fee: order.fee,
platform: order.Platform,
quantity: order.quantity, quantity: order.quantity,
symbol: order.symbol, symbol: order.symbol,
type: <any>order.type, type: <OrderType>order.type,
unitPrice: order.unitPrice unitPrice: order.unitPrice
}) })
); );
@ -582,7 +589,8 @@ export class Portfolio implements PortfolioInterface {
marketPrice: marketPrice:
historicalData[symbol]?.[format(currentDate, 'yyyy-MM-dd')] historicalData[symbol]?.[format(currentDate, 'yyyy-MM-dd')]
?.marketPrice || 0, ?.marketPrice || 0,
quantity: 0 quantity: 0,
transactionCount: 0
}; };
}); });
@ -623,7 +631,8 @@ export class Portfolio implements PortfolioInterface {
marketPrice: marketPrice:
historicalData[symbol]?.[format(yesterday, 'yyyy-MM-dd')] historicalData[symbol]?.[format(yesterday, 'yyyy-MM-dd')]
?.marketPrice || 0, ?.marketPrice || 0,
quantity: 0 quantity: 0,
transactionCount: 0
}; };
}); });
@ -730,6 +739,10 @@ export class Portfolio implements PortfolioInterface {
order.getSymbol() order.getSymbol()
].currency = order.getCurrency(); ].currency = order.getCurrency();
this.portfolioItems[i].positions[
order.getSymbol()
].transactionCount += 1;
if (order.getType() === 'BUY') { if (order.getType() === 'BUY') {
if ( if (
!this.portfolioItems[i].positions[order.getSymbol()].firstBuyDate !this.portfolioItems[i].positions[order.getSymbol()].firstBuyDate

View File

@ -1,7 +1,7 @@
import { groupBy } from '@ghostfolio/helper'; import { groupBy } from '@ghostfolio/common/helper';
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
import { Currency } from '@prisma/client'; import { Currency } from '@prisma/client';
import { PortfolioPosition } from '../app/portfolio/interfaces/portfolio-position.interface';
import { ExchangeRateDataService } from '../services/exchange-rate-data.service'; import { ExchangeRateDataService } from '../services/exchange-rate-data.service';
import { EvaluationResult } from './interfaces/evaluation-result.interface'; import { EvaluationResult } from './interfaces/evaluation-result.interface';
import { RuleInterface } from './interfaces/rule.interface'; import { RuleInterface } from './interfaces/rule.interface';

View File

@ -1,9 +1,9 @@
import { PortfolioPosition } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position.interface';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
import { Rule } from '../../rule'; import { Rule } from '../../rule';
export class PlatformClusterRiskCurrentInvestment extends Rule { export class AccountClusterRiskCurrentInvestment extends Rule {
public constructor(public exchangeRateDataService: ExchangeRateDataService) { public constructor(public exchangeRateDataService: ExchangeRateDataService) {
super(exchangeRateDataService, { super(exchangeRateDataService, {
name: 'Current Investment' name: 'Current Investment'
@ -18,24 +18,22 @@ export class PlatformClusterRiskCurrentInvestment extends Rule {
} }
) { ) {
const ruleSettings = const ruleSettings =
aRuleSettingsMap[PlatformClusterRiskCurrentInvestment.name]; aRuleSettingsMap[AccountClusterRiskCurrentInvestment.name];
const platforms: { const accounts: {
[symbol: string]: Pick<PortfolioPosition, 'name'> & { [symbol: string]: Pick<PortfolioPosition, 'name'> & {
investment: number; investment: number;
}; };
} = {}; } = {};
Object.values(aPositions).forEach((position) => { Object.values(aPositions).forEach((position) => {
for (const [platform, { current }] of Object.entries( for (const [account, { current }] of Object.entries(position.accounts)) {
position.platforms if (accounts[account]?.investment) {
)) { accounts[account].investment += current;
if (platforms[platform]?.investment) {
platforms[platform].investment += current;
} else { } else {
platforms[platform] = { accounts[account] = {
investment: current, investment: current,
name: platform name: account
}; };
} }
} }
@ -44,17 +42,17 @@ export class PlatformClusterRiskCurrentInvestment extends Rule {
let maxItem; let maxItem;
let totalInvestment = 0; let totalInvestment = 0;
Object.values(platforms).forEach((platform) => { Object.values(accounts).forEach((account) => {
if (!maxItem) { if (!maxItem) {
maxItem = platform; maxItem = account;
} }
// Calculate total investment // Calculate total investment
totalInvestment += platform.investment; totalInvestment += account.investment;
// Find maximum // Find maximum
if (platform.investment > maxItem?.investment) { if (account.investment > maxItem?.investment) {
maxItem = platform; maxItem = account;
} }
}); });

View File

@ -1,9 +1,9 @@
import { PortfolioPosition } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position.interface'; import { PortfolioPosition } from '@ghostfolio/common/interfaces';
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service'; import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
import { Rule } from '../../rule'; import { Rule } from '../../rule';
export class PlatformClusterRiskInitialInvestment extends Rule { export class AccountClusterRiskInitialInvestment extends Rule {
public constructor(public exchangeRateDataService: ExchangeRateDataService) { public constructor(public exchangeRateDataService: ExchangeRateDataService) {
super(exchangeRateDataService, { super(exchangeRateDataService, {
name: 'Initial Investment' name: 'Initial Investment'
@ -18,7 +18,7 @@ export class PlatformClusterRiskInitialInvestment extends Rule {
} }
) { ) {
const ruleSettings = const ruleSettings =
aRuleSettingsMap[PlatformClusterRiskInitialInvestment.name]; aRuleSettingsMap[AccountClusterRiskInitialInvestment.name];
const platforms: { const platforms: {
[symbol: string]: Pick<PortfolioPosition, 'name'> & { [symbol: string]: Pick<PortfolioPosition, 'name'> & {
@ -27,15 +27,13 @@ export class PlatformClusterRiskInitialInvestment extends Rule {
} = {}; } = {};
Object.values(aPositions).forEach((position) => { Object.values(aPositions).forEach((position) => {
for (const [platform, { original }] of Object.entries( for (const [account, { original }] of Object.entries(position.accounts)) {
position.platforms if (platforms[account]?.investment) {
)) { platforms[account].investment += original;
if (platforms[platform]?.investment) {
platforms[platform].investment += original;
} else { } else {
platforms[platform] = { platforms[account] = {
investment: original, investment: original,
name: platform name: account
}; };
} }
} }

View File

@ -1,35 +1,35 @@
import { PortfolioPosition } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position.interface'; import { PortfolioPosition } from '@ghostfolio/common/interfaces';
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service'; import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
import { Rule } from '../../rule'; import { Rule } from '../../rule';
export class PlatformClusterRiskSinglePlatform extends Rule { export class AccountClusterRiskSingleAccount extends Rule {
public constructor(public exchangeRateDataService: ExchangeRateDataService) { public constructor(public exchangeRateDataService: ExchangeRateDataService) {
super(exchangeRateDataService, { super(exchangeRateDataService, {
name: 'Single Platform' name: 'Single Account'
}); });
} }
public evaluate(positions: { [symbol: string]: PortfolioPosition }) { public evaluate(positions: { [symbol: string]: PortfolioPosition }) {
const platforms: string[] = []; const accounts: string[] = [];
Object.values(positions).forEach((position) => { Object.values(positions).forEach((position) => {
for (const [platform] of Object.entries(position.platforms)) { for (const [account] of Object.entries(position.accounts)) {
if (!platforms.includes(platform)) { if (!accounts.includes(account)) {
platforms.push(platform); accounts.push(account);
} }
} }
}); });
if (platforms.length === 1) { if (accounts.length === 1) {
return { return {
evaluation: `All your investment is managed by a single platform`, evaluation: `All your investment is managed by a single account`,
value: false value: false
}; };
} }
return { return {
evaluation: `Your investment is managed by ${platforms.length} platforms`, evaluation: `Your investment is managed by ${accounts.length} accounts`,
value: true value: true
}; };
} }

View File

@ -1,5 +1,5 @@
import { PortfolioPosition } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position.interface';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
import { Rule } from '../../rule'; import { Rule } from '../../rule';

View File

@ -1,4 +1,4 @@
import { PortfolioPosition } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position.interface'; import { PortfolioPosition } from '@ghostfolio/common/interfaces';
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service'; import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
import { Rule } from '../../rule'; import { Rule } from '../../rule';

View File

@ -1,4 +1,4 @@
import { PortfolioPosition } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position.interface'; import { PortfolioPosition } from '@ghostfolio/common/interfaces';
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service'; import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
import { Rule } from '../../rule'; import { Rule } from '../../rule';

View File

@ -1,4 +1,4 @@
import { PortfolioPosition } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position.interface'; import { PortfolioPosition } from '@ghostfolio/common/interfaces';
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service'; import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
import { Rule } from '../../rule'; import { Rule } from '../../rule';

View File

@ -1,4 +1,4 @@
import { PortfolioPosition } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position.interface'; import { PortfolioPosition } from '@ghostfolio/common/interfaces';
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service'; import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
import { Rule } from '../../rule'; import { Rule } from '../../rule';

View File

@ -1,5 +1,6 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { bool, cleanEnv, num, port, str } from 'envalid'; import { DataSource } from '@prisma/client';
import { bool, cleanEnv, json, num, port, str } from 'envalid';
import { Environment } from './interfaces/environment.interface'; import { Environment } from './interfaces/environment.interface';
@ -12,9 +13,11 @@ export class ConfigurationService {
ACCESS_TOKEN_SALT: str(), ACCESS_TOKEN_SALT: str(),
ALPHA_VANTAGE_API_KEY: str({ default: '' }), ALPHA_VANTAGE_API_KEY: str({ default: '' }),
CACHE_TTL: num({ default: 1 }), CACHE_TTL: num({ default: 1 }),
DATA_SOURCES: json({ default: JSON.stringify([DataSource.YAHOO]) }),
ENABLE_FEATURE_CUSTOM_SYMBOLS: bool({ default: false }), ENABLE_FEATURE_CUSTOM_SYMBOLS: bool({ default: false }),
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: bool({ default: false }), ENABLE_FEATURE_FEAR_AND_GREED_INDEX: bool({ default: false }),
ENABLE_FEATURE_SOCIAL_LOGIN: bool({ default: false }), ENABLE_FEATURE_SOCIAL_LOGIN: bool({ default: false }),
ENABLE_FEATURE_SUBSCRIPTION: bool({ default: false }),
GOOGLE_CLIENT_ID: str({ default: 'dummyClientId' }), GOOGLE_CLIENT_ID: str({ default: 'dummyClientId' }),
GOOGLE_SECRET: str({ default: 'dummySecret' }), GOOGLE_SECRET: str({ default: 'dummySecret' }),
JWT_SECRET_KEY: str({}), JWT_SECRET_KEY: str({}),

View File

@ -1,10 +1,9 @@
import { benchmarks, currencyPairs } from '@ghostfolio/common/config';
import { import {
benchmarks,
currencyPairs,
getUtc, getUtc,
isGhostfolioScraperApiSymbol, isGhostfolioScraperApiSymbol,
resetHours resetHours
} from '@ghostfolio/helper'; } from '@ghostfolio/common/helper';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { import {
differenceInHours, differenceInHours,
@ -18,6 +17,7 @@ import {
import { ConfigurationService } from './configuration.service'; import { ConfigurationService } from './configuration.service';
import { DataProviderService } from './data-provider.service'; import { DataProviderService } from './data-provider.service';
import { GhostfolioScraperApiService } from './data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
import { PrismaService } from './prisma.service'; import { PrismaService } from './prisma.service';
@Injectable() @Injectable()
@ -25,6 +25,7 @@ export class DataGatheringService {
public constructor( public constructor(
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
private readonly ghostfolioScraperApi: GhostfolioScraperApiService,
private prisma: PrismaService private prisma: PrismaService
) {} ) {}
@ -183,6 +184,17 @@ export class DataGatheringService {
} }
} }
public async getCustomSymbolsToGather(startDate?: Date) {
const scraperConfigurations = await this.ghostfolioScraperApi.getScraperConfigurations();
return scraperConfigurations.map((scraperConfiguration) => {
return {
date: startDate,
symbol: scraperConfiguration.symbol
};
});
}
private getBenchmarksToGather(startDate: Date) { private getBenchmarksToGather(startDate: Date) {
const benchmarksToGather = benchmarks.map((symbol) => { const benchmarksToGather = benchmarks.map((symbol) => {
return { return {
@ -201,32 +213,6 @@ export class DataGatheringService {
return benchmarksToGather; return benchmarksToGather;
} }
private async getCustomSymbolsToGather(startDate: Date) {
const customSymbolsToGather = [];
if (this.configurationService.get('ENABLE_FEATURE_CUSTOM_SYMBOLS')) {
try {
const {
value: scraperConfigString
} = await this.prisma.property.findFirst({
select: {
value: true
},
where: { key: 'SCRAPER_CONFIG' }
});
JSON.parse(scraperConfigString).forEach((item) => {
customSymbolsToGather.push({
date: startDate,
symbol: item.symbol
});
});
} catch {}
}
return customSymbolsToGather;
}
private async getSymbols7D(): Promise<{ date: Date; symbol: string }[]> { private async getSymbols7D(): Promise<{ date: Date; symbol: string }[]> {
const startDate = subDays(resetHours(new Date()), 7); const startDate = subDays(resetHours(new Date()), 7);

View File

@ -1,10 +1,12 @@
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { import {
isCrypto, isCrypto,
isGhostfolioScraperApiSymbol, isGhostfolioScraperApiSymbol,
isRakutenRapidApiSymbol isRakutenRapidApiSymbol
} from '@ghostfolio/helper'; } from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { MarketData } from '@prisma/client'; import { DataSource, MarketData } from '@prisma/client';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { ConfigurationService } from './configuration.service'; import { ConfigurationService } from './configuration.service';
@ -13,7 +15,6 @@ import { GhostfolioScraperApiService } from './data-provider/ghostfolio-scraper-
import { RakutenRapidApiService } from './data-provider/rakuten-rapid-api/rakuten-rapid-api.service'; import { RakutenRapidApiService } from './data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
import { YahooFinanceService } from './data-provider/yahoo-finance/yahoo-finance.service'; import { YahooFinanceService } from './data-provider/yahoo-finance/yahoo-finance.service';
import { DataProviderInterface } from './interfaces/data-provider.interface'; import { DataProviderInterface } from './interfaces/data-provider.interface';
import { Granularity } from './interfaces/granularity.type';
import { import {
IDataProviderHistoricalResponse, IDataProviderHistoricalResponse,
IDataProviderResponse IDataProviderResponse
@ -184,4 +185,19 @@ export class DataProviderService implements DataProviderInterface {
return dataOfYahoo; return dataOfYahoo;
} }
public async search(aSymbol: string) {
return this.getDataProvider().search(aSymbol);
}
private getDataProvider() {
switch (this.configurationService.get('DATA_SOURCES')[0]) {
case DataSource.ALPHA_VANTAGE:
return this.alphaVantageService;
case DataSource.YAHOO:
return this.yahooFinanceService;
default:
throw new Error('No data provider has been found.');
}
}
} }

View File

@ -1,9 +1,11 @@
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { Granularity } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { DataSource } from '@prisma/client';
import { isAfter, isBefore, parse } from 'date-fns'; import { isAfter, isBefore, parse } from 'date-fns';
import { ConfigurationService } from '../../configuration.service'; import { ConfigurationService } from '../../configuration.service';
import { DataProviderInterface } from '../../interfaces/data-provider.interface'; import { DataProviderInterface } from '../../interfaces/data-provider.interface';
import { Granularity } from '../../interfaces/granularity.type';
import { import {
IDataProviderHistoricalResponse, IDataProviderHistoricalResponse,
IDataProviderResponse IDataProviderResponse
@ -77,7 +79,17 @@ export class AlphaVantageService implements DataProviderInterface {
} }
} }
public search(aSymbol: string) { public async search(aSymbol: string): Promise<{ items: LookupItem[] }> {
return this.alphaVantage.data.search(aSymbol); const result = await this.alphaVantage.data.search(aSymbol);
return {
items: result?.bestMatches?.map((bestMatch) => {
return {
dataSource: DataSource.ALPHA_VANTAGE,
name: bestMatch['2. name'],
symbol: bestMatch['1. symbol']
};
})
};
} }
} }

View File

@ -1,20 +1,24 @@
import { getYesterday } from '@ghostfolio/helper'; import { getYesterday } from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { DataSource } from '@prisma/client';
import * as bent from 'bent'; import * as bent from 'bent';
import * as cheerio from 'cheerio'; import * as cheerio from 'cheerio';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { DataProviderInterface } from '../../interfaces/data-provider.interface'; import { DataProviderInterface } from '../../interfaces/data-provider.interface';
import { Granularity } from '../../interfaces/granularity.type';
import { import {
IDataProviderHistoricalResponse, IDataProviderHistoricalResponse,
IDataProviderResponse, IDataProviderResponse,
MarketState MarketState
} from '../../interfaces/interfaces'; } from '../../interfaces/interfaces';
import { PrismaService } from '../../prisma.service'; import { PrismaService } from '../../prisma.service';
import { ScraperConfig } from './interfaces/scraper-config.interface';
@Injectable() @Injectable()
export class GhostfolioScraperApiService implements DataProviderInterface { export class GhostfolioScraperApiService implements DataProviderInterface {
private static NUMERIC_REGEXP = /[-]{0,1}[\d]*[.,]{0,1}[\d]+/g;
public constructor(private prisma: PrismaService) {} public constructor(private prisma: PrismaService) {}
public async get( public async get(
@ -27,7 +31,7 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
try { try {
const symbol = aSymbols[0]; const symbol = aSymbols[0];
const scraperConfig = await this.getScraperConfig(symbol); const scraperConfig = await this.getScraperConfigurationBySymbol(symbol);
const { marketPrice } = await this.prisma.marketData.findFirst({ const { marketPrice } = await this.prisma.marketData.findFirst({
orderBy: { orderBy: {
@ -42,6 +46,7 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
[symbol]: { [symbol]: {
marketPrice, marketPrice,
currency: scraperConfig?.currency, currency: scraperConfig?.currency,
dataSource: DataSource.GHOSTFOLIO,
marketState: MarketState.delayed, marketState: MarketState.delayed,
name: scraperConfig?.name name: scraperConfig?.name
} }
@ -68,19 +73,18 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
try { try {
const symbol = aSymbols[0]; const symbol = aSymbols[0];
const scraperConfig = await this.getScraperConfig(symbol); const scraperConfiguration = await this.getScraperConfigurationBySymbol(
symbol
);
const get = bent(scraperConfig?.url, 'GET', 'string', 200, {}); const get = bent(scraperConfiguration?.url, 'GET', 'string', 200, {});
const html = await get(); const html = await get();
const $ = cheerio.load(html); const $ = cheerio.load(html);
const string = $(scraperConfig?.selector) const value = this.extractNumberFromString(
.text() $(scraperConfiguration?.selector).text()
.replace('CHF', '') );
.trim();
const value = parseFloat(string);
return { return {
[symbol]: { [symbol]: {
@ -96,7 +100,7 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
return {}; return {};
} }
private async getScraperConfig(aSymbol: string) { public async getScraperConfigurations(): Promise<ScraperConfig[]> {
try { try {
const { const {
value: scraperConfigString value: scraperConfigString
@ -107,11 +111,31 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
where: { key: 'SCRAPER_CONFIG' } where: { key: 'SCRAPER_CONFIG' }
}); });
return JSON.parse(scraperConfigString).find((item) => { return JSON.parse(scraperConfigString);
return item.symbol === aSymbol;
});
} catch {} } catch {}
return {}; return [];
}
public async search(aSymbol: string) {
return { items: [] };
}
private extractNumberFromString(aString: string): number {
try {
const [numberString] = aString.match(
GhostfolioScraperApiService.NUMERIC_REGEXP
);
return parseFloat(numberString.trim());
} catch {
return undefined;
}
}
private async getScraperConfigurationBySymbol(aSymbol: string) {
const scraperConfigurations = await this.getScraperConfigurations();
return scraperConfigurations.find((scraperConfiguration) => {
return scraperConfiguration.symbol === aSymbol;
});
} }
} }

View File

@ -0,0 +1,9 @@
import { Currency } from '@prisma/client';
export interface ScraperConfig {
currency: Currency;
name: string;
selector: string;
symbol: string;
url: string;
}

View File

@ -1,11 +1,12 @@
import { getToday, getYesterday } from '@ghostfolio/helper'; import { getToday, getYesterday } from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { DataSource } from '@prisma/client';
import * as bent from 'bent'; import * as bent from 'bent';
import { format, subMonths, subWeeks, subYears } from 'date-fns'; import { format, subMonths, subWeeks, subYears } from 'date-fns';
import { ConfigurationService } from '../../configuration.service'; import { ConfigurationService } from '../../configuration.service';
import { DataProviderInterface } from '../../interfaces/data-provider.interface'; import { DataProviderInterface } from '../../interfaces/data-provider.interface';
import { Granularity } from '../../interfaces/granularity.type';
import { import {
IDataProviderHistoricalResponse, IDataProviderHistoricalResponse,
IDataProviderResponse, IDataProviderResponse,
@ -39,6 +40,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
return { return {
'GF.FEAR_AND_GREED_INDEX': { 'GF.FEAR_AND_GREED_INDEX': {
currency: undefined, currency: undefined,
dataSource: DataSource.RAKUTEN,
marketPrice: fgi.now.value, marketPrice: fgi.now.value,
marketState: MarketState.open, marketState: MarketState.open,
name: RakutenRapidApiService.FEAR_AND_GREED_INDEX_NAME name: RakutenRapidApiService.FEAR_AND_GREED_INDEX_NAME
@ -115,6 +117,14 @@ export class RakutenRapidApiService implements DataProviderInterface {
return {}; return {};
} }
public async search(aSymbol: string) {
return { items: [] };
}
public setPrisma(aPrismaService: PrismaService) {
this.prisma = aPrismaService;
}
private async getFearAndGreedIndex(): Promise<{ private async getFearAndGreedIndex(): Promise<{
now: { value: number; valueText: string }; now: { value: number; valueText: string };
previousClose: { value: number; valueText: string }; previousClose: { value: number; valueText: string };
@ -145,8 +155,4 @@ export class RakutenRapidApiService implements DataProviderInterface {
return undefined; return undefined;
} }
} }
public setPrisma(aPrismaService: PrismaService) {
this.prisma = aPrismaService;
}
} }

View File

@ -1,24 +0,0 @@
/*
import { Test } from '@nestjs/testing';
import { YahooFinanceService } from './yahoo-finance.service';
describe('AppService', () => {
let service: YahooFinanceService;
beforeAll(async () => {
const app = await Test.createTestingModule({
imports: [],
providers: [YahooFinanceService]
}).compile();
service = app.get<YahooFinanceService>(YahooFinanceService);
});
describe('get', () => {
it('should return data for USDCHF', () => {
expect(service.get(['USDCHF'])).toEqual('{}');
});
});
});
*/

View File

@ -1,10 +1,14 @@
import { isCrypto, isCurrency, parseCurrency } from '@ghostfolio/helper'; import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
import { isCrypto, isCurrency, parseCurrency } from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { DataSource } from '@prisma/client';
import * as bent from 'bent';
import { format } from 'date-fns'; import { format } from 'date-fns';
import * as yahooFinance from 'yahoo-finance'; import * as yahooFinance from 'yahoo-finance';
import { DataProviderInterface } from '../../interfaces/data-provider.interface'; import { DataProviderInterface } from '../../interfaces/data-provider.interface';
import { Granularity } from '../../interfaces/granularity.type';
import { import {
IDataProviderHistoricalResponse, IDataProviderHistoricalResponse,
IDataProviderResponse, IDataProviderResponse,
@ -20,6 +24,8 @@ import {
@Injectable() @Injectable()
export class YahooFinanceService implements DataProviderInterface { export class YahooFinanceService implements DataProviderInterface {
private yahooFinanceHostname = 'https://query1.finance.yahoo.com';
public constructor() {} public constructor() {}
public async get( public async get(
@ -49,6 +55,7 @@ export class YahooFinanceService implements DataProviderInterface {
response[symbol] = { response[symbol] = {
currency: parseCurrency(value.price?.currency), currency: parseCurrency(value.price?.currency),
dataSource: DataSource.YAHOO,
exchange: this.parseExchange(value.price?.exchangeName), exchange: this.parseExchange(value.price?.exchangeName),
marketState: marketState:
value.price?.marketState === 'REGULAR' || isCrypto(symbol) value.price?.marketState === 'REGULAR' || isCrypto(symbol)
@ -133,6 +140,49 @@ export class YahooFinanceService implements DataProviderInterface {
} }
} }
public async search(aSymbol: string): Promise<{ items: LookupItem[] }> {
let items = [];
try {
const get = bent(
`${this.yahooFinanceHostname}/v1/finance/search?q=${aSymbol}&lang=en-US&region=US&quotesCount=8&newsCount=0&enableFuzzyQuery=false&quotesQueryId=tss_match_phrase_query&multiQuoteQueryId=multi_quote_single_token_query&newsQueryId=news_cie_vespa&enableCb=true&enableNavLinks=false&enableEnhancedTrivialQuery=true`,
'GET',
'json',
200
);
const result = await get();
items = result.quotes
.filter((quote) => {
return quote.isYahooFinance;
})
.filter(({ quoteType }) => {
return (
quoteType === 'CRYPTOCURRENCY' ||
quoteType === 'EQUITY' ||
quoteType === 'ETF'
);
})
.filter(({ quoteType, symbol }) => {
if (quoteType === 'CRYPTOCURRENCY') {
// Only allow cryptocurrencies in USD
return symbol.includes('USD');
}
return true;
})
.map(({ longname, shortname, symbol }) => {
return {
dataSource: DataSource.YAHOO,
name: longname || shortname,
symbol: convertFromYahooSymbol(symbol)
};
});
} catch {}
return { items };
}
/** /**
* Converts a symbol to a Yahoo symbol * Converts a symbol to a Yahoo symbol
* *
@ -168,7 +218,7 @@ export class YahooFinanceService implements DataProviderInterface {
private parseExchange(aString: string): string { private parseExchange(aString: string): string {
if (aString?.toLowerCase() === 'ccc') { if (aString?.toLowerCase() === 'ccc') {
return 'Other'; return UNKNOWN_KEY;
} }
return aString; return aString;
@ -198,7 +248,7 @@ export class YahooFinanceService implements DataProviderInterface {
return Industry.Software; return Industry.Software;
} }
return Industry.Other; return Industry.Unknown;
} }
private parseSector(aString: string): Sector { private parseSector(aString: string): Sector {
@ -220,7 +270,7 @@ export class YahooFinanceService implements DataProviderInterface {
return Sector.Technology; return Sector.Technology;
} }
return Sector.Other; return Sector.Unknown;
} }
private parseType(aString: string): Type { private parseType(aString: string): Type {
@ -232,7 +282,7 @@ export class YahooFinanceService implements DataProviderInterface {
return Type.Stock; return Type.Stock;
} }
return Type.Other; return Type.Unknown;
} }
} }

View File

@ -1,4 +1,4 @@
import { getYesterday } from '@ghostfolio/helper'; import { getYesterday } from '@ghostfolio/common/helper';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Currency } from '@prisma/client'; import { Currency } from '@prisma/client';
import { format } from 'date-fns'; import { format } from 'date-fns';

View File

@ -1,4 +1,6 @@
import { Granularity } from './granularity.type'; import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { Granularity } from '@ghostfolio/common/types';
import { import {
IDataProviderHistoricalResponse, IDataProviderHistoricalResponse,
IDataProviderResponse IDataProviderResponse
@ -15,4 +17,6 @@ export interface DataProviderInterface {
): Promise<{ ): Promise<{
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse }; [symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
}>; }>;
search(aSymbol: string): Promise<{ items: LookupItem[] }>;
} }

View File

@ -4,9 +4,11 @@ export interface Environment extends CleanedEnvAccessors {
ACCESS_TOKEN_SALT: string; ACCESS_TOKEN_SALT: string;
ALPHA_VANTAGE_API_KEY: string; ALPHA_VANTAGE_API_KEY: string;
CACHE_TTL: number; CACHE_TTL: number;
DATA_SOURCES: string | string[]; // string is not correct, error in envalid?
ENABLE_FEATURE_CUSTOM_SYMBOLS: boolean; ENABLE_FEATURE_CUSTOM_SYMBOLS: boolean;
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: boolean; ENABLE_FEATURE_FEAR_AND_GREED_INDEX: boolean;
ENABLE_FEATURE_SOCIAL_LOGIN: boolean; ENABLE_FEATURE_SOCIAL_LOGIN: boolean;
ENABLE_FEATURE_SUBSCRIPTION: boolean;
GOOGLE_CLIENT_ID: string; GOOGLE_CLIENT_ID: string;
GOOGLE_SECRET: string; GOOGLE_SECRET: string;
JWT_SECRET_KEY: string; JWT_SECRET_KEY: string;

View File

@ -1,4 +1,5 @@
import { Currency, Platform } from '@prisma/client'; import { UNKNOWN_KEY } from '@ghostfolio/common/config';
import { Account, Currency, DataSource } from '@prisma/client';
import { OrderType } from '../../models/order-type'; import { OrderType } from '../../models/order-type';
@ -7,9 +8,9 @@ export const Industry = {
Biotechnology: 'Biotechnology', Biotechnology: 'Biotechnology',
Food: 'Food', Food: 'Food',
Internet: 'Internet', Internet: 'Internet',
Other: 'Other',
Pharmaceutical: 'Pharmaceutical', Pharmaceutical: 'Pharmaceutical',
Software: 'Software' Software: 'Software',
Unknown: UNKNOWN_KEY
}; };
export const MarketState = { export const MarketState = {
@ -21,23 +22,23 @@ export const MarketState = {
export const Sector = { export const Sector = {
Consumer: 'Consumer', Consumer: 'Consumer',
Healthcare: 'Healthcare', Healthcare: 'Healthcare',
Other: 'Other', Technology: 'Technology',
Technology: 'Technology' Unknown: UNKNOWN_KEY
}; };
export const Type = { export const Type = {
Cryptocurrency: 'Cryptocurrency', Cryptocurrency: 'Cryptocurrency',
ETF: 'ETF', ETF: 'ETF',
Other: 'Other', Stock: 'Stock',
Stock: 'Stock' Unknown: UNKNOWN_KEY
}; };
export interface IOrder { export interface IOrder {
account: Account;
currency: Currency; currency: Currency;
date: string; date: string;
fee: number; fee: number;
id?: string; id?: string;
platform: Platform;
quantity: number; quantity: number;
symbol: string; symbol: string;
type: OrderType; type: OrderType;
@ -51,6 +52,7 @@ export interface IDataProviderHistoricalResponse {
export interface IDataProviderResponse { export interface IDataProviderResponse {
currency: Currency; currency: Currency;
dataSource: DataSource;
exchange?: string; exchange?: string;
industry?: Industry; industry?: Industry;
marketChange?: number; marketChange?: number;

View File

@ -2,7 +2,8 @@ import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { PrismaClient } from '@prisma/client'; import { PrismaClient } from '@prisma/client';
@Injectable() @Injectable()
export class PrismaService extends PrismaClient export class PrismaService
extends PrismaClient
implements OnModuleInit, OnModuleDestroy { implements OnModuleInit, OnModuleDestroy {
async onModuleInit() { async onModuleInit() {
await this.$connect(); await this.$connect();

View File

@ -2,14 +2,14 @@ import { Injectable } from '@nestjs/common';
import { Portfolio } from '../models/portfolio'; import { Portfolio } from '../models/portfolio';
import { Rule } from '../models/rule'; import { Rule } from '../models/rule';
import { AccountClusterRiskCurrentInvestment } from '../models/rules/account-cluster-risk/current-investment';
import { AccountClusterRiskInitialInvestment } from '../models/rules/account-cluster-risk/initial-investment';
import { AccountClusterRiskSingleAccount } from '../models/rules/account-cluster-risk/single-account';
import { CurrencyClusterRiskBaseCurrencyCurrentInvestment } from '../models/rules/currency-cluster-risk/base-currency-current-investment'; import { CurrencyClusterRiskBaseCurrencyCurrentInvestment } from '../models/rules/currency-cluster-risk/base-currency-current-investment';
import { CurrencyClusterRiskBaseCurrencyInitialInvestment } from '../models/rules/currency-cluster-risk/base-currency-initial-investment'; import { CurrencyClusterRiskBaseCurrencyInitialInvestment } from '../models/rules/currency-cluster-risk/base-currency-initial-investment';
import { CurrencyClusterRiskCurrentInvestment } from '../models/rules/currency-cluster-risk/current-investment'; import { CurrencyClusterRiskCurrentInvestment } from '../models/rules/currency-cluster-risk/current-investment';
import { CurrencyClusterRiskInitialInvestment } from '../models/rules/currency-cluster-risk/initial-investment'; import { CurrencyClusterRiskInitialInvestment } from '../models/rules/currency-cluster-risk/initial-investment';
import { FeeRatioInitialInvestment } from '../models/rules/fees/fee-ratio-initial-investment'; import { FeeRatioInitialInvestment } from '../models/rules/fees/fee-ratio-initial-investment';
import { PlatformClusterRiskCurrentInvestment } from '../models/rules/platform-cluster-risk/current-investment';
import { PlatformClusterRiskInitialInvestment } from '../models/rules/platform-cluster-risk/initial-investment';
import { PlatformClusterRiskSinglePlatform } from '../models/rules/platform-cluster-risk/single-platform';
@Injectable() @Injectable()
export class RulesService { export class RulesService {
@ -39,6 +39,17 @@ export class RulesService {
private getDefaultRuleSettings(aUserSettings: { baseCurrency: string }) { private getDefaultRuleSettings(aUserSettings: { baseCurrency: string }) {
return { return {
[AccountClusterRiskCurrentInvestment.name]: {
baseCurrency: aUserSettings.baseCurrency,
isActive: true,
threshold: 0.5
},
[AccountClusterRiskInitialInvestment.name]: {
baseCurrency: aUserSettings.baseCurrency,
isActive: true,
threshold: 0.5
},
[AccountClusterRiskSingleAccount.name]: { isActive: true },
[CurrencyClusterRiskBaseCurrencyInitialInvestment.name]: { [CurrencyClusterRiskBaseCurrencyInitialInvestment.name]: {
baseCurrency: aUserSettings.baseCurrency, baseCurrency: aUserSettings.baseCurrency,
isActive: true isActive: true
@ -61,18 +72,7 @@ export class RulesService {
baseCurrency: aUserSettings.baseCurrency, baseCurrency: aUserSettings.baseCurrency,
isActive: true, isActive: true,
threshold: 0.01 threshold: 0.01
}, }
[PlatformClusterRiskCurrentInvestment.name]: {
baseCurrency: aUserSettings.baseCurrency,
isActive: true,
threshold: 0.5
},
[PlatformClusterRiskInitialInvestment.name]: {
baseCurrency: aUserSettings.baseCurrency,
isActive: true,
threshold: 0.5
},
[PlatformClusterRiskSinglePlatform.name]: { isActive: true }
}; };
} }
} }

View File

@ -1,7 +1,7 @@
import { import {
DEFAULT_DATE_FORMAT, DEFAULT_DATE_FORMAT,
DEFAULT_DATE_FORMAT_MONTH_YEAR DEFAULT_DATE_FORMAT_MONTH_YEAR
} from '@ghostfolio/helper'; } from '@ghostfolio/common/config';
export const DateFormats = { export const DateFormats = {
display: { display: {

View File

@ -9,11 +9,6 @@ const routes: Routes = [
loadChildren: () => loadChildren: () =>
import('./pages/about/about-page.module').then((m) => m.AboutPageModule) import('./pages/about/about-page.module').then((m) => m.AboutPageModule)
}, },
{
path: 'admin',
loadChildren: () =>
import('./pages/admin/admin-page.module').then((m) => m.AdminPageModule)
},
{ {
path: 'account', path: 'account',
loadChildren: () => loadChildren: () =>
@ -22,9 +17,16 @@ const routes: Routes = [
) )
}, },
{ {
path: 'auth', path: 'accounts',
loadChildren: () => loadChildren: () =>
import('./pages/auth/auth-page.module').then((m) => m.AuthPageModule) import('./pages/accounts/accounts-page.module').then(
(m) => m.AccountsPageModule
)
},
{
path: 'admin',
loadChildren: () =>
import('./pages/admin/admin-page.module').then((m) => m.AdminPageModule)
}, },
{ {
path: 'analysis', path: 'analysis',
@ -33,11 +35,23 @@ const routes: Routes = [
(m) => m.AnalysisPageModule (m) => m.AnalysisPageModule
) )
}, },
{
path: 'auth',
loadChildren: () =>
import('./pages/auth/auth-page.module').then((m) => m.AuthPageModule)
},
{ {
path: 'home', path: 'home',
loadChildren: () => loadChildren: () =>
import('./pages/home/home-page.module').then((m) => m.HomePageModule) import('./pages/home/home-page.module').then((m) => m.HomePageModule)
}, },
{
path: 'pricing',
loadChildren: () =>
import('./pages/pricing/pricing-page.module').then(
(m) => m.PricingPageModule
)
},
{ {
path: 'report', path: 'report',
loadChildren: () => loadChildren: () =>
@ -57,6 +71,11 @@ const routes: Routes = [
loadChildren: () => loadChildren: () =>
import('./pages/login/login-page.module').then((m) => m.LoginPageModule) import('./pages/login/login-page.module').then((m) => m.LoginPageModule)
}, },
{
path: 'tools',
loadChildren: () =>
import('./pages/tools/tools-page.module').then((m) => m.ToolsPageModule)
},
{ {
path: 'transactions', path: 'transactions',
loadChildren: () => loadChildren: () =>
@ -64,11 +83,16 @@ const routes: Routes = [
(m) => m.TransactionsPageModule (m) => m.TransactionsPageModule
) )
}, },
{
path: 'zen',
loadChildren: () =>
import('./pages/zen/zen-page.module').then((m) => m.ZenPageModule)
},
{ {
// wildcard, if requested url doesn't match any paths for routes defined // wildcard, if requested url doesn't match any paths for routes defined
// earlier // earlier
path: '**', path: '**',
redirectTo: '/home', redirectTo: 'home',
pathMatch: 'full' pathMatch: 'full'
} }
]; ];

View File

@ -4,6 +4,7 @@
[currentRoute]="currentRoute" [currentRoute]="currentRoute"
[info]="info" [info]="info"
[user]="user" [user]="user"
(signOut)="onSignOut()"
></gf-header> ></gf-header>
</header> </header>
@ -25,7 +26,10 @@
<router-outlet></router-outlet> <router-outlet></router-outlet>
</main> </main>
<footer class="footer d-flex justify-content-center position-absolute w-100"> <footer
*ngIf="currentRoute === 'start' || deviceType !== 'mobile'"
class="footer d-flex justify-content-center position-absolute w-100"
>
<div class="container text-center"> <div class="container text-center">
<div> <div>
© {{ currentYear }} <a href="https://ghostfol.io">Ghostfolio</a> © {{ currentYear }} <a href="https://ghostfol.io">Ghostfolio</a>

View File

@ -5,22 +5,19 @@ import {
OnDestroy, OnDestroy,
OnInit OnInit
} from '@angular/core'; } from '@angular/core';
import { NavigationEnd, Router } from '@angular/router'; import { NavigationEnd, PRIMARY_OUTLET, Router } from '@angular/router';
import { InfoItem } from '@ghostfolio/api/app/info/interfaces/info-item.interface'; import { primaryColorHex, secondaryColorHex } from '@ghostfolio/common/config';
import { User } from '@ghostfolio/api/app/user/interfaces/user.interface'; import { InfoItem, User } from '@ghostfolio/common/interfaces';
import { import { hasPermission, permissions } from '@ghostfolio/common/permissions';
hasPermission,
permissions,
primaryColorHex,
secondaryColorHex
} from '@ghostfolio/helper';
import { MaterialCssVarsService } from 'angular-material-css-vars'; import { MaterialCssVarsService } from 'angular-material-css-vars';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { filter, takeUntil } from 'rxjs/operators'; import { filter, takeUntil } from 'rxjs/operators';
import { environment } from '../environments/environment'; import { environment } from '../environments/environment';
import { DataService } from './services/data.service'; import { DataService } from './services/data.service';
import { TokenStorageService } from './services/token-storage.service'; import { TokenStorageService } from './services/token-storage.service';
import { UserService } from './services/user/user.service';
@Component({ @Component({
selector: 'gf-root', selector: 'gf-root',
@ -32,59 +29,78 @@ export class AppComponent implements OnDestroy, OnInit {
public canCreateAccount: boolean; public canCreateAccount: boolean;
public currentRoute: string; public currentRoute: string;
public currentYear = new Date().getFullYear(); public currentYear = new Date().getFullYear();
public deviceType: string;
public info: InfoItem; public info: InfoItem;
public isLoggedIn = false;
public user: User; public user: User;
public version = environment.version; public version = environment.version;
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
public constructor( public constructor(
private cd: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService, private dataService: DataService,
private deviceService: DeviceDetectorService,
private materialCssVarsService: MaterialCssVarsService, private materialCssVarsService: MaterialCssVarsService,
private router: Router, private router: Router,
private tokenStorageService: TokenStorageService private tokenStorageService: TokenStorageService,
private userService: UserService
) { ) {
this.initializeTheme(); this.initializeTheme();
this.user = undefined; this.user = undefined;
} }
public ngOnInit() { public ngOnInit() {
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
this.dataService.fetchInfo().subscribe((info) => { this.dataService.fetchInfo().subscribe((info) => {
this.info = info; this.info = info;
}); });
this.router.events this.router.events
.pipe(filter((event) => event instanceof NavigationEnd)) .pipe(filter((event) => event instanceof NavigationEnd))
.subscribe((test) => { .subscribe(() => {
this.currentRoute = this.router.url.toString().substring(1); const urlTree = this.router.parseUrl(this.router.url);
// this.initializeTheme(); const urlSegmentGroup = urlTree.root.children[PRIMARY_OUTLET];
const urlSegments = urlSegmentGroup.segments;
this.currentRoute = urlSegments[0].path;
}); });
this.tokenStorageService this.userService.stateChanged
.onChangeHasToken()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => { .subscribe((state) => {
this.isLoggedIn = !!this.tokenStorageService.getToken(); if (state?.user) {
this.user = state.user;
if (this.isLoggedIn) { this.canCreateAccount = hasPermission(
this.dataService.fetchUser().subscribe((user) => { this.user.permissions,
this.user = user; permissions.createUserAccount
);
this.canCreateAccount = hasPermission( } else if (!this.tokenStorageService.getToken()) {
this.user.permissions, // User has not been logged in
permissions.createAccount
);
this.cd.markForCheck();
});
} else {
this.user = null; this.user = null;
} }
this.changeDetectorRef.markForCheck();
}); });
} }
public onCreateAccount() {
this.tokenStorageService.signOut();
window.location.reload();
}
public onSignOut() {
this.tokenStorageService.signOut();
this.userService.remove();
window.location.reload();
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
private initializeTheme() { private initializeTheme() {
this.materialCssVarsService.setDarkTheme( this.materialCssVarsService.setDarkTheme(
window.matchMedia('(prefers-color-scheme: dark)').matches window.matchMedia('(prefers-color-scheme: dark)').matches
@ -97,14 +113,4 @@ export class AppComponent implements OnDestroy, OnInit {
this.materialCssVarsService.setPrimaryColor(primaryColorHex); this.materialCssVarsService.setPrimaryColor(primaryColorHex);
this.materialCssVarsService.setAccentColor(secondaryColorHex); this.materialCssVarsService.setAccentColor(secondaryColorHex);
} }
public onCreateAccount() {
this.tokenStorageService.signOut();
window.location.reload();
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
} }

View File

@ -22,7 +22,7 @@ import { AppComponent } from './app.component';
import { GfHeaderModule } from './components/header/header.module'; import { GfHeaderModule } from './components/header/header.module';
import { authInterceptorProviders } from './core/auth.interceptor'; import { authInterceptorProviders } from './core/auth.interceptor';
import { httpResponseInterceptorProviders } from './core/http-response.interceptor'; import { httpResponseInterceptorProviders } from './core/http-response.interceptor';
import { LanguageManager } from './core/language-manager.service'; import { LanguageService } from './core/language.service';
@NgModule({ @NgModule({
declarations: [AppComponent], declarations: [AppComponent],
@ -46,11 +46,11 @@ import { LanguageManager } from './core/language-manager.service';
providers: [ providers: [
authInterceptorProviders, authInterceptorProviders,
httpResponseInterceptorProviders, httpResponseInterceptorProviders,
LanguageManager, LanguageService,
{ {
provide: DateAdapter, provide: DateAdapter,
useClass: CustomDateAdapter, useClass: CustomDateAdapter,
deps: [LanguageManager, MAT_DATE_LOCALE, Platform] deps: [LanguageService, MAT_DATE_LOCALE, Platform]
}, },
{ provide: MAT_DATE_FORMATS, useValue: DateFormats } { provide: MAT_DATE_FORMATS, useValue: DateFormats }
], ],

View File

@ -1,14 +1,14 @@
<table mat-table [dataSource]="dataSource" class="w-100"> <table class="gf-table w-100" mat-table [dataSource]="dataSource">
<ng-container matColumnDef="granteeAlias"> <ng-container matColumnDef="granteeAlias">
<th mat-header-cell *matHeaderCellDef i18n>User</th> <th *matHeaderCellDef class="px-1" i18n mat-header-cell>User</th>
<td mat-cell *matCellDef="let element"> <td *matCellDef="let element" class="px-1" mat-cell>
{{ element.granteeAlias }} {{ element.granteeAlias }}
</td></ng-container </td></ng-container
> >
<ng-container matColumnDef="type"> <ng-container matColumnDef="type">
<th mat-header-cell *matHeaderCellDef i18n>Type</th> <th *matHeaderCellDef class="px-1" i18n mat-header-cell>Type</th>
<td mat-cell *matCellDef="let element"> <td *matCellDef="let element" class="px-1" mat-cell>
<ion-icon class="mr-1" name="lock-closed-outline"></ion-icon> <ion-icon class="mr-1" name="lock-closed-outline"></ion-icon>
Restricted Access Restricted Access
</td></ng-container </td></ng-container

View File

@ -1,3 +1,5 @@
@import '~apps/client/src/styles/ghostfolio-style';
:host { :host {
display: block; display: block;
} }

View File

@ -6,7 +6,7 @@ import {
OnInit OnInit
} from '@angular/core'; } from '@angular/core';
import { MatTableDataSource } from '@angular/material/table'; import { MatTableDataSource } from '@angular/material/table';
import { Access } from '@ghostfolio/api/app/access/interfaces/access.interface'; import { Access } from '@ghostfolio/common/interfaces';
@Component({ @Component({
selector: 'gf-access-table', selector: 'gf-access-table',

View File

@ -0,0 +1,81 @@
<table
class="gf-table w-100"
matSort
matSortActive="account"
matSortDirection="desc"
mat-table
[dataSource]="dataSource"
>
<ng-container matColumnDef="account">
<th *matHeaderCellDef class="px-1" i18n mat-header-cell mat-sort-header>
Name
</th>
<td *matCellDef="let element" class="px-1" mat-cell>
{{ element.name }}
</td>
</ng-container>
<ng-container matColumnDef="platform">
<th *matHeaderCellDef class="px-1" i18n mat-header-cell mat-sort-header>
Platform
</th>
<td *matCellDef="let element" class="px-1" mat-cell>
<div class="d-flex">
<gf-symbol-icon
*ngIf="element.Platform?.url"
class="mr-1"
[tooltip]=""
[url]="element.Platform?.url"
></gf-symbol-icon>
<span>{{ element.Platform?.name }}</span>
</div>
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th *matHeaderCellDef class="px-1 text-center" i18n mat-header-cell></th>
<td *matCellDef="let element" class="px-1 text-center" mat-cell>
<button
class="mx-1 no-min-width px-2"
mat-button
[matMenuTriggerFor]="accountMenu"
(click)="$event.stopPropagation()"
>
<ion-icon name="ellipsis-vertical"></ion-icon>
</button>
<mat-menu #accountMenu="matMenu" xPosition="before">
<button i18n mat-menu-item (click)="onUpdateAccount(element)">
Edit
</button>
<button
i18n
mat-menu-item
[disabled]="element.isDefault || element.Order?.length > 0"
(click)="onDeleteAccount(element.id)"
>
Delete
</button>
</mat-menu>
</td>
</ng-container>
<ng-container matColumnDef="transactions">
<th *matHeaderCellDef i18n mat-header-cell mat-sort-header>Transactions</th>
<td *matCellDef="let element" mat-cell>
{{ element.Order?.length }}
</td>
</ng-container>
<tr *matHeaderRowDef="displayedColumns" mat-header-row></tr>
<tr *matRowDef="let row; columns: displayedColumns" mat-row></tr>
</table>
<ngx-skeleton-loader
*ngIf="isLoading"
animation="pulse"
class="px-4 py-3"
[theme]="{
height: '1.5rem',
width: '100%'
}"
></ngx-skeleton-loader>

View File

@ -0,0 +1,27 @@
@import '~apps/client/src/styles/ghostfolio-style';
:host {
display: block;
::ng-deep {
.mat-form-field-infix {
border-top: 0 solid transparent !important;
}
}
.mat-table {
th {
::ng-deep {
.mat-sort-header-container {
justify-content: inherit;
}
}
}
}
}
:host-context(.is-dark-theme) {
.mat-form-field {
color: rgba(var(--light-primary-text));
}
}

View File

@ -0,0 +1,85 @@
import {
ChangeDetectionStrategy,
Component,
EventEmitter,
Input,
OnChanges,
OnDestroy,
OnInit,
Output,
ViewChild
} from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { MatSort } from '@angular/material/sort';
import { MatTableDataSource } from '@angular/material/table';
import { ActivatedRoute, Router } from '@angular/router';
import { Account as AccountModel } from '@prisma/client';
import { Subject, Subscription } from 'rxjs';
@Component({
selector: 'gf-accounts-table',
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './accounts-table.component.html',
styleUrls: ['./accounts-table.component.scss']
})
export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit {
@Input() accounts: AccountModel[];
@Input() baseCurrency: string;
@Input() deviceType: string;
@Input() locale: string;
@Input() showActions: boolean;
@Output() accountDeleted = new EventEmitter<string>();
@Output() accountToUpdate = new EventEmitter<AccountModel>();
@ViewChild(MatSort) sort: MatSort;
public dataSource: MatTableDataSource<AccountModel> = new MatTableDataSource();
public displayedColumns = [];
public isLoading = true;
public routeQueryParams: Subscription;
private unsubscribeSubject = new Subject<void>();
public constructor(
private dialog: MatDialog,
private route: ActivatedRoute,
private router: Router
) {}
public ngOnInit() {}
public ngOnChanges() {
this.displayedColumns = ['account', 'platform', 'transactions'];
if (this.showActions) {
this.displayedColumns.push('actions');
}
this.isLoading = true;
if (this.accounts) {
this.dataSource = new MatTableDataSource(this.accounts);
this.dataSource.sort = this.sort;
this.isLoading = false;
}
}
public onDeleteAccount(aId: string) {
const confirmation = confirm('Do you really want to delete this account?');
if (confirmation) {
this.accountDeleted.emit(aId);
}
}
public onUpdateAccount(aAccount: AccountModel) {
this.accountToUpdate.emit(aAccount);
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
}

View File

@ -0,0 +1,33 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatInputModule } from '@angular/material/input';
import { MatMenuModule } from '@angular/material/menu';
import { MatSortModule } from '@angular/material/sort';
import { MatTableModule } from '@angular/material/table';
import { RouterModule } from '@angular/router';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { GfSymbolIconModule } from '../symbol-icon/symbol-icon.module';
import { GfValueModule } from '../value/value.module';
import { AccountsTableComponent } from './accounts-table.component';
@NgModule({
declarations: [AccountsTableComponent],
exports: [AccountsTableComponent],
imports: [
CommonModule,
GfSymbolIconModule,
GfValueModule,
MatButtonModule,
MatInputModule,
MatMenuModule,
MatSortModule,
MatTableModule,
NgxSkeletonLoaderModule,
RouterModule
],
providers: [],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class GfAccountsTableModule {}

View File

@ -5,7 +5,7 @@ import {
OnChanges, OnChanges,
OnInit OnInit
} from '@angular/core'; } from '@angular/core';
import { resolveFearAndGreedIndex } from '@ghostfolio/helper'; import { resolveFearAndGreedIndex } from '@ghostfolio/common/helper';
@Component({ @Component({
selector: 'gf-fear-and-greed-index', selector: 'gf-fear-and-greed-index',

View File

@ -6,59 +6,77 @@
<span class="spacer"></span> <span class="spacer"></span>
<a <a
class="d-none d-sm-block" class="d-none d-sm-block"
[routerLink]="['/']"
i18n i18n
mat-flat-button mat-flat-button
[color]="currentRoute === 'home' ? 'primary' : null" [color]="
currentRoute === 'home' || currentRoute === 'zen' ? 'primary' : null
"
[routerLink]="['/']"
>Overview</a >Overview</a
> >
<a <a
*ngIf="user?.settings?.viewMode === 'DEFAULT'"
class="d-none d-sm-block mx-1" class="d-none d-sm-block mx-1"
[routerLink]="['/analysis']"
i18n i18n
mat-flat-button mat-flat-button
[color]="currentRoute === 'analysis' ? 'primary' : null" [color]="
>Analysis</a currentRoute === 'analysis' ||
currentRoute === 'report' ||
currentRoute === 'tools'
? 'primary'
: null
"
[routerLink]="['/tools']"
>Tools</a
> >
<a <a
class="d-none d-sm-block mx-1" class="d-none d-sm-block mx-1"
[routerLink]="['/report']"
i18n
mat-flat-button
[color]="currentRoute === 'report' ? 'primary' : null"
>X-ray</a
>
<a
class="d-none d-sm-block mx-1"
[routerLink]="['/transactions']"
i18n i18n
mat-flat-button mat-flat-button
[color]="currentRoute === 'transactions' ? 'primary' : null" [color]="currentRoute === 'transactions' ? 'primary' : null"
[routerLink]="['/transactions']"
>Transactions</a >Transactions</a
> >
<a <a
*ngIf="canAccessAdminAccessControl"
class="d-none d-sm-block mx-1" class="d-none d-sm-block mx-1"
[routerLink]="['/admin']" i18n
mat-flat-button
[color]="currentRoute === 'accounts' ? 'primary' : null"
[routerLink]="['/accounts']"
>Accounts</a
>
<a
*ngIf="hasPermissionToAccessAdminControl"
class="d-none d-sm-block mx-1"
i18n i18n
mat-flat-button mat-flat-button
[color]="currentRoute === 'admin' ? 'primary' : null" [color]="currentRoute === 'admin' ? 'primary' : null"
[routerLink]="['/admin']"
>Admin Control</a >Admin Control</a
> >
<a <a
class="d-none d-sm-block mx-1" class="d-none d-sm-block mx-1"
[routerLink]="['/resources']"
i18n i18n
mat-flat-button mat-flat-button
[color]="currentRoute === 'resources' ? 'primary' : null" [color]="currentRoute === 'resources' ? 'primary' : null"
[routerLink]="['/resources']"
>Resources</a >Resources</a
> >
<a
*ngIf="hasPermissionForSubscription"
class="d-none d-sm-block mx-1"
i18n
mat-flat-button
[color]="currentRoute === 'pricing' ? 'primary' : null"
[routerLink]="['/pricing']"
>Pricing</a
>
<a <a
class="d-none d-sm-block mx-1" class="d-none d-sm-block mx-1"
[routerLink]="['/about']"
i18n i18n
mat-flat-button mat-flat-button
[color]="currentRoute === 'about' ? 'primary' : null" [color]="currentRoute === 'about' ? 'primary' : null"
[routerLink]="['/about']"
>About</a >About</a
> >
<button <button
@ -119,60 +137,78 @@
</ng-container> </ng-container>
<a <a
class="d-block d-sm-none" class="d-block d-sm-none"
[routerLink]="['/analysis']"
i18n i18n
mat-menu-item mat-menu-item
[ngClass]="{ 'font-weight-bold': currentRoute === 'analysis' }" [ngClass]="{
>Analysis</a 'font-weight-bold':
currentRoute === 'analysis' ||
currentRoute === 'report' ||
currentRoute === 'tools'
}"
[routerLink]="['/tools']"
>Tools</a
> >
<a <a
class="d-block d-sm-none" class="d-block d-sm-none"
[routerLink]="['/report']"
i18n i18n
mat-menu-item mat-menu-item
[ngClass]="{ 'font-weight-bold': currentRoute === 'report' }" [ngClass]="{
>X-ray</a 'font-weight-bold': currentRoute === 'transactions'
> }"
<a
class="d-block d-sm-none"
[routerLink]="['/transactions']" [routerLink]="['/transactions']"
i18n
mat-menu-item
[ngClass]="{ 'font-weight-bold': currentRoute === 'transactions' }"
>Transactions</a >Transactions</a
> >
<a
class="d-block d-sm-none"
i18n
mat-menu-item
[ngClass]="{ 'font-weight-bold': currentRoute === 'accounts' }"
[routerLink]="['/accounts']"
>Accounts</a
>
<a <a
class="align-items-center d-flex" class="align-items-center d-flex"
[routerLink]="['/account']"
i18n i18n
mat-menu-item mat-menu-item
[ngClass]="{ 'font-weight-bold': currentRoute === 'account' }" [ngClass]="{ 'font-weight-bold': currentRoute === 'account' }"
>Account</a [routerLink]="['/account']"
>My Ghostfolio</a
> >
<a <a
*ngIf="canAccessAdminAccessControl" *ngIf="hasPermissionToAccessAdminControl"
class="d-block d-sm-none" class="d-block d-sm-none"
[routerLink]="['/admin']"
i18n i18n
mat-menu-item mat-menu-item
[ngClass]="{ 'font-weight-bold': currentRoute === 'admin' }" [ngClass]="{ 'font-weight-bold': currentRoute === 'admin' }"
[routerLink]="['/admin']"
>Admin Control</a >Admin Control</a
> >
<hr class="m-0" /> <hr class="m-0" />
<a <a
class="d-block d-sm-none" class="d-block d-sm-none"
[routerLink]="['/resources']"
i18n i18n
mat-menu-item mat-menu-item
[ngClass]="{ 'font-weight-bold': currentRoute === 'resources' }" [ngClass]="{
'font-weight-bold': currentRoute === 'resources'
}"
[routerLink]="['/resources']"
>Resources</a >Resources</a
> >
<a
*ngIf="hasPermissionForSubscription"
class="d-block d-sm-none"
i18n
mat-menu-item
[ngClass]="{ 'font-weight-bold': currentRoute === 'pricing' }"
[routerLink]="['/pricing']"
>Pricing</a
>
<a <a
class="d-block d-sm-none" class="d-block d-sm-none"
[routerLink]="['/about']"
i18n i18n
mat-menu-item mat-menu-item
[ngClass]="{ 'font-weight-bold': currentRoute === 'about' }" [ngClass]="{ 'font-weight-bold': currentRoute === 'about' }"
[routerLink]="['/about']"
>About Ghostfolio</a >About Ghostfolio</a
> >
<hr class="d-block d-sm-none m-0" /> <hr class="d-block d-sm-none m-0" />
@ -182,19 +218,27 @@
<ng-container *ngIf="user === null"> <ng-container *ngIf="user === null">
<a <a
*ngIf="currentRoute && currentRoute !== 'start'" *ngIf="currentRoute && currentRoute !== 'start'"
[routerLink]="['/']"
class="mx-2 no-min-width px-2" class="mx-2 no-min-width px-2"
mat-button mat-button
[routerLink]="['/']"
> >
<gf-logo></gf-logo> <gf-logo></gf-logo>
</a> </a>
<span class="spacer"></span> <span class="spacer"></span>
<a
*ngIf="hasPermissionForSubscription"
i18n
mat-flat-button
[color]="currentRoute === 'pricing' ? 'primary' : null"
[routerLink]="['/pricing']"
>Pricing</a
>
<a <a
class="d-none d-sm-block mx-1" class="d-none d-sm-block mx-1"
[routerLink]="['/about']"
i18n i18n
mat-flat-button mat-flat-button
[color]="currentRoute === 'about' ? 'primary' : null" [color]="currentRoute === 'about' ? 'primary' : null"
[routerLink]="['/about']"
>About</a >About</a
> >
<a <a

View File

@ -1,18 +1,19 @@
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
Component, Component,
EventEmitter,
Input, Input,
OnChanges OnChanges,
Output
} from '@angular/core'; } from '@angular/core';
import { MatDialog } from '@angular/material/dialog'; import { MatDialog } from '@angular/material/dialog';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { InfoItem } from '@ghostfolio/api/app/info/interfaces/info-item.interface';
import { User } from '@ghostfolio/api/app/user/interfaces/user.interface';
import { LoginWithAccessTokenDialog } from '@ghostfolio/client/pages/login/login-with-access-token-dialog/login-with-access-token-dialog.component'; import { LoginWithAccessTokenDialog } from '@ghostfolio/client/pages/login/login-with-access-token-dialog/login-with-access-token-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';
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service'; import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
import { hasPermission, permissions } from '@ghostfolio/helper'; import { InfoItem, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { EMPTY, Subject } from 'rxjs'; import { EMPTY, Subject } from 'rxjs';
import { catchError, takeUntil } from 'rxjs/operators'; import { catchError, takeUntil } from 'rxjs/operators';
@ -27,8 +28,11 @@ export class HeaderComponent implements OnChanges {
@Input() info: InfoItem; @Input() info: InfoItem;
@Input() user: User; @Input() user: User;
public canAccessAdminAccessControl: boolean; @Output() signOut = new EventEmitter<void>();
public hasPermissionToUseSocialLogin: boolean;
public hasPermissionForSocialLogin: boolean;
public hasPermissionForSubscription: boolean;
public hasPermissionToAccessAdminControl: boolean;
public impersonationId: string; public impersonationId: string;
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
@ -48,16 +52,19 @@ export class HeaderComponent implements OnChanges {
} }
public ngOnChanges() { public ngOnChanges() {
if (this.user) { this.hasPermissionForSocialLogin = hasPermission(
this.canAccessAdminAccessControl = hasPermission(
this.user.permissions,
permissions.accessAdminControl
);
}
this.hasPermissionToUseSocialLogin = hasPermission(
this.info?.globalPermissions, this.info?.globalPermissions,
permissions.useSocialLogin permissions.enableSocialLogin
);
this.hasPermissionForSubscription = hasPermission(
this.info?.globalPermissions,
permissions.enableSubscription
);
this.hasPermissionToAccessAdminControl = hasPermission(
this.user?.permissions,
permissions.accessAdminControl
); );
} }
@ -72,8 +79,7 @@ export class HeaderComponent implements OnChanges {
} }
public onSignOut() { public onSignOut() {
this.tokenStorageService.signOut(); this.signOut.next();
window.location.reload();
} }
public openLoginDialog(): void { public openLoginDialog(): void {
@ -81,7 +87,7 @@ export class HeaderComponent implements OnChanges {
autoFocus: false, autoFocus: false,
data: { data: {
accessToken: '', accessToken: '',
hasPermissionToUseSocialLogin: this.hasPermissionToUseSocialLogin hasPermissionToUseSocialLogin: this.hasPermissionForSocialLogin
}, },
width: '30rem' width: '30rem'
}); });

View File

@ -9,8 +9,8 @@ import {
OnInit, OnInit,
ViewChild ViewChild
} from '@angular/core'; } from '@angular/core';
import { PortfolioItem } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-item.interface'; import { primaryColorRgb } from '@ghostfolio/common/config';
import { primaryColorRgb } from '@ghostfolio/helper'; import { PortfolioItem } from '@ghostfolio/common/interfaces';
import { import {
LineController, LineController,
LineElement, LineElement,

View File

@ -9,7 +9,8 @@ import {
OnInit, OnInit,
ViewChild ViewChild
} from '@angular/core'; } from '@angular/core';
import { primaryColorRgb, secondaryColorRgb } from '@ghostfolio/helper'; import { primaryColorRgb, secondaryColorRgb } from '@ghostfolio/common/config';
import { getBackgroundColor } from '@ghostfolio/common/helper';
import { import {
Chart, Chart,
Filler, Filler,
@ -62,6 +63,10 @@ export class LineChartComponent implements OnChanges, OnDestroy, OnInit {
} }
} }
public ngOnDestroy() {
this.chart?.destroy();
}
private initialize() { private initialize() {
this.isLoading = true; this.isLoading = true;
const benchmarkPrices = []; const benchmarkPrices = [];
@ -76,7 +81,7 @@ export class LineChartComponent implements OnChanges, OnDestroy, OnInit {
const canvas = document.getElementById('chartCanvas'); const canvas = document.getElementById('chartCanvas');
var gradient = this.chartCanvas?.nativeElement const gradient = this.chartCanvas?.nativeElement
?.getContext('2d') ?.getContext('2d')
.createLinearGradient( .createLinearGradient(
0, 0,
@ -88,14 +93,7 @@ export class LineChartComponent implements OnChanges, OnDestroy, OnInit {
0, 0,
`rgba(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b}, 0.01)` `rgba(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b}, 0.01)`
); );
gradient.addColorStop( gradient.addColorStop(1, getBackgroundColor());
1,
getComputedStyle(document.documentElement).getPropertyValue(
window.matchMedia('(prefers-color-scheme: dark)').matches
? '--dark-background'
: '--light-background'
)
);
const data = { const data = {
labels, labels,
@ -181,8 +179,4 @@ export class LineChartComponent implements OnChanges, OnDestroy, OnInit {
this.isLoading = false; this.isLoading = false;
} }
public ngOnDestroy() {
this.chart?.destroy();
}
} }

View File

@ -28,7 +28,7 @@ export class PerformanceChartDialog {
public title: string; public title: string;
public constructor( public constructor(
private cd: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService, private dataService: DataService,
public dialogRef: MatDialogRef<PerformanceChartDialog>, public dialogRef: MatDialogRef<PerformanceChartDialog>,
@Inject(MAT_DIALOG_DATA) public data: PositionDetailDialogParams @Inject(MAT_DIALOG_DATA) public data: PositionDetailDialogParams
@ -46,7 +46,7 @@ export class PerformanceChartDialog {
this.historicalDataItems = this.data.historicalDataItems; this.historicalDataItems = this.data.historicalDataItems;
this.historicalDataItems.forEach((historicalDataItem) => { this.historicalDataItems?.forEach((historicalDataItem) => {
const benchmarkItem = historicalData.find((item) => { const benchmarkItem = historicalData.find((item) => {
return item.date === historicalDataItem.date; return item.date === historicalDataItem.date;
}); });
@ -75,7 +75,7 @@ export class PerformanceChartDialog {
} }
}); });
this.cd.markForCheck(); this.changeDetectorRef.markForCheck();
}); });
this.title = `Performance vs. ${this.benchmarkLabel}`; this.title = `Performance vs. ${this.benchmarkLabel}`;

View File

@ -5,7 +5,7 @@ import {
OnChanges, OnChanges,
OnInit OnInit
} from '@angular/core'; } from '@angular/core';
import { PortfolioOverview } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-overview.interface'; import { PortfolioOverview } from '@ghostfolio/common/interfaces';
import { Currency } from '@prisma/client'; import { Currency } from '@prisma/client';
@Component({ @Component({

View File

@ -34,16 +34,16 @@
<div *ngIf="showDetails" class="row"> <div *ngIf="showDetails" class="row">
<div class="d-flex col justify-content-end"> <div class="d-flex col justify-content-end">
<gf-value <gf-value
colorizeSign="true" [colorizeSign]="true"
isCurrency="true" [isCurrency]="true"
[locale]="locale" [locale]="locale"
[value]="isLoading ? undefined : performance?.currentNetPerformance" [value]="isLoading ? undefined : performance?.currentNetPerformance"
></gf-value> ></gf-value>
</div> </div>
<div class="col"> <div class="col">
<gf-value <gf-value
colorizeSign="true" [colorizeSign]="true"
isPercent="true" [isPercent]="true"
[locale]="locale" [locale]="locale"
[value]=" [value]="
isLoading ? undefined : performance?.currentNetPerformancePercent isLoading ? undefined : performance?.currentNetPerformancePercent

View File

@ -7,7 +7,7 @@ import {
OnInit, OnInit,
ViewChild ViewChild
} from '@angular/core'; } from '@angular/core';
import { PortfolioPerformance } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-performance.interface'; import { PortfolioPerformance } from '@ghostfolio/common/interfaces';
import { Currency } from '@prisma/client'; import { Currency } from '@prisma/client';
import { CountUp } from 'countup.js'; import { CountUp } from 'countup.js';
import { isNumber } from 'lodash'; import { isNumber } from 'lodash';

View File

@ -16,17 +16,17 @@
<div class="d-flex flex-column flex-wrap justify-content-end"> <div class="d-flex flex-column flex-wrap justify-content-end">
<gf-value <gf-value
class="justify-content-end mb-2" class="justify-content-end mb-2"
colorizeSign="true"
position="end" position="end"
[colorizeSign]="true"
[currency]="baseCurrency" [currency]="baseCurrency"
[locale]="locale" [locale]="locale"
[value]="isLoading ? undefined : performance?.currentGrossPerformance" [value]="isLoading ? undefined : performance?.currentGrossPerformance"
></gf-value> ></gf-value>
<gf-value <gf-value
class="justify-content-end" class="justify-content-end"
colorizeSign="true"
isPercent="true"
position="end" position="end"
[colorizeSign]="true"
[isPercent]="true"
[locale]="locale" [locale]="locale"
[value]=" [value]="
isLoading ? undefined : performance?.currentGrossPerformancePercent isLoading ? undefined : performance?.currentGrossPerformancePercent
@ -39,17 +39,17 @@
<div class="d-flex flex-column flex-wrap justify-content-end"> <div class="d-flex flex-column flex-wrap justify-content-end">
<gf-value <gf-value
class="justify-content-end mb-2" class="justify-content-end mb-2"
colorizeSign="true"
position="end" position="end"
[colorizeSign]="true"
[currency]="baseCurrency" [currency]="baseCurrency"
[locale]="locale" [locale]="locale"
[value]="isLoading ? undefined : performance?.currentNetPerformance" [value]="isLoading ? undefined : performance?.currentNetPerformance"
></gf-value> ></gf-value>
<gf-value <gf-value
class="justify-content-end" class="justify-content-end"
colorizeSign="true"
isPercent="true"
position="end" position="end"
[colorizeSign]="true"
[isPercent]="true"
[locale]="locale" [locale]="locale"
[value]=" [value]="
isLoading ? undefined : performance?.currentNetPerformancePercent isLoading ? undefined : performance?.currentNetPerformancePercent

View File

@ -4,7 +4,7 @@ import {
Input, Input,
OnInit OnInit
} from '@angular/core'; } from '@angular/core';
import { PortfolioPerformance } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-performance.interface'; import { PortfolioPerformance } from '@ghostfolio/common/interfaces';
import { Currency } from '@prisma/client'; import { Currency } from '@prisma/client';
@Component({ @Component({

View File

@ -7,7 +7,7 @@ import {
OnChanges, OnChanges,
OnInit OnInit
} from '@angular/core'; } from '@angular/core';
import { PortfolioItem } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-item.interface'; import { PortfolioItem } from '@ghostfolio/common/interfaces';
import { endOfDay, parseISO, startOfDay } from 'date-fns'; import { endOfDay, parseISO, startOfDay } from 'date-fns';
@Component({ @Component({

View File

@ -7,7 +7,9 @@ import {
OnInit, OnInit,
ViewChild ViewChild
} from '@angular/core'; } from '@angular/core';
import { PortfolioPosition } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position.interface'; import { UNKNOWN_KEY } from '@ghostfolio/common/config';
import { getCssVariable, getTextColor } from '@ghostfolio/common/helper';
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
import { Currency } from '@prisma/client'; import { Currency } from '@prisma/client';
import { Tooltip } from 'chart.js'; import { Tooltip } from 'chart.js';
import { LinearScale } from 'chart.js'; import { LinearScale } from 'chart.js';
@ -36,6 +38,14 @@ export class PortfolioProportionChartComponent
public chart: Chart; public chart: Chart;
public isLoading = true; public isLoading = true;
private colorMap: {
[symbol: string]: string;
} = {
[UNKNOWN_KEY]: `rgba(${getTextColor()}, ${getCssVariable(
'--palette-foreground-divider-alpha'
)})`
};
public constructor() { public constructor() {
Chart.register(ArcElement, DoughnutController, LinearScale, Tooltip); Chart.register(ArcElement, DoughnutController, LinearScale, Tooltip);
} }
@ -48,37 +58,67 @@ export class PortfolioProportionChartComponent
} }
} }
public ngOnDestroy() {
this.chart?.destroy();
}
private initialize() { private initialize() {
this.isLoading = true; this.isLoading = true;
const chartData: { [symbol: string]: number } = {}; const chartData: {
[symbol: string]: { color?: string; value: number };
} = {};
Object.keys(this.positions).forEach((symbol) => { Object.keys(this.positions).forEach((symbol) => {
if (this.positions[symbol][this.key]) { if (this.positions[symbol][this.key]) {
if (chartData[this.positions[symbol][this.key]]) { if (chartData[this.positions[symbol][this.key]]) {
chartData[this.positions[symbol][this.key]] += this.positions[ chartData[this.positions[symbol][this.key]].value += this.positions[
symbol symbol
].value; ].value;
} else { } else {
chartData[this.positions[symbol][this.key]] = this.positions[ chartData[this.positions[symbol][this.key]] = {
symbol value: this.positions[symbol].value
].value; };
}
} else {
if (chartData[UNKNOWN_KEY]) {
chartData[UNKNOWN_KEY].value += this.positions[symbol].value;
} else {
chartData[UNKNOWN_KEY] = {
value: this.positions[symbol].value
};
} }
} }
}); });
const chartDataSorted = Object.entries(chartData) const chartDataSorted = Object.entries(chartData)
.sort((a, b) => { .sort((a, b) => {
return a[1] - b[1]; return a[1].value - b[1].value;
}) })
.reverse(); .reverse();
chartDataSorted.forEach(([symbol, item], index) => {
if (this.colorMap[symbol]) {
// Reuse color
item.color = this.colorMap[symbol];
} else {
const color = this.getColorPalette()[index];
// Store color for reuse
this.colorMap[symbol] = color;
item.color = color;
}
});
const data = { const data = {
datasets: [ datasets: [
{ {
backgroundColor: this.getColorPalette(), backgroundColor: chartDataSorted.map(([, item]) => {
return item.color;
}),
borderWidth: 0, borderWidth: 0,
data: chartDataSorted.map(([, value]) => { data: chartDataSorted.map(([, item]) => {
return value; return item.value;
}) })
} }
], ],
@ -100,21 +140,14 @@ export class PortfolioProportionChartComponent
tooltip: { tooltip: {
callbacks: { callbacks: {
label: (context) => { label: (context) => {
const label = data.labels[context.dataIndex]; const label =
context.label === UNKNOWN_KEY ? 'Other' : context.label;
if (this.isInPercent) { if (this.isInPercent) {
const value = const value = 100 * <number>context.raw;
100 *
data.datasets[context.datasetIndex].data[
context.dataIndex
];
return `${label} (${value.toFixed(2)}%)`; return `${label} (${value.toFixed(2)}%)`;
} else { } else {
const value = const value = <number>context.raw;
data.datasets[context.datasetIndex].data[
context.dataIndex
];
return `${label} (${value.toLocaleString(this.locale, { return `${label} (${value.toLocaleString(this.locale, {
maximumFractionDigits: 2, maximumFractionDigits: 2,
minimumFractionDigits: 2 minimumFractionDigits: 2
@ -153,8 +186,4 @@ export class PortfolioProportionChartComponent
'#cc5de8' // grape 5 '#cc5de8' // grape 5
]; ];
} }
public ngOnDestroy() {
this.chart?.destroy();
}
} }

View File

@ -23,6 +23,7 @@ export class PositionDetailDialog {
public benchmarkDataItems: LineChartItem[]; public benchmarkDataItems: LineChartItem[];
public currency: string; public currency: string;
public firstBuyDate: string; public firstBuyDate: string;
public grossPerformance: number;
public grossPerformancePercent: number; public grossPerformancePercent: number;
public historicalDataItems: LineChartItem[]; public historicalDataItems: LineChartItem[];
public investment: number; public investment: number;
@ -30,9 +31,10 @@ export class PositionDetailDialog {
public maxPrice: number; public maxPrice: number;
public minPrice: number; public minPrice: number;
public quantity: number; public quantity: number;
public transactionCount: number;
public constructor( public constructor(
private cd: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService, private dataService: DataService,
public dialogRef: MatDialogRef<PositionDetailDialog>, public dialogRef: MatDialogRef<PositionDetailDialog>,
@Inject(MAT_DIALOG_DATA) public data: PositionDetailDialogParams @Inject(MAT_DIALOG_DATA) public data: PositionDetailDialogParams
@ -44,18 +46,21 @@ export class PositionDetailDialog {
averagePrice, averagePrice,
currency, currency,
firstBuyDate, firstBuyDate,
grossPerformance,
grossPerformancePercent, grossPerformancePercent,
historicalData, historicalData,
investment, investment,
marketPrice, marketPrice,
maxPrice, maxPrice,
minPrice, minPrice,
quantity quantity,
transactionCount
}) => { }) => {
this.averagePrice = averagePrice; this.averagePrice = averagePrice;
this.benchmarkDataItems = []; this.benchmarkDataItems = [];
this.currency = currency; this.currency = currency;
this.firstBuyDate = firstBuyDate; this.firstBuyDate = firstBuyDate;
this.grossPerformance = quantity * grossPerformance;
this.grossPerformancePercent = grossPerformancePercent; this.grossPerformancePercent = grossPerformancePercent;
this.historicalDataItems = historicalData.map( this.historicalDataItems = historicalData.map(
(historicalDataItem) => { (historicalDataItem) => {
@ -75,6 +80,7 @@ export class PositionDetailDialog {
this.maxPrice = maxPrice; this.maxPrice = maxPrice;
this.minPrice = minPrice; this.minPrice = minPrice;
this.quantity = quantity; this.quantity = quantity;
this.transactionCount = transactionCount;
if (isToday(parseISO(this.firstBuyDate))) { if (isToday(parseISO(this.firstBuyDate))) {
// Add average price // Add average price
@ -121,7 +127,7 @@ export class PositionDetailDialog {
this.benchmarkDataItems[0].value = this.averagePrice; this.benchmarkDataItems[0].value = this.averagePrice;
} }
this.cd.markForCheck(); this.changeDetectorRef.markForCheck();
} }
); );
} }

View File

@ -21,19 +21,22 @@
<div class="row"> <div class="row">
<div class="col-6 mb-3"> <div class="col-6 mb-3">
<gf-value <gf-value
colorizeSign="true" label="Change"
isPercent="true"
label="Performance"
size="medium" size="medium"
[colorizeSign]="true"
[currency]="data.baseCurrency"
[locale]="data.locale" [locale]="data.locale"
[value]="grossPerformancePercent" [value]="grossPerformance"
></gf-value> ></gf-value>
</div> </div>
<div class="col-6 mb-3"> <div class="col-6 mb-3">
<gf-value <gf-value
label="First Buy Date" label="Performance"
size="medium" size="medium"
[value]="firstBuyDate" [colorizeSign]="true"
[isPercent]="true"
[locale]="data.locale"
[value]="grossPerformancePercent"
></gf-value> ></gf-value>
</div> </div>
<div class="col-6 mb-3"> <div class="col-6 mb-3">
@ -76,9 +79,9 @@
</div> </div>
<div class="col-6 mb-3"> <div class="col-6 mb-3">
<gf-value <gf-value
isCurrency="true"
label="Quantity" label="Quantity"
size="medium" size="medium"
[isCurrency]="true"
[value]="quantity" [value]="quantity"
></gf-value> ></gf-value>
</div> </div>
@ -91,6 +94,22 @@
[value]="investment" [value]="investment"
></gf-value> ></gf-value>
</div> </div>
<div class="col-6 mb-3">
<gf-value
label="First Buy Date"
size="medium"
[value]="firstBuyDate"
></gf-value>
</div>
<div class="col-6 mb-3">
<gf-value
label="Transactions"
size="medium"
[isCurrency]="true"
[locale]="data.locale"
[value]="transactionCount"
></gf-value>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -35,21 +35,23 @@
<div class="h6 m-0 text-truncate">{{ position?.name }}</div> <div class="h6 m-0 text-truncate">{{ position?.name }}</div>
<div class="d-flex"> <div class="d-flex">
<span>{{ position?.symbol | gfSymbol }}</span> <span>{{ position?.symbol | gfSymbol }}</span>
<span *ngIf="position?.exchange" class="ml-2 text-muted" <span
*ngIf="position?.exchange && position?.exchange !== unknownKey"
class="ml-2 text-muted"
>({{ position.exchange }})</span >({{ position.exchange }})</span
> >
</div> </div>
<div class="d-flex mt-1"> <div class="d-flex mt-1">
<gf-value <gf-value
class="mr-3" class="mr-3"
colorizeSign="true" [colorizeSign]="true"
[currency]="position?.currency" [currency]="position?.currency"
[locale]="locale" [locale]="locale"
[value]="position?.grossPerformance" [value]="position?.grossPerformance"
></gf-value> ></gf-value>
<gf-value <gf-value
colorizeSign="true" [colorizeSign]="true"
isPercent="true" [isPercent]="true"
[locale]="locale" [locale]="locale"
[value]="position?.grossPerformancePercent" [value]="position?.grossPerformancePercent"
></gf-value> ></gf-value>

View File

@ -7,8 +7,9 @@ import {
} from '@angular/core'; } 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 { PortfolioPosition } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position.interface'; import { UNKNOWN_KEY } from '@ghostfolio/common/config';
import { Subject, Subscription } from 'rxjs'; import { PortfolioPosition } from '@ghostfolio/common/interfaces';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators'; import { takeUntil } from 'rxjs/operators';
import { PositionDetailDialog } from './position-detail-dialog/position-detail-dialog.component'; import { PositionDetailDialog } from './position-detail-dialog/position-detail-dialog.component';
@ -27,7 +28,7 @@ export class PositionComponent implements OnDestroy, OnInit {
@Input() position: PortfolioPosition; @Input() position: PortfolioPosition;
@Input() range: string; @Input() range: string;
public routeQueryParams: Subscription; public unknownKey = UNKNOWN_KEY;
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
@ -36,7 +37,7 @@ export class PositionComponent implements OnDestroy, OnInit {
private route: ActivatedRoute, private route: ActivatedRoute,
private router: Router private router: Router
) { ) {
this.routeQueryParams = route.queryParams route.queryParams
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe((params) => { .subscribe((params) => {
if ( if (

View File

@ -1,35 +1,34 @@
<!--<mat-form-field appearance="outline" class="w-100">
<input #input autocomplete="off" matInput (keyup)="applyFilter($event)" />
<ion-icon class="mr-1" matPrefix name="search-outline"></ion-icon>
</mat-form-field>-->
<table <table
class="w-100" class="gf-table w-100"
matSort matSort
matSortActive="shareCurrent" matSortActive="allocationCurrent"
matSortDirection="desc" matSortDirection="desc"
mat-table mat-table
[dataSource]="dataSource" [dataSource]="dataSource"
> >
<ng-container matColumnDef="symbol"> <ng-container matColumnDef="symbol">
<th mat-header-cell *matHeaderCellDef mat-sort-header i18n>Symbol</th> <th *matHeaderCellDef class="px-1" i18n mat-header-cell mat-sort-header>
<td mat-cell *matCellDef="let element">{{ element.symbol | gfSymbol }}</td> Symbol
</th>
<td *matCellDef="let element" class="px-1" mat-cell>
{{ element.symbol | gfSymbol }}
</td>
</ng-container> </ng-container>
<ng-container matColumnDef="performance"> <ng-container matColumnDef="performance">
<th <th
*matHeaderCellDef *matHeaderCellDef
class="d-none d-lg-table-cell text-right" class="d-none d-lg-table-cell px-1 text-right"
i18n i18n
mat-header-cell mat-header-cell
> >
Performance Performance
</th> </th>
<td class="d-none d-lg-table-cell" mat-cell *matCellDef="let element"> <td class="d-none d-lg-table-cell px-1" mat-cell *matCellDef="let element">
<div class="d-flex justify-content-end"> <div class="d-flex justify-content-end">
<gf-value <gf-value
colorizeSign="true" [colorizeSign]="true"
isPercent="true" [isPercent]="true"
[locale]="locale" [locale]="locale"
[value]="isLoading ? undefined : element.grossPerformancePercent" [value]="isLoading ? undefined : element.grossPerformancePercent"
></gf-value> ></gf-value>
@ -37,43 +36,43 @@
</td> </td>
</ng-container> </ng-container>
<ng-container matColumnDef="shareInvestment"> <ng-container matColumnDef="allocationInvestment">
<th <th
*matHeaderCellDef *matHeaderCellDef
class="justify-content-end" class="justify-content-end px-1"
i18n i18n
mat-header-cell mat-header-cell
mat-sort-header mat-sort-header
> >
Initial Share Initial Allocation
</th> </th>
<td mat-cell *matCellDef="let element"> <td mat-cell *matCellDef="let element">
<div class="d-flex justify-content-end"> <div class="d-flex justify-content-end px-1">
<gf-value <gf-value
isPercent="true" [isPercent]="true"
[locale]="locale" [locale]="locale"
[value]="isLoading ? undefined : element.shareInvestment" [value]="isLoading ? undefined : element.allocationInvestment"
></gf-value> ></gf-value>
</div> </div>
</td> </td>
</ng-container> </ng-container>
<ng-container matColumnDef="shareCurrent"> <ng-container matColumnDef="allocationCurrent">
<th <th
*matHeaderCellDef *matHeaderCellDef
class="justify-content-end" class="justify-content-end px-1"
i18n i18n
mat-header-cell mat-header-cell
mat-sort-header mat-sort-header
> >
Current Share Current Allocation
</th> </th>
<td mat-cell *matCellDef="let element"> <td *matCellDef="let element" class="px-1" mat-cell>
<div class="d-flex justify-content-end"> <div class="d-flex justify-content-end">
<gf-value <gf-value
isPercent="true" [isPercent]="true"
[locale]="locale" [locale]="locale"
[value]="isLoading ? undefined : element.shareCurrent" [value]="isLoading ? undefined : element.allocationCurrent"
></gf-value> ></gf-value>
</div> </div>
</td> </td>

View File

@ -1,3 +1,5 @@
@import '~apps/client/src/styles/ghostfolio-style';
:host { :host {
display: block; display: block;
@ -8,10 +10,6 @@
} }
.mat-table { .mat-table {
td {
border: 0;
}
th { th {
::ng-deep { ::ng-deep {
.mat-sort-header-container { .mat-sort-header-container {
@ -22,13 +20,6 @@
.mat-row { .mat-row {
cursor: pointer; cursor: pointer;
&:nth-child(even) {
background-color: rgba(
var(--dark-primary-text),
var(--palette-background-hover-alpha)
);
}
} }
} }
} }
@ -37,15 +28,4 @@
.mat-form-field { .mat-form-field {
color: rgba(var(--light-primary-text)); color: rgba(var(--light-primary-text));
} }
.mat-table {
.mat-row {
&:nth-child(even) {
background-color: rgba(
var(--light-primary-text),
var(--palette-background-hover-alpha)
);
}
}
}
} }

View File

@ -13,7 +13,7 @@ import { MatPaginator } from '@angular/material/paginator';
import { MatSort } from '@angular/material/sort'; import { MatSort } from '@angular/material/sort';
import { MatTableDataSource } from '@angular/material/table'; import { MatTableDataSource } from '@angular/material/table';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { PortfolioPosition } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position.interface'; import { PortfolioPosition } from '@ghostfolio/common/interfaces';
import { Order as OrderModel } from '@prisma/client'; import { Order as OrderModel } from '@prisma/client';
import { Subject, Subscription } from 'rxjs'; import { Subject, Subscription } from 'rxjs';
import { takeUntil } from 'rxjs/operators'; import { takeUntil } from 'rxjs/operators';
@ -73,8 +73,8 @@ export class PositionsTableComponent implements OnChanges, OnInit {
this.displayedColumns = [ this.displayedColumns = [
'symbol', 'symbol',
'performance', 'performance',
'shareInvestment', 'allocationInvestment',
'shareCurrent' 'allocationCurrent'
]; ];
this.isLoading = true; this.isLoading = true;

View File

@ -5,8 +5,8 @@ import {
OnChanges, OnChanges,
OnInit OnInit
} from '@angular/core'; } from '@angular/core';
import { PortfolioPosition } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position.interface';
import { MarketState } from '@ghostfolio/api/services/interfaces/interfaces'; import { MarketState } from '@ghostfolio/api/services/interfaces/interfaces';
import { PortfolioPosition } from '@ghostfolio/common/interfaces/portfolio-position.interface';
@Component({ @Component({
selector: 'gf-positions', selector: 'gf-positions',

View File

@ -1,18 +1,18 @@
<div class="py-3"> <div class="py-3">
<div class="flex-nowrap no-gutters row"> <div class="align-items-center flex-nowrap no-gutters row">
<div *ngIf="isLoading"> <div *ngIf="isLoading">
<ngx-skeleton-loader <ngx-skeleton-loader
animation="pulse" animation="pulse"
class="mr-3" class="mr-2"
[theme]="{ [theme]="{
height: '3rem', height: '2rem',
width: '3rem' width: '2rem'
}" }"
></ngx-skeleton-loader> ></ngx-skeleton-loader>
</div> </div>
<div <div
*ngIf="!isLoading" *ngIf="!isLoading"
class="align-items-center d-flex icon-container mr-3 px-3" class="align-items-center d-flex icon-container mr-2 px-2"
[ngClass]="{ okay: rule?.value === true, warn: rule?.value === false }" [ngClass]="{ okay: rule?.value === true, warn: rule?.value === false }"
> >
<ion-icon <ion-icon

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